diff --git a/CLAUDE.md b/CLAUDE.md index 9cd5e0e..9813e7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ Entry: `src/main.ts` → bundled to `main.js` via esbuild (CJS, ES2018 target). | Field | Default | Description | |---|---|---| | `apiKey` | `""` | Anthropic API key | -| `model` | `claude-opus-4-5-20251101` | Claude model ID | +| `model` | `claude-opus-4-6` | Claude model ID | | `maxTokens` | `8192` | Max output tokens (1024–16000) | | `maxContextNotes` | `6` | TF-IDF/embedding context notes per query | | `maxCharsPerNote` | `2500` | Characters per context note | @@ -118,4 +118,4 @@ Copy `main.js`, `manifest.json`, `styles.css` into `.obsidian/plugins/memex-chat ## Models (SettingsTab.ts) -Default: `claude-opus-4-5-20251101`. Update `MODELS` array and `DEFAULT_SETTINGS.model` when adding new model IDs. +Default: `claude-opus-4-6`. Update `MODELS` array and `DEFAULT_SETTINGS.model` when adding new model IDs. diff --git a/docs/superpowers/plans/2026-03-27-fetch-models.md b/docs/superpowers/plans/2026-03-27-fetch-models.md new file mode 100644 index 0000000..f4fa5c5 --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-fetch-models.md @@ -0,0 +1,173 @@ +# Fetch Models Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an "Aktualisieren" button next to the model dropdown in settings that fetches the 3 newest Claude models from the Anthropic API and repopulates the dropdown. + +**Architecture:** `ClaudeClient` gains a `fetchModels()` method (reusing existing `requestUrl`/`headers` patterns). `SettingsTab` captures the `DropdownComponent` and `ButtonComponent` references, wires the button to call `fetchModels`, and rebuilds the dropdown on success. + +**Tech Stack:** TypeScript, Obsidian plugin API (`requestUrl`, `Setting`, `DropdownComponent`, `ButtonComponent`, `Notice`), Anthropic Models API (`GET /v1/models`) + +--- + +## File Map + +| File | Change | +|---|---| +| `src/ClaudeClient.ts` | Add `fetchModels(apiKey)` method | +| `src/SettingsTab.ts` | Update imports; refactor "Modell" `Setting` to capture dropdown + add button | + +--- + +### Task 1: Add `fetchModels` to `ClaudeClient` + +**Files:** +- Modify: `src/ClaudeClient.ts` + +- [ ] **Step 1: Add the method after the `chat()` method** + +In `src/ClaudeClient.ts`, add after line 86 (after `chat()`'s closing brace): + +```typescript + /** Fetch the 3 newest Claude models from the Anthropic Models API. */ + async fetchModels(apiKey: string): Promise<{ id: string; name: string }[]> { + const response = await requestUrl({ + url: "https://api.anthropic.com/v1/models", + method: "GET", + headers: this.headers(apiKey), + throw: false, + }); + + if (response.status >= 400) { + throw new Error(`API Error ${response.status}: ${response.text}`); + } + + const data: { id: string; created: number }[] = response.json.data ?? []; + if (data.length === 0) { + throw new Error("No models returned"); + } + + return data + .sort((a, b) => b.created - a.created) + .slice(0, 3) + .map((m) => ({ id: m.id, name: m.id })); + } +``` + +- [ ] **Step 2: Verify build passes** + +```bash +npm run build +``` + +Expected: no TypeScript errors, `main.js` written successfully. + +- [ ] **Step 3: Commit** + +```bash +git add src/ClaudeClient.ts +git commit -m "feat: add fetchModels to ClaudeClient" +``` + +--- + +### Task 2: Update SettingsTab — imports and model setting + +**Files:** +- Modify: `src/SettingsTab.ts:1` (import line) +- Modify: `src/SettingsTab.ts:151-160` (the "Modell" Setting block) + +- [ ] **Step 1: Extend the import from `"obsidian"`** + +Replace the current import line 1: + +```typescript +import { App, PluginSettingTab, Setting } from "obsidian"; +``` + +With: + +```typescript +import { App, ButtonComponent, DropdownComponent, Notice, PluginSettingTab, Setting } from "obsidian"; +``` + +- [ ] **Step 2: Replace the "Modell" Setting block** + +Replace lines 151–160: + +```typescript + new Setting(containerEl) + .setName("Modell") + .setDesc("Welches Claude-Modell verwenden?") + .addDropdown((drop) => { + for (const m of MODELS) drop.addOption(m.id, m.name); + drop.setValue(this.plugin.settings.model).onChange(async (value) => { + this.plugin.settings.model = value; + await this.plugin.saveSettings(); + }); + }); +``` + +With: + +```typescript + let modelDrop: DropdownComponent; + let refreshBtn: ButtonComponent; + + new Setting(containerEl) + .setName("Modell") + .setDesc("Welches Claude-Modell verwenden?") + .addDropdown((drop) => { + modelDrop = drop; + for (const m of MODELS) drop.addOption(m.id, m.name); + drop.setValue(this.plugin.settings.model).onChange(async (value) => { + this.plugin.settings.model = value; + await this.plugin.saveSettings(); + }); + }) + .addButton((btn) => { + refreshBtn = btn; + btn.setButtonText("Aktualisieren").onClick(async () => { + const prev = modelDrop.getValue(); + refreshBtn.setDisabled(true); + refreshBtn.setButtonText("..."); + try { + const models = await this.plugin.claude.fetchModels(this.plugin.settings.apiKey); + modelDrop.selectEl.empty(); + for (const m of models) modelDrop.addOption(m.id, m.name); + modelDrop.setValue(prev); + this.plugin.settings.model = modelDrop.getValue(); + await this.plugin.saveSettings(); + } catch (err) { + new Notice("Modelle konnten nicht geladen werden: " + (err as Error).message); + } finally { + refreshBtn.setDisabled(false); + refreshBtn.setButtonText("Aktualisieren"); + } + }); + }); +``` + +- [ ] **Step 3: Verify build passes** + +```bash +npm run build +``` + +Expected: no TypeScript errors, `main.js` written successfully. + +- [ ] **Step 4: Manual smoke test in Obsidian** + +1. Copy `main.js`, `manifest.json`, `styles.css` to `.obsidian/plugins/memex-chat/` in your vault +2. Reload the plugin (or restart Obsidian) +3. Open Settings → Memex Chat +4. Confirm the "Modell" row has a dropdown and an "Aktualisieren" button +5. With a valid API key set, click "Aktualisieren" — button should show "...", then restore; dropdown should show 3 model IDs +6. With no API key, click "Aktualisieren" — a Notice should appear with an error message; dropdown should be unchanged + +- [ ] **Step 5: Commit** + +```bash +git add src/SettingsTab.ts +git commit -m "feat: add Aktualisieren button to fetch models from API" +``` diff --git a/docs/superpowers/specs/2026-03-27-fetch-models-design.md b/docs/superpowers/specs/2026-03-27-fetch-models-design.md new file mode 100644 index 0000000..755069c --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-fetch-models-design.md @@ -0,0 +1,89 @@ +# Fetch Models Design + +**Date:** 2026-03-27 +**Status:** Approved + +## Summary + +Add an "Aktualisieren" button to the Model setting in the settings tab. When clicked, it fetches the 3 newest Claude models from the Anthropic Models API and updates the dropdown. Falls back to the hardcoded `MODELS` list if the request fails or returns no models. + +## ClaudeClient changes + +Add a new method `fetchModels(apiKey: string): Promise<{id: string, name: string}[]>` to `ClaudeClient`. + +- URL: `"https://api.anthropic.com/v1/models"` — inline string or a separate private constant; **do not use or modify `baseUrl`** (which points to `/v1/messages`) +- Call `requestUrl` with `throw: false` (same pattern as `streamChat`/`chat`) and `this.headers(apiKey)` +- Response shape: `{ data: [{ id: string, created: number, display_name: string, ... }] }` +- Throw on `response.status >= 400` with the response text +- If `data` is empty, throw an error ("No models returned") — do not return an empty array +- Sort `data` descending by `created`, take top 3 +- Return `{ id, name: id }` for each — use `id` as the display name (not `display_name`). Note: fetched entries will show raw IDs (e.g. `claude-opus-4-6`) while hardcoded `MODELS` show human-friendly names (e.g. `"Claude Opus 4.6 (Stärkste)"`). This is intentional — keeps the implementation simple and avoids relying on API-provided display strings. + +## SettingsTab changes + +**Import addition:** Add `Notice, ButtonComponent, DropdownComponent` to the `import { ... } from "obsidian"` line. + +Convert the existing "Modell" `Setting` to capture both the `DropdownComponent` and `ButtonComponent` references by chaining `addDropdown()` and `addButton()` on the same `Setting` instance: + +```typescript +let modelDrop: DropdownComponent; +let refreshBtn: ButtonComponent; + +new Setting(containerEl) + .setName("Modell") + .setDesc("Welches Claude-Modell verwenden?") + .addDropdown((drop) => { + modelDrop = drop; + for (const m of MODELS) drop.addOption(m.id, m.name); + drop.setValue(this.plugin.settings.model).onChange(async (value) => { + this.plugin.settings.model = value; + await this.plugin.saveSettings(); + }); + }) + .addButton((btn) => { + refreshBtn = btn; + btn.setButtonText("Aktualisieren").onClick(async () => { /* see click flow */ }); + }); +``` + +**Click flow:** +1. Capture current value: `const prev = modelDrop.getValue()` +2. `refreshBtn.setDisabled(true)` and `refreshBtn.setButtonText("...")` +3. In a try/catch/finally: + - **try:** Call `this.plugin.claude.fetchModels(this.plugin.settings.apiKey)` + - On success: clear dropdown with `modelDrop.selectEl.empty()`, repopulate via `modelDrop.addOption(id, name)` for each fetched model, then set value to `prev` if it exists among the fetched ids, otherwise the first fetched id; save via `this.plugin.settings.model = modelDrop.getValue(); await this.plugin.saveSettings()` + - **catch:** `new Notice("Modelle konnten nicht geladen werden: " + err.message)` — dropdown is **not** modified on error (hardcoded options remain) + - **finally:** `refreshBtn.setDisabled(false)` and `refreshBtn.setButtonText("Aktualisieren")` + +**Fallback:** The hardcoded `MODELS` array in `SettingsTab.ts` is unchanged and remains the initial population of the dropdown on every settings open. + +## Data flow + +``` +[Aktualisieren button click] + → capture prev = modelDrop.getValue() + → disable button, show "..." + → this.plugin.claude.fetchModels(apiKey) [throw: false, separate URL] + → throw if status >= 400 or data empty + → sort by created desc, take 3 + → return [{id, name: id}] + → clear selectEl, repopulate, restore selection + → save model to settings + → finally: restore button +``` + +## Error handling + +| Scenario | Behaviour | +|---|---| +| No API key (401) | Notice shown; dropdown unchanged | +| Network failure | Notice shown; dropdown unchanged | +| Empty `data` array | Treated as error; Notice shown; dropdown unchanged | +| Fewer than 3 models returned | Take all returned (no error) | + +## Out of scope + +- Persisting fetched models across restarts +- Auto-fetching on settings open or plugin startup +- Configurable count of models to show +- Updating `DEFAULT_SETTINGS.model` after a fetch diff --git a/main.js b/main.js index 1262274..d1354ab 100644 --- a/main.js +++ b/main.js @@ -32725,13 +32725,37 @@ var ClaudeClient = class { } return response.json.content?.[0]?.text ?? ""; } + /** + * Fetch Claude models from the Anthropic Models API. + * Returns the 2 newest versions of each family (opus, sonnet, haiku), in that order. + */ + async fetchModels(apiKey) { + const response = await (0, import_obsidian2.requestUrl)({ + url: "https://api.anthropic.com/v1/models", + method: "GET", + headers: this.headers(apiKey), + throw: false + }); + if (response.status >= 400) { + throw new Error(`API Error ${response.status}: ${response.text}`); + } + const data = response.json.data ?? []; + if (data.length === 0) { + throw new Error("No models returned"); + } + const sorted = data.sort((a, b) => b.created - a.created); + const families = ["opus", "sonnet", "haiku"]; + return families.flatMap( + (family) => sorted.filter((m) => m.id.includes(family)).slice(0, 2).map((m) => ({ id: m.id, name: m.id })) + ); + } }; // src/SettingsTab.ts var import_obsidian3 = require("obsidian"); var DEFAULT_SETTINGS = { apiKey: "", - model: "claude-opus-4-5-20251101", + model: "claude-opus-4-6", maxTokens: 8192, maxContextNotes: 6, maxCharsPerNote: 2500, @@ -32768,8 +32792,8 @@ Wenn du Fragen beantwortest: ] }; var MODELS = [ - { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5 (St\xE4rkste)" }, - { id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5 (Empfohlen)" }, + { id: "claude-opus-4-6", name: "Claude Opus 4.6 (St\xE4rkste)" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Empfohlen)" }, { id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5 (Schnell)" } ]; var MemexChatSettingsTab = class extends import_obsidian3.PluginSettingTab { @@ -32827,13 +32851,37 @@ var MemexChatSettingsTab = class extends import_obsidian3.PluginSettingTab { await this.plugin.saveSettings(); }) ); - new import_obsidian3.Setting(containerEl).setName("Modell").setDesc("Welches Claude-Modell verwenden?").addDropdown((drop) => { + let modelDrop; + let refreshBtn; + new import_obsidian3.Setting(containerEl).setName("Modell").setDesc("Welches Claude-Modell verwenden? (Aktualisieren zeigt Roh-IDs)").addDropdown((drop) => { + modelDrop = drop; for (const m of MODELS) drop.addOption(m.id, m.name); drop.setValue(this.plugin.settings.model).onChange(async (value) => { this.plugin.settings.model = value; await this.plugin.saveSettings(); }); + }).addButton((btn) => { + refreshBtn = btn; + btn.setButtonText("Aktualisieren").onClick(async () => { + const prev = modelDrop.getValue(); + refreshBtn.setDisabled(true); + refreshBtn.setButtonText("..."); + try { + const models = await this.plugin.claude.fetchModels(this.plugin.settings.apiKey); + modelDrop.selectEl.empty(); + for (const m of models) + modelDrop.addOption(m.id, m.name); + modelDrop.setValue(prev); + this.plugin.settings.model = modelDrop.getValue(); + await this.plugin.saveSettings(); + } catch (err) { + new import_obsidian3.Notice("Modelle konnten nicht geladen werden: " + err.message); + } finally { + refreshBtn.setDisabled(false); + refreshBtn.setButtonText("Aktualisieren"); + } + }); }); new import_obsidian3.Setting(containerEl).setName("Max. Antwort-Tokens").setDesc("Maximale L\xE4nge der Claude-Antwort. F\xFCr lange Analysen (z.B. Monthly Check) h\xF6her einstellen. (1024\u201316000)").addSlider( (slider) => slider.setLimits(1024, 16e3, 512).setValue(this.plugin.settings.maxTokens).setDynamicTooltip().onChange(async (value) => { @@ -33314,6 +33362,12 @@ var MemexChatPlugin = class extends import_obsidian5.Plugin { } }); this.addSettingTab(new MemexChatSettingsTab(this.app, this)); + this.registerEvent( + this.app.vault.on("modify", (file) => { + if (this.embedSearch && file instanceof import_obsidian5.TFile && file.extension === "md") + this.embedSearch.reembedFile(file); + }) + ); this.app.workspace.onLayoutReady(() => { if (!this.search.isIndexed()) { this.search.priorityProperties = this.settings.contextProperties; @@ -33366,24 +33420,19 @@ var MemexChatPlugin = class extends import_obsidian5.Plugin { this.embedSearch = new EmbedSearch(this.app, this.settings.embeddingModel); this.embedSearch.excludeFolders = this.settings.embedExcludeFolders ?? []; this.embedSearch.contextProperties = this.settings.contextProperties ?? []; - this.registerEvent( - this.app.vault.on("modify", (file) => { - if (this.embedSearch && file instanceof import_obsidian5.TFile && file.extension === "md") - this.embedSearch.reembedFile(file); - }) - ); - const notice = new import_obsidian5.Notice("Memex: Embedding wird vorbereitet\u2026", 0); + const modelShort = this.settings.embeddingModel.split("/").pop() ?? this.settings.embeddingModel; + const notice = new import_obsidian5.Notice(`Memex [${modelShort}]: Embedding wird vorbereitet\u2026`, 0); this.embedSearch.onModelStatus = (status) => { - notice.setMessage(`Memex: ${status}`); + notice.setMessage(`Memex [${modelShort}]: ${status}`); }; this.embedSearch.onProgress = (done, total, speed) => { const speedStr = speed > 0 ? ` \u2022 ${speed.toFixed(1)} N/s` : ""; const remaining = speed > 0 && done < total ? (total - done) / speed : 0; const eta = remaining > 0 ? ` \u2022 ~${remaining < 60 ? Math.ceil(remaining) + "s" : Math.ceil(remaining / 60) + "min"}` : ""; - notice.setMessage(`Memex Embedding: ${done}/${total}${speedStr}${eta}`); + notice.setMessage(`Memex [${modelShort}]: ${done}/${total}${speedStr}${eta}`); }; this.waitForSyncIdle(notice).then(() => this.embedSearch?.buildIndex()).then(() => { - notice.setMessage(`\u2713 Memex: ${this.app.vault.getMarkdownFiles().length} Notizen eingebettet`); + notice.setMessage(`\u2713 Memex [${modelShort}]: ${this.app.vault.getMarkdownFiles().length} Notizen eingebettet`); setTimeout(() => notice.hide(), 4e3); this.notifyRelatedView(); }).catch((e) => {