feat(obsidian): requestUrl adapter, settings tab, provider check command
Refs #5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
This commit is contained in:
parent
129e7c0b40
commit
4f6f202eda
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 — отдельная подсистема. Пока пустой плагин, чтобы сборка работала.
|
interface VoiceNoteData {
|
||||||
export default class VoiceNotePlugin extends Plugin {
|
openai: OpenAiSettings;
|
||||||
override async onload(): Promise<void> {}
|
}
|
||||||
|
|
||||||
|
// Обёртка 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