feat: OpenAI-compatible transcriber provider (#5) #6

Merged
aep merged 15 commits from feat/5-openai-transcriber into main 2026-10-10 17:43:23 +03:00
3 changed files with 116 additions and 4 deletions
Showing only changes of commit 4f6f202eda - Show all commits

View File

@ -1,6 +1,59 @@
import { Plugin } from 'obsidian';
import { Notice, Plugin } from 'obsidian';
import { OpenAiTranscriber, readSettings, type OpenAiSettings } from './core/openai';
import { checkNotice, NO_CHECK_AUDIO, pickCheckAudio } from './core/provider-check';
import { obsidianHttp } from './obsidian/http';
import { VoiceNoteSettingTab } from './obsidian/settings';
// Обёртка Obsidian — отдельная подсистема. Пока пустой плагин, чтобы сборка работала.
export default class VoiceNotePlugin extends Plugin {
override async onload(): Promise<void> {}
interface VoiceNoteData {
openai: OpenAiSettings;
}
// Обёртка Obsidian. Очередь и запись подключит issue обёртки; здесь — настройки
// провайдера и команда его проверки.
export default class VoiceNotePlugin extends Plugin {
override settings: VoiceNoteData = { openai: readSettings(undefined) };
private readonly controller = new AbortController();
private transcriber = new OpenAiTranscriber(obsidianHttp, () => this.settings.openai, Math.random);
override async onload(): Promise<void> {
await this.loadSettings();
this.addSettingTab(new VoiceNoteSettingTab(this.app, this));
this.addCommand({
id: 'check-transcriber',
name: 'Проверить провайдер распознавания',
callback: () => void this.checkTranscriber(),
});
}
override onunload(): void {
this.controller.abort();
}
async loadSettings(): Promise<void> {
const data = (await this.loadData()) as { openai?: unknown } | null;
this.settings = { openai: readSettings(data?.openai) };
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
// Только уведомление: заметки, frontmatter и очередь не трогаются.
private async checkTranscriber(): Promise<void> {
const files = this.app.vault.getFiles();
const picked = pickCheckAudio(files.map((f) => f.path));
const file = picked && files.find((f) => f.path === picked.path);
if (!picked || !file) {
new Notice(NO_CHECK_AUDIO);
return;
}
new Notice(`Проверка провайдера: ${picked.path}…`);
try {
const audio = await this.app.vault.readBinary(file);
const result = await this.transcriber.transcribe(audio, picked.mime, this.controller.signal);
new Notice(checkNotice(result), 10_000);
} catch (e) {
new Notice(`Не удалось прочитать ${picked.path}: ${e instanceof Error ? e.message : String(e)}`, 10_000);
}
}
}

24
src/obsidian/http.ts Normal file
View File

@ -0,0 +1,24 @@
import { requestUrl } from 'obsidian';
import type { HttpClient, HttpRequest, HttpResponse } from '../core/ports';
// requestUrl обходит CORS на мобильном. contentType — отдельным параметром,
// иначе boundary multipart может потеряться. throw: false — любой статус это ответ.
export const obsidianHttp: HttpClient = {
async post(req: HttpRequest): Promise<HttpResponse> {
const res = await requestUrl({
url: req.url,
method: 'POST',
headers: req.headers,
contentType: req.contentType,
body: req.body,
throw: false,
});
let text = '';
try {
text = res.text;
} catch {
// Тело не декодируется как текст — классификация пойдёт по статусу.
}
return { status: res.status, text };
},
};

35
src/obsidian/settings.ts Normal file
View File

@ -0,0 +1,35 @@
import { type App, PluginSettingTab, Setting } from 'obsidian';
import type { OpenAiSettings } from '../core/openai';
import type VoiceNotePlugin from '../main';
export class VoiceNoteSettingTab extends PluginSettingTab {
constructor(
app: App,
private readonly plugin: VoiceNotePlugin,
) {
super(app, plugin);
}
override display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl).setName('Распознавание речи').setHeading();
this.field('Адрес сервиса', 'OpenAI-совместимый base URL. По умолчанию AITUNNEL: https://api.aitunnel.ru/v1', 'baseUrl');
this.field('Модель', 'Например, gpt-4o-transcribe', 'model');
this.field('API-ключ', 'Хранится только в data.json плагина и синхронизируется вместе с vault', 'apiKey', true);
this.field('Язык', 'Код языка (ru, en). Пусто — модель определяет язык сама', 'language');
}
private field(name: string, desc: string, key: keyof OpenAiSettings, secret = false): void {
new Setting(this.containerEl)
.setName(name)
.setDesc(desc)
.addText((text) => {
if (secret) text.inputEl.type = 'password';
text.setValue(this.plugin.settings.openai[key]).onChange(async (value) => {
this.plugin.settings.openai[key] = value;
await this.plugin.saveSettings();
});
});
}
}