feat: OpenAI-compatible transcriber provider (#5) #6
@ -32,7 +32,7 @@ Core (`src/core/`, issue #1) сохраняет запись и ведёт её
|
||||
|---|---|
|
||||
| `src/core/ports.ts` | + `HttpRequest`, `HttpResponse`, `HttpClient` |
|
||||
| `src/core/multipart.ts` | `buildMultipart(fields, file, boundary) → { body: ArrayBuffer, contentType }`, побайтно через `TextEncoder`/`Uint8Array` |
|
||||
| `src/core/openai.ts` | `OpenAiSettings`, `DEFAULT_OPENAI`, `REQUEST_TIMEOUT_MS`, `readSettings` (trim всех четырёх полей + наложение на умолчания), `classify(status, text)`, `redact(message, apiKey)`, `OpenAiTranscriber` |
|
||||
| `src/core/openai.ts` | `OpenAiSettings`, `DEFAULT_OPENAI`, `REQUEST_TIMEOUT_MS`, `readSettings` (trim всех четырёх полей + наложение на умолчания), `classify(res: HttpResponse)`, `redact(message, { apiKey, boundary })`, `OpenAiTranscriber` |
|
||||
| `src/core/naming.ts` | + `mimeFromExt(ext)` для команды проверки |
|
||||
| `src/obsidian/http.ts` | `HttpClient` на `requestUrl({ url, method: 'POST', headers, contentType, body, throw: false })` |
|
||||
| `src/obsidian/settings.ts` | `PluginSettingTab`: четыре поля, ключ — `inputEl.type = 'password'` |
|
||||
@ -65,7 +65,7 @@ new OpenAiTranscriber(http: HttpClient, settings: () => OpenAiSettings, random:
|
||||
|
||||
Настройки передаются геттером и читаются на каждом вызове: исправленный ключ применяется без пересоздания очереди. Boundary строится из `random` — детерминированно в тестах.
|
||||
|
||||
Поток `transcribe(audio, mime, signal)`: чтение и `trim` настроек → предпроверки (ключ, адрес, 0 байт, отменённый сигнал) → `buildMultipart` → `Promise.race(post, таймер, abort)` → `classify` → `redact` для любой причины. В `finally` снимаются таймер и слушатель `abort`; у промиса `post` висит пустой `.catch`, чтобы поздний reject не стал необработанным.
|
||||
Поток `transcribe(audio, mime, signal)`: чтение и `trim` настроек → предпроверки (ключ, адрес, 0 байт, отменённый сигнал) → `buildMultipart` → `Promise.race(post, таймер, abort)` → `classify` → `redact` для любой причины. В `finally` снимаются таймер и слушатель `abort`; у промиса `post` есть обработчик отклонения (второй аргумент `.then`), который превращает любой сетевой сбой в обычный `TranscribeResult`, поэтому необработанного отклонения не возникает.
|
||||
|
||||
Таймер — глобальный `setTimeout`/`clearTimeout` (есть и в браузере, и в Node), в тестах — `vi.useFakeTimers()`; отдельный порт не нужен.
|
||||
|
||||
|
||||
@ -92,8 +92,8 @@ export function classify(res: HttpResponse): TranscribeResult {
|
||||
// Причина попадает во frontmatter и в заметку: ни ключа, ни тела запроса.
|
||||
export function redact(message: string, secrets: { apiKey: string; boundary?: string }): string {
|
||||
let out = message;
|
||||
const lower = out.toLowerCase();
|
||||
const cuts = [lower.indexOf('content-disposition')];
|
||||
const cdMatch = /content-disposition/i.exec(out);
|
||||
const cuts = [cdMatch ? cdMatch.index : -1];
|
||||
if (secrets.boundary) cuts.push(out.indexOf(`--${secrets.boundary}`), out.indexOf(secrets.boundary));
|
||||
const cut = Math.min(...cuts.filter((i) => i >= 0), out.length);
|
||||
out = out.slice(0, cut);
|
||||
@ -105,7 +105,13 @@ export function redact(message: string, secrets: { apiKey: string; boundary?: st
|
||||
|
||||
const fail = (kind: 'temporary' | 'permanent', message: string): TranscribeResult => ({ ok: false, kind, message });
|
||||
|
||||
const errorText = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
||||
const errorText = (e: unknown): string => {
|
||||
try {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
} catch {
|
||||
return 'неизвестная ошибка';
|
||||
}
|
||||
};
|
||||
|
||||
function partType(mime: string): string {
|
||||
return mime.split(';')[0].trim().toLowerCase() || 'application/octet-stream';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user