diff --git a/client/src/App.js b/client/src/App.js
index a70cc97..2b2c40b 100644
--- a/client/src/App.js
+++ b/client/src/App.js
@@ -5,6 +5,8 @@ import io from 'socket.io-client';
import DocumentUpload from './components/DocumentUpload';
import TranslationProgress from './components/TranslationProgress';
import DocumentPreview from './components/DocumentPreview';
+import SideBySideViewer from './components/SideBySideViewer';
+import UsagePanel from './components/UsagePanel';
// API origin: пусто => тот же origin, что и страница (прод, через Traefik).
// В dev задаётся через client/.env.development (REACT_APP_API_URL=http://localhost:3001).
@@ -20,6 +22,8 @@ function App() {
});
const [socket, setSocket] = React.useState(null);
+ const [translationDoc, setTranslationDoc] = React.useState(null);
+ const [resultToken, setResultToken] = React.useState(null);
// Стабильный идентификатор сессии: события прогресса приходят только в нашу комнату
const sessionId = useMemo(
@@ -56,6 +60,15 @@ function App() {
progress: 100,
documentUrl: data.downloadUrl
}));
+
+ setResultToken(data.resultToken || null);
+
+ if (data.resultToken) {
+ fetch(`${API_URL}/api/result/${data.resultToken}`)
+ .then(r => r.ok ? r.json() : null)
+ .then(doc => { if (doc) setTranslationDoc(doc); })
+ .catch(() => {});
+ }
});
socket.on('translation:error', (data) => {
@@ -75,6 +88,8 @@ function App() {
const handleFileUpload = async (file, targetLang) => {
try {
+ setTranslationDoc(null);
+ setResultToken(null);
setTranslationState({
status: 'uploading',
progress: 0,
@@ -122,26 +137,24 @@ function App() {
}
};
- const handleDownload = async () => {
+ const handleDownload = () => {
if (!translationState.documentUrl) return;
-
- try {
- const response = await fetch(translationState.documentUrl);
- const blob = await response.blob();
- const url = window.URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `translated_${translationState.originalName}`;
- document.body.appendChild(a);
- a.click();
- window.URL.revokeObjectURL(url);
- document.body.removeChild(a);
- } catch (error) {
- console.error('Download error:', error);
- }
+ // Direct anchor to the server's attachment URL (server sends
+ // Content-Disposition: attachment). More reliable than a blob+download on
+ // iOS Safari, which ignores the download attribute for blob: URLs and opens
+ // them inline instead.
+ const a = document.createElement('a');
+ a.href = `${API_URL}${translationState.documentUrl}`;
+ a.download = '';
+ a.rel = 'noopener';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
};
const handleReset = () => {
+ setTranslationDoc(null);
+ setResultToken(null);
setTranslationState({
status: 'idle',
progress: 0,
@@ -183,6 +196,10 @@ function App() {
onDownload={handleDownload}
/>
)}
+
+
+
+ {translationDoc && }
diff --git a/client/src/App.test.js b/client/src/App.test.js
index 1f03afe..1cf9dec 100644
--- a/client/src/App.test.js
+++ b/client/src/App.test.js
@@ -1,8 +1,16 @@
import { render, screen } from '@testing-library/react';
+
+// socket.io-client is mocked so the test never opens a real connection.
+jest.mock('socket.io-client', () => ({
+ __esModule: true,
+ default: () => ({ on: jest.fn(), off: jest.fn(), close: jest.fn() }),
+}));
+
import App from './App';
-test('renders learn react link', () => {
+test('App mounts without crashing', () => {
render();
- const linkElement = screen.getByText(/learn react/i);
- expect(linkElement).toBeInTheDocument();
+ expect(
+ screen.getByText(/Переводчик документов с иврита/i)
+ ).toBeInTheDocument();
});
diff --git a/client/src/components/SideBySideViewer.js b/client/src/components/SideBySideViewer.js
new file mode 100644
index 0000000..8c4f2cc
--- /dev/null
+++ b/client/src/components/SideBySideViewer.js
@@ -0,0 +1,168 @@
+import React, { useState, useCallback } from 'react';
+import { Box, Grid, Paper, Typography } from '@mui/material';
+
+// Build a Set-membership key for a token span.
+const tokenKey = (side, blockId, sentenceId, tokenIdx) =>
+ `${side}:${blockId}:${sentenceId}:${tokenIdx}`;
+
+const HL_COLOR = '#fff59d'; // light yellow highlight
+
+/**
+ * SideBySideViewer
+ * Renders a TranslationDocument as two synchronized panes:
+ * left = source (dir="rtl")
+ * right = target (dir="ltr")
+ *
+ * Security: ALL token/sentence text is rendered as React text children.
+ * dangerouslySetInnerHTML is never used, so any HTML inside a token shows
+ * up as literal text and cannot be injected into the DOM.
+ *
+ * Highlighting: hover/click a token to highlight aligned token(s) on the
+ * other side (word-level when `align` is present, otherwise whole-sentence
+ * fallback). Clicking the sentence wrapper highlights the paired sentence
+ * on both sides.
+ */
+function SideBySideViewer({ doc }) {
+ // Set of highlighted token keys (see tokenKey()).
+ const [highlighted, setHighlighted] = useState(() => new Set());
+
+ // Highlight every token of a sentence on both sides.
+ const highlightWholeSentence = useCallback((blockId, sentence) => {
+ const next = new Set();
+ (sentence.srcTokens || []).forEach((_, i) =>
+ next.add(tokenKey('src', blockId, sentence.id, i))
+ );
+ (sentence.tgtTokens || []).forEach((_, i) =>
+ next.add(tokenKey('tgt', blockId, sentence.id, i))
+ );
+ setHighlighted(next);
+ }, []);
+
+ // Highlight from a single hovered/clicked token.
+ const highlightFromToken = useCallback(
+ (side, blockId, sentence, tokenIdx) => {
+ const next = new Set();
+ // The token itself is always highlighted.
+ next.add(tokenKey(side, blockId, sentence.id, tokenIdx));
+
+ const align = sentence.align;
+ const otherSide = side === 'src' ? 'tgt' : 'src';
+
+ if (!align || align.length === 0) {
+ // Word-level alignment unavailable: fall back to whole paired sentence.
+ highlightWholeSentence(blockId, sentence);
+ // Also re-include the hovered token (already in set above) so the
+ // hovered side keeps its own highlight.
+ return;
+ }
+
+ const fromKey = side === 'src' ? 'src' : 'tgt';
+ const toKey = side === 'src' ? 'tgt' : 'src';
+ align.forEach((group) => {
+ const fromIdxs = group[fromKey] || [];
+ if (fromIdxs.includes(tokenIdx)) {
+ (group[toKey] || []).forEach((j) =>
+ next.add(tokenKey(otherSide, blockId, sentence.id, j))
+ );
+ }
+ });
+
+ setHighlighted(next);
+ },
+ [highlightWholeSentence]
+ );
+
+ const clearHighlight = useCallback(() => {
+ setHighlighted((prev) => (prev.size === 0 ? prev : new Set()));
+ }, []);
+
+ const renderTokens = (side, block, sentence) => {
+ const tokens = side === 'src' ? sentence.srcTokens : sentence.tgtTokens;
+ return (tokens || []).map((tok, i) => {
+ const key = tokenKey(side, block.id, sentence.id, i);
+ const isHl = highlighted.has(key);
+ return (
+
+ {i > 0 ? ' ' : null}
+ highlightFromToken(side, block.id, sentence, i)}
+ onMouseLeave={clearHighlight}
+ onClick={(e) => {
+ // Token click acts like hover-highlight; stop it from also
+ // triggering the sentence-level click handler.
+ e.stopPropagation();
+ highlightFromToken(side, block.id, sentence, i);
+ }}
+ >
+ {/* Rendered as a text child => XSS-safe, never HTML. */}
+ {tok}
+
+
+ );
+ });
+ };
+
+ const renderPane = (side) => {
+ const dir = side === 'src' ? 'rtl' : 'ltr';
+ return (
+
+ {(doc?.blocks || []).map((block) => (
+
+ {(block.sentences || []).map((sentence) => (
+ highlightWholeSentence(block.id, sentence)}
+ style={{
+ // unicodeBidi: plaintext keeps mixed Hebrew + Latin/numbers
+ // rendering in correct visual order.
+ unicodeBidi: 'plaintext',
+ }}
+ >
+ {renderTokens(side, block, sentence)}{' '}
+
+ ))}
+
+ ))}
+
+ );
+ };
+
+ return (
+
+
+
+
+ {doc?.sourceLang ? doc.sourceLang.toUpperCase() : 'Source'}
+
+ {renderPane('src')}
+
+
+
+ {doc?.targetLang ? doc.targetLang.toUpperCase() : 'Target'}
+
+ {renderPane('tgt')}
+
+
+
+ );
+}
+
+export default SideBySideViewer;
diff --git a/client/src/components/UsagePanel.js b/client/src/components/UsagePanel.js
new file mode 100644
index 0000000..dcf969d
--- /dev/null
+++ b/client/src/components/UsagePanel.js
@@ -0,0 +1,77 @@
+import React from 'react';
+import { Paper, Typography, Box } from '@mui/material';
+
+// Re-derived here so the component is self-contained (same value as App.js).
+const API_URL = process.env.REACT_APP_API_URL || '';
+
+/**
+ * UsagePanel — admin-only cost/usage readout for a finished translation job.
+ *
+ * Security: renders NOTHING unless an admin key is present (localStorage
+ * 'adminKey' or ?adminKey= query param). The public /api/result payload has
+ * usage stripped, so cost data is fetched from the admin-gated endpoint and
+ * must never be shown to public users.
+ */
+function UsagePanel({ resultToken }) {
+ const params = new URLSearchParams(window.location.search);
+ const adminKey = params.get('adminKey') || localStorage.getItem('adminKey');
+
+ const [job, setJob] = React.useState(null);
+
+ React.useEffect(() => {
+ if (!adminKey || !resultToken) {
+ setJob(null);
+ return;
+ }
+ let cancelled = false;
+ fetch(`${API_URL}/api/admin/usage`, { headers: { 'x-admin-key': adminKey } })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((data) => {
+ if (cancelled || !data || !Array.isArray(data.jobs)) return;
+ const match = data.jobs.find((j) => j.token === resultToken) || null;
+ setJob(match);
+ })
+ .catch(() => {
+ // Errors → render nothing for this fetch.
+ if (!cancelled) setJob(null);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [adminKey, resultToken]);
+
+ // Gate: no admin key or no token → render nothing (public users).
+ if (!adminKey || !resultToken) return null;
+
+ const totals = job && job.totals ? job.totals : null;
+ const byModel = job && job.byModel ? job.byModel : {};
+
+ return (
+
+
+ Обработка (admin)
+
+ {!totals ? (
+ —
+ ) : (
+
+
+ Токены: in {totals.in} · out {totals.out} · total {totals.total}
+
+
+ Стоимость: ${Number(totals.costUsd || 0).toFixed(4)}
+
+
+ {Object.entries(byModel).map(([model, m]) => (
+
+ {model} · {m.calls} calls · ${Number(m.costUsd || 0).toFixed(4)}
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+export default UsagePanel;
diff --git a/client/src/components/__tests__/SideBySideViewer.test.js b/client/src/components/__tests__/SideBySideViewer.test.js
new file mode 100644
index 0000000..f6a5c2a
--- /dev/null
+++ b/client/src/components/__tests__/SideBySideViewer.test.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import { render, screen, fireEvent, within } from '@testing-library/react';
+import SideBySideViewer from '../SideBySideViewer';
+
+const doc = {
+ schemaVersion:1, sourceLang:'he', targetLang:'en',
+ blocks:[{ id:'b0', type:'paragraph', sentences:[
+ { id:'b0s0', source:'שלום עולם', target:'Hello world',
+ srcTokens:['שלום','עולם'], tgtTokens:['Hello','world'],
+ align:[{src:[0],tgt:[0]},{src:[1],tgt:[1]}] }
+ ]}]
+};
+
+test('renders source and target tokens', () => {
+ render();
+ expect(screen.getByText('שלום')).toBeInTheDocument();
+ expect(screen.getByText('Hello')).toBeInTheDocument();
+});
+
+test('XSS: a token with HTML is rendered as literal text, not injected', () => {
+ const evil = { schemaVersion:1, sourceLang:'he', targetLang:'en', blocks:[{ id:'b0', type:'paragraph', sentences:[
+ { id:'b0s0', source:'x', target:'
',
+ srcTokens:['x'], tgtTokens:['
'], align:[] }
+ ]}]};
+ const { container } = render();
+ expect(container.querySelector('img')).toBeNull(); // no element injected
+ expect(screen.getByText('
{
+ const { container } = render();
+ const srcTok0 = container.querySelector('[data-side="src"][data-block="b0"][data-sentence="b0s0"][data-token="0"]');
+ fireEvent.mouseEnter(srcTok0);
+ const tgtTok0 = container.querySelector('[data-side="tgt"][data-block="b0"][data-sentence="b0s0"][data-token="0"]');
+ // highlighted token gets a non-empty backgroundColor (or hl class)
+ expect(tgtTok0.className.includes('hl') || tgtTok0.style.backgroundColor).toBeTruthy();
+});
diff --git a/client/src/components/__tests__/UsagePanel.test.js b/client/src/components/__tests__/UsagePanel.test.js
new file mode 100644
index 0000000..464ff4b
--- /dev/null
+++ b/client/src/components/__tests__/UsagePanel.test.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import UsagePanel from '../UsagePanel';
+
+afterEach(() => { localStorage.clear(); jest.restoreAllMocks(); });
+
+test('renders nothing without admin key', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+});
+
+test('shows usage when admin key present', async () => {
+ localStorage.setItem('adminKey', 'secret');
+ global.fetch = jest.fn(async () => ({ ok:true, json: async () => ({
+ byOwner:{}, jobs:[{ token:'tok1', owner:'anon', jobId:'1',
+ totals:{calls:2,in:150,out:50,total:200,costUsd:0.0015},
+ byModel:{ 'gemini/gemini-2.0-flash': {calls:2,in:150,out:50,total:200,costUsd:0.0015} } }] }) }));
+ render();
+ await waitFor(() => expect(screen.getByText(/200/)).toBeInTheDocument());
+ expect(screen.getByText(/gemini/i)).toBeInTheDocument();
+});
diff --git a/docs/plans/2026-06-03-hebrew-translator-phase1-design.md b/docs/plans/2026-06-03-hebrew-translator-phase1-design.md
new file mode 100644
index 0000000..28055a9
--- /dev/null
+++ b/docs/plans/2026-06-03-hebrew-translator-phase1-design.md
@@ -0,0 +1,88 @@
+# Hebrew Translator — Phase 1 Design: segment-aligned model + side-by-side viewer
+
+**Date:** 2026-06-03
+**Status:** Approved (design-lock)
+**Builds on:** `2026-06-01-hebrew-translator-production-design.md` (Phase 0 live).
+
+## Scope (locked)
+Phase 1 delivers the **alignment foundation + a synchronized side-by-side viewer**, plus per-document budget tracking and the iOS download fix. Pixel-faithful file output (PDF overlay, DOCX in-place) is the **next** phase.
+
+| Decision | Value |
+|----------|-------|
+| Formats (fidelity files) | both PDF+DOCX — but **next phase**; Phase 1 = viewer |
+| Highlight granularity | all three: block → sentence → word |
+| Word alignment source | LLM, **precomputed in the batch pass** (not lazy) — required by <1s click latency |
+| Viewer rendering | HTML panes (source RTL / target LTR), not pixel-faithful |
+| Budget tracking | per-document tokens + cost + actual model, admin-gated |
+
+## 1. Foundation — `TranslationDocument` (canonical intermediate)
+Pipeline becomes extract → structure → batch-translate(+align) → `TranslationDocument` JSON:
+```jsonc
+{ schemaVersion: 1, sourceLang, targetLang,
+ usage: { byModel: { "gemini-2.0-flash": {calls,in,out,total,costUsd} }, totals: {...} },
+ blocks: [ { id:"b1", type:"paragraph", sentences:[
+ { id:"b1s1", source:"שלום עולם", target:"Hello world",
+ srcTokens:["שלום","עולם"], tgtTokens:["Hello","world"],
+ align:[ {src:[0],tgt:[0]}, {src:[1],tgt:[1]} ] } ] } ] }
+```
+
+**Pipeline:**
+1. Extract (pdf-parse / mammoth) → blocks.
+2. Split blocks → sentences via `Intl.Segmenter` (sentence granularity); NFC-normalize Hebrew first.
+3. Tokenize source per sentence via `Intl.Segmenter` (word granularity) → `srcTokens` (server is the source of truth for tokens).
+4. **Batch translate + align**: group sentences into chunks (≈15-20 segments / ~1.5k tokens, respecting block boundaries). One LLM call per chunk returns `[{id, target, align}, …]`. A few calls per document, not per-sentence.
+5. Assemble `TranslationDocument`; store by random token (TTL) in the result store.
+
+**Why precompute align:** <1s click latency forbids per-click LLM round-trips (first click would miss cache). Block/sentence highlight is free (shared ids, 1:1); word-align rides along in the same batch calls.
+
+**Caching:** per chunk/sentence, key = `model + promptVersion + schemaVersion + langs + hash(text)`. Distinct namespace from Phase-0 string cache (value shape changed → collision = crash).
+
+## 2. Viewer (frontend — new result screen)
+- API: `GET /api/result/:token` → `TranslationDocument` (minus admin-only `usage`). `Cache-Control: private, no-store`.
+- Two scrollable panes: **left = source** (`dir="rtl"`, `unicode-bidi: plaintext` per segment), **right = target** (LTR). Each word is a span carrying `data-block / data-sentence / data-token`.
+- Interaction (bidirectional, from either pane):
+ - hover/click a **word** → highlight aligned word(s) in the other pane via `align`;
+ - click a **sentence** → highlight the paired sentence both sides;
+ - **block** → highlight the block.
+- **Rendered only via React text nodes** — never `dangerouslySetInnerHTML` for document content (XSS).
+- Client renders the server's `srcTokens`/`tgtTokens` **as-is** (never re-tokenizes) so align indices line up.
+- Keeps a **Download** button.
+
+## 3. Budget tracking (admin)
+- `LiteLLMProvider` returns `usage {model, promptTokens, completionTokens, totalTokens, costUsd}` from the response (`usage` + `x-litellm-response-cost` / `_hidden_params`). Capture the **actual** model (fallback-aware), not the alias.
+- Worker aggregates across batch calls → `usage.byModel` + totals, stored with the result.
+- Admin: per-document panel (`model · in/out tokens · $cost`) + `GET /api/admin/usage` (recent jobs). Gated by `ADMIN_KEY` env (minimal gate until real auth exists). Usage stripped from the public result payload.
+- **Forward-compatible for multi-user:** each usage record carries an `owner` field (`anon`/null now; a real userId once auth exists) plus `jobId` and timestamp, stored append-only so a **future admin panel can aggregate consumption per user / per period** when accounts arrive. The admin usage endpoint is built to group by `owner` (today: one bucket). No user system in Phase 1 — only the data shape + aggregation are future-proofed (YAGNI: no auth UI now).
+
+## 4. iOS download fix
+Replace blob + `a.download` with a direct anchor to the attachment URL (server already sends `Content-Disposition: attachment`) — reliable on iOS Safari.
+
+## 5. Error handling (graceful degradation)
+- Malformed batch JSON → per-item parse; bad item degrades to sentence-level (no crash), job continues.
+- align out-of-range / not covering tokens → drop word-level for that sentence; sentence/block highlight still works.
+- Sentence segmentation failure → whole block as one sentence.
+- Keep Phase-0 guardrails (refusal/length-ratio).
+
+## 6. Testing
+Unit: `Intl.Segmenter` sentence/word wrappers, align validator (out-of-range→drop), `TranslationDocument` builder, usage aggregator. Adapter: structured-output mock (incl. malformed item). Integration: fixture → valid `TranslationDocument`. Frontend: clicking a word highlights the paired token; XSS fixture (text with `