diff --git a/src/main.ts b/src/main.ts index 358a51f..ebaf5cc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 {} +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 { + 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 { + const data = (await this.loadData()) as { openai?: unknown } | null; + this.settings = { openai: readSettings(data?.openai) }; + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + } + + // Только уведомление: заметки, frontmatter и очередь не трогаются. + private async checkTranscriber(): Promise { + 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); + } + } } diff --git a/src/obsidian/http.ts b/src/obsidian/http.ts new file mode 100644 index 0000000..3710a3d --- /dev/null +++ b/src/obsidian/http.ts @@ -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 { + 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 }; + }, +}; diff --git a/src/obsidian/settings.ts b/src/obsidian/settings.ts new file mode 100644 index 0000000..71c8760 --- /dev/null +++ b/src/obsidian/settings.ts @@ -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(); + }); + }); + } +}