mirror of
https://github.com/jamjan05/AI-Vault-for-Obsidian.git
synced 2026-07-22 06:56:43 +00:00
first commit
This commit is contained in:
commit
49841447c9
38 changed files with 7383 additions and 0 deletions
33
.github/workflows/release.yml
vendored
Normal file
33
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*" # odpala się gdy zrobisz: git tag 1.0.1 && git push --tags
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
main.js
|
||||
styles.css
|
||||
manifest.json
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
main.js
|
||||
*.js.map
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
data.json
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 JamJan05
|
||||
|
||||
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.
|
||||
191
README.md
Normal file
191
README.md
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
# AI Vault for Obsidian
|
||||
|
||||
Chat with **GPT** and **Claude** directly inside Obsidian — with RAG over your vault, conversation history, projects, and smart modes.
|
||||
|
||||
AI Vault turns Obsidian into a full AI workspace. Talk to multiple frontier models, give them access to your notes and canvases, organize chats into projects, and keep everything synced with your vault — without ever leaving the app.
|
||||
|
||||
-----
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### 🤖 Multi-provider AI
|
||||
|
||||
Switch between **OpenAI** and **Anthropic** models with a single click in the chat header — no need to dive into settings.
|
||||
|
||||
**OpenAI models:**
|
||||
|
||||
- GPT-5
|
||||
- GPT-5 nano
|
||||
- GPT-5 mini
|
||||
- GPT-5 Search
|
||||
- GPT-4o
|
||||
- GPT-4o mini
|
||||
- GPT-4o turbo
|
||||
|
||||
**Anthropic models:**
|
||||
|
||||
- Claude Sonnet 4.5
|
||||
- Claude Opus 4.5
|
||||
- Claude Haiku 4.5
|
||||
|
||||
### 📚 RAG over your vault
|
||||
|
||||
The plugin indexes your notes locally and feeds the most relevant context to the AI on every message.
|
||||
|
||||
- Full support for **Markdown** (`.md`) files
|
||||
- Full support for **Canvas** (`.canvas`) files — the parser understands canvas topology through edges and reconstructs the logical flow of nodes
|
||||
- Local index stored outside the vault (no sync bloat)
|
||||
|
||||
### 🗂️ Projects
|
||||
|
||||
Group related conversations into **projects** — each with its own system prompt, model, and context scope. Perfect for separating work, study, and personal threads.
|
||||
|
||||
### 💬 Conversation history
|
||||
|
||||
Every chat is saved automatically and stored **outside the vault**, so it never inflates your Obsidian Sync or clutters your file tree.
|
||||
|
||||
### 🎯 Smart modes
|
||||
|
||||
Switch the AI’s behavior depending on what you need:
|
||||
|
||||
- **Code mode** — focused on programming, with code-aware formatting
|
||||
- **Learn mode** — explains concepts step by step, asks clarifying questions
|
||||
- **Thinking mode** — deeper reasoning before answering
|
||||
|
||||
### 🌐 Web search
|
||||
|
||||
Use GPT-5 Search to fetch live information from the web directly in your chat.
|
||||
|
||||
### 🌍 Bilingual UI
|
||||
|
||||
Full interface in **English** and **Polish**.
|
||||
|
||||
### ⚡ Streaming & cost tracking
|
||||
|
||||
- Live token streaming — see the response as it’s generated
|
||||
- Built-in **token counter** and **cost tracker** for every conversation
|
||||
|
||||
-----
|
||||
|
||||
## 📱 Platform support
|
||||
|
||||
|Platform|Status |
|
||||
|--------|--------------------------------------|
|
||||
|Windows |✅ Supported |
|
||||
|macOS |✅ Supported |
|
||||
|Linux |✅ Supported |
|
||||
|iOS |✅ Supported |
|
||||
|Android |⚠️ Should work (not extensively tested)|
|
||||
|
||||
The plugin is **not desktop-only** — it runs on mobile devices as well.
|
||||
|
||||
-----
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
### From Obsidian Community Plugins (recommended once approved)
|
||||
|
||||
1. Open **Settings → Community plugins**
|
||||
1. Make sure **Restricted mode** is **off**
|
||||
1. Click **Browse** and search for **AI Vault**
|
||||
1. Click **Install**, then **Enable**
|
||||
|
||||
### Manual installation
|
||||
|
||||
1. Download the latest release from the [Releases page](https://github.com/JamJan05/AI-Vault-for-Obsidian/releases)
|
||||
1. Extract `main.js`, `manifest.json`, and `styles.css` into:
|
||||
|
||||
```
|
||||
<your-vault>/.obsidian/plugins/ai-vault/
|
||||
```
|
||||
1. Reload Obsidian
|
||||
1. Enable **AI Vault** in **Settings → Community plugins**
|
||||
|
||||
-----
|
||||
|
||||
## 🔑 Setup
|
||||
|
||||
After enabling the plugin:
|
||||
|
||||
1. Open **Settings → AI Vault**
|
||||
1. Paste your **OpenAI API key** and/or **Anthropic API key**
|
||||
1. Choose your default model
|
||||
1. Open the chat from the ribbon icon on the left sidebar
|
||||
|
||||
> Your API keys are stored locally on your device. They are never sent anywhere except directly to the official OpenAI and Anthropic endpoints.
|
||||
|
||||
-----
|
||||
|
||||
## 📖 Usage
|
||||
|
||||
### Starting a chat
|
||||
|
||||
Click the **AI Vault** icon in the left ribbon — the chat panel opens on the right.
|
||||
|
||||
### Switching models
|
||||
|
||||
Click the model name in the chat header to switch between GPT and Claude variants without leaving the conversation.
|
||||
|
||||
### Using vault context (RAG)
|
||||
|
||||
Toggle the **vault context** button to let the AI search your notes and canvases for relevant information before answering.
|
||||
|
||||
### Creating a project
|
||||
|
||||
Use the **Projects** menu in the chat header to create a new project, set its system prompt, and assign conversations to it.
|
||||
|
||||
### Changing mode
|
||||
|
||||
Pick **Code**, **Learn**, or **Thinking** mode from the mode selector — the AI will adapt its style accordingly.
|
||||
|
||||
-----
|
||||
|
||||
## 🔒 Privacy
|
||||
|
||||
- **API keys** are stored locally on your device.
|
||||
- **Conversation history** is stored locally, outside your vault.
|
||||
- **RAG index** is stored locally, outside your vault.
|
||||
- Vault content is sent to the model **only** when you enable vault context, and only the relevant chunks are transmitted.
|
||||
- No data is sent to any third party other than the official OpenAI / Anthropic APIs.
|
||||
|
||||
-----
|
||||
|
||||
## 💰 Cost
|
||||
|
||||
The plugin itself is **free and open source**.
|
||||
|
||||
You pay only for what you use via your own OpenAI / Anthropic API keys, billed directly by the providers. The built-in cost tracker shows estimated spend per conversation.
|
||||
|
||||
-----
|
||||
|
||||
## 🛠️ Tech
|
||||
|
||||
- Written in **TypeScript**
|
||||
- Built with **esbuild**
|
||||
- Uses official **OpenAI** and **Anthropic** REST APIs with streaming (SSE)
|
||||
- Local-first architecture — no external servers
|
||||
|
||||
-----
|
||||
|
||||
## 🐛 Reporting issues
|
||||
|
||||
Found a bug or have a feature request? Open an issue on [GitHub Issues](https://github.com/JamJan05/AI-Vault-for-Obsidian/issues).
|
||||
|
||||
When reporting bugs, please include:
|
||||
|
||||
- Obsidian version
|
||||
- Operating system
|
||||
- Plugin version
|
||||
- Steps to reproduce
|
||||
|
||||
-----
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Pull requests are welcome. For larger changes, please open an issue first to discuss what you’d like to change.
|
||||
|
||||
-----
|
||||
|
||||
## 📜 License
|
||||
|
||||
[MIT](LICENSE) © 2026 JamJan05
|
||||
40
esbuild.config.mjs
Normal file
40
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
|
||||
const context = await esbuild.context({
|
||||
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",
|
||||
...builtins,
|
||||
],
|
||||
format: "cjs",
|
||||
target: "es2022",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "ai-vault",
|
||||
"name": "AI-Vault",
|
||||
"version": "1.0.1",
|
||||
"minAppVersion": "1.4.0",
|
||||
"description": "Chat with GPT and Claude directly inside your notes — RAG from your vault, history, projects and smart modes.",
|
||||
"author": "JamJan05",
|
||||
"authorUrl": "https://github.com/JamJan05",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
611
package-lock.json
generated
Normal file
611
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,611 @@
|
|||
{
|
||||
"name": "obsidian-gpt",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-gpt",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.18",
|
||||
"builtin-modules": "^3.3.0",
|
||||
"esbuild": "^0.21.5",
|
||||
"obsidian": "^1.12.3",
|
||||
"tslib": "^2.6.3",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz",
|
||||
"integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.38.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
|
||||
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
||||
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
||||
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
||||
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
||||
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/codemirror": {
|
||||
"version": "5.60.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz",
|
||||
"integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/tern": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz",
|
||||
"integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tern": {
|
||||
"version": "0.23.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
|
||||
"integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/builtin-modules": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
|
||||
"integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.21.5",
|
||||
"@esbuild/android-arm": "0.21.5",
|
||||
"@esbuild/android-arm64": "0.21.5",
|
||||
"@esbuild/android-x64": "0.21.5",
|
||||
"@esbuild/darwin-arm64": "0.21.5",
|
||||
"@esbuild/darwin-x64": "0.21.5",
|
||||
"@esbuild/freebsd-arm64": "0.21.5",
|
||||
"@esbuild/freebsd-x64": "0.21.5",
|
||||
"@esbuild/linux-arm": "0.21.5",
|
||||
"@esbuild/linux-arm64": "0.21.5",
|
||||
"@esbuild/linux-ia32": "0.21.5",
|
||||
"@esbuild/linux-loong64": "0.21.5",
|
||||
"@esbuild/linux-mips64el": "0.21.5",
|
||||
"@esbuild/linux-ppc64": "0.21.5",
|
||||
"@esbuild/linux-riscv64": "0.21.5",
|
||||
"@esbuild/linux-s390x": "0.21.5",
|
||||
"@esbuild/linux-x64": "0.21.5",
|
||||
"@esbuild/netbsd-x64": "0.21.5",
|
||||
"@esbuild/openbsd-x64": "0.21.5",
|
||||
"@esbuild/sunos-x64": "0.21.5",
|
||||
"@esbuild/win32-arm64": "0.21.5",
|
||||
"@esbuild/win32-ia32": "0.21.5",
|
||||
"@esbuild/win32-x64": "0.21.5"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.29.4",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
|
||||
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/obsidian": {
|
||||
"version": "1.12.3",
|
||||
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz",
|
||||
"integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/codemirror": "5.60.8",
|
||||
"moment": "2.29.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@codemirror/state": "6.5.0",
|
||||
"@codemirror/view": "6.38.6"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.6.3",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
|
||||
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
}
|
||||
}
|
||||
}
|
||||
22
package.json
Normal file
22
package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "ai-vault",
|
||||
"version": "1.0.0",
|
||||
"description": "AI-Vault — chat with GPT & Claude inside Obsidian, powered by your vault",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc --noEmit --skipLibCheck && node esbuild.config.mjs production",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.18",
|
||||
"builtin-modules": "^3.3.0",
|
||||
"esbuild": "^0.21.5",
|
||||
"obsidian": "^1.12.3",
|
||||
"tslib": "^2.6.3",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
459
src/SettingsTab.ts
Normal file
459
src/SettingsTab.ts
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
import { App, Notice, PluginSettingTab, Setting } from "obsidian";
|
||||
import { t, setLanguage } from "./i18n";
|
||||
import { DEFAULT_SYSTEM_PROMPTS } from "./settings";
|
||||
import { FILE_API_KEYS } from "./constants";
|
||||
import type { ExternalStorage } from "./storage/ExternalStorage";
|
||||
import type { HistoryManager } from "./history/HistoryManager";
|
||||
import type { ProjectManager } from "./history/ProjectManager";
|
||||
import type { RAGEngine } from "./rag/RAGEngine";
|
||||
import type { GPTHistoryView } from "./views/HistoryView";
|
||||
import type { GPTProjectsView } from "./views/ProjectsView";
|
||||
import type { PluginSettings } from "./settings";
|
||||
|
||||
// ─── Plugin interface ──────────────────────────────────────────────────────────
|
||||
|
||||
interface PluginWithDeps {
|
||||
app: App;
|
||||
settings: PluginSettings;
|
||||
externalStorage: ExternalStorage;
|
||||
history: HistoryManager;
|
||||
projects: ProjectManager;
|
||||
rag: RAGEngine;
|
||||
saveSettings(): Promise<void>;
|
||||
loadData(): Promise<Record<string, unknown>>;
|
||||
saveData(data: Record<string, unknown>): Promise<void>;
|
||||
getHistoryView(): GPTHistoryView | null;
|
||||
getProjectsView(): GPTProjectsView | null;
|
||||
}
|
||||
|
||||
// ─── SettingsTab ───────────────────────────────────────────────────────────────
|
||||
|
||||
export class GPTSettingsTab extends PluginSettingTab {
|
||||
constructor(app: App, private readonly plugin: PluginWithDeps) {
|
||||
super(app, plugin as never);
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
containerEl.createEl("h2", { text: t("settings_title") });
|
||||
|
||||
this.renderLanguage(containerEl);
|
||||
this.renderKeyWarning(containerEl);
|
||||
this.renderApiKeySync(containerEl);
|
||||
this.renderOpenAIKeys(containerEl);
|
||||
this.renderClaude(containerEl);
|
||||
this.renderOpenAISettings(containerEl);
|
||||
this.renderContext(containerEl);
|
||||
this.renderRAG(containerEl);
|
||||
this.renderStorage(containerEl);
|
||||
}
|
||||
|
||||
// ── Sections ───────────────────────────────────────────────────────────────
|
||||
|
||||
private renderLanguage(el: HTMLElement): void {
|
||||
// Always in English — understandable regardless of the current language
|
||||
new Setting(el)
|
||||
.setName("Language / Język")
|
||||
.setDesc("Plugin interface language / Język interfejsu wtyczki")
|
||||
.addDropdown(d => d
|
||||
.addOption("en", "🇬🇧 English")
|
||||
.addOption("pl", "🇵🇱 Polski")
|
||||
.setValue(this.plugin.settings.language ?? "en")
|
||||
.onChange(async (v: string) => {
|
||||
this.plugin.settings.language = v as "en" | "pl";
|
||||
await this.plugin.saveSettings();
|
||||
setLanguage(v, this.plugin);
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private renderKeyWarning(el: HTMLElement): void {
|
||||
const warning = el.createEl("div", { cls: "gpt-settings-warning" });
|
||||
warning.innerHTML = t("settings_keys_local_warning_html");
|
||||
}
|
||||
|
||||
private renderApiKeySync(el: HTMLElement): void {
|
||||
const keysInSync = this.plugin.settings.apiKeysInSync;
|
||||
const extEnabled = this.plugin.externalStorage.isEnabled;
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_keys_sync_name"))
|
||||
.setDesc(keysInSync ? t("settings_keys_sync_desc_on") : t("settings_keys_sync_desc_off"))
|
||||
.addToggle(tog => tog
|
||||
.setValue(keysInSync)
|
||||
.setDisabled(!extEnabled)
|
||||
.onChange(async (v: boolean) => {
|
||||
const oldApiKey = this.plugin.settings.apiKey;
|
||||
const oldClaudeApiKey = this.plugin.settings.claudeApiKey;
|
||||
this.plugin.settings.apiKeysInSync = v;
|
||||
this.plugin.settings.apiKey = oldApiKey;
|
||||
this.plugin.settings.claudeApiKey = oldClaudeApiKey;
|
||||
await this.plugin.saveSettings();
|
||||
|
||||
if (v) {
|
||||
// Switched to Sync → remove keys.json
|
||||
const keysPath = this.plugin.externalStorage.resolve(FILE_API_KEYS);
|
||||
await this.plugin.externalStorage.remove(keysPath);
|
||||
new Notice(t("notice_keys_moved_sync"), 5000);
|
||||
} else {
|
||||
// Switched to local → keys saved via saveSettings, remove from data.json
|
||||
const d = await this.plugin.loadData();
|
||||
if (d) {
|
||||
delete d.apiKey;
|
||||
delete d.claudeApiKey;
|
||||
await this.plugin.saveData(d);
|
||||
}
|
||||
new Notice(t("notice_keys_moved_local"), 5000);
|
||||
}
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
||||
if (!extEnabled) {
|
||||
el.createEl("div", {
|
||||
cls: "gpt-settings-note",
|
||||
text: t("settings_keys_mobile_note"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderOpenAIKeys(el: HTMLElement): void {
|
||||
const keysInSync = this.plugin.settings.apiKeysInSync;
|
||||
const extEnabled = this.plugin.externalStorage.isEnabled;
|
||||
const keysStoredLocal = !keysInSync && extEnabled;
|
||||
const keysLocation = keysStoredLocal ? t("settings_key_local") : t("settings_key_sync");
|
||||
|
||||
el.createEl("h3", { text: t("settings_openai_title") });
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_openai_key_name"))
|
||||
.setDesc(keysLocation)
|
||||
.addText(txt => {
|
||||
txt.inputEl.type = "password";
|
||||
txt.setPlaceholder("sk-…")
|
||||
.setValue(this.plugin.settings.apiKey)
|
||||
.onChange(async (v: string) => {
|
||||
this.plugin.settings.apiKey = v.trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_openai_model_name"))
|
||||
.addDropdown(d => d
|
||||
.addOption("gpt-5", "GPT-5 (reasoning, best)")
|
||||
.addOption("gpt-5-mini", "GPT-5 Mini (reasoning, faster)")
|
||||
.addOption("gpt-5-nano", t("model_gpt5nano_label"))
|
||||
.addOption("gpt-5-search-api", "GPT-5 Search (web search 🌐)")
|
||||
.addOption("gpt-4o", "GPT-4o (web search ✓)")
|
||||
.addOption("gpt-4o-mini", "GPT-4o Mini (web search ✓)")
|
||||
.addOption("gpt-4-turbo", "GPT-4 Turbo")
|
||||
.setValue(this.plugin.settings.model)
|
||||
.onChange(async (v: string) => {
|
||||
this.plugin.settings.model = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private renderOpenAISettings(el: HTMLElement): void {
|
||||
el.createEl("h3", { text: "⚙️ " + t("settings_openai_title") + " — " + t("settings_thinking_name") });
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_thinking_name"))
|
||||
.addDropdown(d => d
|
||||
.addOption("fast", t("chat_mode_fast"))
|
||||
.addOption("normal", t("chat_mode_normal"))
|
||||
.addOption("think", t("chat_mode_think"))
|
||||
.setValue(this.plugin.settings.thinkingMode)
|
||||
.onChange(async (v: string) => {
|
||||
this.plugin.settings.thinkingMode = v as "fast" | "normal" | "think";
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
el.createEl("h4", { text: t("settings_max_tokens_title") });
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_max_tokens_fast_name"))
|
||||
.setDesc(t("settings_max_tokens_fast_desc"))
|
||||
.addText(txt => {
|
||||
txt.inputEl.type = "number";
|
||||
txt.inputEl.min = "256";
|
||||
txt.inputEl.style.width = "90px";
|
||||
txt.setValue(String(this.plugin.settings.maxTokensFast ?? 4096))
|
||||
.onChange(async (v: string) => {
|
||||
const n = parseInt(v, 10);
|
||||
if (!isNaN(n) && n >= 256) {
|
||||
this.plugin.settings.maxTokensFast = n;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_max_tokens_normal_name"))
|
||||
.setDesc(t("settings_max_tokens_normal_desc"))
|
||||
.addText(txt => {
|
||||
txt.inputEl.type = "number";
|
||||
txt.inputEl.min = "256";
|
||||
txt.inputEl.style.width = "90px";
|
||||
txt.setValue(String(this.plugin.settings.maxTokensNormal ?? 8192))
|
||||
.onChange(async (v: string) => {
|
||||
const n = parseInt(v, 10);
|
||||
if (!isNaN(n) && n >= 256) {
|
||||
this.plugin.settings.maxTokensNormal = n;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_max_tokens_think_name"))
|
||||
.setDesc(t("settings_max_tokens_think_desc"))
|
||||
.addText(txt => {
|
||||
txt.inputEl.type = "number";
|
||||
txt.inputEl.min = "256";
|
||||
txt.inputEl.style.width = "90px";
|
||||
txt.setValue(String(this.plugin.settings.maxTokensThink ?? 16000))
|
||||
.onChange(async (v: string) => {
|
||||
const n = parseInt(v, 10);
|
||||
if (!isNaN(n) && n >= 256) {
|
||||
this.plugin.settings.maxTokensThink = n;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_system_prompt_name"))
|
||||
.setDesc(t("settings_system_prompt_desc"))
|
||||
.addTextArea(ta => {
|
||||
ta.inputEl.rows = 4;
|
||||
ta.setValue(this.plugin.settings.systemPrompt)
|
||||
.onChange(async (v: string) => {
|
||||
this.plugin.settings.systemPrompt = v;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
})
|
||||
.addButton(b => b
|
||||
.setButtonText(t("settings_system_prompt_reset"))
|
||||
.setTooltip(t("settings_system_prompt_reset_tip"))
|
||||
.onClick(async () => {
|
||||
const lang = this.plugin.settings.language ?? "en";
|
||||
this.plugin.settings.systemPrompt = DEFAULT_SYSTEM_PROMPTS[lang] ?? DEFAULT_SYSTEM_PROMPTS.en;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private renderContext(el: HTMLElement): void {
|
||||
el.createEl("h3", { text: "💬 " + t("settings_context_title") });
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_context_name"))
|
||||
.setDesc(t("settings_context_desc"))
|
||||
.addText(txt => {
|
||||
txt.inputEl.type = "number";
|
||||
txt.inputEl.min = "0";
|
||||
txt.inputEl.style.width = "90px";
|
||||
txt.setValue(String(this.plugin.settings.maxContextMessages ?? 0))
|
||||
.onChange(async (v: string) => {
|
||||
const n = parseInt(v, 10);
|
||||
if (!isNaN(n) && n >= 0) {
|
||||
this.plugin.settings.maxContextMessages = n;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private renderClaude(el: HTMLElement): void {
|
||||
const keysInSync = this.plugin.settings.apiKeysInSync;
|
||||
const extEnabled = this.plugin.externalStorage.isEnabled;
|
||||
const keysStoredLocal = !keysInSync && extEnabled;
|
||||
const keysLocation = keysStoredLocal ? t("settings_key_local") : t("settings_key_sync");
|
||||
|
||||
el.createEl("h3", { text: t("settings_claude_title") });
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_claude_key_name"))
|
||||
.setDesc(keysLocation)
|
||||
.addText(txt => {
|
||||
txt.inputEl.type = "password";
|
||||
txt.setPlaceholder("sk-ant-…")
|
||||
.setValue(this.plugin.settings.claudeApiKey ?? "")
|
||||
.onChange(async (v: string) => {
|
||||
this.plugin.settings.claudeApiKey = v.trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_claude_model_name"))
|
||||
.addDropdown(d => d
|
||||
.addOption("claude-opus-4-5", "Claude Opus 4.5 (best)")
|
||||
.addOption("claude-sonnet-4-5", "Claude Sonnet 4.5 (recommended)")
|
||||
.addOption("claude-haiku-4-5", "Claude Haiku 4.5 (fast / affordable)")
|
||||
.setValue(this.plugin.settings.claudeModel ?? "claude-sonnet-4-5")
|
||||
.onChange(async (v: string) => {
|
||||
this.plugin.settings.claudeModel = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private renderRAG(el: HTMLElement): void {
|
||||
el.createEl("h3", { text: t("settings_rag_title") });
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_rag_enable_name"))
|
||||
.setDesc(t("settings_rag_enable_desc"))
|
||||
.addToggle(tog => tog
|
||||
.setValue(this.plugin.settings.ragEnabled)
|
||||
.onChange(async (v: boolean) => {
|
||||
this.plugin.settings.ragEnabled = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_rag_auto_name"))
|
||||
.setDesc(t("settings_rag_auto_desc"))
|
||||
.addToggle(tog => tog
|
||||
.setValue(this.plugin.settings.ragAutoIndex)
|
||||
.onChange(async (v: boolean) => {
|
||||
this.plugin.settings.ragAutoIndex = v;
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
|
||||
// Status RAG
|
||||
const s = this.plugin.rag.stats;
|
||||
const info = el.createEl("div", { cls: "gpt-settings-rag-status" });
|
||||
info.innerHTML = t("rag_status",
|
||||
this.plugin.rag.indexed ? t("rag_indexed") : t("rag_not_indexed"),
|
||||
s.files, s.chunks, s.embeddings,
|
||||
);
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_rag_reindex_name"))
|
||||
.addButton(b => b
|
||||
.setButtonText(t("settings_rag_reindex_btn"))
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
b.setButtonText(t("settings_rag_indexing")).setDisabled(true);
|
||||
await this.plugin.rag.buildIndex();
|
||||
const st = this.plugin.rag.stats;
|
||||
new Notice(t("rag_done", st.files));
|
||||
b.setButtonText(t("settings_rag_reindex_btn")).setDisabled(false);
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private renderStorage(el: HTMLElement): void {
|
||||
el.createEl("h3", { text: t("settings_storage_title") });
|
||||
|
||||
const isDesktop = this.plugin.externalStorage.isDesktop;
|
||||
const isActive = this.plugin.externalStorage.isEnabled;
|
||||
const currentPath = this.plugin.externalStorage.baseDir
|
||||
?? this.plugin.externalStorage.getDefaultPath();
|
||||
|
||||
// Status bar
|
||||
const info = el.createEl("div", { cls: "gpt-settings-storage-info" });
|
||||
if (!isDesktop) {
|
||||
info.innerHTML = t("settings_storage_mobile_full");
|
||||
} else if (isActive) {
|
||||
info.innerHTML =
|
||||
`✅ <strong>${t("settings_storage_active")}</strong><br>` +
|
||||
`Obsidian Sync does not sync this data.<br><br>` +
|
||||
`<strong>Location:</strong><br>` +
|
||||
`<code style="font-size:11px;word-break:break-all">${currentPath}</code>`;
|
||||
} else {
|
||||
info.innerHTML = t("settings_storage_inactive_html");
|
||||
}
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_storage_name"))
|
||||
.setDesc(t("settings_storage_desc"))
|
||||
.addToggle(tog => tog
|
||||
.setValue(this.plugin.settings.externalStorageEnabled)
|
||||
.setDisabled(!isDesktop)
|
||||
.onChange(async (v: boolean) => {
|
||||
this.plugin.settings.externalStorageEnabled = v;
|
||||
await this.plugin.saveSettings();
|
||||
new Notice(t("notice_restart_required"), 6000);
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
||||
const defaultPath = this.plugin.externalStorage.getDefaultPath()
|
||||
|| t("settings_storage_mobile_na");
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_storage_path_name"))
|
||||
.setDesc(t("settings_storage_path_desc", defaultPath))
|
||||
.addText(txt => {
|
||||
txt.setPlaceholder("/path/to/folder (empty = auto)")
|
||||
.setValue(this.plugin.settings.externalStoragePath ?? "")
|
||||
.setDisabled(!isDesktop);
|
||||
txt.inputEl.style.width = "100%";
|
||||
txt.onChange(async (v: string) => {
|
||||
this.plugin.settings.externalStoragePath = v.trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_storage_migrate_name"))
|
||||
.setDesc(t("settings_storage_migrate_desc"))
|
||||
.addButton(b => b
|
||||
.setButtonText(t("settings_storage_migrate_btn"))
|
||||
.setDisabled(!isActive)
|
||||
.onClick(async () => {
|
||||
b.setButtonText(t("settings_storage_migrating")).setDisabled(true);
|
||||
try {
|
||||
const r = await this.plugin.externalStorage.migrateFromVault();
|
||||
await this.plugin.history.load();
|
||||
await this.plugin.projects.load();
|
||||
this.plugin.getHistoryView()?.render();
|
||||
this.plugin.getProjectsView()?.render();
|
||||
|
||||
if (r.errors.length === 0) {
|
||||
new Notice(t("notice_migrated_manual", r.moved, r.skipped), 5000);
|
||||
} else {
|
||||
new Notice(t("notice_migration_partial_short", r.moved, r.errors.length), 6000);
|
||||
console.error("[AI-Vault] Migration errors:", r.errors);
|
||||
}
|
||||
} catch (e) {
|
||||
new Notice(t("notice_migration_failed", (e as Error)?.message));
|
||||
console.error("[AI-Vault] Migration crashed:", e);
|
||||
} finally {
|
||||
b.setButtonText(t("settings_storage_migrate_btn")).setDisabled(!isActive);
|
||||
this.display();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_storage_open_name"))
|
||||
.setDesc(t("settings_storage_open_desc"))
|
||||
.addButton(b => b
|
||||
.setButtonText(t("settings_storage_open_btn"))
|
||||
.setDisabled(!isActive)
|
||||
.onClick(() => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { shell } = require("electron") as { shell: { openPath: (p: string) => void } };
|
||||
shell.openPath(this.plugin.externalStorage.baseDir ?? "");
|
||||
} catch (e) {
|
||||
new Notice(t("notice_storage_open_fail", (e as Error)?.message));
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
77
src/api/anthropic.ts
Normal file
77
src/api/anthropic.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { THINKING_MODES } from "../models";
|
||||
import { withRetry } from "../utils";
|
||||
import { streamSSE } from "./streaming";
|
||||
import type { ChatMessage } from "../types";
|
||||
import type { StreamResult } from "./streaming";
|
||||
|
||||
/**
|
||||
* Calls the Anthropic Claude API via SSE streaming.
|
||||
*
|
||||
* Supports:
|
||||
* - Extended thinking (mode === "think") — budget_tokens from cfg
|
||||
* - Web search — server tool web_search_20260209 (Anthropic runs the searches on its side)
|
||||
*
|
||||
* Note: anthropic-dangerous-direct-browser-access is required when the
|
||||
* request goes directly from the browser (Obsidian desktop/mobile).
|
||||
*/
|
||||
export async function callClaude(
|
||||
apiKey: string,
|
||||
model: string,
|
||||
messages: ChatMessage[],
|
||||
mode: string,
|
||||
webSearch = false,
|
||||
onChunk: ((fullText: string) => void) | null = null,
|
||||
signal: AbortSignal | null = null,
|
||||
maxTokens?: number,
|
||||
): Promise<StreamResult> {
|
||||
const cfg = THINKING_MODES[mode] ?? THINKING_MODES.normal;
|
||||
const tokens = maxTokens ?? cfg.tokens;
|
||||
const isThinking = mode === "think";
|
||||
|
||||
const systemMsg = messages.find(m => m.role === "system");
|
||||
const inputMsgs = messages
|
||||
.filter(m => m.role !== "system")
|
||||
.map(m => ({ role: m.role, content: m.content }));
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
max_tokens: isThinking ? tokens + 8000 : tokens,
|
||||
system: systemMsg?.content ?? undefined,
|
||||
messages: inputMsgs,
|
||||
stream: true,
|
||||
};
|
||||
|
||||
if (isThinking) {
|
||||
body.thinking = { type: "enabled", budget_tokens: tokens };
|
||||
}
|
||||
|
||||
// Web search — server tool: Anthropic runs the searches on its side.
|
||||
// One request, one stream — no agentic loop needed.
|
||||
if (webSearch) {
|
||||
body.tools = [{ type: "web_search_20260209", name: "web_search" }];
|
||||
}
|
||||
|
||||
return withRetry(() =>
|
||||
streamSSE(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
{
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
},
|
||||
body,
|
||||
(event) => {
|
||||
// Extract only text_delta — ignore thinking_delta (internal reasoning)
|
||||
if (
|
||||
event.type === "content_block_delta" &&
|
||||
(event.delta as { type?: string } | undefined)?.type === "text_delta"
|
||||
) {
|
||||
return (event.delta as { text?: string }).text ?? null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChunk,
|
||||
signal,
|
||||
),
|
||||
);
|
||||
}
|
||||
4
src/api/index.ts
Normal file
4
src/api/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { callOpenAI, callOpenAIResponses } from "./openai";
|
||||
export { callClaude } from "./anthropic";
|
||||
export { streamSSE, throwHttpError } from "./streaming";
|
||||
export type { StreamResult, StreamUsage } from "./streaming";
|
||||
240
src/api/openai.ts
Normal file
240
src/api/openai.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { t } from "../i18n";
|
||||
import { THINKING_MODES, isGPT5, isGPT5Search, mapEffortForGPT5, WEB_SEARCH_CAPABLE } from "../models";
|
||||
import { withRetry } from "../utils";
|
||||
import { streamSSE, throwHttpError } from "./streaming";
|
||||
import type { ChatMessage } from "../types";
|
||||
import type { StreamResult, StreamUsage } from "./streaming";
|
||||
|
||||
// ─── Chat Completions ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calls the OpenAI API.
|
||||
* Automatically picks the endpoint:
|
||||
* - GPT-5 + webSearch → Responses API (only way to use web search with these models)
|
||||
* - GPT-5 without webSearch → Chat Completions with reasoning_effort
|
||||
* - GPT-5-search-api → Chat Completions with web_search_options
|
||||
* - Others → Chat Completions, optionally tools:[{type:"web_search"}]
|
||||
*/
|
||||
export async function callOpenAI(
|
||||
apiKey: string,
|
||||
model: string,
|
||||
messages: ChatMessage[],
|
||||
mode: string,
|
||||
webSearch = false,
|
||||
onChunk: ((fullText: string) => void) | null = null,
|
||||
signal: AbortSignal | null = null,
|
||||
maxTokens?: number,
|
||||
): Promise<StreamResult> {
|
||||
const cfg = THINKING_MODES[mode] ?? THINKING_MODES.normal;
|
||||
const tokens = maxTokens ?? cfg.tokens;
|
||||
const gpt5 = isGPT5(model);
|
||||
const gpt5Srch = isGPT5Search(model);
|
||||
|
||||
// GPT-5/Mini/Nano + web search → Responses API
|
||||
if (gpt5 && webSearch) {
|
||||
return callOpenAIResponses(apiKey, model, messages, mode, onChunk, signal, maxTokens);
|
||||
}
|
||||
|
||||
const systemMsg = messages.find(m => m.role === "system");
|
||||
const userMsgs = messages.filter(m => m.role !== "system");
|
||||
|
||||
const chatMessages: { role: string; content: string }[] = [];
|
||||
if (systemMsg?.content) chatMessages.push({ role: "system", content: systemMsg.content });
|
||||
chatMessages.push(...userMsgs.map(m => ({ role: m.role, content: m.content })));
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
if (gpt5Srch) {
|
||||
// gpt-5-search-api: built-in web search via Chat Completions, web_search_options param
|
||||
body = {
|
||||
model,
|
||||
messages: chatMessages,
|
||||
max_tokens: tokens,
|
||||
web_search_options: {},
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
};
|
||||
} else if (gpt5) {
|
||||
// GPT-5/Mini/Nano without web search — Chat Completions with reasoning_effort
|
||||
const effort = mapEffortForGPT5(mode);
|
||||
const tokenBudget =
|
||||
effort === "high" ? tokens + 12000 :
|
||||
effort === "medium" ? tokens + 4000 :
|
||||
tokens;
|
||||
|
||||
body = {
|
||||
model,
|
||||
messages: chatMessages,
|
||||
max_completion_tokens: tokenBudget,
|
||||
reasoning_effort: effort,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
};
|
||||
} else {
|
||||
// GPT-4o, GPT-4-turbo and other classic models
|
||||
body = {
|
||||
model,
|
||||
messages: chatMessages,
|
||||
max_tokens: tokens,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
};
|
||||
if (webSearch && WEB_SEARCH_CAPABLE.has(model)) {
|
||||
body.tools = [{ type: "web_search" }];
|
||||
}
|
||||
}
|
||||
|
||||
return withRetry(() =>
|
||||
streamSSE(
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
{ "Authorization": `Bearer ${apiKey}` },
|
||||
body,
|
||||
(event) => {
|
||||
const choices = event.choices as Array<{ delta?: { content?: string } }> | undefined;
|
||||
return choices?.[0]?.delta?.content ?? null;
|
||||
},
|
||||
onChunk,
|
||||
signal,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Responses API (GPT-5 + web search) ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* OpenAI Responses API — used exclusively for GPT-5/Mini/Nano with web search.
|
||||
* Different endpoint and format than Chat Completions — handles reasoning + web search in one stream.
|
||||
*/
|
||||
export async function callOpenAIResponses(
|
||||
apiKey: string,
|
||||
model: string,
|
||||
messages: ChatMessage[],
|
||||
mode: string,
|
||||
onChunk: ((fullText: string) => void) | null = null,
|
||||
signal: AbortSignal | null = null,
|
||||
maxTokens?: number,
|
||||
): Promise<StreamResult> {
|
||||
const cfg = THINKING_MODES[mode] ?? THINKING_MODES.normal;
|
||||
const tokens = maxTokens ?? cfg.tokens;
|
||||
const effort = mapEffortForGPT5(mode);
|
||||
const tokenBudget =
|
||||
effort === "high" ? tokens + 12000 :
|
||||
effort === "medium" ? tokens + 4000 :
|
||||
tokens;
|
||||
|
||||
const systemMsg = messages.find(m => m.role === "system");
|
||||
const userMsgs = messages.filter(m => m.role !== "system");
|
||||
|
||||
// Responses API uses `input` instead of `messages`, `instructions` instead of `system`
|
||||
const input = userMsgs.map(m => ({
|
||||
type: "message",
|
||||
role: m.role,
|
||||
content: [{
|
||||
type: m.role === "user" ? "input_text" : "output_text",
|
||||
text: m.content,
|
||||
}],
|
||||
}));
|
||||
|
||||
const body = {
|
||||
model,
|
||||
input,
|
||||
instructions: systemMsg?.content ?? undefined,
|
||||
max_output_tokens: tokenBudget,
|
||||
reasoning: { effort },
|
||||
tools: [{ type: "web_search" }],
|
||||
stream: true,
|
||||
};
|
||||
|
||||
return withRetry(async () => {
|
||||
const response = await fetch("https://api.openai.com/v1/responses", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: signal ?? undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) await throwHttpError(response, model);
|
||||
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullText = "";
|
||||
let buffer = "";
|
||||
let finished = false;
|
||||
let chunksDelivered = false;
|
||||
let usage: StreamUsage | null = null;
|
||||
|
||||
const abortError = (): Error => {
|
||||
const e = new Error("Aborted by user");
|
||||
e.name = "AbortError";
|
||||
return e;
|
||||
};
|
||||
|
||||
try {
|
||||
while (!finished) {
|
||||
if (signal?.aborted) throw abortError();
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (signal?.aborted) throw abortError();
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const data = line.slice(6).trim();
|
||||
if (data === "[DONE]") { finished = true; break; }
|
||||
|
||||
let event: Record<string, unknown>;
|
||||
try { event = JSON.parse(data) as Record<string, unknown>; }
|
||||
catch { continue; }
|
||||
|
||||
// Responses API stream events:
|
||||
// response.output_text.delta → response text
|
||||
// response.completed → usage including reasoning tokens
|
||||
if (event.type === "response.output_text.delta") {
|
||||
const delta = (event.delta as string) ?? "";
|
||||
if (delta) {
|
||||
fullText += delta;
|
||||
chunksDelivered = true;
|
||||
onChunk?.(fullText);
|
||||
}
|
||||
} else if (event.type === "response.completed") {
|
||||
const r = event.response as {
|
||||
usage?: {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
output_tokens_details?: { reasoning_tokens?: number };
|
||||
};
|
||||
} | undefined;
|
||||
if (r?.usage) {
|
||||
usage = {
|
||||
input: r.usage.input_tokens ?? 0,
|
||||
output: r.usage.output_tokens ?? 0,
|
||||
reasoning: r.usage.output_tokens_details?.reasoning_tokens ?? 0,
|
||||
};
|
||||
}
|
||||
} else if (event.type === "error" || event.error) {
|
||||
const err = event.error as { message?: string } | undefined;
|
||||
throw new Error(err?.message ?? (event.message as string) ?? t("err_stream_responses"));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (chunksDelivered && (err as { name?: string }).name !== "AbortError") {
|
||||
(err as { noRetry?: boolean }).noRetry = true;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (!finished) {
|
||||
try { await reader.cancel(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (!fullText.trim()) throw new Error(t("err_empty_response"));
|
||||
return { text: fullText.trim(), usage };
|
||||
});
|
||||
}
|
||||
196
src/api/streaming.ts
Normal file
196
src/api/streaming.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { t } from "../i18n";
|
||||
import { ModelAccessError } from "../models";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StreamUsage {
|
||||
input: number;
|
||||
output: number;
|
||||
reasoning: number;
|
||||
}
|
||||
|
||||
export interface StreamResult {
|
||||
text: string;
|
||||
usage: StreamUsage | null;
|
||||
}
|
||||
|
||||
type DeltaExtractor = (event: Record<string, unknown>) => string | null;
|
||||
|
||||
// ─── HTTP error ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parses an HTTP error response and throws the appropriate Error.
|
||||
* ModelAccessError — for 403/404/model_not_found (chat view can then suggest a fallback).
|
||||
*/
|
||||
export async function throwHttpError(response: Response, modelHint?: string | null): Promise<never> {
|
||||
let errMsg = `HTTP ${response.status}`;
|
||||
let errCode: string | null = null;
|
||||
|
||||
try {
|
||||
const d = await response.json() as {
|
||||
error?: { message?: string; code?: string; type?: string };
|
||||
message?: string;
|
||||
};
|
||||
errMsg = d?.error?.message ?? d?.message ?? errMsg;
|
||||
errCode = d?.error?.code ?? d?.error?.type ?? null;
|
||||
} catch { /* ignore — body may be empty */ }
|
||||
|
||||
if (
|
||||
response.status === 403 ||
|
||||
response.status === 404 ||
|
||||
errCode === "model_not_found"
|
||||
) {
|
||||
throw new ModelAccessError(errMsg, {
|
||||
model: modelHint ?? undefined,
|
||||
status: response.status,
|
||||
code: errCode ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
// ─── SSE streaming ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Unified SSE streaming helper — supports OpenAI Chat Completions and Anthropic Messages.
|
||||
*
|
||||
* fetch is required here (not Obsidian's requestUrl) because requestUrl does
|
||||
* not expose the ReadableStream needed for SSE.
|
||||
*
|
||||
* @param url - API endpoint
|
||||
* @param headers - request headers (Authorization, x-api-key etc.)
|
||||
* @param body - request body (serialized to JSON)
|
||||
* @param extractDelta - function that extracts text from an SSE event
|
||||
* @param onChunk - callback fired after each new fragment (full text so far)
|
||||
* @param signal - AbortSignal to interrupt the stream
|
||||
*/
|
||||
export async function streamSSE(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
body: Record<string, unknown>,
|
||||
extractDelta: DeltaExtractor,
|
||||
onChunk: ((fullText: string) => void) | null,
|
||||
signal?: AbortSignal | null,
|
||||
): Promise<StreamResult> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
signal: signal ?? undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await throwHttpError(response, body.model as string | undefined);
|
||||
}
|
||||
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullText = "";
|
||||
let buffer = "";
|
||||
let finished = false;
|
||||
let chunksDelivered = false;
|
||||
let usage: StreamUsage | null = null;
|
||||
|
||||
const abortError = (): Error => {
|
||||
const e = new Error("Aborted by user");
|
||||
e.name = "AbortError";
|
||||
return e;
|
||||
};
|
||||
|
||||
try {
|
||||
while (!finished) {
|
||||
if (signal?.aborted) throw abortError();
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (signal?.aborted) throw abortError();
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
|
||||
const data = line.slice(6).trim();
|
||||
if (data === "[DONE]") { finished = true; break; }
|
||||
|
||||
let event: Record<string, unknown>;
|
||||
try { event = JSON.parse(data) as Record<string, unknown>; }
|
||||
catch { continue; } // incomplete chunk — skip
|
||||
|
||||
// Stream-side error (Anthropic/OpenAI may emit an "error" event)
|
||||
if (event.type === "error" || event.error) {
|
||||
const err = event.error as { message?: string } | undefined;
|
||||
throw new Error(err?.message ?? (event.message as string) ?? t("err_stream"));
|
||||
}
|
||||
|
||||
// Collect usage:
|
||||
// OpenAI: last chunk before [DONE] (when stream_options.include_usage = true)
|
||||
// Anthropic: message_start (input), message_delta (output)
|
||||
const eventUsage = event.usage as Record<string, unknown> | undefined;
|
||||
if (eventUsage) {
|
||||
const details: Record<string, number> | undefined = eventUsage.completion_tokens_details as Record<string, number> | undefined;
|
||||
const prevInput: number = usage !== null ? usage.input : 0;
|
||||
const prevOutput: number = usage !== null ? usage.output : 0;
|
||||
const prevReasoning: number = usage !== null ? usage.reasoning : 0;
|
||||
usage = {
|
||||
input: (eventUsage.prompt_tokens as number | undefined) ??
|
||||
(eventUsage.input_tokens as number | undefined) ??
|
||||
prevInput,
|
||||
output: (eventUsage.completion_tokens as number | undefined) ??
|
||||
(eventUsage.output_tokens as number | undefined) ??
|
||||
prevOutput,
|
||||
reasoning: details?.reasoning_tokens ?? prevReasoning,
|
||||
};
|
||||
}
|
||||
|
||||
// Anthropic: message_start carries input_tokens in a different shape
|
||||
if (event.type === "message_start") {
|
||||
const msg = event.message as { usage?: { input_tokens?: number; output_tokens?: number } } | undefined;
|
||||
if (msg?.usage) {
|
||||
usage = {
|
||||
input: msg.usage.input_tokens ?? 0,
|
||||
output: msg.usage.output_tokens ?? 0,
|
||||
reasoning: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Anthropic: message_delta carries output_tokens
|
||||
if (event.type === "message_delta") {
|
||||
const deltaUsage = event.usage as { output_tokens?: number } | undefined;
|
||||
if (deltaUsage?.output_tokens != null) {
|
||||
usage = {
|
||||
input: usage !== null ? usage.input : 0,
|
||||
reasoning: usage !== null ? usage.reasoning : 0,
|
||||
output: deltaUsage.output_tokens,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const delta = extractDelta(event);
|
||||
if (delta) {
|
||||
fullText += delta;
|
||||
chunksDelivered = true;
|
||||
onChunk?.(fullText);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Once any chunk has reached the UI, retrying would corrupt the visible
|
||||
// output. Flag the error so withRetry stops here.
|
||||
if (chunksDelivered && (err as { name?: string }).name !== "AbortError") {
|
||||
(err as { noRetry?: boolean }).noRetry = true;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
// Always release the connection on early exit (error or abort)
|
||||
if (!finished) {
|
||||
try { await reader.cancel(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (!fullText.trim()) throw new Error(t("err_empty_response"));
|
||||
return { text: fullText.trim(), usage };
|
||||
}
|
||||
23
src/constants.ts
Normal file
23
src/constants.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// ─── View types ───────────────────────────────────────────────────────────────
|
||||
export const CHAT_VIEW_TYPE = "gpt-chat-view";
|
||||
export const HISTORY_VIEW_TYPE = "gpt-history-view";
|
||||
export const PROJECTS_VIEW_TYPE = "gpt-projects-view";
|
||||
|
||||
// ─── Storage keys (localStorage / data.json) ─────────────────────────────────
|
||||
export const RAG_INDEX_KEY = "gpt-rag-index-v1";
|
||||
export const HISTORY_KEY = "gpt-history-v1";
|
||||
|
||||
// ─── RAG tuning ───────────────────────────────────────────────────────────────
|
||||
export const RAG_TOP_K = 5;
|
||||
export const RAG_CHUNK_SIZE = 1200;
|
||||
export const RAG_CHUNK_OVERLAP = 150;
|
||||
|
||||
// ─── Data files (relative to plugin folder) ──────────────────────────────────
|
||||
export const FILE_RAG_INDEX = "rag-index.json";
|
||||
export const FILE_HISTORY_INDEX = "history-index.json";
|
||||
export const FILE_PROJECTS = "projects.json";
|
||||
export const FILE_API_KEYS = "keys.json";
|
||||
export const DIR_HISTORY = "history";
|
||||
|
||||
// ─── Migration from older versions ────────────────────────────────────────────
|
||||
export const LEGACY_DIR_NAME = "obsidian-gpt";
|
||||
150
src/history/HistoryManager.ts
Normal file
150
src/history/HistoryManager.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { DIR_HISTORY, FILE_HISTORY_INDEX } from "../constants";
|
||||
import type { ExternalStorage } from "../storage/ExternalStorage";
|
||||
import type { ChatMessage, ChatSession, SessionMeta } from "../types";
|
||||
|
||||
interface PluginWithStorage {
|
||||
externalStorage: ExternalStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages chat history.
|
||||
* Architecture: a lightweight index (SessionMeta[]) kept in memory plus lazy
|
||||
* loading of messages from per-session files (session-{id}.json) — loaded
|
||||
* only on demand. Capped at 100 sessions — oldest are evicted.
|
||||
*/
|
||||
export class HistoryManager {
|
||||
/** Lightweight index — metadata without message bodies */
|
||||
sessions: SessionMeta[] = [];
|
||||
|
||||
private readonly storage: ExternalStorage;
|
||||
private readonly messagesCache: Record<string, ChatMessage[]> = {};
|
||||
|
||||
constructor(private readonly plugin: PluginWithStorage) {
|
||||
this.storage = plugin.externalStorage;
|
||||
}
|
||||
|
||||
// ── Paths ──────────────────────────────────────────────────────────────────
|
||||
|
||||
get historyDir(): string { return this.storage.resolve(DIR_HISTORY); }
|
||||
get indexPath(): string { return this.storage.resolve(FILE_HISTORY_INDEX); }
|
||||
|
||||
private sessionPath(id: string): string {
|
||||
// Validate id — only alphanumerics, underscores and dashes (id = Date.now() in practice)
|
||||
const safe = String(id).replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
return `${this.historyDir}/session-${safe}.json`;
|
||||
}
|
||||
|
||||
// ── Loading ────────────────────────────────────────────────────────────────
|
||||
|
||||
async load(): Promise<void> {
|
||||
await this.storage.ensureDir(this.historyDir);
|
||||
const index = await this.storage.readJson<SessionMeta[] | null>(this.indexPath, null);
|
||||
if (Array.isArray(index) && index.length) {
|
||||
this.sessions = index;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Saving ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private async saveIndex(): Promise<void> {
|
||||
await this.storage.writeJson(this.indexPath, this.sessions);
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
await this.saveIndex();
|
||||
}
|
||||
|
||||
// ── Messages ───────────────────────────────────────────────────────────────
|
||||
|
||||
async getMessages(sessionId: string): Promise<ChatMessage[]> {
|
||||
if (this.messagesCache[sessionId]) return this.messagesCache[sessionId];
|
||||
|
||||
const path = this.sessionPath(sessionId);
|
||||
|
||||
// If the file does not exist, the session is genuinely empty — cache and return []
|
||||
if (!(await this.storage.exists(path))) {
|
||||
this.messagesCache[sessionId] = [];
|
||||
return this.messagesCache[sessionId];
|
||||
}
|
||||
|
||||
// File exists — read it. readJson returns fallback on parse/IO failure,
|
||||
// but we MUST NOT cache that fallback: a transient failure would otherwise
|
||||
// permanently mask the real history and the next save would overwrite the
|
||||
// file with only the latest messages.
|
||||
const messages = await this.storage.readJson<ChatMessage[] | null>(path, null);
|
||||
if (!Array.isArray(messages)) {
|
||||
console.warn("[AI-Vault] getMessages: read failed, not caching", path);
|
||||
return [];
|
||||
}
|
||||
|
||||
this.messagesCache[sessionId] = messages;
|
||||
return messages;
|
||||
}
|
||||
|
||||
// ── Session CRUD ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Creates a new empty session (does not persist to disk) */
|
||||
newSession(projectId: string | null = null): ChatSession {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: now.toString(),
|
||||
title: "New conversation",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
messages: [],
|
||||
projectId,
|
||||
};
|
||||
}
|
||||
|
||||
async saveSession(session: ChatSession): Promise<void> {
|
||||
// Persist messages to their own file
|
||||
if (session.messages?.length) {
|
||||
await this.storage.writeJson(this.sessionPath(session.id), session.messages);
|
||||
this.messagesCache[session.id] = session.messages;
|
||||
}
|
||||
|
||||
// Update the lightweight index entry
|
||||
const meta: SessionMeta = {
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
projectId: session.projectId ?? null,
|
||||
preview: session.messages?.find(m => m.role === "user")?.content?.slice(0, 80) ?? "",
|
||||
model: session.model,
|
||||
};
|
||||
|
||||
const idx = this.sessions.findIndex(s => s.id === session.id);
|
||||
if (idx >= 0) {
|
||||
this.sessions[idx] = meta;
|
||||
} else {
|
||||
this.sessions.unshift(meta);
|
||||
}
|
||||
|
||||
// Cap at 100 sessions — drop the oldest
|
||||
if (this.sessions.length > 100) {
|
||||
const removed = this.sessions.splice(100);
|
||||
for (const old of removed) {
|
||||
await this.storage.remove(this.sessionPath(old.id));
|
||||
delete this.messagesCache[old.id];
|
||||
}
|
||||
}
|
||||
|
||||
await this.saveIndex();
|
||||
}
|
||||
|
||||
async deleteSession(id: string): Promise<void> {
|
||||
this.sessions = this.sessions.filter(s => s.id !== id);
|
||||
delete this.messagesCache[id];
|
||||
await this.storage.remove(this.sessionPath(id));
|
||||
await this.saveIndex();
|
||||
}
|
||||
|
||||
async getFullSession(id: string): Promise<ChatSession | null> {
|
||||
const meta = this.sessions.find(s => s.id === id);
|
||||
if (!meta) return null;
|
||||
|
||||
const messages = await this.getMessages(id);
|
||||
return { ...meta, messages };
|
||||
}
|
||||
}
|
||||
152
src/history/ProjectManager.ts
Normal file
152
src/history/ProjectManager.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { FILE_PROJECTS } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import type { ExternalStorage } from "../storage/ExternalStorage";
|
||||
import type { HistoryManager } from "./HistoryManager";
|
||||
import type { Project, SessionMeta, ChatMessage } from "../types";
|
||||
|
||||
interface PluginWithDeps {
|
||||
externalStorage: ExternalStorage;
|
||||
history: HistoryManager;
|
||||
}
|
||||
|
||||
const PROJECT_COLORS = [
|
||||
"#10a37f", "#7c3aed", "#2563eb",
|
||||
"#e74c3c", "#f59e0b", "#ec4899",
|
||||
"#06b6d4", "#84cc16",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Manages projects — groups of chats that share context and a system prompt.
|
||||
*/
|
||||
export class ProjectManager {
|
||||
projects: Project[] = [];
|
||||
|
||||
private readonly storage: ExternalStorage;
|
||||
|
||||
constructor(private readonly plugin: PluginWithDeps) {
|
||||
this.storage = plugin.externalStorage;
|
||||
}
|
||||
|
||||
// ── Paths ──────────────────────────────────────────────────────────────────
|
||||
|
||||
get filePath(): string { return this.storage.resolve(FILE_PROJECTS); }
|
||||
|
||||
// ── Load / save ────────────────────────────────────────────────────────────
|
||||
|
||||
async load(): Promise<void> {
|
||||
const data = await this.storage.readJson<Project[]>(this.filePath, []);
|
||||
this.projects = Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
await this.storage.writeJson(this.filePath, this.projects);
|
||||
}
|
||||
|
||||
// ── Project CRUD ───────────────────────────────────────────────────────────
|
||||
|
||||
async createProject(
|
||||
name: string,
|
||||
description = "",
|
||||
customPrompt = "",
|
||||
): Promise<Project> {
|
||||
const project: Project = {
|
||||
id: Date.now().toString(),
|
||||
name,
|
||||
color: this.randomColor(),
|
||||
systemPrompt: customPrompt,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
this.projects.unshift(project);
|
||||
await this.save();
|
||||
return project;
|
||||
}
|
||||
|
||||
async updateProject(id: string, data: Partial<Project>): Promise<void> {
|
||||
const project = this.projects.find(p => p.id === id);
|
||||
if (!project) return;
|
||||
|
||||
Object.assign(project, data, { updatedAt: Date.now() });
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async deleteProject(id: string): Promise<void> {
|
||||
this.projects = this.projects.filter(p => p.id !== id);
|
||||
|
||||
// Detach sessions from the deleted project
|
||||
for (const session of this.plugin.history.sessions) {
|
||||
if (session.projectId === id) session.projectId = null;
|
||||
}
|
||||
|
||||
await this.plugin.history.save();
|
||||
await this.save();
|
||||
}
|
||||
|
||||
getProject(id: string): Project | null {
|
||||
return this.projects.find(p => p.id === id) ?? null;
|
||||
}
|
||||
|
||||
// ── Project sessions ───────────────────────────────────────────────────────
|
||||
|
||||
getProjectSessions(projectId: string): SessionMeta[] {
|
||||
return this.plugin.history.sessions
|
||||
.filter(s => s.projectId === projectId)
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}
|
||||
|
||||
// ── Project context (for the system prompt) ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Builds context from previous chats in the project.
|
||||
* Injected as extra context for every query within the project.
|
||||
*/
|
||||
async buildProjectContext(
|
||||
projectId: string,
|
||||
currentSessionId: string,
|
||||
maxChars = 4000,
|
||||
): Promise<string> {
|
||||
const sessions = this.getProjectSessions(projectId)
|
||||
.filter(s => s.id !== currentSessionId);
|
||||
|
||||
if (!sessions.length) return "";
|
||||
|
||||
let ctx = "";
|
||||
for (const session of sessions) {
|
||||
const summary = await this.summarizeSession(session);
|
||||
if (!summary) continue;
|
||||
if (ctx.length + summary.length > maxChars) break;
|
||||
ctx += summary + "\n\n---\n\n";
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Builds a short session summary (last 6 messages) */
|
||||
async summarizeSession(session: SessionMeta & { messages?: ChatMessage[] }): Promise<string> {
|
||||
const messages: ChatMessage[] =
|
||||
session.messages ?? await this.plugin.history.getMessages(session.id);
|
||||
|
||||
if (!messages.length) return "";
|
||||
|
||||
const title = session.title || "Rozmowa";
|
||||
const recent = messages.slice(-6);
|
||||
|
||||
const lines = recent.map(m => {
|
||||
const role = m.role === "user"
|
||||
? t("export_role_user")
|
||||
: t("export_role_assistant");
|
||||
const text = m.content.length > 300
|
||||
? m.content.slice(0, 300) + "…"
|
||||
: m.content;
|
||||
return `${role}: ${text}`;
|
||||
});
|
||||
|
||||
return `### Chat: ${title}\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
private randomColor(): string {
|
||||
return PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)];
|
||||
}
|
||||
}
|
||||
2
src/history/index.ts
Normal file
2
src/history/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { HistoryManager } from "./HistoryManager";
|
||||
export { ProjectManager } from "./ProjectManager";
|
||||
598
src/i18n.ts
Normal file
598
src/i18n.ts
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type TranslationValue = string | ((...args: any[]) => string);
|
||||
|
||||
interface TranslationDict {
|
||||
[key: string]: TranslationValue;
|
||||
}
|
||||
|
||||
// ─── Translations ─────────────────────────────────────────────────────────────
|
||||
|
||||
const en: TranslationDict = {
|
||||
// Settings
|
||||
settings_title: "AI-Vault — Settings",
|
||||
settings_keys_sync_name: "Sync API keys via Obsidian Sync",
|
||||
settings_keys_sync_desc_on: "⚠️ API keys are stored in data.json — synced via Obsidian Sync. Convenient across devices, but keys reach Obsidian servers.",
|
||||
settings_keys_sync_desc_off: "🔒 API keys stored locally (outside vault) — Obsidian Sync does not synchronize them. You need to enter the key once on each new device.",
|
||||
settings_keys_mobile_note: "ℹ️ Toggle unavailable on mobile — keys always stored in data.json.",
|
||||
settings_key_local: "🔒 Stored locally outside vault — Obsidian Sync does not synchronize it.",
|
||||
settings_key_sync: "☁️ Stored in data.json — synced via Obsidian Sync.",
|
||||
settings_openai_title: "🤖 OpenAI",
|
||||
settings_openai_key_name: "OpenAI API Key",
|
||||
settings_openai_model_name: "OpenAI Model",
|
||||
settings_thinking_name: "Default thinking mode",
|
||||
settings_max_tokens_title: "Max tokens per mode",
|
||||
settings_max_tokens_fast_name: "⚡ Fast — max tokens",
|
||||
settings_max_tokens_fast_desc: "Maximum output tokens for Fast mode (default: 4096)",
|
||||
settings_max_tokens_normal_name: "⚖️ Normal — max tokens",
|
||||
settings_max_tokens_normal_desc: "Maximum output tokens for Normal mode (default: 8192)",
|
||||
settings_max_tokens_think_name: "🧠 Think — max tokens",
|
||||
settings_max_tokens_think_desc: "Maximum output tokens for Think mode (default: 16000). For Claude, thinking budget = this value; actual max_tokens = this + 8000.",
|
||||
settings_system_prompt_name: "System prompt",
|
||||
settings_system_prompt_desc: "Global prompt. Projects with their own prompt override this one.",
|
||||
settings_system_prompt_reset: "Reset to default",
|
||||
settings_system_prompt_reset_tip: "Restore the default system prompt for the current language",
|
||||
settings_claude_title: "🟣 Claude (Anthropic)",
|
||||
settings_claude_key_name: "Claude API Key",
|
||||
settings_claude_model_name: "Claude Model",
|
||||
settings_context_title: "Context window",
|
||||
settings_context_name: "Max messages in context",
|
||||
settings_context_desc: "Number of previous messages sent to the model. 0 = unlimited (current default).",
|
||||
settings_rag_title: "🗃️ RAG — Vault Search",
|
||||
settings_rag_enable_name: "Enable RAG",
|
||||
settings_rag_enable_desc: "Automatically search matching notes for each message.",
|
||||
settings_rag_auto_name: "Auto-index on startup",
|
||||
settings_rag_auto_desc: "Index new and changed notes when opening the chat panel.",
|
||||
settings_rag_reindex_name: "Re-index vault",
|
||||
settings_rag_reindex_btn: "Index now",
|
||||
settings_rag_indexing: "Indexing…",
|
||||
settings_storage_title: "💾 Local storage (outside vault)",
|
||||
settings_storage_name: "Save outside vault",
|
||||
settings_storage_desc: "Chat history, projects and RAG index saved in a local folder next to the vault — Obsidian Sync does not synchronize them. Desktop only.",
|
||||
settings_storage_path_name: "Folder path (optional)",
|
||||
settings_storage_path_desc: (p: string) => `Full system path. Empty = default folder next to vault. Default: ${p}`,
|
||||
settings_storage_migrate_name: "Migrate history from vault",
|
||||
settings_storage_migrate_desc: "Manually move existing history from the plugin folder in the vault to the external folder. Safe — files are moved, not copied.",
|
||||
settings_storage_migrate_btn: "Migrate now",
|
||||
settings_storage_migrating: "Migrating…",
|
||||
settings_storage_open_name: "Open folder in explorer",
|
||||
settings_storage_open_btn: "Open folder",
|
||||
settings_storage_open_desc: "Shows the history folder in the system file manager.",
|
||||
settings_storage_active: "✅ Active — history saved OUTSIDE vault",
|
||||
settings_storage_inactive: "⚠️ Disabled — history saved inside vault",
|
||||
settings_storage_inactive_html: "⚠️ <strong>Disabled — history saved inside vault</strong><br>Obsidian Sync may synchronize chat history (uses up your GB limit).",
|
||||
settings_storage_mobile: "📱 Mobile device detected",
|
||||
settings_storage_mobile_desc:"External storage outside vault works on desktop only. On mobile, data is saved in the plugin folder inside the vault.",
|
||||
settings_storage_mobile_full:"📱 <strong>Mobile device detected</strong><br>External storage outside vault works on desktop only. On mobile, data is saved in the plugin folder inside the vault.<br><br>💡 <em>Tip:</em> To limit Obsidian Sync, disable configuration file sync (<code>.obsidian</code>) or add the plugin folder to exclusions.",
|
||||
settings_storage_mobile_na: "(unavailable on mobile)",
|
||||
settings_keys_local_warning_html: "🔒 <strong>API keys are stored locally</strong> in the plugin folder inside your vault (plain text). If you sync your vault — keys are synced too.",
|
||||
settings_lang_name: "Language",
|
||||
settings_lang_desc: "Interface language for the plugin.",
|
||||
|
||||
// Chat view
|
||||
chat_new: "New",
|
||||
chat_history: "History",
|
||||
chat_projects: "Projects",
|
||||
chat_welcome_rag: "RAG active — I'll automatically find matching notes.",
|
||||
chat_welcome_hint: "Use 📎 Notes to manually select context.",
|
||||
chat_placeholder: "Type a message… (Enter = send)",
|
||||
chat_placeholder_claude: "Type a message to Claude… (Enter = send)",
|
||||
chat_placeholder_code: "Describe what you want to code…",
|
||||
chat_placeholder_learn: "Enter a topic or type 'make a test'…",
|
||||
chat_notes_btn: "Notes",
|
||||
chat_notes_title: "Select notes for context:",
|
||||
chat_notes_search: "Search note or canvas…",
|
||||
chat_notes_add: "Add to context",
|
||||
chat_notes_cancel: "Cancel",
|
||||
chat_notes_added: (n: number) => `📎 Added ${n} file(s) to context`,
|
||||
chat_notes_more: (n: number) => `…and ${n} more. Narrow your search.`,
|
||||
chat_send: "Send",
|
||||
chat_stop: "Stop",
|
||||
chat_copy: "Copy",
|
||||
chat_copied: "Copied!",
|
||||
chat_interrupted: "⏹ Interrupted by user",
|
||||
chat_btn_rag: "RAG",
|
||||
chat_btn_index: "Index",
|
||||
chat_btn_notes: "Notes",
|
||||
chat_btn_internet: "Internet",
|
||||
chat_btn_learn: "Learn",
|
||||
chat_btn_code: "Code",
|
||||
chat_title_rag: "Enable/disable auto-RAG",
|
||||
chat_title_index: "Re-index vault",
|
||||
chat_title_notes: "Select notes for context (alongside RAG)",
|
||||
chat_title_internet: "Web search",
|
||||
chat_title_learn: "Learn mode — model asks questions, generates quizzes",
|
||||
chat_title_code: "Code mode — model as expert programmer",
|
||||
chat_section_rag: "🗄️ RAG (vault context)",
|
||||
chat_model_tooltip: (m: string) => `Current model: ${m}\nClick to change`,
|
||||
chat_model_session_tooltip: (title: string, model: string) => `${title}\nModel: ${model}\nClick to change`,
|
||||
chat_mode_fast: "⚡ Fast",
|
||||
chat_mode_fast_desc: "Quick response",
|
||||
chat_mode_normal: "⚖️ Normal",
|
||||
chat_mode_normal_desc: "Balanced (default)",
|
||||
chat_mode_think: "🧠 Thinking",
|
||||
chat_mode_think_desc: "Deep analysis",
|
||||
chat_picker_openai: "🤖 Select OpenAI model",
|
||||
chat_picker_claude: "🟣 Select Claude model",
|
||||
chat_regen_tooltip: "Regenerate last response",
|
||||
chat_export_tooltip: "Export conversation to note",
|
||||
model_desc_gpt5: "Reasoning, best",
|
||||
model_desc_gpt5mini: "Reasoning, faster",
|
||||
model_desc_gpt5nano: "Reasoning, cheapest",
|
||||
model_desc_gpt5search: "Web search built-in",
|
||||
model_desc_gpt4o: "Web search ✓",
|
||||
model_desc_gpt4omini: "Web search ✓, cheaper",
|
||||
model_desc_gpt4turbo: "Classic",
|
||||
model_desc_opus: "Best Claude",
|
||||
model_desc_sonnet: "Recommended",
|
||||
model_desc_haiku: "Fast, cheap",
|
||||
model_gpt5nano_label: "GPT-5 Nano",
|
||||
|
||||
// Web search
|
||||
ws_enabled: (m: string) => `🌐 Internet ON (${m})`,
|
||||
ws_disabled: "🌐 Internet OFF",
|
||||
ws_claude_enabled: (m: string) => `🌐 Internet ON — Claude will decide when to search (${m})`,
|
||||
ws_gpt5search_always: "ℹ️ GPT-5 Search has web search built-in — always active regardless of toggle.",
|
||||
ws_unsupported: (m: string) => `⚠️ Model ${m} does not support web search. Choose GPT-5 Search, GPT-4o or GPT-4o Mini.`,
|
||||
ws_searching_label: "Searching the web…",
|
||||
|
||||
// Modes
|
||||
mode_code_on: (p: string) => `💻 Code mode ON — ${p} as expert programmer`,
|
||||
mode_code_off: "💻 Code mode OFF",
|
||||
mode_learn_on: "🎓 Learn mode ON",
|
||||
mode_learn_off: "🎓 Learn mode OFF",
|
||||
|
||||
// History view
|
||||
history_title: "Chat History",
|
||||
history_btn_new: "+ New",
|
||||
history_empty: "No standalone chats yet",
|
||||
history_empty_hint: "Start a new chat in the chat panel.",
|
||||
history_delete_confirm: (t: string) => `Delete chat "${t}"?`,
|
||||
history_chats_in_projects: "Chats assigned to projects\ncan be found in the Projects tab",
|
||||
history_delete_chat_confirm: (t: string) => `Delete chat "${t}"?`,
|
||||
history_delete_btn: "Delete",
|
||||
|
||||
// Projects view
|
||||
projects_title: "Projects",
|
||||
projects_btn_back: "← History",
|
||||
projects_btn_new: "+ New",
|
||||
projects_chat_count: (n: number) => `${n} chat${n === 1 ? "" : "s"}`,
|
||||
projects_bar_label: (name: string, n: number) => `📁 ${name} — ${n} chat${n === 1 ? "" : "s"} with shared context`,
|
||||
projects_new: "New project",
|
||||
projects_empty: "No projects yet",
|
||||
projects_empty_hint: "Create your first project to organize chats by topic.",
|
||||
projects_empty_hint_long: "Create a project to group related chats with shared context.",
|
||||
projects_delete_confirm: (n: string) => `Delete project "${n}" and all its chats?`,
|
||||
projects_delete_with_count: (n: string, c: number) => `Delete project "${n}"${c ? ` and unlink ${c} chats` : ""}?`,
|
||||
projects_created: (n: string) => `📁 Project "${n}" created`,
|
||||
projects_updated: (n: string) => `📁 Project "${n}" updated`,
|
||||
projects_chat_delete_confirm:(t: string) => `Delete chat "${t}"?`,
|
||||
projects_active: "Active project:",
|
||||
projects_no_chats: "No chats yet",
|
||||
projects_leave_btn: "Leave",
|
||||
projects_own_prompt_btn: "Custom prompt",
|
||||
projects_more_chats: (n: number) => `…and ${n} more`,
|
||||
projects_prompt_label: "Project prompt (independent of global)",
|
||||
projects_prompt_placeholder: 'E.g. "You are a marketing expert. All answers relate to our brand X strategy…"',
|
||||
projects_prompt_hint: "If set, this prompt REPLACES the global system prompt. Leave empty to use the global prompt.",
|
||||
projects_create_btn: "Create",
|
||||
projects_custom_prompt_badge:"· ✏️ custom prompt",
|
||||
|
||||
// RAG
|
||||
rag_ready_short: (f: number, e: number) => `🗄️ RAG ready — ${f} notes (${e} embeddings)`,
|
||||
rag_ready_full: (f: number, e: number) => `✅ RAG ready — ${f} notes, ${e} embeddings`,
|
||||
rag_on_notice: "🗄️ RAG ON",
|
||||
rag_off_notice: "🗄️ RAG OFF",
|
||||
rag_manual_ctx_header: "NOTES SELECTED BY USER (+ linked via [[links]]):",
|
||||
rag_project_ctx_header: (n: string) => `CONTEXT FROM PROJECT "${n}" (other chats in this project — you know their content):`,
|
||||
rag_ctx_truncated: "[…context truncated due to limit]",
|
||||
rag_sources_label: "Sources:",
|
||||
rag_indexed: "✅ ready",
|
||||
rag_not_indexed: "⏳ not indexed",
|
||||
rag_status: (indexed: string, files: number, chunks: number, embs: number) =>
|
||||
`<strong>Status:</strong> ${indexed} | <strong>Notes:</strong> ${files} | <strong>Chunks:</strong> ${chunks} | <strong>Embeddings:</strong> ${embs}`,
|
||||
rag_done: (n: number) => `✅ RAG: ${n} notes`,
|
||||
|
||||
// Tokens / cost
|
||||
tokens_label: (n: number) => `~${n} tokens`,
|
||||
tokens_session_cost: (v: string) => `\nSession total: ${v}`,
|
||||
|
||||
// Notices
|
||||
notice_model_changed: (m: string) => `✓ Model: ${m}`,
|
||||
notice_keys_moved_local: "🔒 API keys moved outside vault.",
|
||||
notice_keys_moved_sync: "☁️ API keys will be synced via Obsidian Sync.",
|
||||
notice_keys_migrated: "🔑 API keys moved outside vault.",
|
||||
notice_fallback_switched: (m: string) => `✅ Switched to ${m}. Retrying message…`,
|
||||
notice_fallback_return: (m: string) => `↩ Returned to ${m}`,
|
||||
notice_migration_done: (n: number, p: string) => `✅ AI-Vault: Chat history moved outside vault (${n} files).\nLocation: ${p}`,
|
||||
notice_migration_partial: (n: number) => `⚠️ AI-Vault: Migration partially failed — ${n} errors. Check console.`,
|
||||
notice_migration_partial_short: (m: number, e: number) => `⚠️ Moved ${m}, errors: ${e}. Console: F12.`,
|
||||
notice_migrated_manual: (n: number, s: number) => `✅ Moved ${n} files (skipped ${s}).`,
|
||||
notice_migration_failed: (e: string) => `❌ Migration failed: ${e}`,
|
||||
notice_export_done: (f: string) => `📝 Exported to: ${f}`,
|
||||
notice_export_fail: (e: string) => `❌ Export error: ${e}`,
|
||||
notice_manual_notes: (n: number) => `📎 Added ${n} note(s) to context`,
|
||||
notice_restart_required: "⏳ Change requires plugin restart (disable/enable in Community Plugins).",
|
||||
notice_storage_open_fail: (e: string) => `Could not open: ${e}`,
|
||||
|
||||
// Fallback modal
|
||||
fallback_title: "⚠️ Model unavailable",
|
||||
fallback_unavailable: (m: string) => `Model ${m} is unavailable for your OpenAI account.`,
|
||||
fallback_tier1: "Most common cause: GPT-5 models require an API account with minimal spending history (Tier 1, ~$5).",
|
||||
fallback_api_error: "API error: ",
|
||||
fallback_suggest: (m: string) => `I can switch the conversation to ${m} (always works) and retry the message.`,
|
||||
fallback_save_default: (m: string) => ` Set ${m} as default model (you can change this in settings)`,
|
||||
fallback_cancel: "Cancel",
|
||||
fallback_accept: (m: string) => `Switch to ${m} and retry`,
|
||||
|
||||
// Errors
|
||||
err_no_openai_key: "⚠️ Set your OpenAI API key in settings.",
|
||||
err_no_claude_key: "⚠️ Set your Claude API key in settings.",
|
||||
err_empty_response: "Model returned an empty response. Please try again.",
|
||||
err_stream: "Streaming error",
|
||||
err_stream_responses: "Responses API streaming error",
|
||||
|
||||
// Canvas
|
||||
canvas_parse_error: (b: string) => `[Canvas: ${b} — JSON parse error]`,
|
||||
canvas_no_nodes: (b: string) => `[Canvas: ${b} — no nodes]`,
|
||||
canvas_flow_header: "## Flow\n",
|
||||
canvas_content_header: "## Content\n",
|
||||
|
||||
// Export
|
||||
export_role_user: "User",
|
||||
export_role_assistant: "Assistant",
|
||||
export_no_messages: "No messages to export.",
|
||||
export_header: (title: string, model: string, date: string) => `# ${title}\n\n> Model: ${model} | Date: ${date}\n\n---\n\n`,
|
||||
export_user: "**You:**",
|
||||
export_assistant: "**Assistant:**",
|
||||
|
||||
// Provider
|
||||
provider_switched_gpt: "🤖 Switched to GPT",
|
||||
provider_switched_claude:"🟣 Switched to Claude",
|
||||
|
||||
// Commands
|
||||
cmd_open_chat: "Open AI-Vault chat panel",
|
||||
cmd_open_history: "Open chat history",
|
||||
cmd_open_projects: "Open projects",
|
||||
cmd_summarize: "AI-Vault: Summarize current note",
|
||||
|
||||
// Quiz
|
||||
quiz_error: "Error!",
|
||||
quiz_no_question: "(no question text)",
|
||||
quiz_wrong_prefix: "❌ Wrong. Correct answer: ",
|
||||
quiz_correct_prefix: "❌ Correct answer: ",
|
||||
quiz_check_btn: "Check",
|
||||
quiz_eval_error: "Evaluation error. Please try again.",
|
||||
quiz_true_option: "True",
|
||||
quiz_false_option: "False",
|
||||
quiz_fill_placeholder: "Type the missing word…",
|
||||
quiz_open_placeholder: "Type your answer…",
|
||||
quiz_eval_prompt: (q: string, a: string, u: string) => `Evaluate the student's answer. Question: "${q}". Model answer: "${a}". Student's answer: "${u}". Reply ONLY in JSON format: {"correct": true/false, "feedback": "brief explanation max 2 sentences"}`,
|
||||
quiz_instruction: "\n\nYOU ARE IN LEARN MODE. Your only task is to test the user's knowledge through tests and quizzes. When the user provides a topic or asks for a test, ALWAYS reply ONLY in JSON format (no text before or after):\n```json\n{\"title\": \"Test title\", \"questions\": [{\"type\": \"choice\", \"question\": \"Question text\", \"options\": [\"A\",\"B\",\"C\",\"D\"], \"correct\": 0, \"explanation\": \"Explanation\"}]}\n```\nQuestion types: choice (A/B/C/D), truefalse (options: [\"True\",\"False\"], correct: 0 or 1), open (open answer, answer field with model answer), fill (fill in the blank, answer field with answer). Generate 5–10 questions unless specified otherwise.",
|
||||
|
||||
// Code mode prompts
|
||||
code_system_prompt_intro: "You are an expert programmer and software architect. Your answers focus EXCLUSIVELY on coding.\n\n",
|
||||
code_rule_1: "- Always provide complete, ready-to-use code (not fragments)\n",
|
||||
code_rule_2: "- Use best practices and design patterns\n",
|
||||
code_rule_3: "- Explain architectural decisions briefly and concisely\n",
|
||||
code_rule_4: "- If the user did not specify a language, ask or choose the best one\n",
|
||||
code_rule_4_lang: "language\n...",
|
||||
code_rule_5: "- Reply in the user's language (code comments in English)\n\n",
|
||||
code_system_prompt_closing: "Do not answer questions unrelated to programming — politely redirect to the topic of coding.",
|
||||
|
||||
// Storage
|
||||
storage_save_error: (f: string) => `Failed to save ${f}`,
|
||||
default_system_prompt: "You are a helpful assistant integrated with Obsidian. Reply in the user's language. Be concise, specific and helpful.",
|
||||
};
|
||||
|
||||
const pl: TranslationDict = {
|
||||
// Settings
|
||||
settings_title: "AI-Vault — Ustawienia",
|
||||
settings_keys_sync_name: "Synchronizuj klucze API przez Obsidian Sync",
|
||||
settings_keys_sync_desc_on: "⚠️ Klucze API są zapisywane w data.json — synchronizowane przez Obsidian Sync. Wygodne między urządzeniami, ale klucze trafiają na serwery Obsidian.",
|
||||
settings_keys_sync_desc_off: "🔒 Klucze API przechowywane lokalnie (poza vaultem) — Obsidian Sync ich nie synchronizuje. Musisz wpisać klucz raz na każdym nowym urządzeniu.",
|
||||
settings_keys_mobile_note: "ℹ️ Przełącznik niedostępny na mobile — klucze zawsze w data.json.",
|
||||
settings_key_local: "🔒 Przechowywany lokalnie poza vaultem — Obsidian Sync go nie synchronizuje.",
|
||||
settings_key_sync: "☁️ Przechowywany w data.json — synchronizowany przez Obsidian Sync.",
|
||||
settings_openai_title: "🤖 OpenAI",
|
||||
settings_openai_key_name: "Klucz API OpenAI",
|
||||
settings_openai_model_name: "Model OpenAI",
|
||||
settings_thinking_name: "Domyślny tryb myślenia",
|
||||
settings_max_tokens_title: "Maks. tokeny na tryb",
|
||||
settings_max_tokens_fast_name: "⚡ Szybki — maks. tokeny",
|
||||
settings_max_tokens_fast_desc: "Maksymalna liczba tokenów wyjściowych dla trybu Szybki (domyślnie: 4096)",
|
||||
settings_max_tokens_normal_name: "⚖️ Normalny — maks. tokeny",
|
||||
settings_max_tokens_normal_desc: "Maksymalna liczba tokenów wyjściowych dla trybu Normalny (domyślnie: 8192)",
|
||||
settings_max_tokens_think_name: "🧠 Myślący — maks. tokeny",
|
||||
settings_max_tokens_think_desc: "Maksymalna liczba tokenów wyjściowych dla trybu Myślący (domyślnie: 16000). Dla Claude: budżet myślenia = ta wartość; rzeczywiste max_tokens = ta wartość + 8000.",
|
||||
settings_system_prompt_name: "Prompt systemowy",
|
||||
settings_system_prompt_desc: "Globalny prompt. Projekty z własnym promptem nadpisują ten.",
|
||||
settings_system_prompt_reset: "Resetuj do domyślnego",
|
||||
settings_system_prompt_reset_tip: "Przywróć domyślny prompt systemowy dla bieżącego języka",
|
||||
settings_claude_title: "🟣 Claude (Anthropic)",
|
||||
settings_claude_key_name: "Klucz API Claude",
|
||||
settings_claude_model_name: "Model Claude",
|
||||
settings_context_title: "Okno kontekstu",
|
||||
settings_context_name: "Maks. wiadomości w kontekście",
|
||||
settings_context_desc: "Liczba poprzednich wiadomości wysyłanych do modelu. 0 = bez limitu (obecny domyślny).",
|
||||
settings_rag_title: "🗃️ RAG — Przeszukiwanie notatek",
|
||||
settings_rag_enable_name: "Włącz RAG",
|
||||
settings_rag_enable_desc: "Automatyczne wyszukiwanie pasujących notatek do każdej wiadomości.",
|
||||
settings_rag_auto_name: "Auto-indeksowanie przy starcie",
|
||||
settings_rag_auto_desc: "Indeksuj nowe i zmienione notatki gdy otwierasz panel chatu.",
|
||||
settings_rag_reindex_name: "Przeindeksuj vault",
|
||||
settings_rag_reindex_btn: "Indeksuj teraz",
|
||||
settings_rag_indexing: "Indeksowanie…",
|
||||
settings_storage_title: "💾 Lokalny zapis (poza vaultem)",
|
||||
settings_storage_name: "Zapis poza vaultem",
|
||||
settings_storage_desc: "Historia rozmów, projekty i indeks RAG zapisywane w lokalnym folderze obok vaultu — Obsidian Sync ich nie synchronizuje. Działa tylko na desktopie.",
|
||||
settings_storage_path_name: "Ścieżka folderu (opcjonalnie)",
|
||||
settings_storage_path_desc: (p: string) => `Pełna ścieżka systemowa. Pusta = domyślny folder obok vaultu. Domyślnie: ${p}`,
|
||||
settings_storage_migrate_name: "Migruj historię z vaultu",
|
||||
settings_storage_migrate_desc: "Ręcznie przenieś istniejącą historię z folderu pluginu w vaulcie do folderu zewnętrznego. Bezpieczne — pliki są przenoszone, nie kopiowane.",
|
||||
settings_storage_migrate_btn: "Migruj teraz",
|
||||
settings_storage_migrating: "Migrowanie…",
|
||||
settings_storage_open_name: "Otwórz folder w eksploratorze",
|
||||
settings_storage_open_btn: "Otwórz folder",
|
||||
settings_storage_open_desc: "Pokazuje folder z historią w systemowym menedżerze plików.",
|
||||
settings_storage_active: "✅ Aktywny — historia zapisywana POZA vaultem",
|
||||
settings_storage_inactive: "⚠️ Wyłączony — historia zapisywana w vaulcie",
|
||||
settings_storage_inactive_html: "⚠️ <strong>Wyłączony — historia zapisywana w vaulcie</strong><br>Obsidian Sync może synchronizować historię rozmów (zjada limit GB).",
|
||||
settings_storage_mobile: "📱 Wykryto urządzenie mobilne",
|
||||
settings_storage_mobile_desc:"Zewnętrzny zapis poza vaultem działa tylko na desktopie. Na mobile dane są zapisywane w folderze pluginu w vaulcie.",
|
||||
settings_storage_mobile_full:"📱 <strong>Wykryto urządzenie mobilne</strong><br>Zewnętrzny zapis poza vaultem działa tylko na desktopie. Na mobile dane są zapisywane w folderze pluginu w vaulcie.<br><br>💡 <em>Tip:</em> Aby ograniczyć Obsidian Sync, w ustawieniach Sync wyłącz synchronizację plików konfiguracyjnych (<code>.obsidian</code>) lub dodaj folder pluginu do wyjątków.",
|
||||
settings_storage_mobile_na: "(niedostępne na mobile)",
|
||||
settings_keys_local_warning_html: "🔒 <strong>Klucze API są zapisywane lokalnie</strong> w folderze pluginu wewnątrz Twojego vaulta (plain text). Jeśli synchronizujesz vault — klucze też się synchronizują.",
|
||||
settings_lang_name: "Język / Language",
|
||||
settings_lang_desc: "Język interfejsu wtyczki.",
|
||||
|
||||
// Chat view
|
||||
chat_new: "Nowa",
|
||||
chat_history: "Historia",
|
||||
chat_projects: "Projekty",
|
||||
chat_welcome_rag: "RAG aktywny — automatycznie znajdę pasujące notatki.",
|
||||
chat_welcome_hint: "Użyj 📎 Notatki, aby ręcznie wybrać kontekst.",
|
||||
chat_placeholder: "Napisz wiadomość… (Enter = wyślij)",
|
||||
chat_placeholder_claude: "Napisz wiadomość do Claude… (Enter = wyślij)",
|
||||
chat_placeholder_code: "Opisz co chcesz zakodować…",
|
||||
chat_placeholder_learn: "Podaj temat lub napisz 'zrób test'…",
|
||||
chat_notes_btn: "Notatki",
|
||||
chat_notes_title: "Wybierz notatki do kontekstu:",
|
||||
chat_notes_search: "Szukaj notatki lub canvas…",
|
||||
chat_notes_add: "Dodaj do kontekstu",
|
||||
chat_notes_cancel: "Anuluj",
|
||||
chat_notes_added: (n: number) => `📎 Dodano ${n} plików do kontekstu`,
|
||||
chat_notes_more: (n: number) => `…i ${n} więcej. Zawęź wyszukiwanie.`,
|
||||
chat_send: "Wyślij",
|
||||
chat_stop: "Stop",
|
||||
chat_copy: "Kopiuj",
|
||||
chat_copied: "Skopiowano!",
|
||||
chat_interrupted: "⏹ Przerwane przez użytkownika",
|
||||
chat_btn_rag: "RAG",
|
||||
chat_btn_index: "Indeksuj",
|
||||
chat_btn_notes: "Notatki",
|
||||
chat_btn_internet: "Internet",
|
||||
chat_btn_learn: "Nauka",
|
||||
chat_btn_code: "Kodowanie",
|
||||
chat_title_rag: "Włącz/wyłącz auto-RAG",
|
||||
chat_title_index: "Przeindeksuj vault",
|
||||
chat_title_notes: "Wybierz notatki do kontekstu (obok RAG)",
|
||||
chat_title_internet: "Wyszukiwanie internetu",
|
||||
chat_title_learn: "Tryb nauki — model pyta, generuje quizy",
|
||||
chat_title_code: "Tryb kodowania — model jako ekspert programista",
|
||||
chat_section_rag: "🗄️ RAG (kontekst z vault)",
|
||||
chat_model_tooltip: (m: string) => `Aktualny model: ${m}\nKliknij aby zmienić`,
|
||||
chat_model_session_tooltip: (title: string, model: string) => `${title}\nModel: ${model}\nKliknij aby zmienić`,
|
||||
chat_mode_fast: "⚡ Szybki",
|
||||
chat_mode_fast_desc: "Szybka odpowiedź",
|
||||
chat_mode_normal: "⚖️ Normalny",
|
||||
chat_mode_normal_desc: "Zbalansowany (domyślny)",
|
||||
chat_mode_think: "🧠 Myślący",
|
||||
chat_mode_think_desc: "Głęboka analiza",
|
||||
chat_picker_openai: "🤖 Wybierz model OpenAI",
|
||||
chat_picker_claude: "🟣 Wybierz model Claude",
|
||||
chat_regen_tooltip: "Regeneruj ostatnią odpowiedź",
|
||||
chat_export_tooltip: "Eksportuj rozmowę do notatki",
|
||||
model_desc_gpt5: "Reasoning, najlepszy",
|
||||
model_desc_gpt5mini: "Reasoning, szybszy",
|
||||
model_desc_gpt5nano: "Reasoning, najtańszy",
|
||||
model_desc_gpt5search: "Web search wbudowany",
|
||||
model_desc_gpt4o: "Web search ✓",
|
||||
model_desc_gpt4omini: "Web search ✓, tańszy",
|
||||
model_desc_gpt4turbo: "Klasyczny",
|
||||
model_desc_opus: "Najlepszy Claude",
|
||||
model_desc_sonnet: "Polecany",
|
||||
model_desc_haiku: "Szybki, tani",
|
||||
model_gpt5nano_label: "GPT-5 Nano",
|
||||
|
||||
// Web search
|
||||
ws_enabled: (m: string) => `🌐 Internet WŁĄCZONY (${m})`,
|
||||
ws_disabled: "🌐 Internet WYŁĄCZONY",
|
||||
ws_claude_enabled: (m: string) => `🌐 Internet WŁĄCZONY — Claude sam zdecyduje kiedy szukać (${m})`,
|
||||
ws_gpt5search_always: "ℹ️ GPT-5 Search ma web search wbudowany — zawsze aktywny, niezależnie od przełącznika.",
|
||||
ws_unsupported: (m: string) => `⚠️ Model ${m} nie wspiera web search. Wybierz GPT-5 Search, GPT-4o lub GPT-4o Mini.`,
|
||||
ws_searching_label: "Przeszukuję internet…",
|
||||
|
||||
// Modes
|
||||
mode_code_on: (p: string) => `💻 Tryb kodowania WŁĄCZONY — ${p} jako ekspert programista`,
|
||||
mode_code_off: "💻 Tryb kodowania WYŁĄCZONY",
|
||||
mode_learn_on: "🎓 Tryb nauki WŁĄCZONY",
|
||||
mode_learn_off: "🎓 Tryb nauki WYŁĄCZONY",
|
||||
|
||||
// History view
|
||||
history_title: "Historia rozmów",
|
||||
history_btn_new: "+ Nowa",
|
||||
history_empty: "Brak luźnych rozmów",
|
||||
history_empty_hint: "Zacznij nową rozmowę w panelu czatu.",
|
||||
history_delete_confirm: (t: string) => `Usunąć czat "${t}"?`,
|
||||
history_chats_in_projects: "Chaty przypisane do projektów\nznajdziesz w zakładce Projekty",
|
||||
history_delete_chat_confirm: (t: string) => `Usunąć rozmowę "${t}"?`,
|
||||
history_delete_btn: "Usuń",
|
||||
|
||||
// Projects view
|
||||
projects_title: "Projekty",
|
||||
projects_btn_back: "← Historia",
|
||||
projects_btn_new: "+ Nowy",
|
||||
projects_chat_count: (n: number) => `${n} chat${n === 1 ? "" : "ów"}`,
|
||||
projects_bar_label: (name: string, n: number) => `📁 ${name} — ${n} chat${n === 1 ? "" : "ów"} ze wspólnym kontekstem`,
|
||||
projects_new: "Nowy projekt",
|
||||
projects_empty: "Brak projektów",
|
||||
projects_empty_hint: "Utwórz pierwszy projekt aby organizować rozmowy tematycznie.",
|
||||
projects_empty_hint_long: "Utwórz projekt, aby grupować powiązane chaty ze wspólnym kontekstem.",
|
||||
projects_delete_confirm: (n: string) => `Usunąć projekt "${n}" i wszystkie jego rozmowy?`,
|
||||
projects_delete_with_count: (n: string, c: number) => `Usunąć projekt "${n}"${c ? ` i odłączyć ${c} rozmów` : ""}?`,
|
||||
projects_created: (n: string) => `📁 Projekt "${n}" utworzony`,
|
||||
projects_updated: (n: string) => `📁 Projekt "${n}" zaktualizowany`,
|
||||
projects_chat_delete_confirm:(t: string) => `Usunąć czat "${t}"?`,
|
||||
projects_active: "Aktywny projekt:",
|
||||
projects_no_chats: "Brak rozmów",
|
||||
projects_leave_btn: "Opuść",
|
||||
projects_own_prompt_btn: "Własny prompt",
|
||||
projects_more_chats: (n: number) => `…i ${n} więcej`,
|
||||
projects_prompt_label: "Prompt projektu (niezależny od globalnego)",
|
||||
projects_prompt_placeholder: 'Np. „Jesteś ekspertem od marketingu. Wszystkie odpowiedzi dotyczą strategii naszej marki X…"',
|
||||
projects_prompt_hint: "Gdy ustawisz ten prompt, ZASTĄPI on globalny prompt systemowy. Jeśli zostawisz puste — użyty będzie domyślny prompt.",
|
||||
projects_create_btn: "Utwórz",
|
||||
projects_custom_prompt_badge:"· ✏️ własny prompt",
|
||||
|
||||
// RAG
|
||||
rag_ready_short: (f: number, e: number) => `🗄️ RAG gotowy — ${f} notatek (${e} embeddingów)`,
|
||||
rag_ready_full: (f: number, e: number) => `✅ RAG gotowy — ${f} notatek, ${e} embeddingów`,
|
||||
rag_on_notice: "🗄️ RAG WŁĄCZONY",
|
||||
rag_off_notice: "🗄️ RAG WYŁĄCZONY",
|
||||
rag_manual_ctx_header: "NOTATKI WYBRANE PRZEZ UŻYTKOWNIKA (+ powiązane przez [[linki]]):",
|
||||
rag_project_ctx_header: (n: string) => `KONTEKST Z PROJEKTU "${n}" (inne rozmowy w tym projekcie — znasz ich treść):`,
|
||||
rag_ctx_truncated: "[…kontekst obcięty ze względu na limit]",
|
||||
rag_sources_label: "Źródła:",
|
||||
rag_indexed: "✅ gotowy",
|
||||
rag_not_indexed: "⏳ nieindeksowany",
|
||||
rag_status: (indexed: string, files: number, chunks: number, embs: number) =>
|
||||
`<strong>Status:</strong> ${indexed} | <strong>Notatki:</strong> ${files} | <strong>Chunki:</strong> ${chunks} | <strong>Embeddingi:</strong> ${embs}`,
|
||||
rag_done: (n: number) => `✅ RAG: ${n} notatek`,
|
||||
|
||||
// Tokens / cost
|
||||
tokens_label: (n: number) => `~${n} tokenów`,
|
||||
tokens_session_cost: (v: string) => `\nSesja łącznie: ${v}`,
|
||||
|
||||
// Notices
|
||||
notice_model_changed: (m: string) => `✓ Model: ${m}`,
|
||||
notice_keys_moved_local: "🔒 Klucze API przeniesione poza vault.",
|
||||
notice_keys_moved_sync: "☁️ Klucze API będą synchronizowane przez Obsidian Sync.",
|
||||
notice_keys_migrated: "🔑 Klucze API przeniesione poza vault.",
|
||||
notice_fallback_switched: (m: string) => `✅ Przełączono na ${m}. Ponawiam wiadomość…`,
|
||||
notice_fallback_return: (m: string) => `↩ Powrót do ${m}`,
|
||||
notice_migration_done: (n: number, p: string) => `✅ AI-Vault: Historia rozmów przeniesiona poza vault (${n} plików).\nLokalizacja: ${p}`,
|
||||
notice_migration_partial: (n: number) => `⚠️ AI-Vault: Migracja częściowo nieudana — ${n} błędów. Sprawdź konsolę.`,
|
||||
notice_migration_partial_short: (m: number, e: number) => `⚠️ Przeniesiono ${m}, błędów: ${e}. Konsola: F12.`,
|
||||
notice_migrated_manual: (n: number, s: number) => `✅ Przeniesiono ${n} plików (pominięto ${s}).`,
|
||||
notice_migration_failed: (e: string) => `❌ Migracja nieudana: ${e}`,
|
||||
notice_export_done: (f: string) => `📝 Wyeksportowano do: ${f}`,
|
||||
notice_export_fail: (e: string) => `❌ Błąd eksportu: ${e}`,
|
||||
notice_manual_notes: (n: number) => `📎 Dodano ${n} notatek do kontekstu`,
|
||||
notice_restart_required: "⏳ Zmiana wymaga restartu pluginu (wyłącz/włącz w Community Plugins).",
|
||||
notice_storage_open_fail: (e: string) => `Nie udało się otworzyć: ${e}`,
|
||||
|
||||
// Fallback modal
|
||||
fallback_title: "⚠️ Model niedostępny",
|
||||
fallback_unavailable: (m: string) => `Model ${m} jest niedostępny dla Twojego konta OpenAI.`,
|
||||
fallback_tier1: "Najczęstsza przyczyna: modele GPT-5 wymagają konta API z minimalnymi wydatkami (Tier 1, ok. $5 historii płatności).",
|
||||
fallback_api_error: "Błąd API: ",
|
||||
fallback_suggest: (m: string) => `Mogę przełączyć rozmowę na ${m} (działa zawsze) i ponowić wiadomość.`,
|
||||
fallback_save_default: (m: string) => ` Ustaw ${m} jako domyślny model (możesz zmienić w ustawieniach)`,
|
||||
fallback_cancel: "Anuluj",
|
||||
fallback_accept: (m: string) => `Przełącz na ${m} i ponów`,
|
||||
|
||||
// Errors
|
||||
err_no_openai_key: "⚠️ Ustaw klucz API OpenAI w ustawieniach.",
|
||||
err_no_claude_key: "⚠️ Ustaw klucz API Claude w ustawieniach.",
|
||||
err_empty_response: "Model zwrócił pustą odpowiedź. Spróbuj ponownie.",
|
||||
err_stream: "Błąd streamingu",
|
||||
err_stream_responses: "Błąd streamingu Responses API",
|
||||
|
||||
// Canvas
|
||||
canvas_parse_error: (b: string) => `[Canvas: ${b} — błąd parsowania JSON]`,
|
||||
canvas_no_nodes: (b: string) => `[Canvas: ${b} — brak węzłów]`,
|
||||
canvas_flow_header: "## Przepływ\n",
|
||||
canvas_content_header: "## Zawartość\n",
|
||||
|
||||
// Export
|
||||
export_role_user: "Użytkownik",
|
||||
export_role_assistant: "Asystent",
|
||||
export_no_messages: "Brak wiadomości do eksportu.",
|
||||
export_header: (title: string, model: string, date: string) => `# ${title}\n\n> Model: ${model} | Data: ${date}\n\n---\n\n`,
|
||||
export_user: "**Ty:**",
|
||||
export_assistant: "**Asystent:**",
|
||||
|
||||
// Provider
|
||||
provider_switched_gpt: "🤖 Przełączono na GPT",
|
||||
provider_switched_claude:"🟣 Przełączono na Claude",
|
||||
|
||||
// Commands
|
||||
cmd_open_chat: "Otwórz panel czatu AI-Vault",
|
||||
cmd_open_history: "Otwórz historię rozmów",
|
||||
cmd_open_projects: "Otwórz projekty",
|
||||
cmd_summarize: "AI-Vault: Podsumuj aktualną notatkę",
|
||||
|
||||
// Quiz
|
||||
quiz_error: "Błąd!",
|
||||
quiz_no_question: "(brak treści pytania)",
|
||||
quiz_wrong_prefix: "❌ Źle. Poprawna odpowiedź: ",
|
||||
quiz_correct_prefix: "❌ Poprawna odpowiedź: ",
|
||||
quiz_check_btn: "Sprawdź",
|
||||
quiz_eval_error: "Błąd oceniania. Spróbuj ponownie.",
|
||||
quiz_true_option: "Prawda",
|
||||
quiz_false_option: "Fałsz",
|
||||
quiz_fill_placeholder: "Wpisz brakujące słowo…",
|
||||
quiz_open_placeholder: "Wpisz odpowiedź…",
|
||||
quiz_eval_prompt: (q: string, a: string, u: string) => `Oceń odpowiedź ucznia. Pytanie: "${q}". Wzorcowa odpowiedź: "${a}". Odpowiedź ucznia: "${u}". Odpowiedz TYLKO w formacie JSON: {"correct": true/false, "feedback": "krótkie wyjaśnienie max 2 zdania"}`,
|
||||
quiz_instruction: "\n\nJESTEŚ W TRYBIE NAUKI. Twoim jedynym zadaniem jest sprawdzanie wiedzy użytkownika przez testy i quizy. Gdy użytkownik poda temat lub poprosi o test, ZAWSZE zwróć odpowiedź WYŁĄCZNIE w formacie JSON (bez żadnego tekstu przed ani po):\n```json\n{\"title\": \"Tytuł testu\", \"questions\": [{\"type\": \"choice\", \"question\": \"Treść pytania\", \"options\": [\"A\",\"B\",\"C\",\"D\"], \"correct\": 0, \"explanation\": \"Wyjaśnienie\"}]}\n```\nTypy pytań: choice (A/B/C/D), truefalse (options: [\"Prawda\",\"Fałsz\"], correct: 0 lub 1), open (odpowiedź otwarta, pole answer z wzorcową odpowiedzią), fill (uzupełnij lukę, pole answer z odpowiedzią). Generuj 5–10 pytań jeśli nie podano inaczej.",
|
||||
|
||||
// Code mode prompts
|
||||
code_system_prompt_intro: "Jesteś ekspertem programistą i architektem oprogramowania. Twoje odpowiedzi skupiają się WYŁĄCZNIE na kodowaniu.\n\n",
|
||||
code_rule_1: "- Zawsze podawaj pełny, gotowy do użycia kod (nie fragmenty)\n",
|
||||
code_rule_2: "- Używaj najlepszych praktyk i wzorców projektowych\n",
|
||||
code_rule_3: "- Wyjaśniaj decyzje architektoniczne krótko i konkretnie\n",
|
||||
code_rule_4: "- Jeśli użytkownik nie podał języka, zapytaj lub dobierz najlepszy\n",
|
||||
code_rule_4_lang: "język\n...",
|
||||
code_rule_5: "- Odpowiadaj w języku polskim (komentarze w kodzie po angielsku)\n\n",
|
||||
code_system_prompt_closing: "Nie odpowiadaj na pytania niezwiązane z programowaniem — grzecznie przekieruj na temat kodowania.",
|
||||
|
||||
// Storage
|
||||
storage_save_error: (f: string) => `Nie udało się zapisać ${f}`,
|
||||
default_system_prompt: "Jesteś pomocnym asystentem zintegrowanym z Obsidian. Odpowiadaj w języku użytkownika. Bądź zwięzły, konkretny i pomocny.",
|
||||
};
|
||||
|
||||
// ─── Runtime ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let _lang: "en" | "pl" = "en";
|
||||
|
||||
/**
|
||||
* Translation function.
|
||||
* Usage: t("chat_new") or t("chat_notes_added", 3)
|
||||
*/
|
||||
export function t(key: string, ...args: unknown[]): string {
|
||||
const dict = _lang === "pl" ? pl : en;
|
||||
const val = dict[key] ?? en[key] ?? key;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return typeof val === "function" ? (val as (...a: any[]) => string)(...args) : (val as string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the active language and updates the default system prompt if it is still the default.
|
||||
*/
|
||||
export function setLanguage(lang: string, plugin?: { settings: { systemPrompt: string } }): void {
|
||||
_lang = lang === "pl" ? "pl" : "en";
|
||||
|
||||
if (plugin) {
|
||||
const allDefaults = [en.default_system_prompt as string, pl.default_system_prompt as string];
|
||||
const current = plugin.settings.systemPrompt || "";
|
||||
if (!current || allDefaults.includes(current)) {
|
||||
plugin.settings.systemPrompt = t("default_system_prompt");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentLang(): "en" | "pl" {
|
||||
return _lang;
|
||||
}
|
||||
398
src/main.ts
Normal file
398
src/main.ts
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
import { Notice, Plugin, TFile } from "obsidian";
|
||||
|
||||
import { t, setLanguage } from "./i18n";
|
||||
import { DEFAULT_SETTINGS } from "./settings";
|
||||
import { CHAT_VIEW_TYPE, HISTORY_VIEW_TYPE, PROJECTS_VIEW_TYPE, FILE_API_KEYS, RAG_INDEX_KEY, HISTORY_KEY } from "./constants";
|
||||
import { PluginStorage } from "./storage/PluginStorage";
|
||||
import { ExternalStorage } from "./storage/ExternalStorage";
|
||||
import { HistoryManager } from "./history/HistoryManager";
|
||||
import { ProjectManager } from "./history/ProjectManager";
|
||||
import { RAGEngine } from "./rag/RAGEngine";
|
||||
import { GPTChatView } from "./views/ChatView";
|
||||
import { GPTHistoryView } from "./views/HistoryView";
|
||||
import { GPTProjectsView } from "./views/ProjectsView";
|
||||
import { GPTSettingsTab } from "./SettingsTab";
|
||||
import { debounce } from "./utils";
|
||||
import type { PluginSettings } from "./settings";
|
||||
import type { ChatMessage } from "./types";
|
||||
|
||||
export default class GPTPlugin extends Plugin {
|
||||
settings!: PluginSettings;
|
||||
storage!: PluginStorage;
|
||||
externalStorage!: ExternalStorage;
|
||||
rag!: RAGEngine;
|
||||
history!: HistoryManager;
|
||||
projects!: ProjectManager;
|
||||
|
||||
currentSessionId: string | null = null;
|
||||
currentSession: import("./types").ChatSession | null = null;
|
||||
activeProjectId: string | null = null;
|
||||
|
||||
private debouncedUpdateFile!: ReturnType<typeof debounce<[TFile]>>;
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
async onload(): Promise<void> {
|
||||
// Storage — vault adapter (mobile + desktop, always available)
|
||||
this.storage = new PluginStorage(this);
|
||||
await this.loadSettings();
|
||||
setLanguage(this.settings.language ?? "en", this);
|
||||
|
||||
// External storage — outside the vault (desktop only, bypasses Obsidian Sync)
|
||||
this.externalStorage = new ExternalStorage(this, this.storage);
|
||||
const externalActive = await this.externalStorage.init();
|
||||
|
||||
// API keys — from keys.json outside the vault, migrated from old data.json
|
||||
await this._loadApiKeys();
|
||||
|
||||
// Auto-migrate history when external storage has just been enabled
|
||||
if (externalActive) await this._maybeAutoMigrate();
|
||||
|
||||
// Data managers
|
||||
this.rag = new RAGEngine(this);
|
||||
this.history = new HistoryManager(this);
|
||||
this.projects = new ProjectManager(this);
|
||||
await this.history.load();
|
||||
await this.projects.load();
|
||||
|
||||
// Debounced RAG update — max once per 3s per file
|
||||
this.debouncedUpdateFile = debounce((file: TFile) => {
|
||||
void this.rag.updateFile(file);
|
||||
}, 3000);
|
||||
|
||||
// Startup RAG load + auto-index (without opening chat view)
|
||||
if (this.settings.ragEnabled && this.settings.ragAutoIndex) {
|
||||
void (async () => {
|
||||
const loaded = await this.rag.loadIndex();
|
||||
if (!loaded && !this.rag.indexing) await this.rag.buildIndex();
|
||||
})();
|
||||
}
|
||||
|
||||
// Views
|
||||
this.registerView(CHAT_VIEW_TYPE, leaf => new GPTChatView(leaf, this));
|
||||
this.registerView(HISTORY_VIEW_TYPE, leaf => new GPTHistoryView(leaf, this));
|
||||
this.registerView(PROJECTS_VIEW_TYPE, leaf => new GPTProjectsView(leaf, this));
|
||||
|
||||
// Ribbon
|
||||
this.addRibbonIcon("message-square", "AI-Vault Chat", () => void this.activateChatView());
|
||||
this.addRibbonIcon("clock", "AI-Vault History", () => void this.activateHistoryView());
|
||||
this.addRibbonIcon("folder-open", "AI-Vault Projects", () => void this.activateProjectsView());
|
||||
|
||||
// Commands
|
||||
this.addCommand({ id: "open-gpt-chat", name: t("cmd_open_chat"), callback: () => void this.activateChatView() });
|
||||
this.addCommand({ id: "open-gpt-history", name: t("cmd_open_history"), callback: () => void this.activateHistoryView() });
|
||||
this.addCommand({ id: "open-gpt-projects", name: t("cmd_open_projects"),callback: () => void this.activateProjectsView() });
|
||||
this.addCommand({ id: "new-gpt-chat", name: "AI-Vault: New conversation", callback: () => this.newChat() });
|
||||
|
||||
this.addCommand({
|
||||
id: "analyze-selection",
|
||||
name: "AI-Vault: Analyze selected text",
|
||||
editorCallback: async editor => {
|
||||
const sel = editor.getSelection();
|
||||
if (!sel) { new Notice("Select text first."); return; }
|
||||
const view = await this.activateChatView();
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
void view?.sendMessage(`Analyze:\n\n${sel}`);
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "summarize-note",
|
||||
name: t("cmd_summarize"),
|
||||
editorCallback: async editor => {
|
||||
const c = editor.getValue();
|
||||
if (!c.trim()) { new Notice("Note is empty."); return; }
|
||||
const view = await this.activateChatView();
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
void view?.sendMessage(`Summarize in 5 points:\n\n${c.slice(0, 8000)}`);
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "reindex-vault",
|
||||
name: "AI-Vault: Re-index vault (RAG)",
|
||||
callback: async () => {
|
||||
new Notice("⏳ Indexing…");
|
||||
await this.rag.buildIndex();
|
||||
const s = this.rag.stats;
|
||||
new Notice(`✅ RAG: ${s.files} notes`);
|
||||
},
|
||||
});
|
||||
|
||||
// Vault events — .md and .canvas
|
||||
this.registerEvent(this.app.vault.on("modify", (file) => {
|
||||
if (file instanceof TFile && (file.extension === "md" || file.extension === "canvas")) {
|
||||
this.debouncedUpdateFile(file);
|
||||
}
|
||||
}));
|
||||
this.registerEvent(this.app.vault.on("delete", (file) => {
|
||||
if (file instanceof TFile && (file.extension === "md" || file.extension === "canvas")) {
|
||||
this.rag.removeFile(file.path);
|
||||
}
|
||||
}));
|
||||
this.registerEvent(this.app.vault.on("rename", (file, old) => {
|
||||
if (file instanceof TFile && (file.extension === "md" || file.extension === "canvas")) {
|
||||
this.rag.renameFile(old, file.path, file.basename);
|
||||
}
|
||||
}));
|
||||
|
||||
// Editor context menu
|
||||
this.registerEvent(this.app.workspace.on("editor-menu", (menu, editor) => {
|
||||
const sel = editor.getSelection();
|
||||
if (!sel) return;
|
||||
menu.addItem(i => i.setTitle("✦ AI-Vault: Analyze").setIcon("message-square").onClick(async () => {
|
||||
const v = await this.activateChatView();
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
void v?.sendMessage(`Analyze:\n\n${sel}`);
|
||||
}));
|
||||
menu.addItem(i => i.setTitle("✦ AI-Vault: Summarize").setIcon("list").onClick(async () => {
|
||||
const v = await this.activateChatView();
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
void v?.sendMessage(`Summarize in 3 points:\n\n${sel}`);
|
||||
}));
|
||||
}));
|
||||
|
||||
this.addSettingTab(new GPTSettingsTab(this.app, this));
|
||||
}
|
||||
|
||||
async onunload(): Promise<void> {
|
||||
this.debouncedUpdateFile?.cancel();
|
||||
try { await this.rag?.saveIndexNow(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Sessions ──────────────────────────────────────────────────────────────
|
||||
|
||||
setActiveProject(projectId: string | null): void {
|
||||
this.activeProjectId = projectId;
|
||||
this.getChatView()?.updateProjectBar();
|
||||
this.getProjectsView()?.render();
|
||||
}
|
||||
|
||||
newChat(): void {
|
||||
this.currentSession = this.history.newSession(this.activeProjectId);
|
||||
this.currentSessionId = this.currentSession.id;
|
||||
this.getChatView()?.clearAndNew();
|
||||
this.getChatView()?.updateProjectBar();
|
||||
this.getHistoryView()?.render();
|
||||
this.getProjectsView()?.render();
|
||||
}
|
||||
|
||||
async loadSession(id: string): Promise<void> {
|
||||
const session = await this.history.getFullSession(id);
|
||||
if (!session) return;
|
||||
this.currentSession = session;
|
||||
this.currentSessionId = id;
|
||||
if (session.projectId) this.activeProjectId = session.projectId;
|
||||
const chatView = await this.activateChatView();
|
||||
chatView?.loadSession(session);
|
||||
chatView?.updateProjectBar();
|
||||
this.getHistoryView()?.render();
|
||||
this.getProjectsView()?.render();
|
||||
}
|
||||
|
||||
async autoSaveSession(messages: ChatMessage[]): Promise<void> {
|
||||
if (!this.currentSession) {
|
||||
this.currentSession = this.history.newSession(this.activeProjectId);
|
||||
this.currentSessionId = this.currentSession.id;
|
||||
}
|
||||
if (this.activeProjectId && !this.currentSession.projectId) {
|
||||
(this.currentSession as { projectId?: string | null }).projectId = this.activeProjectId;
|
||||
}
|
||||
|
||||
const session = this.currentSession as {
|
||||
id: string; title: string; projectId: string | null;
|
||||
messages: ChatMessage[]; createdAt: number; updatedAt: number;
|
||||
};
|
||||
session.messages = messages;
|
||||
session.updatedAt = Date.now();
|
||||
|
||||
// Auto-title from the first user message
|
||||
if (messages.length >= 1 && session.title === "New conversation") {
|
||||
const first = messages.find(m => m.role === "user");
|
||||
if (first) {
|
||||
session.title = first.content.slice(0, 50) + (first.content.length > 50 ? "…" : "");
|
||||
}
|
||||
}
|
||||
|
||||
await this.history.saveSession(session);
|
||||
this.getHistoryView()?.render();
|
||||
this.getProjectsView()?.render();
|
||||
}
|
||||
|
||||
// ── Views ──────────────────────────────────────────────────────────────────
|
||||
|
||||
getChatView(): GPTChatView | null {
|
||||
return this.app.workspace.getLeavesOfType(CHAT_VIEW_TYPE)
|
||||
.map(l => l.view).find((v): v is GPTChatView => v instanceof GPTChatView) ?? null;
|
||||
}
|
||||
|
||||
getHistoryView(): GPTHistoryView | null {
|
||||
return this.app.workspace.getLeavesOfType(HISTORY_VIEW_TYPE)
|
||||
.map(l => l.view).find((v): v is GPTHistoryView => v instanceof GPTHistoryView) ?? null;
|
||||
}
|
||||
|
||||
getProjectsView(): GPTProjectsView | null {
|
||||
return this.app.workspace.getLeavesOfType(PROJECTS_VIEW_TYPE)
|
||||
.map(l => l.view).find((v): v is GPTProjectsView => v instanceof GPTProjectsView) ?? null;
|
||||
}
|
||||
|
||||
async activateChatView(): Promise<GPTChatView | null> {
|
||||
const { workspace } = this.app;
|
||||
let leaf = workspace.getLeavesOfType(CHAT_VIEW_TYPE)[0];
|
||||
if (!leaf) {
|
||||
leaf = workspace.getRightLeaf(false) ?? workspace.getLeaf(true);
|
||||
await leaf.setViewState({ type: CHAT_VIEW_TYPE, active: true });
|
||||
}
|
||||
workspace.revealLeaf(leaf);
|
||||
return this.getChatView();
|
||||
}
|
||||
|
||||
async activateHistoryView(): Promise<void> {
|
||||
const { workspace } = this.app;
|
||||
let leaf = workspace.getLeavesOfType(HISTORY_VIEW_TYPE)[0];
|
||||
if (!leaf) {
|
||||
const projLeaf = workspace.getLeavesOfType(PROJECTS_VIEW_TYPE)[0];
|
||||
if (projLeaf) { await projLeaf.setViewState({ type: HISTORY_VIEW_TYPE, active: true }); workspace.revealLeaf(projLeaf); return; }
|
||||
const chatLeaf = workspace.getLeavesOfType(CHAT_VIEW_TYPE)[0];
|
||||
leaf = chatLeaf ? workspace.createLeafBySplit(chatLeaf, "vertical") : (workspace.getLeftLeaf(false) ?? workspace.getLeaf(true));
|
||||
await leaf.setViewState({ type: HISTORY_VIEW_TYPE, active: true });
|
||||
}
|
||||
workspace.revealLeaf(leaf);
|
||||
}
|
||||
|
||||
async activateProjectsView(): Promise<void> {
|
||||
const { workspace } = this.app;
|
||||
let leaf = workspace.getLeavesOfType(PROJECTS_VIEW_TYPE)[0];
|
||||
if (!leaf) {
|
||||
const histLeaf = workspace.getLeavesOfType(HISTORY_VIEW_TYPE)[0];
|
||||
if (histLeaf) { await histLeaf.setViewState({ type: PROJECTS_VIEW_TYPE, active: true }); workspace.revealLeaf(histLeaf); return; }
|
||||
const chatLeaf = workspace.getLeavesOfType(CHAT_VIEW_TYPE)[0];
|
||||
leaf = chatLeaf ? workspace.createLeafBySplit(chatLeaf, "vertical") : (workspace.getLeftLeaf(false) ?? workspace.getLeaf(true));
|
||||
await leaf.setViewState({ type: PROJECTS_VIEW_TYPE, active: true });
|
||||
}
|
||||
workspace.revealLeaf(leaf);
|
||||
}
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────────────
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
const d = await this.loadData() as Record<string, unknown> | null;
|
||||
|
||||
// Clean up legacy keys that previously ended up in data.json
|
||||
if (d && (d[RAG_INDEX_KEY] || d[HISTORY_KEY])) {
|
||||
delete d[RAG_INDEX_KEY];
|
||||
delete d[HISTORY_KEY];
|
||||
await this.saveData(d);
|
||||
}
|
||||
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, d) as PluginSettings;
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
const toSave = { ...this.settings } as unknown as Record<string, unknown>;
|
||||
delete toSave[RAG_INDEX_KEY];
|
||||
delete toSave[HISTORY_KEY];
|
||||
|
||||
if (this.settings.apiKeysInSync || !this.externalStorage.isEnabled) {
|
||||
await this.saveData(toSave);
|
||||
} else {
|
||||
// Keys go to keys.json outside the vault, the rest to data.json
|
||||
const keysPath = this.externalStorage.resolve(FILE_API_KEYS);
|
||||
await this.externalStorage.writeJson(keysPath, {
|
||||
apiKey: this.settings.apiKey ?? "",
|
||||
claudeApiKey: this.settings.claudeApiKey ?? "",
|
||||
});
|
||||
delete toSave.apiKey;
|
||||
delete toSave.claudeApiKey;
|
||||
await this.saveData(toSave);
|
||||
}
|
||||
}
|
||||
|
||||
// ── API Keys ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async _loadApiKeys(): Promise<void> {
|
||||
if (this.settings.apiKeysInSync) {
|
||||
// Sync mode — keys live in data.json, remove any stale keys.json
|
||||
if (this.externalStorage.isEnabled) {
|
||||
const keysPath = this.externalStorage.resolve(FILE_API_KEYS);
|
||||
if (await this.externalStorage.exists(keysPath)) {
|
||||
await this.externalStorage.remove(keysPath);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Local mode — keys live in keys.json outside the vault
|
||||
if (!this.externalStorage.isEnabled) return; // mobile fallback
|
||||
|
||||
const keysPath = this.externalStorage.resolve(FILE_API_KEYS);
|
||||
|
||||
// If keys.json does not exist there is nothing to load. Keep whatever
|
||||
// keys the user has in memory/data.json (the migration branch below
|
||||
// will still run for legacy data.json keys).
|
||||
if (!(await this.externalStorage.exists(keysPath))) {
|
||||
const hasOld = this.settings.apiKey || this.settings.claudeApiKey;
|
||||
if (hasOld) {
|
||||
// Migration: data.json → keys.json
|
||||
await this.externalStorage.writeJson(keysPath, {
|
||||
apiKey: this.settings.apiKey ?? "",
|
||||
claudeApiKey: this.settings.claudeApiKey ?? "",
|
||||
});
|
||||
delete (this.settings as unknown as Record<string, unknown>).apiKey;
|
||||
delete (this.settings as unknown as Record<string, unknown>).claudeApiKey;
|
||||
await this.saveData({ ...this.settings });
|
||||
this.settings.apiKey = this.settings.apiKey ?? "";
|
||||
this.settings.claudeApiKey = this.settings.claudeApiKey ?? "";
|
||||
new Notice(t("notice_keys_migrated"), 4000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// keys.json exists — read it. Only overwrite settings on a successful
|
||||
// read; on a transient read failure preserve the in-memory values to
|
||||
// avoid wiping the user's keys.
|
||||
const stored = await this.externalStorage.readJson<{ apiKey?: string; claudeApiKey?: string } | null>(keysPath, null);
|
||||
if (!stored) {
|
||||
console.warn("[AI-Vault] _loadApiKeys: keys.json unreadable, preserving in-memory keys");
|
||||
return;
|
||||
}
|
||||
|
||||
this.settings.apiKey = stored.apiKey ?? this.settings.apiKey ?? "";
|
||||
this.settings.claudeApiKey = stored.claudeApiKey ?? this.settings.claudeApiKey ?? "";
|
||||
}
|
||||
|
||||
// ── Auto-migration ─────────────────────────────────────────────────────────
|
||||
|
||||
private async _maybeAutoMigrate(): Promise<void> {
|
||||
if (this.settings._externalMigrationDone) return;
|
||||
|
||||
const vaultHistoryIdx = this.storage.resolve("history-index.json");
|
||||
const vaultHistoryDir = this.storage.resolve("history");
|
||||
const vaultProjects = this.storage.resolve("projects.json");
|
||||
|
||||
const hasOldData =
|
||||
(await this.storage.exists(vaultHistoryIdx)) ||
|
||||
(await this.storage.exists(vaultHistoryDir)) ||
|
||||
(await this.storage.exists(vaultProjects));
|
||||
|
||||
if (!hasOldData) {
|
||||
this.settings._externalMigrationDone = true;
|
||||
await this.saveSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.externalStorage.migrateFromVault();
|
||||
|
||||
if (result.errors.length === 0) {
|
||||
this.settings._externalMigrationDone = true;
|
||||
await this.saveSettings();
|
||||
new Notice(
|
||||
t("notice_migration_done", result.moved, this.externalStorage.baseDir ?? ""),
|
||||
8000,
|
||||
);
|
||||
} else {
|
||||
console.error("[AI-Vault] Migration errors:", result.errors);
|
||||
new Notice(t("notice_migration_partial", result.errors.length), 8000);
|
||||
}
|
||||
}
|
||||
}
|
||||
132
src/models.ts
Normal file
132
src/models.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// ─── Model sets ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Models that support built-in web search */
|
||||
export const WEB_SEARCH_CAPABLE = new Set<string>([
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-5",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-nano",
|
||||
"gpt-5-search-api",
|
||||
]);
|
||||
|
||||
/** GPT-5 family models (reasoning) — require max_completion_tokens + reasoning_effort */
|
||||
export const GPT5_MODELS = new Set<string>([
|
||||
"gpt-5",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-nano",
|
||||
]);
|
||||
|
||||
/** GPT-5 variant with built-in web search (non-reasoning) */
|
||||
export const GPT5_SEARCH_API = "gpt-5-search-api";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function isGPT5(model: string): boolean {
|
||||
return GPT5_MODELS.has(model);
|
||||
}
|
||||
|
||||
export function isGPT5Search(model: string): boolean {
|
||||
return model === GPT5_SEARCH_API;
|
||||
}
|
||||
|
||||
/** Maps internal thinking modes → reasoning_effort for GPT-5 */
|
||||
export function mapEffortForGPT5(mode: string): string {
|
||||
switch (mode) {
|
||||
case "fast": return "minimal";
|
||||
case "normal": return "medium";
|
||||
case "think": return "high";
|
||||
default: return "medium";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pricing (USD per 1M tokens) ──────────────────────────────────────────────
|
||||
|
||||
interface ModelPrice {
|
||||
input: number;
|
||||
output: number;
|
||||
}
|
||||
|
||||
export const MODEL_PRICING: Record<string, ModelPrice> = {
|
||||
// OpenAI
|
||||
"gpt-5": { input: 1.25, output: 10.00 },
|
||||
"gpt-5-mini": { input: 0.25, output: 2.00 },
|
||||
"gpt-5-nano": { input: 0.05, output: 0.40 },
|
||||
"gpt-5-search-api": { input: 1.25, output: 10.00 },
|
||||
"gpt-4o": { input: 2.50, output: 10.00 },
|
||||
"gpt-4o-mini": { input: 0.15, output: 0.60 },
|
||||
"gpt-4-turbo": { input: 10.00, output: 30.00 },
|
||||
// Anthropic
|
||||
"claude-opus-4-5": { input: 15.00, output: 75.00 },
|
||||
"claude-sonnet-4-5": { input: 3.00, output: 15.00 },
|
||||
"claude-haiku-4-5": { input: 0.80, output: 4.00 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Estimates the request cost in USD.
|
||||
* @returns formatted string (e.g. "$0.0042") or null when no pricing is available.
|
||||
*/
|
||||
export function estimateCost(model: string, inputTokens: number, outputTokens: number): string | null {
|
||||
const p = MODEL_PRICING[model];
|
||||
if (!p) return null;
|
||||
const cost = (inputTokens * p.input + outputTokens * p.output) / 1_000_000;
|
||||
if (cost < 0.0001) return "<$0.0001";
|
||||
if (cost < 0.01) return `$${cost.toFixed(4)}`;
|
||||
return `$${cost.toFixed(3)}`;
|
||||
}
|
||||
|
||||
// ─── Thinking modes ───────────────────────────────────────────────────────────
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface ThinkingModeConfig {
|
||||
readonly label: string;
|
||||
readonly desc: string;
|
||||
readonly tokens: number;
|
||||
readonly effort: string;
|
||||
}
|
||||
|
||||
/** Lazy getters — label and desc resolved from the active language at runtime */
|
||||
export const THINKING_MODES: Record<string, ThinkingModeConfig> = {
|
||||
fast: {
|
||||
get label() { return t("chat_mode_fast"); },
|
||||
get desc() { return t("chat_mode_fast_desc"); },
|
||||
tokens: 4096,
|
||||
effort: "low",
|
||||
},
|
||||
normal: {
|
||||
get label() { return t("chat_mode_normal"); },
|
||||
get desc() { return t("chat_mode_normal_desc"); },
|
||||
tokens: 8192,
|
||||
effort: "medium",
|
||||
},
|
||||
think: {
|
||||
get label() { return t("chat_mode_think"); },
|
||||
get desc() { return t("chat_mode_think_desc"); },
|
||||
tokens: 16000,
|
||||
effort: "high",
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Custom error ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface ModelAccessErrorOptions {
|
||||
model?: string;
|
||||
status?: number;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/** Thrown when the model is unavailable for the account (403/404) */
|
||||
export class ModelAccessError extends Error {
|
||||
readonly model?: string;
|
||||
readonly status?: number;
|
||||
readonly code?: string;
|
||||
|
||||
constructor(message: string, { model, status, code }: ModelAccessErrorOptions = {}) {
|
||||
super(message);
|
||||
this.name = "ModelAccessError";
|
||||
this.model = model;
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
480
src/rag/RAGEngine.ts
Normal file
480
src/rag/RAGEngine.ts
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
import { requestUrl } from "obsidian";
|
||||
import type { TFile } from "obsidian";
|
||||
import { RAG_TOP_K } from "../constants";
|
||||
import {
|
||||
tokenize, buildTermFreq, chunkText,
|
||||
bm25Score, cosineSim, vectorNorm,
|
||||
contentHash, withRetry,
|
||||
} from "../utils";
|
||||
import { parseCanvasToText } from "./canvasParser";
|
||||
import type { ExternalStorage } from "../storage/ExternalStorage";
|
||||
import type { RAGEntry, RAGIndex, RAGSearchResult } from "../types";
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const BATCH_SIZE = 20;
|
||||
const SAVE_DELAY_MS = 5000;
|
||||
const FILE_RAG_INDEX = "rag-index.json";
|
||||
|
||||
interface PluginWithDeps {
|
||||
app: import("obsidian").App;
|
||||
externalStorage: ExternalStorage;
|
||||
settings: {
|
||||
apiKey: string;
|
||||
ragAutoIndex: boolean;
|
||||
ragSearchMode: "hybrid" | "semantic" | "exact" | "recent";
|
||||
};
|
||||
}
|
||||
|
||||
interface RAGStats {
|
||||
files: number;
|
||||
chunks: number;
|
||||
embeddings: number;
|
||||
}
|
||||
|
||||
interface PendingChunk {
|
||||
entry: RAGEntry;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RAG (Retrieval-Augmented Generation) engine.
|
||||
*
|
||||
* Search algorithm: BM25 (keyword) + cosine similarity (embeddings),
|
||||
* combined via Reciprocal Rank Fusion — scale-invariant, no weight tuning required.
|
||||
*
|
||||
* Improvements over the original:
|
||||
* - tokenizer handles Polish characters (Unicode \p{L})
|
||||
* - PL + EN stopwords
|
||||
* - chunking by H1/H2 headers (not just by paragraphs)
|
||||
* - RRF instead of a linear combination with arbitrary weights
|
||||
* - note-title boost in the result
|
||||
* - versioned index (_version: 2) with migration from the older format
|
||||
*/
|
||||
export class RAGEngine {
|
||||
private index: RAGEntry[] = [];
|
||||
private fileHashes: Record<string, string> = {};
|
||||
private cachedAvgLen = 0;
|
||||
private saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
indexed = false;
|
||||
indexing = false;
|
||||
|
||||
private readonly storage: ExternalStorage;
|
||||
|
||||
constructor(private readonly plugin: PluginWithDeps) {
|
||||
this.storage = plugin.externalStorage;
|
||||
}
|
||||
|
||||
// ── Getters ────────────────────────────────────────────────────────────────
|
||||
|
||||
private get apiKey(): string { return this.plugin.settings.apiKey; }
|
||||
private get indexPath(): string { return this.storage.resolve(FILE_RAG_INDEX); }
|
||||
|
||||
get stats(): RAGStats {
|
||||
return {
|
||||
files: new Set(this.index.map(e => e.path)).size,
|
||||
chunks: this.index.length,
|
||||
embeddings: this.index.filter(e => e.embedding).length,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Index — load / save ────────────────────────────────────────────────────
|
||||
|
||||
async loadIndex(): Promise<boolean> {
|
||||
const data = await this.storage.readJson<RAGIndex | RAGEntry[] | null>(
|
||||
this.indexPath,
|
||||
null,
|
||||
);
|
||||
|
||||
// v2 format
|
||||
if (
|
||||
data &&
|
||||
!Array.isArray(data) &&
|
||||
(data as RAGIndex)._version === 2 &&
|
||||
Array.isArray((data as RAGIndex).entries)
|
||||
) {
|
||||
const idx = data as RAGIndex;
|
||||
this.index = idx.entries;
|
||||
this.fileHashes = idx.hashes ?? {};
|
||||
this.indexed = true;
|
||||
this.recalcAvgLen();
|
||||
for (const e of this.index) this.ensureEntryCache(e);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Old format (migration) — flat array
|
||||
if (Array.isArray(data) && data.length) {
|
||||
this.index = data as RAGEntry[];
|
||||
this.fileHashes = {};
|
||||
this.indexed = true;
|
||||
this.recalcAvgLen();
|
||||
for (const e of this.index) this.ensureEntryCache(e);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
scheduleSave(): void {
|
||||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||
this.saveTimer = setTimeout(() => {
|
||||
this.saveTimer = null;
|
||||
void this.saveIndex();
|
||||
}, SAVE_DELAY_MS);
|
||||
}
|
||||
|
||||
async saveIndexNow(): Promise<void> {
|
||||
if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; }
|
||||
await this.saveIndex();
|
||||
}
|
||||
|
||||
private async saveIndex(): Promise<void> {
|
||||
// Strip cache fields (_ prefix) before saving — rebuilt during loadIndex()
|
||||
const cleanEntries = this.index.map(e => ({
|
||||
path: e.path,
|
||||
basename: e.basename,
|
||||
extension: e.extension,
|
||||
folder: e.folder,
|
||||
mtime: e.mtime,
|
||||
chunk: e.chunk,
|
||||
tokens: e.tokens,
|
||||
embedding: e.embedding,
|
||||
}));
|
||||
|
||||
await this.storage.writeJson(this.indexPath, {
|
||||
_version: 2,
|
||||
entries: cleanEntries,
|
||||
hashes: this.fileHashes,
|
||||
} satisfies RAGIndex);
|
||||
}
|
||||
|
||||
// ── Embeddings (OpenAI) ────────────────────────────────────────────────────
|
||||
|
||||
private async getEmbedding(text: string): Promise<number[]> {
|
||||
return withRetry(async () => {
|
||||
const r = await requestUrl({
|
||||
url: "https://api.openai.com/v1/embeddings",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ model: "text-embedding-3-small", input: text.slice(0, 8000) }),
|
||||
throw: false,
|
||||
});
|
||||
if (r.status !== 200) throw new Error(`Embedding error ${r.status}`);
|
||||
return (r.json as { data: { embedding: number[] }[] }).data[0].embedding;
|
||||
});
|
||||
}
|
||||
|
||||
private async getEmbeddingsBatch(texts: string[]): Promise<number[][]> {
|
||||
if (!texts.length) return [];
|
||||
return withRetry(async () => {
|
||||
const r = await requestUrl({
|
||||
url: "https://api.openai.com/v1/embeddings",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "text-embedding-3-small",
|
||||
input: texts.map(t => t.slice(0, 8000)),
|
||||
}),
|
||||
throw: false,
|
||||
});
|
||||
if (r.status !== 200) throw new Error(`Embedding error ${r.status}`);
|
||||
return (r.json as { data: { embedding: number[] }[] }).data.map(d => d.embedding);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Budowanie indeksu ──────────────────────────────────────────────────────
|
||||
|
||||
async buildIndex(onProgress?: (done: number, total: number) => void): Promise<void> {
|
||||
if (this.indexing) return;
|
||||
this.indexing = true;
|
||||
|
||||
try {
|
||||
const mdFiles = this.plugin.app.vault.getMarkdownFiles();
|
||||
const canvasFiles = this.plugin.app.vault.getFiles()
|
||||
.filter((f: TFile) => f.extension === "canvas");
|
||||
const files = [...mdFiles, ...canvasFiles];
|
||||
const currentPaths = new Set(files.map((f: TFile) => f.path));
|
||||
|
||||
// Remove entries for files that no longer exist
|
||||
const removedPaths = Object.keys(this.fileHashes).filter(p => !currentPaths.has(p));
|
||||
if (removedPaths.length) {
|
||||
const removedSet = new Set(removedPaths);
|
||||
this.index = this.index.filter(e => !removedSet.has(e.path));
|
||||
for (const p of removedPaths) delete this.fileHashes[p];
|
||||
}
|
||||
|
||||
const newHashes: Record<string, string> = {};
|
||||
let pendingChunks: PendingChunk[] = [];
|
||||
let done = 0, skipped = 0, reindexed = 0;
|
||||
|
||||
const flushEmbeddings = async (): Promise<void> => {
|
||||
if (!pendingChunks.length || !this.apiKey) { pendingChunks = []; return; }
|
||||
try {
|
||||
const embeddings = await this.getEmbeddingsBatch(pendingChunks.map(c => c.text));
|
||||
for (let i = 0; i < embeddings.length; i++) {
|
||||
pendingChunks[i].entry.embedding = embeddings[i];
|
||||
pendingChunks[i].entry._embNorm = vectorNorm(embeddings[i]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[GPT RAG] batch embedding failed:", (e as Error)?.message);
|
||||
}
|
||||
pendingChunks = [];
|
||||
};
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const raw = await this.plugin.app.vault.cachedRead(file as TFile);
|
||||
const content = (file as TFile).extension === "canvas"
|
||||
? parseCanvasToText(raw, (file as TFile).basename)
|
||||
: raw;
|
||||
const hash = contentHash(content);
|
||||
newHashes[(file as TFile).path] = hash;
|
||||
|
||||
// Skip files that have not changed
|
||||
if (this.fileHashes[(file as TFile).path] === hash) {
|
||||
skipped++;
|
||||
done++;
|
||||
onProgress?.(done, files.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.index = this.index.filter(e => e.path !== (file as TFile).path);
|
||||
if (!content.trim()) { done++; continue; }
|
||||
reindexed++;
|
||||
|
||||
for (const chunk of chunkText(content)) {
|
||||
const tokens = tokenize(chunk);
|
||||
const entry: RAGEntry = {
|
||||
path: (file as TFile).path,
|
||||
basename: (file as TFile).basename,
|
||||
chunk,
|
||||
tokens,
|
||||
embedding: null,
|
||||
_tf: buildTermFreq(tokens),
|
||||
};
|
||||
this.index.push(entry);
|
||||
|
||||
if (this.apiKey) {
|
||||
pendingChunks.push({ entry, text: chunk });
|
||||
if (pendingChunks.length >= BATCH_SIZE) await flushEmbeddings();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[GPT RAG] file failed:", (file as TFile).path, (e as Error)?.message);
|
||||
}
|
||||
|
||||
done++;
|
||||
onProgress?.(done, files.length);
|
||||
}
|
||||
|
||||
await flushEmbeddings();
|
||||
|
||||
this.fileHashes = newHashes;
|
||||
this.recalcAvgLen();
|
||||
await this.saveIndexNow();
|
||||
this.indexed = true;
|
||||
|
||||
console.info(
|
||||
`[GPT RAG] Incremental: ${reindexed} reindexed,`,
|
||||
`${skipped} skipped, ${removedPaths.length} removed`,
|
||||
);
|
||||
} finally {
|
||||
this.indexing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search (BM25 + cosine → RRF) ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Finds the chunks that best match the query.
|
||||
* Algorithm: BM25 + cosine similarity combined via Reciprocal Rank Fusion.
|
||||
* RRF is scale-invariant — no manual weight tuning needed.
|
||||
*/
|
||||
async search(query: string, topK = RAG_TOP_K): Promise<RAGSearchResult[]> {
|
||||
if (!this.index.length) return [];
|
||||
|
||||
const qt = tokenize(query);
|
||||
if (!qt.length) return [];
|
||||
|
||||
const avgLen = this.cachedAvgLen;
|
||||
const mode = this.plugin.settings.ragSearchMode ?? "hybrid";
|
||||
const useEmbedding = mode !== "exact";
|
||||
const useLexical = mode !== "semantic";
|
||||
|
||||
|
||||
// Optional query embedding
|
||||
let qEmb: number[] | null = null;
|
||||
let qNorm = 0;
|
||||
|
||||
if (useEmbedding && this.apiKey && this.index.some(e => e.embedding)) {
|
||||
try {
|
||||
qEmb = await this.getEmbedding(query);
|
||||
qNorm = vectorNorm(qEmb);
|
||||
} catch (e) {
|
||||
console.warn("[GPT RAG] query embedding failed:", (e as Error)?.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute both scores for each chunk
|
||||
const scored = this.index.map(e => {
|
||||
this.ensureEntryCache(e);
|
||||
const bm = useLexical ? bm25Score(qt, e._tf ?? {}, e.tokens.length, avgLen) : 0;
|
||||
const cos = (useEmbedding && qEmb && e.embedding)
|
||||
? cosineSim(qEmb, e.embedding, qNorm, e._embNorm ?? undefined)
|
||||
: 0;
|
||||
return { entry: e, bm, cos };
|
||||
});
|
||||
|
||||
// Reciprocal Rank Fusion — independent of result scale
|
||||
const K = 60;
|
||||
const rankBM = [...scored].sort((a, b) => b.bm - a.bm)
|
||||
.map((s, i) => [s.entry.path + s.entry.chunk, i] as const);
|
||||
const rankCos = [...scored].sort((a, b) => b.cos - a.cos)
|
||||
.map((s, i) => [s.entry.path + s.entry.chunk, i] as const);
|
||||
|
||||
const rrfMap = new Map<string, number>();
|
||||
for (const [id, rank] of rankBM) rrfMap.set(id, (rrfMap.get(id) ?? 0) + 1 / (K + rank));
|
||||
for (const [id, rank] of rankCos) rrfMap.set(id, (rrfMap.get(id) ?? 0) + 1 / (K + rank));
|
||||
|
||||
// Bonus for matching the note title
|
||||
const byFile: Record<string, RAGSearchResult> = {};
|
||||
for (const s of scored) {
|
||||
const id = s.entry.path + s.entry.chunk;
|
||||
let score = rrfMap.get(id) ?? 0;
|
||||
|
||||
// Title boost — a note whose name matches the query ranks higher
|
||||
const titleTokens = tokenize(s.entry.basename);
|
||||
const titleMatches = qt.filter(q => titleTokens.includes(q)).length;
|
||||
if (titleMatches > 0) score += 0.15 * (titleMatches / qt.length);
|
||||
|
||||
if (mode === "recent" && s.entry.mtime) {
|
||||
const ageDays = Math.max(0, (Date.now() - s.entry.mtime) / 86_400_000);
|
||||
score += Math.max(0, 0.2 - Math.min(0.2, ageDays / 365));
|
||||
}
|
||||
|
||||
if (!byFile[s.entry.path] || score > byFile[s.entry.path].score) {
|
||||
byFile[s.entry.path] = {
|
||||
path: s.entry.path,
|
||||
basename: s.entry.basename,
|
||||
chunk: s.entry.chunk,
|
||||
score,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(byFile)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, topK);
|
||||
}
|
||||
|
||||
// ── Incremental updates ────────────────────────────────────────────────────
|
||||
|
||||
async updateFile(file: TFile): Promise<void> {
|
||||
try {
|
||||
const raw = await this.plugin.app.vault.cachedRead(file);
|
||||
const content = file.extension === "canvas"
|
||||
? parseCanvasToText(raw, file.basename)
|
||||
: raw;
|
||||
const hash = contentHash(content);
|
||||
|
||||
if (this.fileHashes[file.path] === hash) return;
|
||||
|
||||
this.index = this.index.filter(e => e.path !== file.path);
|
||||
this.fileHashes[file.path] = hash;
|
||||
|
||||
if (!content.trim()) {
|
||||
this.recalcAvgLen();
|
||||
this.scheduleSave();
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = chunkText(content);
|
||||
const newEntries: RAGEntry[] = chunks.map(chunk => {
|
||||
const tokens = tokenize(chunk);
|
||||
return {
|
||||
path: file.path,
|
||||
basename: file.basename,
|
||||
extension: file.extension,
|
||||
folder: file.parent?.path ?? "",
|
||||
mtime: file.stat?.mtime ?? Date.now(),
|
||||
chunk,
|
||||
tokens,
|
||||
embedding: null,
|
||||
_tf: buildTermFreq(tokens),
|
||||
};
|
||||
});
|
||||
|
||||
// Batch embeddings (instead of sequential requests)
|
||||
if (this.apiKey && newEntries.length) {
|
||||
try {
|
||||
const embeddings = await this.getEmbeddingsBatch(newEntries.map(e => e.chunk));
|
||||
for (let i = 0; i < embeddings.length; i++) {
|
||||
newEntries[i].embedding = embeddings[i];
|
||||
newEntries[i]._embNorm = vectorNorm(embeddings[i]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[GPT RAG] updateFile embedding failed:", (e as Error)?.message);
|
||||
}
|
||||
}
|
||||
|
||||
this.index.push(...newEntries);
|
||||
this.recalcAvgLen();
|
||||
this.scheduleSave();
|
||||
} catch (e) {
|
||||
console.warn("[GPT RAG] updateFile error:", file?.path, (e as Error)?.message);
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(filePath: string): void {
|
||||
const before = this.index.length;
|
||||
this.index = this.index.filter(e => e.path !== filePath);
|
||||
if (this.index.length === before && !this.fileHashes[filePath]) return;
|
||||
|
||||
delete this.fileHashes[filePath];
|
||||
this.recalcAvgLen();
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
renameFile(oldPath: string, newPath: string, basename: string): void {
|
||||
let changed = false;
|
||||
|
||||
for (const e of this.index) {
|
||||
if (e.path === oldPath) {
|
||||
e.path = newPath;
|
||||
e.basename = basename;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.fileHashes[oldPath]) {
|
||||
this.fileHashes[newPath] = this.fileHashes[oldPath];
|
||||
delete this.fileHashes[oldPath];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) this.scheduleSave();
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
private recalcAvgLen(): void {
|
||||
this.cachedAvgLen = this.index.length
|
||||
? this.index.reduce((s, e) => s + e.tokens.length, 0) / this.index.length
|
||||
: 0;
|
||||
}
|
||||
|
||||
/** Ensures the entry has cache populated: TF + embeddingNorm */
|
||||
private ensureEntryCache(entry: RAGEntry): void {
|
||||
if (!entry._tf) entry._tf = buildTermFreq(entry.tokens);
|
||||
if (entry.embedding && entry._embNorm == null) {
|
||||
entry._embNorm = vectorNorm(entry.embedding);
|
||||
}
|
||||
}
|
||||
}
|
||||
158
src/rag/canvasParser.ts
Normal file
158
src/rag/canvasParser.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { t } from "../i18n";
|
||||
|
||||
// ─── Typy Obsidian Canvas ──────────────────────────────────────────────────────
|
||||
|
||||
interface CanvasNode {
|
||||
id: string;
|
||||
type: "text" | "file" | "link" | "group";
|
||||
x?: number;
|
||||
y?: number;
|
||||
text?: string;
|
||||
file?: string;
|
||||
url?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface CanvasEdge {
|
||||
id?: string;
|
||||
fromNode: string;
|
||||
toNode: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface CanvasData {
|
||||
nodes?: CanvasNode[];
|
||||
edges?: CanvasEdge[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a .canvas file (JSON) into readable text for RAG.
|
||||
*
|
||||
* Obsidian Canvas = JSON with nodes[] and edges[].
|
||||
* Node types: text, file, link, group.
|
||||
* The parser reconstructs the step order via edges (topological BFS)
|
||||
* and builds a readable document with the flow and node contents.
|
||||
*/
|
||||
export function parseCanvasToText(raw: string, basename: string): string {
|
||||
let data: CanvasData;
|
||||
try {
|
||||
data = JSON.parse(raw) as CanvasData;
|
||||
} catch {
|
||||
return t("canvas_parse_error", basename);
|
||||
}
|
||||
|
||||
const nodes: CanvasNode[] = Array.isArray(data.nodes) ? data.nodes : [];
|
||||
const edges: CanvasEdge[] = Array.isArray(data.edges) ? data.edges : [];
|
||||
|
||||
if (!nodes.length) return t("canvas_no_nodes", basename);
|
||||
|
||||
// Map id → node
|
||||
const nodeMap = new Map<string, CanvasNode>(nodes.map(n => [n.id, n]));
|
||||
|
||||
// Build the graph: successors and predecessors
|
||||
const successors = new Map<string, Set<string>>();
|
||||
const predecessors = new Map<string, Set<string>>();
|
||||
for (const n of nodes) {
|
||||
successors.set(n.id, new Set());
|
||||
predecessors.set(n.id, new Set());
|
||||
}
|
||||
for (const e of edges) {
|
||||
if (e.fromNode && e.toNode) {
|
||||
successors.get(e.fromNode)?.add(e.toNode);
|
||||
predecessors.get(e.toNode)?.add(e.fromNode);
|
||||
}
|
||||
}
|
||||
|
||||
// Starting nodes (no predecessors) — sort by X position (left→right)
|
||||
const starts = nodes
|
||||
.filter(n => (predecessors.get(n.id)?.size ?? 0) === 0)
|
||||
.sort((a, b) => (a.x ?? 0) - (b.x ?? 0) || (a.y ?? 0) - (b.y ?? 0));
|
||||
|
||||
// BFS / topological graph traversal
|
||||
const visited = new Set<string>();
|
||||
const ordered: CanvasNode[] = [];
|
||||
|
||||
const traverse = (id: string): void => {
|
||||
if (visited.has(id)) return;
|
||||
visited.add(id);
|
||||
|
||||
const node = nodeMap.get(id);
|
||||
if (node) ordered.push(node);
|
||||
|
||||
// Sort successors by Y (top→bottom) then X
|
||||
const nexts = [...(successors.get(id) ?? [])]
|
||||
.map(sid => nodeMap.get(sid))
|
||||
.filter((n): n is CanvasNode => n !== undefined)
|
||||
.sort((a, b) => (a.y ?? 0) - (b.y ?? 0) || (a.x ?? 0) - (b.x ?? 0));
|
||||
|
||||
for (const next of nexts) traverse(next.id);
|
||||
};
|
||||
|
||||
for (const s of starts) traverse(s.id);
|
||||
|
||||
// Append isolated nodes (no edges), sorted by X
|
||||
nodes
|
||||
.filter(n => !visited.has(n.id))
|
||||
.sort((a, b) => (a.x ?? 0) - (b.x ?? 0))
|
||||
.forEach(n => ordered.push(n));
|
||||
|
||||
// Build the text
|
||||
const lines: string[] = [`# Canvas: ${basename}\n`];
|
||||
|
||||
// Flow section (only when there are edges)
|
||||
if (edges.length) {
|
||||
const flowParts: string[] = [];
|
||||
for (const e of edges) {
|
||||
const from = nodeMap.get(e.fromNode);
|
||||
const to = nodeMap.get(e.toNode);
|
||||
if (!from || !to) continue;
|
||||
|
||||
const edgeLabel = e.label ? ` —[${e.label}]→ ` : " → ";
|
||||
flowParts.push(`${nodeLabel(from)}${edgeLabel}${nodeLabel(to)}`);
|
||||
}
|
||||
if (flowParts.length) {
|
||||
lines.push(t("canvas_flow_header"));
|
||||
lines.push(flowParts.join("\n") + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Section with node contents
|
||||
lines.push(t("canvas_content_header"));
|
||||
for (const n of ordered) {
|
||||
switch (n.type) {
|
||||
case "text":
|
||||
if (n.text?.trim()) {
|
||||
lines.push(`### ${nodeLabel(n)}`);
|
||||
lines.push(n.text.trim() + "\n");
|
||||
}
|
||||
break;
|
||||
case "file":
|
||||
lines.push(`### Plik: ${n.file || nodeLabel(n)}\n`);
|
||||
break;
|
||||
case "link":
|
||||
lines.push(`### Link: ${n.url || nodeLabel(n)}\n`);
|
||||
break;
|
||||
case "group":
|
||||
if (n.label?.trim()) {
|
||||
lines.push(`## Grupa: ${n.label.trim()}\n`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Returns a short node label (used in the flow description) */
|
||||
function nodeLabel(n: CanvasNode): string {
|
||||
if (n.label) return n.label;
|
||||
if (n.type === "text" && n.text) {
|
||||
const header = n.text.match(/^#+\s*(.+)/m);
|
||||
return header
|
||||
? header[1].trim()
|
||||
: n.text.slice(0, 40).replace(/\n/g, " ").trim();
|
||||
}
|
||||
if (n.type === "file") return n.file?.split("/").pop() ?? n.id;
|
||||
if (n.type === "link") return n.url ?? n.id;
|
||||
return n.id;
|
||||
}
|
||||
4
src/rag/index.ts
Normal file
4
src/rag/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { RAGEngine } from "./RAGEngine";
|
||||
export { parseCanvasToText } from "./canvasParser";
|
||||
export { resolveNoteWithLinks } from "./linkResolver";
|
||||
export type { ResolvedNote } from "./linkResolver";
|
||||
60
src/rag/linkResolver.ts
Normal file
60
src/rag/linkResolver.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import type { App, TFile } from "obsidian";
|
||||
|
||||
export interface ResolvedNote {
|
||||
file: TFile;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively follows [[wiki-links]] in a note.
|
||||
* Returns an array of {file, content} for the main note and linked notes (up to `depth`).
|
||||
*/
|
||||
export async function resolveNoteWithLinks(
|
||||
app: App,
|
||||
file: TFile,
|
||||
depth = 1,
|
||||
visited = new Set<string>(),
|
||||
fileMap?: Map<string, TFile>,
|
||||
): Promise<ResolvedNote[]> {
|
||||
if (visited.has(file.path)) return [];
|
||||
visited.add(file.path);
|
||||
|
||||
// Build the map once — basename → TFile, path → TFile
|
||||
if (!fileMap) {
|
||||
fileMap = new Map<string, TFile>();
|
||||
for (const f of app.vault.getMarkdownFiles()) {
|
||||
fileMap.set(f.basename, f);
|
||||
fileMap.set(f.path, f);
|
||||
fileMap.set(f.path.replace(/\.md$/, ""), f);
|
||||
}
|
||||
}
|
||||
|
||||
const results: ResolvedNote[] = [];
|
||||
|
||||
try {
|
||||
const content = await app.vault.cachedRead(file);
|
||||
results.push({ file, content });
|
||||
|
||||
if (depth <= 0) return results;
|
||||
|
||||
// Find [[links]] and [[alias|links]]
|
||||
const linkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = linkRegex.exec(content)) !== null) {
|
||||
const linkName = match[1].trim();
|
||||
const linked =
|
||||
fileMap.get(linkName) ??
|
||||
fileMap.get(linkName + ".md");
|
||||
|
||||
if (linked && !visited.has(linked.path)) {
|
||||
const sub = await resolveNoteWithLinks(app, linked, depth - 1, visited, fileMap);
|
||||
results.push(...sub);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[AI-Vault] resolveNoteWithLinks failed:", file?.path, (e as Error)?.message);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
76
src/settings.ts
Normal file
76
src/settings.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ThinkingMode = "fast" | "normal" | "think";
|
||||
export type Provider = "openai" | "anthropic";
|
||||
export type Language = "en" | "pl";
|
||||
|
||||
export type RAGSearchMode = "hybrid" | "semantic" | "exact" | "recent";
|
||||
|
||||
export interface PluginSettings {
|
||||
// API Keys
|
||||
apiKey: string;
|
||||
claudeApiKey: string;
|
||||
apiKeysInSync: boolean;
|
||||
|
||||
// Models
|
||||
provider: Provider;
|
||||
model: string;
|
||||
claudeModel: string;
|
||||
thinkingMode: ThinkingMode;
|
||||
|
||||
// Max tokens per thinking mode
|
||||
maxTokensFast: number;
|
||||
maxTokensNormal: number;
|
||||
maxTokensThink: number;
|
||||
|
||||
// Prompts
|
||||
systemPrompt: string;
|
||||
|
||||
// RAG
|
||||
ragEnabled: boolean;
|
||||
ragAutoIndex: boolean;
|
||||
ragSearchMode: RAGSearchMode;
|
||||
|
||||
// External storage
|
||||
externalStorageEnabled: boolean;
|
||||
externalStoragePath: string;
|
||||
|
||||
// Context window
|
||||
maxContextMessages: number;
|
||||
|
||||
// UI
|
||||
language: Language;
|
||||
|
||||
// Internal flags
|
||||
_externalMigrationDone?: boolean;
|
||||
}
|
||||
|
||||
// ─── Default system prompts ───────────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_SYSTEM_PROMPTS: Record<Language, string> = {
|
||||
en: "You are a helpful assistant integrated with Obsidian. Reply in the user's language. Be concise, specific and helpful.",
|
||||
pl: "Jesteś pomocnym asystentem zintegrowanym z Obsidian. Odpowiadaj w języku użytkownika. Bądź zwięzły, konkretny i pomocny.",
|
||||
};
|
||||
|
||||
// ─── Default settings ─────────────────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_SETTINGS: PluginSettings = {
|
||||
apiKey: "",
|
||||
claudeApiKey: "",
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
claudeModel: "claude-sonnet-4-5",
|
||||
thinkingMode: "normal",
|
||||
maxTokensFast: 4096,
|
||||
maxTokensNormal: 8192,
|
||||
maxTokensThink: 16000,
|
||||
systemPrompt: DEFAULT_SYSTEM_PROMPTS.en,
|
||||
ragEnabled: true,
|
||||
ragAutoIndex: true,
|
||||
ragSearchMode: "hybrid",
|
||||
externalStorageEnabled: true,
|
||||
externalStoragePath: "",
|
||||
apiKeysInSync: false,
|
||||
maxContextMessages: 0,
|
||||
language: "en",
|
||||
};
|
||||
312
src/storage/ExternalStorage.ts
Normal file
312
src/storage/ExternalStorage.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import type { Plugin } from "obsidian";
|
||||
import { t } from "../i18n";
|
||||
import {
|
||||
DIR_HISTORY,
|
||||
FILE_HISTORY_INDEX,
|
||||
FILE_PROJECTS,
|
||||
FILE_RAG_INDEX,
|
||||
} from "../constants";
|
||||
import type { PluginStorage, ListResult } from "./PluginStorage";
|
||||
|
||||
// Node.js types — available only on desktop
|
||||
type FSPromises = typeof import("fs/promises");
|
||||
type NodePath = typeof import("path");
|
||||
|
||||
interface MigrateResult {
|
||||
moved: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage that writes data to a local system directory NEXT TO the vault folder.
|
||||
* Obsidian Sync does NOT synchronize these files.
|
||||
*
|
||||
* Works only on DESKTOP (requires Node.js fs/path).
|
||||
* On mobile it automatically delegates to PluginStorage (vault adapter).
|
||||
*
|
||||
* Default path: {parent_of_vault}/{vault_name}-gpt-data
|
||||
* Can be overridden via settings.externalStoragePath.
|
||||
*/
|
||||
export class ExternalStorage {
|
||||
private _fs: FSPromises | null = null;
|
||||
private _path: NodePath | null = null;
|
||||
private _desktop = false;
|
||||
private _baseDir: string | null = null;
|
||||
private _enabled = false;
|
||||
|
||||
constructor(
|
||||
private readonly plugin: Plugin,
|
||||
private readonly fallback: PluginStorage,
|
||||
) {
|
||||
// Detect desktop — Obsidian on desktop exposes Node via require()
|
||||
try {
|
||||
if (
|
||||
typeof process !== "undefined" &&
|
||||
process.versions &&
|
||||
process.versions.node
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
this._fs = require("fs/promises") as FSPromises;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
this._path = require("path") as NodePath;
|
||||
this._desktop = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"[AI-Vault] Node fs unavailable — external storage disabled (mobile?)",
|
||||
(e as Error)?.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Getters ────────────────────────────────────────────────────────────────
|
||||
|
||||
get isDesktop(): boolean { return this._desktop; }
|
||||
get isEnabled(): boolean { return this._enabled && this._desktop; }
|
||||
get baseDir(): string | null { return this._baseDir; }
|
||||
|
||||
// ── Initialization ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initializes the storage.
|
||||
* @returns true if external storage is active, false = we use the fallback
|
||||
*/
|
||||
async init(): Promise<boolean> {
|
||||
if (!this._desktop) {
|
||||
this._enabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cast to any to read settings because the plugin is generic here
|
||||
const settings = (this.plugin as unknown as { settings: { externalStorageEnabled: boolean } }).settings;
|
||||
if (!settings.externalStorageEnabled) {
|
||||
this._enabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this._baseDir = this._resolveBaseDir();
|
||||
await this._fs!.mkdir(this._baseDir, { recursive: true });
|
||||
this._enabled = true;
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("[AI-Vault] Failed to init external storage:", e);
|
||||
this._enabled = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Paths ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Computes the default path: {parent_of_vault}/{vaultName}-gpt-data
|
||||
* Or uses settings.externalStoragePath if set.
|
||||
*/
|
||||
private _resolveBaseDir(): string {
|
||||
const settings = (this.plugin as unknown as {
|
||||
settings: { externalStoragePath: string };
|
||||
}).settings;
|
||||
|
||||
const custom = (settings.externalStoragePath || "").trim();
|
||||
if (custom) return this._path!.resolve(custom);
|
||||
|
||||
// vault.adapter.basePath exists on desktop
|
||||
const adapter = this.plugin.app.vault.adapter as unknown as {
|
||||
basePath?: string;
|
||||
getBasePath?: () => string;
|
||||
};
|
||||
const vaultPath = adapter.basePath ?? adapter.getBasePath?.();
|
||||
if (!vaultPath) throw new Error("Cannot determine vault path");
|
||||
|
||||
const parent = this._path!.dirname(vaultPath);
|
||||
const vaultName = this._path!.basename(vaultPath);
|
||||
return this._path!.join(parent, `${vaultName}-gpt-data`);
|
||||
}
|
||||
|
||||
/** Returns the default path or an empty string when unavailable */
|
||||
getDefaultPath(): string {
|
||||
try { return this._resolveBaseDir(); }
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
/** Resolves a path relative to baseDir — falls back to PluginStorage when disabled */
|
||||
resolve(...parts: string[]): string {
|
||||
if (this.isEnabled) return this._path!.join(this._baseDir!, ...parts);
|
||||
return this.fallback.resolve(...parts);
|
||||
}
|
||||
|
||||
// ── CRUD ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async ensureDir(dirPath: string): Promise<void> {
|
||||
if (this.isEnabled) {
|
||||
try { await this._fs!.mkdir(dirPath, { recursive: true }); }
|
||||
catch (e) { console.warn("[AI-Vault] ensureDir failed:", dirPath, (e as Error)?.message); }
|
||||
return;
|
||||
}
|
||||
return this.fallback.ensureDir(dirPath);
|
||||
}
|
||||
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
if (this.isEnabled) {
|
||||
try { await this._fs!.access(filePath); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
return this.fallback.exists(filePath);
|
||||
}
|
||||
|
||||
async readJson<T>(filePath: string, fallback: T): Promise<T> {
|
||||
if (this.isEnabled) {
|
||||
try {
|
||||
const raw = await this._fs!.readFile(filePath, "utf-8");
|
||||
return JSON.parse(raw) as T;
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err.code !== "ENOENT") {
|
||||
console.warn("[AI-Vault] readJson failed:", filePath, err?.message);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
return this.fallback.readJson(filePath, fallback);
|
||||
}
|
||||
|
||||
async writeJson(filePath: string, data: unknown): Promise<boolean> {
|
||||
if (this.isEnabled) {
|
||||
try {
|
||||
const dir = this._path!.dirname(filePath);
|
||||
await this._fs!.mkdir(dir, { recursive: true });
|
||||
|
||||
// Atomic write: temp → rename (guards against corrupted JSON)
|
||||
const tmp = `${filePath}.tmp`;
|
||||
await this._fs!.writeFile(tmp, JSON.stringify(data), "utf-8");
|
||||
await this._fs!.rename(tmp, filePath);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("[AI-Vault] writeJson failed:", filePath, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return this.fallback.writeJson(filePath, data);
|
||||
}
|
||||
|
||||
async remove(filePath: string): Promise<void> {
|
||||
if (this.isEnabled) {
|
||||
try { await this._fs!.unlink(filePath); }
|
||||
catch (e) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err.code !== "ENOENT") {
|
||||
console.warn("[AI-Vault] remove failed:", filePath, err?.message);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
return this.fallback.remove(filePath);
|
||||
}
|
||||
|
||||
async list(dirPath: string): Promise<ListResult> {
|
||||
if (this.isEnabled) {
|
||||
try {
|
||||
const entries = await this._fs!.readdir(dirPath, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
const folders: string[] = [];
|
||||
|
||||
for (const ent of entries) {
|
||||
const full = this._path!.join(dirPath, ent.name);
|
||||
if (ent.isDirectory()) folders.push(full);
|
||||
else files.push(full);
|
||||
}
|
||||
|
||||
return { files, folders };
|
||||
} catch {
|
||||
return { files: [], folders: [] };
|
||||
}
|
||||
}
|
||||
return this.fallback.list(dirPath);
|
||||
}
|
||||
|
||||
// ── Migration ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Migrates data from the vault (PluginStorage) → external folder.
|
||||
* Moves: history-index.json, history/, projects.json, rag-index.json
|
||||
* On success it removes the files from the vault.
|
||||
*/
|
||||
async migrateFromVault(): Promise<MigrateResult> {
|
||||
const result: MigrateResult = { moved: 0, skipped: 0, errors: [] };
|
||||
|
||||
if (!this.isEnabled) {
|
||||
result.errors.push("External storage nieaktywny");
|
||||
return result;
|
||||
}
|
||||
|
||||
const vault = this.fallback;
|
||||
const singleFiles = [FILE_HISTORY_INDEX, FILE_PROJECTS, FILE_RAG_INDEX];
|
||||
|
||||
// Single JSON files
|
||||
for (const fname of singleFiles) {
|
||||
const src = vault.resolve(fname);
|
||||
const dst = this.resolve(fname);
|
||||
|
||||
try {
|
||||
if (!(await vault.exists(src))) { result.skipped++; continue; }
|
||||
|
||||
const data = await vault.readJson<unknown>(src, null);
|
||||
if (data === null) { result.skipped++; continue; }
|
||||
|
||||
const ok = await this.writeJson(dst, data);
|
||||
if (ok) {
|
||||
await vault.remove(src);
|
||||
result.moved++;
|
||||
} else {
|
||||
result.errors.push(t("storage_save_error", fname));
|
||||
}
|
||||
} catch (e) {
|
||||
result.errors.push(`${fname}: ${(e as Error)?.message ?? e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// history/ folder — all session-*.json files
|
||||
try {
|
||||
const srcDir = vault.resolve(DIR_HISTORY);
|
||||
const dstDir = this.resolve(DIR_HISTORY);
|
||||
|
||||
if (await vault.exists(srcDir)) {
|
||||
await this.ensureDir(dstDir);
|
||||
const { files } = await vault.list(srcDir);
|
||||
|
||||
for (const srcFile of files) {
|
||||
try {
|
||||
const fileName = srcFile.split("/").pop() ?? "";
|
||||
const dstFile = this._path!.join(dstDir, fileName);
|
||||
|
||||
const data = await vault.readJson<unknown>(srcFile, null);
|
||||
if (data === null) continue;
|
||||
|
||||
const ok = await this.writeJson(dstFile, data);
|
||||
if (ok) {
|
||||
await vault.remove(srcFile);
|
||||
result.moved++;
|
||||
}
|
||||
} catch (e) {
|
||||
result.errors.push(`history/${srcFile}: ${(e as Error)?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to remove the now-empty history/ folder from the vault
|
||||
try {
|
||||
const adapter = this.plugin.app.vault.adapter as unknown as {
|
||||
rmdir?: (path: string, recursive: boolean) => Promise<void>;
|
||||
};
|
||||
await adapter.rmdir?.(srcDir, false);
|
||||
} catch {
|
||||
// Harmless if the folder is not empty or the operation is unsupported
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
result.errors.push(`history dir: ${(e as Error)?.message}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
93
src/storage/PluginStorage.ts
Normal file
93
src/storage/PluginStorage.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import type { Plugin } from "obsidian";
|
||||
|
||||
export interface ListResult {
|
||||
files: string[];
|
||||
folders: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage layer backed by the Obsidian vault adapter.
|
||||
* Works on desktop and mobile — always available as a fallback.
|
||||
*/
|
||||
export class PluginStorage {
|
||||
private readonly adapter: import("obsidian").DataAdapter;
|
||||
|
||||
constructor(private readonly plugin: Plugin) {
|
||||
this.adapter = plugin.app.vault.adapter;
|
||||
}
|
||||
|
||||
/** Plugin folder inside the vault config (.obsidian/plugins/<id>) */
|
||||
get baseDir(): string {
|
||||
return (
|
||||
this.plugin.manifest.dir ||
|
||||
`${this.plugin.app.vault.configDir}/plugins/${this.plugin.manifest.id}`
|
||||
);
|
||||
}
|
||||
|
||||
/** Joins baseDir with the given path parts */
|
||||
resolve(...parts: string[]): string {
|
||||
return [this.baseDir, ...parts].join("/");
|
||||
}
|
||||
|
||||
async ensureDir(dirPath: string): Promise<void> {
|
||||
try {
|
||||
const a = this.plugin.app.vault.adapter;
|
||||
if (!(await a.exists(dirPath))) {
|
||||
await a.mkdir(dirPath);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[AI-Vault] ensureDir failed:", dirPath, e);
|
||||
}
|
||||
}
|
||||
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
return await this.plugin.app.vault.adapter.exists(filePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async readJson<T>(filePath: string, fallback: T): Promise<T> {
|
||||
try {
|
||||
const a = this.plugin.app.vault.adapter;
|
||||
if (!(await a.exists(filePath))) return fallback;
|
||||
const raw = await a.read(filePath);
|
||||
return JSON.parse(raw) as T;
|
||||
} catch (e) {
|
||||
console.warn("[AI-Vault] readJson failed:", filePath, (e as Error)?.message);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async writeJson(filePath: string, data: unknown): Promise<boolean> {
|
||||
try {
|
||||
const dir = filePath.split("/").slice(0, -1).join("/");
|
||||
if (dir) await this.ensureDir(dir);
|
||||
await this.plugin.app.vault.adapter.write(filePath, JSON.stringify(data));
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("[AI-Vault] writeJson failed:", filePath, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(filePath: string): Promise<void> {
|
||||
try {
|
||||
const a = this.plugin.app.vault.adapter;
|
||||
if (await a.exists(filePath)) await a.remove(filePath);
|
||||
} catch (e) {
|
||||
console.warn("[AI-Vault] remove failed:", filePath, e);
|
||||
}
|
||||
}
|
||||
|
||||
async list(dirPath: string): Promise<ListResult> {
|
||||
try {
|
||||
const a = this.plugin.app.vault.adapter;
|
||||
if (!(await a.exists(dirPath))) return { files: [], folders: [] };
|
||||
return await a.list(dirPath) as ListResult;
|
||||
} catch {
|
||||
return { files: [], folders: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/storage/index.ts
Normal file
3
src/storage/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { PluginStorage } from "./PluginStorage";
|
||||
export { ExternalStorage } from "./ExternalStorage";
|
||||
export type { ListResult } from "./PluginStorage";
|
||||
99
src/types.ts
Normal file
99
src/types.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// ─── Chat / Messages ──────────────────────────────────────────────────────────
|
||||
|
||||
export type MessageRole = "user" | "assistant" | "system";
|
||||
|
||||
export interface ChatMessage {
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// ─── History ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
projectId: string | null;
|
||||
messages: ChatMessage[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
preview?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/** Lightweight entry in the history index (without messages) */
|
||||
export interface SessionMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
projectId: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
preview?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface HistoryIndex {
|
||||
sessions: SessionMeta[];
|
||||
}
|
||||
|
||||
// ─── Projects ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
systemPrompt: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ProjectsFile {
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
// ─── RAG ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RAGEntry {
|
||||
path: string;
|
||||
basename: string;
|
||||
extension?: string;
|
||||
folder?: string;
|
||||
mtime?: number;
|
||||
chunk: string;
|
||||
tokens: string[];
|
||||
embedding: number[] | null;
|
||||
|
||||
// Cache — not persisted to JSON (underscore prefix)
|
||||
_tf?: Record<string, number>;
|
||||
_embNorm?: number | null;
|
||||
}
|
||||
|
||||
export interface RAGIndex {
|
||||
_version: number;
|
||||
entries: RAGEntry[];
|
||||
hashes: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RAGSearchResult {
|
||||
path: string;
|
||||
basename: string;
|
||||
chunk: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
// ─── API ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UsageStats {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
}
|
||||
|
||||
export interface APICallOptions {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
mode: string;
|
||||
webSearch?: boolean;
|
||||
onChunk?: ((delta: string) => void) | null;
|
||||
signal?: AbortSignal | null;
|
||||
}
|
||||
236
src/utils.ts
Normal file
236
src/utils.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { RAG_CHUNK_OVERLAP, RAG_CHUNK_SIZE } from "./constants";
|
||||
|
||||
// ─── Async helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
// ─── Debounce ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DebouncedFn<T extends unknown[]> {
|
||||
(...args: T): void;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export function debounce<T extends unknown[]>(fn: (...args: T) => void, delay: number): DebouncedFn<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const debounced = (...args: T): void => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => { timer = null; fn(...args); }, delay);
|
||||
};
|
||||
|
||||
debounced.cancel = (): void => {
|
||||
if (timer) { clearTimeout(timer); timer = null; }
|
||||
};
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
// ─── Retry helper ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface RetryOptions {
|
||||
maxRetries?: number;
|
||||
baseDelay?: number;
|
||||
maxDelay?: number;
|
||||
}
|
||||
|
||||
export async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
{ maxRetries = 3, baseDelay = 1000, maxDelay = 30000 }: RetryOptions = {},
|
||||
): Promise<T> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
const e = err as { name?: string; noRetry?: boolean } | null;
|
||||
// Do not retry user-initiated aborts, nor errors flagged as non-retryable
|
||||
// (e.g. streaming errors after partial chunks were already delivered to the UI).
|
||||
if (e?.name === "AbortError") throw err;
|
||||
if (e?.noRetry) throw err;
|
||||
if (attempt === maxRetries) break;
|
||||
|
||||
const jitter = Math.random() * 200;
|
||||
const delay = Math.min(baseDelay * 2 ** attempt + jitter, maxDelay);
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// ─── String / HTML helpers ────────────────────────────────────────────────────
|
||||
|
||||
/** Escape HTML — apply to anything written to innerHTML */
|
||||
export function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** Sanitizes a URL — allows only http(s) and mailto, blocks javascript:/data: */
|
||||
export function sanitizeUrl(url: string): string {
|
||||
if (typeof url !== "string") return "#";
|
||||
const trimmed = url.trim();
|
||||
if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed;
|
||||
if (/^[/.#]/.test(trimmed)) return trimmed;
|
||||
return "#";
|
||||
}
|
||||
|
||||
/** UTF-8 safe base64 encode */
|
||||
export function utf8ToBase64(str: string): string {
|
||||
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) =>
|
||||
String.fromCharCode(parseInt(p1, 16)),
|
||||
));
|
||||
}
|
||||
|
||||
/** UTF-8 safe base64 decode */
|
||||
export function base64ToUtf8(b64: string): string {
|
||||
return decodeURIComponent(
|
||||
atob(b64).split("").map(c => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)).join(""),
|
||||
);
|
||||
}
|
||||
|
||||
/** Formats a timestamp as a locale date and time */
|
||||
export function formatDate(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
return (
|
||||
d.toLocaleDateString(undefined, { day: "2-digit", month: "2-digit", year: "numeric" }) +
|
||||
" " +
|
||||
d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
|
||||
);
|
||||
}
|
||||
|
||||
// ─── RAG: text helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/** FNV-1a 32-bit hash — fast, collisions are negligible for file change detection */
|
||||
export function contentHash(text: string): string {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h ^= text.charCodeAt(i);
|
||||
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
|
||||
}
|
||||
return h.toString(16);
|
||||
}
|
||||
|
||||
// ─── Stopwords (Polish + English) ────────────────────────────────────────────
|
||||
|
||||
const STOPWORDS = new Set<string>([
|
||||
// Polish
|
||||
"ale","albo","aby","bez","być","było","była","były","czy","dla","gdy","gdzie",
|
||||
"ich","jak","jako","jest","jego","jej","już","kiedy","która","które","który",
|
||||
"lub","może","nad","nie","oraz","poza","przez","przy","tak","tam","tej","ten",
|
||||
"tego","tym","tu","tylko","wam","wasz","więc","wszystko","wy","ze","że","żeby",
|
||||
"się","pan","pani","tego","temu","tych","tym","tymi","nas","nam","was","ją",
|
||||
// English
|
||||
"the","and","for","are","but","not","you","all","can","her","was","one","our",
|
||||
"had","have","has","with","this","that","they","from","were","been","will","its",
|
||||
"been","than","into","more","also","over","such","when","than","then","some",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Tokenizes text with Unicode support (preserves accented characters e.g. ą,ć,ę,ł,ń,ó,ś,ź,ż).
|
||||
* Removes stopwords and tokens shorter than 3 characters.
|
||||
*/
|
||||
export function tokenize(text: string): string[] {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
||||
.split(/\s+/)
|
||||
.filter(t => t.length > 2 && !STOPWORDS.has(t));
|
||||
}
|
||||
|
||||
/** Builds a term-frequency map for a document (cached on the entry) */
|
||||
export function buildTermFreq(tokens: string[]): Record<string, number> {
|
||||
const tf: Record<string, number> = Object.create(null);
|
||||
for (const token of tokens) tf[token] = (tf[token] || 0) + 1;
|
||||
return tf;
|
||||
}
|
||||
|
||||
// ─── RAG: math ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function dotProduct(a: number[], b: number[]): number {
|
||||
let s = 0;
|
||||
for (let i = 0; i < a.length; i++) s += a[i] * b[i];
|
||||
return s;
|
||||
}
|
||||
|
||||
export function vectorNorm(v: number[]): number {
|
||||
let s = 0;
|
||||
for (let i = 0; i < v.length; i++) s += v[i] * v[i];
|
||||
return Math.sqrt(s);
|
||||
}
|
||||
|
||||
/** Cosine similarity with optional pre-computed norms */
|
||||
export function cosineSim(a: number[], b: number[], normA?: number, normB?: number): number {
|
||||
const na = normA ?? vectorNorm(a);
|
||||
const nb = normB ?? vectorNorm(b);
|
||||
if (!na || !nb) return 0;
|
||||
return dotProduct(a, b) / (na * nb);
|
||||
}
|
||||
|
||||
export function bm25Score(
|
||||
qTokens: string[],
|
||||
docTf: Record<string, number>,
|
||||
docLen: number,
|
||||
avgLen: number,
|
||||
k1 = 1.5,
|
||||
b = 0.75,
|
||||
): number {
|
||||
let score = 0;
|
||||
const lenNorm = 1 - b + b * docLen / Math.max(avgLen, 1);
|
||||
for (const token of qTokens) {
|
||||
const tf = docTf[token];
|
||||
if (!tf) continue;
|
||||
score += (tf * (k1 + 1)) / (tf + k1 * lenNorm);
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
// ─── Chunking ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Splits text into chunks — first by H1/H2 headings,
|
||||
* then by paragraphs with overlap, preserving context across fragments.
|
||||
*/
|
||||
export function chunkText(
|
||||
text: string,
|
||||
size = RAG_CHUNK_SIZE,
|
||||
overlap = RAG_CHUNK_OVERLAP,
|
||||
): string[] {
|
||||
// Split on H1/H2 headings — natural section boundaries
|
||||
const sections = text.split(/(?=^#{1,2}\s)/m);
|
||||
const chunks: string[] = [];
|
||||
|
||||
for (const section of sections) {
|
||||
if (section.length <= size) {
|
||||
if (section.trim()) chunks.push(section.trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Large sections: split by paragraphs with overlap
|
||||
const paras = section.split(/\n{2,}/);
|
||||
let cur = "";
|
||||
|
||||
for (const p of paras) {
|
||||
if (cur.length + p.length > size && cur.length > 0) {
|
||||
chunks.push(cur.trim());
|
||||
const tail = cur.length > overlap ? cur.slice(-overlap) : "";
|
||||
cur = tail + (tail ? "\n\n" : "") + p;
|
||||
} else {
|
||||
cur += (cur ? "\n\n" : "") + p;
|
||||
}
|
||||
}
|
||||
|
||||
if (cur.trim()) chunks.push(cur.trim());
|
||||
}
|
||||
|
||||
return chunks.length ? chunks : [text.slice(0, size)];
|
||||
}
|
||||
1327
src/views/ChatView.ts
Normal file
1327
src/views/ChatView.ts
Normal file
File diff suppressed because it is too large
Load diff
96
src/views/FallbackModal.ts
Normal file
96
src/views/FallbackModal.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { App, Modal } from "obsidian";
|
||||
import { t } from "../i18n";
|
||||
import { isGPT5 } from "../models";
|
||||
|
||||
interface FallbackModalOptions {
|
||||
failedModel: string;
|
||||
fallbackModel: string;
|
||||
errorMessage?: string;
|
||||
onAccept?: (saveAsDefault: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal shown when the selected model is unavailable for the user's account (403/404).
|
||||
* Suggests switching to a fallback model (usually gpt-4o) and retrying the message.
|
||||
* Optionally saves the fallback as the default model in settings.
|
||||
*/
|
||||
export class FallbackModal extends Modal {
|
||||
private readonly failedModel: string;
|
||||
private readonly fallbackModel: string;
|
||||
private readonly errorMessage: string;
|
||||
private readonly onAccept?: (saveAsDefault: boolean) => Promise<void>;
|
||||
private saveAsDefault = false;
|
||||
|
||||
constructor(app: App, opts: FallbackModalOptions) {
|
||||
super(app);
|
||||
this.failedModel = opts.failedModel;
|
||||
this.fallbackModel = opts.fallbackModel;
|
||||
this.errorMessage = opts.errorMessage ?? "";
|
||||
this.onAccept = opts.onAccept;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.addClass("gpt-fallback-modal");
|
||||
|
||||
contentEl.createEl("h2", { text: t("fallback_title") });
|
||||
|
||||
const desc = contentEl.createEl("div", { cls: "gpt-fallback-desc" });
|
||||
desc.createEl("p", { text: t("fallback_unavailable", this.failedModel) });
|
||||
|
||||
// Hint for GPT-5 — most common cause: no Tier 1 access
|
||||
if (isGPT5(this.failedModel)) {
|
||||
const parts = (t("fallback_tier1") as string).split(": ");
|
||||
const hint = desc.createEl("div", { cls: "gpt-fallback-hint" });
|
||||
hint.createEl("strong", { text: parts[0] + ": " });
|
||||
hint.appendText(parts.slice(1).join(": "));
|
||||
}
|
||||
|
||||
// API error details
|
||||
if (this.errorMessage) {
|
||||
const errBox = desc.createEl("div", { cls: "gpt-fallback-err" });
|
||||
errBox.createEl("span", { text: t("fallback_api_error") });
|
||||
errBox.createEl("code", { text: this.errorMessage });
|
||||
}
|
||||
|
||||
desc.createEl("p", { text: t("fallback_suggest", this.fallbackModel) });
|
||||
|
||||
// Checkbox: save as default model
|
||||
const checkRow = contentEl.createEl("div", { cls: "gpt-fallback-check" });
|
||||
const checkbox = checkRow.createEl("input", {
|
||||
type: "checkbox",
|
||||
attr: { id: "gpt-fallback-save-default" },
|
||||
}) as HTMLInputElement;
|
||||
checkRow.createEl("label", {
|
||||
text: " " + t("fallback_save_default", this.fallbackModel),
|
||||
attr: { for: "gpt-fallback-save-default" },
|
||||
});
|
||||
checkbox.addEventListener("change", () => {
|
||||
this.saveAsDefault = checkbox.checked;
|
||||
});
|
||||
|
||||
// Przyciski
|
||||
const btnRow = contentEl.createEl("div", { cls: "gpt-fallback-buttons" });
|
||||
|
||||
const cancelBtn = btnRow.createEl("button", { text: t("chat_notes_cancel") });
|
||||
cancelBtn.addEventListener("click", () => this.close());
|
||||
|
||||
const acceptBtn = btnRow.createEl("button", {
|
||||
cls: "mod-cta",
|
||||
text: t("fallback_accept", this.fallbackModel),
|
||||
});
|
||||
acceptBtn.addEventListener("click", async () => {
|
||||
this.close();
|
||||
try {
|
||||
await this.onAccept?.(this.saveAsDefault);
|
||||
} catch (e) {
|
||||
console.error("[AI-Vault] Fallback retry failed:", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
124
src/views/HistoryView.ts
Normal file
124
src/views/HistoryView.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { ItemView, WorkspaceLeaf } from "obsidian";
|
||||
import { HISTORY_VIEW_TYPE, PROJECTS_VIEW_TYPE } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { formatDate } from "../utils";
|
||||
import type { HistoryManager } from "../history/HistoryManager";
|
||||
import type { ProjectManager } from "../history/ProjectManager";
|
||||
|
||||
// SVG helpers — inline SVG for icons (Obsidian's Lucide set doesn't include these)
|
||||
const SVG_CLOSE = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
|
||||
const SVG_FOLDER = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`;
|
||||
const SVG_DELETE = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M9 6V4h6v2"/></svg>`;
|
||||
|
||||
interface PluginWithDeps {
|
||||
app: import("obsidian").App;
|
||||
history: HistoryManager;
|
||||
projects: ProjectManager;
|
||||
currentSessionId: string | null;
|
||||
newChat(): void;
|
||||
loadSession(id: string): Promise<void>;
|
||||
activateProjectsView(): Promise<void>;
|
||||
}
|
||||
|
||||
export class GPTHistoryView extends ItemView {
|
||||
private readonly plugin: PluginWithDeps;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, plugin: PluginWithDeps) {
|
||||
super(leaf);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
getViewType(): string { return HISTORY_VIEW_TYPE; }
|
||||
getDisplayText(): string { return t("history_title"); }
|
||||
getIcon(): string { return "clock"; }
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
await this.plugin.history.load();
|
||||
this.render();
|
||||
}
|
||||
async onClose(): Promise<void> { /* cleanup handled by Obsidian */ }
|
||||
|
||||
render(): void {
|
||||
const root = this.containerEl.children[1] as HTMLElement;
|
||||
root.empty();
|
||||
root.addClass("gpt-history-root");
|
||||
|
||||
this.buildHeader(root);
|
||||
this.buildProjectsShortcut(root);
|
||||
this.buildSessionList(root);
|
||||
}
|
||||
|
||||
// ── Sekcje ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private buildHeader(root: HTMLElement): void {
|
||||
const header = root.createEl("div", { cls: "gpt-history-header" });
|
||||
header.createEl("span", { cls: "gpt-header-title", text: t("history_title") });
|
||||
|
||||
const newBtn = header.createEl("button", { cls: "gpt-pill-btn", text: t("history_btn_new") });
|
||||
newBtn.onclick = () => this.plugin.newChat();
|
||||
|
||||
const closeBtn = header.createEl("button", {
|
||||
cls: "gpt-icon-btn",
|
||||
attr: { "aria-label": "Zamknij" },
|
||||
});
|
||||
closeBtn.innerHTML = SVG_CLOSE;
|
||||
closeBtn.onclick = () => {
|
||||
const leaves = this.plugin.app.workspace.getLeavesOfType(HISTORY_VIEW_TYPE);
|
||||
leaves[0]?.detach();
|
||||
};
|
||||
}
|
||||
|
||||
private buildProjectsShortcut(root: HTMLElement): void {
|
||||
const projCount = this.plugin.projects.projects.length;
|
||||
const btn = root.createEl("div", { cls: "gpt-projects-shortcut" });
|
||||
btn.innerHTML = SVG_FOLDER;
|
||||
btn.createEl("span", { cls: "gpt-projects-shortcut-label", text: t("chat_projects") });
|
||||
if (projCount) {
|
||||
btn.createEl("span", {
|
||||
cls: "gpt-projects-shortcut-badge",
|
||||
text: String(projCount),
|
||||
});
|
||||
}
|
||||
btn.createEl("span", { cls: "gpt-projects-shortcut-arrow", text: "›" });
|
||||
btn.onclick = () => void this.plugin.activateProjectsView();
|
||||
}
|
||||
|
||||
private buildSessionList(root: HTMLElement): void {
|
||||
const list = root.createEl("div", { cls: "gpt-history-list" });
|
||||
const sessions = this.plugin.history.sessions.filter(s => !s.projectId);
|
||||
|
||||
if (!sessions.length) {
|
||||
list.createEl("div", { cls: "gpt-history-empty", text: t("history_empty") });
|
||||
list.createEl("div", { cls: "gpt-history-empty-hint", text: t("history_chats_in_projects") });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const session of sessions) {
|
||||
const item = list.createEl("div", { cls: "gpt-history-item" });
|
||||
if (this.plugin.currentSessionId === session.id) {
|
||||
item.addClass("gpt-history-item--active");
|
||||
}
|
||||
|
||||
// Row header: title + delete button
|
||||
const top = item.createEl("div", { cls: "gpt-history-item-top" });
|
||||
top.createEl("span", { cls: "gpt-history-item-title", text: session.title });
|
||||
|
||||
const delBtn = top.createEl("button", {
|
||||
cls: "gpt-history-item-del",
|
||||
attr: { "aria-label": t("history_delete_btn") },
|
||||
});
|
||||
delBtn.innerHTML = SVG_DELETE;
|
||||
delBtn.onclick = async (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(t("history_delete_chat_confirm", session.title.slice(0, 40)))) return;
|
||||
await this.plugin.history.deleteSession(session.id);
|
||||
if (this.plugin.currentSessionId === session.id) this.plugin.newChat();
|
||||
this.render();
|
||||
};
|
||||
|
||||
item.createEl("div", { cls: "gpt-history-item-date", text: formatDate(session.updatedAt) });
|
||||
item.createEl("div", { cls: "gpt-history-item-preview", text: session.preview ?? "…" });
|
||||
item.onclick = () => void this.plugin.loadSession(session.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
297
src/views/ProjectsView.ts
Normal file
297
src/views/ProjectsView.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import { ItemView, Notice, WorkspaceLeaf } from "obsidian";
|
||||
import { PROJECTS_VIEW_TYPE } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { formatDate } from "../utils";
|
||||
import type { Project } from "../types";
|
||||
import type { HistoryManager } from "../history/HistoryManager";
|
||||
import type { ProjectManager } from "../history/ProjectManager";
|
||||
|
||||
// ─── SVG icons ────────────────────────────────────────────────────────────────
|
||||
|
||||
const SVG_CLOSE = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
|
||||
const SVG_DELETE = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M9 6V4h6v2"/></svg>`;
|
||||
const SVG_EDIT = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>`;
|
||||
const SVG_CHAT = `<svg class="gpt-projects-card-chat-icon" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`;
|
||||
const SVG_PROMPT = `<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>`;
|
||||
|
||||
// ─── Plugin interface ──────────────────────────────────────────────────────────
|
||||
|
||||
interface PluginWithDeps {
|
||||
app: import("obsidian").App;
|
||||
history: HistoryManager;
|
||||
projects: ProjectManager;
|
||||
currentSessionId: string | null;
|
||||
activeProjectId: string | null;
|
||||
newChat(): void;
|
||||
loadSession(id: string): Promise<void>;
|
||||
setActiveProject(id: string | null): void;
|
||||
activateHistoryView(): Promise<void>;
|
||||
}
|
||||
|
||||
// ─── View ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class GPTProjectsView extends ItemView {
|
||||
private readonly plugin: PluginWithDeps;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, plugin: PluginWithDeps) {
|
||||
super(leaf);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
getViewType(): string { return PROJECTS_VIEW_TYPE; }
|
||||
getDisplayText(): string { return t("projects_title"); }
|
||||
getIcon(): string { return "folder-open"; }
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
await this.plugin.projects.load();
|
||||
this.render();
|
||||
}
|
||||
async onClose(): Promise<void> { /* cleanup handled by Obsidian */ }
|
||||
|
||||
render(): void {
|
||||
const root = this.containerEl.children[1] as HTMLElement;
|
||||
root.empty();
|
||||
root.addClass("gpt-projects-root");
|
||||
|
||||
this.buildHeader(root);
|
||||
this.buildActiveBar(root);
|
||||
this.buildProjectList(root);
|
||||
}
|
||||
|
||||
// ── Sekcje ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private buildHeader(root: HTMLElement): void {
|
||||
const header = root.createEl("div", { cls: "gpt-projects-header" });
|
||||
|
||||
const backBtn = header.createEl("button", { cls: "gpt-pill-btn", text: t("projects_btn_back") });
|
||||
backBtn.onclick = () => void this.plugin.activateHistoryView();
|
||||
|
||||
header.createEl("span", { cls: "gpt-header-title", text: "📁 " + t("projects_title") });
|
||||
|
||||
const newBtn = header.createEl("button", { cls: "gpt-pill-btn", text: t("projects_btn_new") });
|
||||
newBtn.onclick = () => this.showCreateDialog();
|
||||
|
||||
const closeBtn = header.createEl("button", {
|
||||
cls: "gpt-icon-btn",
|
||||
attr: { "aria-label": "Zamknij" },
|
||||
});
|
||||
closeBtn.innerHTML = SVG_CLOSE;
|
||||
closeBtn.onclick = () => {
|
||||
const leaves = this.plugin.app.workspace.getLeavesOfType(PROJECTS_VIEW_TYPE);
|
||||
leaves[0]?.detach();
|
||||
};
|
||||
}
|
||||
|
||||
private buildActiveBar(root: HTMLElement): void {
|
||||
if (!this.plugin.activeProjectId) return;
|
||||
const proj = this.plugin.projects.getProject(this.plugin.activeProjectId);
|
||||
if (!proj) return;
|
||||
|
||||
const bar = root.createEl("div", { cls: "gpt-projects-active-bar" });
|
||||
bar.style.background = `color-mix(in srgb,${proj.color} 12%,var(--background-secondary))`;
|
||||
bar.style.borderBottomColor = `color-mix(in srgb,${proj.color} 25%,transparent)`;
|
||||
|
||||
const dot = bar.createEl("span", { cls: "gpt-projects-active-dot" });
|
||||
dot.style.background = proj.color;
|
||||
|
||||
bar.createEl("span", {
|
||||
cls: "gpt-projects-active-name",
|
||||
text: `${t("projects_active")} ${proj.name}`,
|
||||
});
|
||||
|
||||
const exitBtn = bar.createEl("button", { cls: "gpt-pill-btn", text: t("projects_leave_btn") });
|
||||
exitBtn.onclick = () => { this.plugin.setActiveProject(null); this.render(); };
|
||||
}
|
||||
|
||||
private buildProjectList(root: HTMLElement): void {
|
||||
const list = root.createEl("div", { cls: "gpt-projects-list" });
|
||||
const projects = this.plugin.projects.projects;
|
||||
|
||||
if (!projects.length) {
|
||||
const empty = list.createEl("div", { cls: "gpt-projects-empty" });
|
||||
empty.createEl("div", { text: t("projects_empty") });
|
||||
empty.createEl("div", {
|
||||
cls: "gpt-projects-empty-hint",
|
||||
text: t("projects_empty_hint_long"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const proj of projects) {
|
||||
this.buildProjectCard(list, proj);
|
||||
}
|
||||
}
|
||||
|
||||
private buildProjectCard(list: HTMLElement, proj: Project): void {
|
||||
const sessions = this.plugin.projects.getProjectSessions(proj.id);
|
||||
const isActive = this.plugin.activeProjectId === proj.id;
|
||||
|
||||
const card = list.createEl("div", { cls: "gpt-projects-card" });
|
||||
if (isActive) {
|
||||
card.style.borderColor = proj.color;
|
||||
card.style.background = `color-mix(in srgb,${proj.color} 8%,var(--background-secondary))`;
|
||||
}
|
||||
|
||||
// ── Card header ─────────────────────────────────────────────────────────
|
||||
const top = card.createEl("div", { cls: "gpt-projects-card-top" });
|
||||
|
||||
const dot = top.createEl("span", { cls: "gpt-projects-card-dot" });
|
||||
dot.style.background = proj.color;
|
||||
|
||||
top.createEl("span", { cls: "gpt-projects-card-name", text: proj.name });
|
||||
|
||||
const badge = top.createEl("span", {
|
||||
cls: "gpt-projects-card-badge",
|
||||
text: t("projects_chat_count", sessions.length),
|
||||
});
|
||||
badge.style.background = `color-mix(in srgb,${proj.color} 15%,var(--background-primary))`;
|
||||
badge.style.color = proj.color;
|
||||
|
||||
// Edit button
|
||||
const editBtn = top.createEl("button", {
|
||||
cls: "gpt-projects-icon-btn",
|
||||
attr: { "aria-label": "Edytuj" },
|
||||
});
|
||||
editBtn.innerHTML = SVG_EDIT;
|
||||
editBtn.onclick = (e: MouseEvent) => { e.stopPropagation(); this.showCreateDialog(proj); };
|
||||
|
||||
// Delete button
|
||||
const delBtn = top.createEl("button", {
|
||||
cls: "gpt-projects-icon-btn",
|
||||
attr: { "aria-label": t("history_delete_btn") },
|
||||
});
|
||||
delBtn.innerHTML = SVG_DELETE;
|
||||
delBtn.onclick = async (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(t("projects_delete_with_count", proj.name, sessions.length))) return;
|
||||
if (this.plugin.activeProjectId === proj.id) this.plugin.setActiveProject(null);
|
||||
await this.plugin.projects.deleteProject(proj.id);
|
||||
this.render();
|
||||
};
|
||||
|
||||
// Custom prompt — badge
|
||||
if (proj.systemPrompt) {
|
||||
const tag = card.createEl("div", { cls: "gpt-projects-card-prompt-tag" });
|
||||
tag.style.background = `color-mix(in srgb,${proj.color} 10%,var(--background-primary))`;
|
||||
tag.style.color = proj.color;
|
||||
tag.style.borderColor = `color-mix(in srgb,${proj.color} 20%,transparent)`;
|
||||
tag.innerHTML = `${SVG_PROMPT} ${t("projects_own_prompt_btn")}`;
|
||||
}
|
||||
|
||||
// ── Chat list ───────────────────────────────────────────────────────────
|
||||
if (sessions.length) {
|
||||
const chatList = card.createEl("div", { cls: "gpt-projects-card-chats" });
|
||||
|
||||
for (const s of sessions.slice(0, 5)) {
|
||||
const row = chatList.createEl("div", { cls: "gpt-projects-card-chat" });
|
||||
row.innerHTML = SVG_CHAT;
|
||||
row.createEl("span", { cls: "gpt-projects-card-chat-title", text: s.title.slice(0, 40) });
|
||||
row.createEl("span", { cls: "gpt-projects-card-chat-date", text: formatDate(s.updatedAt) });
|
||||
|
||||
const chatDel = row.createEl("button", {
|
||||
cls: "gpt-projects-card-chat-del",
|
||||
attr: { "aria-label": t("history_delete_btn") },
|
||||
});
|
||||
chatDel.innerHTML = SVG_DELETE;
|
||||
chatDel.onclick = async (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(t("projects_chat_delete_confirm", s.title.slice(0, 40)))) return;
|
||||
await this.plugin.history.deleteSession(s.id);
|
||||
if (this.plugin.currentSessionId === s.id) this.plugin.newChat();
|
||||
this.render();
|
||||
};
|
||||
|
||||
row.onclick = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
void this.plugin.loadSession(s.id);
|
||||
};
|
||||
}
|
||||
|
||||
if (sessions.length > 5) {
|
||||
chatList.createEl("div", {
|
||||
cls: "gpt-projects-card-more",
|
||||
text: t("projects_more_chats", sessions.length - 5),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
card.onclick = () => { this.plugin.setActiveProject(proj.id); this.render(); };
|
||||
}
|
||||
|
||||
// ── Dialog tworzenia / edycji projektu ─────────────────────────────────────
|
||||
|
||||
showCreateDialog(editProject?: Project): void {
|
||||
const isEdit = !!editProject;
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "gpt-modal-overlay";
|
||||
|
||||
const box = document.createElement("div");
|
||||
box.className = "gpt-modal-box";
|
||||
|
||||
box.createEl("p", {
|
||||
cls: "gpt-modal-title",
|
||||
text: isEdit ? `Edytuj projekt: ${editProject!.name}` : "Nowy projekt:",
|
||||
});
|
||||
|
||||
const nameInput = box.createEl("input", {
|
||||
cls: "gpt-modal-input",
|
||||
attr: { type: "text", placeholder: "Nazwa projektu…" },
|
||||
}) as HTMLInputElement;
|
||||
if (isEdit) nameInput.value = editProject!.name;
|
||||
|
||||
const promptLabel = box.createEl("div", { cls: "gpt-modal-prompt-label" });
|
||||
promptLabel.innerHTML = `${SVG_EDIT} ${t("projects_prompt_label")}`;
|
||||
|
||||
const promptInput = box.createEl("textarea", {
|
||||
cls: "gpt-modal-input gpt-modal-prompt-area",
|
||||
attr: { placeholder: t("projects_prompt_placeholder") },
|
||||
}) as HTMLTextAreaElement;
|
||||
if (isEdit) promptInput.value = editProject!.systemPrompt ?? "";
|
||||
|
||||
box.createEl("div", { cls: "gpt-modal-prompt-hint", text: t("projects_prompt_hint") });
|
||||
|
||||
// Przyciski
|
||||
const btns = box.createEl("div", { cls: "gpt-modal-btns" });
|
||||
const cancel = btns.createEl("button", {
|
||||
cls: "gpt-modal-cancel",
|
||||
text: t("chat_notes_cancel"),
|
||||
});
|
||||
cancel.onclick = () => overlay.remove();
|
||||
|
||||
const ok = btns.createEl("button", {
|
||||
cls: "gpt-modal-ok",
|
||||
text: isEdit ? "Zapisz" : t("projects_create_btn"),
|
||||
});
|
||||
ok.onclick = async () => { const name = nameInput.value.trim();
|
||||
if (!name) { nameInput.style.borderColor = "var(--color-red)"; return; }
|
||||
|
||||
if (isEdit) {
|
||||
await this.plugin.projects.updateProject(editProject!.id, {
|
||||
name,
|
||||
systemPrompt: promptInput.value.trim(),
|
||||
});
|
||||
overlay.remove();
|
||||
this.render();
|
||||
new Notice(t("projects_updated", name));
|
||||
} else {
|
||||
const proj = await this.plugin.projects.createProject(
|
||||
name,
|
||||
"",
|
||||
promptInput.value.trim(),
|
||||
);
|
||||
this.plugin.setActiveProject(proj.id);
|
||||
overlay.remove();
|
||||
this.render();
|
||||
new Notice(t("projects_created", name));
|
||||
}
|
||||
};
|
||||
|
||||
nameInput.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") void (ok.onclick as (() => Promise<void>) | null)?.();
|
||||
});
|
||||
|
||||
overlay.appendChild(box);
|
||||
this.containerEl.appendChild(overlay);
|
||||
setTimeout(() => nameInput.focus(), 50);
|
||||
}
|
||||
}
|
||||
3
src/views/index.ts
Normal file
3
src/views/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { GPTHistoryView } from "./HistoryView";
|
||||
export { GPTProjectsView } from "./ProjectsView";
|
||||
export { FallbackModal } from "./FallbackModal";
|
||||
627
styles.css
Normal file
627
styles.css
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
/* ─── CSS Variables (overrideable via Style Settings plugin) ────────────────── */
|
||||
:root {
|
||||
--gpt-color-openai: #10a37f;
|
||||
--gpt-color-claude: #7c3aed;
|
||||
--gpt-color-web: #2563eb;
|
||||
--gpt-color-learn: #f59e0b;
|
||||
--gpt-color-error: #e74c3c;
|
||||
--gpt-color-ok-text: #065f46;
|
||||
--gpt-color-err-text: #991b1b;
|
||||
}
|
||||
|
||||
/* ─── Root containers ────────────────────────────────────────────────────────── */
|
||||
.gpt-chat-root,
|
||||
.gpt-history-root,
|
||||
.gpt-projects-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--background-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ─── Headers ────────────────────────────────────────────────────────────────── */
|
||||
.gpt-header,
|
||||
.gpt-history-header,
|
||||
.gpt-projects-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gpt-header-icon { font-size: 15px; color: var(--gpt-color-openai); }
|
||||
.gpt-header-title {
|
||||
font-size: 13px; font-weight: 700; flex: 1;
|
||||
letter-spacing: .03em; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ─── Provider switch ────────────────────────────────────────────────────────── */
|
||||
.gpt-provider-switch {
|
||||
display: flex;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gpt-provider-btn {
|
||||
padding: 3px 10px; font-size: 11px; font-weight: 600;
|
||||
border: none; background: transparent;
|
||||
color: var(--text-muted); cursor: pointer; transition: all .15s;
|
||||
}
|
||||
.gpt-provider-btn--active:first-child { background: var(--gpt-color-openai); color: #fff; }
|
||||
.gpt-provider-btn--active:last-child { background: var(--gpt-color-claude); color: #fff; }
|
||||
.gpt-provider-btn:hover:not(.gpt-provider-btn--active) {
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
/* ─── Model selector ─────────────────────────────────────────────────────────── */
|
||||
.gpt-model-selector {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
padding: 3px 9px 3px 7px; border-radius: 8px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-secondary); color: var(--text-normal);
|
||||
cursor: pointer; font-size: 12px; font-weight: 600;
|
||||
flex: 1; min-width: 0; max-width: 160px;
|
||||
transition: background .15s, border-color .15s;
|
||||
white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
.gpt-model-selector:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
.gpt-ms-icon { font-size: 13px; flex-shrink: 0; }
|
||||
.gpt-ms-label { overflow: hidden; text-overflow: ellipsis; flex: 1; text-align: left; }
|
||||
.gpt-ms-arrow { flex-shrink: 0; opacity: .5; transition: opacity .15s; }
|
||||
.gpt-model-selector:hover .gpt-ms-arrow { opacity: 1; }
|
||||
|
||||
/* ─── Model picker dropdown ──────────────────────────────────────────────────── */
|
||||
.gpt-model-picker {
|
||||
position: fixed; z-index: 9999;
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 10px; box-shadow: 0 8px 32px rgba(0,0,0,.25);
|
||||
padding: 6px; min-width: 220px; max-height: 420px; overflow-y: auto;
|
||||
}
|
||||
.gpt-mp-header {
|
||||
font-size: 11px; font-weight: 700; color: var(--text-muted);
|
||||
padding: 4px 8px 6px; letter-spacing: .04em;
|
||||
}
|
||||
.gpt-mp-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
width: 100%; padding: 6px 8px; border-radius: 6px;
|
||||
border: none; background: transparent; cursor: pointer;
|
||||
text-align: left; gap: 4px; transition: background .12s;
|
||||
}
|
||||
.gpt-mp-row:hover { background: var(--background-modifier-hover); }
|
||||
.gpt-mp-row--active { background: color-mix(in srgb, var(--interactive-accent) 12%, transparent); }
|
||||
.gpt-mp-row--active:hover { background: color-mix(in srgb, var(--interactive-accent) 18%, transparent); }
|
||||
.gpt-mp-row-left { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }
|
||||
.gpt-mp-row-name { font-size: 12px; font-weight: 600; color: var(--text-normal); }
|
||||
.gpt-mp-row-desc { font-size: 10px; color: var(--text-muted); }
|
||||
.gpt-mp-row-check { font-size: 12px; color: var(--interactive-accent); font-weight: 700; flex-shrink: 0; }
|
||||
.gpt-mp-divider { height: 1px; background: var(--background-modifier-border); margin: 4px 0; }
|
||||
|
||||
/* ─── Badges & buttons ───────────────────────────────────────────────────────── */
|
||||
.gpt-rag-badge {
|
||||
font-size: 9px; font-weight: 700; padding: 2px 6px;
|
||||
border-radius: 10px; background: var(--gpt-color-openai);
|
||||
color: #fff; letter-spacing: .05em; flex-shrink: 0;
|
||||
}
|
||||
.gpt-icon-btn {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--text-muted); padding: 4px; border-radius: 5px;
|
||||
display: flex; align-items: center; transition: all .15s;
|
||||
}
|
||||
.gpt-icon-btn:hover { background: var(--background-modifier-hover); color: var(--text-normal); }
|
||||
.gpt-clear-btn, .gpt-pill-btn {
|
||||
font-size: 11px; padding: 3px 10px; border-radius: 20px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background: transparent; color: var(--text-muted);
|
||||
cursor: pointer; transition: all .2s; flex-shrink: 0;
|
||||
}
|
||||
.gpt-clear-btn:hover, .gpt-pill-btn:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
/* ─── Mode bar ───────────────────────────────────────────────────────────────── */
|
||||
.gpt-mode-bar {
|
||||
display: flex; gap: 5px; padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-secondary); flex-shrink: 0;
|
||||
}
|
||||
.gpt-mode-btn {
|
||||
flex: 1; padding: 5px 3px; font-size: 11px; border-radius: 7px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background: transparent; color: var(--text-muted);
|
||||
cursor: pointer; transition: all .2s; white-space: nowrap;
|
||||
}
|
||||
.gpt-mode-btn:hover { background: var(--background-modifier-hover); color: var(--text-normal); }
|
||||
.gpt-mode-btn--active {
|
||||
background: var(--interactive-accent) !important;
|
||||
color: #fff !important;
|
||||
border-color: var(--interactive-accent) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ─── Context bars ───────────────────────────────────────────────────────────── */
|
||||
.gpt-rag-status {
|
||||
padding: 6px 14px; font-size: 11px;
|
||||
flex-shrink: 0; border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
.gpt-rag-indexing {
|
||||
background: color-mix(in srgb, var(--gpt-color-learn) 12%, var(--background-secondary));
|
||||
color: #92400e;
|
||||
}
|
||||
.gpt-rag-ready {
|
||||
background: color-mix(in srgb, var(--gpt-color-openai) 12%, var(--background-secondary));
|
||||
color: var(--gpt-color-ok-text);
|
||||
}
|
||||
.gpt-manual-bar {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 5px 12px;
|
||||
background: color-mix(in srgb, #6366f1 10%, var(--background-secondary));
|
||||
border-bottom: 1px solid color-mix(in srgb, #6366f1 20%, transparent);
|
||||
font-size: 11px; flex-shrink: 0;
|
||||
}
|
||||
.gpt-project-bar {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 5px 12px; border-bottom: 1px solid transparent;
|
||||
font-size: 11px; flex-shrink: 0;
|
||||
}
|
||||
.gpt-project-bar-label {
|
||||
color: var(--text-muted); flex: 1; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap;
|
||||
font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.gpt-ctx-hidden { display: none !important; }
|
||||
.gpt-ctx-list { color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
|
||||
.gpt-ctx-clear { background: none; border: none; cursor: pointer; color: var(--text-muted); padding: 0 2px; font-size: 13px; line-height: 1; }
|
||||
|
||||
/* ─── Chat messages ──────────────────────────────────────────────────────────── */
|
||||
.gpt-messages {
|
||||
flex: 1; overflow-y: auto; padding: 14px;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.gpt-welcome {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; height: 100%;
|
||||
color: var(--text-muted); text-align: center; gap: 6px;
|
||||
}
|
||||
.gpt-welcome-icon { font-size: 28px; color: var(--gpt-color-openai); opacity: .5; }
|
||||
.gpt-welcome-hint { font-size: 11px; opacity: .6; }
|
||||
|
||||
.gpt-msg { display: flex; flex-direction: column; gap: 2px; }
|
||||
.gpt-msg-user { align-items: flex-end; }
|
||||
.gpt-msg-assistant { align-items: flex-start; }
|
||||
.gpt-bubble {
|
||||
max-width: 88%; padding: 10px 14px;
|
||||
border-radius: 14px; font-size: 13px;
|
||||
line-height: 1.6; word-break: break-word;
|
||||
}
|
||||
.gpt-msg-user .gpt-bubble {
|
||||
background: var(--gpt-color-openai); color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.gpt-msg-assistant .gpt-bubble {
|
||||
background: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-bottom-left-radius: 4px; color: var(--text-normal);
|
||||
}
|
||||
|
||||
/* ─── Message footer ─────────────────────────────────────────────────────────── */
|
||||
.gpt-msg-footer {
|
||||
display: flex; align-items: center;
|
||||
justify-content: space-between; padding: 0 4px; margin-top: 2px;
|
||||
}
|
||||
.gpt-msg-user .gpt-msg-footer { flex-direction: row-reverse; }
|
||||
.gpt-msg-label {
|
||||
font-size: 10px; color: var(--text-faint);
|
||||
text-transform: uppercase; letter-spacing: .05em;
|
||||
}
|
||||
.gpt-copy-btn {
|
||||
display: flex; align-items: center; background: none;
|
||||
border: none; cursor: pointer; color: var(--text-faint);
|
||||
padding: 3px 5px; border-radius: 5px;
|
||||
transition: all .15s; opacity: 0;
|
||||
}
|
||||
.gpt-msg:hover .gpt-copy-btn { opacity: 1; }
|
||||
.gpt-copy-btn:hover { background: var(--background-modifier-hover); color: var(--text-normal); }
|
||||
.gpt-msg-content { user-select: text; -webkit-user-select: text; cursor: text; }
|
||||
.gpt-msg-interrupted { font-size: 10px; color: var(--text-faint); margin-top: 6px; font-style: italic; }
|
||||
.gpt-msg-error-line { color: var(--gpt-color-error); font-weight: 600; }
|
||||
.gpt-msg-error-detail { font-size: 10px; color: var(--text-faint); margin-top: 6px; }
|
||||
.gpt-error { color: var(--gpt-color-error) !important; }
|
||||
|
||||
/* ─── Loading dots ───────────────────────────────────────────────────────────── */
|
||||
.gpt-loading .gpt-msg-content { display: none; }
|
||||
.gpt-dots { display: flex; gap: 4px; padding: 4px 2px; align-items: center; }
|
||||
.gpt-dots span {
|
||||
width: 6px; height: 6px; background: var(--text-muted);
|
||||
border-radius: 50%; animation: gpt-bounce 1.2s infinite ease-in-out;
|
||||
}
|
||||
.gpt-dots span:nth-child(2) { animation-delay: .2s; }
|
||||
.gpt-dots span:nth-child(3) { animation-delay: .4s; }
|
||||
@keyframes gpt-bounce {
|
||||
0%, 80%, 100% { transform: scale(.7); opacity: .4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ─── Web search indicator ───────────────────────────────────────────────────── */
|
||||
.gpt-websearch-indicator {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
font-size: 11px; color: var(--gpt-color-web);
|
||||
padding: 4px 2px; animation: gpt-pulse-web 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes gpt-pulse-web { 0%, 100% { opacity: .5; } 50% { opacity: 1; } }
|
||||
|
||||
/* ─── Code blocks ────────────────────────────────────────────────────────────── */
|
||||
.gpt-code-block {
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 8px; overflow: hidden; margin: 8px 0;
|
||||
}
|
||||
.gpt-code-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 5px 10px;
|
||||
background: color-mix(in srgb, var(--background-modifier-border) 50%, var(--background-secondary));
|
||||
border-bottom: 1px solid var(--background-modifier-border); min-height: 28px;
|
||||
}
|
||||
.gpt-code-lang {
|
||||
font-size: 10px; font-weight: 700; color: var(--text-faint);
|
||||
text-transform: uppercase; letter-spacing: .08em;
|
||||
font-family: var(--font-monospace);
|
||||
}
|
||||
.gpt-code-copy {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 3px 9px; font-size: 11px; border-radius: 5px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-secondary); color: var(--text-muted);
|
||||
cursor: pointer; transition: all .15s;
|
||||
font-family: var(--font-interface); margin-left: auto; line-height: 1;
|
||||
}
|
||||
.gpt-code-copy:hover { background: var(--background-modifier-hover); color: var(--text-normal); }
|
||||
.gpt-code-copy--ok {
|
||||
color: var(--gpt-color-openai) !important;
|
||||
border-color: var(--gpt-color-openai) !important;
|
||||
background: color-mix(in srgb, var(--gpt-color-openai) 10%, var(--background-secondary)) !important;
|
||||
}
|
||||
.gpt-bubble pre { background: var(--background-primary); border-radius: 0; padding: 10px 12px; overflow-x: auto; margin: 0; font-size: 12px; }
|
||||
.gpt-bubble code { font-family: var(--font-monospace); font-size: 12px; }
|
||||
.gpt-bubble h1, .gpt-bubble h2, .gpt-bubble h3 { margin: 8px 0 4px; line-height: 1.3; }
|
||||
.gpt-bubble ul { padding-left: 18px; margin: 4px 0; }
|
||||
.gpt-bubble li { margin: 2px 0; }
|
||||
.gpt-bubble ol { padding-left: 18px; margin: 4px 0; }
|
||||
.gpt-bubble ol li { margin: 2px 0; }
|
||||
.gpt-bubble s { opacity: .65; }
|
||||
|
||||
/* ─── RAG sources ────────────────────────────────────────────────────────────── */
|
||||
.gpt-rag-sources {
|
||||
font-size: 11px; padding: 2px 6px 6px;
|
||||
display: flex; flex-wrap: wrap; align-items: center;
|
||||
gap: 4px; max-width: 88%;
|
||||
}
|
||||
.gpt-rag-src-icon { font-size: 12px; }
|
||||
.gpt-rag-src-label { color: var(--text-muted); font-weight: 600; font-size: 10px; }
|
||||
.gpt-rag-src-chip {
|
||||
background: color-mix(in srgb, var(--gpt-color-openai) 12%, var(--background-secondary));
|
||||
color: var(--gpt-color-openai); padding: 1px 7px; border-radius: 10px;
|
||||
font-size: 10px; font-weight: 500;
|
||||
border: 1px solid color-mix(in srgb, var(--gpt-color-openai) 25%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Input area ─────────────────────────────────────────────────────────────── */
|
||||
.gpt-input-area {
|
||||
padding: 10px 12px 12px;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-secondary); flex-shrink: 0;
|
||||
}
|
||||
.gpt-tool-row { display: flex; gap: 5px; margin-bottom: 7px; flex-wrap: wrap; }
|
||||
.gpt-tool-btn {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 4px 9px; font-size: 11px; border-radius: 6px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background: transparent; color: var(--text-muted);
|
||||
cursor: pointer; transition: all .2s; white-space: nowrap;
|
||||
}
|
||||
.gpt-tool-btn:hover { background: var(--background-modifier-hover); color: var(--text-normal); }
|
||||
|
||||
/* Active states for tool buttons */
|
||||
.gpt-rag-btn--active {
|
||||
background: color-mix(in srgb, var(--gpt-color-openai) 15%, var(--background-secondary)) !important;
|
||||
color: var(--gpt-color-openai) !important;
|
||||
border-color: var(--gpt-color-openai) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gpt-websearch-btn--active {
|
||||
background: color-mix(in srgb, var(--gpt-color-web) 15%, var(--background-secondary)) !important;
|
||||
color: var(--gpt-color-web) !important;
|
||||
border-color: var(--gpt-color-web) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gpt-learn-btn--active {
|
||||
background: color-mix(in srgb, var(--gpt-color-learn) 15%, var(--background-secondary)) !important;
|
||||
color: #b45309 !important;
|
||||
border-color: var(--gpt-color-learn) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gpt-code-btn--active {
|
||||
background: color-mix(in srgb, var(--gpt-color-claude) 15%, var(--background-secondary)) !important;
|
||||
color: var(--gpt-color-claude) !important;
|
||||
border-color: var(--gpt-color-claude) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Textarea & send buttons */
|
||||
.gpt-input {
|
||||
width: 100%; padding: 9px 12px; border-radius: 10px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-primary); color: var(--text-normal);
|
||||
font-size: 13px; resize: none; outline: none;
|
||||
transition: border-color .2s; box-sizing: border-box;
|
||||
font-family: var(--font-interface);
|
||||
}
|
||||
.gpt-input:focus { border-color: var(--gpt-color-openai); }
|
||||
.gpt-btn-row { display: flex; align-items: center; gap: 6px; margin-top: 7px; }
|
||||
.gpt-mode-label {
|
||||
font-size: 10px; color: var(--text-faint); flex: 1;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: help;
|
||||
}
|
||||
.gpt-send-btn {
|
||||
padding: 6px 18px; border-radius: 20px; border: none;
|
||||
background: var(--gpt-color-openai); color: #fff;
|
||||
font-size: 12px; font-weight: 700; cursor: pointer;
|
||||
transition: opacity .2s, transform .1s;
|
||||
}
|
||||
.gpt-send-btn:hover { opacity: .85; }
|
||||
.gpt-send-btn:active { transform: scale(.97); }
|
||||
.gpt-send-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.gpt-stop-btn {
|
||||
padding: 6px 18px; border-radius: 20px; border: none;
|
||||
background: var(--gpt-color-error); color: #fff;
|
||||
font-size: 12px; font-weight: 700; cursor: pointer;
|
||||
transition: opacity .2s; animation: gpt-pulse-stop 1.2s ease-in-out infinite;
|
||||
}
|
||||
.gpt-stop-btn:hover { opacity: .85; }
|
||||
@keyframes gpt-pulse-stop { 0%, 100% { opacity: .85; } 50% { opacity: 1; } }
|
||||
.gpt-action-btn {
|
||||
display: flex; align-items: center; background: none;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
cursor: pointer; color: var(--text-faint);
|
||||
padding: 4px 7px; border-radius: 8px; transition: all .15s;
|
||||
}
|
||||
.gpt-action-btn:hover { background: var(--background-modifier-hover); color: var(--text-normal); border-color: var(--text-faint); }
|
||||
.gpt-link { color: var(--interactive-accent); text-decoration: underline; text-underline-offset: 2px; }
|
||||
.gpt-link:hover { opacity: .8; }
|
||||
.gpt-cursor {
|
||||
display: inline-block; width: 2px; height: 14px;
|
||||
background: var(--text-normal); margin-left: 1px;
|
||||
vertical-align: middle; animation: gpt-blink .7s step-end infinite;
|
||||
}
|
||||
@keyframes gpt-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
|
||||
|
||||
/* ─── Modal overlay ──────────────────────────────────────────────────────────── */
|
||||
.gpt-modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.55);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 9999;
|
||||
}
|
||||
.gpt-modal-box {
|
||||
background: var(--background-primary); border-radius: 12px; padding: 20px;
|
||||
width: 400px; max-width: 92vw;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.3);
|
||||
}
|
||||
.gpt-modal-title { margin: 0; font-size: 13px; font-weight: 600; }
|
||||
.gpt-modal-input {
|
||||
width: 100%; padding: 8px 12px; border-radius: 8px; box-sizing: border-box;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-secondary); color: var(--text-normal);
|
||||
font-size: 13px; outline: none;
|
||||
}
|
||||
.gpt-modal-input:focus { border-color: var(--gpt-color-openai); }
|
||||
.gpt-modal-list {
|
||||
max-height: 260px; overflow-y: auto;
|
||||
display: flex; flex-direction: column; gap: 3px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 8px; padding: 6px;
|
||||
}
|
||||
.gpt-modal-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 5px 6px; border-radius: 6px; cursor: pointer;
|
||||
font-size: 12px; color: var(--text-normal);
|
||||
}
|
||||
.gpt-modal-row:hover { background: var(--background-modifier-hover); }
|
||||
.gpt-modal-row-label { font-size: 12px; }
|
||||
.gpt-modal-more { font-size: 11px; color: var(--text-faint); padding: 4px 6px; }
|
||||
.gpt-modal-btns { display: flex; gap: 8px; justify-content: flex-end; margin-top: 2px; }
|
||||
.gpt-modal-cancel { padding: 7px 16px; border-radius: 8px; cursor: pointer; background: transparent; border: 1px solid var(--background-modifier-border); color: var(--text-normal); font-size: 12px; }
|
||||
.gpt-modal-ok { padding: 7px 16px; border-radius: 8px; cursor: pointer; background: var(--gpt-color-openai); color: #fff; border: none; font-weight: 700; font-size: 12px; }
|
||||
.gpt-modal-cancel:hover { background: var(--background-modifier-hover); }
|
||||
.gpt-modal-ok:hover { opacity: .87; }
|
||||
.gpt-modal-prompt-label { font-size: 11px; font-weight: 600; color: var(--text-muted); margin-top: 8px; display: flex; align-items: center; gap: 4px; }
|
||||
.gpt-modal-prompt-hint { font-size: 10px; color: var(--text-faint); margin-top: 2px; }
|
||||
.gpt-modal-prompt-area { margin-top: 4px; min-height: 80px; resize: vertical; font-size: 12px; line-height: 1.5; }
|
||||
|
||||
/* ─── Quiz ───────────────────────────────────────────────────────────────────── */
|
||||
.gpt-quiz-title {
|
||||
font-size: 14px; font-weight: 700; margin-bottom: 12px;
|
||||
color: var(--text-normal); padding: 2px 0;
|
||||
}
|
||||
.gpt-quiz-card {
|
||||
background: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 12px; padding: 14px 16px; margin-bottom: 12px;
|
||||
}
|
||||
.gpt-quiz-qnum { font-size: 10px; font-weight: 600; color: var(--text-faint); text-transform: uppercase; letter-spacing: .06em; margin-bottom: 6px; }
|
||||
.gpt-quiz-qtext { font-size: 13px; font-weight: 600; color: var(--text-normal); margin-bottom: 12px; line-height: 1.5; }
|
||||
.gpt-quiz-opts { display: flex; flex-direction: column; gap: 7px; }
|
||||
.gpt-quiz-opt {
|
||||
padding: 9px 14px; border-radius: 8px;
|
||||
border: 1.5px solid var(--background-modifier-border);
|
||||
background: var(--background-primary); color: var(--text-normal);
|
||||
font-size: 12px; text-align: left; cursor: pointer;
|
||||
transition: all .15s; font-family: var(--font-interface);
|
||||
}
|
||||
.gpt-quiz-opt:hover:not(:disabled) {
|
||||
border-color: var(--interactive-accent);
|
||||
background: color-mix(in srgb, var(--interactive-accent) 8%, var(--background-primary));
|
||||
}
|
||||
.gpt-quiz-opt--correct {
|
||||
border-color: var(--gpt-color-openai) !important;
|
||||
background: color-mix(in srgb, var(--gpt-color-openai) 12%, var(--background-primary)) !important;
|
||||
color: var(--gpt-color-ok-text) !important; font-weight: 600;
|
||||
}
|
||||
.gpt-quiz-opt--wrong {
|
||||
border-color: var(--gpt-color-error) !important;
|
||||
background: color-mix(in srgb, var(--gpt-color-error) 10%, var(--background-primary)) !important;
|
||||
color: var(--gpt-color-err-text) !important;
|
||||
}
|
||||
.gpt-quiz-opt:disabled { cursor: default; }
|
||||
.gpt-quiz-fb { margin-top: 10px; padding: 9px 12px; border-radius: 8px; font-size: 12px; line-height: 1.5; }
|
||||
.gpt-quiz-fb--ok {
|
||||
background: color-mix(in srgb, var(--gpt-color-openai) 12%, var(--background-primary));
|
||||
color: var(--gpt-color-ok-text);
|
||||
border: 1px solid color-mix(in srgb, var(--gpt-color-openai) 25%, transparent);
|
||||
}
|
||||
.gpt-quiz-fb--err {
|
||||
background: color-mix(in srgb, var(--gpt-color-error) 10%, var(--background-primary));
|
||||
color: var(--gpt-color-err-text);
|
||||
border: 1px solid color-mix(in srgb, var(--gpt-color-error) 20%, transparent);
|
||||
}
|
||||
.gpt-quiz-input {
|
||||
width: 100%; padding: 8px 12px; border-radius: 8px;
|
||||
border: 1.5px solid var(--background-modifier-border);
|
||||
background: var(--background-primary); color: var(--text-normal);
|
||||
font-size: 12px; resize: none; outline: none; box-sizing: border-box;
|
||||
font-family: var(--font-interface); transition: border-color .15s; margin-bottom: 8px;
|
||||
}
|
||||
.gpt-quiz-input:focus { border-color: var(--interactive-accent); }
|
||||
.gpt-quiz-check {
|
||||
padding: 6px 16px; border-radius: 8px; border: none;
|
||||
background: var(--interactive-accent); color: #fff;
|
||||
font-size: 12px; font-weight: 600; cursor: pointer; transition: opacity .15s;
|
||||
}
|
||||
.gpt-quiz-check:hover { opacity: .85; }
|
||||
.gpt-quiz-check:disabled { opacity: .45; cursor: not-allowed; }
|
||||
|
||||
/* ─── History view ───────────────────────────────────────────────────────────── */
|
||||
.gpt-history-list {
|
||||
flex: 1; overflow-y: auto; padding: 8px;
|
||||
display: flex; flex-direction: column; gap: 4px; scrollbar-width: thin;
|
||||
}
|
||||
.gpt-history-item {
|
||||
padding: 9px 11px; border-radius: 8px; cursor: pointer;
|
||||
border: 1px solid transparent; background: var(--background-secondary);
|
||||
transition: all .15s;
|
||||
}
|
||||
.gpt-history-item--active {
|
||||
border-color: var(--interactive-accent);
|
||||
background: color-mix(in srgb, var(--interactive-accent) 10%, var(--background-secondary));
|
||||
}
|
||||
.gpt-history-item:hover:not(.gpt-history-item--active) { background: var(--background-modifier-hover); }
|
||||
.gpt-history-item:hover .gpt-history-item-del,
|
||||
.gpt-projects-card:hover .gpt-projects-icon-btn { opacity: 1; }
|
||||
.gpt-history-item-top { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.gpt-history-item-title { font-size: 12px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; color: var(--text-normal); }
|
||||
.gpt-history-item-del,
|
||||
.gpt-projects-icon-btn { background: none; border: none; cursor: pointer; color: var(--text-faint); padding: 2px; border-radius: 3px; opacity: 0; transition: opacity .15s; flex-shrink: 0; }
|
||||
.gpt-history-item-date { font-size: 10px; color: var(--text-faint); margin-top: 2px; }
|
||||
.gpt-history-item-preview { font-size: 11px; color: var(--text-muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.gpt-history-empty { color: var(--text-muted); font-size: 12px; text-align: center; margin-top: 24px; line-height: 1.6; }
|
||||
.gpt-history-empty-hint { color: var(--text-faint); font-size: 11px; text-align: center; margin-top: 6px; white-space: pre-line; }
|
||||
.gpt-projects-shortcut {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 9px 14px; border-bottom: 1px solid var(--background-modifier-border);
|
||||
cursor: pointer; transition: background .15s; flex-shrink: 0;
|
||||
}
|
||||
.gpt-projects-shortcut:hover { background: var(--background-modifier-hover); }
|
||||
.gpt-projects-shortcut-label { font-size: 12px; font-weight: 600; flex: 1; color: var(--text-normal); }
|
||||
.gpt-projects-shortcut-badge {
|
||||
font-size: 10px; padding: 1px 7px; border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--gpt-color-claude) 15%, var(--background-secondary));
|
||||
color: var(--gpt-color-claude); font-weight: 700;
|
||||
}
|
||||
.gpt-projects-shortcut-arrow { font-size: 16px; color: var(--text-faint); font-weight: 300; }
|
||||
|
||||
/* ─── Projects view ──────────────────────────────────────────────────────────── */
|
||||
.gpt-projects-list { flex: 1; overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 6px; scrollbar-width: thin; }
|
||||
.gpt-projects-active-bar { display: flex; align-items: center; gap: 8px; padding: 8px 14px; border-bottom: 1px solid; flex-shrink: 0; font-size: 11px; }
|
||||
.gpt-projects-active-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.gpt-projects-active-name { font-size: 11px; font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.gpt-projects-empty { color: var(--text-muted); font-size: 12px; text-align: center; margin-top: 24px; padding: 0 12px; line-height: 1.6; }
|
||||
.gpt-projects-empty-hint { font-size: 11px; opacity: .7; margin-top: 4px; }
|
||||
.gpt-projects-card { padding: 11px 13px; border-radius: 10px; cursor: pointer; border: 2px solid transparent; background: var(--background-secondary); transition: all .15s; }
|
||||
.gpt-projects-card:hover { background: var(--background-modifier-hover); }
|
||||
.gpt-projects-card-top { display: flex; align-items: center; gap: 8px; }
|
||||
.gpt-projects-card-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.gpt-projects-card-name { font-size: 12px; font-weight: 700; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-normal); }
|
||||
.gpt-projects-card-badge { font-size: 10px; padding: 1px 7px; border-radius: 10px; font-weight: 600; flex-shrink: 0; }
|
||||
.gpt-projects-card-desc { font-size: 11px; color: var(--text-muted); margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.gpt-projects-card-prompt-tag { display: inline-flex; align-items: center; gap: 4px; margin-top: 4px; padding: 2px 8px; border-radius: 10px; font-size: 10px; font-weight: 600; border: 1px solid; }
|
||||
.gpt-projects-card-chats { margin-top: 8px; display: flex; flex-direction: column; gap: 3px; }
|
||||
.gpt-projects-card-chat { display: flex; align-items: center; gap: 6px; padding: 4px 8px; border-radius: 6px; font-size: 11px; color: var(--text-muted); cursor: pointer; transition: background .1s; }
|
||||
.gpt-projects-card-chat:hover { background: var(--background-modifier-hover); }
|
||||
.gpt-projects-card-chat-icon { flex-shrink: 0; opacity: .6; }
|
||||
.gpt-projects-card-chat-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.gpt-projects-card-chat-date { font-size: 9px; color: var(--text-faint); flex-shrink: 0; }
|
||||
.gpt-projects-card-chat-del {
|
||||
display: none; flex-shrink: 0; padding: 2px 4px;
|
||||
border: none; background: transparent; color: var(--text-faint);
|
||||
cursor: pointer; border-radius: 4px; line-height: 1;
|
||||
transition: color .12s, background .12s;
|
||||
}
|
||||
.gpt-projects-card-chat-del:hover { color: var(--gpt-color-error); background: color-mix(in srgb, var(--gpt-color-error) 12%, transparent); }
|
||||
.gpt-projects-card-chat:hover .gpt-projects-card-chat-del { display: flex; align-items: center; }
|
||||
.gpt-projects-card-more { font-size: 10px; color: var(--text-faint); padding: 2px 8px; }
|
||||
|
||||
/* ─── Fallback modal ─────────────────────────────────────────────────────────── */
|
||||
.gpt-fallback-modal h2 { margin: 0 0 12px; font-size: 18px; }
|
||||
.gpt-fallback-desc { font-size: 13px; line-height: 1.55; color: var(--text-normal); }
|
||||
.gpt-fallback-desc p { margin: 8px 0; }
|
||||
.gpt-fallback-hint {
|
||||
padding: 10px 12px; margin: 10px 0;
|
||||
background: color-mix(in srgb, var(--gpt-color-learn) 10%, var(--background-secondary));
|
||||
border: 1px solid color-mix(in srgb, var(--gpt-color-learn) 30%, transparent);
|
||||
border-radius: 6px; font-size: 12px; line-height: 1.55;
|
||||
}
|
||||
.gpt-fallback-err { padding: 8px 10px; margin: 10px 0; background: var(--background-secondary); border-radius: 6px; font-size: 11px; color: var(--text-muted); font-family: var(--font-monospace); word-break: break-word; }
|
||||
.gpt-fallback-err code { background: transparent; padding: 0; color: var(--color-red, var(--gpt-color-error)); }
|
||||
.gpt-fallback-check { display: flex; align-items: center; gap: 6px; margin: 14px 0 8px; font-size: 12px; color: var(--text-muted); }
|
||||
.gpt-fallback-check input[type=checkbox] { margin: 0; }
|
||||
.gpt-fallback-check label { cursor: pointer; }
|
||||
.gpt-fallback-buttons { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; }
|
||||
.gpt-fallback-buttons button {
|
||||
padding: 6px 14px; font-size: 13px; border-radius: 6px; cursor: pointer;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-secondary); color: var(--text-normal); transition: background .15s;
|
||||
}
|
||||
.gpt-fallback-buttons button:hover { background: var(--background-modifier-hover); }
|
||||
.gpt-fallback-buttons button.mod-cta { background: var(--interactive-accent); color: var(--text-on-accent); border-color: var(--interactive-accent); }
|
||||
.gpt-fallback-buttons button.mod-cta:hover { background: var(--interactive-accent-hover, var(--interactive-accent)); }
|
||||
|
||||
/* ─── Settings tab ───────────────────────────────────────────────────────────── */
|
||||
.gpt-settings-warning {
|
||||
padding: 8px 12px; margin-bottom: 14px; border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--gpt-color-learn) 10%, var(--background-secondary));
|
||||
border: 1px solid color-mix(in srgb, var(--gpt-color-learn) 30%, transparent);
|
||||
font-size: 12px; color: var(--text-muted);
|
||||
}
|
||||
.gpt-settings-note {
|
||||
font-size: 11px; color: var(--text-muted);
|
||||
margin: -8px 0 12px; padding: 0 12px;
|
||||
}
|
||||
.gpt-settings-rag-status {
|
||||
padding: 8px 12px; margin: 8px 0; border-radius: 6px;
|
||||
background: var(--background-secondary); font-size: 12px; color: var(--text-muted);
|
||||
}
|
||||
.gpt-settings-storage-info {
|
||||
padding: 10px 14px; margin-bottom: 14px; border-radius: 6px;
|
||||
font-size: 12px; line-height: 1.6;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"ignoreDeprecations": "5.0",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES2022",
|
||||
"allowImportingTsExtensions": true,
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/main.ts"]
|
||||
}
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "1.4.0"
|
||||
}
|
||||
Loading…
Reference in a new issue