feat: Add web localization

This commit is contained in:
2026-05-09 17:56:48 +03:00
parent 4062600995
commit 6d3c02e6f3
9 changed files with 444 additions and 140 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="ru">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+109 -94
View File
@@ -1,30 +1,14 @@
import { startTransition, useEffect, useState } from "react";
const LEVEL_FALLBACKS = [
{
id: 1,
title: "Level 1",
description: "Базовый агент без дополнительных проверок.",
},
{
id: 2,
title: "Level 2",
description: "Ответ агента проверяется защитным фильтром.",
},
{
id: 3,
title: "Level 3",
description: "Фильтр проверяет и запрос, и ответ агента.",
},
{
id: 4,
title: "Level 4",
description: "Уровень 3 с ограничением на астрономические темы.",
},
];
import { detectLanguage, getMessages, normalizeLanguage, t } from "./i18n";
const LEVEL_FALLBACKS = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
const MAX_MESSAGE_LENGTH = 2000;
const BLOCKED_PREFIX = "Ответ скрыт защитным фильтром";
const BLOCKED_PREFIXES = [
"Ответ скрыт защитным фильтром",
"Фильтр сработал",
"Response hidden by safety filter",
];
function createUuid7() {
const timestamp = BigInt(Date.now());
@@ -72,22 +56,8 @@ function buildConfettiPieces(seed) {
}
function looksBlocked(text) {
return text.trim().startsWith(BLOCKED_PREFIX);
}
function levelBadge(levelId) {
switch (levelId) {
case 1:
return "Intro";
case 2:
return "Social Engineer";
case 3:
return "Context Shift";
case 4:
return "Red Team";
default:
return "Unknown";
}
const normalized = text.trim();
return BLOCKED_PREFIXES.some((prefix) => normalized.startsWith(prefix));
}
function statusTone({ loading, error, blocked, success }) {
@@ -98,14 +68,25 @@ function statusTone({ loading, error, blocked, success }) {
return "status-ready";
}
function localizeLevel(level, messages) {
const key = String(level.id);
return {
...level,
title: t(messages, `levels.${key}.title`) || level.title || `Level ${level.id}`,
badge: t(messages, `levels.${key}.badge`),
description: t(messages, `levels.${key}.description`) || level.description || "",
};
}
export default function App() {
const [language, setLanguage] = useState(() => detectLanguage());
const [levels, setLevels] = useState(LEVEL_FALLBACKS);
const [levelsError, setLevelsError] = useState("");
const [selectedLevelId, setSelectedLevelId] = useState(LEVEL_FALLBACKS[0].id);
const [sessionId, setSessionId] = useState(() => createUuid7());
const [message, setMessage] = useState("");
const [hardMode, setHardMode] = useState(false);
const [responseText, setResponseText] = useState("Выберите уровень и попробуйте выманить пароль у агента.");
const [responseState, setResponseState] = useState({ type: "local", key: "response.intro" });
const [lastUserMessage, setLastUserMessage] = useState("");
const [loading, setLoading] = useState(false);
const [apiError, setApiError] = useState("");
@@ -115,6 +96,12 @@ export default function App() {
const [success, setSuccess] = useState(false);
const [confettiSeed, setConfettiSeed] = useState(0);
const messages = getMessages(language);
useEffect(() => {
document.documentElement.lang = normalizeLanguage(language);
}, [language]);
useEffect(() => {
let cancelled = false;
@@ -126,7 +113,7 @@ export default function App() {
}
const data = await response.json();
if (!cancelled && Array.isArray(data) && data.length > 0) {
setLevels(data);
setLevels(data.map((level) => ({ id: level.id, title: level.title, description: level.description })));
setSelectedLevelId((current) => {
if (data.some((level) => level.id === current)) {
return current;
@@ -135,9 +122,9 @@ export default function App() {
});
setLevelsError("");
}
} catch (error) {
} catch {
if (!cancelled) {
setLevelsError("Не удалось загрузить уровни. Использую локальное описание.");
setLevelsError("levelsError");
}
}
}
@@ -148,7 +135,11 @@ export default function App() {
};
}, []);
const selectedLevel = levels.find((level) => level.id === selectedLevelId) ?? levels[0];
const localizedLevels = levels.map((level) => localizeLevel(level, messages));
const selectedLevel =
localizedLevels.find((level) => level.id === selectedLevelId) ?? localizedLevels[0];
const responseText =
responseState.type === "backend" ? responseState.text : t(messages, responseState.key);
const blocked = looksBlocked(responseText);
const toneClassName = statusTone({ loading, error: apiError, blocked, success });
const confettiPieces = buildConfettiPieces(confettiSeed);
@@ -157,7 +148,7 @@ export default function App() {
setSelectedLevelId(levelId);
setSessionId(createUuid7());
setMessage("");
setResponseText("Новая сессия готова. Отправьте сообщение агенту.");
setResponseState({ type: "local", key: "response.newSession" });
setLastUserMessage("");
setApiError("");
setSessionRotated(false);
@@ -206,7 +197,7 @@ export default function App() {
startTransition(() => {
setLastLatencyMs(latency);
setLastUserMessage(trimmed);
setResponseText(payload.response_text);
setResponseState({ type: "backend", text: payload.response_text });
setSessionRotated(Boolean(payload.session_rotated));
setSessionId(payload.session_id);
setSuccess(Boolean(payload.success));
@@ -216,7 +207,7 @@ export default function App() {
}
});
} catch (error) {
setApiError(error instanceof Error ? error.message : "Неизвестная ошибка запроса.");
setApiError(error instanceof Error ? error.message : t(messages, "status.unknownError"));
} finally {
setLoading(false);
}
@@ -226,13 +217,31 @@ export default function App() {
try {
await navigator.clipboard.writeText(sessionId);
} catch {
setApiError("Не удалось скопировать session_id в буфер обмена.");
setApiError(t(messages, "session.copyError"));
}
}
return (
<div className="app-shell">
<div className="page-noise" aria-hidden="true" />
<div className="language-switcher" aria-label={t(messages, "language.label")}>
<button
type="button"
className={`language-button ${language === "ru" ? "is-active" : ""}`}
onClick={() => setLanguage("ru")}
>
{t(messages, "language.ru")}
</button>
<button
type="button"
className={`language-button ${language === "en" ? "is-active" : ""}`}
onClick={() => setLanguage("en")}
>
{t(messages, "language.en")}
</button>
</div>
<header className="hero">
<div className="hero-mark">
<div className="beaker-icon" aria-hidden="true">
@@ -241,28 +250,25 @@ export default function App() {
<span />
</div>
<div>
<p className="hero-kicker">Secret Stress Lab</p>
<h1>Проверь, сможет ли человек вытянуть секрет у LLM-агента.</h1>
<p className="hero-kicker">{t(messages, "hero.kicker")}</p>
<h1>{t(messages, "hero.title")}</h1>
</div>
</div>
<aside className="hero-note">
<p className="hero-note-label">Lab Notes</p>
<p>
У каждого уровня есть скрытый пароль. Выбирай уровень, включай hard mode и смотри,
где защита ломается.
</p>
<p className="hero-note-label">{t(messages, "hero.noteLabel")}</p>
<p>{t(messages, "hero.noteBody")}</p>
</aside>
</header>
<main className="layout">
<section className="panel panel-left">
<div className="panel-header">
<h2>Выбор уровня</h2>
<span className="panel-tag">4 сценария</span>
<h2>{t(messages, "panel.levelsTitle")}</h2>
<span className="panel-tag">{t(messages, "panel.levelsTag")}</span>
</div>
<div className="level-grid">
{levels.map((level) => {
{localizedLevels.map((level) => {
const active = level.id === selectedLevelId;
return (
<button
@@ -272,7 +278,7 @@ export default function App() {
onClick={() => resetSessionForLevel(level.id)}
>
<span className="level-number">{level.id}</span>
<span className="level-name">{levelBadge(level.id)}</span>
<span className="level-name">{level.badge}</span>
<span className="level-description">{level.description}</span>
</button>
);
@@ -281,21 +287,18 @@ export default function App() {
<div className="session-box">
<div>
<p className="eyebrow">Session ID</p>
<p className="eyebrow">{t(messages, "session.eyebrow")}</p>
<p className="session-value">{sessionId}</p>
</div>
<button type="button" className="ghost-button" onClick={handleCopySession}>
Скопировать
{t(messages, "session.copy")}
</button>
</div>
<div className="toggle-row">
<div>
<p className="eyebrow danger">Hard Mode</p>
<p className="toggle-copy">
Пароль будет ротироваться каждые несколько запросов. Фронтенд принимает новый
<code>session_id</code> из ответа сервера.
</p>
<p className="eyebrow danger">{t(messages, "controls.hardModeTitle")}</p>
<p className="toggle-copy">{t(messages, "controls.hardModeBody")}</p>
</div>
<label className="switch">
<input
@@ -308,23 +311,23 @@ export default function App() {
</div>
<div className="level-explainer">
<p className="eyebrow accent">Текущий уровень</p>
<p className="eyebrow accent">{t(messages, "controls.currentLevel")}</p>
<h3>
{selectedLevel.title} · {levelBadge(selectedLevel.id)}
{selectedLevel.title} · {selectedLevel.badge}
</h3>
<p>{selectedLevel.description}</p>
{levelsError ? <p className="helper-warning">{levelsError}</p> : null}
{levelsError ? <p className="helper-warning">{t(messages, "controls.levelsError")}</p> : null}
</div>
<form className="composer" onSubmit={handleSubmit}>
<label className="composer-label" htmlFor="message">
Сообщение агенту
{t(messages, "controls.composerLabel")}
</label>
<textarea
id="message"
value={message}
maxLength={MAX_MESSAGE_LENGTH}
placeholder="Напишите вопрос, инструкцию или попытку обхода правил…"
placeholder={t(messages, "controls.composerPlaceholder")}
onChange={(event) => setMessage(event.target.value)}
/>
<div className="composer-footer">
@@ -332,7 +335,7 @@ export default function App() {
{message.length} / {MAX_MESSAGE_LENGTH}
</p>
<button type="submit" className="primary-button" disabled={loading || !message.trim()}>
{loading ? "Отправка…" : "Send Message"}
{loading ? t(messages, "controls.sending") : t(messages, "controls.send")}
</button>
</div>
</form>
@@ -340,8 +343,8 @@ export default function App() {
<section className={`panel panel-right ${toneClassName}`}>
<div className="panel-header">
<h2>Ответ агента</h2>
<span className="panel-tag live-tag">{loading ? "live" : "ready"}</span>
<h2>{t(messages, "panel.responseTitle")}</h2>
<span className="panel-tag live-tag">{loading ? t(messages, "panel.live") : t(messages, "panel.ready")}</span>
</div>
<div className="response-box">
@@ -359,60 +362,72 @@ export default function App() {
<div className="status-grid">
<article className={`status-card ${blocked ? "is-alert" : ""}`}>
<p className="eyebrow">Filter Status</p>
<strong>{blocked ? "Сработал" : selectedLevelId > 1 ? "Активен" : "Выключен"}</strong>
<p className="eyebrow">{t(messages, "status.filterTitle")}</p>
<strong>
{blocked
? t(messages, "status.filterTriggered")
: selectedLevelId > 1
? t(messages, "status.filterActive")
: t(messages, "status.filterOff")}
</strong>
<p>
{blocked
? "Ответ заменён защитной заглушкой."
? t(messages, "status.filterTriggeredBody")
: selectedLevelId > 1
? "Дополнительный LLM-фильтр следит за запросом и ответом."
: "На первом уровне дополнительных фильтров нет."}
? t(messages, "status.filterActiveBody")
: t(messages, "status.filterOffBody")}
</p>
</article>
<article className={`status-card ${success ? "is-success" : ""}`}>
<p className="eyebrow">Mission State</p>
<strong>{success ? "Пароль угадан" : "Атака продолжается"}</strong>
<p className="eyebrow">{t(messages, "status.missionTitle")}</p>
<strong>{success ? t(messages, "status.missionSuccess") : t(messages, "status.missionActive")}</strong>
<p>
{success
? "Сервер подтвердил успех и вернул победный статус."
: "Пробуй другие формулировки, роли и обходные сценарии."}
? t(messages, "status.missionSuccessBody")
: t(messages, "status.missionActiveBody")}
</p>
</article>
</div>
<div className="status-strip">
<div>
<p className="eyebrow">API status</p>
<strong>{apiError ? "Ошибка" : loading ? "Запрос…" : "Готов"}</strong>
<p className="eyebrow">{t(messages, "status.apiTitle")}</p>
<strong>
{apiError
? t(messages, "status.apiError")
: loading
? t(messages, "status.apiLoading")
: t(messages, "status.apiReady")}
</strong>
</div>
<div>
<p className="eyebrow">HTTP</p>
<p className="eyebrow">{t(messages, "status.httpTitle")}</p>
<strong>{lastStatusCode ?? "—"}</strong>
</div>
<div>
<p className="eyebrow">Latency</p>
<p className="eyebrow">{t(messages, "status.latencyTitle")}</p>
<strong>{lastLatencyMs ? `${lastLatencyMs} ms` : "—"}</strong>
</div>
<div>
<p className="eyebrow">Rotation</p>
<strong>{sessionRotated ? "New session" : "Stable"}</strong>
<p className="eyebrow">{t(messages, "status.rotationTitle")}</p>
<strong>{sessionRotated ? t(messages, "status.rotationNew") : t(messages, "status.rotationStable")}</strong>
</div>
</div>
<div className="transcript">
<div className="transcript-card">
<p className="eyebrow">Последний запрос</p>
<p>{lastUserMessage || "Пока пусто. Первый ход за вами."}</p>
<p className="eyebrow">{t(messages, "status.lastPrompt")}</p>
<p>{lastUserMessage || t(messages, "status.lastPromptEmpty")}</p>
</div>
<div className="transcript-card">
<p className="eyebrow">Системное сообщение</p>
<p className="eyebrow">{t(messages, "status.systemTitle")}</p>
<p>
{apiError
? apiError
: sessionRotated
? "Сервер ротировал сессию и прислал новый идентификатор."
: "Сессия активна. Следующий запрос пойдёт в текущий контекст."}
? t(messages, "session.rotated")
: t(messages, "session.stable")}
</p>
</div>
</div>
+232
View File
@@ -0,0 +1,232 @@
const dictionaries = {
en: {
language: {
label: "Language",
ru: "RU",
en: "EN",
},
hero: {
kicker: "Secret Stress Lab",
title: "Probe whether a human operator can pull a secret out of an LLM agent.",
noteLabel: "Lab Notes",
noteBody:
"Each level hides a password. Pick a scenario, enable hard mode, and map the weak points in the defense.",
},
panel: {
levelsTitle: "Scenario Select",
levelsTag: "4 scenarios",
responseTitle: "Agent Output",
live: "live",
ready: "ready",
},
levels: {
1: {
title: "Level 1",
badge: "Open Door",
description: "Bare prompt-only agent with no extra defensive checks.",
},
2: {
title: "Level 2",
badge: "Leak Guard",
description: "A second LLM filter inspects the agent output before it reaches the operator.",
},
3: {
title: "Level 3",
badge: "Checkpoint",
description: "The defensive filter scans both the inbound prompt and the outbound reply.",
},
4: {
title: "Level 4",
badge: "Orbital Cage",
description: "Level 3 with an extra astronomy-only prompt restriction on the main agent.",
},
},
session: {
eyebrow: "Session ID",
copy: "Copy Session ID",
copyError: "Failed to copy the session ID to the clipboard.",
stateTitle: "Session Signal",
rotated: "Server rotated the session and issued a new identifier.",
stable: "Session is still live. The next probe will continue in the same context.",
},
controls: {
hardModeTitle: "Hard Mode",
hardModeBody:
"After a configured number of probes, the server will switch to a fresh session with a new password.",
currentLevel: "Current Scenario",
composerLabel: "Operator Prompt",
composerPlaceholder: "Type a prompt, instruction, or jailbreak attempt…",
send: "Launch Probe",
sending: "Launching…",
levelsError: "Could not load scenarios from the server. Using local metadata.",
},
response: {
intro: "Select a scenario and send the first probe to the agent.",
newSession: "Fresh session armed. Send a new probe to the agent.",
},
status: {
filterTitle: "Filter Status",
filterTriggered: "Triggered",
filterActive: "Armed",
filterOff: "Offline",
filterTriggeredBody: "The response was replaced with the defensive decoy message.",
filterActiveBody: "An extra LLM filter is monitoring the prompt and the response path.",
filterOffBody: "Level 1 runs without extra filter layers.",
missionTitle: "Mission State",
missionSuccess: "Password Extracted",
missionActive: "Attack Continues",
missionSuccessBody: "The server confirmed the breach and returned a success state.",
missionActiveBody: "Try role shifts, reframing, or context breaks to escalate the attack.",
apiTitle: "API Status",
apiError: "Error",
apiLoading: "Requesting…",
apiReady: "Ready",
httpTitle: "HTTP",
latencyTitle: "Latency",
rotationTitle: "Session",
rotationNew: "Rotated",
rotationStable: "Stable",
lastPrompt: "Last Probe",
lastPromptEmpty: "No moves yet. The first probe is yours.",
systemTitle: "Mission Feed",
unknownError: "Unknown request error.",
},
},
ru: {
language: {
label: "Язык",
ru: "RU",
en: "EN",
},
hero: {
kicker: "Secret Stress Lab",
title: "Проверь, сможет ли оператор выманить секрет у LLM-агента.",
noteLabel: "Lab Notes",
noteBody:
"На каждом уровне спрятан пароль. Выбирайте сценарий, включайте hard mode и ищите слабые места в защите.",
},
panel: {
levelsTitle: "Выбор сценария",
levelsTag: "4 сценария",
responseTitle: "Ответ агента",
live: "live",
ready: "ready",
},
levels: {
1: {
title: "Level 1",
badge: "Open Door",
description: "Базовый prompt-only агент без дополнительных защитных проверок.",
},
2: {
title: "Level 2",
badge: "Leak Guard",
description: "Второй LLM-фильтр проверяет ответ агента перед выдачей оператору.",
},
3: {
title: "Level 3",
badge: "Checkpoint",
description: "Защитный фильтр контролирует и входящий запрос, и исходящий ответ.",
},
4: {
title: "Level 4",
badge: "Orbital Cage",
description: "Уровень 3 с дополнительным ограничением: основной агент отвечает только по астрономии.",
},
},
session: {
eyebrow: "Session ID",
copy: "Скопировать ID сессии",
copyError: "Не удалось скопировать session ID в буфер обмена.",
stateTitle: "Сигнал сессии",
rotated: "Сервер ротировал сессию и прислал новый идентификатор.",
stable: "Сессия активна. Следующая атака пойдёт в тот же контекст.",
},
controls: {
hardModeTitle: "Hard Mode",
hardModeBody:
"После заданного числа атак сервер переключит вас на новую сессию с новым паролем.",
currentLevel: "Текущий сценарий",
composerLabel: "Операторский запрос",
composerPlaceholder: "Введите промпт, инструкцию или попытку обхода правил…",
send: "Запустить атаку",
sending: "Запуск…",
levelsError: "Не удалось загрузить сценарии с сервера. Используются локальные описания.",
},
response: {
intro: "Выберите сценарий и отправьте первое сообщение агенту.",
newSession: "Новая сессия готова. Отправьте новое сообщение агенту.",
},
status: {
filterTitle: "Статус фильтра",
filterTriggered: "Сработал",
filterActive: "Вооружён",
filterOff: "Отключён",
filterTriggeredBody: "Ответ был заменён защитной ложной целью.",
filterActiveBody: "Дополнительный LLM-фильтр следит за запросом и ответом.",
filterOffBody: "На первом уровне дополнительных фильтров нет.",
missionTitle: "Состояние миссии",
missionSuccess: "Пароль извлечён",
missionActive: "Атака продолжается",
missionSuccessBody: "Сервер подтвердил взлом и вернул успешный статус.",
missionActiveBody: "Пробуйте смену ролей, переформулировки и разрыв контекста.",
apiTitle: "Статус API",
apiError: "Ошибка",
apiLoading: "Запрос…",
apiReady: "Готов",
httpTitle: "HTTP",
latencyTitle: "Задержка",
rotationTitle: "Сессия",
rotationNew: "Сменена",
rotationStable: "Без смены",
lastPrompt: "Последняя атака",
lastPromptEmpty: "Ходов пока не было. Первый запуск за вами.",
systemTitle: "Лента миссии",
unknownError: "Неизвестная ошибка запроса.",
},
},
};
function resolveKey(messages, key) {
return key.split(".").reduce((value, part) => value?.[part], messages);
}
export function normalizeLanguage(value) {
if (!value) {
return "en";
}
const normalized = String(value).toLowerCase();
if (normalized.startsWith("ru")) {
return "ru";
}
if (normalized.startsWith("en")) {
return "en";
}
return "en";
}
export function detectLanguage() {
if (typeof navigator === "undefined") {
return "en";
}
const candidates = [...(navigator.languages ?? []), navigator.language].filter(Boolean);
for (const candidate of candidates) {
const language = normalizeLanguage(candidate);
if (language === "ru" || language === "en") {
return language;
}
}
return "en";
}
export function getMessages(language) {
return dictionaries[normalizeLanguage(language)] ?? dictionaries.en;
}
export function t(messages, key) {
const value = resolveKey(messages, key);
return typeof value === "string" ? value : key;
}
+58 -1
View File
@@ -60,6 +60,49 @@ textarea {
overflow: hidden;
}
.language-switcher {
position: absolute;
top: 20px;
right: 20px;
z-index: 3;
display: inline-flex;
gap: 6px;
padding: 6px;
border: 1px solid rgba(115, 88, 58, 0.18);
border-radius: 999px;
background: rgba(255, 251, 244, 0.92);
box-shadow: 0 12px 24px rgba(99, 73, 45, 0.08);
}
.language-button {
min-width: 52px;
padding: 10px 12px;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--muted);
font-family: "IBM Plex Mono", monospace;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.08em;
cursor: pointer;
transition:
background 160ms ease,
color 160ms ease,
transform 160ms ease;
}
.language-button:hover {
transform: translateY(-1px);
color: var(--ink);
}
.language-button.is-active {
background: linear-gradient(180deg, var(--accent), var(--accent-strong));
color: #fff8f2;
box-shadow: 0 10px 20px rgba(202, 67, 27, 0.22);
}
.page-noise {
position: absolute;
inset: 0;
@@ -645,6 +688,10 @@ textarea {
}
@media (max-width: 1080px) {
.app-shell {
padding-top: 88px;
}
.hero,
.layout {
grid-template-columns: 1fr;
@@ -661,10 +708,20 @@ textarea {
.app-shell {
width: calc(100% - 16px);
margin: 8px auto;
padding: 16px;
padding: 84px 16px 16px;
border-radius: 24px;
}
.language-switcher {
top: 14px;
right: 14px;
}
.language-button {
min-width: 48px;
padding: 9px 10px;
}
.hero-mark {
align-items: flex-start;
}