mirror of
https://github.com/jamjan05/AI-Vault-for-Obsidian.git
synced 2026-07-22 06:56:43 +00:00
Compare commits
57 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
064aa87662 | ||
|
|
205ee38f85 | ||
|
|
2ed5ad3ff7 | ||
|
|
f04ab5f5dc | ||
|
|
a49cd3e8aa | ||
|
|
d432c56b46 | ||
|
|
3c19c07c67 | ||
|
|
8e25e997a6 | ||
|
|
ac32667a64 | ||
|
|
12c12f3767 | ||
|
|
15cabc62ea | ||
|
|
d2b2b0e9a5 | ||
|
|
5a48363e84 | ||
|
|
689dd0f689 | ||
|
|
92aec984ea | ||
|
|
96e7c6146e | ||
|
|
1001e7fb9b | ||
|
|
988dc9ca6b | ||
|
|
3f2dc911e9 | ||
|
|
c32931f261 | ||
|
|
4a4ab3e867 | ||
|
|
c5c2ee8a01 | ||
|
|
c84ceb9edf | ||
|
|
4d148ed647 | ||
|
|
98d665660e | ||
|
|
c07a4e4e6c | ||
|
|
6f0552540f | ||
|
|
34539f9e37 | ||
|
|
d73109387a | ||
|
|
f2aa747c34 | ||
|
|
bd368affae | ||
|
|
3c2e9d7671 | ||
|
|
5e22cd326f | ||
|
|
2f03e1c357 | ||
|
|
f3bb4ae6ab | ||
|
|
930df313e8 | ||
|
|
72fc830e94 | ||
|
|
7ce9677ffc | ||
|
|
f6f47c552e | ||
|
|
07fd389950 | ||
|
|
a82d73033b | ||
|
|
19dd8fd0e9 | ||
|
|
e0a4d3de37 | ||
|
|
226c7c9c7f | ||
|
|
b5901f83de | ||
|
|
56d1c8d8ec | ||
|
|
37b3e4a0b6 | ||
|
|
7a9bc5052a | ||
|
|
4549d64d5b | ||
|
|
31da06fa35 | ||
|
|
85c12671b3 | ||
|
|
620510b2cc | ||
|
|
f4246b5c10 | ||
|
|
34eb7321e5 | ||
|
|
82eb2abebe | ||
|
|
5f16b52dfb | ||
|
|
6516c36547 |
33 changed files with 6436 additions and 892 deletions
237
.github/workflows/release.yml
vendored
237
.github/workflows/release.yml
vendored
|
|
@ -1,186 +1,111 @@
|
|||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "*"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
attestations: write
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Check and build plugin
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Validate tag is semver
|
||||
env:
|
||||
VERSION: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
# Obsidian requires plain semver (x.y.z, optionally -prerelease). Fail fast
|
||||
# on bad tags like "1.0.2.1" before building or creating a release.
|
||||
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Tag '${VERSION}' is not valid semver (expected x.y.z or x.y.z-prerelease)."
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag '${VERSION}' is valid semver."
|
||||
|
||||
- name: Ensure stable releases are created from main
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
run: |
|
||||
git fetch origin main
|
||||
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
|
||||
echo "::error::Stable releases must be created from the current main branch. Use pre-release for beta/test branches."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint --if-present
|
||||
- name: Update version files from tag
|
||||
env:
|
||||
VERSION: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const version = process.env.VERSION;
|
||||
const readJson = (p) => JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
const writeJson = (p, d) => fs.writeFileSync(p, JSON.stringify(d, null, 2) + "\n");
|
||||
|
||||
- name: Test
|
||||
run: npm run test --if-present
|
||||
const manifest = readJson("manifest.json");
|
||||
manifest.version = version;
|
||||
writeJson("manifest.json", manifest);
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
const pkg = readJson("package.json");
|
||||
pkg.version = version;
|
||||
writeJson("package.json", pkg);
|
||||
|
||||
if (fs.existsSync("package-lock.json")) {
|
||||
const lock = readJson("package-lock.json");
|
||||
lock.version = version;
|
||||
if (lock.packages?.[""]) lock.packages[""].version = version;
|
||||
writeJson("package-lock.json", lock);
|
||||
}
|
||||
|
||||
if (fs.existsSync("versions.json")) {
|
||||
const versions = readJson("versions.json");
|
||||
versions[version] = manifest.minAppVersion;
|
||||
writeJson("versions.json", versions);
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Upload plugin artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Attest release assets
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
name: ai-vault-plugin
|
||||
if-no-files-found: error
|
||||
path: |
|
||||
subject-path: |
|
||||
main.js
|
||||
styles.css
|
||||
manifest.json
|
||||
|
||||
release:
|
||||
name: Create GitHub release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download plugin artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ai-vault-plugin
|
||||
|
||||
- name: Resolve release version
|
||||
id: release_version
|
||||
shell: bash
|
||||
- name: Commit updated version files to main
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
run: |
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
echo "version=${GITHUB_REF_NAME}" >> "${GITHUB_OUTPUT}"
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git fetch origin main
|
||||
git checkout main
|
||||
git add manifest.json package.json versions.json
|
||||
git add package-lock.json 2>/dev/null || true
|
||||
git diff --cached --quiet || git commit -m "chore: update version to ${{ github.event.release.tag_name }}"
|
||||
git push origin main
|
||||
|
||||
git fetch --force --tags
|
||||
git tag --list > /tmp/ai-vault-release-tags.txt
|
||||
|
||||
node <<'NODE' >> "${GITHUB_OUTPUT}"
|
||||
const fs = require("node:fs");
|
||||
const pkg = require("./package.json");
|
||||
|
||||
const semverPattern = /^\d+\.\d+\.\d+$/;
|
||||
const parse = (version) => version.split(".").map(Number);
|
||||
const compare = (a, b) => {
|
||||
const pa = parse(a);
|
||||
const pb = parse(b);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (pa[i] !== pb[i]) return pa[i] - pb[i];
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
const incrementPatch = (version) => {
|
||||
const [major, minor, patch] = parse(version);
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
};
|
||||
|
||||
const packageVersion = semverPattern.test(pkg.version) ? pkg.version : "0.0.0";
|
||||
const tags = fs.readFileSync("/tmp/ai-vault-release-tags.txt", "utf8")
|
||||
.split(/\r?\n/)
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => semverPattern.test(tag))
|
||||
.sort(compare);
|
||||
|
||||
const latestTag = tags.at(-1);
|
||||
const version = !latestTag || compare(latestTag, packageVersion) < 0
|
||||
? packageVersion
|
||||
: incrementPatch(latestTag);
|
||||
|
||||
console.log(`version=${version}`);
|
||||
NODE
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.release_version.outputs.version }}
|
||||
name: ${{ steps.release_version.outputs.version }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
main.js
|
||||
styles.css
|
||||
manifest.json
|
||||
|
||||
notify-failure:
|
||||
name: Notify issues about main failure
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Create or update failure issue
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const title = "CI failure on main";
|
||||
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
|
||||
const shortSha = context.sha.slice(0, 7);
|
||||
const body = [
|
||||
"<!-- ai-vault-ci-failure -->",
|
||||
"The main branch CI failed.",
|
||||
"",
|
||||
`- Workflow: ${context.workflow}`,
|
||||
`- Run: ${runUrl}`,
|
||||
`- Commit: ${context.sha}`,
|
||||
`- Actor: ${context.actor}`,
|
||||
"",
|
||||
"Please check the failing job logs and fix the build before creating a release."
|
||||
].join("\n");
|
||||
|
||||
const query = `repo:${owner}/${repo} is:issue is:open in:title "${title}"`;
|
||||
const search = await github.rest.search.issuesAndPullRequests({ q: query, per_page: 1 });
|
||||
|
||||
if (search.data.items.length > 0) {
|
||||
const issueNumber = search.data.items[0].number;
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body: `${body}\n\nNew failing commit: \`${shortSha}\``
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.create({
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
body
|
||||
});
|
||||
}
|
||||
- name: Upload release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
gh release upload "$TAG" main.js styles.css manifest.json --clobber
|
||||
|
|
|
|||
76
.github/workflows/validate.yml
vendored
Normal file
76
.github/workflows/validate.yml
vendored
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
name: Validate version
|
||||
|
||||
# Guards against the classic mistake: bumping the version in manifest.json by hand
|
||||
# without a matching stable release tag. Pre-releases are built from beta/test
|
||||
# branches and must not update main. This job fails fast with a clear message
|
||||
# instead of letting Obsidian reject the plugin later.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Check version consistency
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # need all tags
|
||||
|
||||
- name: Validate manifest version, internal consistency and matching tag
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const { execSync } = require("node:child_process");
|
||||
|
||||
const read = (p) => JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
const manifest = read("manifest.json");
|
||||
const pkg = read("package.json");
|
||||
const version = manifest.version;
|
||||
const errors = [];
|
||||
|
||||
// 1. main must point at a stable semver release only.
|
||||
if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(version)) {
|
||||
errors.push(`manifest.json version "${version}" is not a stable semver version (expected x.y.z). Use GitHub pre-releases for beta/test branches.`);
|
||||
}
|
||||
|
||||
// 2. package.json must match manifest.json.
|
||||
if (pkg.version !== version) {
|
||||
errors.push(`package.json version (${pkg.version}) does not match manifest.json version (${version}).`);
|
||||
}
|
||||
|
||||
// 3. versions.json must list manifest.json's version.
|
||||
if (fs.existsSync("versions.json")) {
|
||||
const versions = read("versions.json");
|
||||
if (!(version in versions)) {
|
||||
errors.push(`versions.json has no entry for ${version}.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. A git tag named <version> must exist (i.e. an actual release).
|
||||
const tags = execSync("git tag -l", { encoding: "utf8" })
|
||||
.split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
if (!tags.includes(version)) {
|
||||
errors.push(
|
||||
`No git tag "${version}" exists, so manifest.json points at a version that was never released.\n` +
|
||||
` Do NOT bump the version by hand. Let the release workflow set it: ` +
|
||||
`git tag ${version} && git push origin ${version}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
console.error("::error::Version validation failed:\n - " + errors.join("\n - "));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`OK: version ${version} is consistent and has a matching release tag.`);
|
||||
NODE
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
include=dev
|
||||
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -1,5 +1,18 @@
|
|||
# Changelog
|
||||
|
||||
## [1.0.7] - 2026-07-11
|
||||
|
||||
### Fixed
|
||||
- Replaced direct `fetch()` calls with Obsidian `requestUrl()` for OpenAI and Anthropic requests.
|
||||
- Moved static chat-view styles to CSS and kept only dynamic values in `setCssProps()` or `setCssStyles()`.
|
||||
- Removed unsafe Node module loading and tightened external-storage response and error types.
|
||||
- Fixed fallback modal promise handling, unused imports, empty expressions, and unnecessary type assertions.
|
||||
- Replaced vault-wide wikilink lookup with Obsidian `MetadataCache` resolution.
|
||||
|
||||
### Security and release
|
||||
- Documented the narrowly scoped uses of desktop file access, vault enumeration, and clipboard writes.
|
||||
- Added build provenance attestations for `main.js` and `styles.css` release assets.
|
||||
|
||||
## [1.0.2] - 2026-05-21
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
71
README.md
71
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# ✨ AI-Vault for Obsidian
|
||||
|
||||
Chat with **OpenAI GPT** and **Anthropic Claude** directly inside Obsidian, with vault-aware context, conversation history, projects, smart modes, and local-first storage.
|
||||
Chat with **OpenAI GPT**, **Anthropic Claude**, and **local models** directly inside Obsidian, with vault-aware context, conversation history, projects, smart modes, and local-first storage.
|
||||
|
||||
AI-Vault turns your Obsidian workspace into an AI assistant that can use your notes, canvases, selected files, and project history as context while keeping plugin data on your own machine.
|
||||
|
||||
|
|
@ -8,17 +8,18 @@ AI-Vault turns your Obsidian workspace into an AI assistant that can use your no
|
|||
|
||||
## 🚀 Highlights
|
||||
|
||||
- 🤖 **Multi-provider chat** - switch between OpenAI and Anthropic models from the chat view.
|
||||
- 🤖 **Multi-provider chat** - switch between OpenAI, Anthropic, and local models from the chat view.
|
||||
- 🖥️ **Local models** - run models offline through LM Studio, Ollama, and other OpenAI-compatible servers.
|
||||
- 📚 **Vault RAG** - search relevant Markdown and Canvas content from your vault.
|
||||
- 📎 **Manual note context** - attach specific notes or canvases to a conversation.
|
||||
- 🗂️ **Projects** - group related chats with custom prompts and shared project context.
|
||||
- 🕘 **Conversation history** - automatically save and reopen previous chats.
|
||||
- ⚡ **Streaming responses** - see answers as they are generated.
|
||||
- ⚡ **Cancelable responses** - stop an in-progress conversation from the chat view.
|
||||
- 🧠 **Thinking modes** - choose Fast, Normal, or Think mode.
|
||||
- 🎓 **Learn mode** - generate study-friendly answers and interactive quiz-style responses.
|
||||
- 💻 **Code mode** - get programming-focused answers with code formatting.
|
||||
- 🌐 **Web search** - use supported OpenAI and Claude web-search capabilities.
|
||||
- 🌍 **Bilingual UI** - English and Polish interface.
|
||||
- 🌍 **Bilingual UI** - fully localized English and Polish interface.
|
||||
- 🔐 **Local-first storage** - history, projects, keys, and RAG index can stay outside your vault.
|
||||
|
||||
---
|
||||
|
|
@ -26,10 +27,10 @@ AI-Vault turns your Obsidian workspace into an AI assistant that can use your no
|
|||
## 🧩 Requirements
|
||||
|
||||
- 🖥️ Obsidian desktop **1.7.2 or newer**
|
||||
- 🔑 OpenAI API key and/or Anthropic API key
|
||||
- 📡 Internet access for model calls, embeddings, and web search
|
||||
- 🔑 OpenAI API key and/or Anthropic API key — **or** a local model server (LM Studio, Ollama, …)
|
||||
- 📡 Internet access for cloud model calls, embeddings, and web search (local models can run offline)
|
||||
|
||||
> AI-Vault is desktop-only because streaming and external local storage rely on desktop APIs.
|
||||
> AI-Vault is desktop-only because optional storage outside the vault requires desktop file-system APIs.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -51,6 +52,40 @@ AI-Vault turns your Obsidian workspace into an AI assistant that can use your no
|
|||
- Claude Sonnet 4.5
|
||||
- Claude Haiku 4.5
|
||||
|
||||
### Local API
|
||||
|
||||
Any model served by an OpenAI-compatible or Ollama endpoint, including:
|
||||
|
||||
- LM Studio
|
||||
- Ollama
|
||||
- LocalAI
|
||||
- llama.cpp server
|
||||
- vLLM
|
||||
- Other OpenAI-compatible local servers
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Local Models
|
||||
|
||||
Use **Provider → Local API** to chat with models running on your own machine. AI-Vault supports two endpoint types and can fetch the available model list from your server.
|
||||
|
||||
### LM Studio (and other OpenAI-compatible servers)
|
||||
|
||||
1. Start the local server in LM Studio and load a model.
|
||||
2. In **Settings → AI-Vault**, set **Local API Type** to **OpenAI-compatible**.
|
||||
3. Set **Base URL** to `http://localhost:1234/v1`.
|
||||
4. Click **Refresh models** and select a model from the list.
|
||||
|
||||
### Ollama
|
||||
|
||||
1. Start Ollama with `ollama serve`.
|
||||
2. Pull a model, for example `ollama pull llama3`.
|
||||
3. In **Settings → AI-Vault**, set **Local API Type** to **Ollama**.
|
||||
4. Set **Base URL** to `http://localhost:11434` (no `/v1` — AI-Vault uses Ollama's native endpoints).
|
||||
5. Click **Refresh models** and select a model from the list.
|
||||
|
||||
> Web search is not available for Local API models. Local API requests are sent only to the Base URL you configure. If your Base URL points to a cloud service or third-party gateway, your chat messages and Local API key are sent to that endpoint. Leave **Local API key** empty for local Ollama or LM Studio.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Vault Context
|
||||
|
|
@ -64,6 +99,8 @@ Canvas files are parsed into readable text using their nodes and edges, so the m
|
|||
|
||||
The RAG engine combines keyword search and embeddings when available. If no OpenAI key is configured for embeddings, lexical search can still provide useful matches.
|
||||
|
||||
Building the enabled vault-wide RAG index enumerates Markdown and Canvas files. File contents are read incrementally for indexing; ordinary wikilink resolution uses Obsidian's metadata cache and does not scan the vault.
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Projects
|
||||
|
|
@ -99,6 +136,12 @@ Inside the AI-Vault chat view you can:
|
|||
|
||||
---
|
||||
|
||||
## 🌍 Language
|
||||
|
||||
The entire interface is fully localized in **English** and **Polish**. Switch it any time in **Settings → AI-Vault → Language / Język**; the change applies across settings, chat, projects, history, quizzes, and notices.
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Privacy And Storage
|
||||
|
||||
By default, AI-Vault stores plugin data outside your vault in a local folder next to the vault directory:
|
||||
|
|
@ -109,6 +152,8 @@ By default, AI-Vault stores plugin data outside your vault in a local folder nex
|
|||
|
||||
This keeps data out of Obsidian Sync by default.
|
||||
|
||||
Storage location is configurable. API keys have their own **Obsidian Sync / local** choice. Conversation history, projects, and the RAG index use the external-storage setting and can instead be kept in the plugin folder inside the vault for Obsidian Sync.
|
||||
|
||||
Stored locally:
|
||||
|
||||
- 🔑 API keys
|
||||
|
|
@ -124,7 +169,9 @@ Data is sent to model providers only when it is part of a request, for example:
|
|||
- project context,
|
||||
- web-search requests.
|
||||
|
||||
AI-Vault does not use its own backend server. Requests go directly from Obsidian to the configured OpenAI or Anthropic API.
|
||||
AI-Vault does not use its own backend server. Requests go directly from Obsidian to the configured OpenAI or Anthropic API, or to the Local API Base URL you set. Local API can be a local server such as Ollama or LM Studio, or a user-configured authenticated Ollama/OpenAI-compatible gateway such as Ollama Cloud, LiteLLM, LocalAI, or vLLM.
|
||||
|
||||
Desktop Node.js `fs` and `path` access is limited to the optional external storage directory. Clipboard access is write-only and runs only when you press a message or code copy button. The plugin never reads clipboard contents.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -167,7 +214,7 @@ Once available in the Obsidian Community Plugins directory:
|
|||
|
||||
1. Open **Settings -> AI-Vault**.
|
||||
2. Choose the interface language.
|
||||
3. Add your OpenAI API key and/or Anthropic API key.
|
||||
3. Add your OpenAI API key and/or Anthropic API key, or configure a **Local API** server.
|
||||
4. Choose your default provider and model.
|
||||
5. Configure RAG, token limits, storage, and system prompt settings.
|
||||
6. Open AI-Vault from the ribbon icon or command palette.
|
||||
|
|
@ -194,6 +241,12 @@ Run typecheck:
|
|||
npm run typecheck
|
||||
```
|
||||
|
||||
Run the Obsidian and TypeScript lint rules:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Create a production build:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
19
RELEASE_NOTES_1.0.7.md
Normal file
19
RELEASE_NOTES_1.0.7.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# AI-Vault 1.0.7
|
||||
|
||||
This maintenance release resolves the Obsidian community-plugin review findings.
|
||||
|
||||
## Highlights
|
||||
|
||||
- All cloud API requests now use Obsidian's `requestUrl()` API.
|
||||
- Chat view styling uses CSS classes and Obsidian CSS helpers instead of static inline assignments.
|
||||
- External storage has strict typing and desktop-gated Node.js module loading.
|
||||
- Wikilinks are resolved through Obsidian's metadata cache instead of scanning every Markdown file.
|
||||
- Release assets include GitHub build provenance attestations for `main.js` and `styles.css`.
|
||||
|
||||
## Permissions and privacy
|
||||
|
||||
- Node.js `fs` and `path` are used only on desktop for the optional storage folder outside the vault.
|
||||
- Full vault enumeration is limited to the note picker and the explicitly enabled RAG index.
|
||||
- Clipboard access is write-only and occurs only after the user presses a copy button.
|
||||
|
||||
Because `requestUrl()` does not expose an abortable SSE stream, cloud responses are applied as a complete JSON response. Pressing Stop immediately ends the conversation operation and discards any response that arrives later.
|
||||
|
|
@ -1,9 +1,21 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
|
||||
const nodeBuiltins = [
|
||||
"fs",
|
||||
"fs/promises",
|
||||
"path",
|
||||
"os",
|
||||
"crypto",
|
||||
"events",
|
||||
"stream",
|
||||
"util",
|
||||
"http",
|
||||
"https",
|
||||
];
|
||||
|
||||
const context = await esbuild.context({
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
|
|
@ -21,7 +33,7 @@ const context = await esbuild.context({
|
|||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins,
|
||||
...nodeBuiltins,
|
||||
],
|
||||
format: "cjs",
|
||||
target: "es2022",
|
||||
|
|
|
|||
17
eslint.config.mjs
Normal file
17
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import tsParser from "@typescript-eslint/parser";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
|
||||
export default defineConfig([
|
||||
...obsidianmd.configs.recommended,
|
||||
{
|
||||
files: ["src/**/*.ts"],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "ai-vault",
|
||||
"name": "AI-Vault",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.9",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Chat with GPT and Claude directly inside your notes — RAG from your vault, history, projects and smart modes.",
|
||||
"author": "JamJan05",
|
||||
|
|
|
|||
4587
package-lock.json
generated
4587
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,20 +1,23 @@
|
|||
{
|
||||
"name": "ai-vault",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.9",
|
||||
"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",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"@types/node": "^22.19.18",
|
||||
"builtin-modules": "^3.3.0",
|
||||
"esbuild": "^0.21.5",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-obsidianmd": "^0.4.1",
|
||||
"obsidian": "^1.12.3",
|
||||
"tslib": "^2.6.3",
|
||||
"typescript": "^5.9.3"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { App, Notice, PluginSettingTab, Setting } from "obsidian";
|
||||
import { t, setLanguage } from "./i18n";
|
||||
import { DEFAULT_SYSTEM_PROMPTS } from "./settings";
|
||||
import { DEFAULT_SYSTEM_PROMPTS, DEFAULT_LOCAL_OPENAI_URL, DEFAULT_LOCAL_OLLAMA_URL } from "./settings";
|
||||
import { detectProvider } from "./models";
|
||||
import { fetchLocalModels, normalizeLocalBaseUrl } from "./api/local";
|
||||
import { FILE_API_KEYS } from "./constants";
|
||||
import type { ExternalStorage } from "./storage/ExternalStorage";
|
||||
import type { HistoryManager } from "./history/HistoryManager";
|
||||
|
|
@ -8,7 +10,7 @@ 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";
|
||||
import type { LocalApiType, PluginSettings, Provider } from "./settings";
|
||||
|
||||
// ─── Plugin interface ──────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -26,6 +28,14 @@ interface PluginWithDeps {
|
|||
getProjectsView(): GPTProjectsView | null;
|
||||
}
|
||||
|
||||
function isProvider(value: string): value is Provider {
|
||||
return value === "openai" || value === "anthropic" || value === "local";
|
||||
}
|
||||
|
||||
function isLocalApiType(value: string): value is LocalApiType {
|
||||
return value === "openai-compatible" || value === "ollama";
|
||||
}
|
||||
|
||||
// ─── SettingsTab ───────────────────────────────────────────────────────────────
|
||||
|
||||
export class GPTSettingsTab extends PluginSettingTab {
|
||||
|
|
@ -43,8 +53,8 @@ export class GPTSettingsTab extends PluginSettingTab {
|
|||
this.renderLanguage(containerEl);
|
||||
this.renderKeyWarning(containerEl);
|
||||
this.renderApiKeySync(containerEl);
|
||||
this.renderOpenAIKeys(containerEl);
|
||||
this.renderClaude(containerEl);
|
||||
this.renderModelSelector(containerEl);
|
||||
this.renderLocalApiSettings(containerEl);
|
||||
this.renderOpenAISettings(containerEl);
|
||||
this.renderContext(containerEl);
|
||||
this.renderRAG(containerEl);
|
||||
|
|
@ -109,42 +119,65 @@ export class GPTSettingsTab extends PluginSettingTab {
|
|||
|
||||
private renderApiKeySync(el: HTMLElement): void {
|
||||
const keysInSync = this.plugin.settings.apiKeysInSync;
|
||||
const extEnabled = this.plugin.externalStorage.isEnabled;
|
||||
const isDesktop = this.plugin.externalStorage.isDesktop;
|
||||
|
||||
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)
|
||||
.setDisabled(!isDesktop)
|
||||
.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);
|
||||
tog.setDisabled(true);
|
||||
try {
|
||||
// Local-only keys require a working folder outside the vault. Try
|
||||
// to initialize it here instead of permanently disabling the toggle.
|
||||
if (!v && !this.plugin.externalStorage.isEnabled) {
|
||||
if (!this.plugin.settings.externalStorageEnabled) {
|
||||
new Notice(t("notice_keys_need_external"), 6000);
|
||||
return;
|
||||
}
|
||||
if (!(await this.plugin.externalStorage.init())) {
|
||||
new Notice(t("notice_storage_init_failed", this.plugin.externalStorage.lastError ?? "unknown error"), 7000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
new Notice(t("notice_keys_moved_local"), 5000);
|
||||
|
||||
const oldApiKey = this.plugin.settings.apiKey;
|
||||
const oldClaudeApiKey = this.plugin.settings.claudeApiKey;
|
||||
const oldLocalApiKey = this.plugin.settings.localApiKey;
|
||||
this.plugin.settings.apiKeysInSync = v;
|
||||
this.plugin.settings.apiKey = oldApiKey;
|
||||
this.plugin.settings.claudeApiKey = oldClaudeApiKey;
|
||||
this.plugin.settings.localApiKey = oldLocalApiKey;
|
||||
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;
|
||||
delete d.localApiKey;
|
||||
await this.plugin.saveData(d);
|
||||
}
|
||||
new Notice(t("notice_keys_moved_local"), 5000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[AI-Vault] Failed to change API key sync setting:", e);
|
||||
new Notice(t("notice_setting_change_failed", (e as Error)?.message ?? String(e)), 7000);
|
||||
} finally {
|
||||
this.display();
|
||||
}
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
||||
if (!extEnabled) {
|
||||
if (!isDesktop) {
|
||||
el.createEl("div", {
|
||||
cls: "gpt-settings-note",
|
||||
text: t("settings_keys_mobile_note"),
|
||||
|
|
@ -152,45 +185,276 @@ export class GPTSettingsTab extends PluginSettingTab {
|
|||
}
|
||||
}
|
||||
|
||||
private renderOpenAIKeys(el: HTMLElement): void {
|
||||
private renderModelSelector(el: HTMLElement): void {
|
||||
const currentModel = this.getCurrentActiveModel();
|
||||
const localModels = this.getLocalModelOptions();
|
||||
const knownModels = new Set<string>();
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_model_heading"))
|
||||
.setHeading();
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_provider_name"))
|
||||
.setDesc(t("settings_provider_desc"))
|
||||
.addDropdown(d => d
|
||||
.addOption("openai", "OpenAI")
|
||||
.addOption("anthropic", "Anthropic")
|
||||
.addOption("local", "Local API")
|
||||
.setValue(this.plugin.settings.provider)
|
||||
.onChange(async (value: string) => {
|
||||
if (!isProvider(value)) return;
|
||||
this.plugin.settings.provider = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_active_model_name"))
|
||||
.setDesc(t("settings_active_model_desc"))
|
||||
.addDropdown(d => {
|
||||
const addModel = (id: string, label: string): void => {
|
||||
knownModels.add(id);
|
||||
d.addOption(id, label);
|
||||
};
|
||||
|
||||
d.addOption("__openai_header__", "--- OpenAI ---");
|
||||
addModel("gpt-5", "GPT-5 (reasoning, best)");
|
||||
addModel("gpt-5-mini", "GPT-5 Mini (reasoning, faster)");
|
||||
addModel("gpt-5-nano", "GPT-5 Nano (fast / affordable)");
|
||||
addModel("gpt-5-search-api", "GPT-5 Search (web search)");
|
||||
addModel("gpt-4o", "GPT-4o (web search)");
|
||||
addModel("gpt-4o-mini", "GPT-4o Mini (web search)");
|
||||
addModel("gpt-4-turbo", "GPT-4 Turbo");
|
||||
|
||||
d.addOption("__claude_header__", "--- Anthropic ---");
|
||||
addModel("claude-opus-4-5", "Claude Opus 4.5 (best)");
|
||||
addModel("claude-sonnet-4-5", "Claude Sonnet 4.5 (recommended)");
|
||||
addModel("claude-haiku-4-5", "Claude Haiku 4.5 (fast / affordable)");
|
||||
|
||||
d.addOption("__local_header__", "--- Local API ---");
|
||||
for (const model of localModels) addModel(model, model);
|
||||
if (localModels.length === 0) {
|
||||
d.addOption("__local_empty__", t("settings_local_empty_paren"));
|
||||
}
|
||||
|
||||
if (currentModel && !knownModels.has(currentModel)) addModel(currentModel, currentModel);
|
||||
d.setValue(currentModel || "__local_empty__");
|
||||
|
||||
d.onChange(async (value: string) => {
|
||||
if (value.startsWith("__")) {
|
||||
d.setValue(currentModel || "__local_empty__");
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = localModels.includes(value) ? "local" : detectProvider(value);
|
||||
this.plugin.settings.provider = provider;
|
||||
if (provider === "openai") {
|
||||
this.plugin.settings.model = value;
|
||||
} else if (provider === "anthropic") {
|
||||
this.plugin.settings.claudeModel = value;
|
||||
} else {
|
||||
this.plugin.settings.localModel = value;
|
||||
}
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
});
|
||||
|
||||
return d;
|
||||
});
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_autodetect_name"))
|
||||
.setDesc(t("settings_autodetect_desc"))
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.autoDetectProvider)
|
||||
.onChange(async (value: boolean) => {
|
||||
this.plugin.settings.autoDetectProvider = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
||||
this.renderActiveApiKey(el, this.plugin.settings.provider);
|
||||
}
|
||||
|
||||
private getLocalModelOptions(): string[] {
|
||||
const models = [...(this.plugin.settings.localModelsCache ?? [])];
|
||||
const current = this.plugin.settings.localModel?.trim();
|
||||
if (current && !models.includes(current)) models.unshift(current);
|
||||
return models;
|
||||
}
|
||||
|
||||
private getCurrentActiveModel(): string {
|
||||
const provider = this.plugin.settings.provider;
|
||||
if (provider === "anthropic") return this.plugin.settings.claudeModel ?? "claude-sonnet-4-5";
|
||||
if (provider === "local") return this.plugin.settings.localModel?.trim() ?? "";
|
||||
return this.plugin.settings.model ?? "gpt-4o";
|
||||
}
|
||||
|
||||
private renderActiveApiKey(el: HTMLElement, provider: Provider): 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");
|
||||
|
||||
if (provider === "openai") {
|
||||
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 (value: string) => {
|
||||
this.plugin.settings.apiKey = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
} else if (provider === "anthropic") {
|
||||
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 (value: string) => {
|
||||
this.plugin.settings.claudeApiKey = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderLocalApiSettings(el: HTMLElement): void {
|
||||
if (this.plugin.settings.provider !== "local") return;
|
||||
|
||||
const localType = this.plugin.settings.localApiType;
|
||||
const placeholder = this.getDefaultLocalBaseUrl(localType);
|
||||
const localModels = this.getLocalModelOptions();
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_openai_title"))
|
||||
.setName(t("settings_local_title"))
|
||||
.setDesc(t("settings_local_desc"))
|
||||
.setHeading();
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_openai_key_name"))
|
||||
.setDesc(keysLocation)
|
||||
.setName(t("settings_local_type_name"))
|
||||
.addDropdown(d => d
|
||||
.addOption("openai-compatible", "OpenAI-compatible")
|
||||
.addOption("ollama", "Ollama")
|
||||
.setValue(localType)
|
||||
.onChange(async (value: string) => {
|
||||
if (!isLocalApiType(value)) return;
|
||||
const previousType = this.plugin.settings.localApiType;
|
||||
this.plugin.settings.localApiType = value;
|
||||
|
||||
const currentBase = this.plugin.settings.localBaseUrl.trim();
|
||||
const previousDefault = this.getDefaultLocalBaseUrl(previousType);
|
||||
if (!currentBase || normalizeLocalBaseUrl(currentBase, previousType) === previousDefault) {
|
||||
this.plugin.settings.localBaseUrl = this.getDefaultLocalBaseUrl(value);
|
||||
}
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_local_baseurl_name"))
|
||||
.setDesc(t("settings_local_baseurl_desc"))
|
||||
.addText(txt => {
|
||||
txt.inputEl.type = "password";
|
||||
txt.setPlaceholder("sk-…")
|
||||
.setValue(this.plugin.settings.apiKey)
|
||||
.onChange(async (v: string) => {
|
||||
this.plugin.settings.apiKey = v.trim();
|
||||
txt.setPlaceholder(placeholder)
|
||||
.setValue(this.plugin.settings.localBaseUrl ?? "")
|
||||
.onChange(async (value: string) => {
|
||||
this.plugin.settings.localBaseUrl = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
txt.inputEl.addClass("gpt-settings-input-full");
|
||||
});
|
||||
|
||||
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();
|
||||
.setName(t("settings_local_api_key_name"))
|
||||
.setDesc(t("settings_local_api_key_desc"))
|
||||
.addText(txt => {
|
||||
txt.inputEl.type = "password";
|
||||
txt.setValue(this.plugin.settings.localApiKey ?? "")
|
||||
.onChange(async (value: string) => {
|
||||
this.plugin.settings.localApiKey = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
txt.inputEl.addClass("gpt-settings-input-full");
|
||||
});
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_local_refresh_name"))
|
||||
.setDesc(t("settings_local_refresh_desc"))
|
||||
.addButton(btn => btn
|
||||
.setButtonText(t("settings_local_refresh_btn"))
|
||||
.setTooltip(t("settings_local_refresh_tip"))
|
||||
.onClick(() => {
|
||||
btn.setButtonText(t("settings_local_refreshing")).setDisabled(true);
|
||||
void this.refreshLocalModelsInSelector()
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("Local API refresh failed:", message);
|
||||
new Notice(t("settings_local_refresh_fail", message), 7000);
|
||||
})
|
||||
.finally(() => {
|
||||
btn.setButtonText(t("settings_local_refresh_btn")).setDisabled(false);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_local_model_name"))
|
||||
.setDesc(localModels.length > 0
|
||||
? t("settings_local_model_desc_ok")
|
||||
: t("settings_local_model_desc_empty"))
|
||||
.addDropdown(d => {
|
||||
if (localModels.length === 0) {
|
||||
d.addOption("__local_empty__", t("settings_local_model_empty_opt"));
|
||||
d.setValue("__local_empty__");
|
||||
return d;
|
||||
}
|
||||
|
||||
for (const model of localModels) d.addOption(model, model);
|
||||
d.setValue(this.plugin.settings.localModel || localModels[0]);
|
||||
d.onChange(async (value: string) => {
|
||||
if (value.startsWith("__")) return;
|
||||
this.plugin.settings.localModel = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
});
|
||||
return d;
|
||||
});
|
||||
}
|
||||
|
||||
private getDefaultLocalBaseUrl(localApiType: LocalApiType): string {
|
||||
return localApiType === "ollama" ? DEFAULT_LOCAL_OLLAMA_URL : DEFAULT_LOCAL_OPENAI_URL;
|
||||
}
|
||||
|
||||
private async refreshLocalModelsInSelector(): Promise<void> {
|
||||
const defaultBaseUrl = this.getDefaultLocalBaseUrl(this.plugin.settings.localApiType);
|
||||
const normalizedBaseUrl = normalizeLocalBaseUrl(
|
||||
this.plugin.settings.localBaseUrl || defaultBaseUrl,
|
||||
this.plugin.settings.localApiType,
|
||||
);
|
||||
this.plugin.settings.localBaseUrl = normalizedBaseUrl;
|
||||
|
||||
const models = await fetchLocalModels(this.plugin.settings);
|
||||
this.plugin.settings.localModelsCache = models;
|
||||
|
||||
if (!this.plugin.settings.localModel?.trim() && models.length > 0) {
|
||||
this.plugin.settings.localModel = models[0];
|
||||
}
|
||||
|
||||
await this.plugin.saveSettings();
|
||||
new Notice(t("settings_local_models_found", models.length), 3000);
|
||||
this.display();
|
||||
}
|
||||
|
||||
private renderOpenAISettings(el: HTMLElement): void {
|
||||
|
|
@ -312,43 +576,6 @@ export class GPTSettingsTab extends PluginSettingTab {
|
|||
});
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
new Setting(el)
|
||||
.setName(t("settings_claude_title"))
|
||||
.setHeading();
|
||||
|
||||
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 {
|
||||
new Setting(el)
|
||||
.setName(t("settings_rag_title"))
|
||||
|
|
@ -418,10 +645,10 @@ export class GPTSettingsTab extends PluginSettingTab {
|
|||
info.empty();
|
||||
info.createEl("strong", { text: t("settings_storage_active") });
|
||||
info.createEl("br");
|
||||
info.appendChild(info.ownerDocument.createTextNode("Obsidian Sync does not sync this data."));
|
||||
info.appendChild(info.ownerDocument.createTextNode(t("settings_storage_no_sync")));
|
||||
info.createEl("br");
|
||||
info.createEl("br");
|
||||
info.createEl("strong", { text: "Location:" });
|
||||
info.createEl("strong", { text: t("settings_storage_location") });
|
||||
info.createEl("br");
|
||||
const pathEl = info.createEl("code", { text: currentPath });
|
||||
pathEl.addClass("gpt-settings-storage-path");
|
||||
|
|
@ -436,10 +663,27 @@ export class GPTSettingsTab extends PluginSettingTab {
|
|||
.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();
|
||||
tog.setDisabled(true);
|
||||
try {
|
||||
this.plugin.settings.externalStorageEnabled = v;
|
||||
if (v) {
|
||||
if (!(await this.plugin.externalStorage.init())) {
|
||||
this.plugin.settings.externalStorageEnabled = false;
|
||||
await this.plugin.saveSettings();
|
||||
new Notice(t("notice_storage_init_failed", this.plugin.externalStorage.lastError ?? "unknown error"), 7000);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.plugin.externalStorage.disable();
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
new Notice(t(v ? "notice_storage_enabled" : "notice_storage_disabled"), 5000);
|
||||
} catch (e) {
|
||||
console.error("[AI-Vault] Failed to change external storage setting:", e);
|
||||
new Notice(t("notice_setting_change_failed", (e as Error)?.message ?? String(e)), 7000);
|
||||
} finally {
|
||||
this.display();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -450,7 +694,7 @@ export class GPTSettingsTab extends PluginSettingTab {
|
|||
.setName(t("settings_storage_path_name"))
|
||||
.setDesc(t("settings_storage_path_desc", defaultPath))
|
||||
.addText(txt => {
|
||||
txt.setPlaceholder("/path/to/folder (empty = auto)")
|
||||
txt.setPlaceholder(t("settings_storage_path_placeholder"))
|
||||
.setValue(this.plugin.settings.externalStoragePath ?? "")
|
||||
.setDisabled(!isDesktop);
|
||||
txt.inputEl.addClass("gpt-settings-input-full");
|
||||
|
|
@ -491,21 +735,5 @@ export class GPTSettingsTab extends PluginSettingTab {
|
|||
}),
|
||||
);
|
||||
|
||||
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 -- Electron shell is only available at runtime in Obsidian desktop.
|
||||
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));
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
import { THINKING_MODES } from "../models";
|
||||
import { withRetry } from "../utils";
|
||||
import { streamSSE } from "./streaming";
|
||||
import { requestCompletion } from "./streaming";
|
||||
import type { ChatMessage } from "../types";
|
||||
import type { StreamResult } from "./streaming";
|
||||
|
||||
function isUnknownArray(value: unknown): value is unknown[] {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the Anthropic Claude API via SSE streaming.
|
||||
* Calls the Anthropic Claude API through Obsidian requestUrl.
|
||||
*
|
||||
* 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,
|
||||
|
|
@ -38,7 +39,7 @@ export async function callClaude(
|
|||
max_tokens: isThinking ? tokens + 8000 : tokens,
|
||||
system: systemMsg?.content ?? undefined,
|
||||
messages: inputMsgs,
|
||||
stream: true,
|
||||
stream: false,
|
||||
};
|
||||
|
||||
if (isThinking) {
|
||||
|
|
@ -46,29 +47,30 @@ export async function callClaude(
|
|||
}
|
||||
|
||||
// Web search — server tool: Anthropic runs the searches on its side.
|
||||
// One request, one stream — no agentic loop needed.
|
||||
// Anthropic runs the search within the same request.
|
||||
if (webSearch) {
|
||||
body.tools = [{ type: "web_search_20260209", name: "web_search" }];
|
||||
}
|
||||
|
||||
return withRetry(() =>
|
||||
streamSSE(
|
||||
requestCompletion(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
{
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
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;
|
||||
if (!isUnknownArray(event.content)) return null;
|
||||
const fragments: string[] = [];
|
||||
for (const block of event.content) {
|
||||
if (
|
||||
typeof block === "object" && block !== null &&
|
||||
"type" in block && block.type === "text" &&
|
||||
"text" in block && typeof block.text === "string"
|
||||
) fragments.push(block.text);
|
||||
}
|
||||
return null;
|
||||
return fragments.join("") || null;
|
||||
},
|
||||
onChunk,
|
||||
signal,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export { callOpenAI, callOpenAIResponses } from "./openai";
|
||||
export { callClaude } from "./anthropic";
|
||||
export { streamSSE, throwHttpError } from "./streaming";
|
||||
export { callLocalApi, fetchLocalModels, normalizeLocalBaseUrl, parseLocalModelList } from "./local";
|
||||
export { requestCompletion, throwHttpError } from "./streaming";
|
||||
export type { StreamResult, StreamUsage } from "./streaming";
|
||||
|
|
|
|||
211
src/api/local.ts
Normal file
211
src/api/local.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { requestUrl } from "obsidian";
|
||||
|
||||
import type { ChatMessage } from "../types";
|
||||
import type { LocalApiType, PluginSettings } from "../settings";
|
||||
|
||||
// ─── Response shapes (validated with type guards) ───────────────────────────────
|
||||
|
||||
interface OpenAIModelsResponse {
|
||||
data?: Array<{ id?: unknown }>;
|
||||
}
|
||||
|
||||
interface OllamaModelsResponse {
|
||||
models?: Array<{ name?: unknown }>;
|
||||
}
|
||||
|
||||
interface OpenAIChatResponse {
|
||||
choices?: Array<{ message?: { content?: unknown } }>;
|
||||
}
|
||||
|
||||
interface OllamaChatResponse {
|
||||
message?: { content?: unknown };
|
||||
}
|
||||
|
||||
export interface LocalCallOptions {
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
function isAuthenticationFailure(status: number): boolean {
|
||||
return status === 401 || status === 403;
|
||||
}
|
||||
|
||||
export function buildLocalApiHeaders(settings: PluginSettings, includeJsonContentType = false): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (includeJsonContentType) headers["Content-Type"] = "application/json";
|
||||
|
||||
const localApiKey = settings.localApiKey?.trim();
|
||||
if (localApiKey) headers["Authorization"] = `Bearer ${localApiKey}`;
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function requestLocal(options: {
|
||||
url: string;
|
||||
method: "GET" | "POST";
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
}) {
|
||||
try {
|
||||
return await requestUrl({ ...options, throw: false });
|
||||
} catch (err: unknown) {
|
||||
throw new Error(
|
||||
`Could not connect to Local API. Check that LM Studio/Ollama is running and the Base URL is correct. ${errorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── URL normalization ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalizes a local Base URL.
|
||||
* - Strips trailing slashes.
|
||||
* - For "openai-compatible": ensures the URL ends with /v1.
|
||||
* - For "ollama": leaves the host as-is (endpoints are /api/tags, /api/chat).
|
||||
*/
|
||||
export function normalizeLocalBaseUrl(baseUrl: string, localApiType: LocalApiType): string {
|
||||
const url = (baseUrl ?? "").trim().replace(/\/+$/, "");
|
||||
if (localApiType === "openai-compatible" && url.length > 0 && !/\/v1$/i.test(url)) {
|
||||
return `${url}/v1`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// ─── Model list parsing ─────────────────────────────────────────────────────────
|
||||
|
||||
export function parseLocalModelList(data: unknown, type: LocalApiType): string[] {
|
||||
if (type === "openai-compatible") {
|
||||
if (!isRecord(data) || !Array.isArray(data.data)) {
|
||||
throw new Error("Invalid OpenAI-compatible response. Expected data[].id.");
|
||||
}
|
||||
const response = data as OpenAIModelsResponse;
|
||||
return response.data
|
||||
?.map(model => model.id)
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0) ?? [];
|
||||
}
|
||||
|
||||
if (!isRecord(data) || !Array.isArray(data.models)) {
|
||||
throw new Error("Invalid Ollama response. Expected models[].name.");
|
||||
}
|
||||
const response = data as OllamaModelsResponse;
|
||||
return response.models
|
||||
?.map(model => model.name)
|
||||
.filter((name): name is string => typeof name === "string" && name.length > 0) ?? [];
|
||||
}
|
||||
|
||||
// ─── Fetch available models ─────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchLocalModels(settings: PluginSettings): Promise<string[]> {
|
||||
const base = normalizeLocalBaseUrl(settings.localBaseUrl, settings.localApiType);
|
||||
if (!base) throw new Error("Local API Base URL is empty.");
|
||||
|
||||
const url = settings.localApiType === "ollama" ? `${base}/api/tags` : `${base}/models`;
|
||||
|
||||
const response = await requestLocal({
|
||||
url,
|
||||
method: "GET",
|
||||
headers: buildLocalApiHeaders(settings),
|
||||
});
|
||||
if (isAuthenticationFailure(response.status)) {
|
||||
throw new Error("Authentication failed. Check your Local API key and Base URL.");
|
||||
}
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(`Local API error ${response.status}: ${response.text || "Check that the Base URL and API type are correct."}`);
|
||||
}
|
||||
|
||||
const models = parseLocalModelList(response.json, settings.localApiType);
|
||||
if (models.length === 0) {
|
||||
throw new Error("No models found. Load a model in LM Studio or run ollama pull <model>.");
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
// ─── Send a chat request ────────────────────────────────────────────────────────
|
||||
|
||||
async function postLocal(settings: PluginSettings, url: string, body: Record<string, unknown>): Promise<unknown> {
|
||||
const response = await requestLocal({
|
||||
url,
|
||||
method: "POST",
|
||||
headers: buildLocalApiHeaders(settings, true),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (isAuthenticationFailure(response.status)) {
|
||||
throw new Error("Authentication failed. Check your Local API key and Base URL.");
|
||||
}
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(`Local API error ${response.status}: ${response.text}`);
|
||||
}
|
||||
|
||||
return response.json;
|
||||
}
|
||||
|
||||
function extractOpenAIContent(data: unknown): string {
|
||||
if (!isRecord(data) || !Array.isArray(data.choices)) {
|
||||
throw new Error("Invalid OpenAI-compatible response. Expected choices[0].message.content.");
|
||||
}
|
||||
const response = data as OpenAIChatResponse;
|
||||
const content = response.choices?.[0]?.message?.content;
|
||||
return typeof content === "string" ? content.trim() : "";
|
||||
}
|
||||
|
||||
function extractOllamaContent(data: unknown): string {
|
||||
if (!isRecord(data) || !isRecord(data.message)) {
|
||||
throw new Error("Invalid Ollama response. Expected message.content.");
|
||||
}
|
||||
const response = data as OllamaChatResponse;
|
||||
const content = response.message?.content;
|
||||
return typeof content === "string" ? content.trim() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a local OpenAI-compatible or Ollama server (non-streaming).
|
||||
* Uses Obsidian requestUrl(), not fetch(), per Community Plugin guidelines.
|
||||
*/
|
||||
export async function callLocalApi(
|
||||
settings: PluginSettings,
|
||||
messages: ChatMessage[],
|
||||
options: LocalCallOptions = {},
|
||||
): Promise<string> {
|
||||
if (!settings.localModel?.trim()) {
|
||||
throw new Error("No local model selected. Start your local server, refresh models, and select a model.");
|
||||
}
|
||||
|
||||
const base = normalizeLocalBaseUrl(settings.localBaseUrl, settings.localApiType);
|
||||
if (!base) throw new Error("Local API Base URL is empty.");
|
||||
|
||||
const payloadMessages = messages.map(m => ({ role: m.role, content: m.content }));
|
||||
|
||||
if (settings.localApiType === "ollama") {
|
||||
const body: Record<string, unknown> = {
|
||||
model: settings.localModel,
|
||||
messages: payloadMessages,
|
||||
stream: false,
|
||||
};
|
||||
const data = await postLocal(settings, `${base}/api/chat`, body);
|
||||
const content = extractOllamaContent(data);
|
||||
if (!content) throw new Error("Invalid Ollama response. Expected message.content.");
|
||||
return content;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: settings.localModel,
|
||||
messages: payloadMessages,
|
||||
temperature: options.temperature ?? 0.7,
|
||||
stream: false,
|
||||
};
|
||||
if (typeof options.maxTokens === "number") body.max_tokens = options.maxTokens;
|
||||
|
||||
const data = await postLocal(settings, `${base}/chat/completions`, body);
|
||||
const content = extractOpenAIContent(data);
|
||||
if (!content) throw new Error("Invalid OpenAI-compatible response. Expected choices[0].message.content.");
|
||||
return content;
|
||||
}
|
||||
|
|
@ -1,9 +1,16 @@
|
|||
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 { requestCompletion } from "./streaming";
|
||||
import type { ChatMessage } from "../types";
|
||||
import type { StreamResult, StreamUsage } from "./streaming";
|
||||
import type { StreamResult } from "./streaming";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isUnknownArray(value: unknown): value is unknown[] {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
// ─── Chat Completions ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -51,8 +58,7 @@ export async function callOpenAI(
|
|||
messages: chatMessages,
|
||||
max_tokens: tokens,
|
||||
web_search_options: {},
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
stream: false,
|
||||
};
|
||||
} else if (gpt5) {
|
||||
// GPT-5/Mini/Nano without web search — Chat Completions with reasoning_effort
|
||||
|
|
@ -67,8 +73,7 @@ export async function callOpenAI(
|
|||
messages: chatMessages,
|
||||
max_completion_tokens: tokenBudget,
|
||||
reasoning_effort: effort,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
stream: false,
|
||||
};
|
||||
} else {
|
||||
// GPT-4o, GPT-4-turbo and other classic models
|
||||
|
|
@ -76,8 +81,7 @@ export async function callOpenAI(
|
|||
model,
|
||||
messages: chatMessages,
|
||||
max_tokens: tokens,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
stream: false,
|
||||
};
|
||||
if (webSearch && WEB_SEARCH_CAPABLE.has(model)) {
|
||||
body.tools = [{ type: "web_search" }];
|
||||
|
|
@ -85,13 +89,15 @@ export async function callOpenAI(
|
|||
}
|
||||
|
||||
return withRetry(() =>
|
||||
streamSSE(
|
||||
requestCompletion(
|
||||
"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;
|
||||
if (!isUnknownArray(event.choices)) return null;
|
||||
const first = event.choices[0];
|
||||
if (!isRecord(first) || !isRecord(first.message)) return null;
|
||||
return typeof first.message.content === "string" ? first.message.content : null;
|
||||
},
|
||||
onChunk,
|
||||
signal,
|
||||
|
|
@ -142,101 +148,27 @@ export async function callOpenAIResponses(
|
|||
max_output_tokens: tokenBudget,
|
||||
reasoning: { effort },
|
||||
tools: [{ type: "web_search" }],
|
||||
stream: true,
|
||||
stream: false,
|
||||
};
|
||||
|
||||
return withRetry(async () => {
|
||||
// NOTE: Using fetch() for Responses API SSE streaming as requestUrl() doesn't support streaming.
|
||||
// This requires isDesktopOnly: true in manifest.json.
|
||||
const response = await fetch("https://api.openai.com/v1/responses", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: signal ?? undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) await throwHttpError(response, model);
|
||||
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullText = "";
|
||||
let buffer = "";
|
||||
let finished = false;
|
||||
let chunksDelivered = false;
|
||||
let usage: StreamUsage | null = null;
|
||||
|
||||
const abortError = (): Error => {
|
||||
const e = new Error("Aborted by user");
|
||||
e.name = "AbortError";
|
||||
return e;
|
||||
};
|
||||
|
||||
try {
|
||||
while (!finished) {
|
||||
if (signal?.aborted) throw abortError();
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (signal?.aborted) throw abortError();
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const data = line.slice(6).trim();
|
||||
if (data === "[DONE]") { finished = true; break; }
|
||||
|
||||
let event: Record<string, unknown>;
|
||||
try { event = JSON.parse(data) as Record<string, unknown>; }
|
||||
catch { continue; }
|
||||
|
||||
// Responses API stream events:
|
||||
// response.output_text.delta → response text
|
||||
// response.completed → usage including reasoning tokens
|
||||
if (event.type === "response.output_text.delta") {
|
||||
const delta = (event.delta as string) ?? "";
|
||||
if (delta) {
|
||||
fullText += delta;
|
||||
chunksDelivered = true;
|
||||
onChunk?.(fullText);
|
||||
}
|
||||
} else if (event.type === "response.completed") {
|
||||
const r = event.response as {
|
||||
usage?: {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
output_tokens_details?: { reasoning_tokens?: number };
|
||||
};
|
||||
} | undefined;
|
||||
if (r?.usage) {
|
||||
usage = {
|
||||
input: r.usage.input_tokens ?? 0,
|
||||
output: r.usage.output_tokens ?? 0,
|
||||
reasoning: r.usage.output_tokens_details?.reasoning_tokens ?? 0,
|
||||
};
|
||||
}
|
||||
} else if (event.type === "error" || event.error) {
|
||||
const err = event.error as { message?: string } | undefined;
|
||||
throw new Error(err?.message ?? (event.message as string) ?? t("err_stream_responses"));
|
||||
return withRetry(() => requestCompletion(
|
||||
"https://api.openai.com/v1/responses",
|
||||
{ "Authorization": `Bearer ${apiKey}` },
|
||||
body,
|
||||
(response) => {
|
||||
if (!isUnknownArray(response.output)) return null;
|
||||
const fragments: string[] = [];
|
||||
for (const item of response.output) {
|
||||
if (!isRecord(item) || !isUnknownArray(item.content)) continue;
|
||||
for (const content of item.content) {
|
||||
if (isRecord(content) && content.type === "output_text" && typeof content.text === "string") {
|
||||
fragments.push(content.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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 };
|
||||
});
|
||||
return fragments.join("") || null;
|
||||
},
|
||||
onChunk,
|
||||
signal,
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { requestUrl } from "obsidian";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { ModelAccessError } from "../models";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StreamUsage {
|
||||
input: number;
|
||||
output: number;
|
||||
|
|
@ -14,32 +14,63 @@ export interface StreamResult {
|
|||
usage: StreamUsage | null;
|
||||
}
|
||||
|
||||
type DeltaExtractor = (event: Record<string, unknown>) => string | null;
|
||||
interface HttpResponse {
|
||||
status: number;
|
||||
json: unknown;
|
||||
}
|
||||
|
||||
// ─── HTTP error ────────────────────────────────────────────────────────────────
|
||||
type TextExtractor = (response: Record<string, unknown>) => string | null;
|
||||
|
||||
/**
|
||||
* Parses an HTTP error response and throws the appropriate Error.
|
||||
* ModelAccessError — for 403/404/model_not_found (chat view can then suggest a fallback).
|
||||
*/
|
||||
export async function throwHttpError(response: Response, modelHint?: string | null): Promise<never> {
|
||||
let errMsg = `HTTP ${response.status}`;
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown>, key: string): string | null {
|
||||
const value = record[key];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
const error = new Error("Aborted by user");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
function parseUsage(response: Record<string, unknown>): StreamUsage | null {
|
||||
if (!isRecord(response.usage)) return null;
|
||||
|
||||
const usage = response.usage;
|
||||
const details = isRecord(usage.completion_tokens_details)
|
||||
? usage.completion_tokens_details
|
||||
: isRecord(usage.output_tokens_details)
|
||||
? usage.output_tokens_details
|
||||
: null;
|
||||
const input = usage.prompt_tokens ?? usage.input_tokens;
|
||||
const output = usage.completion_tokens ?? usage.output_tokens;
|
||||
const reasoning = details?.reasoning_tokens;
|
||||
|
||||
return {
|
||||
input: typeof input === "number" ? input : 0,
|
||||
output: typeof output === "number" ? output : 0,
|
||||
reasoning: typeof reasoning === "number" ? reasoning : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Throws a provider-aware error for a failed Obsidian requestUrl response. */
|
||||
export function throwHttpError(response: HttpResponse, modelHint?: string | null): never {
|
||||
let errMsg = `HTTP ${response.status}`;
|
||||
let errCode: string | null = null;
|
||||
|
||||
try {
|
||||
const d = await response.json() as {
|
||||
error?: { message?: string; code?: string; type?: string };
|
||||
message?: string;
|
||||
};
|
||||
errMsg = d?.error?.message ?? d?.message ?? errMsg;
|
||||
errCode = d?.error?.code ?? d?.error?.type ?? null;
|
||||
} catch { /* ignore — body may be empty */ }
|
||||
if (isRecord(response.json)) {
|
||||
const error = isRecord(response.json.error) ? response.json.error : null;
|
||||
errMsg = (error ? readString(error, "message") : null)
|
||||
?? readString(response.json, "message")
|
||||
?? errMsg;
|
||||
errCode = (error ? readString(error, "code") : null)
|
||||
?? (error ? readString(error, "type") : null);
|
||||
}
|
||||
|
||||
if (
|
||||
response.status === 403 ||
|
||||
response.status === 404 ||
|
||||
errCode === "model_not_found"
|
||||
) {
|
||||
if (response.status === 403 || response.status === 404 || errCode === "model_not_found") {
|
||||
throw new ModelAccessError(errMsg, {
|
||||
model: modelHint ?? undefined,
|
||||
status: response.status,
|
||||
|
|
@ -50,149 +81,62 @@ export async function throwHttpError(response: Response, modelHint?: string | nu
|
|||
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
|
||||
* Sends a provider request through Obsidian's requestUrl API.
|
||||
* requestUrl does not expose SSE streams, so providers return one JSON response.
|
||||
*/
|
||||
export async function streamSSE(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
body: Record<string, unknown>,
|
||||
extractDelta: DeltaExtractor,
|
||||
onChunk: ((fullText: string) => void) | null,
|
||||
signal?: AbortSignal | null,
|
||||
export async function requestCompletion(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
body: Record<string, unknown>,
|
||||
extractText: TextExtractor,
|
||||
onChunk: ((fullText: string) => void) | null,
|
||||
signal?: AbortSignal | null,
|
||||
): Promise<StreamResult> {
|
||||
// NOTE: Using fetch() for SSE streaming as requestUrl() doesn't support streaming.
|
||||
// This requires isDesktopOnly: true in manifest.json.
|
||||
const response = await fetch(url, {
|
||||
if (signal?.aborted) throw abortError();
|
||||
|
||||
const requestBody: Record<string, unknown> = { ...body, stream: false };
|
||||
delete requestBody.stream_options;
|
||||
|
||||
const request = requestUrl({
|
||||
url,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
signal: signal ?? undefined,
|
||||
body: JSON.stringify(requestBody),
|
||||
throw: false,
|
||||
});
|
||||
let abortHandler: (() => void) | null = null;
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
abortHandler = () => reject(abortError());
|
||||
signal?.addEventListener("abort", abortHandler, { once: true });
|
||||
});
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
let response: Awaited<ReturnType<typeof requestUrl>>;
|
||||
try {
|
||||
while (!finished) {
|
||||
if (signal?.aborted) throw abortError();
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (signal?.aborted) throw abortError();
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
|
||||
const data = line.slice(6).trim();
|
||||
if (data === "[DONE]") { finished = true; break; }
|
||||
|
||||
let event: Record<string, unknown>;
|
||||
try { event = JSON.parse(data) as Record<string, unknown>; }
|
||||
catch { continue; } // incomplete chunk — skip
|
||||
|
||||
// Stream-side error (Anthropic/OpenAI may emit an "error" event)
|
||||
if (event.type === "error" || event.error) {
|
||||
const err = event.error as { message?: string } | undefined;
|
||||
throw new Error(err?.message ?? (event.message as string) ?? t("err_stream"));
|
||||
}
|
||||
|
||||
// Collect usage:
|
||||
// OpenAI: last chunk before [DONE] (when stream_options.include_usage = true)
|
||||
// Anthropic: message_start (input), message_delta (output)
|
||||
const eventUsage = event.usage as Record<string, unknown> | undefined;
|
||||
if (eventUsage) {
|
||||
const details: Record<string, number> | undefined = eventUsage.completion_tokens_details as Record<string, number> | undefined;
|
||||
const prevInput: number = usage !== null ? usage.input : 0;
|
||||
const prevOutput: number = usage !== null ? usage.output : 0;
|
||||
const prevReasoning: number = usage !== null ? usage.reasoning : 0;
|
||||
usage = {
|
||||
input: (eventUsage.prompt_tokens as number | undefined) ??
|
||||
(eventUsage.input_tokens as number | undefined) ??
|
||||
prevInput,
|
||||
output: (eventUsage.completion_tokens as number | undefined) ??
|
||||
(eventUsage.output_tokens as number | undefined) ??
|
||||
prevOutput,
|
||||
reasoning: details?.reasoning_tokens ?? prevReasoning,
|
||||
};
|
||||
}
|
||||
|
||||
// Anthropic: message_start carries input_tokens in a different shape
|
||||
if (event.type === "message_start") {
|
||||
const msg = event.message as { usage?: { input_tokens?: number; output_tokens?: number } } | undefined;
|
||||
if (msg?.usage) {
|
||||
usage = {
|
||||
input: msg.usage.input_tokens ?? 0,
|
||||
output: msg.usage.output_tokens ?? 0,
|
||||
reasoning: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Anthropic: message_delta carries output_tokens
|
||||
if (event.type === "message_delta") {
|
||||
const deltaUsage = event.usage as { output_tokens?: number } | undefined;
|
||||
if (deltaUsage?.output_tokens != null) {
|
||||
usage = {
|
||||
input: usage !== null ? usage.input : 0,
|
||||
reasoning: usage !== null ? usage.reasoning : 0,
|
||||
output: deltaUsage.output_tokens,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const delta = extractDelta(event);
|
||||
if (delta) {
|
||||
fullText += delta;
|
||||
chunksDelivered = true;
|
||||
onChunk?.(fullText);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Once any chunk has reached the UI, retrying would corrupt the visible
|
||||
// output. Flag the error so withRetry stops here.
|
||||
if (chunksDelivered && (err as { name?: string }).name !== "AbortError") {
|
||||
(err as { noRetry?: boolean }).noRetry = true;
|
||||
}
|
||||
throw err;
|
||||
response = signal ? await Promise.race([request, aborted]) : await request;
|
||||
} finally {
|
||||
// Always release the connection on early exit (error or abort)
|
||||
if (!finished) {
|
||||
try { await reader.cancel(); } catch { /* ignore */ }
|
||||
}
|
||||
if (abortHandler) signal?.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
|
||||
if (!fullText.trim()) throw new Error(t("err_empty_response"));
|
||||
return { text: fullText.trim(), usage };
|
||||
if (signal?.aborted) throw abortError();
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const model = typeof body.model === "string" ? body.model : null;
|
||||
throwHttpError({ status: response.status, json: response.json }, model);
|
||||
}
|
||||
if (!isRecord(response.json)) throw new Error(t("err_empty_response"));
|
||||
|
||||
const providerError = isRecord(response.json.error) ? response.json.error : null;
|
||||
if (providerError || response.json.type === "error") {
|
||||
throw new Error(
|
||||
(providerError ? readString(providerError, "message") : null)
|
||||
?? readString(response.json, "message")
|
||||
?? t("err_stream"),
|
||||
);
|
||||
}
|
||||
|
||||
const text = extractText(response.json)?.trim() ?? "";
|
||||
if (!text) throw new Error(t("err_empty_response"));
|
||||
|
||||
onChunk?.(text);
|
||||
return { text, usage: parseUsage(response.json) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { DIR_HISTORY, FILE_HISTORY_INDEX } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import type { ExternalStorage } from "../storage/ExternalStorage";
|
||||
import type { ChatMessage, ChatSession, SessionMeta } from "../types";
|
||||
|
||||
|
|
@ -88,7 +89,7 @@ export class HistoryManager {
|
|||
const now = Date.now();
|
||||
return {
|
||||
id: now.toString(),
|
||||
title: "New conversation",
|
||||
title: t("chat_default_title"),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
messages: [],
|
||||
|
|
|
|||
124
src/i18n.ts
124
src/i18n.ts
|
|
@ -58,6 +58,9 @@ const en: TranslationDict = {
|
|||
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_no_sync: "Obsidian Sync does not sync this data.",
|
||||
settings_storage_location: "Location:",
|
||||
settings_storage_path_placeholder: "/path/to/folder (empty = auto)",
|
||||
settings_storage_active: "✅ Active — history saved OUTSIDE vault",
|
||||
settings_storage_inactive: "⚠️ Disabled — history saved inside vault",
|
||||
settings_storage_inactive_html: "⚠️ <strong>Disabled — history saved inside vault</strong><br>Obsidian Sync may synchronize chat history (uses up your GB limit).",
|
||||
|
|
@ -69,6 +72,36 @@ const en: TranslationDict = {
|
|||
settings_lang_name: "Language",
|
||||
settings_lang_desc: "Interface language for the plugin.",
|
||||
|
||||
// Model selector
|
||||
settings_model_heading: "Model",
|
||||
settings_provider_name: "Provider",
|
||||
settings_provider_desc: "Select the active AI provider.",
|
||||
settings_active_model_name: "Active model",
|
||||
settings_active_model_desc: "Provider is detected automatically from the model name.",
|
||||
settings_local_empty_paren: "(no models - click Refresh)",
|
||||
settings_autodetect_name: "Auto-detect provider",
|
||||
settings_autodetect_desc: "Automatically detect AI provider from model name: claude-* -> Anthropic, gpt-* -> OpenAI, others -> Local API.",
|
||||
|
||||
// Local API
|
||||
settings_local_title: "🖥️ Local API",
|
||||
settings_local_desc: "Use local models through LM Studio, Ollama or other local servers.",
|
||||
settings_local_type_name: "Local API Type",
|
||||
settings_local_baseurl_name: "Base URL",
|
||||
settings_local_baseurl_desc: "LM Studio usually uses http://localhost:1234/v1. Ollama usually uses http://localhost:11434.",
|
||||
settings_local_api_key_name: "Local API key",
|
||||
settings_local_api_key_desc: "Optional. Required only for authenticated Ollama/OpenAI-compatible endpoints such as Ollama Cloud or third-party LLM gateways. Leave empty for local Ollama or LM Studio.",
|
||||
settings_local_refresh_name: "Refresh models",
|
||||
settings_local_refresh_desc: "Fetch available models from your selected local server.",
|
||||
settings_local_refresh_btn: "Refresh models",
|
||||
settings_local_refresh_tip: "Fetch available models from your local server",
|
||||
settings_local_refreshing: "Refreshing...",
|
||||
settings_local_refresh_fail: (m: string) => `Local API refresh failed: ${m}`,
|
||||
settings_local_model_name: "Model",
|
||||
settings_local_model_desc_ok: "Select the local model used for chat.",
|
||||
settings_local_model_desc_empty: "Click Refresh models after starting your local server.",
|
||||
settings_local_model_empty_opt: "No models loaded",
|
||||
settings_local_models_found: (n: number) => `Found ${n} Local API model(s)`,
|
||||
|
||||
// Chat view
|
||||
chat_new: "New",
|
||||
chat_history: "History",
|
||||
|
|
@ -77,6 +110,7 @@ const en: TranslationDict = {
|
|||
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_ollama: "Type a message to Local API… (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",
|
||||
|
|
@ -91,6 +125,9 @@ const en: TranslationDict = {
|
|||
chat_copy: "Copy",
|
||||
chat_copied: "Copied!",
|
||||
chat_interrupted: "⏹ Interrupted by user",
|
||||
chat_generation_stopped: "⏹ Generation stopped",
|
||||
chat_role_you: "You",
|
||||
chat_default_title: "New conversation",
|
||||
chat_btn_rag: "RAG",
|
||||
chat_btn_index: "Index",
|
||||
chat_btn_notes: "Notes",
|
||||
|
|
@ -104,6 +141,11 @@ const en: TranslationDict = {
|
|||
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_provider_tooltip: (p: string) => `Current AI type: ${p}\nClick to change`,
|
||||
chat_provider_picker_title: "Select AI type",
|
||||
chat_provider_gpt_desc: "OpenAI GPT models",
|
||||
chat_provider_claude_desc: "Anthropic Claude models",
|
||||
chat_provider_ollama_desc: "Local API models",
|
||||
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",
|
||||
|
|
@ -114,6 +156,7 @@ const en: TranslationDict = {
|
|||
chat_mode_think_desc: "Deep analysis",
|
||||
chat_picker_openai: "🤖 Select OpenAI model",
|
||||
chat_picker_claude: "🟣 Select Claude model",
|
||||
chat_picker_ollama: "🖥️ Select Local API model",
|
||||
chat_regen_tooltip: "Regenerate last response",
|
||||
chat_export_tooltip: "Export conversation to note",
|
||||
model_desc_gpt5: "Reasoning, best",
|
||||
|
|
@ -126,6 +169,8 @@ const en: TranslationDict = {
|
|||
model_desc_opus: "Best Claude",
|
||||
model_desc_sonnet: "Recommended",
|
||||
model_desc_haiku: "Fast, cheap",
|
||||
model_desc_ollama: "Local API model",
|
||||
model_desc_custom: "Current custom model",
|
||||
model_gpt5nano_label: "GPT-5 Nano",
|
||||
|
||||
// Web search
|
||||
|
|
@ -134,6 +179,7 @@ const en: TranslationDict = {
|
|||
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_ollama_unsupported: "⚠️ Web search is not available for Local API models.",
|
||||
ws_searching_label: "Searching the web…",
|
||||
|
||||
// Modes
|
||||
|
|
@ -177,6 +223,9 @@ const en: TranslationDict = {
|
|||
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",
|
||||
projects_modal_new_title: "New project:",
|
||||
projects_modal_edit_title: (name: string) => `Edit project: ${name}`,
|
||||
projects_name_placeholder: "Project name…",
|
||||
|
||||
// RAG
|
||||
rag_ready_short: (f: number, e: number) => `🗄️ RAG ready — ${f} notes (${e} embeddings)`,
|
||||
|
|
@ -202,6 +251,11 @@ const en: TranslationDict = {
|
|||
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_keys_need_external: "⚠️ Enable 'Save outside vault' before storing API keys locally.",
|
||||
notice_storage_init_failed: (e: string) => `❌ Could not enable storage outside vault: ${e}`,
|
||||
notice_storage_enabled: "✅ Storage outside vault enabled.",
|
||||
notice_storage_disabled: "✅ Storage outside vault disabled.",
|
||||
notice_setting_change_failed: (e: string) => `❌ Could not change setting: ${e}`,
|
||||
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}`,
|
||||
|
|
@ -228,6 +282,7 @@ const en: TranslationDict = {
|
|||
// Errors
|
||||
err_no_openai_key: "⚠️ Set your OpenAI API key in settings.",
|
||||
err_no_claude_key: "⚠️ Set your Claude API key in settings.",
|
||||
err_no_ollama_url: "⚠️ Set your Local API Base URL in settings.",
|
||||
err_empty_response: "Model returned an empty response. Please try again.",
|
||||
err_stream: "Streaming error",
|
||||
err_stream_responses: "Responses API streaming error",
|
||||
|
|
@ -249,12 +304,19 @@ const en: TranslationDict = {
|
|||
// Provider
|
||||
provider_switched_gpt: "🤖 Switched to GPT",
|
||||
provider_switched_claude:"🟣 Switched to Claude",
|
||||
provider_switched_ollama:"🖥️ Switched to Local API",
|
||||
|
||||
// Commands
|
||||
cmd_open_chat: "Open chat panel",
|
||||
cmd_open_history: "Open chat history",
|
||||
cmd_open_projects: "Open projects",
|
||||
cmd_summarize: "Summarize current note",
|
||||
cmd_new_chat: "New conversation",
|
||||
cmd_analyze: "Analyze selected text",
|
||||
cmd_reindex: "Re-index vault (RAG)",
|
||||
cmd_select_text_first: "Select text first.",
|
||||
cmd_note_empty: "Note is empty.",
|
||||
cmd_indexing: "⏳ Indexing…",
|
||||
|
||||
// Quiz
|
||||
quiz_error: "Error!",
|
||||
|
|
@ -335,6 +397,9 @@ const pl: TranslationDict = {
|
|||
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_no_sync: "Obsidian Sync nie synchronizuje tych danych.",
|
||||
settings_storage_location: "Lokalizacja:",
|
||||
settings_storage_path_placeholder: "/ścieżka/do/folderu (puste = auto)",
|
||||
settings_storage_active: "✅ Aktywny — historia zapisywana POZA vaultem",
|
||||
settings_storage_inactive: "⚠️ Wyłączony — historia zapisywana w vaulcie",
|
||||
settings_storage_inactive_html: "⚠️ <strong>Wyłączony — historia zapisywana w vaulcie</strong><br>Obsidian Sync może synchronizować historię rozmów (zjada limit GB).",
|
||||
|
|
@ -346,6 +411,36 @@ const pl: TranslationDict = {
|
|||
settings_lang_name: "Język / Language",
|
||||
settings_lang_desc: "Język interfejsu wtyczki.",
|
||||
|
||||
// Model selector
|
||||
settings_model_heading: "Model",
|
||||
settings_provider_name: "Dostawca",
|
||||
settings_provider_desc: "Wybierz aktywnego dostawcę AI.",
|
||||
settings_active_model_name: "Aktywny model",
|
||||
settings_active_model_desc: "Dostawca jest wykrywany automatycznie na podstawie nazwy modelu.",
|
||||
settings_local_empty_paren: "(brak modeli — kliknij Odśwież)",
|
||||
settings_autodetect_name: "Auto-wykrywanie dostawcy",
|
||||
settings_autodetect_desc: "Automatycznie wykrywaj dostawcę AI z nazwy modelu: claude-* → Anthropic, gpt-* → OpenAI, pozostałe → Lokalne API.",
|
||||
|
||||
// Local API
|
||||
settings_local_title: "🖥️ Lokalne API",
|
||||
settings_local_desc: "Używaj lokalnych modeli przez LM Studio, Ollama lub inne lokalne serwery.",
|
||||
settings_local_type_name: "Typ lokalnego API",
|
||||
settings_local_baseurl_name: "Base URL",
|
||||
settings_local_baseurl_desc: "LM Studio zwykle używa http://localhost:1234/v1. Ollama zwykle używa http://localhost:11434.",
|
||||
settings_local_api_key_name: "Local API key",
|
||||
settings_local_api_key_desc: "Optional. Required only for authenticated Ollama/OpenAI-compatible endpoints such as Ollama Cloud or third-party LLM gateways. Leave empty for local Ollama or LM Studio.",
|
||||
settings_local_refresh_name: "Odśwież modele",
|
||||
settings_local_refresh_desc: "Pobierz dostępne modele z wybranego lokalnego serwera.",
|
||||
settings_local_refresh_btn: "Odśwież modele",
|
||||
settings_local_refresh_tip: "Pobierz dostępne modele z lokalnego serwera",
|
||||
settings_local_refreshing: "Odświeżanie…",
|
||||
settings_local_refresh_fail: (m: string) => `Odświeżanie lokalnego API nie powiodło się: ${m}`,
|
||||
settings_local_model_name: "Model",
|
||||
settings_local_model_desc_ok: "Wybierz lokalny model używany do czatu.",
|
||||
settings_local_model_desc_empty: "Kliknij Odśwież modele po uruchomieniu lokalnego serwera.",
|
||||
settings_local_model_empty_opt: "Brak załadowanych modeli",
|
||||
settings_local_models_found: (n: number) => `Znaleziono ${n} modeli lokalnego API`,
|
||||
|
||||
// Chat view
|
||||
chat_new: "Nowa",
|
||||
chat_history: "Historia",
|
||||
|
|
@ -354,6 +449,7 @@ const pl: TranslationDict = {
|
|||
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_ollama: "Napisz wiadomość do lokalnego API… (Enter = wyślij)",
|
||||
chat_placeholder_code: "Opisz co chcesz zakodować…",
|
||||
chat_placeholder_learn: "Podaj temat lub napisz 'zrób test'…",
|
||||
chat_notes_btn: "Notatki",
|
||||
|
|
@ -368,6 +464,9 @@ const pl: TranslationDict = {
|
|||
chat_copy: "Kopiuj",
|
||||
chat_copied: "Skopiowano!",
|
||||
chat_interrupted: "⏹ Przerwane przez użytkownika",
|
||||
chat_generation_stopped: "⏹ Przerwano generowanie",
|
||||
chat_role_you: "Ty",
|
||||
chat_default_title: "Nowa rozmowa",
|
||||
chat_btn_rag: "RAG",
|
||||
chat_btn_index: "Indeksuj",
|
||||
chat_btn_notes: "Notatki",
|
||||
|
|
@ -381,6 +480,11 @@ const pl: TranslationDict = {
|
|||
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_provider_tooltip: (p: string) => `Aktualny typ AI: ${p}\nKliknij aby zmienić`,
|
||||
chat_provider_picker_title: "Wybierz typ AI",
|
||||
chat_provider_gpt_desc: "Modele OpenAI GPT",
|
||||
chat_provider_claude_desc: "Modele Anthropic Claude",
|
||||
chat_provider_ollama_desc: "Lokalne modele API",
|
||||
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",
|
||||
|
|
@ -391,6 +495,7 @@ const pl: TranslationDict = {
|
|||
chat_mode_think_desc: "Głęboka analiza",
|
||||
chat_picker_openai: "🤖 Wybierz model OpenAI",
|
||||
chat_picker_claude: "🟣 Wybierz model Claude",
|
||||
chat_picker_ollama: "🖥️ Wybierz model lokalnego API",
|
||||
chat_regen_tooltip: "Regeneruj ostatnią odpowiedź",
|
||||
chat_export_tooltip: "Eksportuj rozmowę do notatki",
|
||||
model_desc_gpt5: "Reasoning, najlepszy",
|
||||
|
|
@ -403,6 +508,8 @@ const pl: TranslationDict = {
|
|||
model_desc_opus: "Najlepszy Claude",
|
||||
model_desc_sonnet: "Polecany",
|
||||
model_desc_haiku: "Szybki, tani",
|
||||
model_desc_ollama: "Lokalny model API",
|
||||
model_desc_custom: "Bieżący własny model",
|
||||
model_gpt5nano_label: "GPT-5 Nano",
|
||||
|
||||
// Web search
|
||||
|
|
@ -411,6 +518,7 @@ const pl: TranslationDict = {
|
|||
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_ollama_unsupported: "⚠️ Web search nie jest dostępny dla modeli lokalnego API.",
|
||||
ws_searching_label: "Przeszukuję internet…",
|
||||
|
||||
// Modes
|
||||
|
|
@ -454,6 +562,9 @@ const pl: TranslationDict = {
|
|||
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",
|
||||
projects_modal_new_title: "Nowy projekt:",
|
||||
projects_modal_edit_title: (name: string) => `Edytuj projekt: ${name}`,
|
||||
projects_name_placeholder: "Nazwa projektu…",
|
||||
|
||||
// RAG
|
||||
rag_ready_short: (f: number, e: number) => `🗄️ RAG gotowy — ${f} notatek (${e} embeddingów)`,
|
||||
|
|
@ -479,6 +590,11 @@ const pl: TranslationDict = {
|
|||
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_keys_need_external: "⚠️ Najpierw włącz „Zapis poza vaultem”, aby przechowywać klucze API lokalnie.",
|
||||
notice_storage_init_failed: (e: string) => `❌ Nie udało się włączyć zapisu poza vaultem: ${e}`,
|
||||
notice_storage_enabled: "✅ Włączono zapis poza vaultem.",
|
||||
notice_storage_disabled: "✅ Wyłączono zapis poza vaultem.",
|
||||
notice_setting_change_failed: (e: string) => `❌ Nie udało się zmienić ustawienia: ${e}`,
|
||||
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}`,
|
||||
|
|
@ -505,6 +621,7 @@ const pl: TranslationDict = {
|
|||
// Errors
|
||||
err_no_openai_key: "⚠️ Ustaw klucz API OpenAI w ustawieniach.",
|
||||
err_no_claude_key: "⚠️ Ustaw klucz API Claude w ustawieniach.",
|
||||
err_no_ollama_url: "⚠️ Ustaw Base URL lokalnego API 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",
|
||||
|
|
@ -526,12 +643,19 @@ const pl: TranslationDict = {
|
|||
// Provider
|
||||
provider_switched_gpt: "🤖 Przełączono na GPT",
|
||||
provider_switched_claude:"🟣 Przełączono na Claude",
|
||||
provider_switched_ollama:"🖥️ Przełączono na Lokalne API",
|
||||
|
||||
// Commands
|
||||
cmd_open_chat: "Otwórz panel czatu",
|
||||
cmd_open_history: "Otwórz historię rozmów",
|
||||
cmd_open_projects: "Otwórz projekty",
|
||||
cmd_summarize: "Podsumuj aktualną notatkę",
|
||||
cmd_new_chat: "Nowa rozmowa",
|
||||
cmd_analyze: "Analizuj zaznaczony tekst",
|
||||
cmd_reindex: "Przeindeksuj vault (RAG)",
|
||||
cmd_select_text_first: "Najpierw zaznacz tekst.",
|
||||
cmd_note_empty: "Notatka jest pusta.",
|
||||
cmd_indexing: "⏳ Indeksowanie…",
|
||||
|
||||
// Quiz
|
||||
quiz_error: "Błąd!",
|
||||
|
|
|
|||
52
src/main.ts
52
src/main.ts
|
|
@ -82,14 +82,14 @@ export default class GPTPlugin extends Plugin {
|
|||
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: "New conversation", callback: () => this.newChat() });
|
||||
this.addCommand({ id: "new-gpt-chat", name: t("cmd_new_chat"), callback: () => this.newChat() });
|
||||
|
||||
this.addCommand({
|
||||
id: "analyze-selection",
|
||||
name: "Analyze selected text",
|
||||
name: t("cmd_analyze"),
|
||||
editorCallback: async editor => {
|
||||
const sel = editor.getSelection();
|
||||
if (!sel) { new Notice("Select text first."); return; }
|
||||
if (!sel) { new Notice(t("cmd_select_text_first")); return; }
|
||||
const view = await this.activateChatView();
|
||||
await new Promise(r => window.setTimeout(r, 300));
|
||||
void view?.sendMessage(`Analyze:\n\n${sel}`);
|
||||
|
|
@ -101,7 +101,7 @@ export default class GPTPlugin extends Plugin {
|
|||
name: t("cmd_summarize"),
|
||||
editorCallback: async editor => {
|
||||
const c = editor.getValue();
|
||||
if (!c.trim()) { new Notice("Note is empty."); return; }
|
||||
if (!c.trim()) { new Notice(t("cmd_note_empty")); return; }
|
||||
const view = await this.activateChatView();
|
||||
await new Promise(r => window.setTimeout(r, 300));
|
||||
void view?.sendMessage(`Summarize in 5 points:\n\n${c.slice(0, 8000)}`);
|
||||
|
|
@ -110,12 +110,12 @@ export default class GPTPlugin extends Plugin {
|
|||
|
||||
this.addCommand({
|
||||
id: "reindex-vault",
|
||||
name: "Re-index vault (RAG)",
|
||||
name: t("cmd_reindex"),
|
||||
callback: async () => {
|
||||
new Notice("⏳ Indexing…");
|
||||
new Notice(t("cmd_indexing"));
|
||||
await this.rag.buildIndex();
|
||||
const s = this.rag.stats;
|
||||
new Notice(`✅ RAG: ${s.files} notes`);
|
||||
new Notice(t("rag_done", s.files));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -203,9 +203,14 @@ export default class GPTPlugin extends Plugin {
|
|||
const session = this.currentSession as {
|
||||
id: string; title: string; projectId: string | null;
|
||||
messages: ChatMessage[]; createdAt: number; updatedAt: number;
|
||||
model?: string;
|
||||
};
|
||||
session.messages = messages;
|
||||
session.updatedAt = Date.now();
|
||||
session.model =
|
||||
this.settings.provider === "anthropic" ? (this.settings.claudeModel ?? "claude-sonnet-4-5") :
|
||||
this.settings.provider === "local" ? (this.settings.localModel || "") :
|
||||
this.settings.model;
|
||||
|
||||
// Auto-title from the first user message
|
||||
if (messages.length >= 1 && session.title === "New conversation") {
|
||||
|
|
@ -286,7 +291,28 @@ export default class GPTPlugin extends Plugin {
|
|||
await this.saveData(d);
|
||||
}
|
||||
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, d) as PluginSettings;
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, d);
|
||||
|
||||
// Migrate the legacy standalone "ollama" provider → unified Local API
|
||||
if (d) this._migrateLegacyOllamaSettings(d);
|
||||
}
|
||||
|
||||
/** Maps old ollama provider/fields onto the new Local API settings (one-time, non-destructive). */
|
||||
private _migrateLegacyOllamaSettings(raw: Record<string, unknown>): void {
|
||||
const legacyBaseUrl = raw.ollamaBaseUrl;
|
||||
const legacyModel = raw.ollamaModel;
|
||||
|
||||
if ((raw.provider as string) === "ollama") {
|
||||
this.settings.provider = "local";
|
||||
this.settings.localApiType = "ollama";
|
||||
if (typeof legacyModel === "string" && legacyModel && !this.settings.localModel) {
|
||||
this.settings.localModel = legacyModel;
|
||||
}
|
||||
if (typeof legacyBaseUrl === "string" && legacyBaseUrl
|
||||
&& this.settings.localBaseUrl === DEFAULT_SETTINGS.localBaseUrl) {
|
||||
this.settings.localBaseUrl = legacyBaseUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
|
|
@ -302,9 +328,11 @@ export default class GPTPlugin extends Plugin {
|
|||
await this.externalStorage.writeJson(keysPath, {
|
||||
apiKey: this.settings.apiKey ?? "",
|
||||
claudeApiKey: this.settings.claudeApiKey ?? "",
|
||||
localApiKey: this.settings.localApiKey ?? "",
|
||||
});
|
||||
delete toSave.apiKey;
|
||||
delete toSave.claudeApiKey;
|
||||
delete toSave.localApiKey;
|
||||
await this.saveData(toSave);
|
||||
}
|
||||
}
|
||||
|
|
@ -332,18 +360,21 @@ export default class GPTPlugin extends Plugin {
|
|||
// 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;
|
||||
const hasOld = this.settings.apiKey || this.settings.claudeApiKey || this.settings.localApiKey;
|
||||
if (hasOld) {
|
||||
// Migration: data.json → keys.json
|
||||
await this.externalStorage.writeJson(keysPath, {
|
||||
apiKey: this.settings.apiKey ?? "",
|
||||
claudeApiKey: this.settings.claudeApiKey ?? "",
|
||||
localApiKey: this.settings.localApiKey ?? "",
|
||||
});
|
||||
delete (this.settings as unknown as Record<string, unknown>).apiKey;
|
||||
delete (this.settings as unknown as Record<string, unknown>).claudeApiKey;
|
||||
delete (this.settings as unknown as Record<string, unknown>).localApiKey;
|
||||
await this.saveData({ ...this.settings });
|
||||
this.settings.apiKey = this.settings.apiKey ?? "";
|
||||
this.settings.claudeApiKey = this.settings.claudeApiKey ?? "";
|
||||
this.settings.localApiKey = this.settings.localApiKey ?? "";
|
||||
new Notice(t("notice_keys_migrated"), 4000);
|
||||
}
|
||||
return;
|
||||
|
|
@ -352,7 +383,7 @@ export default class GPTPlugin extends Plugin {
|
|||
// 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);
|
||||
const stored = await this.externalStorage.readJson<{ apiKey?: string; claudeApiKey?: string; localApiKey?: string } | null>(keysPath, null);
|
||||
if (!stored) {
|
||||
console.warn("[AI-Vault] _loadApiKeys: keys.json unreadable, preserving in-memory keys");
|
||||
return;
|
||||
|
|
@ -360,6 +391,7 @@ export default class GPTPlugin extends Plugin {
|
|||
|
||||
this.settings.apiKey = stored.apiKey ?? this.settings.apiKey ?? "";
|
||||
this.settings.claudeApiKey = stored.claudeApiKey ?? this.settings.claudeApiKey ?? "";
|
||||
this.settings.localApiKey = stored.localApiKey ?? this.settings.localApiKey ?? "";
|
||||
}
|
||||
|
||||
// ── Auto-migration ─────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { Provider } from "./settings";
|
||||
|
||||
// ─── Model sets ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Models that support built-in web search */
|
||||
|
|
@ -30,6 +32,27 @@ export function isGPT5Search(model: string): boolean {
|
|||
return model === GPT5_SEARCH_API;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the AI provider from a model id.
|
||||
* claude-* -> Anthropic, GPT/o-series/text-davinci -> OpenAI, everything else -> Local API.
|
||||
*/
|
||||
export function detectProvider(model: string): Provider {
|
||||
const lower = model.trim().toLowerCase();
|
||||
|
||||
if (lower.startsWith("claude")) return "anthropic";
|
||||
|
||||
if (
|
||||
lower.startsWith("gpt-") ||
|
||||
lower.startsWith("o1") ||
|
||||
lower.startsWith("o3") ||
|
||||
lower.startsWith("o4") ||
|
||||
lower.startsWith("chatgpt-") ||
|
||||
lower.startsWith("text-davinci")
|
||||
) return "openai";
|
||||
|
||||
return "local";
|
||||
}
|
||||
|
||||
/** Maps internal thinking modes → reasoning_effort for GPT-5 */
|
||||
export function mapEffortForGPT5(mode: string): string {
|
||||
switch (mode) {
|
||||
|
|
|
|||
|
|
@ -90,10 +90,10 @@ export class RAGEngine {
|
|||
if (
|
||||
data &&
|
||||
!Array.isArray(data) &&
|
||||
(data as RAGIndex)._version === 2 &&
|
||||
Array.isArray((data as RAGIndex).entries)
|
||||
data._version === 2 &&
|
||||
Array.isArray(data.entries)
|
||||
) {
|
||||
const idx = data as RAGIndex;
|
||||
const idx = data;
|
||||
this.index = idx.entries;
|
||||
this.fileHashes = idx.hashes ?? {};
|
||||
this.indexed = true;
|
||||
|
|
@ -104,7 +104,7 @@ export class RAGEngine {
|
|||
|
||||
// Old format (migration) — flat array
|
||||
if (Array.isArray(data) && data.length) {
|
||||
this.index = data as RAGEntry[];
|
||||
this.index = data;
|
||||
this.fileHashes = {};
|
||||
this.indexed = true;
|
||||
this.recalcAvgLen();
|
||||
|
|
@ -195,10 +195,10 @@ export class RAGEngine {
|
|||
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];
|
||||
// A full list is required here to build the user-enabled vault-wide RAG index
|
||||
// and remove index entries for deleted notes. File contents are read incrementally.
|
||||
const files = this.plugin.app.vault.getFiles()
|
||||
.filter((file: TFile) => file.extension === "md" || file.extension === "canvas");
|
||||
const currentPaths = new Set(files.map((f: TFile) => f.path));
|
||||
|
||||
// Remove entries for files that no longer exist
|
||||
|
|
@ -211,7 +211,7 @@ export class RAGEngine {
|
|||
|
||||
const newHashes: Record<string, string> = {};
|
||||
let pendingChunks: PendingChunk[] = [];
|
||||
let done = 0, skipped = 0, reindexed = 0;
|
||||
let done = 0;
|
||||
|
||||
const flushEmbeddings = async (): Promise<void> => {
|
||||
if (!pendingChunks.length || !this.apiKey) { pendingChunks = []; return; }
|
||||
|
|
@ -245,7 +245,6 @@ export class RAGEngine {
|
|||
|
||||
// Skip files that have not changed
|
||||
if (this.fileHashes[file.path] === hash) {
|
||||
skipped++;
|
||||
done++;
|
||||
onProgress?.(done, files.length);
|
||||
continue;
|
||||
|
|
@ -253,7 +252,6 @@ export class RAGEngine {
|
|||
|
||||
this.index = this.index.filter(e => e.path !== file.path);
|
||||
if (!content.trim()) { done++; continue; }
|
||||
reindexed++;
|
||||
|
||||
for (const chunk of chunkText(content)) {
|
||||
const tokens = tokenize(chunk);
|
||||
|
|
@ -287,10 +285,6 @@ export class RAGEngine {
|
|||
await this.saveIndexNow();
|
||||
this.indexed = true;
|
||||
|
||||
console.info(
|
||||
`[GPT RAG] Incremental: ${reindexed} reindexed,`,
|
||||
`${skipped} skipped, ${removedPaths.length} removed`,
|
||||
);
|
||||
} finally {
|
||||
this.indexing = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,21 +14,10 @@ export async function resolveNoteWithLinks(
|
|||
file: TFile,
|
||||
depth = 1,
|
||||
visited = new Set<string>(),
|
||||
fileMap?: Map<string, TFile>,
|
||||
): Promise<ResolvedNote[]> {
|
||||
if (visited.has(file.path)) return [];
|
||||
visited.add(file.path);
|
||||
|
||||
// Build the map once — basename → TFile, path → TFile
|
||||
if (!fileMap) {
|
||||
fileMap = new Map<string, TFile>();
|
||||
for (const f of app.vault.getMarkdownFiles()) {
|
||||
fileMap.set(f.basename, f);
|
||||
fileMap.set(f.path, f);
|
||||
fileMap.set(f.path.replace(/\.md$/, ""), f);
|
||||
}
|
||||
}
|
||||
|
||||
const results: ResolvedNote[] = [];
|
||||
|
||||
try {
|
||||
|
|
@ -43,12 +32,10 @@ export async function resolveNoteWithLinks(
|
|||
|
||||
while ((match = linkRegex.exec(content)) !== null) {
|
||||
const linkName = match[1].trim();
|
||||
const linked =
|
||||
fileMap.get(linkName) ??
|
||||
fileMap.get(linkName + ".md");
|
||||
const linked = app.metadataCache.getFirstLinkpathDest(linkName, file.path);
|
||||
|
||||
if (linked && !visited.has(linked.path)) {
|
||||
const sub = await resolveNoteWithLinks(app, linked, depth - 1, visited, fileMap);
|
||||
const sub = await resolveNoteWithLinks(app, linked, depth - 1, visited);
|
||||
results.push(...sub);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,32 @@
|
|||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ThinkingMode = "fast" | "normal" | "think";
|
||||
export type Provider = "openai" | "anthropic";
|
||||
export type Provider = "openai" | "anthropic" | "local";
|
||||
export type LocalApiType = "openai-compatible" | "ollama";
|
||||
export type Language = "en" | "pl";
|
||||
|
||||
export type RAGSearchMode = "hybrid" | "semantic" | "exact" | "recent";
|
||||
|
||||
// Default Base URLs per local API type
|
||||
export const DEFAULT_LOCAL_OPENAI_URL = "http://localhost:1234/v1";
|
||||
export const DEFAULT_LOCAL_OLLAMA_URL = "http://localhost:11434";
|
||||
|
||||
export interface PluginSettings {
|
||||
// API Keys
|
||||
apiKey: string;
|
||||
claudeApiKey: string;
|
||||
localApiKey: string;
|
||||
apiKeysInSync: boolean;
|
||||
|
||||
// Models
|
||||
provider: Provider;
|
||||
model: string;
|
||||
claudeModel: string;
|
||||
localApiType: LocalApiType;
|
||||
localBaseUrl: string;
|
||||
localModel: string;
|
||||
localModelsCache: string[];
|
||||
autoDetectProvider: boolean;
|
||||
thinkingMode: ThinkingMode;
|
||||
|
||||
// Max tokens per thinking mode
|
||||
|
|
@ -57,9 +68,15 @@ export const DEFAULT_SYSTEM_PROMPTS: Record<Language, string> = {
|
|||
export const DEFAULT_SETTINGS: PluginSettings = {
|
||||
apiKey: "",
|
||||
claudeApiKey: "",
|
||||
localApiKey: "",
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
claudeModel: "claude-sonnet-4-5",
|
||||
localApiType: "openai-compatible",
|
||||
localBaseUrl: DEFAULT_LOCAL_OPENAI_URL,
|
||||
localModel: "",
|
||||
localModelsCache: [],
|
||||
autoDetectProvider: true,
|
||||
thinkingMode: "normal",
|
||||
maxTokensFast: 4096,
|
||||
maxTokensNormal: 8192,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type { Plugin } from "obsidian";
|
||||
import { FileSystemAdapter, Platform } from "obsidian";
|
||||
import * as nodeFsModule from "fs/promises";
|
||||
import * as nodePathModule from "path";
|
||||
import { t } from "../i18n";
|
||||
import {
|
||||
DIR_HISTORY,
|
||||
|
|
@ -7,10 +9,30 @@ import {
|
|||
FILE_RAG_INDEX,
|
||||
} from "../constants";
|
||||
import type { PluginStorage, ListResult } from "./PluginStorage";
|
||||
import type { PluginSettings } from "../settings";
|
||||
import type { Plugin } from "obsidian";
|
||||
|
||||
// Node.js types — available only on desktop
|
||||
type FSPromises = typeof import("fs/promises");
|
||||
type NodePath = typeof import("path");
|
||||
interface NodeDirent {
|
||||
name: string;
|
||||
isDirectory(): boolean;
|
||||
}
|
||||
|
||||
interface NodeFsPromises {
|
||||
mkdir(path: string, options: { recursive: true }): Promise<unknown>;
|
||||
access(path: string): Promise<void>;
|
||||
readFile(path: string, encoding: "utf-8"): Promise<string>;
|
||||
writeFile(path: string, data: string, encoding: "utf-8"): Promise<void>;
|
||||
rename(oldPath: string, newPath: string): Promise<void>;
|
||||
unlink(path: string): Promise<void>;
|
||||
readdir(path: string, options: { withFileTypes: true }): Promise<NodeDirent[]>;
|
||||
}
|
||||
|
||||
interface NodePathApi {
|
||||
resolve(path: string): string;
|
||||
dirname(path: string): string;
|
||||
basename(path: string): string;
|
||||
join(...paths: string[]): string;
|
||||
}
|
||||
|
||||
interface MigrateResult {
|
||||
moved: number;
|
||||
|
|
@ -18,6 +40,49 @@ interface MigrateResult {
|
|||
errors: string[];
|
||||
}
|
||||
|
||||
interface ExternalStoragePlugin extends Plugin {
|
||||
settings: Pick<PluginSettings, "externalStorageEnabled" | "externalStoragePath">;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | null {
|
||||
if (typeof error !== "object" || error === null || !("code" in error)) return null;
|
||||
return typeof error.code === "string" ? error.code : null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function hasFunctions(value: unknown, names: string[]): value is Record<string, (...args: unknown[]) => unknown> {
|
||||
return isRecord(value) && names.every(name => typeof value[name] === "function");
|
||||
}
|
||||
|
||||
function isNodeFsPromises(value: unknown): value is NodeFsPromises {
|
||||
return hasFunctions(value, ["mkdir", "access", "readFile", "writeFile", "rename", "unlink", "readdir"]);
|
||||
}
|
||||
|
||||
function isNodePathApi(value: unknown): value is NodePathApi {
|
||||
return hasFunctions(value, ["resolve", "dirname", "basename", "join"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Node built-ins do not have one consistent runtime shape across the Electron
|
||||
* versions used by Obsidian. Native ESM exposes the methods directly, while
|
||||
* CommonJS interop can put the whole module under `default`.
|
||||
*/
|
||||
function getModuleApi<T>(
|
||||
value: unknown,
|
||||
isApi: (candidate: unknown) => candidate is T,
|
||||
): T | null {
|
||||
if (isApi(value)) return value;
|
||||
if (isRecord(value) && isApi(value.default)) return value.default;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage that writes data to a local system directory NEXT TO the vault folder.
|
||||
* Obsidian Sync does NOT synchronize these files.
|
||||
|
|
@ -29,44 +94,65 @@ interface MigrateResult {
|
|||
* Can be overridden via settings.externalStoragePath.
|
||||
*/
|
||||
export class ExternalStorage {
|
||||
private _fs: FSPromises | null = null;
|
||||
private _path: NodePath | null = null;
|
||||
private _fs: NodeFsPromises | null = null;
|
||||
private _path: NodePathApi | null = null;
|
||||
private _desktop = false;
|
||||
private _baseDir: string | null = null;
|
||||
private _enabled = false;
|
||||
private _lastError: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly plugin: Plugin,
|
||||
private readonly plugin: ExternalStoragePlugin,
|
||||
private readonly fallback: PluginStorage,
|
||||
) {
|
||||
// Detect desktop — Obsidian on desktop exposes Node via require()
|
||||
// NOTE: Using Node.js fs/path for external storage outside the vault.
|
||||
// This requires desktop Obsidian. See manifest.json: isDesktopOnly = true.
|
||||
try {
|
||||
if (
|
||||
typeof process !== "undefined" &&
|
||||
process.versions &&
|
||||
process.versions.node
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- Node.js fs is required for desktop-only external storage.
|
||||
this._fs = require("fs/promises") as FSPromises;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- Node.js path is required for desktop-only external storage.
|
||||
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,
|
||||
);
|
||||
) {}
|
||||
|
||||
private async _loadNodeModules(): Promise<boolean> {
|
||||
if (!Platform.isDesktopApp) return false;
|
||||
if (this._fs && this._path) {
|
||||
this._desktop = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Static imports are bundled as CommonJS require calls, which is the module
|
||||
// system used by Obsidian plugins. Native dynamic imports are unreliable in
|
||||
// some Electron versions and previously left the settings toggles disabled.
|
||||
const fsApi = getModuleApi(nodeFsModule, isNodeFsPromises);
|
||||
const pathApi = getModuleApi(nodePathModule, isNodePathApi);
|
||||
if (!fsApi || !pathApi) {
|
||||
throw new Error("Unexpected Node module shape");
|
||||
}
|
||||
this._fs = fsApi;
|
||||
this._path = pathApi;
|
||||
this._desktop = true;
|
||||
this._lastError = null;
|
||||
return true;
|
||||
} catch (e) {
|
||||
this._lastError = errorMessage(e);
|
||||
console.warn(
|
||||
"[AI-Vault] Node fs unavailable; external storage disabled",
|
||||
this._lastError,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private get nodeFs(): NodeFsPromises {
|
||||
if (!this._fs) throw new Error("Node fs is unavailable");
|
||||
return this._fs;
|
||||
}
|
||||
|
||||
private get nodePath(): NodePathApi {
|
||||
if (!this._path) throw new Error("Node path is unavailable");
|
||||
return this._path;
|
||||
}
|
||||
|
||||
// ── Getters ────────────────────────────────────────────────────────────────
|
||||
|
||||
get isDesktop(): boolean { return this._desktop; }
|
||||
get isDesktop(): boolean { return Platform.isDesktopApp; }
|
||||
get isEnabled(): boolean { return this._enabled && this._desktop; }
|
||||
get baseDir(): string | null { return this._baseDir; }
|
||||
get lastError(): string | null { return this._lastError; }
|
||||
|
||||
// ── Initialization ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -75,30 +161,35 @@ export class ExternalStorage {
|
|||
* @returns true if external storage is active, false = we use the fallback
|
||||
*/
|
||||
async init(): Promise<boolean> {
|
||||
if (!this._desktop) {
|
||||
if (!(await this._loadNodeModules())) {
|
||||
this._enabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read settings through a narrowed plugin shape because the base Plugin type is generic here.
|
||||
const settings = (this.plugin as unknown as { settings: { externalStorageEnabled: boolean } }).settings;
|
||||
if (!settings.externalStorageEnabled) {
|
||||
if (!this.plugin.settings.externalStorageEnabled) {
|
||||
this._enabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this._baseDir = this._resolveBaseDir();
|
||||
await this._fs!.mkdir(this._baseDir, { recursive: true });
|
||||
await this.nodeFs.mkdir(this._baseDir, { recursive: true });
|
||||
this._enabled = true;
|
||||
this._lastError = null;
|
||||
return true;
|
||||
} catch (e) {
|
||||
this._lastError = errorMessage(e);
|
||||
console.error("[AI-Vault] Failed to init external storage:", e);
|
||||
this._enabled = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
disable(): void {
|
||||
this._enabled = false;
|
||||
this._baseDir = null;
|
||||
}
|
||||
|
||||
// ── Paths ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -106,24 +197,16 @@ export class ExternalStorage {
|
|||
* Or uses settings.externalStoragePath if set.
|
||||
*/
|
||||
private _resolveBaseDir(): string {
|
||||
const settings = (this.plugin as unknown as {
|
||||
settings: { externalStoragePath: string };
|
||||
}).settings;
|
||||
const custom = this.plugin.settings.externalStoragePath.trim();
|
||||
if (custom) return this.nodePath.resolve(custom);
|
||||
|
||||
const custom = (settings.externalStoragePath || "").trim();
|
||||
if (custom) return this._path!.resolve(custom);
|
||||
const adapter = this.plugin.app.vault.adapter;
|
||||
if (!(adapter instanceof FileSystemAdapter)) throw new Error("Cannot determine vault path");
|
||||
const vaultPath = adapter.getBasePath();
|
||||
|
||||
// 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`);
|
||||
const parent = this.nodePath.dirname(vaultPath);
|
||||
const vaultName = this.nodePath.basename(vaultPath);
|
||||
return this.nodePath.join(parent, `${vaultName}-gpt-data`);
|
||||
}
|
||||
|
||||
/** Returns the default path or an empty string when unavailable */
|
||||
|
|
@ -134,7 +217,7 @@ export class ExternalStorage {
|
|||
|
||||
/** 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);
|
||||
if (this.isEnabled && this._baseDir) return this.nodePath.join(this._baseDir, ...parts);
|
||||
return this.fallback.resolve(...parts);
|
||||
}
|
||||
|
||||
|
|
@ -142,8 +225,8 @@ export class ExternalStorage {
|
|||
|
||||
async ensureDir(dirPath: string): Promise<void> {
|
||||
if (this.isEnabled) {
|
||||
try { await this._fs!.mkdir(dirPath, { recursive: true }); }
|
||||
catch (e) { console.warn("[AI-Vault] ensureDir failed:", dirPath, (e as Error)?.message); }
|
||||
try { await this.nodeFs.mkdir(dirPath, { recursive: true }); }
|
||||
catch (e) { console.warn("[AI-Vault] ensureDir failed:", dirPath, errorMessage(e)); }
|
||||
return;
|
||||
}
|
||||
return this.fallback.ensureDir(dirPath);
|
||||
|
|
@ -151,7 +234,7 @@ export class ExternalStorage {
|
|||
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
if (this.isEnabled) {
|
||||
try { await this._fs!.access(filePath); return true; }
|
||||
try { await this.nodeFs.access(filePath); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
return this.fallback.exists(filePath);
|
||||
|
|
@ -160,12 +243,12 @@ export class ExternalStorage {
|
|||
async readJson<T>(filePath: string, fallback: T): Promise<T> {
|
||||
if (this.isEnabled) {
|
||||
try {
|
||||
const raw = await this._fs!.readFile(filePath, "utf-8");
|
||||
return JSON.parse(raw) as T;
|
||||
const raw = await this.nodeFs.readFile(filePath, "utf-8");
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return parsed as T;
|
||||
} catch (e) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err.code !== "ENOENT") {
|
||||
console.warn("[AI-Vault] readJson failed:", filePath, err?.message);
|
||||
if (errorCode(e) !== "ENOENT") {
|
||||
console.warn("[AI-Vault] readJson failed:", filePath, errorMessage(e));
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
|
@ -176,13 +259,13 @@ export class ExternalStorage {
|
|||
async writeJson(filePath: string, data: unknown): Promise<boolean> {
|
||||
if (this.isEnabled) {
|
||||
try {
|
||||
const dir = this._path!.dirname(filePath);
|
||||
await this._fs!.mkdir(dir, { recursive: true });
|
||||
const dir = this.nodePath.dirname(filePath);
|
||||
await this.nodeFs.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);
|
||||
await this.nodeFs.writeFile(tmp, JSON.stringify(data), "utf-8");
|
||||
await this.nodeFs.rename(tmp, filePath);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("[AI-Vault] writeJson failed:", filePath, e);
|
||||
|
|
@ -194,11 +277,10 @@ export class ExternalStorage {
|
|||
|
||||
async remove(filePath: string): Promise<void> {
|
||||
if (this.isEnabled) {
|
||||
try { await this._fs!.unlink(filePath); }
|
||||
try { await this.nodeFs.unlink(filePath); }
|
||||
catch (e) {
|
||||
const err = e as NodeJS.ErrnoException;
|
||||
if (err.code !== "ENOENT") {
|
||||
console.warn("[AI-Vault] remove failed:", filePath, err?.message);
|
||||
if (errorCode(e) !== "ENOENT") {
|
||||
console.warn("[AI-Vault] remove failed:", filePath, errorMessage(e));
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
|
@ -209,12 +291,12 @@ export class ExternalStorage {
|
|||
async list(dirPath: string): Promise<ListResult> {
|
||||
if (this.isEnabled) {
|
||||
try {
|
||||
const entries = await this._fs!.readdir(dirPath, { withFileTypes: true });
|
||||
const entries = await this.nodeFs.readdir(dirPath, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
const folders: string[] = [];
|
||||
|
||||
for (const ent of entries) {
|
||||
const full = this._path!.join(dirPath, ent.name);
|
||||
const full = this.nodePath.join(dirPath, ent.name);
|
||||
if (ent.isDirectory()) folders.push(full);
|
||||
else files.push(full);
|
||||
}
|
||||
|
|
@ -264,7 +346,7 @@ export class ExternalStorage {
|
|||
result.errors.push(t("storage_save_error", fname));
|
||||
}
|
||||
} catch (e) {
|
||||
result.errors.push(`${fname}: ${(e as Error)?.message ?? e}`);
|
||||
result.errors.push(`${fname}: ${errorMessage(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -280,7 +362,7 @@ export class ExternalStorage {
|
|||
for (const srcFile of files) {
|
||||
try {
|
||||
const fileName = srcFile.split("/").pop() ?? "";
|
||||
const dstFile = this._path!.join(dstDir, fileName);
|
||||
const dstFile = this.nodePath.join(dstDir, fileName);
|
||||
|
||||
const data = await vault.readJson<unknown>(srcFile, null);
|
||||
if (data === null) continue;
|
||||
|
|
@ -291,22 +373,19 @@ export class ExternalStorage {
|
|||
result.moved++;
|
||||
}
|
||||
} catch (e) {
|
||||
result.errors.push(`history/${srcFile}: ${(e as Error)?.message}`);
|
||||
result.errors.push(`history/${srcFile}: ${errorMessage(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to remove the now-empty history/ folder from the vault
|
||||
try {
|
||||
const adapter = this.plugin.app.vault.adapter as unknown as {
|
||||
rmdir?: (path: string, recursive: boolean) => Promise<void>;
|
||||
};
|
||||
await adapter.rmdir?.(srcDir, false);
|
||||
await this.plugin.app.vault.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}`);
|
||||
result.errors.push(`history dir: ${errorMessage(e)}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export class PluginStorage {
|
|||
try {
|
||||
const a = this.plugin.app.vault.adapter;
|
||||
if (!(await a.exists(dirPath))) return { files: [], folders: [] };
|
||||
return await a.list(dirPath) as ListResult;
|
||||
return await a.list(dirPath);
|
||||
} catch {
|
||||
return { files: [], folders: [] };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export function sanitizeUrl(url: string): string {
|
|||
|
||||
/** UTF-8 safe base64 encode */
|
||||
export function utf8ToBase64(str: string): string {
|
||||
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) =>
|
||||
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_match: string, p1: string) =>
|
||||
String.fromCharCode(parseInt(p1, 16)),
|
||||
));
|
||||
}
|
||||
|
|
@ -149,7 +149,7 @@ export function tokenize(text: string): string[] {
|
|||
|
||||
/** Builds a term-frequency map for a document (cached on the entry) */
|
||||
export function buildTermFreq(tokens: string[]): Record<string, number> {
|
||||
const tf: Record<string, number> = Object.create(null);
|
||||
const tf: Record<string, number> = {};
|
||||
for (const token of tokens) tf[token] = (tf[token] || 0) + 1;
|
||||
return tf;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
THINKING_MODES,
|
||||
WEB_SEARCH_CAPABLE,
|
||||
ModelAccessError,
|
||||
detectProvider,
|
||||
isGPT5,
|
||||
isGPT5Search,
|
||||
} from "../models";
|
||||
|
|
@ -23,6 +24,7 @@ import {
|
|||
} from "../utils";
|
||||
import { callOpenAI } from "../api/openai";
|
||||
import { callClaude } from "../api/anthropic";
|
||||
import { callLocalApi } from "../api/local";
|
||||
import { parseCanvasToText } from "../rag/canvasParser";
|
||||
import { resolveNoteWithLinks } from "../rag/linkResolver";
|
||||
import { FallbackModal } from "./FallbackModal";
|
||||
|
|
@ -30,8 +32,8 @@ 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";
|
||||
import type { PluginSettings, Provider } from "../settings";
|
||||
import type { StreamResult, StreamUsage } from "../api/streaming";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -65,7 +67,27 @@ interface QuizQuestion {
|
|||
|
||||
const RENDER_INTERVAL_MS = 80;
|
||||
const MAX_SYSTEM_CHARS = 120_000;
|
||||
const ALL_MODELS = {
|
||||
|
||||
interface ModelOption {
|
||||
id: string;
|
||||
label: string;
|
||||
desc: () => string;
|
||||
}
|
||||
|
||||
interface ProviderOption {
|
||||
id: Provider;
|
||||
label: string;
|
||||
icon: string;
|
||||
desc: () => string;
|
||||
}
|
||||
|
||||
const PROVIDER_OPTIONS: ProviderOption[] = [
|
||||
{ id: "openai", label: "GPT", icon: "🤖", desc: () => t("chat_provider_gpt_desc") },
|
||||
{ id: "anthropic", label: "Claude", icon: "🟣", desc: () => t("chat_provider_claude_desc") },
|
||||
{ id: "local", label: "Local API", icon: "🖥️", desc: () => t("chat_provider_ollama_desc") },
|
||||
];
|
||||
|
||||
const ALL_MODELS: Record<"openai" | "anthropic", ModelOption[]> = {
|
||||
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") },
|
||||
|
|
@ -80,7 +102,7 @@ const ALL_MODELS = {
|
|||
{ 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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -96,6 +118,7 @@ export class GPTChatView extends ItemView {
|
|||
|
||||
// Reference to the open model picker and its global mousedown handler
|
||||
private currentPicker: HTMLElement | null = null;
|
||||
private currentPickerKind: "model" | "provider" | null = null;
|
||||
private pickerCloseHandler: ((e: MouseEvent) => void) | null = null;
|
||||
|
||||
private lastUsage: StreamUsage | null = null;
|
||||
|
|
@ -114,9 +137,8 @@ export class GPTChatView extends ItemView {
|
|||
private webSearchBtn!: HTMLButtonElement;
|
||||
private learnBtn!: HTMLButtonElement;
|
||||
private codeBtn!: HTMLButtonElement;
|
||||
private providerSelectorBtn!: HTMLButtonElement;
|
||||
private modelSelectorBtn!: HTMLButtonElement;
|
||||
private gptBtn!: HTMLButtonElement;
|
||||
private claudeBtn!: HTMLButtonElement;
|
||||
private projectBar!: HTMLElement;
|
||||
private projectBarLabel!: HTMLElement;
|
||||
private manualBar!: HTMLElement;
|
||||
|
|
@ -150,6 +172,7 @@ export class GPTChatView extends ItemView {
|
|||
}
|
||||
this.currentPicker?.remove();
|
||||
this.currentPicker = null;
|
||||
this.currentPickerKind = null;
|
||||
}
|
||||
|
||||
// ── Auto-index ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -214,18 +237,15 @@ export class GPTChatView extends ItemView {
|
|||
const header = root.createEl("div", { cls: "gpt-header" });
|
||||
header.createEl("span", { cls: "gpt-header-icon", text: "✦" });
|
||||
|
||||
this.providerSelectorBtn = header.createEl("button", { cls: "gpt-provider-selector" });
|
||||
this.providerSelectorBtn.onclick = () => this.openProviderPicker();
|
||||
|
||||
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") } });
|
||||
|
|
@ -319,7 +339,7 @@ export class GPTChatView extends ItemView {
|
|||
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) => {
|
||||
|
|
@ -346,9 +366,10 @@ export class GPTChatView extends ItemView {
|
|||
// ── 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));
|
||||
// The picker intentionally lists eligible vault files after explicit user action.
|
||||
const files = this.plugin.app.vault.getFiles()
|
||||
.filter((file: TFile) => file.extension === "md" || file.extension === "canvas")
|
||||
.sort((a, b) => a.basename.localeCompare(b.basename));
|
||||
const doc = this.containerEl.ownerDocument;
|
||||
|
||||
const overlay = doc.createElement("div");
|
||||
|
|
@ -361,7 +382,7 @@ export class GPTChatView extends ItemView {
|
|||
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));
|
||||
|
|
@ -371,10 +392,12 @@ export class GPTChatView extends ItemView {
|
|||
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;
|
||||
const cb = row.createEl("input", { cls: "gpt-modal-checkbox", attr: { type: "checkbox" } });
|
||||
cb.checked = selected.has(f.path);
|
||||
cb.setCssProps({ "accent-color": "var(--interactive-accent)" });
|
||||
cb.onchange = () => { selected.has(f.path) ? selected.delete(f.path) : selected.add(f.path); };
|
||||
cb.onchange = () => {
|
||||
if (selected.has(f.path)) selected.delete(f.path);
|
||||
else selected.add(f.path);
|
||||
};
|
||||
const icon = f.extension === "canvas" ? "🗂️ " : "";
|
||||
row.createEl("span", { cls: "gpt-modal-row-label", text: icon + f.basename });
|
||||
}
|
||||
|
|
@ -448,10 +471,7 @@ export class GPTChatView extends ItemView {
|
|||
if (!proj) { this.projectBar.addClass("gpt-ctx-hidden"); return; }
|
||||
|
||||
this.projectBar.removeClass("gpt-ctx-hidden");
|
||||
this.projectBar.setCssProps({
|
||||
background: `color-mix(in srgb,${proj.color} 10%,var(--background-secondary))`,
|
||||
"border-bottom-color": `color-mix(in srgb,${proj.color} 25%,transparent)`,
|
||||
});
|
||||
this.projectBar.setCssProps({ "--gpt-project-color": proj.color });
|
||||
|
||||
const sessions = this.plugin.projects.getProjectSessions(proj.id);
|
||||
const promptBadge = proj.systemPrompt ? " " + t("projects_custom_prompt_badge") : "";
|
||||
|
|
@ -472,25 +492,143 @@ export class GPTChatView extends ItemView {
|
|||
this.modeLabel.textContent = m ? `${m.label} · ${m.desc}` : "";
|
||||
}
|
||||
|
||||
private getCurrentActiveModel(): string {
|
||||
const provider = this.settings.provider;
|
||||
if (provider === "anthropic") return this.settings.claudeModel ?? "claude-sonnet-4-5";
|
||||
if (provider === "local") return this.settings.localModel?.trim() ?? "";
|
||||
return this.settings.model ?? "gpt-4o";
|
||||
}
|
||||
|
||||
private getEffectiveProvider(model = this.getCurrentActiveModel()): Provider {
|
||||
if (!this.settings.autoDetectProvider) return this.settings.provider;
|
||||
const detected = detectProvider(model);
|
||||
return detected === this.settings.provider ? detected : this.settings.provider;
|
||||
}
|
||||
|
||||
private getProviderLabel(provider: Provider): string {
|
||||
if (provider === "anthropic") return "Claude";
|
||||
if (provider === "local") return "Local API";
|
||||
return "GPT";
|
||||
}
|
||||
|
||||
private getProviderIcon(provider: Provider): string {
|
||||
if (provider === "anthropic") return "🟣";
|
||||
if (provider === "local") return "🖥️";
|
||||
return "🤖";
|
||||
}
|
||||
|
||||
private getProviderOption(provider: Provider): ProviderOption {
|
||||
return PROVIDER_OPTIONS.find(option => option.id === provider) ?? PROVIDER_OPTIONS[0];
|
||||
}
|
||||
|
||||
private getProviderPlaceholder(provider: Provider): string {
|
||||
if (provider === "anthropic") return t("chat_placeholder_claude");
|
||||
if (provider === "local") return t("chat_placeholder_ollama");
|
||||
return t("chat_placeholder");
|
||||
}
|
||||
|
||||
private formatModelLabel(model: string, provider: Provider): string {
|
||||
const known = this.getModelsForProvider(provider).find(option => option.id === model);
|
||||
if (known) return known.label;
|
||||
|
||||
if (provider === "openai") {
|
||||
return model
|
||||
.replace("gpt-", "GPT-")
|
||||
.replace("-search-api", " Search");
|
||||
}
|
||||
if (provider === "anthropic") {
|
||||
return model
|
||||
.replace("claude-", "")
|
||||
.replace(/-4-[56]/g, "");
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
private getLocalModelsForPicker(): ModelOption[] {
|
||||
const models = [...(this.settings.localModelsCache ?? [])];
|
||||
const current = this.settings.localModel?.trim();
|
||||
if (current && !models.includes(current)) models.unshift(current);
|
||||
return models.map(model => ({ id: model, label: model, desc: () => t("model_desc_ollama") }));
|
||||
}
|
||||
|
||||
private getModelsForProvider(provider: Provider): ModelOption[] {
|
||||
if (provider === "openai") return [...ALL_MODELS.openai];
|
||||
if (provider === "anthropic") return [...ALL_MODELS.anthropic];
|
||||
return this.getLocalModelsForPicker();
|
||||
}
|
||||
|
||||
private getModelPickerGroups(): Array<{ provider: Provider; title: string; models: ModelOption[] }> {
|
||||
const provider = this.settings.provider;
|
||||
const group = {
|
||||
provider,
|
||||
title: this.getModelPickerTitle(provider),
|
||||
models: this.getModelsForProvider(provider),
|
||||
};
|
||||
const activeModel = this.getCurrentActiveModel();
|
||||
const known = group.models.some(model => model.id === activeModel);
|
||||
if (!known) {
|
||||
group.models.unshift({ id: activeModel, label: activeModel, desc: () => t("model_desc_custom") });
|
||||
}
|
||||
|
||||
return [group];
|
||||
}
|
||||
|
||||
private getModelPickerTitle(provider: Provider): string {
|
||||
if (provider === "anthropic") return t("chat_picker_claude");
|
||||
if (provider === "local") return t("chat_picker_ollama");
|
||||
return t("chat_picker_openai");
|
||||
}
|
||||
|
||||
private setActiveModel(model: string): Provider {
|
||||
const provider = this.settings.provider;
|
||||
this.settings.provider = provider;
|
||||
if (provider === "openai") {
|
||||
this.settings.model = model;
|
||||
} else if (provider === "anthropic") {
|
||||
this.settings.claudeModel = model;
|
||||
} else {
|
||||
this.settings.localModel = model;
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
private disableUnsupportedWebSearch(provider: Provider, model: string): void {
|
||||
if (!this.webSearchActive) return;
|
||||
const unsupported =
|
||||
provider === "local" ||
|
||||
(provider === "openai" && !WEB_SEARCH_CAPABLE.has(model));
|
||||
if (!unsupported) return;
|
||||
|
||||
this.webSearchActive = false;
|
||||
this.webSearchBtn?.classList.remove("gpt-websearch-btn--active");
|
||||
}
|
||||
|
||||
toggleWebSearch(): void {
|
||||
const activeModel = this.getCurrentActiveModel();
|
||||
const provider = this.getEffectiveProvider(activeModel);
|
||||
|
||||
if (!this.webSearchActive && provider === "local") {
|
||||
new Notice(t("ws_ollama_unsupported"), 7000);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!this.webSearchActive &&
|
||||
this.settings.provider === "openai" &&
|
||||
!WEB_SEARCH_CAPABLE.has(this.settings.model)
|
||||
provider === "openai" &&
|
||||
!WEB_SEARCH_CAPABLE.has(activeModel)
|
||||
) {
|
||||
new Notice(t("ws_unsupported", this.settings.model), 7000);
|
||||
new Notice(t("ws_unsupported", activeModel), 7000);
|
||||
return;
|
||||
}
|
||||
this.webSearchActive = !this.webSearchActive;
|
||||
this.webSearchBtn.classList.toggle("gpt-websearch-btn--active", this.webSearchActive);
|
||||
|
||||
if (isGPT5Search(this.settings.model)) {
|
||||
if (isGPT5Search(activeModel)) {
|
||||
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 if (this.webSearchActive && provider === "anthropic") {
|
||||
new Notice(t("ws_claude_enabled", activeModel));
|
||||
} else {
|
||||
new Notice(this.webSearchActive
|
||||
? t("ws_enabled", this.settings.model)
|
||||
? t("ws_enabled", activeModel)
|
||||
: t("ws_disabled"));
|
||||
}
|
||||
}
|
||||
|
|
@ -503,62 +641,65 @@ export class GPTChatView extends ItemView {
|
|||
this.inputEl.placeholder = t("chat_placeholder_learn");
|
||||
} else {
|
||||
new Notice(t("mode_learn_off"));
|
||||
this.inputEl.placeholder = t("chat_placeholder");
|
||||
this.inputEl.placeholder = this.getProviderPlaceholder(this.getEffectiveProvider());
|
||||
}
|
||||
}
|
||||
|
||||
toggleCodeMode(): void {
|
||||
this.codeMode = !this.codeMode;
|
||||
this.codeBtn.classList.toggle("gpt-code-btn--active", this.codeMode);
|
||||
const provName = this.settings.provider === "anthropic" ? "Claude" : "GPT";
|
||||
const provName = this.getProviderLabel(this.getEffectiveProvider());
|
||||
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");
|
||||
this.inputEl.placeholder = this.getProviderPlaceholder(this.getEffectiveProvider());
|
||||
}
|
||||
}
|
||||
|
||||
setProvider(provider: "openai" | "anthropic"): void {
|
||||
setProvider(provider: Provider): void {
|
||||
this.closePicker();
|
||||
this.settings.provider = provider;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.saveSettings()
|
||||
.catch(err => console.error("[AI-Vault] Failed to save provider:", err));
|
||||
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"));
|
||||
const activeModel = this.getCurrentActiveModel();
|
||||
this.disableUnsupportedWebSearch(provider, activeModel);
|
||||
const message =
|
||||
provider === "openai" ? t("provider_switched_gpt") :
|
||||
provider === "anthropic" ? t("provider_switched_claude") :
|
||||
t("provider_switched_ollama");
|
||||
new Notice(message);
|
||||
}
|
||||
|
||||
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");
|
||||
if (!this.providerSelectorBtn) return;
|
||||
const provider = this.settings.provider;
|
||||
const option = this.getProviderOption(provider);
|
||||
|
||||
this.providerSelectorBtn.empty();
|
||||
this.providerSelectorBtn.classList.toggle("gpt-provider-selector--openai", provider === "openai");
|
||||
this.providerSelectorBtn.classList.toggle("gpt-provider-selector--claude", provider === "anthropic");
|
||||
this.providerSelectorBtn.classList.toggle("gpt-provider-selector--ollama", provider === "local");
|
||||
this.providerSelectorBtn.createEl("span", { cls: "gpt-provider-icon", text: option.icon });
|
||||
this.providerSelectorBtn.createEl("span", { cls: "gpt-provider-label", text: option.label });
|
||||
const arrow = this.providerSelectorBtn.createEl("span", { cls: "gpt-provider-arrow" });
|
||||
setIcon(arrow, "chevron-down");
|
||||
this.providerSelectorBtn.title = t("chat_provider_tooltip", option.label);
|
||||
|
||||
if (this.inputEl && !this.codeMode && !this.learnMode) {
|
||||
this.inputEl.placeholder = this.getProviderPlaceholder(provider);
|
||||
}
|
||||
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");
|
||||
const model = this.getCurrentActiveModel();
|
||||
const provider = this.settings.provider;
|
||||
const icon = this.getProviderIcon(provider);
|
||||
const label = this.formatModelLabel(model, provider);
|
||||
|
||||
this.modelSelectorBtn.empty();
|
||||
this.modelSelectorBtn.createEl("span", { cls: "gpt-ms-icon", text: icon });
|
||||
|
|
@ -576,44 +717,49 @@ export class GPTChatView extends ItemView {
|
|||
}
|
||||
this.currentPicker?.remove();
|
||||
this.currentPicker = null;
|
||||
this.currentPickerKind = null;
|
||||
}
|
||||
|
||||
private openModelPicker(): void {
|
||||
// Toggle: if the picker is already open — close it
|
||||
if (this.currentPicker) { this.closePicker(); return; }
|
||||
private openProviderPicker(): void {
|
||||
if (this.currentPicker) {
|
||||
const wasProviderPicker = this.currentPickerKind === "provider";
|
||||
this.closePicker();
|
||||
if (wasProviderPicker) 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 activeProvider = this.settings.provider;
|
||||
const doc = this.containerEl.ownerDocument;
|
||||
|
||||
const picker = doc.createElement("div");
|
||||
picker.className = "gpt-model-picker";
|
||||
picker.className = "gpt-model-picker gpt-provider-picker";
|
||||
this.currentPicker = picker;
|
||||
this.currentPickerKind = "provider";
|
||||
|
||||
const hdr = doc.createElement("div");
|
||||
hdr.className = "gpt-mp-header";
|
||||
hdr.textContent = isOpenAI ? t("chat_picker_openai") : t("chat_picker_claude");
|
||||
hdr.className = "gpt-mp-header";
|
||||
hdr.textContent = t("chat_provider_picker_title");
|
||||
picker.appendChild(hdr);
|
||||
|
||||
for (const m of models) {
|
||||
const isActive = m.id === activeId;
|
||||
for (const option of PROVIDER_OPTIONS) {
|
||||
const isActive = option.id === activeProvider;
|
||||
const row = doc.createElement("button");
|
||||
row.className = "gpt-mp-row" + (isActive ? " gpt-mp-row--active" : "");
|
||||
row.className = "gpt-mp-row gpt-provider-row" + (isActive ? " gpt-mp-row--active" : "");
|
||||
row.type = "button";
|
||||
|
||||
const icon = doc.createElement("span");
|
||||
icon.className = "gpt-provider-row-icon";
|
||||
icon.textContent = option.icon;
|
||||
row.appendChild(icon);
|
||||
|
||||
const left = doc.createElement("span");
|
||||
left.className = "gpt-mp-row-left";
|
||||
|
||||
const name = doc.createElement("span");
|
||||
name.className = "gpt-mp-row-name";
|
||||
name.textContent = m.label;
|
||||
name.className = "gpt-mp-row-name";
|
||||
name.textContent = option.label;
|
||||
|
||||
const desc = doc.createElement("span");
|
||||
desc.className = "gpt-mp-row-desc";
|
||||
desc.textContent = m.desc();
|
||||
desc.className = "gpt-mp-row-desc";
|
||||
desc.textContent = option.desc();
|
||||
|
||||
left.appendChild(name);
|
||||
left.appendChild(desc);
|
||||
|
|
@ -621,31 +767,108 @@ export class GPTChatView extends ItemView {
|
|||
|
||||
if (isActive) {
|
||||
const check = doc.createElement("span");
|
||||
check.className = "gpt-mp-row-check";
|
||||
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);
|
||||
});
|
||||
|
||||
row.addEventListener("click", () => this.setProvider(option.id));
|
||||
picker.appendChild(row);
|
||||
}
|
||||
|
||||
doc.body.appendChild(picker);
|
||||
const rect = this.providerSelectorBtn.getBoundingClientRect();
|
||||
picker.setCssStyles({
|
||||
top: `${rect.bottom + 4}px`,
|
||||
left: `${rect.left}px`,
|
||||
});
|
||||
|
||||
this.pickerCloseHandler = (e: MouseEvent): void => {
|
||||
const target = e.target as Node | null;
|
||||
if (!target) return;
|
||||
const inside = picker.contains(target) || (this.providerSelectorBtn?.contains(target) ?? false);
|
||||
if (!inside) this.closePicker();
|
||||
};
|
||||
window.setTimeout(() => {
|
||||
if (this.pickerCloseHandler) {
|
||||
doc.addEventListener("mousedown", this.pickerCloseHandler);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private openModelPicker(): void {
|
||||
// Toggle: if the picker is already open — close it
|
||||
if (this.currentPicker) {
|
||||
const wasModelPicker = this.currentPickerKind === "model";
|
||||
this.closePicker();
|
||||
if (wasModelPicker) return;
|
||||
}
|
||||
|
||||
const groups = this.getModelPickerGroups();
|
||||
const activeId = this.getCurrentActiveModel();
|
||||
const doc = this.containerEl.ownerDocument;
|
||||
|
||||
const picker = doc.createElement("div");
|
||||
picker.className = "gpt-model-picker";
|
||||
this.currentPicker = picker;
|
||||
this.currentPickerKind = "model";
|
||||
|
||||
for (const group of groups) {
|
||||
const hdr = doc.createElement("div");
|
||||
hdr.className = "gpt-mp-header";
|
||||
hdr.textContent = group.title;
|
||||
picker.appendChild(hdr);
|
||||
|
||||
for (const model of group.models) {
|
||||
const isActive = model.id === activeId;
|
||||
const row = doc.createElement("button");
|
||||
row.className = "gpt-mp-row" + (isActive ? " gpt-mp-row--active" : "");
|
||||
row.type = "button";
|
||||
|
||||
const left = doc.createElement("span");
|
||||
left.className = "gpt-mp-row-left";
|
||||
|
||||
const name = doc.createElement("span");
|
||||
name.className = "gpt-mp-row-name";
|
||||
name.textContent = model.label;
|
||||
|
||||
const desc = doc.createElement("span");
|
||||
desc.className = "gpt-mp-row-desc";
|
||||
desc.textContent = model.desc();
|
||||
|
||||
left.appendChild(name);
|
||||
left.appendChild(desc);
|
||||
row.appendChild(left);
|
||||
|
||||
if (isActive) {
|
||||
const check = doc.createElement("span");
|
||||
check.className = "gpt-mp-row-check";
|
||||
check.textContent = "✓";
|
||||
row.appendChild(check);
|
||||
}
|
||||
|
||||
row.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
row.addEventListener("click", () => {
|
||||
this.closePicker();
|
||||
const provider = this.setActiveModel(model.id);
|
||||
this.disableUnsupportedWebSearch(provider, model.id);
|
||||
this.plugin.saveSettings()
|
||||
.then(() => {
|
||||
this.updateProviderSwitch();
|
||||
new Notice(t("notice_model_changed", model.label), 2000);
|
||||
})
|
||||
.catch(err => console.error("[AI-Vault] Failed to save selected model:", err));
|
||||
});
|
||||
|
||||
picker.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach to the view document body — avoids CSS transform issues on Obsidian panels
|
||||
doc.body.appendChild(picker);
|
||||
const rect = this.modelSelectorBtn.getBoundingClientRect();
|
||||
picker.setCssProps({
|
||||
picker.setCssStyles({
|
||||
top: `${rect.bottom + 4}px`,
|
||||
left: `${rect.left}px`,
|
||||
});
|
||||
|
|
@ -696,9 +919,12 @@ export class GPTChatView extends ItemView {
|
|||
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; }
|
||||
const activeModel = this.getCurrentActiveModel();
|
||||
const activeProvider = this.getEffectiveProvider(activeModel);
|
||||
const webSearchEnabled = activeProvider !== "local" && this.webSearchActive;
|
||||
if (activeProvider === "openai" && !this.settings.apiKey) { new Notice(t("err_no_openai_key")); return; }
|
||||
if (activeProvider === "anthropic" && !this.settings.claudeApiKey) { new Notice(t("err_no_claude_key")); return; }
|
||||
if (activeProvider === "local" && !this.settings.localBaseUrl.trim()) { new Notice(t("err_no_ollama_url")); return; }
|
||||
|
||||
if (!override) this.inputEl.value = "";
|
||||
this.sendBtn.disabled = true;
|
||||
|
|
@ -706,7 +932,7 @@ export class GPTChatView extends ItemView {
|
|||
this.appendMessage("user", userText);
|
||||
|
||||
const bubble = this.appendMessage("assistant", "");
|
||||
this.setLoading(bubble, true, this.webSearchActive);
|
||||
this.setLoading(bubble, true, webSearchEnabled);
|
||||
|
||||
try {
|
||||
const systemMsg = await this.buildSystemMessage(userText);
|
||||
|
|
@ -715,7 +941,7 @@ export class GPTChatView extends ItemView {
|
|||
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;
|
||||
const contentEl = bubble.querySelector<HTMLElement>(".gpt-msg-content");
|
||||
|
||||
this.abortController = new AbortController();
|
||||
this.showStopBtn(true);
|
||||
|
|
@ -736,23 +962,32 @@ export class GPTChatView extends ItemView {
|
|||
};
|
||||
|
||||
const activeMode = this.currentMode ?? this.settings.thinkingMode;
|
||||
const result = isClaude
|
||||
? await callClaude(
|
||||
let result: StreamResult;
|
||||
if (activeProvider === "anthropic") {
|
||||
result = await callClaude(
|
||||
this.settings.claudeApiKey,
|
||||
this.settings.claudeModel ?? "claude-sonnet-4-5",
|
||||
activeModel,
|
||||
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,
|
||||
webSearchEnabled, onChunk, this.abortController.signal,
|
||||
this.getMaxTokensForMode(activeMode),
|
||||
);
|
||||
} else if (activeProvider === "local") {
|
||||
const text = await callLocalApi(this.settings, msgs, {
|
||||
maxTokens: this.getMaxTokensForMode(activeMode),
|
||||
});
|
||||
onChunk(text);
|
||||
result = { text, usage: null };
|
||||
} else {
|
||||
result = await callOpenAI(
|
||||
this.settings.apiKey,
|
||||
activeModel,
|
||||
msgs,
|
||||
activeMode,
|
||||
webSearchEnabled, onChunk, this.abortController.signal,
|
||||
this.getMaxTokensForMode(activeMode),
|
||||
);
|
||||
}
|
||||
|
||||
const { text: reply, usage } = result;
|
||||
this.setLoading(bubble, false);
|
||||
|
|
@ -788,7 +1023,7 @@ export class GPTChatView extends ItemView {
|
|||
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 contentEl = bubble.querySelector<HTMLElement>(".gpt-msg-content");
|
||||
const partial = contentEl?.innerText?.trim() ?? "";
|
||||
|
||||
if (isAbort && partial) {
|
||||
|
|
@ -802,17 +1037,18 @@ export class GPTChatView extends ItemView {
|
|||
} else if (isAbort) {
|
||||
this.messages.pop();
|
||||
bubble.parentElement?.remove();
|
||||
} else if (err instanceof ModelAccessError && this.settings.provider === "openai") {
|
||||
} else if (err instanceof ModelAccessError && activeProvider === "openai") {
|
||||
this.messages.pop();
|
||||
bubble.parentElement?.remove();
|
||||
const failed = error.message;
|
||||
const failedModel = (err as ModelAccessError).model ?? this.settings.model;
|
||||
const failedModel = err.model ?? activeModel;
|
||||
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.provider = "openai";
|
||||
this.plugin.settings.model = fallbackModel;
|
||||
if (saveAsDefault) await this.plugin.saveSettings();
|
||||
new Notice(t("notice_fallback_switched", fallbackModel));
|
||||
|
|
@ -824,7 +1060,7 @@ export class GPTChatView extends ItemView {
|
|||
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.createEl("div", { cls: "gpt-msg-error-detail", text: `Model: ${activeModel} · Mode: ${this.currentMode}` });
|
||||
contentEl.addClass("gpt-error");
|
||||
}
|
||||
console.error("[AI-Vault] sendMessage error:", error.message, err);
|
||||
|
|
@ -935,15 +1171,18 @@ export class GPTChatView extends ItemView {
|
|||
|
||||
// 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 assistLabel = this.getProviderLabel(this.getEffectiveProvider());
|
||||
footer.createEl("span", { cls: "gpt-msg-label", text: role === "user" ? t("chat_role_you") : assistLabel });
|
||||
|
||||
const copyBtn = footer.createEl("button", { cls: "gpt-copy-btn", attr: { title: "Copy", "aria-label": "Copy" } });
|
||||
const copyBtn = footer.createEl("button", { cls: "gpt-copy-btn", attr: { title: t("chat_copy"), "aria-label": t("chat_copy") } });
|
||||
this.setButtonIcon(copyBtn, "copy");
|
||||
copyBtn.onclick = async () => {
|
||||
await navigator.clipboard.writeText(bubble.dataset.raw ?? contentEl.innerText);
|
||||
this.setButtonIcon(copyBtn, "check");
|
||||
window.setTimeout(() => { this.setButtonIcon(copyBtn, "copy"); }, 2000);
|
||||
copyBtn.onclick = () => {
|
||||
void navigator.clipboard.writeText(bubble.dataset.raw ?? contentEl.innerText)
|
||||
.then(() => {
|
||||
this.setButtonIcon(copyBtn, "check");
|
||||
window.setTimeout(() => { this.setButtonIcon(copyBtn, "copy"); }, 2000);
|
||||
})
|
||||
.catch(error => console.error("[AI-Vault] Copy failed:", error));
|
||||
};
|
||||
|
||||
if (content) bubble.dataset.raw = content;
|
||||
|
|
@ -973,11 +1212,11 @@ export class GPTChatView extends ItemView {
|
|||
if (this.stopBtn) return;
|
||||
this.stopBtn = this.sendBtn.parentElement!.createEl("button", {
|
||||
cls: "gpt-stop-btn",
|
||||
text: "⏹ Stop",
|
||||
text: `⏹ ${t("chat_stop")}`,
|
||||
});
|
||||
this.stopBtn.onclick = () => {
|
||||
this.abortController?.abort();
|
||||
new Notice("⏹ Generation stopped");
|
||||
new Notice(t("chat_generation_stopped"));
|
||||
};
|
||||
this.sendBtn.addClass("gpt-ctx-hidden");
|
||||
} else {
|
||||
|
|
@ -1026,7 +1265,7 @@ export class GPTChatView extends ItemView {
|
|||
|
||||
async exportToNote(): Promise<void> {
|
||||
if (!this.messages.length) { new Notice(t("export_no_messages")); return; }
|
||||
const provName = this.settings.provider === "anthropic" ? "Claude" : "GPT";
|
||||
const provName = this.getProviderLabel(this.getEffectiveProvider());
|
||||
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`;
|
||||
|
|
@ -1148,10 +1387,11 @@ export class GPTChatView extends ItemView {
|
|||
container.empty();
|
||||
if (quiz.title) container.createEl("div", { cls: "gpt-quiz-title", text: quiz.title });
|
||||
|
||||
const questionCount = quiz.questions.length;
|
||||
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-qnum", text: `Question ${qi + 1} of ${questionCount}` });
|
||||
card.createEl("div", { cls: "gpt-quiz-qtext", text: q.question || t("quiz_no_question") });
|
||||
|
||||
let answered = false;
|
||||
|
|
@ -1189,7 +1429,7 @@ export class GPTChatView extends ItemView {
|
|||
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;
|
||||
|
|
@ -1206,10 +1446,21 @@ export class GPTChatView extends ItemView {
|
|||
} 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 activeModel = this.getCurrentActiveModel();
|
||||
const provider = this.getEffectiveProvider(activeModel);
|
||||
let r: StreamResult;
|
||||
if (provider === "anthropic") {
|
||||
r = await callClaude(this.settings.claudeApiKey, activeModel, [{ role: "user", content: prompt }], "fast");
|
||||
} else if (provider === "local") {
|
||||
const text = await callLocalApi(
|
||||
this.settings,
|
||||
[{ role: "user", content: prompt }],
|
||||
{ maxTokens: this.getMaxTokensForMode("fast") },
|
||||
);
|
||||
r = { text, usage: null };
|
||||
} else {
|
||||
r = await callOpenAI(this.settings.apiKey, activeModel, [{ 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") }); }
|
||||
|
|
@ -1222,7 +1473,7 @@ export class GPTChatView extends ItemView {
|
|||
}
|
||||
|
||||
private normalizeQuestion(q: QuizQuestion): void {
|
||||
if (!q.question) q.question = String(q["text"] ?? q["prompt"] ?? q["content"] ?? "");
|
||||
if (!q.question) q.question = this.stringValue(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";
|
||||
|
|
@ -1252,10 +1503,16 @@ export class GPTChatView extends ItemView {
|
|||
}
|
||||
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"] ?? "");
|
||||
q.answer = this.stringValue(ca ?? q["expected_answer"]);
|
||||
}
|
||||
}
|
||||
|
||||
private stringValue(value: unknown): string {
|
||||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
||||
? String(value)
|
||||
: "";
|
||||
}
|
||||
|
||||
private getMaxTokensForMode(mode: string): number {
|
||||
switch (mode) {
|
||||
case "fast": return this.settings.maxTokensFast ?? 4096;
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export class FallbackModal extends Modal {
|
|||
|
||||
// Hint for GPT-5 — most common cause: no Tier 1 access
|
||||
if (isGPT5(this.failedModel)) {
|
||||
const parts = (t("fallback_tier1") as string).split(": ");
|
||||
const parts = t("fallback_tier1").split(": ");
|
||||
const hint = desc.createEl("div", { cls: "gpt-fallback-hint" });
|
||||
hint.createEl("strong", { text: parts[0] + ": " });
|
||||
hint.appendText(parts.slice(1).join(": "));
|
||||
|
|
@ -61,7 +61,7 @@ export class FallbackModal extends Modal {
|
|||
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" },
|
||||
|
|
@ -80,16 +80,20 @@ export class FallbackModal extends Modal {
|
|||
cls: "mod-cta",
|
||||
text: t("fallback_accept", this.fallbackModel),
|
||||
});
|
||||
acceptBtn.addEventListener("click", async () => {
|
||||
acceptBtn.addEventListener("click", () => {
|
||||
this.close();
|
||||
try {
|
||||
await this.onAccept?.(this.saveAsDefault);
|
||||
} catch (e) {
|
||||
console.error("[AI-Vault] Fallback retry failed:", e);
|
||||
}
|
||||
void this.acceptFallback();
|
||||
});
|
||||
}
|
||||
|
||||
private async acceptFallback(): Promise<void> {
|
||||
try {
|
||||
await this.onAccept?.(this.saveAsDefault);
|
||||
} catch (e) {
|
||||
console.error("[AI-Vault] Fallback retry failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ItemView, setIcon, WorkspaceLeaf } from "obsidian";
|
||||
import { HISTORY_VIEW_TYPE, PROJECTS_VIEW_TYPE } from "../constants";
|
||||
import { HISTORY_VIEW_TYPE } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { formatDate } from "../utils";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
|
|
|
|||
|
|
@ -242,7 +242,7 @@ export class GPTProjectsView extends ItemView {
|
|||
// ── Dialog tworzenia / edycji projektu ─────────────────────────────────────
|
||||
|
||||
showCreateDialog(editProject?: Project): void {
|
||||
const isEdit = !!editProject;
|
||||
const isEdit = editProject !== undefined;
|
||||
const doc = this.containerEl.ownerDocument;
|
||||
const overlay = doc.createElement("div");
|
||||
overlay.className = "gpt-modal-overlay";
|
||||
|
|
@ -252,14 +252,14 @@ export class GPTProjectsView extends ItemView {
|
|||
|
||||
box.createEl("p", {
|
||||
cls: "gpt-modal-title",
|
||||
text: isEdit ? `Edytuj projekt: ${editProject!.name}` : "Nowy projekt:",
|
||||
text: editProject ? t("projects_modal_edit_title", editProject.name) : t("projects_modal_new_title"),
|
||||
});
|
||||
|
||||
const nameInput = box.createEl("input", {
|
||||
cls: "gpt-modal-input",
|
||||
attr: { type: "text", placeholder: "Nazwa projektu…" },
|
||||
}) as HTMLInputElement;
|
||||
if (isEdit) nameInput.value = editProject!.name;
|
||||
attr: { type: "text", placeholder: t("projects_name_placeholder") },
|
||||
});
|
||||
if (editProject) nameInput.value = editProject.name;
|
||||
|
||||
const promptLabel = box.createEl("div", { cls: "gpt-modal-prompt-label" });
|
||||
this.setIconText(promptLabel, "pencil", t("projects_prompt_label"));
|
||||
|
|
@ -267,8 +267,8 @@ export class GPTProjectsView extends ItemView {
|
|||
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 ?? "";
|
||||
});
|
||||
if (editProject) promptInput.value = editProject.systemPrompt ?? "";
|
||||
|
||||
box.createEl("div", { cls: "gpt-modal-prompt-hint", text: t("projects_prompt_hint") });
|
||||
|
||||
|
|
@ -288,8 +288,8 @@ export class GPTProjectsView extends ItemView {
|
|||
const name = nameInput.value.trim();
|
||||
if (!name) { nameInput.addClass("gpt-modal-input-error"); return; }
|
||||
|
||||
if (isEdit) {
|
||||
await this.plugin.projects.updateProject(editProject!.id, {
|
||||
if (editProject) {
|
||||
await this.plugin.projects.updateProject(editProject.id, {
|
||||
name,
|
||||
systemPrompt: promptInput.value.trim(),
|
||||
});
|
||||
|
|
|
|||
112
styles.css
112
styles.css
|
|
@ -2,6 +2,7 @@
|
|||
:root {
|
||||
--gpt-color-openai: #10a37f;
|
||||
--gpt-color-claude: #7c3aed;
|
||||
--gpt-color-ollama: #475569;
|
||||
--gpt-color-web: #2563eb;
|
||||
--gpt-color-learn: #f59e0b;
|
||||
--gpt-color-error: #e74c3c;
|
||||
|
|
@ -39,25 +40,27 @@
|
|||
text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ─── Provider switch ────────────────────────────────────────────────────────── */
|
||||
.gpt-provider-switch {
|
||||
display: flex;
|
||||
border-radius: 8px;
|
||||
/* ─── Provider selector ─────────────────────────────────────────────────────── */
|
||||
.gpt-provider-selector {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
padding: 3px 8px; border-radius: 8px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--background-primary); color: var(--text-normal);
|
||||
cursor: pointer; font-size: 12px; font-weight: 700;
|
||||
flex: 0 0 auto; min-width: 86px; max-width: 112px;
|
||||
transition: background .15s, border-color .15s, color .15s;
|
||||
white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
.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) {
|
||||
.gpt-provider-selector:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
.gpt-provider-selector--openai { border-color: color-mix(in srgb, var(--gpt-color-openai) 45%, var(--background-modifier-border)); }
|
||||
.gpt-provider-selector--claude { border-color: color-mix(in srgb, var(--gpt-color-claude) 45%, var(--background-modifier-border)); }
|
||||
.gpt-provider-selector--ollama { border-color: color-mix(in srgb, var(--gpt-color-ollama) 45%, var(--background-modifier-border)); }
|
||||
.gpt-provider-icon { font-size: 13px; flex-shrink: 0; }
|
||||
.gpt-provider-label { overflow: hidden; text-overflow: ellipsis; flex: 1; text-align: left; }
|
||||
.gpt-provider-arrow { flex-shrink: 0; opacity: .55; transition: opacity .15s; }
|
||||
.gpt-provider-selector:hover .gpt-provider-arrow { opacity: 1; }
|
||||
|
||||
/* ─── Model selector ─────────────────────────────────────────────────────────── */
|
||||
.gpt-model-selector {
|
||||
|
|
@ -66,7 +69,7 @@
|
|||
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;
|
||||
flex: 1 1 120px; min-width: 96px; max-width: 220px;
|
||||
transition: background .15s, border-color .15s;
|
||||
white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
|
|
@ -87,6 +90,7 @@
|
|||
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-provider-picker { min-width: 190px; }
|
||||
.gpt-mp-header {
|
||||
font-size: 11px; font-weight: 700; color: var(--text-muted);
|
||||
padding: 4px 8px 6px; letter-spacing: .04em;
|
||||
|
|
@ -105,6 +109,8 @@
|
|||
.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; }
|
||||
.gpt-provider-row { justify-content: flex-start; gap: 8px; }
|
||||
.gpt-provider-row-icon { font-size: 14px; width: 18px; text-align: center; flex-shrink: 0; }
|
||||
|
||||
/* ─── Badges & buttons ───────────────────────────────────────────────────────── */
|
||||
.gpt-rag-badge {
|
||||
|
|
@ -142,10 +148,10 @@
|
|||
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;
|
||||
.gpt-mode-btn.gpt-mode-btn--active {
|
||||
background: var(--interactive-accent);
|
||||
color: #fff;
|
||||
border-color: var(--interactive-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
|
@ -173,13 +179,18 @@
|
|||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 5px 12px; border-bottom: 1px solid transparent;
|
||||
font-size: 11px; flex-shrink: 0;
|
||||
--gpt-project-color: var(--interactive-accent);
|
||||
background: color-mix(in srgb, var(--gpt-project-color) 10%, var(--background-secondary));
|
||||
border-bottom-color: color-mix(in srgb, var(--gpt-project-color) 25%, transparent);
|
||||
}
|
||||
.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-chat-root .gpt-ctx-hidden,
|
||||
.gpt-history-root .gpt-ctx-hidden,
|
||||
.gpt-projects-root .gpt-ctx-hidden { display: none; }
|
||||
.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; }
|
||||
|
||||
|
|
@ -237,7 +248,7 @@
|
|||
.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; }
|
||||
.gpt-bubble .gpt-error { color: var(--gpt-color-error); }
|
||||
|
||||
/* ─── Loading dots ───────────────────────────────────────────────────────────── */
|
||||
.gpt-loading .gpt-msg-content { display: none; }
|
||||
|
|
@ -286,10 +297,10 @@
|
|||
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-code-copy.gpt-code-copy--ok {
|
||||
color: var(--gpt-color-openai);
|
||||
border-color: var(--gpt-color-openai);
|
||||
background: color-mix(in srgb, var(--gpt-color-openai) 10%, var(--background-secondary));
|
||||
}
|
||||
.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; }
|
||||
|
|
@ -332,28 +343,28 @@
|
|||
.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;
|
||||
.gpt-tool-btn.gpt-rag-btn--active {
|
||||
background: color-mix(in srgb, var(--gpt-color-openai) 15%, var(--background-secondary));
|
||||
color: var(--gpt-color-openai);
|
||||
border-color: var(--gpt-color-openai);
|
||||
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;
|
||||
.gpt-tool-btn.gpt-websearch-btn--active {
|
||||
background: color-mix(in srgb, var(--gpt-color-web) 15%, var(--background-secondary));
|
||||
color: var(--gpt-color-web);
|
||||
border-color: var(--gpt-color-web);
|
||||
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;
|
||||
.gpt-tool-btn.gpt-learn-btn--active {
|
||||
background: color-mix(in srgb, var(--gpt-color-learn) 15%, var(--background-secondary));
|
||||
color: #b45309;
|
||||
border-color: var(--gpt-color-learn);
|
||||
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;
|
||||
.gpt-tool-btn.gpt-code-btn--active {
|
||||
background: color-mix(in srgb, var(--gpt-color-claude) 15%, var(--background-secondary));
|
||||
color: var(--gpt-color-claude);
|
||||
border-color: var(--gpt-color-claude);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
|
@ -438,6 +449,7 @@
|
|||
font-size: 12px; color: var(--text-normal);
|
||||
}
|
||||
.gpt-modal-row:hover { background: var(--background-modifier-hover); }
|
||||
.gpt-modal-checkbox { accent-color: var(--interactive-accent); }
|
||||
.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; }
|
||||
|
|
@ -473,15 +485,15 @@
|
|||
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.gpt-quiz-opt--correct {
|
||||
border-color: var(--gpt-color-openai);
|
||||
background: color-mix(in srgb, var(--gpt-color-openai) 12%, var(--background-primary));
|
||||
color: var(--gpt-color-ok-text); 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.gpt-quiz-opt--wrong {
|
||||
border-color: var(--gpt-color-error);
|
||||
background: color-mix(in srgb, var(--gpt-color-error) 10%, var(--background-primary));
|
||||
color: var(--gpt-color-err-text);
|
||||
}
|
||||
.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; }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
{
|
||||
"1.0.0": "1.4.0",
|
||||
"1.0.2": "1.7.2"
|
||||
"1.0.2": "1.7.2",
|
||||
"1.0.4": "1.7.2",
|
||||
"1.0.5": "1.7.2",
|
||||
"1.0.6": "1.7.2",
|
||||
"1.0.7": "1.7.2",
|
||||
"1.0.8": "1.7.2",
|
||||
"1.0.9": "1.7.2"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue