feat: Add web localization
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user