anc
Этап 0: каркас Tauri 2, разбор встречи через OpenRouter, стиль skillok.org
- Ядро на Rust: расшифровка и импорт текста, схема разбора со строгой
проверкой ответа модели, клиент OpenRouter (проверка ключа, разбор),
настройки и ключ в хранилище паролей ОС, встречи папками на диске.
- Интерфейс: экраны «Встречи», «Встреча» (расшифровка, конспект, задачи),
«Настройки»; токены, шрифты и кнопки как на skillok.org.
- Промпт разбора prompts/analysis.md и проверочные расшифровки.
- 30 тестов ядра; клиент OpenRouter проверяется против локальной заглушки.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d93e31a
30 сентября 2026 г.
meeting.ts
// Экран «Встреча»: вкладки «Расшифровка», «Конспект», «Задачи» и запуск разбора.
import { api, errorText, formatDate, formatDuration, formatTime, speakerLabel, type Analysis, type MeetingDetail } from "../api";
import { $, busy, esc, list } from "../dom";
export type Tab = "transcript" | "summary" | "tasks";
function jump(d: MeetingDetail, index: number): string {
const seg = d.transcript.segments[index];
if (!seg) return "";
return `<a class="jump" href="#/meeting/${encodeURIComponent(d.meeting.id)}/transcript/${index}">${formatTime(seg.start_ms)}</a>`;
}
function transcriptTab(d: MeetingDetail, target: number | null): string {
return `<section class="box">${d.transcript.segments
.map(
(s, i) => `
<div class="segment${i === target ? " is-target" : ""}" id="seg-${i}">
<span class="at mono">${formatTime(s.start_ms)}</span>
<p><span class="who">${esc(speakerLabel(s.speaker))}:</span> ${esc(s.text)}</p>
</div>`,
)
.join("")}</section>`;
}
function notAnalyzed(): string {
return `<section class="box"><p class="empty">Разбора ещё нет.</p></section>`;
}
function summaryTab(d: MeetingDetail, a: Analysis): string {
const r = a.review;
const review = [
r.achieved.length ? `<h3>Чего добились</h3>${list(r.achieved)}` : "",
r.open.length ? `<h3>Осталось открытым</h3>${list(r.open)}` : "",
r.off_track.length ? `<h3>Где ушли в сторону</h3>${list(r.off_track)}` : "",
`<p><strong>Итог:</strong> ${esc(r.verdict)}</p>`,
].join("");
return `
<section class="prose">
<p>${esc(a.summary)}</p>
<div class="section"><h2>Ревью</h2>${review}</div>
<div class="section"><h2>Решения</h2>${
a.decisions.length
? `<ul>${a.decisions.map((x) => `<li>${esc(x.text)} ${jump(d, x.source_segment)}</li>`).join("")}</ul>`
: `<p class="muted">Явных решений не было.</p>`
}</div>
${a.open_questions.length ? `<div class="section"><h2>Открытые вопросы</h2>${list(a.open_questions)}</div>` : ""}
</section>`;
}
function tasksTab(d: MeetingDetail, a: Analysis): string {
if (!a.tasks.length) return `<section class="box"><p class="empty">Задач не прозвучало.</p></section>`;
return `<section class="box">${a.tasks
.map(
(t) => `
<div class="task">
<p class="text">${esc(t.text)}</p>
<p class="small muted">${esc(t.owner ?? "исполнитель не назван")}${t.due ? ` · ${esc(t.due)}` : ""} · ${jump(d, t.source_segment)}</p>
<p class="quote">${esc(t.quote)}</p>
</div>`,
)
.join("")}</section>`;
}
function cost(d: MeetingDetail): string {
const a = d.meeting.analysis;
if (!a) return "";
const price = a.usage.cost != null ? ` · $${a.usage.cost.toFixed(4)}` : "";
return `<p class="xs muted">Разбор: ${esc(a.model)} · ${esc(formatDate(a.at))} · ${a.usage.prompt_tokens + a.usage.completion_tokens} токенов${price}</p>`;
}
export async function renderMeeting(root: HTMLElement, id: string, tab: Tab, target: number | null = null): Promise<void> {
const d = await api.getMeeting(id);
const a = d.analysis;
const base = `#/meeting/${encodeURIComponent(id)}`;
const tabLink = (key: Tab, label: string, count?: number) =>
`<a href="${base}/${key}"${key === tab ? ` aria-current="page"` : ""}>${label}${
count !== undefined ? ` <span class="ui-nav-count">${count}</span>` : ""
}</a>`;
const body =
tab === "transcript" ? transcriptTab(d, target) : !a ? notAnalyzed() : tab === "summary" ? summaryTab(d, a) : tasksTab(d, a);
root.innerHTML = `
<div class="page-head">
<div>
<h1>${esc(d.meeting.title)}</h1>
<p class="meta small muted">${esc(formatDate(d.meeting.created_at))} · ${esc(formatDuration(d.meeting.duration_ms))} · ${d.meeting.segments} реплик</p>
</div>
<div class="actions">
<button class="ui-btn" data-analyze>${a ? "Разобрать заново" : "Разобрать"}</button>
</div>
</div>
<p class="error" data-error hidden></p>
<nav class="ui-underline-nav" aria-label="Встреча">
${tabLink("transcript", "Расшифровка", d.meeting.segments)}
${tabLink("summary", "Конспект")}
${tabLink("tasks", "Задачи", a ? a.tasks.length : undefined)}
</nav>
${body}
${cost(d)}`;
if (target !== null) document.getElementById(`seg-${target}`)?.scrollIntoView({ block: "center" });
const button = $(root, "[data-analyze]") as HTMLButtonElement;
const error = $(root, "[data-error]");
button.addEventListener("click", async () => {
error.hidden = true;
try {
await busy(button, "Разбираю…", () => api.analyzeMeeting(id));
location.hash = `${base}/summary`;
if (tab === "summary") await renderMeeting(root, id, "summary");
} catch (e) {
error.textContent = errorText(e);
error.hidden = false;
}
});
}