Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
506cf9e
docs: Phase 1 design — segment-aligned model + side-by-side viewer
CreatmanCEO Jun 3, 2026
ccd636d
docs: Phase 1 design — usage records forward-compatible for per-user …
CreatmanCEO Jun 3, 2026
5d04484
docs: Phase 1 implementation plan (16 tasks, TDD)
CreatmanCEO Jun 3, 2026
3601996
Merge branch 'main' into feat/phase1-viewer
CreatmanCEO Jun 3, 2026
6ab4dce
feat: sentence/token segmentation helpers (Intl.Segmenter + NFC)
CreatmanCEO Jun 3, 2026
2ce4bb0
feat: alignment validator (graceful degradation, never throws)
CreatmanCEO Jun 3, 2026
0166df7
feat: TranslationDocument segment builder (blocks->sentences, flat refs)
CreatmanCEO Jun 3, 2026
af5a9f4
feat: LiteLLM batch translate+align with usage/cost capture
CreatmanCEO Jun 3, 2026
e3eab21
feat: per-job usage/cost aggregator (byModel + totals + owner)
CreatmanCEO Jun 3, 2026
41f31bb
feat: pipeline assembly (chunking + buildTranslationDocument, segment…
CreatmanCEO Jun 3, 2026
9f28030
feat: result store + wire batch-aligned pipeline into worker (resultT…
CreatmanCEO Jun 3, 2026
1b87a2f
feat: hardened GET /api/result/:token (token-validated, usage strippe…
CreatmanCEO Jun 3, 2026
a05cf63
feat: ADMIN_KEY-gated /api/admin/usage (per-owner aggregation)
CreatmanCEO Jun 3, 2026
5963666
feat(client): side-by-side viewer with XSS-safe render + sync highlight
CreatmanCEO Jun 3, 2026
6085c4b
feat(client): fetch TranslationDocument on completion and render side…
CreatmanCEO Jun 3, 2026
b07132d
fix(client): direct attachment download (reliable on iOS Safari)
CreatmanCEO Jun 3, 2026
890b683
feat(client): admin usage panel (tokens/cost/model, admin-key gated)
CreatmanCEO Jun 3, 2026
7197bb0
chore(client): drop unused sentenceIndex memo (lint clean); Phase 1 t…
CreatmanCEO Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 33 additions & 16 deletions client/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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(
Expand Down Expand Up @@ -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) => {
Expand All @@ -75,6 +88,8 @@ function App() {

const handleFileUpload = async (file, targetLang) => {
try {
setTranslationDoc(null);
setResultToken(null);
setTranslationState({
status: 'uploading',
progress: 0,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -183,6 +196,10 @@ function App() {
onDownload={handleDownload}
/>
)}

<UsagePanel resultToken={resultToken} />

{translationDoc && <SideBySideViewer doc={translationDoc} />}
</Box>
</Container>
</SnackbarProvider>
Expand Down
14 changes: 11 additions & 3 deletions client/src/App.test.js
Original file line number Diff line number Diff line change
@@ -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(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
expect(
screen.getByText(/Переводчик документов с иврита/i)
).toBeInTheDocument();
});
168 changes: 168 additions & 0 deletions client/src/components/SideBySideViewer.js
Original file line number Diff line number Diff line change
@@ -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<string> 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 (
<React.Fragment key={key}>
{i > 0 ? ' ' : null}
<span
data-side={side}
data-block={block.id}
data-sentence={sentence.id}
data-token={i}
className={isHl ? 'hl' : undefined}
style={{
backgroundColor: isHl ? HL_COLOR : undefined,
borderRadius: 3,
cursor: 'pointer',
}}
onMouseEnter={() => 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}
</span>
</React.Fragment>
);
});
};

const renderPane = (side) => {
const dir = side === 'src' ? 'rtl' : 'ltr';
return (
<Box dir={dir} sx={{ p: 2 }}>
{(doc?.blocks || []).map((block) => (
<Box
key={`${side}:${block.id}`}
data-block-wrapper={block.id}
component="p"
sx={{ my: 1, lineHeight: 1.8 }}
>
{(block.sentences || []).map((sentence) => (
<span
key={`${side}:${block.id}:${sentence.id}`}
data-sentence-wrapper={sentence.id}
// Sentence-level click (when not on a token) highlights the
// entire paired sentence on both sides.
onClick={() => highlightWholeSentence(block.id, sentence)}
style={{
// unicodeBidi: plaintext keeps mixed Hebrew + Latin/numbers
// rendering in correct visual order.
unicodeBidi: 'plaintext',
}}
>
{renderTokens(side, block, sentence)}{' '}
</span>
))}
</Box>
))}
</Box>
);
};

return (
<Paper sx={{ mt: 2, p: 2 }} data-testid="side-by-side-viewer">
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" gutterBottom>
{doc?.sourceLang ? doc.sourceLang.toUpperCase() : 'Source'}
</Typography>
{renderPane('src')}
</Grid>
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" gutterBottom>
{doc?.targetLang ? doc.targetLang.toUpperCase() : 'Target'}
</Typography>
{renderPane('tgt')}
</Grid>
</Grid>
</Paper>
);
}

export default SideBySideViewer;
77 changes: 77 additions & 0 deletions client/src/components/UsagePanel.js
Original file line number Diff line number Diff line change
@@ -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 (
<Paper sx={{ mt: 2, p: 2 }} data-testid="usage-panel">
<Typography variant="subtitle2" gutterBottom>
Обработка (admin)
</Typography>
{!totals ? (
<Typography variant="body2">—</Typography>
) : (
<Box>
<Typography variant="body2">
Токены: in {totals.in} · out {totals.out} · total {totals.total}
</Typography>
<Typography variant="body2">
Стоимость: ${Number(totals.costUsd || 0).toFixed(4)}
</Typography>
<Box sx={{ mt: 1 }}>
{Object.entries(byModel).map(([model, m]) => (
<Typography key={model} variant="caption" component="div" color="text.secondary">
{model} · {m.calls} calls · ${Number(m.costUsd || 0).toFixed(4)}
</Typography>
))}
</Box>
</Box>
)}
</Paper>
);
}

export default UsagePanel;
37 changes: 37 additions & 0 deletions client/src/components/__tests__/SideBySideViewer.test.js
Original file line number Diff line number Diff line change
@@ -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(<SideBySideViewer doc={doc} />);
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:'<img src=x onerror=alert(1)>',
srcTokens:['x'], tgtTokens:['<img','src=x','onerror=alert(1)>'], align:[] }
]}]};
const { container } = render(<SideBySideViewer doc={evil} />);
expect(container.querySelector('img')).toBeNull(); // no element injected
expect(screen.getByText('<img', { exact:false })).toBeInTheDocument(); // literal text present
});

test('hovering a source token highlights the aligned target token', () => {
const { container } = render(<SideBySideViewer doc={doc} />);
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();
});
Loading
Loading