skillok meeting
d93e31a829c96025e18e4ceed0cf1552df5934c8
Перейти к файлу
Код
meeting srcviewsmeeting.ts
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;
    }
  });
}