feat: OpenAI-compatible transcriber provider (#5) #6
61
src/main.ts
61
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<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
24
src/obsidian/http.ts
Normal 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
35
src/obsidian/settings.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user