first commit

This commit is contained in:
JamJan05 2026-05-21 02:47:47 +02:00
commit 49841447c9
38 changed files with 7383 additions and 0 deletions

33
.github/workflows/release.yml vendored Normal file
View 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
View file

@ -0,0 +1,6 @@
node_modules/
main.js
*.js.map
.DS_Store
Thumbs.db
data.json

21
LICENSE Normal file
View 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
View 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 AIs 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 its 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 youd like to change.
-----
## 📜 License
[MIT](LICENSE) © 2026 JamJan05

40
esbuild.config.mjs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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";

View 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 };
}
}

View 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
View file

@ -0,0 +1,2 @@
export { HistoryManager } from "./HistoryManager";
export { ProjectManager } from "./ProjectManager";

598
src/i18n.ts Normal file
View 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} &nbsp;|&nbsp; <strong>Notes:</strong> ${files} &nbsp;|&nbsp; <strong>Chunks:</strong> ${chunks} &nbsp;|&nbsp; <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 510 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} &nbsp;|&nbsp; <strong>Notatki:</strong> ${files} &nbsp;|&nbsp; <strong>Chunki:</strong> ${chunks} &nbsp;|&nbsp; <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 510 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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",
};

View 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;
}
}

View 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
View file

@ -0,0 +1,3 @@
export { PluginStorage } from "./PluginStorage";
export { ExternalStorage } from "./ExternalStorage";
export type { ListResult } from "./PluginStorage";

99
src/types.ts Normal file
View 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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/** 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

File diff suppressed because it is too large Load diff

View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
export { GPTHistoryView } from "./HistoryView";
export { GPTProjectsView } from "./ProjectsView";
export { FallbackModal } from "./FallbackModal";

627
styles.css Normal file
View 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
View 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
View file

@ -0,0 +1,3 @@
{
"1.0.0": "1.4.0"
}