mirror of
https://github.com/jamjan05/AI-Vault-for-Obsidian.git
synced 2026-07-22 06:56:43 +00:00
Merge branch 'safety-O'
# Conflicts: # .github/workflows/release.yml # README.md # manifest.json # package.json # versions.json
This commit is contained in:
commit
1001e7fb9b
15 changed files with 1062 additions and 213 deletions
55
README.md
55
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,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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
187
src/api/local.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
|
|
|
|||
110
src/i18n.ts
110
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,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!",
|
||||
|
|
|
|||
40
src/main.ts
40
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") {
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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") }); }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
38
styles.css
38
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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue