Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6a98d4050 | |||
| 9b7ec36828 | |||
| 3abd9d9b3b | |||
| d1f760cd2c | |||
| 8caa86753b |
@@ -75,11 +75,15 @@ Open **Settings** → **NibbleAI** and add at least one API key:
|
|||||||
|
|
||||||
| Key | Used For |
|
| Key | Used For |
|
||||||
|-----|----------|
|
|-----|----------|
|
||||||
| **Transcription API Key** | Whisper transcription (OpenAI, Groq, Azure) |
|
| **Groq API Key** | Whisper transcription, and as a chat provider for proofreading/title/property/post-processing |
|
||||||
| **OpenAI API Key** | Proofreading, title generation, property filling, post-processing *(recommended: OpenRouter)* |
|
| **OpenAI API Key** | Whisper transcription, and as a chat provider for proofreading/title/property/post-processing |
|
||||||
| **Anthropic API Key** | Post-processing with Claude models |
|
| **Anthropic API Key** | Claude models for proofreading/title/property/post-processing |
|
||||||
|
| **OpenRouter API Key** | Access to hundreds of models for proofreading/title/property/post-processing |
|
||||||
|
| **Custom API Key + URL** | Any OpenAI-compatible endpoint (self-hosted, Azure, etc.) |
|
||||||
|
|
||||||
> **Tip:** For proofreading, title generation, and property filling, an [OpenRouter](https://openrouter.ai/keys) API key with a free model like `google/gemini-2.5-flash-lite` costs nothing and works great.
|
Every LLM-backed feature (Proofreading, Title Generation, Property Filler, Post-Processing) shows the same provider dropdown — pick any provider that has a key configured above.
|
||||||
|
|
||||||
|
> **Tip:** An [OpenRouter](https://openrouter.ai/keys) API key with a free model like `google/gemini-2.5-flash-lite` costs nothing and works great across all four AI features.
|
||||||
|
|
||||||
### 2. Try the Features
|
### 2. Try the Features
|
||||||
|
|
||||||
@@ -172,13 +176,14 @@ Securely stored in your system keychain via Obsidian's SecretStorage. Never writ
|
|||||||
| Setting | Default | Description |
|
| Setting | Default | Description |
|
||||||
|---------|---------|-------------|
|
|---------|---------|-------------|
|
||||||
| Enable | Off | Clean up transcripts with an LLM |
|
| Enable | Off | Clean up transcripts with an LLM |
|
||||||
| Provider | Anthropic | Anthropic, OpenAI, or Custom |
|
| Provider | Anthropic | Any configured provider: Anthropic, OpenAI, Groq, OpenRouter, or Custom |
|
||||||
| Model | `claude-sonnet-4-20250514` | LLM for cleanup |
|
| Model | `claude-sonnet-4-20250514` | LLM for cleanup |
|
||||||
| Auto-generate title | Off | Create filenames from content |
|
| Auto-generate title | Off | Create filenames from content |
|
||||||
|
|
||||||
### Proofreading
|
### Proofreading
|
||||||
| Setting | Default | Description |
|
| Setting | Default | Description |
|
||||||
|---------|---------|-------------|
|
|---------|---------|-------------|
|
||||||
|
| Provider | OpenRouter | Any configured provider: Anthropic, OpenAI, Groq, OpenRouter, or Custom |
|
||||||
| Model | `openai/gpt-4o-mini` | Model for proofreading |
|
| Model | `openai/gpt-4o-mini` | Model for proofreading |
|
||||||
| Temperature | `0.2` | Lower = more consistent |
|
| Temperature | `0.2` | Lower = more consistent |
|
||||||
| Max tokens | `2000` | Maximum response length |
|
| Max tokens | `2000` | Maximum response length |
|
||||||
@@ -187,12 +192,16 @@ Securely stored in your system keychain via Obsidian's SecretStorage. Never writ
|
|||||||
### Title Generation
|
### Title Generation
|
||||||
| Setting | Default | Description |
|
| Setting | Default | Description |
|
||||||
|---------|---------|-------------|
|
|---------|---------|-------------|
|
||||||
|
| Provider | OpenRouter | Any configured provider: Anthropic, OpenAI, Groq, OpenRouter, or Custom |
|
||||||
|
| Model | `openai/gpt-4o-mini` | Model for generating titles |
|
||||||
| Mode | Rename file | Rename file or set frontmatter property |
|
| Mode | Rename file | Rename file or set frontmatter property |
|
||||||
| Max title length | 8 words | Maximum words in generated title |
|
| Max title length | 8 words | Maximum words in generated title |
|
||||||
|
|
||||||
### Property Filler
|
### Property Filler
|
||||||
| Setting | Default | Description |
|
| Setting | Default | Description |
|
||||||
|---------|---------|-------------|
|
|---------|---------|-------------|
|
||||||
|
| Provider | OpenRouter | Any configured provider: Anthropic, OpenAI, Groq, OpenRouter, or Custom |
|
||||||
|
| Model | `openai/gpt-4o-mini` | Model for generating property values |
|
||||||
| Custom prompt | *(built-in)* | Template with `{property}` placeholder |
|
| Custom prompt | *(built-in)* | Template with `{property}` placeholder |
|
||||||
| Overwrite existing | Off | Replace existing property values |
|
| Overwrite existing | Off | Replace existing property values |
|
||||||
|
|
||||||
@@ -205,7 +214,8 @@ NibbleAI/
|
|||||||
├── main.ts # Plugin entry point, commands, events
|
├── main.ts # Plugin entry point, commands, events
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── SettingsManager.ts # Settings + SecretStorage management
|
│ ├── SettingsManager.ts # Settings + SecretStorage management
|
||||||
│ ├── AIService.ts # Unified AI client (OpenAI + Anthropic)
|
│ ├── AIService.ts # Unified AI client (OpenAI-compatible + Anthropic-native)
|
||||||
|
│ ├── ProviderResolver.ts # Shared provider → API key/endpoint resolution
|
||||||
│ ├── ModelBrowser.ts # OpenRouter model suggest modal
|
│ ├── ModelBrowser.ts # OpenRouter model suggest modal
|
||||||
│ ├── PostProcessor.ts # LLM post-processing for transcripts
|
│ ├── PostProcessor.ts # LLM post-processing for transcripts
|
||||||
│ ├── NibbleAISettingsTab.ts # Settings UI
|
│ ├── NibbleAISettingsTab.ts # Settings UI
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ export default class NibbleAI extends Plugin {
|
|||||||
this.recorder.setDeviceId(deviceId);
|
this.recorder.setDeviceId(deviceId);
|
||||||
|
|
||||||
this.statusBar = new StatusBar(this);
|
this.statusBar = new StatusBar(this);
|
||||||
|
this.recorder.setInputLevelCallback((level) => {
|
||||||
|
this.statusBar.updateInputLevel(level);
|
||||||
|
});
|
||||||
|
|
||||||
this.proofreader = new Proofreader(this);
|
this.proofreader = new Proofreader(this);
|
||||||
this.titleGenerator = new TitleGenerator(this);
|
this.titleGenerator = new TitleGenerator(this);
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "nibbleai",
|
"id": "nibbleai",
|
||||||
"name": "NibbleAI",
|
"name": "NibbleAI",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"minAppVersion": "1.12.7",
|
"minAppVersion": "1.12.7",
|
||||||
"description": "AI-powered tools for your Obsidian vault: speech-to-text, proofreading, title generation, property filling, and more.",
|
"description": "AI-powered tools for your Obsidian vault: speech-to-text, proofreading, title generation, property filling, and more.",
|
||||||
"author": "Olivier",
|
"author": "Olivier",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nibbleai",
|
"name": "nibbleai",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"description": "AI-powered tools for your Obsidian vault",
|
"description": "AI-powered tools for your Obsidian vault",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ export interface ChatCompletionOptions {
|
|||||||
apiKey: string;
|
apiKey: string;
|
||||||
model: string;
|
model: string;
|
||||||
endpoint?: string;
|
endpoint?: string;
|
||||||
provider?: "anthropic" | "openai" | "openrouter" | "custom";
|
provider?: "anthropic" | "openai" | "openrouter" | "groq" | "custom";
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
+144
-82
@@ -3,9 +3,8 @@ import { App, PluginSettingTab, Setting, setIcon } from "obsidian";
|
|||||||
import {
|
import {
|
||||||
SettingsManager,
|
SettingsManager,
|
||||||
PostProcessingProvider,
|
PostProcessingProvider,
|
||||||
AIProvider,
|
|
||||||
TranscriptionProvider,
|
TranscriptionProvider,
|
||||||
PROVIDER_URLS,
|
CHAT_PROVIDERS,
|
||||||
PROVIDER_DEFAULT_MODELS,
|
PROVIDER_DEFAULT_MODELS,
|
||||||
} from "./SettingsManager";
|
} from "./SettingsManager";
|
||||||
import { ModelSuggestModal } from "./ModelBrowser";
|
import { ModelSuggestModal } from "./ModelBrowser";
|
||||||
@@ -106,10 +105,14 @@ export class NibbleAISettingsTab extends PluginSettingTab {
|
|||||||
text: "API keys are stored securely in your system keychain.",
|
text: "API keys are stored securely in your system keychain.",
|
||||||
cls: "setting-item-description",
|
cls: "setting-item-description",
|
||||||
});
|
});
|
||||||
|
containerEl.createEl("p", {
|
||||||
|
text: "Each AI feature below (Proofreading, Title Generation, Property Filler, Post-Processing) lets you pick which of these providers to use, provided its key is set here.",
|
||||||
|
cls: "setting-item-description",
|
||||||
|
});
|
||||||
|
|
||||||
this.createApiKeySetting(containerEl,
|
this.createApiKeySetting(containerEl,
|
||||||
"Groq API Key",
|
"Groq API Key",
|
||||||
"For Whisper transcription via Groq (groq.com)",
|
"For Whisper transcription and Groq chat models (groq.com)",
|
||||||
"gsk_...",
|
"gsk_...",
|
||||||
this.plugin.settings.groqApiKey,
|
this.plugin.settings.groqApiKey,
|
||||||
async (value) => {
|
async (value) => {
|
||||||
@@ -139,7 +142,7 @@ export class NibbleAISettingsTab extends PluginSettingTab {
|
|||||||
);
|
);
|
||||||
this.createApiKeySetting(containerEl,
|
this.createApiKeySetting(containerEl,
|
||||||
"OpenRouter API Key",
|
"OpenRouter API Key",
|
||||||
"For OpenRouter models — get one at openrouter.ai/keys. Used for proofreading, title generation, property filling, and post-processing.",
|
"For OpenRouter models — get one at openrouter.ai/keys.",
|
||||||
"sk-or-v1-...",
|
"sk-or-v1-...",
|
||||||
this.plugin.settings.openRouterApiKey,
|
this.plugin.settings.openRouterApiKey,
|
||||||
async (value) => {
|
async (value) => {
|
||||||
@@ -147,6 +150,26 @@ export class NibbleAISettingsTab extends PluginSettingTab {
|
|||||||
await this.save();
|
await this.save();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
this.createApiKeySetting(containerEl,
|
||||||
|
"Custom API Key",
|
||||||
|
"For a self-hosted or OpenAI-compatible endpoint not listed above",
|
||||||
|
"sk-...xxxx",
|
||||||
|
this.plugin.settings.customApiKey,
|
||||||
|
async (value) => {
|
||||||
|
this.plugin.settings.customApiKey = value;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.createTextSetting(containerEl,
|
||||||
|
"Custom API URL",
|
||||||
|
"Chat completions endpoint for the custom provider",
|
||||||
|
"https://api.example.com/v1/chat/completions",
|
||||||
|
this.plugin.settings.customApiUrl,
|
||||||
|
async (value) => {
|
||||||
|
this.plugin.settings.customApiUrl = value;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Transcription Tab ──
|
// ── Transcription Tab ──
|
||||||
@@ -328,29 +351,22 @@ export class NibbleAISettingsTab extends PluginSettingTab {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (this.plugin.settings.postProcessing) {
|
if (this.plugin.settings.postProcessing) {
|
||||||
this.createPostProcessingProviderSetting(containerEl);
|
this.createChatProviderSetting(containerEl,
|
||||||
|
"Select the LLM provider for cleaning up transcriptions",
|
||||||
|
this.plugin.settings.postProcessingProvider,
|
||||||
|
async (provider) => {
|
||||||
|
this.plugin.settings.postProcessingProvider = provider;
|
||||||
|
if (provider !== "custom") {
|
||||||
|
this.plugin.settings.postProcessingModel =
|
||||||
|
PROVIDER_DEFAULT_MODELS[provider];
|
||||||
|
}
|
||||||
|
await this.save();
|
||||||
|
this.display();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (this.plugin.settings.postProcessingProvider === "custom") {
|
if (this.plugin.settings.postProcessingProvider === "custom") {
|
||||||
this.createTextSetting(containerEl,
|
this.createCustomProviderFields(containerEl);
|
||||||
"API URL",
|
|
||||||
"Custom endpoint for chat completions",
|
|
||||||
"https://api.example.com/v1/chat/completions",
|
|
||||||
this.plugin.settings.postProcessingUrl,
|
|
||||||
async (value) => {
|
|
||||||
this.plugin.settings.postProcessingUrl = value;
|
|
||||||
await this.save();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
this.createApiKeySetting(containerEl,
|
|
||||||
"API Key",
|
|
||||||
"API key for the custom endpoint",
|
|
||||||
"sk-...xxxx",
|
|
||||||
this.plugin.settings.customApiKey,
|
|
||||||
async (value) => {
|
|
||||||
this.plugin.settings.customApiKey = value;
|
|
||||||
await this.save();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.createModelSetting(containerEl,
|
this.createModelSetting(containerEl,
|
||||||
@@ -424,15 +440,23 @@ export class NibbleAISettingsTab extends PluginSettingTab {
|
|||||||
cls: "setting-item-description",
|
cls: "setting-item-description",
|
||||||
});
|
});
|
||||||
|
|
||||||
this.createProviderSetting(containerEl,
|
this.createChatProviderSetting(containerEl,
|
||||||
"Provider",
|
|
||||||
"Select the AI provider for proofreading",
|
"Select the AI provider for proofreading",
|
||||||
this.plugin.settings.proofreadingProvider,
|
this.plugin.settings.proofreadingProvider,
|
||||||
async (value) => {
|
async (provider) => {
|
||||||
this.plugin.settings.proofreadingProvider = value as AIProvider;
|
this.plugin.settings.proofreadingProvider = provider;
|
||||||
|
if (provider !== "custom") {
|
||||||
|
this.plugin.settings.proofreadingModel =
|
||||||
|
PROVIDER_DEFAULT_MODELS[provider];
|
||||||
|
}
|
||||||
await this.save();
|
await this.save();
|
||||||
|
this.display();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (this.plugin.settings.proofreadingProvider === "custom") {
|
||||||
|
this.createCustomProviderFields(containerEl);
|
||||||
|
}
|
||||||
this.createModelSetting(containerEl,
|
this.createModelSetting(containerEl,
|
||||||
"Model",
|
"Model",
|
||||||
"Model for fixing punctuation and grammar",
|
"Model for fixing punctuation and grammar",
|
||||||
@@ -508,15 +532,23 @@ export class NibbleAISettingsTab extends PluginSettingTab {
|
|||||||
cls: "setting-item-description",
|
cls: "setting-item-description",
|
||||||
});
|
});
|
||||||
|
|
||||||
this.createProviderSetting(containerEl,
|
this.createChatProviderSetting(containerEl,
|
||||||
"Provider",
|
|
||||||
"Select the AI provider for title generation",
|
"Select the AI provider for title generation",
|
||||||
this.plugin.settings.titleGenProvider,
|
this.plugin.settings.titleGenProvider,
|
||||||
async (value) => {
|
async (provider) => {
|
||||||
this.plugin.settings.titleGenProvider = value as AIProvider;
|
this.plugin.settings.titleGenProvider = provider;
|
||||||
|
if (provider !== "custom") {
|
||||||
|
this.plugin.settings.titleGenModel =
|
||||||
|
PROVIDER_DEFAULT_MODELS[provider];
|
||||||
|
}
|
||||||
await this.save();
|
await this.save();
|
||||||
|
this.display();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (this.plugin.settings.titleGenProvider === "custom") {
|
||||||
|
this.createCustomProviderFields(containerEl);
|
||||||
|
}
|
||||||
this.createModelSetting(containerEl,
|
this.createModelSetting(containerEl,
|
||||||
"Model",
|
"Model",
|
||||||
"Model for generating titles",
|
"Model for generating titles",
|
||||||
@@ -577,15 +609,23 @@ export class NibbleAISettingsTab extends PluginSettingTab {
|
|||||||
cls: "setting-item-description",
|
cls: "setting-item-description",
|
||||||
});
|
});
|
||||||
|
|
||||||
this.createProviderSetting(containerEl,
|
this.createChatProviderSetting(containerEl,
|
||||||
"Provider",
|
|
||||||
"Select the AI provider for property generation",
|
"Select the AI provider for property generation",
|
||||||
this.plugin.settings.propertyGenProvider,
|
this.plugin.settings.propertyGenProvider,
|
||||||
async (value) => {
|
async (provider) => {
|
||||||
this.plugin.settings.propertyGenProvider = value as AIProvider;
|
this.plugin.settings.propertyGenProvider = provider;
|
||||||
|
if (provider !== "custom") {
|
||||||
|
this.plugin.settings.propertyGenModel =
|
||||||
|
PROVIDER_DEFAULT_MODELS[provider];
|
||||||
|
}
|
||||||
await this.save();
|
await this.save();
|
||||||
|
this.display();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (this.plugin.settings.propertyGenProvider === "custom") {
|
||||||
|
this.createCustomProviderFields(containerEl);
|
||||||
|
}
|
||||||
this.createModelSetting(containerEl,
|
this.createModelSetting(containerEl,
|
||||||
"Model",
|
"Model",
|
||||||
"Model for generating property values",
|
"Model for generating property values",
|
||||||
@@ -748,30 +788,53 @@ export class NibbleAISettingsTab extends PluginSettingTab {
|
|||||||
infoEl.style.flex = "1 1 100%";
|
infoEl.style.flex = "1 1 100%";
|
||||||
}
|
}
|
||||||
|
|
||||||
private createProviderSetting(
|
/**
|
||||||
|
* Shared provider dropdown for every LLM-backed feature (Post-Processing,
|
||||||
|
* Proofreading, Title Generation, Property Filler). Reads from the single
|
||||||
|
* CHAT_PROVIDERS registry so all four features always offer the exact
|
||||||
|
* same providers, and disables any provider whose API key isn't set yet.
|
||||||
|
*/
|
||||||
|
private createChatProviderSetting(
|
||||||
containerEl: HTMLElement,
|
containerEl: HTMLElement,
|
||||||
name: string,
|
|
||||||
desc: string,
|
desc: string,
|
||||||
value: string,
|
currentProvider: PostProcessingProvider,
|
||||||
onChange: (value: string) => Promise<void>
|
onChange: (provider: PostProcessingProvider) => Promise<void>
|
||||||
): void {
|
): void {
|
||||||
const providers: Record<string, string> = {
|
const configured = new Set(
|
||||||
openrouter: "OpenRouter",
|
CHAT_PROVIDERS.filter((p) => !!this.plugin.settings[p.apiKeyField]).map(
|
||||||
openai: "OpenAI",
|
(p) => p.id
|
||||||
anthropic: "Anthropic",
|
)
|
||||||
custom: "Custom",
|
);
|
||||||
};
|
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName(name)
|
.setName("Provider")
|
||||||
.setDesc(desc)
|
.setDesc(desc)
|
||||||
.addDropdown((dropdown) => {
|
.addDropdown((dropdown) => {
|
||||||
for (const [val, label] of Object.entries(providers)) {
|
for (const p of CHAT_PROVIDERS) {
|
||||||
dropdown.addOption(val, label);
|
dropdown.addOption(
|
||||||
|
p.id,
|
||||||
|
configured.has(p.id) ? p.label : `${p.label} (no API key)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
dropdown.setValue(currentProvider || "openrouter");
|
||||||
|
dropdown.onChange(async (value) => {
|
||||||
|
await onChange(value as PostProcessingProvider);
|
||||||
|
});
|
||||||
|
for (const p of CHAT_PROVIDERS) {
|
||||||
|
if (configured.has(p.id)) continue;
|
||||||
|
const optionEl = dropdown.selectEl.querySelector(
|
||||||
|
`option[value="${p.id}"]`
|
||||||
|
) as HTMLOptionElement | null;
|
||||||
|
if (optionEl) optionEl.disabled = true;
|
||||||
}
|
}
|
||||||
dropdown.setValue(value || "openrouter");
|
|
||||||
dropdown.onChange(async (val) => await onChange(val));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (configured.size === 0) {
|
||||||
|
containerEl.createEl("p", {
|
||||||
|
text: "⚠️ No AI provider configured yet — add an API key in the API Keys tab.",
|
||||||
|
cls: "setting-item-description",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private createModelSetting(
|
private createModelSetting(
|
||||||
@@ -860,36 +923,35 @@ export class NibbleAISettingsTab extends PluginSettingTab {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private createPostProcessingProviderSetting(containerEl: HTMLElement): void {
|
/**
|
||||||
const providers: Record<PostProcessingProvider, string> = {
|
* Custom endpoint + key fields, shown whenever a feature's provider is
|
||||||
anthropic: "Anthropic",
|
* set to "Custom". This slot is shared across all four AI features.
|
||||||
openai: "OpenAI",
|
*/
|
||||||
openrouter: "OpenRouter",
|
private createCustomProviderFields(containerEl: HTMLElement): void {
|
||||||
custom: "Custom",
|
containerEl.createEl("p", {
|
||||||
};
|
text: "Custom provider connection \u2014 shared across Post-Processing, Proofreading, Title Generation, and Property Filler.",
|
||||||
|
cls: "setting-item-description",
|
||||||
new Setting(containerEl)
|
});
|
||||||
.setName("Provider")
|
this.createTextSetting(containerEl,
|
||||||
.setDesc("Select the LLM provider")
|
"Custom API URL",
|
||||||
.addDropdown((dropdown) => {
|
"OpenAI-compatible chat completions endpoint",
|
||||||
for (const [value, label] of Object.entries(providers)) {
|
"https://api.example.com/v1/chat/completions",
|
||||||
dropdown.addOption(value, label);
|
this.plugin.settings.customApiUrl,
|
||||||
}
|
async (value) => {
|
||||||
dropdown
|
this.plugin.settings.customApiUrl = value;
|
||||||
.setValue(this.plugin.settings.postProcessingProvider)
|
await this.save();
|
||||||
.onChange(async (value) => {
|
}
|
||||||
const provider = value as PostProcessingProvider;
|
);
|
||||||
this.plugin.settings.postProcessingProvider = provider;
|
this.createApiKeySetting(containerEl,
|
||||||
if (provider !== "custom") {
|
"Custom API Key",
|
||||||
this.plugin.settings.postProcessingUrl =
|
"API key for the custom endpoint",
|
||||||
PROVIDER_URLS[provider];
|
"sk-...xxxx",
|
||||||
this.plugin.settings.postProcessingModel =
|
this.plugin.settings.customApiKey,
|
||||||
PROVIDER_DEFAULT_MODELS[provider];
|
async (value) => {
|
||||||
}
|
this.plugin.settings.customApiKey = value;
|
||||||
await this.save();
|
await this.save();
|
||||||
this.display();
|
}
|
||||||
});
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createAudioDeviceSetting(containerEl: HTMLElement): Promise<void> {
|
private async createAudioDeviceSetting(containerEl: HTMLElement): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { CHAT_PROVIDERS, PROVIDER_URLS, PluginSettings, PostProcessingProvider } from "./SettingsManager";
|
||||||
|
|
||||||
|
export interface ResolvedChatProvider {
|
||||||
|
apiKey: string;
|
||||||
|
endpoint: string;
|
||||||
|
provider: PostProcessingProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the API key + endpoint for a chat-completion provider selection.
|
||||||
|
* Shared by every AI feature that talks to an LLM (Post-Processing,
|
||||||
|
* Proofreading, Title Generation, Property Filler) so they all treat
|
||||||
|
* providers — including "no key configured" — identically.
|
||||||
|
*/
|
||||||
|
export function resolveChatProvider(
|
||||||
|
settings: PluginSettings,
|
||||||
|
provider: PostProcessingProvider
|
||||||
|
): ResolvedChatProvider | null {
|
||||||
|
const info = CHAT_PROVIDERS.find((p) => p.id === provider);
|
||||||
|
if (!info) return null;
|
||||||
|
|
||||||
|
const apiKey = settings[info.apiKeyField];
|
||||||
|
const endpoint = provider === "custom" ? settings.customApiUrl : PROVIDER_URLS[provider];
|
||||||
|
|
||||||
|
if (!apiKey || !endpoint) return null;
|
||||||
|
return { apiKey, endpoint, provider };
|
||||||
|
}
|
||||||
+50
-12
@@ -1,6 +1,6 @@
|
|||||||
import { Plugin } from "obsidian";
|
import { Plugin } from "obsidian";
|
||||||
|
|
||||||
const SECRET_IDS: Record<keyof ApiKeysSettings, string> = {
|
const SECRET_IDS: Partial<Record<keyof ApiKeysSettings, string>> = {
|
||||||
groqApiKey: "groq-api-key",
|
groqApiKey: "groq-api-key",
|
||||||
openAiApiKey: "openai-api-key",
|
openAiApiKey: "openai-api-key",
|
||||||
anthropicApiKey: "anthropic-api-key",
|
anthropicApiKey: "anthropic-api-key",
|
||||||
@@ -8,7 +8,7 @@ const SECRET_IDS: Record<keyof ApiKeysSettings, string> = {
|
|||||||
openRouterApiKey: "openrouter-api-key",
|
openRouterApiKey: "openrouter-api-key",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PostProcessingProvider = "anthropic" | "openai" | "openrouter" | "custom";
|
export type PostProcessingProvider = "anthropic" | "openai" | "openrouter" | "groq" | "custom";
|
||||||
|
|
||||||
export type AIProvider = PostProcessingProvider;
|
export type AIProvider = PostProcessingProvider;
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ export const PROVIDER_URLS: Record<PostProcessingProvider, string> = {
|
|||||||
anthropic: "https://api.anthropic.com/v1/messages",
|
anthropic: "https://api.anthropic.com/v1/messages",
|
||||||
openai: "https://api.openai.com/v1/chat/completions",
|
openai: "https://api.openai.com/v1/chat/completions",
|
||||||
openrouter: "https://openrouter.ai/api/v1/chat/completions",
|
openrouter: "https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
groq: "https://api.groq.com/openai/v1/chat/completions",
|
||||||
custom: "",
|
custom: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,9 +24,30 @@ export const PROVIDER_DEFAULT_MODELS: Record<PostProcessingProvider, string> = {
|
|||||||
anthropic: "claude-sonnet-4-20250514",
|
anthropic: "claude-sonnet-4-20250514",
|
||||||
openai: "gpt-4o-mini",
|
openai: "gpt-4o-mini",
|
||||||
openrouter: "openai/gpt-4o-mini",
|
openrouter: "openai/gpt-4o-mini",
|
||||||
|
groq: "llama-3.3-70b-versatile",
|
||||||
custom: "",
|
custom: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ChatProviderInfo {
|
||||||
|
id: PostProcessingProvider;
|
||||||
|
label: string;
|
||||||
|
apiKeyField: keyof ApiKeysSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for which chat-completion providers exist and which
|
||||||
|
* API key each one uses. UI dropdowns and provider resolution both read from
|
||||||
|
* this list so every AI feature (Post-Processing, Proofreading, Title
|
||||||
|
* Generation, Property Filler) offers the exact same providers.
|
||||||
|
*/
|
||||||
|
export const CHAT_PROVIDERS: ChatProviderInfo[] = [
|
||||||
|
{ id: "anthropic", label: "Anthropic", apiKeyField: "anthropicApiKey" },
|
||||||
|
{ id: "openai", label: "OpenAI", apiKeyField: "openAiApiKey" },
|
||||||
|
{ id: "groq", label: "Groq", apiKeyField: "groqApiKey" },
|
||||||
|
{ id: "openrouter", label: "OpenRouter", apiKeyField: "openRouterApiKey" },
|
||||||
|
{ id: "custom", label: "Custom", apiKeyField: "customApiKey" },
|
||||||
|
];
|
||||||
|
|
||||||
export type TranscriptionProvider = "groq" | "openai" | "azure" | "custom";
|
export type TranscriptionProvider = "groq" | "openai" | "azure" | "custom";
|
||||||
|
|
||||||
export const TRANSCRIPTION_PROVIDER_URLS: Record<TranscriptionProvider, string> = {
|
export const TRANSCRIPTION_PROVIDER_URLS: Record<TranscriptionProvider, string> = {
|
||||||
@@ -42,6 +64,7 @@ export interface ApiKeysSettings {
|
|||||||
openAiApiKey: string;
|
openAiApiKey: string;
|
||||||
anthropicApiKey: string;
|
anthropicApiKey: string;
|
||||||
customApiKey: string;
|
customApiKey: string;
|
||||||
|
customApiUrl: string;
|
||||||
openRouterApiKey: string;
|
openRouterApiKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +97,6 @@ export interface OutputSettings {
|
|||||||
export interface PostProcessingSettings {
|
export interface PostProcessingSettings {
|
||||||
postProcessing: boolean;
|
postProcessing: boolean;
|
||||||
postProcessingProvider: PostProcessingProvider;
|
postProcessingProvider: PostProcessingProvider;
|
||||||
postProcessingUrl: string;
|
|
||||||
postProcessingModel: string;
|
postProcessingModel: string;
|
||||||
postProcessingPrompt: string;
|
postProcessingPrompt: string;
|
||||||
autoGenerateTitle: boolean;
|
autoGenerateTitle: boolean;
|
||||||
@@ -128,6 +150,7 @@ export const DEFAULT_API_KEYS: ApiKeysSettings = {
|
|||||||
openAiApiKey: "",
|
openAiApiKey: "",
|
||||||
anthropicApiKey: "",
|
anthropicApiKey: "",
|
||||||
customApiKey: "",
|
customApiKey: "",
|
||||||
|
customApiUrl: "",
|
||||||
openRouterApiKey: "",
|
openRouterApiKey: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,7 +183,6 @@ export const DEFAULT_OUTPUT: OutputSettings = {
|
|||||||
export const DEFAULT_POST_PROCESSING: PostProcessingSettings = {
|
export const DEFAULT_POST_PROCESSING: PostProcessingSettings = {
|
||||||
postProcessing: false,
|
postProcessing: false,
|
||||||
postProcessingProvider: "anthropic",
|
postProcessingProvider: "anthropic",
|
||||||
postProcessingUrl: "https://api.anthropic.com/v1/messages",
|
|
||||||
postProcessingModel: "claude-sonnet-4-20250514",
|
postProcessingModel: "claude-sonnet-4-20250514",
|
||||||
postProcessingPrompt:
|
postProcessingPrompt:
|
||||||
'You are a transcription editor. Clean up the following voice transcription: fix grammar, remove filler words (um, uh, like) and repetitions, and improve readability. Format the text in markdown. If there are action items or to-dos, format them as task lists with "[ ]". Preserve the original meaning and language. Return only the polished text, nothing else.',
|
'You are a transcription editor. Clean up the following voice transcription: fix grammar, remove filler words (um, uh, like) and repetitions, and improve readability. Format the text in markdown. If there are action items or to-dos, format them as task lists with "[ ]". Preserve the original meaning and language. Return only the polished text, nothing else.',
|
||||||
@@ -274,19 +296,31 @@ export class SettingsManager {
|
|||||||
|
|
||||||
private migratePostProcessingProvider(settings: PluginSettings): boolean {
|
private migratePostProcessingProvider(settings: PluginSettings): boolean {
|
||||||
if (settings.postProcessingProvider) return false;
|
if (settings.postProcessingProvider) return false;
|
||||||
|
const legacyUrl = (settings as PluginSettings & { postProcessingUrl?: string })
|
||||||
|
.postProcessingUrl;
|
||||||
|
if (!legacyUrl) return false;
|
||||||
|
|
||||||
for (const [provider, url] of Object.entries(PROVIDER_URLS)) {
|
for (const [provider, url] of Object.entries(PROVIDER_URLS)) {
|
||||||
if (url && settings.postProcessingUrl === url) {
|
if (url && legacyUrl === url) {
|
||||||
settings.postProcessingProvider =
|
settings.postProcessingProvider = provider as PostProcessingProvider;
|
||||||
provider as PostProcessingProvider;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (settings.postProcessingUrl) {
|
settings.postProcessingProvider = "custom";
|
||||||
settings.postProcessingProvider = "custom";
|
return true;
|
||||||
return true;
|
}
|
||||||
}
|
|
||||||
return false;
|
private migrateCustomApiUrl(settings: PluginSettings): boolean {
|
||||||
|
// One-time migration: the old `postProcessingUrl` field is now the
|
||||||
|
// shared `customApiUrl` used by every feature's "Custom" provider.
|
||||||
|
if (settings.customApiUrl) return false;
|
||||||
|
const legacyUrl = (settings as PluginSettings & { postProcessingUrl?: string })
|
||||||
|
.postProcessingUrl;
|
||||||
|
if (!legacyUrl) return false;
|
||||||
|
if (Object.values(PROVIDER_URLS).includes(legacyUrl)) return false;
|
||||||
|
|
||||||
|
settings.customApiUrl = legacyUrl;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadSettings(): Promise<PluginSettings> {
|
async loadSettings(): Promise<PluginSettings> {
|
||||||
@@ -300,6 +334,10 @@ export class SettingsManager {
|
|||||||
await this.plugin.saveData(settings);
|
await this.plugin.saveData(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.migrateCustomApiUrl(settings)) {
|
||||||
|
await this.plugin.saveData(settings);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.migrateKeysFromDataJson(settings)) {
|
if (this.migrateKeysFromDataJson(settings)) {
|
||||||
await this.plugin.saveData(settings);
|
await this.plugin.saveData(settings);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Editor, Notice } from "obsidian";
|
import { Editor, Notice } from "obsidian";
|
||||||
import { AIService } from "../AIService";
|
import { AIService } from "../AIService";
|
||||||
import { AIProvider, PROVIDER_URLS } from "../SettingsManager";
|
import { resolveChatProvider } from "../ProviderResolver";
|
||||||
import NibbleAI from "main";
|
import NibbleAI from "main";
|
||||||
|
|
||||||
export class Proofreader {
|
export class Proofreader {
|
||||||
@@ -10,33 +10,6 @@ export class Proofreader {
|
|||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveProvider(): { apiKey: string; endpoint: string; provider: AIProvider } | null {
|
|
||||||
const provider = this.plugin.settings.proofreadingProvider || "openrouter";
|
|
||||||
let apiKey = "";
|
|
||||||
let endpoint = PROVIDER_URLS[provider] || "";
|
|
||||||
|
|
||||||
switch (provider) {
|
|
||||||
case "openai":
|
|
||||||
apiKey = this.plugin.settings.openAiApiKey;
|
|
||||||
break;
|
|
||||||
case "anthropic":
|
|
||||||
apiKey = this.plugin.settings.anthropicApiKey;
|
|
||||||
break;
|
|
||||||
case "openrouter":
|
|
||||||
apiKey = this.plugin.settings.openRouterApiKey;
|
|
||||||
break;
|
|
||||||
case "custom":
|
|
||||||
apiKey = this.plugin.settings.customApiKey;
|
|
||||||
endpoint = this.plugin.settings.postProcessingUrl || "";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!apiKey) return null;
|
|
||||||
if (!endpoint) return null;
|
|
||||||
|
|
||||||
return { apiKey, endpoint, provider };
|
|
||||||
}
|
|
||||||
|
|
||||||
async fixSelection(editor: Editor): Promise<void> {
|
async fixSelection(editor: Editor): Promise<void> {
|
||||||
const selection = editor.getSelection();
|
const selection = editor.getSelection();
|
||||||
if (!selection || selection.trim().length === 0) {
|
if (!selection || selection.trim().length === 0) {
|
||||||
@@ -44,7 +17,10 @@ export class Proofreader {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = this.resolveProvider();
|
const config = resolveChatProvider(
|
||||||
|
this.plugin.settings,
|
||||||
|
this.plugin.settings.proofreadingProvider || "openrouter"
|
||||||
|
);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
new Notice(
|
new Notice(
|
||||||
"⚠️ No API key for selected provider. Check Settings \u2192 NibbleAI \u2192 API Keys."
|
"⚠️ No API key for selected provider. Check Settings \u2192 NibbleAI \u2192 API Keys."
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Notice, MarkdownView, Modal, App, Setting, TFile } from "obsidian";
|
import { Notice, MarkdownView, Modal, App, Setting, TFile } from "obsidian";
|
||||||
import { AIService } from "../AIService";
|
import { AIService } from "../AIService";
|
||||||
import { AIProvider, PROVIDER_URLS } from "../SettingsManager";
|
import { resolveChatProvider } from "../ProviderResolver";
|
||||||
import NibbleAI from "main";
|
import NibbleAI from "main";
|
||||||
|
|
||||||
class PropertyPromptModal extends Modal {
|
class PropertyPromptModal extends Modal {
|
||||||
@@ -80,31 +80,6 @@ export class PropertyFiller {
|
|||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveProvider(): { apiKey: string; endpoint: string; provider: AIProvider } | null {
|
|
||||||
const provider = this.plugin.settings.propertyGenProvider || "openrouter";
|
|
||||||
let apiKey = "";
|
|
||||||
let endpoint = PROVIDER_URLS[provider] || "";
|
|
||||||
|
|
||||||
switch (provider) {
|
|
||||||
case "openai":
|
|
||||||
apiKey = this.plugin.settings.openAiApiKey;
|
|
||||||
break;
|
|
||||||
case "anthropic":
|
|
||||||
apiKey = this.plugin.settings.anthropicApiKey;
|
|
||||||
break;
|
|
||||||
case "openrouter":
|
|
||||||
apiKey = this.plugin.settings.openRouterApiKey;
|
|
||||||
break;
|
|
||||||
case "custom":
|
|
||||||
apiKey = this.plugin.settings.customApiKey;
|
|
||||||
endpoint = this.plugin.settings.postProcessingUrl || "";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!apiKey || !endpoint) return null;
|
|
||||||
return { apiKey, endpoint, provider };
|
|
||||||
}
|
|
||||||
|
|
||||||
async fillProperty(): Promise<void> {
|
async fillProperty(): Promise<void> {
|
||||||
new PropertyPromptModal(
|
new PropertyPromptModal(
|
||||||
this.plugin.app,
|
this.plugin.app,
|
||||||
@@ -139,7 +114,10 @@ export class PropertyFiller {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = this.resolveProvider();
|
const config = resolveChatProvider(
|
||||||
|
this.plugin.settings,
|
||||||
|
this.plugin.settings.propertyGenProvider || "openrouter"
|
||||||
|
);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
new Notice(
|
new Notice(
|
||||||
"⚠️ No API key for selected provider. Check Settings \u2192 NibbleAI \u2192 API Keys."
|
"⚠️ No API key for selected provider. Check Settings \u2192 NibbleAI \u2192 API Keys."
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ export class NativeAudioRecorder implements AudioRecorder {
|
|||||||
private recorder: MediaRecorder | null = null;
|
private recorder: MediaRecorder | null = null;
|
||||||
private mimeType: string | undefined;
|
private mimeType: string | undefined;
|
||||||
private deviceId: string | null = null;
|
private deviceId: string | null = null;
|
||||||
|
private audioContext: AudioContext | null = null;
|
||||||
|
private analyser: AnalyserNode | null = null;
|
||||||
|
private inputSource: MediaStreamAudioSourceNode | null = null;
|
||||||
|
private inputSamples: Uint8Array | null = null;
|
||||||
|
private animationFrameId: number | null = null;
|
||||||
|
private onInputLevel: ((level: number) => void) | null = null;
|
||||||
|
|
||||||
getRecordingState(): "inactive" | "recording" | "paused" | undefined {
|
getRecordingState(): "inactive" | "recording" | "paused" | undefined {
|
||||||
return this.recorder?.state;
|
return this.recorder?.state;
|
||||||
@@ -46,6 +52,10 @@ export class NativeAudioRecorder implements AudioRecorder {
|
|||||||
this.deviceId = deviceId;
|
this.deviceId = deviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setInputLevelCallback(callback: ((level: number) => void) | null): void {
|
||||||
|
this.onInputLevel = callback;
|
||||||
|
}
|
||||||
|
|
||||||
async startRecording(): Promise<void> {
|
async startRecording(): Promise<void> {
|
||||||
if (!this.recorder) {
|
if (!this.recorder) {
|
||||||
try {
|
try {
|
||||||
@@ -70,6 +80,7 @@ export class NativeAudioRecorder implements AudioRecorder {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.recorder = recorder;
|
this.recorder = recorder;
|
||||||
|
this.startInputMeter(stream);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
new Notice("✘ Couldn't access microphone");
|
new Notice("✘ Couldn't access microphone");
|
||||||
console.error("Error initializing recorder:", err);
|
console.error("Error initializing recorder:", err);
|
||||||
@@ -87,6 +98,7 @@ export class NativeAudioRecorder implements AudioRecorder {
|
|||||||
|
|
||||||
if (this.recorder.state === "recording") {
|
if (this.recorder.state === "recording") {
|
||||||
this.recorder.pause();
|
this.recorder.pause();
|
||||||
|
this.onInputLevel?.(0);
|
||||||
} else if (this.recorder.state === "paused") {
|
} else if (this.recorder.state === "paused") {
|
||||||
this.recorder.resume();
|
this.recorder.resume();
|
||||||
}
|
}
|
||||||
@@ -97,6 +109,7 @@ export class NativeAudioRecorder implements AudioRecorder {
|
|||||||
if (!this.recorder || this.recorder.state === "inactive") {
|
if (!this.recorder || this.recorder.state === "inactive") {
|
||||||
const blob = new Blob(this.chunks, { type: this.mimeType });
|
const blob = new Blob(this.chunks, { type: this.mimeType });
|
||||||
this.chunks.length = 0;
|
this.chunks.length = 0;
|
||||||
|
this.stopInputMeter();
|
||||||
resolve(blob);
|
resolve(blob);
|
||||||
} else {
|
} else {
|
||||||
this.recorder.addEventListener(
|
this.recorder.addEventListener(
|
||||||
@@ -113,6 +126,7 @@ export class NativeAudioRecorder implements AudioRecorder {
|
|||||||
.forEach((track) => track.stop());
|
.forEach((track) => track.stop());
|
||||||
this.recorder = null;
|
this.recorder = null;
|
||||||
}
|
}
|
||||||
|
this.stopInputMeter();
|
||||||
|
|
||||||
resolve(blob);
|
resolve(blob);
|
||||||
},
|
},
|
||||||
@@ -123,4 +137,54 @@ export class NativeAudioRecorder implements AudioRecorder {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startInputMeter(stream: MediaStream): void {
|
||||||
|
this.stopInputMeter();
|
||||||
|
|
||||||
|
this.audioContext = new AudioContext();
|
||||||
|
this.analyser = this.audioContext.createAnalyser();
|
||||||
|
this.analyser.fftSize = 256;
|
||||||
|
this.inputSamples = new Uint8Array(this.analyser.fftSize);
|
||||||
|
|
||||||
|
this.inputSource = this.audioContext.createMediaStreamSource(stream);
|
||||||
|
this.inputSource.connect(this.analyser);
|
||||||
|
this.tickInputMeter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private tickInputMeter(): void {
|
||||||
|
if (!this.analyser || !this.inputSamples) return;
|
||||||
|
|
||||||
|
if (this.recorder?.state === "recording") {
|
||||||
|
this.analyser.getByteTimeDomainData(this.inputSamples);
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < this.inputSamples.length; i++) {
|
||||||
|
const centeredSample = (this.inputSamples[i] - 128) / 128;
|
||||||
|
sum += centeredSample * centeredSample;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rms = Math.sqrt(sum / this.inputSamples.length);
|
||||||
|
this.onInputLevel?.(Math.min(1, rms * 6));
|
||||||
|
} else {
|
||||||
|
this.onInputLevel?.(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.animationFrameId = requestAnimationFrame(() =>
|
||||||
|
this.tickInputMeter()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopInputMeter(): void {
|
||||||
|
if (this.animationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(this.animationFrameId);
|
||||||
|
this.animationFrameId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.audioContext?.close();
|
||||||
|
this.audioContext = null;
|
||||||
|
this.analyser = null;
|
||||||
|
this.inputSource = null;
|
||||||
|
this.inputSamples = null;
|
||||||
|
this.onInputLevel?.(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+55
-20
@@ -12,6 +12,7 @@ export class StatusBar {
|
|||||||
statusBarItem: HTMLElement | null = null;
|
statusBarItem: HTMLElement | null = null;
|
||||||
status: RecordingStatus = RecordingStatus.Idle;
|
status: RecordingStatus = RecordingStatus.Idle;
|
||||||
private listeners: Array<(status: RecordingStatus) => void> = [];
|
private listeners: Array<(status: RecordingStatus) => void> = [];
|
||||||
|
private meterBars: HTMLElement[] = [];
|
||||||
|
|
||||||
constructor(plugin: Plugin) {
|
constructor(plugin: Plugin) {
|
||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
@@ -33,27 +34,61 @@ export class StatusBar {
|
|||||||
this.listeners.forEach((fn) => fn(status));
|
this.listeners.forEach((fn) => fn(status));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateInputLevel(level: number): void {
|
||||||
|
if (this.status !== RecordingStatus.Recording) return;
|
||||||
|
|
||||||
|
const clampedLevel = Math.max(0, Math.min(1, level));
|
||||||
|
const activeBars = Math.round(clampedLevel * this.meterBars.length);
|
||||||
|
|
||||||
|
this.meterBars.forEach((bar, index) => {
|
||||||
|
bar.toggleClass("nibbleai-meter-bar-active", index < activeBars);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
updateStatusBarItem() {
|
updateStatusBarItem() {
|
||||||
if (this.statusBarItem) {
|
if (!this.statusBarItem) return;
|
||||||
switch (this.status) {
|
|
||||||
case RecordingStatus.Recording:
|
this.statusBarItem.empty();
|
||||||
this.statusBarItem.textContent = "NibbleAI Recording...";
|
this.statusBarItem.removeClass("nibbleai-status-recording");
|
||||||
this.statusBarItem.style.color = "red";
|
this.statusBarItem.removeClass("nibbleai-status-paused");
|
||||||
break;
|
this.statusBarItem.removeClass("nibbleai-status-processing");
|
||||||
case RecordingStatus.Paused:
|
this.statusBarItem.removeClass("nibbleai-status-idle");
|
||||||
this.statusBarItem.textContent = "NibbleAI Paused";
|
this.meterBars = [];
|
||||||
this.statusBarItem.style.color = "yellow";
|
|
||||||
break;
|
switch (this.status) {
|
||||||
case RecordingStatus.Processing:
|
case RecordingStatus.Recording:
|
||||||
this.statusBarItem.textContent = "NibbleAI Processing...";
|
this.statusBarItem.addClass("nibbleai-status-recording");
|
||||||
this.statusBarItem.style.color = "gray";
|
this.statusBarItem.createSpan({ text: "NibbleAI " });
|
||||||
break;
|
this.createInputMeter();
|
||||||
case RecordingStatus.Idle:
|
break;
|
||||||
default:
|
case RecordingStatus.Paused:
|
||||||
this.statusBarItem.textContent = "NibbleAI Idle";
|
this.statusBarItem.addClass("nibbleai-status-paused");
|
||||||
this.statusBarItem.style.color = "green";
|
this.statusBarItem.setText("NibbleAI Paused");
|
||||||
break;
|
break;
|
||||||
}
|
case RecordingStatus.Processing:
|
||||||
|
this.statusBarItem.addClass("nibbleai-status-processing");
|
||||||
|
this.statusBarItem.setText("NibbleAI Processing...");
|
||||||
|
break;
|
||||||
|
case RecordingStatus.Idle:
|
||||||
|
default:
|
||||||
|
this.statusBarItem.addClass("nibbleai-status-idle");
|
||||||
|
this.statusBarItem.setText("NibbleAI Idle");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createInputMeter(): void {
|
||||||
|
if (!this.statusBarItem) return;
|
||||||
|
|
||||||
|
const meter = this.statusBarItem.createSpan({
|
||||||
|
cls: "nibbleai-input-meter",
|
||||||
|
attr: { "aria-label": "Recording input level" },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
this.meterBars.push(
|
||||||
|
meter.createSpan({ cls: "nibbleai-meter-bar" })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Notice, TFile, MarkdownView } from "obsidian";
|
import { Notice, TFile, MarkdownView } from "obsidian";
|
||||||
import { AIService } from "../AIService";
|
import { AIService } from "../AIService";
|
||||||
import { AIProvider, PROVIDER_URLS } from "../SettingsManager";
|
import { resolveChatProvider } from "../ProviderResolver";
|
||||||
import NibbleAI from "main";
|
import NibbleAI from "main";
|
||||||
|
|
||||||
export class TitleGenerator {
|
export class TitleGenerator {
|
||||||
@@ -10,31 +10,6 @@ export class TitleGenerator {
|
|||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveProvider(): { apiKey: string; endpoint: string; provider: AIProvider } | null {
|
|
||||||
const provider = this.plugin.settings.titleGenProvider || "openrouter";
|
|
||||||
let apiKey = "";
|
|
||||||
let endpoint = PROVIDER_URLS[provider] || "";
|
|
||||||
|
|
||||||
switch (provider) {
|
|
||||||
case "openai":
|
|
||||||
apiKey = this.plugin.settings.openAiApiKey;
|
|
||||||
break;
|
|
||||||
case "anthropic":
|
|
||||||
apiKey = this.plugin.settings.anthropicApiKey;
|
|
||||||
break;
|
|
||||||
case "openrouter":
|
|
||||||
apiKey = this.plugin.settings.openRouterApiKey;
|
|
||||||
break;
|
|
||||||
case "custom":
|
|
||||||
apiKey = this.plugin.settings.customApiKey;
|
|
||||||
endpoint = this.plugin.settings.postProcessingUrl || "";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!apiKey || !endpoint) return null;
|
|
||||||
return { apiKey, endpoint, provider };
|
|
||||||
}
|
|
||||||
|
|
||||||
async generateTitle(): Promise<void> {
|
async generateTitle(): Promise<void> {
|
||||||
const activeView =
|
const activeView =
|
||||||
this.plugin.app.workspace.getActiveViewOfType(MarkdownView);
|
this.plugin.app.workspace.getActiveViewOfType(MarkdownView);
|
||||||
@@ -55,7 +30,10 @@ export class TitleGenerator {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = this.resolveProvider();
|
const config = resolveChatProvider(
|
||||||
|
this.plugin.settings,
|
||||||
|
this.plugin.settings.titleGenProvider || "openrouter"
|
||||||
|
);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
new Notice(
|
new Notice(
|
||||||
"⚠️ No API key for selected provider. Check Settings \u2192 NibbleAI \u2192 API Keys."
|
"⚠️ No API key for selected provider. Check Settings \u2192 NibbleAI \u2192 API Keys."
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
resolveTemplate,
|
resolveTemplate,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
import { PostProcessor } from "../PostProcessor";
|
import { PostProcessor } from "../PostProcessor";
|
||||||
|
import { resolveChatProvider } from "../ProviderResolver";
|
||||||
|
|
||||||
export class AudioHandler {
|
export class AudioHandler {
|
||||||
private plugin: NibbleAI;
|
private plugin: NibbleAI;
|
||||||
@@ -16,19 +17,6 @@ export class AudioHandler {
|
|||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPostProcessingApiKey(): string {
|
|
||||||
switch (this.plugin.settings.postProcessingProvider) {
|
|
||||||
case "anthropic":
|
|
||||||
return this.plugin.settings.anthropicApiKey;
|
|
||||||
case "openai":
|
|
||||||
return this.plugin.settings.openAiApiKey;
|
|
||||||
case "openrouter":
|
|
||||||
return this.plugin.settings.openRouterApiKey;
|
|
||||||
case "custom":
|
|
||||||
return this.plugin.settings.customApiKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTranscriptionUrl(): string {
|
private getTranscriptionUrl(): string {
|
||||||
switch (this.plugin.settings.transcriptionProvider) {
|
switch (this.plugin.settings.transcriptionProvider) {
|
||||||
case "groq":
|
case "groq":
|
||||||
@@ -173,8 +161,11 @@ export class AudioHandler {
|
|||||||
|
|
||||||
// Post-process with LLM if enabled
|
// Post-process with LLM if enabled
|
||||||
if (this.plugin.settings.postProcessing) {
|
if (this.plugin.settings.postProcessing) {
|
||||||
const ppApiKey = this.getPostProcessingApiKey();
|
const resolved = resolveChatProvider(
|
||||||
if (!ppApiKey) {
|
this.plugin.settings,
|
||||||
|
this.plugin.settings.postProcessingProvider
|
||||||
|
);
|
||||||
|
if (!resolved) {
|
||||||
new Notice(
|
new Notice(
|
||||||
"✘ Add your post-processing API key in settings"
|
"✘ Add your post-processing API key in settings"
|
||||||
);
|
);
|
||||||
@@ -185,10 +176,10 @@ export class AudioHandler {
|
|||||||
new Notice("Post-processing...");
|
new Notice("Post-processing...");
|
||||||
}
|
}
|
||||||
const processor = new PostProcessor({
|
const processor = new PostProcessor({
|
||||||
apiKey: ppApiKey,
|
apiKey: resolved.apiKey,
|
||||||
model: this.plugin.settings.postProcessingModel,
|
model: this.plugin.settings.postProcessingModel,
|
||||||
url: this.plugin.settings.postProcessingUrl,
|
url: resolved.endpoint,
|
||||||
provider: this.plugin.settings.postProcessingProvider,
|
provider: resolved.provider,
|
||||||
});
|
});
|
||||||
finalText = await processor.process(
|
finalText = await processor.process(
|
||||||
originalText,
|
originalText,
|
||||||
@@ -209,14 +200,17 @@ export class AudioHandler {
|
|||||||
this.plugin.settings.autoGenerateTitle &&
|
this.plugin.settings.autoGenerateTitle &&
|
||||||
this.plugin.settings.createNoteFile
|
this.plugin.settings.createNoteFile
|
||||||
) {
|
) {
|
||||||
const ppApiKey = this.getPostProcessingApiKey();
|
const resolved = resolveChatProvider(
|
||||||
if (ppApiKey) {
|
this.plugin.settings,
|
||||||
|
this.plugin.settings.postProcessingProvider
|
||||||
|
);
|
||||||
|
if (resolved) {
|
||||||
try {
|
try {
|
||||||
const processor = new PostProcessor({
|
const processor = new PostProcessor({
|
||||||
apiKey: ppApiKey,
|
apiKey: resolved.apiKey,
|
||||||
model: this.plugin.settings.postProcessingModel,
|
model: this.plugin.settings.postProcessingModel,
|
||||||
url: this.plugin.settings.postProcessingUrl,
|
url: resolved.endpoint,
|
||||||
provider: this.plugin.settings.postProcessingProvider,
|
provider: resolved.provider,
|
||||||
});
|
});
|
||||||
const title = await processor.process(
|
const title = await processor.process(
|
||||||
finalText,
|
finalText,
|
||||||
|
|||||||
+59
@@ -35,6 +35,65 @@
|
|||||||
height: 14px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Recording Status Meter ── */
|
||||||
|
.nibbleai-status-recording {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nibbleai-status-paused {
|
||||||
|
color: var(--text-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nibbleai-status-processing {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nibbleai-status-idle {
|
||||||
|
color: var(--text-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nibbleai-input-meter {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nibbleai-meter-bar {
|
||||||
|
width: 2px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: currentColor;
|
||||||
|
opacity: 0.25;
|
||||||
|
transition: height 70ms ease-out, opacity 70ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nibbleai-meter-bar-active {
|
||||||
|
height: 12px;
|
||||||
|
opacity: 1;
|
||||||
|
animation: nibbleai-meter-pulse 360ms ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nibbleai-meter-bar:nth-child(2n).nibbleai-meter-bar-active {
|
||||||
|
animation-delay: 80ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nibbleai-meter-bar:nth-child(3n).nibbleai-meter-bar-active {
|
||||||
|
animation-delay: 160ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nibbleai-meter-pulse {
|
||||||
|
from {
|
||||||
|
transform: scaleY(0.55);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Model Browser Suggestion ── */
|
/* ── Model Browser Suggestion ── */
|
||||||
.model-suggestion {
|
.model-suggestion {
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user