From 49841447c91978bfac7775ea67171ce2814db0d0 Mon Sep 17 00:00:00 2001 From: JamJan05 Date: Thu, 21 May 2026 02:47:47 +0200 Subject: [PATCH] first commit --- .github/workflows/release.yml | 33 + .gitignore | 6 + LICENSE | 21 + README.md | 191 +++++ esbuild.config.mjs | 40 + manifest.json | 10 + package-lock.json | 611 +++++++++++++++ package.json | 22 + src/SettingsTab.ts | 459 +++++++++++ src/api/anthropic.ts | 77 ++ src/api/index.ts | 4 + src/api/openai.ts | 240 ++++++ src/api/streaming.ts | 196 +++++ src/constants.ts | 23 + src/history/HistoryManager.ts | 150 ++++ src/history/ProjectManager.ts | 152 ++++ src/history/index.ts | 2 + src/i18n.ts | 598 ++++++++++++++ src/main.ts | 398 ++++++++++ src/models.ts | 132 ++++ src/rag/RAGEngine.ts | 480 ++++++++++++ src/rag/canvasParser.ts | 158 ++++ src/rag/index.ts | 4 + src/rag/linkResolver.ts | 60 ++ src/settings.ts | 76 ++ src/storage/ExternalStorage.ts | 312 ++++++++ src/storage/PluginStorage.ts | 93 +++ src/storage/index.ts | 3 + src/types.ts | 99 +++ src/utils.ts | 236 ++++++ src/views/ChatView.ts | 1327 ++++++++++++++++++++++++++++++++ src/views/FallbackModal.ts | 96 +++ src/views/HistoryView.ts | 124 +++ src/views/ProjectsView.ts | 297 +++++++ src/views/index.ts | 3 + styles.css | 627 +++++++++++++++ tsconfig.json | 20 + versions.json | 3 + 38 files changed, 7383 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 esbuild.config.mjs create mode 100644 manifest.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/SettingsTab.ts create mode 100644 src/api/anthropic.ts create mode 100644 src/api/index.ts create mode 100644 src/api/openai.ts create mode 100644 src/api/streaming.ts create mode 100644 src/constants.ts create mode 100644 src/history/HistoryManager.ts create mode 100644 src/history/ProjectManager.ts create mode 100644 src/history/index.ts create mode 100644 src/i18n.ts create mode 100644 src/main.ts create mode 100644 src/models.ts create mode 100644 src/rag/RAGEngine.ts create mode 100644 src/rag/canvasParser.ts create mode 100644 src/rag/index.ts create mode 100644 src/rag/linkResolver.ts create mode 100644 src/settings.ts create mode 100644 src/storage/ExternalStorage.ts create mode 100644 src/storage/PluginStorage.ts create mode 100644 src/storage/index.ts create mode 100644 src/types.ts create mode 100644 src/utils.ts create mode 100644 src/views/ChatView.ts create mode 100644 src/views/FallbackModal.ts create mode 100644 src/views/HistoryView.ts create mode 100644 src/views/ProjectsView.ts create mode 100644 src/views/index.ts create mode 100644 styles.css create mode 100644 tsconfig.json create mode 100644 versions.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9abbf5f --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b3f57f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +main.js +*.js.map +.DS_Store +Thumbs.db +data.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9553e0c --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc6ed82 --- /dev/null +++ b/README.md @@ -0,0 +1,191 @@ +# AI Vault for Obsidian + +Chat with **GPT** and **Claude** directly inside Obsidian β€” with RAG over your vault, conversation history, projects, and smart modes. + +AI Vault turns Obsidian into a full AI workspace. Talk to multiple frontier models, give them access to your notes and canvases, organize chats into projects, and keep everything synced with your vault β€” without ever leaving the app. + +----- + +## ✨ Features + +### πŸ€– Multi-provider AI + +Switch between **OpenAI** and **Anthropic** models with a single click in the chat header β€” no need to dive into settings. + +**OpenAI models:** + +- GPT-5 +- GPT-5 nano +- GPT-5 mini +- GPT-5 Search +- GPT-4o +- GPT-4o mini +- GPT-4o turbo + +**Anthropic models:** + +- Claude Sonnet 4.5 +- Claude Opus 4.5 +- Claude Haiku 4.5 + +### πŸ“š RAG over your vault + +The plugin indexes your notes locally and feeds the most relevant context to the AI on every message. + +- Full support for **Markdown** (`.md`) files +- Full support for **Canvas** (`.canvas`) files β€” the parser understands canvas topology through edges and reconstructs the logical flow of nodes +- Local index stored outside the vault (no sync bloat) + +### πŸ—‚οΈ Projects + +Group related conversations into **projects** β€” each with its own system prompt, model, and context scope. Perfect for separating work, study, and personal threads. + +### πŸ’¬ Conversation history + +Every chat is saved automatically and stored **outside the vault**, so it never inflates your Obsidian Sync or clutters your file tree. + +### 🎯 Smart modes + +Switch the AI’s behavior depending on what you need: + +- **Code mode** β€” focused on programming, with code-aware formatting +- **Learn mode** β€” explains concepts step by step, asks clarifying questions +- **Thinking mode** β€” deeper reasoning before answering + +### 🌐 Web search + +Use GPT-5 Search to fetch live information from the web directly in your chat. + +### 🌍 Bilingual UI + +Full interface in **English** and **Polish**. + +### ⚑ Streaming & cost tracking + +- Live token streaming β€” see the response as it’s generated +- Built-in **token counter** and **cost tracker** for every conversation + +----- + +## πŸ“± Platform support + +|Platform|Status | +|--------|--------------------------------------| +|Windows |βœ… Supported | +|macOS |βœ… Supported | +|Linux |βœ… Supported | +|iOS |βœ… Supported | +|Android |⚠️ Should work (not extensively tested)| + +The plugin is **not desktop-only** β€” it runs on mobile devices as well. + +----- + +## πŸš€ Installation + +### From Obsidian Community Plugins (recommended once approved) + +1. Open **Settings β†’ Community plugins** +1. Make sure **Restricted mode** is **off** +1. Click **Browse** and search for **AI Vault** +1. Click **Install**, then **Enable** + +### Manual installation + +1. Download the latest release from the [Releases page](https://github.com/JamJan05/AI-Vault-for-Obsidian/releases) +1. Extract `main.js`, `manifest.json`, and `styles.css` into: + + ``` + /.obsidian/plugins/ai-vault/ + ``` +1. Reload Obsidian +1. Enable **AI Vault** in **Settings β†’ Community plugins** + +----- + +## πŸ”‘ Setup + +After enabling the plugin: + +1. Open **Settings β†’ AI Vault** +1. Paste your **OpenAI API key** and/or **Anthropic API key** +1. Choose your default model +1. Open the chat from the ribbon icon on the left sidebar + +> Your API keys are stored locally on your device. They are never sent anywhere except directly to the official OpenAI and Anthropic endpoints. + +----- + +## πŸ“– Usage + +### Starting a chat + +Click the **AI Vault** icon in the left ribbon β€” the chat panel opens on the right. + +### Switching models + +Click the model name in the chat header to switch between GPT and Claude variants without leaving the conversation. + +### Using vault context (RAG) + +Toggle the **vault context** button to let the AI search your notes and canvases for relevant information before answering. + +### Creating a project + +Use the **Projects** menu in the chat header to create a new project, set its system prompt, and assign conversations to it. + +### Changing mode + +Pick **Code**, **Learn**, or **Thinking** mode from the mode selector β€” the AI will adapt its style accordingly. + +----- + +## πŸ”’ Privacy + +- **API keys** are stored locally on your device. +- **Conversation history** is stored locally, outside your vault. +- **RAG index** is stored locally, outside your vault. +- Vault content is sent to the model **only** when you enable vault context, and only the relevant chunks are transmitted. +- No data is sent to any third party other than the official OpenAI / Anthropic APIs. + +----- + +## πŸ’° Cost + +The plugin itself is **free and open source**. + +You pay only for what you use via your own OpenAI / Anthropic API keys, billed directly by the providers. The built-in cost tracker shows estimated spend per conversation. + +----- + +## πŸ› οΈ Tech + +- Written in **TypeScript** +- Built with **esbuild** +- Uses official **OpenAI** and **Anthropic** REST APIs with streaming (SSE) +- Local-first architecture β€” no external servers + +----- + +## πŸ› Reporting issues + +Found a bug or have a feature request? Open an issue on [GitHub Issues](https://github.com/JamJan05/AI-Vault-for-Obsidian/issues). + +When reporting bugs, please include: + +- Obsidian version +- Operating system +- Plugin version +- Steps to reproduce + +----- + +## 🀝 Contributing + +Pull requests are welcome. For larger changes, please open an issue first to discuss what you’d like to change. + +----- + +## πŸ“œ License + +[MIT](LICENSE) Β© 2026 JamJan05 \ No newline at end of file diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..b58c6de --- /dev/null +++ b/esbuild.config.mjs @@ -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(); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..5e50968 --- /dev/null +++ b/manifest.json @@ -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 +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6f73802 --- /dev/null +++ b/package-lock.json @@ -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 + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..836b540 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/SettingsTab.ts b/src/SettingsTab.ts new file mode 100644 index 0000000..315918e --- /dev/null +++ b/src/SettingsTab.ts @@ -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; + loadData(): Promise>; + saveData(data: Record): Promise; + 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 = + `βœ… ${t("settings_storage_active")}
` + + `Obsidian Sync does not sync this data.

