Merge branch 'safety-O'

# Conflicts:
#	.github/workflows/release.yml
#	README.md
#	manifest.json
#	package.json
#	versions.json
This commit is contained in:
JamJan05 2026-06-03 12:42:31 +02:00
commit 1001e7fb9b
15 changed files with 1062 additions and 213 deletions

View file

@ -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,7 +8,8 @@ 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.
@ -18,7 +19,7 @@ AI-Vault turns your Obsidian workspace into an AI assistant that can use your no
- 🎓 **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,8 +27,8 @@ 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.
@ -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 models. Local API requests are sent only to the Base URL you configure and never leave your machine.
---
## 📚 Vault Context
@ -99,6 +134,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:
@ -124,7 +165,7 @@ 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.
---
@ -167,7 +208,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.

View file

@ -1,7 +1,7 @@
{
"id": "ai-vault",
"name": "AI-Vault",
"version": "1.0.5-beta",
"version": "1.0.8",
"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",

View file

@ -1,6 +1,6 @@
{
"name": "ai-vault",
"version": "1.0.5-beta",
"version": "1.0.8",
"description": "AI-Vault — chat with GPT & Claude inside Obsidian, powered by your vault",
"main": "main.js",
"scripts": {
@ -13,6 +13,7 @@
"license": "MIT",
"devDependencies": {
"@types/node": "^22.19.18",
"builtin-modules": "^3.3.0",
"esbuild": "^0.21.5",
"obsidian": "^1.12.3",
"tslib": "^2.6.3",

View file

@ -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);
@ -152,45 +162,263 @@ 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_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:", err);
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 +540,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 +609,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");
@ -450,7 +641,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");

View file

@ -1,4 +1,5 @@
export { callOpenAI, callOpenAIResponses } from "./openai";
export { callClaude } from "./anthropic";
export { callLocalApi, fetchLocalModels, normalizeLocalBaseUrl, parseLocalModelList } from "./local";
export { streamSSE, throwHttpError } from "./streaming";
export type { StreamResult, StreamUsage } from "./streaming";

187
src/api/local.ts Normal file
View file

@ -0,0 +1,187 @@
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);
}
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" });
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(url: string, body: Record<string, unknown>): Promise<unknown> {
const response = await requestLocal({
url,
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
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(`${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(`${base}/chat/completions`, body);
const content = extractOpenAIContent(data);
if (!content) throw new Error("Invalid OpenAI-compatible response. Expected choices[0].message.content.");
return content;
}

View file

@ -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: [],

View file

@ -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,34 @@ 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_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 +108,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 +123,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 +139,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 +154,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 +167,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 +177,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 +221,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)`,
@ -228,6 +275,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 +297,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 +390,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 +404,34 @@ 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_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 +440,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 +455,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 +471,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 +486,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 +499,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 +509,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 +553,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)`,
@ -505,6 +607,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 +629,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!",

View file

@ -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") {
@ -287,6 +292,27 @@ export default class GPTPlugin extends Plugin {
}
this.settings = Object.assign({}, DEFAULT_SETTINGS, d) as PluginSettings;
// 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> {

View file

@ -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) {

View file

@ -1,11 +1,16 @@
// ─── 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;
@ -16,6 +21,11 @@ export interface PluginSettings {
provider: Provider;
model: string;
claudeModel: string;
localApiType: LocalApiType;
localBaseUrl: string;
localModel: string;
localModelsCache: string[];
autoDetectProvider: boolean;
thinkingMode: ThinkingMode;
// Max tokens per thinking mode
@ -60,6 +70,11 @@ export const DEFAULT_SETTINGS: PluginSettings = {
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,

View file

@ -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") } });
@ -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,27 +767,104 @@ 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.setCssProps({
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();
@ -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);
@ -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);
@ -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 as ModelAccessError).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,10 +1171,10 @@ 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);
@ -973,11 +1209,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 +1262,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`;
@ -1206,10 +1442,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") }); }

View file

@ -252,12 +252,12 @@ export class GPTProjectsView extends ItemView {
box.createEl("p", {
cls: "gpt-modal-title",
text: isEdit ? `Edytuj projekt: ${editProject!.name}` : "Nowy projekt:",
text: isEdit ? 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…" },
attr: { type: "text", placeholder: t("projects_name_placeholder") },
}) as HTMLInputElement;
if (isEdit) nameInput.value = editProject!.name;

View file

@ -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 {

View file

@ -2,5 +2,5 @@
"1.0.0": "1.4.0",
"1.0.2": "1.7.2",
"1.0.4": "1.7.2",
"1.0.5-beta": "1.7.2"
"1.0.8": "1.7.2"
}