// web/screens/review1.jsx — Ревью 1 · Анализ. // Подсасывает живые analysis_words из /v1/analysis?shootID=N и рисует их // по группам Объект / Действие / Роль / Эмоция / Место. const ANA_GROUPS = [ { id: "object", label: "Объект" }, { id: "action", label: "Действие" }, { id: "role", label: "Роль" }, { id: "emotion", label: "Эмоция" }, { id: "place", label: "Место" }, ]; // db.AnalysisWord.GroupKind пишется как "object" / "action" / "role" / "emotion" / "place". function wordsByGroup(words) { const out = {}; for (const g of ANA_GROUPS) out[g.id] = []; for (const w of words || []) { const g = (w.GroupKind || "").toLowerCase(); if (out[g]) out[g].push(w); } // Внутри каждой группы — сортировка по Position. for (const g of ANA_GROUPS) { out[g.id].sort((a, b) => (a.Position || 0) - (b.Position || 0)); } return out; } function Review1PhotoBlock({ photo, allWords, allComments, reloadComments, onAnaChanged, activeJobs }) { const myWords = (allWords || []).filter((w) => w.PhotoID === photo.ID); const grouped = wordsByGroup(myWords); const isFailed = photo.Stage === "failed"; // Два типа комментариев Лены для каждой фотки (см. дизайн §21.4): // "fix" — поправка распознавания // "more" — дополнительная информация // Pikasrv хранит в reviewComments и подмешивает в AnalysePrompt при // «Применить правки» (review.ApplyAll → reAnalyse). // Внимание: review.ListComments (RPC через zenrpc) сериализует поля в // lowerCamelCase: c.photoId / c.commentType / c.body. REST-эндпоинты типа // /v1/photos возвращают UpperCamelCase (ID, Filename). Это давало баг // 2026-06-02: после reload текстовое поле «Поправка распознавания» было // пустым, потому что myComments фильтровал по c.PhotoID (===undefined) // и получал пустой массив. Теперь обращаемся к камелкейс-полям из RPC. const myComments = (allComments || []).filter((c) => c.photoId === photo.ID); const findText = (kind) => { const c = myComments.find((c) => c.commentType === kind); return c ? c.body : ""; }; const [fix, setFix] = React.useState(findText("fix")); const [more, setMore] = React.useState(findText("more")); const [_, toast] = useToasts(); // fixRef — всегда содержит АКТУАЛЬНОЕ значение fix (без stale closure). // ReanalyseButton.run() через getCurrentText() читает именно ref, // чтобы при клике на кнопку даже если textarea не blur'нулась, // мы могли явно upsert'нуть текущий текст до запуска reanalyse. const fixRef = React.useRef(fix); React.useEffect(() => { fixRef.current = fix; }, [fix]); // При появлении свежих comments (например после reload) подтягиваем их — // НО только если у нас нет «висящего» локального ввода. Иначе reload // после reanalyse / параллельный save могли обнулять textarea если // запрос успевал раньше нашего save (баг 2026-06-02). React.useEffect(() => { const server = findText("fix"); // Если локальный fix совпадает с тем что в БД — обновляем (no-op). // Если различается И сервер вернул непустое — берём с сервера (свежее). // Если различается И сервер пустой — оставляем локальный (он или // только что введён, или save ещё не отработал). if (server !== fix && server !== "") { setFix(server); } const moreSrv = findText("more"); if (moreSrv !== more && moreSrv !== "") { setMore(moreSrv); } }, [allComments]); // Сохраняем на blur через атомарный upsert. Раньше делали два запроса // (Delete + Add) — если Add падал, текст пропадал из БД и UI обнулял // textarea (баг 2026-06-02 «при клике на Перераспознать поле очищалось»). // upsertComment делает обе операции в одном handler'е: сначала вставка // новой записи, потом удаление старой; при ошибке вставки старая // НЕ удаляется. const save = async (kind, text) => { const existing = myComments.find((c) => c.commentType === kind); const orig = existing ? existing.body : ""; if (text === orig) return; try { await api.feed.upsertComment(photo.ShootID, photo.ID, kind, text); reloadComments && reloadComments(); } catch (e) { toast("Не удалось сохранить комментарий: " + e.message, "error"); } }; // Голосовой ввод правки распознавания — дописать + сразу сохранить. const appendFix = (t) => { setFix((prev) => { const next = (prev.trim() ? prev.trim() + " " : "") + t; api.feed.upsertComment(photo.ShootID, photo.ID, "fix", next) .then(() => reloadComments && reloadComments()) .catch(() => toast("Не удалось сохранить", "error")); return next; }); }; return (
{photo.YandexPath ? {photo.Filename} : null}
{photo.Filename}
{/* Акцент теперь редактируется в правой колонке как 6-й блок (AccentBlock); здесь он не дублируется. */} {photo.Gist && } {/* Два типа комментариев (дизайн §21.4) — Лена видит распознавание и может поправить ошибки или добавить контекст. Уйдёт в analyser при «Применить правки». */} {!isFailed && ( <>