` + + `Location:
` + + `${currentPath}`; + } 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)); + } + }), + ); + } +} diff --git a/src/api/anthropic.ts b/src/api/anthropic.ts new file mode 100644 index 0000000..8ac7638 --- /dev/null +++ b/src/api/anthropic.ts @@ -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 { + 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 = { + 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, + ), + ); +} diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..f27c6f9 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,4 @@ +export { callOpenAI, callOpenAIResponses } from "./openai"; +export { callClaude } from "./anthropic"; +export { streamSSE, throwHttpError } from "./streaming"; +export type { StreamResult, StreamUsage } from "./streaming"; diff --git a/src/api/openai.ts b/src/api/openai.ts new file mode 100644 index 0000000..542c1ea --- /dev/null +++ b/src/api/openai.ts @@ -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 { + 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; + + 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 { + 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; + try { event = JSON.parse(data) as Record; } + 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 }; + }); +} diff --git a/src/api/streaming.ts b/src/api/streaming.ts new file mode 100644 index 0000000..3a0f8be --- /dev/null +++ b/src/api/streaming.ts @@ -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 | 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 { + 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, + body: Record, + extractDelta: DeltaExtractor, + onChunk: ((fullText: string) => void) | null, + signal?: AbortSignal | null, +): Promise { + 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; + try { event = JSON.parse(data) as Record; } + 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 | undefined; + if (eventUsage) { + const details: Record | undefined = eventUsage.completion_tokens_details as Record | 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 }; +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..24aebc0 --- /dev/null +++ b/src/constants.ts @@ -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"; diff --git a/src/history/HistoryManager.ts b/src/history/HistoryManager.ts new file mode 100644 index 0000000..9edd9f5 --- /dev/null +++ b/src/history/HistoryManager.ts @@ -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 = {}; + + 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 { + await this.storage.ensureDir(this.historyDir); + const index = await this.storage.readJson(this.indexPath, null); + if (Array.isArray(index) && index.length) { + this.sessions = index; + } + } + + // ── Saving ───────────────────────────────────────────────────────────────── + + private async saveIndex(): Promise { + await this.storage.writeJson(this.indexPath, this.sessions); + } + + async save(): Promise { + await this.saveIndex(); + } + + // ── Messages ─────────────────────────────────────────────────────────────── + + async getMessages(sessionId: string): Promise { + 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(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 { + // 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 { + 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 { + const meta = this.sessions.find(s => s.id === id); + if (!meta) return null; + + const messages = await this.getMessages(id); + return { ...meta, messages }; + } +} diff --git a/src/history/ProjectManager.ts b/src/history/ProjectManager.ts new file mode 100644 index 0000000..0617721 --- /dev/null +++ b/src/history/ProjectManager.ts @@ -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 { + const data = await this.storage.readJson(this.filePath, []); + this.projects = Array.isArray(data) ? data : []; + } + + async save(): Promise { + await this.storage.writeJson(this.filePath, this.projects); + } + + // ── Project CRUD ─────────────────────────────────────────────────────────── + + async createProject( + name: string, + description = "", + customPrompt = "", + ): Promise { + 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): Promise { + 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 { + 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 { + 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 { + 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)]; + } +} diff --git a/src/history/index.ts b/src/history/index.ts new file mode 100644 index 0000000..9d896cf --- /dev/null +++ b/src/history/index.ts @@ -0,0 +1,2 @@ +export { HistoryManager } from "./HistoryManager"; +export { ProjectManager } from "./ProjectManager"; diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..0542ccf --- /dev/null +++ b/src/i18n.ts @@ -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: "⚠️ Disabled β€” history saved inside vault
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:"πŸ“± Mobile device detected
External storage outside vault works on desktop only. On mobile, data is saved in the plugin folder inside the vault.

πŸ’‘ Tip: To limit Obsidian Sync, disable configuration file sync (.obsidian) or add the plugin folder to exclusions.", + settings_storage_mobile_na: "(unavailable on mobile)", + settings_keys_local_warning_html: "πŸ”’ API keys are stored locally 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) => + `Status: ${indexed}  |  Notes: ${files}  |  Chunks: ${chunks}  |  Embeddings: ${embs}`, + rag_done: (n: number) => `βœ… RAG: ${n} notes`, + + // Tokens / cost + tokens_label: (n: number) => `~${n} tokens`, + tokens_session_cost: (v: string) => `\nSession total: ${v}`, + + // Notices + notice_model_changed: (m: string) => `βœ“ Model: ${m}`, + notice_keys_moved_local: "πŸ”’ API keys moved outside vault.", + notice_keys_moved_sync: "☁️ API keys will be synced via Obsidian Sync.", + notice_keys_migrated: "πŸ”‘ API keys moved outside vault.", + notice_fallback_switched: (m: string) => `βœ… Switched to ${m}. Retrying message…`, + notice_fallback_return: (m: string) => `↩ Returned to ${m}`, + notice_migration_done: (n: number, p: string) => `βœ… AI-Vault: Chat history moved outside vault (${n} files).\nLocation: ${p}`, + notice_migration_partial: (n: number) => `⚠️ AI-Vault: Migration partially failed β€” ${n} errors. Check console.`, + notice_migration_partial_short: (m: number, e: number) => `⚠️ Moved ${m}, errors: ${e}. Console: F12.`, + notice_migrated_manual: (n: number, s: number) => `βœ… Moved ${n} files (skipped ${s}).`, + notice_migration_failed: (e: string) => `❌ Migration failed: ${e}`, + notice_export_done: (f: string) => `πŸ“ Exported to: ${f}`, + notice_export_fail: (e: string) => `❌ Export error: ${e}`, + notice_manual_notes: (n: number) => `πŸ“Ž Added ${n} note(s) to context`, + notice_restart_required: "⏳ Change requires plugin restart (disable/enable in Community Plugins).", + notice_storage_open_fail: (e: string) => `Could not open: ${e}`, + + // Fallback modal + fallback_title: "⚠️ Model unavailable", + fallback_unavailable: (m: string) => `Model ${m} is unavailable for your OpenAI account.`, + fallback_tier1: "Most common cause: GPT-5 models require an API account with minimal spending history (Tier 1, ~$5).", + fallback_api_error: "API error: ", + fallback_suggest: (m: string) => `I can switch the conversation to ${m} (always works) and retry the message.`, + fallback_save_default: (m: string) => ` Set ${m} as default model (you can change this in settings)`, + fallback_cancel: "Cancel", + fallback_accept: (m: string) => `Switch to ${m} and retry`, + + // Errors + err_no_openai_key: "⚠️ Set your OpenAI API key in settings.", + err_no_claude_key: "⚠️ Set your Claude API key in settings.", + err_empty_response: "Model returned an empty response. Please try again.", + err_stream: "Streaming error", + err_stream_responses: "Responses API streaming error", + + // Canvas + canvas_parse_error: (b: string) => `[Canvas: ${b} β€” JSON parse error]`, + canvas_no_nodes: (b: string) => `[Canvas: ${b} β€” no nodes]`, + canvas_flow_header: "## Flow\n", + canvas_content_header: "## Content\n", + + // Export + export_role_user: "User", + export_role_assistant: "Assistant", + export_no_messages: "No messages to export.", + export_header: (title: string, model: string, date: string) => `# ${title}\n\n> Model: ${model} | Date: ${date}\n\n---\n\n`, + export_user: "**You:**", + export_assistant: "**Assistant:**", + + // Provider + provider_switched_gpt: "πŸ€– Switched to GPT", + provider_switched_claude:"🟣 Switched to Claude", + + // Commands + cmd_open_chat: "Open AI-Vault chat panel", + cmd_open_history: "Open chat history", + cmd_open_projects: "Open projects", + cmd_summarize: "AI-Vault: Summarize current note", + + // Quiz + quiz_error: "Error!", + quiz_no_question: "(no question text)", + quiz_wrong_prefix: "❌ Wrong. Correct answer: ", + quiz_correct_prefix: "❌ Correct answer: ", + quiz_check_btn: "Check", + quiz_eval_error: "Evaluation error. Please try again.", + quiz_true_option: "True", + quiz_false_option: "False", + quiz_fill_placeholder: "Type the missing word…", + quiz_open_placeholder: "Type your answer…", + quiz_eval_prompt: (q: string, a: string, u: string) => `Evaluate the student's answer. Question: "${q}". Model answer: "${a}". Student's answer: "${u}". Reply ONLY in JSON format: {"correct": true/false, "feedback": "brief explanation max 2 sentences"}`, + quiz_instruction: "\n\nYOU ARE IN LEARN MODE. Your only task is to test the user's knowledge through tests and quizzes. When the user provides a topic or asks for a test, ALWAYS reply ONLY in JSON format (no text before or after):\n```json\n{\"title\": \"Test title\", \"questions\": [{\"type\": \"choice\", \"question\": \"Question text\", \"options\": [\"A\",\"B\",\"C\",\"D\"], \"correct\": 0, \"explanation\": \"Explanation\"}]}\n```\nQuestion types: choice (A/B/C/D), truefalse (options: [\"True\",\"False\"], correct: 0 or 1), open (open answer, answer field with model answer), fill (fill in the blank, answer field with answer). Generate 5–10 questions unless specified otherwise.", + + // Code mode prompts + code_system_prompt_intro: "You are an expert programmer and software architect. Your answers focus EXCLUSIVELY on coding.\n\n", + code_rule_1: "- Always provide complete, ready-to-use code (not fragments)\n", + code_rule_2: "- Use best practices and design patterns\n", + code_rule_3: "- Explain architectural decisions briefly and concisely\n", + code_rule_4: "- If the user did not specify a language, ask or choose the best one\n", + code_rule_4_lang: "language\n...", + code_rule_5: "- Reply in the user's language (code comments in English)\n\n", + code_system_prompt_closing: "Do not answer questions unrelated to programming β€” politely redirect to the topic of coding.", + + // Storage + storage_save_error: (f: string) => `Failed to save ${f}`, + default_system_prompt: "You are a helpful assistant integrated with Obsidian. Reply in the user's language. Be concise, specific and helpful.", +}; + +const pl: TranslationDict = { + // Settings + settings_title: "AI-Vault β€” Ustawienia", + settings_keys_sync_name: "Synchronizuj klucze API przez Obsidian Sync", + settings_keys_sync_desc_on: "⚠️ Klucze API sΔ… zapisywane w data.json β€” synchronizowane przez Obsidian Sync. Wygodne miΔ™dzy urzΔ…dzeniami, ale klucze trafiajΔ… na serwery Obsidian.", + settings_keys_sync_desc_off: "πŸ”’ Klucze API przechowywane lokalnie (poza vaultem) β€” Obsidian Sync ich nie synchronizuje. Musisz wpisaΔ‡ klucz raz na kaΕΌdym nowym urzΔ…dzeniu.", + settings_keys_mobile_note: "ℹ️ PrzeΕ‚Δ…cznik niedostΔ™pny na mobile β€” klucze zawsze w data.json.", + settings_key_local: "πŸ”’ Przechowywany lokalnie poza vaultem β€” Obsidian Sync go nie synchronizuje.", + settings_key_sync: "☁️ Przechowywany w data.json β€” synchronizowany przez Obsidian Sync.", + settings_openai_title: "πŸ€– OpenAI", + settings_openai_key_name: "Klucz API OpenAI", + settings_openai_model_name: "Model OpenAI", + settings_thinking_name: "DomyΕ›lny tryb myΕ›lenia", + settings_max_tokens_title: "Maks. tokeny na tryb", + settings_max_tokens_fast_name: "⚑ Szybki β€” maks. tokeny", + settings_max_tokens_fast_desc: "Maksymalna liczba tokenΓ³w wyjΕ›ciowych dla trybu Szybki (domyΕ›lnie: 4096)", + settings_max_tokens_normal_name: "βš–οΈ Normalny β€” maks. tokeny", + settings_max_tokens_normal_desc: "Maksymalna liczba tokenΓ³w wyjΕ›ciowych dla trybu Normalny (domyΕ›lnie: 8192)", + settings_max_tokens_think_name: "🧠 MyΕ›lΔ…cy β€” maks. tokeny", + settings_max_tokens_think_desc: "Maksymalna liczba tokenΓ³w wyjΕ›ciowych dla trybu MyΕ›lΔ…cy (domyΕ›lnie: 16000). Dla Claude: budΕΌet myΕ›lenia = ta wartoΕ›Δ‡; rzeczywiste max_tokens = ta wartoΕ›Δ‡ + 8000.", + settings_system_prompt_name: "Prompt systemowy", + settings_system_prompt_desc: "Globalny prompt. Projekty z wΕ‚asnym promptem nadpisujΔ… ten.", + settings_system_prompt_reset: "Resetuj do domyΕ›lnego", + settings_system_prompt_reset_tip: "PrzywrΓ³Δ‡ domyΕ›lny prompt systemowy dla bieΕΌΔ…cego jΔ™zyka", + settings_claude_title: "🟣 Claude (Anthropic)", + settings_claude_key_name: "Klucz API Claude", + settings_claude_model_name: "Model Claude", + settings_context_title: "Okno kontekstu", + settings_context_name: "Maks. wiadomoΕ›ci w kontekΕ›cie", + settings_context_desc: "Liczba poprzednich wiadomoΕ›ci wysyΕ‚anych do modelu. 0 = bez limitu (obecny domyΕ›lny).", + settings_rag_title: "πŸ—ƒοΈ RAG β€” Przeszukiwanie notatek", + settings_rag_enable_name: "WΕ‚Δ…cz RAG", + settings_rag_enable_desc: "Automatyczne wyszukiwanie pasujΔ…cych notatek do kaΕΌdej wiadomoΕ›ci.", + settings_rag_auto_name: "Auto-indeksowanie przy starcie", + settings_rag_auto_desc: "Indeksuj nowe i zmienione notatki gdy otwierasz panel chatu.", + settings_rag_reindex_name: "Przeindeksuj vault", + settings_rag_reindex_btn: "Indeksuj teraz", + settings_rag_indexing: "Indeksowanie…", + settings_storage_title: "πŸ’Ύ Lokalny zapis (poza vaultem)", + settings_storage_name: "Zapis poza vaultem", + settings_storage_desc: "Historia rozmΓ³w, projekty i indeks RAG zapisywane w lokalnym folderze obok vaultu β€” Obsidian Sync ich nie synchronizuje. DziaΕ‚a tylko na desktopie.", + settings_storage_path_name: "ŚcieΕΌka folderu (opcjonalnie)", + settings_storage_path_desc: (p: string) => `PeΕ‚na Ε›cieΕΌka systemowa. Pusta = domyΕ›lny folder obok vaultu. DomyΕ›lnie: ${p}`, + settings_storage_migrate_name: "Migruj historiΔ™ z vaultu", + settings_storage_migrate_desc: "RΔ™cznie przenieΕ› istniejΔ…cΔ… historiΔ™ z folderu pluginu w vaulcie do folderu zewnΔ™trznego. Bezpieczne β€” pliki sΔ… przenoszone, nie kopiowane.", + settings_storage_migrate_btn: "Migruj teraz", + settings_storage_migrating: "Migrowanie…", + settings_storage_open_name: "OtwΓ³rz folder w eksploratorze", + settings_storage_open_btn: "OtwΓ³rz folder", + settings_storage_open_desc: "Pokazuje folder z historiΔ… w systemowym menedΕΌerze plikΓ³w.", + settings_storage_active: "βœ… Aktywny β€” historia zapisywana POZA vaultem", + settings_storage_inactive: "⚠️ WyΕ‚Δ…czony β€” historia zapisywana w vaulcie", + settings_storage_inactive_html: "⚠️ WyΕ‚Δ…czony β€” historia zapisywana w vaulcie
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:"πŸ“± Wykryto urzΔ…dzenie mobilne
ZewnΔ™trzny zapis poza vaultem dziaΕ‚a tylko na desktopie. Na mobile dane sΔ… zapisywane w folderze pluginu w vaulcie.

