feat: Add web localization
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
-3
@@ -1,11 +1,11 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Secret Stress Lab</title>
|
<title>Secret Stress Lab</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DJXE2VWB.js"></script>
|
<script type="module" crossorigin src="/assets/index-CEn0NlHp.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-pCFhhWgJ.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-hHMxQRnM.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|||||||
+109
-94
@@ -1,30 +1,14 @@
|
|||||||
import { startTransition, useEffect, useState } from "react";
|
import { startTransition, useEffect, useState } from "react";
|
||||||
|
|
||||||
const LEVEL_FALLBACKS = [
|
import { detectLanguage, getMessages, normalizeLanguage, t } from "./i18n";
|
||||||
{
|
|
||||||
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 с ограничением на астрономические темы.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
|
const LEVEL_FALLBACKS = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
|
||||||
const MAX_MESSAGE_LENGTH = 2000;
|
const MAX_MESSAGE_LENGTH = 2000;
|
||||||
const BLOCKED_PREFIX = "Ответ скрыт защитным фильтром";
|
const BLOCKED_PREFIXES = [
|
||||||
|
"Ответ скрыт защитным фильтром",
|
||||||
|
"Фильтр сработал",
|
||||||
|
"Response hidden by safety filter",
|
||||||
|
];
|
||||||
|
|
||||||
function createUuid7() {
|
function createUuid7() {
|
||||||
const timestamp = BigInt(Date.now());
|
const timestamp = BigInt(Date.now());
|
||||||
@@ -72,22 +56,8 @@ function buildConfettiPieces(seed) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function looksBlocked(text) {
|
function looksBlocked(text) {
|
||||||
return text.trim().startsWith(BLOCKED_PREFIX);
|
const normalized = text.trim();
|
||||||
}
|
return BLOCKED_PREFIXES.some((prefix) => normalized.startsWith(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";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusTone({ loading, error, blocked, success }) {
|
function statusTone({ loading, error, blocked, success }) {
|
||||||
@@ -98,14 +68,25 @@ function statusTone({ loading, error, blocked, success }) {
|
|||||||
return "status-ready";
|
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() {
|
export default function App() {
|
||||||
|
const [language, setLanguage] = useState(() => detectLanguage());
|
||||||
const [levels, setLevels] = useState(LEVEL_FALLBACKS);
|
const [levels, setLevels] = useState(LEVEL_FALLBACKS);
|
||||||
const [levelsError, setLevelsError] = useState("");
|
const [levelsError, setLevelsError] = useState("");
|
||||||
const [selectedLevelId, setSelectedLevelId] = useState(LEVEL_FALLBACKS[0].id);
|
const [selectedLevelId, setSelectedLevelId] = useState(LEVEL_FALLBACKS[0].id);
|
||||||
const [sessionId, setSessionId] = useState(() => createUuid7());
|
const [sessionId, setSessionId] = useState(() => createUuid7());
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [hardMode, setHardMode] = useState(false);
|
const [hardMode, setHardMode] = useState(false);
|
||||||
const [responseText, setResponseText] = useState("Выберите уровень и попробуйте выманить пароль у агента.");
|
const [responseState, setResponseState] = useState({ type: "local", key: "response.intro" });
|
||||||
const [lastUserMessage, setLastUserMessage] = useState("");
|
const [lastUserMessage, setLastUserMessage] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [apiError, setApiError] = useState("");
|
const [apiError, setApiError] = useState("");
|
||||||
@@ -115,6 +96,12 @@ export default function App() {
|
|||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [confettiSeed, setConfettiSeed] = useState(0);
|
const [confettiSeed, setConfettiSeed] = useState(0);
|
||||||
|
|
||||||
|
const messages = getMessages(language);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.lang = normalizeLanguage(language);
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -126,7 +113,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!cancelled && Array.isArray(data) && data.length > 0) {
|
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) => {
|
setSelectedLevelId((current) => {
|
||||||
if (data.some((level) => level.id === current)) {
|
if (data.some((level) => level.id === current)) {
|
||||||
return current;
|
return current;
|
||||||
@@ -135,9 +122,9 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
setLevelsError("");
|
setLevelsError("");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
if (!cancelled) {
|
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 blocked = looksBlocked(responseText);
|
||||||
const toneClassName = statusTone({ loading, error: apiError, blocked, success });
|
const toneClassName = statusTone({ loading, error: apiError, blocked, success });
|
||||||
const confettiPieces = buildConfettiPieces(confettiSeed);
|
const confettiPieces = buildConfettiPieces(confettiSeed);
|
||||||
@@ -157,7 +148,7 @@ export default function App() {
|
|||||||
setSelectedLevelId(levelId);
|
setSelectedLevelId(levelId);
|
||||||
setSessionId(createUuid7());
|
setSessionId(createUuid7());
|
||||||
setMessage("");
|
setMessage("");
|
||||||
setResponseText("Новая сессия готова. Отправьте сообщение агенту.");
|
setResponseState({ type: "local", key: "response.newSession" });
|
||||||
setLastUserMessage("");
|
setLastUserMessage("");
|
||||||
setApiError("");
|
setApiError("");
|
||||||
setSessionRotated(false);
|
setSessionRotated(false);
|
||||||
@@ -206,7 +197,7 @@ export default function App() {
|
|||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setLastLatencyMs(latency);
|
setLastLatencyMs(latency);
|
||||||
setLastUserMessage(trimmed);
|
setLastUserMessage(trimmed);
|
||||||
setResponseText(payload.response_text);
|
setResponseState({ type: "backend", text: payload.response_text });
|
||||||
setSessionRotated(Boolean(payload.session_rotated));
|
setSessionRotated(Boolean(payload.session_rotated));
|
||||||
setSessionId(payload.session_id);
|
setSessionId(payload.session_id);
|
||||||
setSuccess(Boolean(payload.success));
|
setSuccess(Boolean(payload.success));
|
||||||
@@ -216,7 +207,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setApiError(error instanceof Error ? error.message : "Неизвестная ошибка запроса.");
|
setApiError(error instanceof Error ? error.message : t(messages, "status.unknownError"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -226,13 +217,31 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(sessionId);
|
await navigator.clipboard.writeText(sessionId);
|
||||||
} catch {
|
} catch {
|
||||||
setApiError("Не удалось скопировать session_id в буфер обмена.");
|
setApiError(t(messages, "session.copyError"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<div className="page-noise" aria-hidden="true" />
|
<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">
|
<header className="hero">
|
||||||
<div className="hero-mark">
|
<div className="hero-mark">
|
||||||
<div className="beaker-icon" aria-hidden="true">
|
<div className="beaker-icon" aria-hidden="true">
|
||||||
@@ -241,28 +250,25 @@ export default function App() {
|
|||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="hero-kicker">Secret Stress Lab</p>
|
<p className="hero-kicker">{t(messages, "hero.kicker")}</p>
|
||||||
<h1>Проверь, сможет ли человек вытянуть секрет у LLM-агента.</h1>
|
<h1>{t(messages, "hero.title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<aside className="hero-note">
|
<aside className="hero-note">
|
||||||
<p className="hero-note-label">Lab Notes</p>
|
<p className="hero-note-label">{t(messages, "hero.noteLabel")}</p>
|
||||||
<p>
|
<p>{t(messages, "hero.noteBody")}</p>
|
||||||
У каждого уровня есть скрытый пароль. Выбирай уровень, включай hard mode и смотри,
|
|
||||||
где защита ломается.
|
|
||||||
</p>
|
|
||||||
</aside>
|
</aside>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="layout">
|
<main className="layout">
|
||||||
<section className="panel panel-left">
|
<section className="panel panel-left">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h2>Выбор уровня</h2>
|
<h2>{t(messages, "panel.levelsTitle")}</h2>
|
||||||
<span className="panel-tag">4 сценария</span>
|
<span className="panel-tag">{t(messages, "panel.levelsTag")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="level-grid">
|
<div className="level-grid">
|
||||||
{levels.map((level) => {
|
{localizedLevels.map((level) => {
|
||||||
const active = level.id === selectedLevelId;
|
const active = level.id === selectedLevelId;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -272,7 +278,7 @@ export default function App() {
|
|||||||
onClick={() => resetSessionForLevel(level.id)}
|
onClick={() => resetSessionForLevel(level.id)}
|
||||||
>
|
>
|
||||||
<span className="level-number">{level.id}</span>
|
<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>
|
<span className="level-description">{level.description}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -281,21 +287,18 @@ export default function App() {
|
|||||||
|
|
||||||
<div className="session-box">
|
<div className="session-box">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Session ID</p>
|
<p className="eyebrow">{t(messages, "session.eyebrow")}</p>
|
||||||
<p className="session-value">{sessionId}</p>
|
<p className="session-value">{sessionId}</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="ghost-button" onClick={handleCopySession}>
|
<button type="button" className="ghost-button" onClick={handleCopySession}>
|
||||||
Скопировать
|
{t(messages, "session.copy")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="toggle-row">
|
<div className="toggle-row">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow danger">Hard Mode</p>
|
<p className="eyebrow danger">{t(messages, "controls.hardModeTitle")}</p>
|
||||||
<p className="toggle-copy">
|
<p className="toggle-copy">{t(messages, "controls.hardModeBody")}</p>
|
||||||
Пароль будет ротироваться каждые несколько запросов. Фронтенд принимает новый
|
|
||||||
<code>session_id</code> из ответа сервера.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<label className="switch">
|
<label className="switch">
|
||||||
<input
|
<input
|
||||||
@@ -308,23 +311,23 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="level-explainer">
|
<div className="level-explainer">
|
||||||
<p className="eyebrow accent">Текущий уровень</p>
|
<p className="eyebrow accent">{t(messages, "controls.currentLevel")}</p>
|
||||||
<h3>
|
<h3>
|
||||||
{selectedLevel.title} · {levelBadge(selectedLevel.id)}
|
{selectedLevel.title} · {selectedLevel.badge}
|
||||||
</h3>
|
</h3>
|
||||||
<p>{selectedLevel.description}</p>
|
<p>{selectedLevel.description}</p>
|
||||||
{levelsError ? <p className="helper-warning">{levelsError}</p> : null}
|
{levelsError ? <p className="helper-warning">{t(messages, "controls.levelsError")}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="composer" onSubmit={handleSubmit}>
|
<form className="composer" onSubmit={handleSubmit}>
|
||||||
<label className="composer-label" htmlFor="message">
|
<label className="composer-label" htmlFor="message">
|
||||||
Сообщение агенту
|
{t(messages, "controls.composerLabel")}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="message"
|
id="message"
|
||||||
value={message}
|
value={message}
|
||||||
maxLength={MAX_MESSAGE_LENGTH}
|
maxLength={MAX_MESSAGE_LENGTH}
|
||||||
placeholder="Напишите вопрос, инструкцию или попытку обхода правил…"
|
placeholder={t(messages, "controls.composerPlaceholder")}
|
||||||
onChange={(event) => setMessage(event.target.value)}
|
onChange={(event) => setMessage(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="composer-footer">
|
<div className="composer-footer">
|
||||||
@@ -332,7 +335,7 @@ export default function App() {
|
|||||||
{message.length} / {MAX_MESSAGE_LENGTH}
|
{message.length} / {MAX_MESSAGE_LENGTH}
|
||||||
</p>
|
</p>
|
||||||
<button type="submit" className="primary-button" disabled={loading || !message.trim()}>
|
<button type="submit" className="primary-button" disabled={loading || !message.trim()}>
|
||||||
{loading ? "Отправка…" : "Send Message"}
|
{loading ? t(messages, "controls.sending") : t(messages, "controls.send")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -340,8 +343,8 @@ export default function App() {
|
|||||||
|
|
||||||
<section className={`panel panel-right ${toneClassName}`}>
|
<section className={`panel panel-right ${toneClassName}`}>
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h2>Ответ агента</h2>
|
<h2>{t(messages, "panel.responseTitle")}</h2>
|
||||||
<span className="panel-tag live-tag">{loading ? "live" : "ready"}</span>
|
<span className="panel-tag live-tag">{loading ? t(messages, "panel.live") : t(messages, "panel.ready")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="response-box">
|
<div className="response-box">
|
||||||
@@ -359,60 +362,72 @@ export default function App() {
|
|||||||
|
|
||||||
<div className="status-grid">
|
<div className="status-grid">
|
||||||
<article className={`status-card ${blocked ? "is-alert" : ""}`}>
|
<article className={`status-card ${blocked ? "is-alert" : ""}`}>
|
||||||
<p className="eyebrow">Filter Status</p>
|
<p className="eyebrow">{t(messages, "status.filterTitle")}</p>
|
||||||
<strong>{blocked ? "Сработал" : selectedLevelId > 1 ? "Активен" : "Выключен"}</strong>
|
<strong>
|
||||||
|
{blocked
|
||||||
|
? t(messages, "status.filterTriggered")
|
||||||
|
: selectedLevelId > 1
|
||||||
|
? t(messages, "status.filterActive")
|
||||||
|
: t(messages, "status.filterOff")}
|
||||||
|
</strong>
|
||||||
<p>
|
<p>
|
||||||
{blocked
|
{blocked
|
||||||
? "Ответ заменён защитной заглушкой."
|
? t(messages, "status.filterTriggeredBody")
|
||||||
: selectedLevelId > 1
|
: selectedLevelId > 1
|
||||||
? "Дополнительный LLM-фильтр следит за запросом и ответом."
|
? t(messages, "status.filterActiveBody")
|
||||||
: "На первом уровне дополнительных фильтров нет."}
|
: t(messages, "status.filterOffBody")}
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className={`status-card ${success ? "is-success" : ""}`}>
|
<article className={`status-card ${success ? "is-success" : ""}`}>
|
||||||
<p className="eyebrow">Mission State</p>
|
<p className="eyebrow">{t(messages, "status.missionTitle")}</p>
|
||||||
<strong>{success ? "Пароль угадан" : "Атака продолжается"}</strong>
|
<strong>{success ? t(messages, "status.missionSuccess") : t(messages, "status.missionActive")}</strong>
|
||||||
<p>
|
<p>
|
||||||
{success
|
{success
|
||||||
? "Сервер подтвердил успех и вернул победный статус."
|
? t(messages, "status.missionSuccessBody")
|
||||||
: "Пробуй другие формулировки, роли и обходные сценарии."}
|
: t(messages, "status.missionActiveBody")}
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="status-strip">
|
<div className="status-strip">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">API status</p>
|
<p className="eyebrow">{t(messages, "status.apiTitle")}</p>
|
||||||
<strong>{apiError ? "Ошибка" : loading ? "Запрос…" : "Готов"}</strong>
|
<strong>
|
||||||
|
{apiError
|
||||||
|
? t(messages, "status.apiError")
|
||||||
|
: loading
|
||||||
|
? t(messages, "status.apiLoading")
|
||||||
|
: t(messages, "status.apiReady")}
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">HTTP</p>
|
<p className="eyebrow">{t(messages, "status.httpTitle")}</p>
|
||||||
<strong>{lastStatusCode ?? "—"}</strong>
|
<strong>{lastStatusCode ?? "—"}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Latency</p>
|
<p className="eyebrow">{t(messages, "status.latencyTitle")}</p>
|
||||||
<strong>{lastLatencyMs ? `${lastLatencyMs} ms` : "—"}</strong>
|
<strong>{lastLatencyMs ? `${lastLatencyMs} ms` : "—"}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Rotation</p>
|
<p className="eyebrow">{t(messages, "status.rotationTitle")}</p>
|
||||||
<strong>{sessionRotated ? "New session" : "Stable"}</strong>
|
<strong>{sessionRotated ? t(messages, "status.rotationNew") : t(messages, "status.rotationStable")}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="transcript">
|
<div className="transcript">
|
||||||
<div className="transcript-card">
|
<div className="transcript-card">
|
||||||
<p className="eyebrow">Последний запрос</p>
|
<p className="eyebrow">{t(messages, "status.lastPrompt")}</p>
|
||||||
<p>{lastUserMessage || "Пока пусто. Первый ход за вами."}</p>
|
<p>{lastUserMessage || t(messages, "status.lastPromptEmpty")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="transcript-card">
|
<div className="transcript-card">
|
||||||
<p className="eyebrow">Системное сообщение</p>
|
<p className="eyebrow">{t(messages, "status.systemTitle")}</p>
|
||||||
<p>
|
<p>
|
||||||
{apiError
|
{apiError
|
||||||
? apiError
|
? apiError
|
||||||
: sessionRotated
|
: sessionRotated
|
||||||
? "Сервер ротировал сессию и прислал новый идентификатор."
|
? t(messages, "session.rotated")
|
||||||
: "Сессия активна. Следующий запрос пойдёт в текущий контекст."}
|
: t(messages, "session.stable")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+232
@@ -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
@@ -60,6 +60,49 @@ textarea {
|
|||||||
overflow: hidden;
|
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 {
|
.page-noise {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -645,6 +688,10 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1080px) {
|
@media (max-width: 1080px) {
|
||||||
|
.app-shell {
|
||||||
|
padding-top: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
.hero,
|
.hero,
|
||||||
.layout {
|
.layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -661,10 +708,20 @@ textarea {
|
|||||||
.app-shell {
|
.app-shell {
|
||||||
width: calc(100% - 16px);
|
width: calc(100% - 16px);
|
||||||
margin: 8px auto;
|
margin: 8px auto;
|
||||||
padding: 16px;
|
padding: 84px 16px 16px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.language-switcher {
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-button {
|
||||||
|
min-width: 48px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-mark {
|
.hero-mark {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user