πŸ’‘ Tip: Aby ograniczyΔ‡ Obsidian Sync, w ustawieniach Sync wyΕ‚Δ…cz synchronizacjΔ™ plikΓ³w konfiguracyjnych (.obsidian) lub dodaj folder pluginu do wyjΔ…tkΓ³w.", + settings_storage_mobile_na: "(niedostΔ™pne na mobile)", + settings_keys_local_warning_html: "πŸ”’ Klucze API sΔ… zapisywane lokalnie 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) => + `Status: ${indexed}  |  Notatki: ${files}  |  Chunki: ${chunks}  |  Embeddingi: ${embs}`, + rag_done: (n: number) => `βœ… RAG: ${n} notatek`, + + // Tokens / cost + tokens_label: (n: number) => `~${n} tokenΓ³w`, + tokens_session_cost: (v: string) => `\nSesja Ε‚Δ…cznie: ${v}`, + + // Notices + notice_model_changed: (m: string) => `βœ“ Model: ${m}`, + notice_keys_moved_local: "πŸ”’ Klucze API przeniesione poza vault.", + notice_keys_moved_sync: "☁️ Klucze API bΔ™dΔ… synchronizowane przez Obsidian Sync.", + notice_keys_migrated: "πŸ”‘ Klucze API przeniesione poza vault.", + notice_fallback_switched: (m: string) => `βœ… PrzeΕ‚Δ…czono na ${m}. Ponawiam wiadomość…`, + notice_fallback_return: (m: string) => `↩ PowrΓ³t do ${m}`, + notice_migration_done: (n: number, p: string) => `βœ… AI-Vault: Historia rozmΓ³w przeniesiona poza vault (${n} plikΓ³w).\nLokalizacja: ${p}`, + notice_migration_partial: (n: number) => `⚠️ AI-Vault: Migracja czΔ™Ε›ciowo nieudana β€” ${n} bΕ‚Δ™dΓ³w. SprawdΕΊ konsolΔ™.`, + notice_migration_partial_short: (m: number, e: number) => `⚠️ Przeniesiono ${m}, bΕ‚Δ™dΓ³w: ${e}. Konsola: F12.`, + notice_migrated_manual: (n: number, s: number) => `βœ… Przeniesiono ${n} plikΓ³w (pominiΔ™to ${s}).`, + notice_migration_failed: (e: string) => `❌ Migracja nieudana: ${e}`, + notice_export_done: (f: string) => `πŸ“ Wyeksportowano do: ${f}`, + notice_export_fail: (e: string) => `❌ BΕ‚Δ…d eksportu: ${e}`, + notice_manual_notes: (n: number) => `πŸ“Ž Dodano ${n} notatek do kontekstu`, + notice_restart_required: "⏳ Zmiana wymaga restartu pluginu (wyΕ‚Δ…cz/wΕ‚Δ…cz w Community Plugins).", + notice_storage_open_fail: (e: string) => `Nie udaΕ‚o siΔ™ otworzyΔ‡: ${e}`, + + // Fallback modal + fallback_title: "⚠️ Model niedostΔ™pny", + fallback_unavailable: (m: string) => `Model ${m} jest niedostΔ™pny dla Twojego konta OpenAI.`, + fallback_tier1: "NajczΔ™stsza przyczyna: modele GPT-5 wymagajΔ… konta API z minimalnymi wydatkami (Tier 1, ok. $5 historii pΕ‚atnoΕ›ci).", + fallback_api_error: "BΕ‚Δ…d API: ", + fallback_suggest: (m: string) => `MogΔ™ przeΕ‚Δ…czyΔ‡ rozmowΔ™ na ${m} (dziaΕ‚a zawsze) i ponowiΔ‡ wiadomoΕ›Δ‡.`, + fallback_save_default: (m: string) => ` Ustaw ${m} jako domyΕ›lny model (moΕΌesz zmieniΔ‡ w ustawieniach)`, + fallback_cancel: "Anuluj", + fallback_accept: (m: string) => `PrzeΕ‚Δ…cz na ${m} i ponΓ³w`, + + // Errors + err_no_openai_key: "⚠️ Ustaw klucz API OpenAI w ustawieniach.", + err_no_claude_key: "⚠️ Ustaw klucz API Claude w ustawieniach.", + err_empty_response: "Model zwrΓ³ciΕ‚ pustΔ… odpowiedΕΊ. SprΓ³buj ponownie.", + err_stream: "BΕ‚Δ…d streamingu", + err_stream_responses: "BΕ‚Δ…d streamingu Responses API", + + // Canvas + canvas_parse_error: (b: string) => `[Canvas: ${b} β€” bΕ‚Δ…d parsowania JSON]`, + canvas_no_nodes: (b: string) => `[Canvas: ${b} β€” brak wΔ™zΕ‚Γ³w]`, + canvas_flow_header: "## PrzepΕ‚yw\n", + canvas_content_header: "## ZawartoΕ›Δ‡\n", + + // Export + export_role_user: "UΕΌytkownik", + export_role_assistant: "Asystent", + export_no_messages: "Brak wiadomoΕ›ci do eksportu.", + export_header: (title: string, model: string, date: string) => `# ${title}\n\n> Model: ${model} | Data: ${date}\n\n---\n\n`, + export_user: "**Ty:**", + export_assistant: "**Asystent:**", + + // Provider + provider_switched_gpt: "πŸ€– PrzeΕ‚Δ…czono na GPT", + provider_switched_claude:"🟣 PrzeΕ‚Δ…czono na Claude", + + // Commands + cmd_open_chat: "OtwΓ³rz panel czatu AI-Vault", + cmd_open_history: "OtwΓ³rz historiΔ™ rozmΓ³w", + cmd_open_projects: "OtwΓ³rz projekty", + cmd_summarize: "AI-Vault: Podsumuj aktualnΔ… notatkΔ™", + + // Quiz + quiz_error: "BΕ‚Δ…d!", + quiz_no_question: "(brak treΕ›ci pytania)", + quiz_wrong_prefix: "❌ ΕΉle. Poprawna odpowiedΕΊ: ", + quiz_correct_prefix: "❌ Poprawna odpowiedΕΊ: ", + quiz_check_btn: "SprawdΕΊ", + quiz_eval_error: "BΕ‚Δ…d oceniania. SprΓ³buj ponownie.", + quiz_true_option: "Prawda", + quiz_false_option: "FaΕ‚sz", + quiz_fill_placeholder: "Wpisz brakujΔ…ce sΕ‚owo…", + quiz_open_placeholder: "Wpisz odpowiedź…", + quiz_eval_prompt: (q: string, a: string, u: string) => `OceΕ„ odpowiedΕΊ ucznia. Pytanie: "${q}". Wzorcowa odpowiedΕΊ: "${a}". OdpowiedΕΊ ucznia: "${u}". Odpowiedz TYLKO w formacie JSON: {"correct": true/false, "feedback": "krΓ³tkie wyjaΕ›nienie max 2 zdania"}`, + quiz_instruction: "\n\nJESTEŚ W TRYBIE NAUKI. Twoim jedynym zadaniem jest sprawdzanie wiedzy uΕΌytkownika przez testy i quizy. Gdy uΕΌytkownik poda temat lub poprosi o test, ZAWSZE zwrΓ³Δ‡ odpowiedΕΊ WYŁĄCZNIE w formacie JSON (bez ΕΌadnego tekstu przed ani po):\n```json\n{\"title\": \"TytuΕ‚ testu\", \"questions\": [{\"type\": \"choice\", \"question\": \"TreΕ›Δ‡ pytania\", \"options\": [\"A\",\"B\",\"C\",\"D\"], \"correct\": 0, \"explanation\": \"WyjaΕ›nienie\"}]}\n```\nTypy pytaΕ„: choice (A/B/C/D), truefalse (options: [\"Prawda\",\"FaΕ‚sz\"], correct: 0 lub 1), open (odpowiedΕΊ otwarta, pole answer z wzorcowΔ… odpowiedziΔ…), fill (uzupeΕ‚nij lukΔ™, pole answer z odpowiedziΔ…). Generuj 5–10 pytaΕ„ jeΕ›li nie podano inaczej.", + + // Code mode prompts + code_system_prompt_intro: "JesteΕ› ekspertem programistΔ… i architektem oprogramowania. Twoje odpowiedzi skupiajΔ… siΔ™ WYŁĄCZNIE na kodowaniu.\n\n", + code_rule_1: "- Zawsze podawaj peΕ‚ny, gotowy do uΕΌycia kod (nie fragmenty)\n", + code_rule_2: "- UΕΌywaj najlepszych praktyk i wzorcΓ³w projektowych\n", + code_rule_3: "- WyjaΕ›niaj decyzje architektoniczne krΓ³tko i konkretnie\n", + code_rule_4: "- JeΕ›li uΕΌytkownik nie podaΕ‚ jΔ™zyka, zapytaj lub dobierz najlepszy\n", + code_rule_4_lang: "jΔ™zyk\n...", + code_rule_5: "- Odpowiadaj w jΔ™zyku polskim (komentarze w kodzie po angielsku)\n\n", + code_system_prompt_closing: "Nie odpowiadaj na pytania niezwiΔ…zane z programowaniem β€” grzecznie przekieruj na temat kodowania.", + + // Storage + storage_save_error: (f: string) => `Nie udaΕ‚o siΔ™ zapisaΔ‡ ${f}`, + default_system_prompt: "JesteΕ› pomocnym asystentem zintegrowanym z Obsidian. Odpowiadaj w jΔ™zyku uΕΌytkownika. BΔ…dΕΊ zwiΔ™zΕ‚y, konkretny i pomocny.", +}; + +// ─── Runtime ────────────────────────────────────────────────────────────────── + +let _lang: "en" | "pl" = "en"; + +/** + * Translation function. + * Usage: t("chat_new") or t("chat_notes_added", 3) + */ +export function t(key: string, ...args: unknown[]): string { + const dict = _lang === "pl" ? pl : en; + const val = dict[key] ?? en[key] ?? key; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return typeof val === "function" ? (val as (...a: any[]) => string)(...args) : (val as string); +} + +/** + * Sets the active language and updates the default system prompt if it is still the default. + */ +export function setLanguage(lang: string, plugin?: { settings: { systemPrompt: string } }): void { + _lang = lang === "pl" ? "pl" : "en"; + + if (plugin) { + const allDefaults = [en.default_system_prompt as string, pl.default_system_prompt as string]; + const current = plugin.settings.systemPrompt || ""; + if (!current || allDefaults.includes(current)) { + plugin.settings.systemPrompt = t("default_system_prompt"); + } + } +} + +export function getCurrentLang(): "en" | "pl" { + return _lang; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..661e345 --- /dev/null +++ b/src/main.ts @@ -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>; + + // ── Lifecycle ────────────────────────────────────────────────────────────── + + async onload(): Promise { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const d = await this.loadData() as Record | 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 { + const toSave = { ...this.settings } as unknown as Record; + 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 { + 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).apiKey; + delete (this.settings as unknown as Record).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 { + 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); + } + } +} diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 0000000..ccb7034 --- /dev/null +++ b/src/models.ts @@ -0,0 +1,132 @@ +// ─── Model sets ─────────────────────────────────────────────────────────────── + +/** Models that support built-in web search */ +export const WEB_SEARCH_CAPABLE = new Set([ + "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([ + "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 = { + // 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 = { + 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; + } +} diff --git a/src/rag/RAGEngine.ts b/src/rag/RAGEngine.ts new file mode 100644 index 0000000..44c952b --- /dev/null +++ b/src/rag/RAGEngine.ts @@ -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 = {}; + private cachedAvgLen = 0; + private saveTimer: ReturnType | 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 { + const data = await this.storage.readJson( + 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 { + if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; } + await this.saveIndex(); + } + + private async saveIndex(): Promise { + // 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 { + 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 { + 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 { + 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 = {}; + let pendingChunks: PendingChunk[] = []; + let done = 0, skipped = 0, reindexed = 0; + + const flushEmbeddings = async (): Promise => { + 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 { + 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(); + 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 = {}; + 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 { + 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); + } + } +} diff --git a/src/rag/canvasParser.ts b/src/rag/canvasParser.ts new file mode 100644 index 0000000..171f7cb --- /dev/null +++ b/src/rag/canvasParser.ts @@ -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(nodes.map(n => [n.id, n])); + + // Build the graph: successors and predecessors + const successors = new Map>(); + const predecessors = new Map>(); + 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(); + 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; +} diff --git a/src/rag/index.ts b/src/rag/index.ts new file mode 100644 index 0000000..0ad3ff5 --- /dev/null +++ b/src/rag/index.ts @@ -0,0 +1,4 @@ +export { RAGEngine } from "./RAGEngine"; +export { parseCanvasToText } from "./canvasParser"; +export { resolveNoteWithLinks } from "./linkResolver"; +export type { ResolvedNote } from "./linkResolver"; diff --git a/src/rag/linkResolver.ts b/src/rag/linkResolver.ts new file mode 100644 index 0000000..dd97b6c --- /dev/null +++ b/src/rag/linkResolver.ts @@ -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(), + fileMap?: Map, +): Promise { + if (visited.has(file.path)) return []; + visited.add(file.path); + + // Build the map once β€” basename β†’ TFile, path β†’ TFile + if (!fileMap) { + fileMap = new Map(); + 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; +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..71e77e6 --- /dev/null +++ b/src/settings.ts @@ -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 = { + 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", +}; diff --git a/src/storage/ExternalStorage.ts b/src/storage/ExternalStorage.ts new file mode 100644 index 0000000..1d8eb7e --- /dev/null +++ b/src/storage/ExternalStorage.ts @@ -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 { + 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 { + 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 { + if (this.isEnabled) { + try { await this._fs!.access(filePath); return true; } + catch { return false; } + } + return this.fallback.exists(filePath); + } + + async readJson(filePath: string, fallback: T): Promise { + 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 { + 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 { + 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 { + 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 { + 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(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(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; + }; + 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; + } +} diff --git a/src/storage/PluginStorage.ts b/src/storage/PluginStorage.ts new file mode 100644 index 0000000..66c1e47 --- /dev/null +++ b/src/storage/PluginStorage.ts @@ -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/) */ + 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 { + 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 { + try { + return await this.plugin.app.vault.adapter.exists(filePath); + } catch { + return false; + } + } + + async readJson(filePath: string, fallback: T): Promise { + 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 { + 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 { + 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 { + 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: [] }; + } + } +} diff --git a/src/storage/index.ts b/src/storage/index.ts new file mode 100644 index 0000000..bc9f6e0 --- /dev/null +++ b/src/storage/index.ts @@ -0,0 +1,3 @@ +export { PluginStorage } from "./PluginStorage"; +export { ExternalStorage } from "./ExternalStorage"; +export type { ListResult } from "./PluginStorage"; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..cf1d58c --- /dev/null +++ b/src/types.ts @@ -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; + _embNorm?: number | null; +} + +export interface RAGIndex { + _version: number; + entries: RAGEntry[]; + hashes: Record; +} + +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; +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..4733208 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,236 @@ +import { RAG_CHUNK_OVERLAP, RAG_CHUNK_SIZE } from "./constants"; + +// ─── Async helpers ──────────────────────────────────────────────────────────── + +export function sleep(ms: number): Promise { + return new Promise(r => setTimeout(r, ms)); +} + +// ─── Debounce ───────────────────────────────────────────────────────────────── + +interface DebouncedFn { + (...args: T): void; + cancel(): void; +} + +export function debounce(fn: (...args: T) => void, delay: number): DebouncedFn { + let timer: ReturnType | 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( + fn: () => Promise, + { maxRetries = 3, baseDelay = 1000, maxDelay = 30000 }: RetryOptions = {}, +): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (err) { + lastError = err; + const e = err as { name?: string; noRetry?: boolean } | null; + // Do not retry user-initiated aborts, nor errors flagged as non-retryable + // (e.g. streaming errors after partial chunks were already delivered to the UI). + if (e?.name === "AbortError") throw err; + if (e?.noRetry) throw err; + if (attempt === maxRetries) break; + + const jitter = Math.random() * 200; + const delay = Math.min(baseDelay * 2 ** attempt + jitter, maxDelay); + await sleep(delay); + } + } + + throw lastError; +} + +// ─── String / HTML helpers ──────────────────────────────────────────────────── + +/** Escape HTML β€” apply to anything written to innerHTML */ +export function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** 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([ + // 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 { + const tf: Record = 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, + 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)]; +} diff --git a/src/views/ChatView.ts b/src/views/ChatView.ts new file mode 100644 index 0000000..5b3568f --- /dev/null +++ b/src/views/ChatView.ts @@ -0,0 +1,1327 @@ +import { + Component, + ItemView, + MarkdownRenderer, + Notice, + WorkspaceLeaf, +} from "obsidian"; +import type { TFile } from "obsidian"; + +import { CHAT_VIEW_TYPE, RAG_TOP_K } from "../constants"; +import { t } from "../i18n"; +import { + THINKING_MODES, + WEB_SEARCH_CAPABLE, + MODEL_PRICING, + ModelAccessError, + isGPT5, + isGPT5Search, +} from "../models"; +import { + formatDate, + escapeHtml, + sanitizeUrl, + utf8ToBase64, + base64ToUtf8, +} from "../utils"; +import { callOpenAI } from "../api/openai"; +import { callClaude } from "../api/anthropic"; +import { parseCanvasToText } from "../rag/canvasParser"; +import { resolveNoteWithLinks } from "../rag/linkResolver"; +import { FallbackModal } from "./FallbackModal"; +import type { ChatMessage } from "../types"; +import type { RAGEngine } from "../rag/RAGEngine"; +import type { HistoryManager } from "../history/HistoryManager"; +import type { ProjectManager } from "../history/ProjectManager"; +import type { PluginSettings } from "../settings"; +import type { StreamUsage } from "../api/streaming"; + +// ─── SVG icons ──────────────────────────────────────────────────────────────── + +const SVG = { + history: ``, + folder: ``, + arrow: ``, + regen: ``, + export: ``, + copy: ``, + check: ``, + rag: ``, + reindex: ``, + attach: ``, + web: ``, + learn: ``, + code: ``, + globe: ``, +}; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface PluginWithDeps { + app: import("obsidian").App; + settings: PluginSettings; + rag: RAGEngine; + history: HistoryManager; + projects: ProjectManager; + currentSessionId: string | null; + currentSession: import("../types").ChatSession | null; + activeProjectId: string | null; + saveSettings(): Promise; + newChat(): void; + loadSession(id: string): Promise; + autoSaveSession(messages: ChatMessage[]): Promise; + setActiveProject(id: string | null): void; + activateHistoryView(): Promise; + activateProjectsView(): Promise; +} + +interface QuizQuestion { + question: string; + type: string; + options?: string[]; + correct?: number; + answer?: string; + explanation?: string; + [key: string]: unknown; +} + +const RENDER_INTERVAL_MS = 80; +const MAX_SYSTEM_CHARS = 120_000; +const ALL_MODELS = { + openai: [ + { id: "gpt-5", label: "GPT-5", desc: () => t("model_desc_gpt5") }, + { id: "gpt-5-mini", label: "GPT-5 Mini", desc: () => t("model_desc_gpt5mini") }, + { id: "gpt-5-nano", label: "GPT-5 Nano", desc: () => t("model_desc_gpt5nano") }, + { id: "gpt-5-search-api", label: "GPT-5 Search", desc: () => t("model_desc_gpt5search") }, + { id: "gpt-4o", label: "GPT-4o", desc: () => t("model_desc_gpt4o") }, + { id: "gpt-4o-mini", label: "GPT-4o Mini", desc: () => t("model_desc_gpt4omini") }, + { id: "gpt-4-turbo", label: "GPT-4 Turbo", desc: () => t("model_desc_gpt4turbo") }, + ], + anthropic: [ + { id: "claude-opus-4-5", label: "Opus 4.5", desc: () => t("model_desc_opus") }, + { id: "claude-sonnet-4-5", label: "Sonnet 4.5", desc: () => t("model_desc_sonnet") }, + { id: "claude-haiku-4-5", label: "Haiku 4.5", desc: () => t("model_desc_haiku") }, + ], +} as const; + +// ─── GPTChatView ─────────────────────────────────────────────────────────────── + +export class GPTChatView extends ItemView { + // State + messages: ChatMessage[] = []; + webSearchActive = false; + learnMode = false; + codeMode = false; + manualNotes: TFile[] = []; + currentMode: string | null = null; + abortController: AbortController | null = null; + + // Reference to the open model picker and its global mousedown handler + private currentPicker: HTMLElement | null = null; + private pickerCloseHandler: ((e: MouseEvent) => void) | null = null; + + // Session cost + private sessionCostUsd = 0; + private lastUsage: StreamUsage | null = null; + + // Component for MarkdownRenderer β€” released automatically in onClose() + private readonly renderComponent: Component; + + // DOM refs + private chatContainer!: HTMLElement; + private inputEl!: HTMLTextAreaElement; + private sendBtn!: HTMLButtonElement; + private stopBtn: HTMLButtonElement | null = null; + private ragStatusEl!: HTMLElement; + private ragBadge!: HTMLElement; + private ragToggleBtn!: HTMLButtonElement; + private webSearchBtn!: HTMLButtonElement; + private learnBtn!: HTMLButtonElement; + private codeBtn!: HTMLButtonElement; + private modelSelectorBtn!: HTMLButtonElement; + private gptBtn!: HTMLButtonElement; + private claudeBtn!: HTMLButtonElement; + private projectBar!: HTMLElement; + private projectBarLabel!: HTMLElement; + private manualBar!: HTMLElement; + private manualBarList!: HTMLElement; + private modeLabel!: HTMLElement; + private modeButtons: Record = {}; + + constructor(leaf: WorkspaceLeaf, private readonly plugin: PluginWithDeps) { + super(leaf); + this.renderComponent = new Component(); + this.addChild(this.renderComponent); + } + + private get settings(): PluginSettings { return this.plugin.settings; } + private get rag(): RAGEngine { return this.plugin.rag; } + + getViewType(): string { return CHAT_VIEW_TYPE; } + getDisplayText(): string { return "AI-Vault"; } + getIcon(): string { return "message-square"; } + + async onOpen(): Promise { this.buildUI(); await this.maybeAutoIndex(); } + async onClose(): Promise { + // Abort any in-flight request so post-stream code does not run after the view is gone + this.abortController?.abort(); + this.abortController = null; + + if (this.pickerCloseHandler) { + document.removeEventListener("mousedown", this.pickerCloseHandler); + this.pickerCloseHandler = null; + } + this.currentPicker?.remove(); + this.currentPicker = null; + } + + // ── Auto-index ───────────────────────────────────────────────────────────── + + private async maybeAutoIndex(): Promise { + if (!this.settings.ragEnabled || !this.settings.ragAutoIndex) return; + const loaded = await this.rag.loadIndex(); + if (!loaded) { + await this.startIndexing(); + } else { + const s = this.rag.stats; + this.showRagStatus(t("rag_ready_short", s.files, s.embeddings), "ready"); + setTimeout(() => this.hideRagStatus(), 3500); + } + } + + private async startIndexing(): Promise { + if (this.rag.indexing) return; + this.showRagStatus("⏳ Indexing vault…", "indexing"); + await this.rag.buildIndex((done, total) => { + if (this.ragStatusEl) this.ragStatusEl.textContent = `⏳ Indexing… ${done}/${total}`; + }); + const s = this.rag.stats; + this.showRagStatus(t("rag_ready_full", s.files, s.embeddings), "ready"); + setTimeout(() => this.hideRagStatus(), 4000); + } + + private showRagStatus(text: string, state: "indexing" | "ready"): void { + if (!this.ragStatusEl) return; + this.ragStatusEl.textContent = text; + this.ragStatusEl.className = `gpt-rag-status gpt-rag-${state}`; + this.ragStatusEl.style.display = "block"; + } + + private hideRagStatus(): void { + if (this.ragStatusEl) this.ragStatusEl.style.display = "none"; + } + + // ── Build UI ──────────────────────────────────────────────────────────────── + + private buildUI(): void { + const root = this.containerEl.children[1] as HTMLElement; + root.empty(); + root.addClass("gpt-chat-root"); + + this.buildHeader(root); + this.buildModeBar(root); + this.buildProjectBar(root); + this.buildRagStatus(root); + this.buildManualBar(root); + this.buildChatArea(root); + this.buildInputArea(root); + } + + private buildHeader(root: HTMLElement): void { + const header = root.createEl("div", { cls: "gpt-header" }); + header.createEl("span", { cls: "gpt-header-icon", text: "✦" }); + + this.modelSelectorBtn = header.createEl("button", { cls: "gpt-model-selector" }); + this.modelSelectorBtn.onclick = () => this.openModelPicker(); + this.updateModelSelector(); + + this.ragBadge = header.createEl("span", { cls: "gpt-rag-badge" }); + this.updateRagBadge(); + + const providerSwitch = header.createEl("div", { cls: "gpt-provider-switch" }); + this.gptBtn = providerSwitch.createEl("button", { cls: "gpt-provider-btn", text: "GPT" }); + this.claudeBtn = providerSwitch.createEl("button", { cls: "gpt-provider-btn", text: "Claude" }); + this.gptBtn.onclick = () => this.setProvider("openai"); + this.claudeBtn.onclick = () => this.setProvider("anthropic"); + this.updateProviderSwitch(); + + const histBtn = header.createEl("button", { cls: "gpt-icon-btn", attr: { "aria-label": t("cmd_open_history") } }); + histBtn.innerHTML = SVG.history; + histBtn.onclick = () => void this.plugin.activateHistoryView(); + + const projBtn = header.createEl("button", { cls: "gpt-icon-btn", attr: { "aria-label": t("chat_projects") } }); + projBtn.innerHTML = SVG.folder; + projBtn.onclick = () => void this.plugin.activateProjectsView(); + + const clearBtn = header.createEl("button", { cls: "gpt-clear-btn", text: t("chat_new") }); + clearBtn.onclick = () => this.plugin.newChat(); + } + + private buildModeBar(root: HTMLElement): void { + const bar = root.createEl("div", { cls: "gpt-mode-bar" }); + for (const [key, m] of Object.entries(THINKING_MODES)) { + const btn = bar.createEl("button", { + cls: "gpt-mode-btn", + text: m.label, + attr: { title: m.desc }, + }); + btn.onclick = () => this.setMode(key); + this.modeButtons[key] = btn; + } + this.setMode(this.settings.thinkingMode, true); + } + + private buildProjectBar(root: HTMLElement): void { + this.projectBar = root.createEl("div", { cls: "gpt-project-bar gpt-ctx-hidden" }); + this.projectBarLabel = this.projectBar.createEl("span", { cls: "gpt-project-bar-label" }); + const exitBtn = this.projectBar.createEl("button", { cls: "gpt-ctx-clear", text: "βœ•" }); + exitBtn.onclick = () => { this.plugin.setActiveProject(null); this.updateProjectBar(); }; + this.updateProjectBar(); + } + + private buildRagStatus(root: HTMLElement): void { + this.ragStatusEl = root.createEl("div", { cls: "gpt-rag-status" }); + this.ragStatusEl.style.display = "none"; + } + + private buildManualBar(root: HTMLElement): void { + this.manualBar = root.createEl("div", { cls: "gpt-manual-bar gpt-ctx-hidden" }); + this.manualBarList = this.manualBar.createEl("span", { cls: "gpt-ctx-list" }); + const clear = this.manualBar.createEl("button", { cls: "gpt-ctx-clear", text: "βœ•" }); + clear.onclick = () => { this.manualNotes = []; this.updateManualBar(); }; + } + + private buildChatArea(root: HTMLElement): void { + this.chatContainer = root.createEl("div", { cls: "gpt-messages" }); + this.renderWelcome(); + } + + private buildInputArea(root: HTMLElement): void { + const area = root.createEl("div", { cls: "gpt-input-area" }); + const toolRow = area.createEl("div", { cls: "gpt-tool-row" }); + + // RAG toggle + this.ragToggleBtn = toolRow.createEl("button", { + cls: "gpt-tool-btn" + (this.settings.ragEnabled ? " gpt-rag-btn--active" : ""), + attr: { title: t("chat_title_rag") }, + }); + this.ragToggleBtn.innerHTML = `${SVG.rag} ${t("chat_btn_rag")}`; + this.ragToggleBtn.onclick = () => this.toggleRag(); + + // Re-index + const reindexBtn = toolRow.createEl("button", { cls: "gpt-tool-btn", attr: { title: t("chat_title_index") } }); + reindexBtn.innerHTML = `${SVG.reindex} ${t("chat_btn_index")}`; + reindexBtn.onclick = async () => { reindexBtn.disabled = true; await this.startIndexing(); reindexBtn.disabled = false; }; + + // Note picker + const pickBtn = toolRow.createEl("button", { cls: "gpt-tool-btn", attr: { title: t("chat_title_notes") } }); + pickBtn.innerHTML = `${SVG.attach} ${t("chat_btn_notes")}`; + pickBtn.onclick = () => this.openNotePicker(); + + // Web search + this.webSearchBtn = toolRow.createEl("button", { cls: "gpt-tool-btn", attr: { title: t("chat_title_internet") } }); + this.webSearchBtn.innerHTML = `${SVG.web} ${t("chat_btn_internet")}`; + this.webSearchBtn.onclick = () => this.toggleWebSearch(); + + // Learn mode + this.learnBtn = toolRow.createEl("button", { cls: "gpt-tool-btn", attr: { title: t("chat_title_learn") } }); + this.learnBtn.innerHTML = `${SVG.learn} ${t("chat_btn_learn")}`; + this.learnBtn.onclick = () => this.toggleLearnMode(); + + // Code mode + this.codeBtn = toolRow.createEl("button", { cls: "gpt-tool-btn", attr: { title: t("chat_title_code") } }); + this.codeBtn.innerHTML = `${SVG.code} ${t("chat_btn_code")}`; + this.codeBtn.onclick = () => this.toggleCodeMode(); + + // Textarea + this.inputEl = area.createEl("textarea", { + cls: "gpt-input", + attr: { placeholder: t("chat_placeholder"), rows: "3" }, + }) as HTMLTextAreaElement; + // registerDomEvent instead of addEventListener β€” lets Obsidian know this element handles the keyboard + // prevents Obsidian's global handler from intercepting Enter/shortcuts + this.registerDomEvent(this.inputEl, "keydown", (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void this.sendMessage(); } + }); + + // Button row + const btnRow = area.createEl("div", { cls: "gpt-btn-row" }); + this.modeLabel = btnRow.createEl("span", { cls: "gpt-mode-label" }); + this.updateModeLabel(); + + const regenBtn = btnRow.createEl("button", { cls: "gpt-action-btn", attr: { title: t("chat_regen_tooltip") } }); + regenBtn.innerHTML = SVG.regen; + regenBtn.onclick = () => void this.regenerateLastMessage(); + + const exportBtn = btnRow.createEl("button", { cls: "gpt-action-btn", attr: { title: t("chat_export_tooltip") } }); + exportBtn.innerHTML = SVG.export; + exportBtn.onclick = () => void this.exportToNote(); + + this.sendBtn = btnRow.createEl("button", { cls: "gpt-send-btn", text: t("chat_send") }); + this.sendBtn.onclick = () => void this.sendMessage(); + } + + // ── Note picker ───────────────────────────────────────────────────────────── + + private openNotePicker(): void { + const mdFiles = this.plugin.app.vault.getMarkdownFiles(); + const canvasFiles = this.plugin.app.vault.getFiles().filter((f: TFile) => f.extension === "canvas"); + const files = [...mdFiles, ...canvasFiles].sort((a, b) => a.basename.localeCompare(b.basename)); + + 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: t("chat_notes_title") }); + + const searchInput = box.createEl("input", { + cls: "gpt-modal-input", + attr: { type: "text", placeholder: t("chat_notes_search") }, + }) as HTMLInputElement; + + const list = box.createEl("div", { cls: "gpt-modal-list" }); + const selected = new Set(this.manualNotes.map(f => f.path)); + + const renderList = (filter = ""): void => { + list.empty(); + const filtered = files.filter(f => f.basename.toLowerCase().includes(filter.toLowerCase())); + for (const f of filtered.slice(0, 50)) { + const row = list.createEl("label", { cls: "gpt-modal-row" }); + const cb = row.createEl("input", { attr: { type: "checkbox" } }) as HTMLInputElement; + cb.checked = selected.has(f.path); + cb.style.accentColor = "var(--interactive-accent)"; + cb.onchange = () => { selected.has(f.path) ? selected.delete(f.path) : selected.add(f.path); }; + const icon = f.extension === "canvas" ? "πŸ—‚οΈ " : ""; + row.createEl("span", { cls: "gpt-modal-row-label", text: icon + f.basename }); + } + if (filtered.length > 50) { + list.createEl("div", { + cls: "gpt-modal-more", + text: t("chat_notes_more", filtered.length - 50), + }); + } + }; + renderList(); + searchInput.oninput = () => renderList(searchInput.value); + + 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: t("chat_notes_add") }); + ok.onclick = () => { + this.manualNotes = files.filter(f => selected.has(f.path)); + this.updateManualBar(); + overlay.remove(); + if (this.manualNotes.length) new Notice(t("chat_notes_added", this.manualNotes.length)); + }; + + overlay.appendChild(box); + this.containerEl.appendChild(overlay); + setTimeout(() => searchInput.focus(), 50); + } + + updateManualBar(): void { + if (!this.manualBar) return; + if (!this.manualNotes.length) { + this.manualBar.addClass("gpt-ctx-hidden"); + this.manualBarList.textContent = ""; + return; + } + this.manualBar.removeClass("gpt-ctx-hidden"); + this.manualBarList.textContent = "πŸ“Ž " + this.manualNotes.map(f => f.basename).join(", "); + } + + // ── Controls ──────────────────────────────────────────────────────────────── + + toggleRag(): void { + this.settings.ragEnabled = !this.settings.ragEnabled; + void this.plugin.saveSettings(); + this.ragToggleBtn.classList.toggle("gpt-rag-btn--active", this.settings.ragEnabled); + this.updateRagBadge(); + new Notice(this.settings.ragEnabled ? t("rag_on_notice") : t("rag_off_notice")); + } + + updateRagBadge(): void { + if (!this.ragBadge) return; + this.ragBadge.textContent = this.settings.ragEnabled ? "RAG" : ""; + this.ragBadge.style.display = this.settings.ragEnabled ? "inline-block" : "none"; + } + + updateProjectBar(): void { + if (!this.projectBar) return; + const projId = this.plugin.activeProjectId; + if (!projId) { + this.projectBar.addClass("gpt-ctx-hidden"); + this.projectBarLabel.textContent = ""; + return; + } + const proj = this.plugin.projects.getProject(projId); + if (!proj) { this.projectBar.addClass("gpt-ctx-hidden"); return; } + + this.projectBar.removeClass("gpt-ctx-hidden"); + this.projectBar.style.background = `color-mix(in srgb,${proj.color} 10%,var(--background-secondary))`; + this.projectBar.style.borderBottomColor = `color-mix(in srgb,${proj.color} 25%,transparent)`; + + const sessions = this.plugin.projects.getProjectSessions(proj.id); + const promptBadge = proj.systemPrompt ? " " + t("projects_custom_prompt_badge") : ""; + this.projectBarLabel.textContent = t("projects_bar_label", proj.name, sessions.length) + promptBadge; + } + + setMode(key: string, silent = false): void { + this.currentMode = key; + for (const [k, btn] of Object.entries(this.modeButtons)) { + btn.classList.toggle("gpt-mode-btn--active", k === key); + } + if (!silent) this.updateModeLabel(); + } + + private updateModeLabel(): void { + if (!this.modeLabel) return; + const m = THINKING_MODES[this.currentMode ?? ""]; + this.modeLabel.textContent = m ? `${m.label} Β· ${m.desc}` : ""; + } + + toggleWebSearch(): void { + if ( + !this.webSearchActive && + this.settings.provider === "openai" && + !WEB_SEARCH_CAPABLE.has(this.settings.model) + ) { + new Notice(t("ws_unsupported", this.settings.model), 7000); + return; + } + this.webSearchActive = !this.webSearchActive; + this.webSearchBtn.classList.toggle("gpt-websearch-btn--active", this.webSearchActive); + + if (isGPT5Search(this.settings.model)) { + new Notice(t("ws_gpt5search_always"), 5000); + } else if (this.webSearchActive && this.settings.provider === "anthropic") { + new Notice(t("ws_claude_enabled", this.settings.claudeModel ?? "claude-sonnet-4-5")); + } else { + new Notice(this.webSearchActive + ? t("ws_enabled", this.settings.model) + : t("ws_disabled")); + } + } + + toggleLearnMode(): void { + this.learnMode = !this.learnMode; + this.learnBtn.classList.toggle("gpt-learn-btn--active", this.learnMode); + if (this.learnMode) { + new Notice(t("mode_learn_on")); + this.inputEl.placeholder = t("chat_placeholder_learn"); + } else { + new Notice(t("mode_learn_off")); + this.inputEl.placeholder = t("chat_placeholder"); + } + } + + toggleCodeMode(): void { + this.codeMode = !this.codeMode; + this.codeBtn.classList.toggle("gpt-code-btn--active", this.codeMode); + const provName = this.settings.provider === "anthropic" ? "Claude" : "GPT"; + if (this.codeMode) { + new Notice(t("mode_code_on", provName)); + this.inputEl.placeholder = t("chat_placeholder_code"); + } else { + new Notice(t("mode_code_off")); + this.inputEl.placeholder = this.settings.provider === "anthropic" + ? t("chat_placeholder_claude") + : t("chat_placeholder"); + } + } + + setProvider(provider: "openai" | "anthropic"): void { + this.closePicker(); + this.settings.provider = provider; + void this.plugin.saveSettings(); + this.updateProviderSwitch(); + if (provider === "anthropic" && this.webSearchActive) { + this.webSearchActive = false; + this.webSearchBtn.classList.remove("gpt-websearch-btn--active"); + } + new Notice(provider === "openai" ? t("provider_switched_gpt") : t("provider_switched_claude")); + } + + updateProviderSwitch(): void { + if (!this.gptBtn || !this.claudeBtn) return; + const isOpenAI = this.settings.provider !== "anthropic"; + this.gptBtn.classList.toggle("gpt-provider-btn--active", isOpenAI); + this.claudeBtn.classList.toggle("gpt-provider-btn--active", !isOpenAI); + if (this.inputEl) { + this.inputEl.placeholder = isOpenAI + ? t("chat_placeholder") + : t("chat_placeholder_claude"); + } + this.updateModelSelector(); + } + + updateModelSelector(): void { + if (!this.modelSelectorBtn) return; + const isOpenAI = this.settings.provider !== "anthropic"; + const icon = isOpenAI ? "πŸ€–" : "🟣"; + const model = isOpenAI + ? (this.settings.model ?? "gpt-4o") + : (this.settings.claudeModel ?? "claude-sonnet-4-5"); + const label = model + .replace("claude-", "") + .replace(/-4-[56]/g, "") + .replace("gpt-", "GPT-") + .replace("-search-api", " Search"); + + this.modelSelectorBtn.innerHTML = + `${icon}` + + `${label}` + + SVG.arrow; + this.modelSelectorBtn.title = t("chat_model_tooltip", model); + } + + private closePicker(): void { + if (this.pickerCloseHandler) { + document.removeEventListener("mousedown", this.pickerCloseHandler); + this.pickerCloseHandler = null; + } + this.currentPicker?.remove(); + this.currentPicker = null; + } + + private openModelPicker(): void { + // Toggle: if the picker is already open β€” close it + if (this.currentPicker) { this.closePicker(); return; } + + const isOpenAI = this.settings.provider !== "anthropic"; + const models = ALL_MODELS[isOpenAI ? "openai" : "anthropic"]; + const activeId = isOpenAI + ? (this.settings.model ?? "gpt-4o") + : (this.settings.claudeModel ?? "claude-sonnet-4-5"); + + const picker = document.createElement("div"); + picker.className = "gpt-model-picker"; + this.currentPicker = picker; + + const hdr = document.createElement("div"); + hdr.className = "gpt-mp-header"; + hdr.textContent = isOpenAI ? t("chat_picker_openai") : t("chat_picker_claude"); + picker.appendChild(hdr); + + for (const m of models) { + const isActive = m.id === activeId; + const row = document.createElement("button"); + row.className = "gpt-mp-row" + (isActive ? " gpt-mp-row--active" : ""); + row.type = "button"; + + const left = document.createElement("span"); + left.className = "gpt-mp-row-left"; + + const name = document.createElement("span"); + name.className = "gpt-mp-row-name"; + name.textContent = m.label; + + const desc = document.createElement("span"); + desc.className = "gpt-mp-row-desc"; + desc.textContent = m.desc(); + + left.appendChild(name); + left.appendChild(desc); + row.appendChild(left); + + if (isActive) { + const check = document.createElement("span"); + check.className = "gpt-mp-row-check"; + check.textContent = "βœ“"; + row.appendChild(check); + } + + row.addEventListener("mousedown", (e) => e.stopPropagation()); + row.addEventListener("click", async () => { + this.closePicker(); + if (isOpenAI) { + this.plugin.settings.model = m.id; + } else { + this.plugin.settings.claudeModel = m.id; + } + await this.plugin.saveSettings(); + this.updateModelSelector(); + new Notice(t("notice_model_changed", m.label), 2000); + }); + + picker.appendChild(row); + } + + // Attach to document.body β€” avoids CSS transform issues on Obsidian panels + document.body.appendChild(picker); + const rect = this.modelSelectorBtn.getBoundingClientRect(); + picker.style.top = `${rect.bottom + 4}px`; + picker.style.left = `${rect.left}px`; + + // Close on click outside the picker + this.pickerCloseHandler = (e: MouseEvent): void => { + const target = e.target as Node | null; + if (!target) return; + const inside = picker.contains(target) || (this.modelSelectorBtn?.contains(target) ?? false); + if (!inside) this.closePicker(); + }; + setTimeout(() => { + if (this.pickerCloseHandler) { + document.addEventListener("mousedown", this.pickerCloseHandler); + } + }, 0); + } + + // ── Sessions ──────────────────────────────────────────────────────────────── + + loadSession(session: { title: string; messages: ChatMessage[]; model?: string }): void { + this.messages = [...session.messages]; + this.sessionCostUsd = 0; + this.lastUsage = null; + this.chatContainer.empty(); + + if (!this.messages.length) { this.renderWelcome(); return; } + for (const msg of this.messages) this.appendMessage(msg.role, msg.content); + + if (this.modelSelectorBtn && session.model) { + this.modelSelectorBtn.title = t("chat_model_session_tooltip", session.title, session.model); + } + } + + clearAndNew(): void { + this.messages = []; + this.manualNotes = []; + this.sessionCostUsd = 0; + this.lastUsage = null; + this.updateManualBar(); + this.chatContainer.empty(); + this.renderWelcome(); + this.updateModeLabel(); + this.updateModelSelector(); + } + + // ── Send message ───────────────────────────────────────────────────────────── + + async sendMessage(override?: string): Promise { + const userText = override ?? this.inputEl.value.trim(); + if (!userText) return; + + const isClaude = this.settings.provider === "anthropic"; + if (!isClaude && !this.settings.apiKey) { new Notice(t("err_no_openai_key")); return; } + if (isClaude && !this.settings.claudeApiKey) { new Notice(t("err_no_claude_key")); return; } + + if (!override) this.inputEl.value = ""; + this.sendBtn.disabled = true; + this.messages.push({ role: "user", content: userText }); + this.appendMessage("user", userText); + + const bubble = this.appendMessage("assistant", ""); + this.setLoading(bubble, true, this.webSearchActive); + + try { + const systemMsg = await this.buildSystemMessage(userText); + const ragSources = this.lastRagSources; + + const ctxLimit = this.settings.maxContextMessages ?? 0; + const histMsgs = ctxLimit > 0 ? this.messages.slice(-ctxLimit) : this.messages; + const msgs: ChatMessage[] = [{ role: "system", content: systemMsg }, ...histMsgs]; + const contentEl = bubble.querySelector(".gpt-msg-content") as HTMLElement | null; + + this.abortController = new AbortController(); + this.showStopBtn(true); + + // Throttled streaming β€” fast parser during the stream, native renderer at the end + let streamStarted = false; + let lastRenderTime = 0; + + const onChunk = (partial: string): void => { + if (!streamStarted) { this.setLoading(bubble, false); streamStarted = true; } + const now = Date.now(); + if (now - lastRenderTime < RENDER_INTERVAL_MS) return; + lastRenderTime = now; + if (contentEl) { + contentEl.innerHTML = this.renderMarkdownFast(partial) + 'β–‹'; + } + this.chatContainer.scrollTop = this.chatContainer.scrollHeight; + }; + + const activeMode = this.currentMode ?? this.settings.thinkingMode; + const result = isClaude + ? await callClaude( + this.settings.claudeApiKey, + this.settings.claudeModel ?? "claude-sonnet-4-5", + msgs, + activeMode, + this.webSearchActive, onChunk, this.abortController.signal, + this.getMaxTokensForMode(activeMode), + ) + : await callOpenAI( + this.settings.apiKey, + this.settings.model, + msgs, + activeMode, + this.webSearchActive, onChunk, this.abortController.signal, + this.getMaxTokensForMode(activeMode), + ); + + const { text: reply, usage } = result; + this.setLoading(bubble, false); + + if (contentEl) { + const isQuiz = this.learnMode && this.tryRenderQuiz(reply, contentEl); + if (!isQuiz) this.renderContent(contentEl, reply); + } + bubble.dataset.raw = reply; + + // RAG sources + if (ragSources.length) { + const srcEl = bubble.parentElement!.createEl("div", { cls: "gpt-rag-sources" }); + srcEl.createEl("span", { cls: "gpt-rag-src-icon", text: "πŸ—„οΈ" }); + srcEl.createEl("span", { cls: "gpt-rag-src-label", text: t("rag_sources_label") }); + for (const s of ragSources) srcEl.createEl("span", { cls: "gpt-rag-src-chip", text: s }); + } + + this.messages.push({ role: "assistant", content: reply }); + + // Cost and tokens + const modelName = isClaude + ? (this.settings.claudeModel ?? "claude-sonnet-4-5") + : this.settings.model; + this.lastUsage = usage; + this.accumulateCost(modelName, usage); + + if (usage) { + this.updateTokenCounter(usage.input + usage.output, { usage, model: modelName }); + } else { + const totalChars = this.messages.reduce((s, m) => s + m.content.length, 0) + systemMsg.length; + this.updateTokenCounter(Math.round(totalChars / 4), { model: modelName }); + } + + await this.plugin.autoSaveSession(this.messages); + + } catch (err: unknown) { + this.setLoading(bubble, false); + const error = err as Error & { name?: string }; + const isAbort = error.name === "AbortError"; + const contentEl = bubble.querySelector(".gpt-msg-content") as HTMLElement | null; + const partial = contentEl?.innerText?.trim() ?? ""; + + if (isAbort && partial) { + if (contentEl) { + this.renderContent(contentEl, partial); + contentEl.createEl("div", { cls: "gpt-msg-interrupted", text: t("chat_interrupted") }); + } + bubble.dataset.raw = partial; + this.messages.push({ role: "assistant", content: partial }); + await this.plugin.autoSaveSession(this.messages); + } else if (isAbort) { + this.messages.pop(); + bubble.parentElement?.remove(); + } else if (err instanceof ModelAccessError && this.settings.provider === "openai") { + this.messages.pop(); + bubble.parentElement?.remove(); + const failed = error.message; + const failedModel = (err as ModelAccessError).model ?? this.settings.model; + const fallbackModel = isGPT5(failedModel) ? "gpt-4o" : "gpt-4o-mini"; + new FallbackModal(this.plugin.app, { + failedModel, + fallbackModel, + errorMessage: failed, + onAccept: async (saveAsDefault: boolean) => { + this.plugin.settings.model = fallbackModel; + if (saveAsDefault) await this.plugin.saveSettings(); + new Notice(t("notice_fallback_switched", fallbackModel)); + await this.sendMessage(userText); + }, + }).open(); + } else { + this.messages.pop(); + if (contentEl) { + contentEl.empty(); + contentEl.createEl("div", { cls: "gpt-msg-error-line", text: `❌ ${t("err_stream")}: ${error.message}` }); + contentEl.createEl("div", { cls: "gpt-msg-error-detail", text: `Model: ${this.settings.model} Β· Mode: ${this.currentMode}` }); + contentEl.addClass("gpt-error"); + } + console.error("[AI-Vault] sendMessage error:", error.message, err); + } + } finally { + this.sendBtn.disabled = false; + this.showStopBtn(false); + this.abortController = null; + this.chatContainer.scrollTop = this.chatContainer.scrollHeight; + } + } + + // ── System message builder ────────────────────────────────────────────────── + + private lastRagSources: string[] = []; + + private async buildSystemMessage(userText: string): Promise { + const projId = this.plugin.activeProjectId; + const activeProj = projId ? this.plugin.projects.getProject(projId) : null; + let sys = activeProj?.systemPrompt || this.settings.systemPrompt; + + // Code mode + if (this.codeMode) { + sys = t("code_system_prompt_intro") + + "RULES:\n" + + "- Write clean, efficient, well-commented code\n" + + t("code_rule_1") + t("code_rule_2") + t("code_rule_3") + + t("code_rule_4") + + "- Format code in blocks ```language\n...```\n" + + "- Flag potential issues, edge cases and optimizations\n" + + t("code_rule_5") + t("code_system_prompt_closing"); + } + + if (this.learnMode) sys += t("quiz_instruction"); + + const ragSources: string[] = []; + + // Manually selected notes + if (this.manualNotes.length) { + const allNotes: { file: TFile; content: string }[] = []; + const visited = new Set(); + for (const f of this.manualNotes) { + if (f.extension === "canvas") { + try { + const raw = await this.plugin.app.vault.cachedRead(f); + allNotes.push({ file: f, content: parseCanvasToText(raw, f.basename) }); + } catch (e) { console.warn("[AI-Vault] canvas read failed:", f.path, (e as Error)?.message); } + } else { + const resolved = await resolveNoteWithLinks(this.plugin.app, f, 1, visited); + allNotes.push(...resolved); + } + } + if (allNotes.length) { + const ctx = allNotes.map(({ file, content }) => `### ${file.basename}\n${content.slice(0, 3000)}`); + sys += `\n\n---\n${t("rag_manual_ctx_header")}\n\n${ctx.join("\n\n---\n\n")}\n---`; + ragSources.push(...this.manualNotes.map(f => f.basename)); + const linked = allNotes.filter(n => !this.manualNotes.some(f => f.path === n.file.path)); + ragSources.push(...linked.map(n => `↳ ${n.file.basename}`)); + } + } + + // Auto-RAG + if (this.settings.ragEnabled && this.rag.indexed && userText) { + const results = await this.rag.search(userText, RAG_TOP_K); + const filtered = results.filter(r => !this.manualNotes.some(f => f.path === r.path)); + if (filtered.length) { + const ctx = filtered.map(r => `### ${r.basename}\n${r.chunk}`).join("\n\n---\n\n"); + sys += `\n\n---\nVAULT CONTEXT (RAG):\n\n${ctx}\n---`; + ragSources.push(...filtered.map(r => r.basename)); + } + } + + // Project context + if (projId && this.plugin.currentSessionId) { + const projCtx = await this.plugin.projects.buildProjectContext(projId, this.plugin.currentSessionId); + if (projCtx) { + sys += `\n\n---\n${t("rag_project_ctx_header", activeProj?.name ?? "Project")}\n\n${projCtx}\n---`; + } + } + + if (sys.length > MAX_SYSTEM_CHARS) { + sys = sys.slice(0, MAX_SYSTEM_CHARS) + "\n\n" + t("rag_ctx_truncated"); + } + + this.lastRagSources = ragSources; + return sys; + } + + // ── UI helpers ────────────────────────────────────────────────────────────── + + renderWelcome(): void { + const w = this.chatContainer.createEl("div", { cls: "gpt-welcome" }); + w.createEl("div", { cls: "gpt-welcome-icon", text: "✦" }); + w.createEl("p", { text: t("chat_welcome_rag") }); + w.createEl("p", { cls: "gpt-welcome-hint", text: t("chat_welcome_hint") }); + } + + appendMessage(role: string, content: string): HTMLElement { + this.chatContainer.querySelector(".gpt-welcome")?.remove(); + const msgEl = this.chatContainer.createEl("div", { cls: `gpt-msg gpt-msg-${role}` }); + const bubble = msgEl.createEl("div", { cls: "gpt-bubble" }); + const contentEl = bubble.createEl("div", { cls: "gpt-msg-content" }); + + if (content) { + const isQuiz = role === "assistant" && this.learnMode && this.tryRenderQuiz(content, contentEl); + if (!isQuiz) this.renderContent(contentEl, content); + } + + // Footer with copy button + const footer = msgEl.createEl("div", { cls: "gpt-msg-footer" }); + const assistLabel = this.settings.provider === "anthropic" ? "Claude" : "GPT"; + footer.createEl("span", { cls: "gpt-msg-label", text: role === "user" ? "You" : assistLabel }); + + const copyBtn = footer.createEl("button", { cls: "gpt-copy-btn", attr: { title: "Copy", "aria-label": "Copy" } }); + copyBtn.innerHTML = SVG.copy; + copyBtn.onclick = async () => { + await navigator.clipboard.writeText(bubble.dataset.raw ?? contentEl.innerText); + copyBtn.innerHTML = SVG.check; + setTimeout(() => { copyBtn.innerHTML = SVG.copy; }, 2000); + }; + + if (content) bubble.dataset.raw = content; + this.chatContainer.scrollTop = this.chatContainer.scrollHeight; + return bubble; + } + + private setLoading(bubble: HTMLElement, on: boolean, webSearch = false): void { + bubble.querySelector(".gpt-dots")?.remove(); + bubble.querySelector(".gpt-websearch-indicator")?.remove(); + if (on) { + bubble.addClass("gpt-loading"); + const dots = bubble.createEl("div", { cls: "gpt-dots" }); + dots.createEl("span"); dots.createEl("span"); dots.createEl("span"); + if (webSearch) { + const ind = bubble.createEl("div", { cls: "gpt-websearch-indicator" }); + ind.innerHTML = `${SVG.globe} ${t("ws_searching_label")}`; + } + } else { + bubble.removeClass("gpt-loading"); + } + } + + private showStopBtn(show: boolean): void { + if (show) { + if (this.stopBtn) return; + this.stopBtn = this.sendBtn.parentElement!.createEl("button", { + cls: "gpt-stop-btn", + text: "⏹ Stop", + }); + this.stopBtn.onclick = () => { + this.abortController?.abort(); + new Notice("⏹ Generation stopped"); + }; + this.sendBtn.style.display = "none"; + } else { + this.stopBtn?.remove(); + this.stopBtn = null; + this.sendBtn.style.display = ""; + } + } + + private updateTokenCounter( + tokens: number, + opts: { usage?: StreamUsage | null; model?: string } = {}, + ): void { + if (!this.modeLabel) return; + const m = THINKING_MODES[this.currentMode ?? ""]; + const tokStr = tokens > 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens); + const parts = [m ? m.label : "", t("tokens_label", tokStr)]; + + if (this.sessionCostUsd > 0) { + const c = this.sessionCostUsd; + parts.push(c < 0.0001 ? "<$0.0001" : c < 0.01 ? `$${c.toFixed(4)}` : `$${c.toFixed(3)}`); + } + if (opts.usage?.reasoning && opts.usage.reasoning > 0) { + const r = opts.usage.reasoning; + parts.push(`🧠 ${r > 1000 ? `${(r / 1000).toFixed(1)}k` : r} reasoning`); + } + + this.modeLabel.textContent = parts.join(" Β· "); + + if (opts.usage) { + this.modeLabel.title = + `Last request:\nInput: ${opts.usage.input} tok\nOutput: ${opts.usage.output} tok\n` + + (opts.usage.reasoning ? `Reasoning: ${opts.usage.reasoning} tok\n` : "") + + `\n${t("tokens_session_cost", this.sessionCostUsd > 0 ? "$" + this.sessionCostUsd.toFixed(4) : "β€”")}`; + } + } + + private accumulateCost(model: string, usage: StreamUsage | null): void { + if (!usage) return; + const p = MODEL_PRICING[model]; + if (!p) return; + this.sessionCostUsd += (usage.input * p.input + usage.output * p.output) / 1_000_000; + } + + async regenerateLastMessage(): Promise { + if (this.messages.length < 2) return; + const rev = [...this.messages].reverse(); + const lastUserIdx = rev.findIndex(m => m.role === "user"); + if (lastUserIdx < 0) return; + const idx = this.messages.length - 1 - lastUserIdx; + const userText = this.messages[idx].content; + this.messages = this.messages.slice(0, idx); + const allMsgs = this.chatContainer.querySelectorAll(".gpt-msg"); + for (let i = allMsgs.length - 1; i >= idx; i--) allMsgs[i].remove(); + await this.sendMessage(userText); + } + + async exportToNote(): Promise { + if (!this.messages.length) { new Notice(t("export_no_messages")); return; } + const provName = this.settings.provider === "anthropic" ? "Claude" : "GPT"; + const title = this.plugin.currentSession?.title ?? "Conversation"; + const date = formatDate(Date.now()); + let md = `# ${title}\n\n> Export from ${provName} Β· ${date}\n\n---\n\n`; + for (const msg of this.messages) { + const label = msg.role === "user" ? "**You**" : `**${provName}**`; + md += `${label}:\n\n${msg.content}\n\n---\n\n`; + } + const safeName = title.replace(/[\\/:*?"<>|]/g, "_").slice(0, 60); + const base = `AI-Vault/${safeName} ${new Date().toISOString().slice(0, 10)}`; + if (!this.plugin.app.vault.getAbstractFileByPath("AI-Vault")) { + try { await this.plugin.app.vault.createFolder("AI-Vault"); } catch { /* already exists */ } + } + let fileName = `${base}.md`; + let counter = 1; + while (this.plugin.app.vault.getAbstractFileByPath(fileName)) { + fileName = `${base} (${counter++}).md`; + } + try { + await this.plugin.app.vault.create(fileName, md); + new Notice(t("notice_export_done", fileName)); + } catch (e) { + new Notice(t("notice_export_fail", (e as Error).message)); + } + } + + // ── Markdown rendering ─────────────────────────────────────────────────────── + + /** Final render β€” native Obsidian renderer */ + private renderContent(el: HTMLElement, text: string): void { + try { + el.empty(); + if (typeof MarkdownRenderer.render === "function") { + void MarkdownRenderer.render(this.plugin.app, text, el, "", this.renderComponent); + } else { + // Fallback for older Obsidian versions + (MarkdownRenderer as unknown as { + renderMarkdown: (md: string, el: HTMLElement, path: string, comp: Component) => void; + }).renderMarkdown(text, el, "", this.renderComponent); + } + this.addCodeCopyButtons(el); + } catch (e) { + console.warn("[AI-Vault] native renderer failed, using fallback:", (e as Error)?.message); + el.innerHTML = this.renderMarkdownFast(text); + this.addCodeCopyButtons(el); + } + } + + /** Fast markdown parser β€” used ONLY during streaming (~12 times/s) */ + private renderMarkdownFast(text: string): string { + const codeBlocks: { lang: string; code: string }[] = []; + const links: { label: string; url: string }[] = []; + + let p = text.replace(/```([\w]*)\r?\n?([\s\S]*?)```/g, (_, lang: string, code: string) => { + codeBlocks.push({ lang: lang || "", code }); + return `\x00CODE${codeBlocks.length - 1}\x00`; + }); + + p = p.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g, (_, label: string, url: string) => { + links.push({ label, url }); + return `\x00LINK${links.length - 1}\x00`; + }); + + p = p.replace(/&/g, "&").replace(//g, ">"); + + p = p + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\*(.+?)\*/g, "$1") + .replace(/~~(.+?)~~/g, "$1") + .replace(/^### (.+)$/gm, "

$1

") + .replace(/^## (.+)$/gm, "

$1

") + .replace(/^# (.+)$/gm, "

$1

") + .replace(/^- (.+)$/gm, "
  • $1
  • ") + .replace(/((?:
  • [\s\S]*?<\/li>\n?)+)/g, "
      $1
    ") + .replace(/^\d+\.\s+(.+)$/gm, "$1") + .replace(/((?:[\s\S]*?<\/oli>\n?)+)/g, (m) => + "
      " + m.replace(/<\/?oli>/g, (tag) => tag.replace("oli", "li")) + "
    ", + ) + .replace(/\n{2,}/g, "

    ") + .replace(/\n/g, "
    "); + + p = p.replace(/\x00LINK(\d+)\x00/g, (_, i: string) => { + const { label, url } = links[+i]; + return `${escapeHtml(label)}`; + }); + + p = p.replace(/\x00CODE(\d+)\x00/g, (_, i: string) => { + const { lang, code } = codeBlocks[+i]; + const b64 = utf8ToBase64(code); + const langAttr = lang ? ` data-lang="${escapeHtml(lang)}"` : ""; + return `
    ${escapeHtml(code)}
    `; + }); + + return p; + } + + private addCodeCopyButtons(container: HTMLElement): void { + container.querySelectorAll(".gpt-code-block pre[data-rawcode]").forEach(pre => { + if (pre.parentElement?.querySelector(".gpt-code-header")) return; + const lang = pre.getAttribute("data-lang") ?? ""; + const b64 = pre.getAttribute("data-rawcode") ?? ""; + + const header = document.createElement("div"); + header.className = "gpt-code-header"; + + if (lang) { + const langEl = document.createElement("span"); + langEl.className = "gpt-code-lang"; + langEl.textContent = lang; + header.appendChild(langEl); + } + + const copyBtn = document.createElement("button"); + copyBtn.className = "gpt-code-copy"; + copyBtn.title = "Copy code"; + copyBtn.innerHTML = `${SVG.copy} Copy`; + copyBtn.onclick = async () => { + try { + await navigator.clipboard.writeText(base64ToUtf8(b64)); + copyBtn.innerHTML = `${SVG.check} Copied!`; + copyBtn.classList.add("gpt-code-copy--ok"); + setTimeout(() => { + copyBtn.innerHTML = `${SVG.copy} Copy`; + copyBtn.classList.remove("gpt-code-copy--ok"); + }, 2000); + } catch (e) { console.warn("[AI-Vault] copy failed:", e); } + }; + + header.appendChild(copyBtn); + pre.parentElement!.insertBefore(header, pre); + }); + } + + // ── Quiz renderer ──────────────────────────────────────────────────────────── + + private tryRenderQuiz(content: string, container: HTMLElement): boolean { + interface QuizData { title?: string; questions: QuizQuestion[] } + let quiz: QuizData | null = null; + + const mdMatch = content.match(/```json\s*([\s\S]*?)```/); + if (mdMatch) { try { quiz = JSON.parse(mdMatch[1]) as QuizData; } catch { /* ignore */ } } + + if (!quiz) { + const match = content.match(/\{[\s\S]*"questions"[\s\S]*\}/); + if (match) { try { quiz = JSON.parse(match[0]) as QuizData; } catch { /* ignore */ } } + } + if (!quiz) { + try { + const parsed = JSON.parse(content.trim()) as QuizData; + if (parsed?.questions) quiz = parsed; + } catch { /* ignore */ } + } + + if (!quiz || !Array.isArray(quiz.questions)) return false; + + container.empty(); + if (quiz.title) container.createEl("div", { cls: "gpt-quiz-title", text: quiz.title }); + + quiz.questions.forEach((q, qi) => { + this.normalizeQuestion(q); + const card = container.createEl("div", { cls: "gpt-quiz-card" }); + card.createEl("div", { cls: "gpt-quiz-qnum", text: `Question ${qi + 1} of ${quiz!.questions.length}` }); + card.createEl("div", { cls: "gpt-quiz-qtext", text: q.question || t("quiz_no_question") }); + + let answered = false; + + if ((q.type === "choice" || q.type === "truefalse") && q.options?.length) { + const opts = card.createEl("div", { cls: "gpt-quiz-opts" }); + q.options.forEach((opt, oi) => { + const btn = opts.createEl("button", { cls: "gpt-quiz-opt" }); + const prefix = q.type === "truefalse" ? "" : String.fromCharCode(65 + oi) + ". "; + btn.textContent = prefix + opt; + btn.onclick = () => { + if (answered) return; + answered = true; + const correct = oi === q.correct; + opts.querySelectorAll(".gpt-quiz-opt").forEach((b, bi) => { + b.disabled = true; + if (bi === q.correct) b.classList.add("gpt-quiz-opt--correct"); + else if (bi === oi && !correct) b.classList.add("gpt-quiz-opt--wrong"); + }); + const fb = card.createEl("div", { + cls: correct ? "gpt-quiz-fb gpt-quiz-fb--ok" : "gpt-quiz-fb gpt-quiz-fb--err", + }); + if (correct) { + fb.textContent = "βœ… Correct! "; + if (q.explanation) fb.appendChild(document.createTextNode(q.explanation)); + } else { + const corrPrefix = q.type === "truefalse" ? "" : String.fromCharCode(65 + (q.correct ?? 0)) + ". "; + fb.appendChild(document.createTextNode(t("quiz_wrong_prefix"))); + fb.createEl("strong", { text: corrPrefix + (q.options?.[q.correct ?? 0] ?? "") }); + if (q.explanation) { fb.createEl("br"); fb.appendChild(document.createTextNode(q.explanation)); } + } + }; + }); + } else if (q.type === "open" || q.type === "fill") { + const inp = card.createEl("textarea", { + cls: "gpt-quiz-input", + attr: { placeholder: q.type === "fill" ? t("quiz_fill_placeholder") : t("quiz_open_placeholder"), rows: "2" }, + }) as HTMLTextAreaElement; + const checkBtn = card.createEl("button", { cls: "gpt-quiz-check", text: t("quiz_check_btn") }); + checkBtn.onclick = async () => { + if (answered) return; + const ans = inp.value.trim(); + if (!ans) return; + answered = true; inp.disabled = true; checkBtn.disabled = true; + checkBtn.textContent = "Checking…"; + + if (q.type === "fill") { + const ok = ans.toLowerCase() === String(q.answer ?? "").toLowerCase().trim(); + const fb = card.createEl("div", { cls: ok ? "gpt-quiz-fb gpt-quiz-fb--ok" : "gpt-quiz-fb gpt-quiz-fb--err" }); + if (ok) { fb.textContent = "βœ… Correct!"; } + else { fb.appendChild(document.createTextNode(t("quiz_correct_prefix"))); fb.createEl("strong", { text: String(q.answer ?? "") }); } + } else { + try { + const prompt = t("quiz_eval_prompt", q.question, q.answer, ans); + const isClaude = this.settings.provider === "anthropic"; + const r = isClaude + ? await callClaude(this.settings.claudeApiKey, this.settings.claudeModel ?? "claude-sonnet-4-5", [{ role: "user", content: prompt }], "fast") + : await callOpenAI(this.settings.apiKey, this.settings.model, [{ role: "user", content: prompt }], "fast"); + const ev = JSON.parse(r.text.replace(/```json|```/g, "").trim()) as { correct: boolean; feedback: string }; + card.createEl("div", { cls: ev.correct ? "gpt-quiz-fb gpt-quiz-fb--ok" : "gpt-quiz-fb gpt-quiz-fb--err", text: (ev.correct ? "βœ… " : "❌ ") + (ev.feedback ?? "") }); + } catch { card.createEl("div", { cls: "gpt-quiz-fb gpt-quiz-fb--err", text: t("quiz_eval_error") }); } + } + checkBtn.textContent = t("quiz_check_btn"); + }; + } + }); + return true; + } + + private normalizeQuestion(q: QuizQuestion): void { + if (!q.question) q.question = String(q["text"] ?? q["prompt"] ?? q["content"] ?? ""); + if (!q.type) { + if (q.options?.length === 2 && q.options.every(o => /^(true|false|yes|no)$/i.test(o))) q.type = "truefalse"; + else if (q.options?.length) q.type = "choice"; + else if (q.answer) q.type = "open"; + else q.type = "choice"; + } + const aliases: Record = { + multiple_choice: "choice", single_choice: "choice", mcq: "choice", + true_false: "truefalse", boolean: "truefalse", tf: "truefalse", + short_answer: "open", free_text: "open", fill_blank: "fill", gap: "fill", + }; + if (aliases[q.type]) q.type = aliases[q.type]; + if (q.type === "truefalse" && !q.options?.length) q.options = [t("quiz_true_option"), t("quiz_false_option")]; + if (!q.options && Array.isArray(q["answers"])) q.options = q["answers"] as string[]; + if (!q.options && Array.isArray(q["choices"])) q.options = q["choices"] as string[]; + + const ca = q["correct_answer"] ?? q["correctAnswer"]; + if (q.correct === undefined && ca !== undefined) { + if (typeof ca === "number") q.correct = ca; + else if (typeof ca === "boolean") q.correct = ca ? 0 : 1; + else if (typeof ca === "string" && q.options) { + let idx = q.options.findIndex(o => o === ca); + if (idx < 0) idx = q.options.findIndex(o => o.toLowerCase() === ca.toLowerCase()); + if (idx < 0 && /^[A-D]$/i.test(ca)) idx = ca.toUpperCase().charCodeAt(0) - 65; + if (idx >= 0) q.correct = idx; + } + } + if ((q.type === "choice" || q.type === "truefalse") && q.correct === undefined) q.correct = 0; + if (!q.answer && (q.type === "open" || q.type === "fill")) { + q.answer = String(ca ?? q["expected_answer"] ?? ""); + } + } + + private getMaxTokensForMode(mode: string): number { + switch (mode) { + case "fast": return this.settings.maxTokensFast ?? 4096; + case "normal": return this.settings.maxTokensNormal ?? 8192; + case "think": return this.settings.maxTokensThink ?? 16000; + default: return this.settings.maxTokensNormal ?? 8192; + } + } +} diff --git a/src/views/FallbackModal.ts b/src/views/FallbackModal.ts new file mode 100644 index 0000000..bdacf72 --- /dev/null +++ b/src/views/FallbackModal.ts @@ -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; +} + +/** + * 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; + 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(); + } +} diff --git a/src/views/HistoryView.ts b/src/views/HistoryView.ts new file mode 100644 index 0000000..c3fb945 --- /dev/null +++ b/src/views/HistoryView.ts @@ -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 = ``; +const SVG_FOLDER = ``; +const SVG_DELETE = ``; + +interface PluginWithDeps { + app: import("obsidian").App; + history: HistoryManager; + projects: ProjectManager; + currentSessionId: string | null; + newChat(): void; + loadSession(id: string): Promise; + activateProjectsView(): Promise; +} + +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 { + await this.plugin.history.load(); + this.render(); + } + async onClose(): Promise { /* 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); + } + } +} diff --git a/src/views/ProjectsView.ts b/src/views/ProjectsView.ts new file mode 100644 index 0000000..649d6f8 --- /dev/null +++ b/src/views/ProjectsView.ts @@ -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 = ``; +const SVG_DELETE = ``; +const SVG_EDIT = ``; +const SVG_CHAT = ``; +const SVG_PROMPT = ``; + +// ─── Plugin interface ────────────────────────────────────────────────────────── + +interface PluginWithDeps { + app: import("obsidian").App; + history: HistoryManager; + projects: ProjectManager; + currentSessionId: string | null; + activeProjectId: string | null; + newChat(): void; + loadSession(id: string): Promise; + setActiveProject(id: string | null): void; + activateHistoryView(): Promise; +} + +// ─── 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 { + await this.plugin.projects.load(); + this.render(); + } + async onClose(): Promise { /* 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) | null)?.(); + }); + + overlay.appendChild(box); + this.containerEl.appendChild(overlay); + setTimeout(() => nameInput.focus(), 50); + } +} diff --git a/src/views/index.ts b/src/views/index.ts new file mode 100644 index 0000000..aa0607b --- /dev/null +++ b/src/views/index.ts @@ -0,0 +1,3 @@ +export { GPTHistoryView } from "./HistoryView"; +export { GPTProjectsView } from "./ProjectsView"; +export { FallbackModal } from "./FallbackModal"; diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..5f01733 --- /dev/null +++ b/styles.css @@ -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); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..75047af --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..8b24dc4 --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "1.0.0": "1.4.0" +}