1873 lines
74 KiB
HTML
1873 lines
74 KiB
HTML
<!DOCTYPE html>
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<html lang="en" dir="ltr">
|
||
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>LLM STEGANOGRAPHY</title>
|
||
|
||
<link rel="stylesheet" href="./css/client.css" />
|
||
|
||
<!-- Initialize theme BEFORE any components load -->
|
||
<script>
|
||
(function () {
|
||
const savedTheme = localStorage.getItem('theme');
|
||
if (savedTheme === 'dark') {
|
||
document.documentElement.classList.add('dark-mode');
|
||
if (document.body) {
|
||
document.body.classList.add('dark-mode');
|
||
}
|
||
}
|
||
})();
|
||
</script>
|
||
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
|
||
|
||
<!-- SVG.js library (required by HamburgerMorphing) -->
|
||
<script src="./js/vendor/svg.min.js"></script>
|
||
</head>
|
||
|
||
<body>
|
||
<!-- ================================================================
|
||
HAMBURGER BUTTON (Fixed, separate from header)
|
||
================================================================
|
||
The hamburger button is a fixed element outside the header
|
||
to ensure it stays visible above the sidenav (z-index: 10 > 3).
|
||
================================================================ -->
|
||
<div id="divSvgHam" class="divHeaderButtons">
|
||
<!-- HamburgerMorphing will be injected here -->
|
||
</div>
|
||
|
||
<!-- ================================================================
|
||
HEADER
|
||
================================================================
|
||
Standard header with title (center).
|
||
================================================================ -->
|
||
<div id="divHeader">
|
||
<div id="divHeaderFlexLeft">
|
||
<!-- Hamburger is now separate fixed element -->
|
||
</div>
|
||
|
||
<div id="divHeaderFlexCenter">
|
||
<div class="divHeaderText"></div>
|
||
</div>
|
||
|
||
<div id="divHeaderFlexRight">
|
||
<!-- No button in header right - logout is in sidenav footer -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================================================================
|
||
BODY
|
||
================================================================
|
||
Main content area. Add your page-specific content here.
|
||
================================================================ -->
|
||
<div id="divBody">
|
||
|
||
<!-- ================================================================
|
||
PAGE-SPECIFIC STYLES
|
||
================================================================
|
||
Reuses project CSS variables (var(--primary-color), etc.) so the
|
||
demo matches the rest of the app and respects dark mode.
|
||
================================================================ -->
|
||
<style>
|
||
#stegoWrap {
|
||
max-width: 760px;
|
||
width: 100%;
|
||
margin: 0 auto;
|
||
padding: 8px 16px 48px;
|
||
box-sizing: border-box;
|
||
text-align: left;
|
||
}
|
||
|
||
#stegoWrap header {
|
||
text-align: center;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
#stegoWrap header h1 {
|
||
margin: 0 0 6px;
|
||
font-size: 28px;
|
||
color: var(--primary-color);
|
||
}
|
||
|
||
#stegoWrap .subtitle {
|
||
color: var(--primary-color);
|
||
margin: 0;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.stegoCard {
|
||
background: var(--secondary-color);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--border-radius);
|
||
padding: 18px 20px;
|
||
margin-bottom: 18px;
|
||
color: var(--primary-color);
|
||
}
|
||
|
||
.stegoCard h2 {
|
||
margin: 0 0 12px;
|
||
font-size: 18px;
|
||
color: var(--primary-color);
|
||
}
|
||
|
||
.stegoHint {
|
||
color: var(--primary-color);
|
||
font-size: 13px;
|
||
margin: 0 0 12px;
|
||
}
|
||
|
||
.stegoFormRow {
|
||
display: flex;
|
||
flex-direction: column;
|
||
margin-bottom: 12px;
|
||
}
|
||
.stegoFormRow:last-child { margin-bottom: 0; }
|
||
|
||
.stegoFormRow label {
|
||
font-size: 13px;
|
||
color: var(--primary-color);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.stegoFormRow input[type="text"],
|
||
.stegoFormRow input[type="number"],
|
||
.stegoFormRow textarea {
|
||
background: var(--background-color);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
color: var(--primary-color);
|
||
padding: 9px 11px;
|
||
font-size: 14px;
|
||
font-family: var(--font-family);
|
||
width: 100%;
|
||
outline: none;
|
||
transition: border-color 0.15s;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.stegoFormRow textarea {
|
||
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
|
||
resize: vertical;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.stegoFormRow input:focus,
|
||
.stegoFormRow textarea:focus {
|
||
border-color: var(--accent-color);
|
||
}
|
||
|
||
.stegoStatus {
|
||
color: var(--primary-color);
|
||
font-size: 13px;
|
||
margin: 8px 0;
|
||
min-height: 1.2em;
|
||
}
|
||
|
||
.stegoInfo {
|
||
font-size: 13px;
|
||
color: var(--primary-color);
|
||
margin: 8px 0 0;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.stegoInfo.match-success {
|
||
color: var(--accent-color);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.stegoInfo.match-fail {
|
||
color: var(--accent-color);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.stegoProgressBarContainer {
|
||
background: var(--background-color);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
height: 14px;
|
||
overflow: hidden;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.stegoProgressBar {
|
||
height: 100%;
|
||
width: 0%;
|
||
background: var(--accent-color);
|
||
transition: width 0.2s ease;
|
||
}
|
||
|
||
.stegoProgressFiles {
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
color: var(--primary-color);
|
||
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
|
||
max-height: 120px;
|
||
overflow-y: auto;
|
||
}
|
||
.stegoProgressFiles div {
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.stegoProgressSection {
|
||
background: var(--background-color);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
padding: 10px 12px;
|
||
margin: 10px 0;
|
||
}
|
||
|
||
.stegoProgressLine {
|
||
font-size: 13px;
|
||
color: var(--primary-color);
|
||
margin-bottom: 6px;
|
||
}
|
||
.stegoProgressLine:last-child { margin-bottom: 0; }
|
||
|
||
.stegoCharHighlight {
|
||
display: inline-block;
|
||
background: var(--accent-color);
|
||
color: var(--secondary-color);
|
||
font-weight: 700;
|
||
padding: 1px 8px;
|
||
border-radius: 4px;
|
||
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
|
||
min-width: 1.2em;
|
||
text-align: center;
|
||
}
|
||
|
||
.stegoTokenDisplay {
|
||
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
|
||
background: var(--secondary-color);
|
||
border: 1px solid var(--border-color);
|
||
padding: 1px 6px;
|
||
border-radius: 4px;
|
||
color: var(--accent-color);
|
||
}
|
||
|
||
.stegoRecoveredBox {
|
||
display: inline-block;
|
||
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
|
||
background: var(--secondary-color);
|
||
border: 1px solid var(--accent-color);
|
||
color: var(--accent-color);
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
font-weight: 600;
|
||
min-height: 1.4em;
|
||
}
|
||
|
||
.stegoCollapsibleToggle {
|
||
background: none;
|
||
border: none;
|
||
color: var(--primary-color);
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
width: 100%;
|
||
text-align: left;
|
||
font-family: var(--font-family);
|
||
}
|
||
|
||
.stegoCollapsibleToggle .chevron {
|
||
transition: transform 0.2s;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.stegoCollapsibleToggle[aria-expanded="true"] .chevron {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.stegoCollapsibleContent {
|
||
margin-top: 12px;
|
||
}
|
||
.stegoCollapsibleContent p,
|
||
.stegoCollapsibleContent li {
|
||
color: var(--primary-color);
|
||
}
|
||
.stegoCollapsibleContent ol {
|
||
padding-left: 20px;
|
||
}
|
||
.stegoCollapsibleContent code {
|
||
background: var(--background-color);
|
||
padding: 1px 5px;
|
||
border-radius: 4px;
|
||
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
|
||
font-size: 13px;
|
||
}
|
||
|
||
/* ---------- FAQ items ---------- */
|
||
.stegoFaqItem {
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 6px;
|
||
margin-bottom: 10px;
|
||
overflow: hidden;
|
||
}
|
||
.stegoFaqItem:last-child { margin-bottom: 0; }
|
||
|
||
.stegoFaqToggle {
|
||
padding: 12px 14px;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
background: var(--background-color);
|
||
width: 100%;
|
||
}
|
||
.stegoFaqToggle:hover {
|
||
background: var(--secondary-color);
|
||
}
|
||
.stegoFaqItem .stegoCollapsibleContent {
|
||
padding: 0 14px 14px 14px;
|
||
margin-top: 0;
|
||
}
|
||
.stegoCollapsibleContent ul {
|
||
padding-left: 20px;
|
||
margin: 8px 0;
|
||
}
|
||
.stegoCollapsibleContent li {
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
/* ---------- Example walkthrough ---------- */
|
||
.stegoExampleH3 {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: var(--accent-color);
|
||
margin: 16px 0 8px 0;
|
||
}
|
||
.stegoExampleH4 {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--primary-color);
|
||
margin: 14px 0 6px 0;
|
||
}
|
||
.stegoExampleOutput {
|
||
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
|
||
background: var(--background-color);
|
||
padding: 8px 12px;
|
||
border-radius: 6px;
|
||
border: 1px solid var(--border-color);
|
||
color: var(--accent-color);
|
||
font-size: 13px;
|
||
margin: 8px 0;
|
||
}
|
||
.stegoTableWrap {
|
||
overflow-x: auto;
|
||
margin: 10px 0;
|
||
}
|
||
.stegoTable {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 12px;
|
||
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
|
||
}
|
||
.stegoTable th,
|
||
.stegoTable td {
|
||
border: 1px solid var(--border-color);
|
||
padding: 4px 8px;
|
||
text-align: left;
|
||
color: var(--primary-color);
|
||
}
|
||
.stegoTable th {
|
||
background: var(--background-color);
|
||
font-weight: 600;
|
||
}
|
||
.stegoTable tr:nth-child(even) td {
|
||
background: var(--background-color);
|
||
}
|
||
|
||
.stegoFooter {
|
||
text-align: center;
|
||
color: var(--primary-color);
|
||
font-size: 12px;
|
||
margin-top: 24px;
|
||
}
|
||
.stegoFooter a {
|
||
color: var(--accent-color);
|
||
text-decoration: none;
|
||
}
|
||
.stegoFooter a:hover { text-decoration: underline; }
|
||
|
||
.stegoHidden { display: none !important; }
|
||
</style>
|
||
|
||
<div id="stegoWrap">
|
||
<header>
|
||
<h1>LLM Steganography Demo</h1>
|
||
<p class="subtitle">Hide secret messages in innocent-looking AI-generated text</p>
|
||
</header>
|
||
|
||
<!-- Model loading section -->
|
||
<section id="stegoLoadingSection" class="stegoCard">
|
||
<h2>Model Loading</h2>
|
||
<p id="stegoLoadingStatus" class="stegoStatus">Initializing...</p>
|
||
<div class="stegoProgressBarContainer">
|
||
<div id="stegoProgressBar" class="stegoProgressBar"></div>
|
||
</div>
|
||
<div id="stegoProgressFiles" class="stegoProgressFiles"></div>
|
||
</section>
|
||
|
||
<!-- Main app (hidden until model is loaded) -->
|
||
<main id="stegoApp" class="stegoHidden">
|
||
|
||
<!-- Shared Parameters -->
|
||
<section class="stegoCard">
|
||
<h2>Shared Parameters</h2>
|
||
<p class="stegoHint">Both encoder and decoder must use the same context and key.</p>
|
||
<div class="stegoFormRow">
|
||
<label for="stegoContextInput">Context / Prompt</label>
|
||
<input type="text" id="stegoContextInput" value="I like to eat" autocomplete="off">
|
||
</div>
|
||
<div class="stegoFormRow">
|
||
<label for="stegoKeyInput">Shared Key (integer)</label>
|
||
<input type="number" id="stegoKeyInput" value="42" step="1" autocomplete="off">
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Encode -->
|
||
<section class="stegoCard">
|
||
<h2>Encode</h2>
|
||
<div class="stegoFormRow">
|
||
<label for="stegoSecretInput">Secret Message</label>
|
||
<input type="text" id="stegoSecretInput" value="HELLO" autocomplete="off">
|
||
</div>
|
||
<button id="stegoEncodeBtn" class="btn">Encode</button>
|
||
<p id="stegoEncodeStatus" class="stegoStatus"></p>
|
||
<div id="stegoEncodeProgress" class="stegoProgressSection stegoHidden">
|
||
<div class="stegoProgressLine">
|
||
<span>Encoding character <span id="stegoEncodeCurrentChar" class="stegoCharHighlight"></span> (<span id="stegoEncodeCharIndex">0</span>/<span id="stegoEncodeTotalChars">0</span>)</span>
|
||
</div>
|
||
<div class="stegoProgressLine">
|
||
<span id="stegoEncodeBitInfo">bit 0 of 0</span>
|
||
</div>
|
||
<div class="stegoProgressBarContainer">
|
||
<div id="stegoEncodeProgressBar" class="stegoProgressBar"></div>
|
||
</div>
|
||
</div>
|
||
<div class="stegoFormRow">
|
||
<label for="stegoCoverOutput">Cover Text</label>
|
||
<textarea id="stegoCoverOutput" readonly rows="4"></textarea>
|
||
</div>
|
||
<p id="stegoEncodeInfo" class="stegoInfo"></p>
|
||
</section>
|
||
|
||
<!-- Decode -->
|
||
<section class="stegoCard">
|
||
<h2>Decode</h2>
|
||
<button id="stegoDecodeBtn" class="btn">Decode</button>
|
||
<p id="stegoDecodeStatus" class="stegoStatus"></p>
|
||
<div id="stegoDecodeProgress" class="stegoProgressSection stegoHidden">
|
||
<div class="stegoProgressLine">
|
||
<span>Processing token: <span id="stegoDecodeCurrentToken" class="stegoTokenDisplay"></span> → bit <span id="stegoDecodeRecoveredBit">-</span></span>
|
||
</div>
|
||
<div class="stegoProgressLine">
|
||
<span id="stegoDecodeBitInfo">bit 0 of 0</span>
|
||
</div>
|
||
<div class="stegoProgressBarContainer">
|
||
<div id="stegoDecodeProgressBar" class="stegoProgressBar"></div>
|
||
</div>
|
||
<div class="stegoProgressLine">
|
||
<span>Recovered so far: <span id="stegoDecodeRecoveredChars" class="stegoRecoveredBox"></span></span>
|
||
</div>
|
||
</div>
|
||
<div class="stegoFormRow">
|
||
<label for="stegoRecoveredOutput">Recovered Message</label>
|
||
<textarea id="stegoRecoveredOutput" readonly rows="2"></textarea>
|
||
</div>
|
||
<p id="stegoMatchIndicator" class="stegoInfo"></p>
|
||
</section>
|
||
|
||
<!-- ================================================================
|
||
FAQ SECTION
|
||
================================================================
|
||
Each question is its own collapsible card using the same
|
||
stegoCollapsibleToggle / stegoCollapsibleContent pattern.
|
||
================================================================ -->
|
||
<section class="stegoCard">
|
||
<h2 style="text-align: center; margin-bottom: 16px;">FAQ</h2>
|
||
|
||
<!-- Q1: What is the point of this? -->
|
||
<div class="stegoFaqItem">
|
||
<button class="stegoCollapsibleToggle stegoFaqToggle" aria-expanded="false">
|
||
What is the point of this? <span class="chevron">▸</span>
|
||
</button>
|
||
<div class="stegoCollapsibleContent stegoHidden">
|
||
<p>
|
||
This demo lets you <strong>hide a secret message inside ordinary-looking
|
||
AI-generated text</strong>. The output reads like a normal sentence a
|
||
language model might produce, but it secretly encodes your message bit
|
||
by bit.
|
||
</p>
|
||
<p>
|
||
The practical point is <em>covert communication</em>: two people who
|
||
share a key can exchange messages that, to anyone watching, look like
|
||
innocuous GPT-2 text. There's no obvious ciphertext, no encrypted file,
|
||
and no metadata screaming "this is encrypted." The secret is hidden in
|
||
plain sight.
|
||
</p>
|
||
<p>
|
||
It's also a neat demonstration of how much information is packed into
|
||
every token a language model emits — each token can carry a full secret
|
||
bit while still looking natural.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Q2: How does it work? -->
|
||
<div class="stegoFaqItem">
|
||
<button class="stegoCollapsibleToggle stegoFaqToggle" aria-expanded="false">
|
||
How does it work? <span class="chevron">▸</span>
|
||
</button>
|
||
<div class="stegoCollapsibleContent stegoHidden">
|
||
<p>
|
||
This demo uses a <strong>half-splitting entropy coding</strong> scheme
|
||
built on top of GPT-2's next-token probability distribution.
|
||
</p>
|
||
<ol>
|
||
<li>The secret message is converted to a bit string (UTF-8 → bits).</li>
|
||
<li>For each secret bit, GPT-2 produces a probability distribution over
|
||
the entire vocabulary for the next token.</li>
|
||
<li>Tokens are sorted by probability (descending) and split into two
|
||
halves at the 50% cumulative probability mark.</li>
|
||
<li>Bit <code>0</code> → the next token is sampled from the
|
||
<em>first</em> (higher-probability) half; bit <code>1</code> →
|
||
from the <em>second</em> half.</li>
|
||
<li>A shared-key PRNG (mulberry32) selects the exact token within the
|
||
chosen half, so the decoder can reproduce the same random draws.</li>
|
||
<li>The decoder re-runs GPT-2 on the same context, observes which half
|
||
each cover token fell into, and recovers the bits → original message.</li>
|
||
</ol>
|
||
<p>
|
||
Because both sides share the same model, context, and PRNG seed, the
|
||
decoder can perfectly reconstruct the hidden bits. The resulting cover
|
||
text reads like normal GPT-2 output, hiding the secret in plain sight.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Q3: Can you give me a simple example? -->
|
||
<div class="stegoFaqItem">
|
||
<button class="stegoCollapsibleToggle stegoFaqToggle" aria-expanded="false">
|
||
Can you give me a simple example of how it works? <span class="chevron">▸</span>
|
||
</button>
|
||
<div class="stegoCollapsibleContent stegoHidden">
|
||
<p>
|
||
To make this tangible, let's use a <strong>toy model with a small
|
||
vocabulary of 11 words</strong> so you can see every number. The real
|
||
scheme works identically, just with vocabularies of ~50,000 tokens and
|
||
floating-point probabilities.
|
||
</p>
|
||
|
||
<h3 class="stegoExampleH3">Setup</h3>
|
||
<p>
|
||
<strong>The model:</strong> A tiny "LLM" with a fixed vocabulary of 11
|
||
words: <code>{apple, date, banana, cherry, pie, juice, tart, sauce, for, with, and}</code>.
|
||
At each step, the model assigns a probability to all 11 words based on
|
||
the context. The probabilities change as the context grows, and they
|
||
always sum to 1.0.
|
||
</p>
|
||
<p>
|
||
<strong>Shared context/prompt:</strong> <code>"I like to eat"</code> —
|
||
both Alice and Bob have this. It is not secret, just shared.
|
||
</p>
|
||
<p>
|
||
<strong>Shared secret key:</strong> Used to seed a PRNG, producing the
|
||
random stream: <code>0.15, 0.62, 0.40, ...</code>
|
||
</p>
|
||
<p>
|
||
<strong>Secret message:</strong> The bits <code>0 1 1</code> (3 bits).
|
||
</p>
|
||
|
||
<h3 class="stegoExampleH3">Encoding (Alice's Side)</h3>
|
||
|
||
<h4 class="stegoExampleH4">Step 1: Alice runs the model</h4>
|
||
<p>
|
||
Alice feeds <code>"I like to eat"</code> into the model. After "I like
|
||
to eat", fruits are the most natural continuation, so they get the
|
||
highest probabilities:
|
||
</p>
|
||
<div class="stegoTableWrap">
|
||
<table class="stegoTable">
|
||
<thead><tr><th>Word</th><th>Probability</th><th>Interval</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>apple</td><td>0.20</td><td>[0.00, 0.20)</td></tr>
|
||
<tr><td>date</td><td>0.15</td><td>[0.20, 0.35)</td></tr>
|
||
<tr><td>banana</td><td>0.10</td><td>[0.35, 0.45)</td></tr>
|
||
<tr><td>cherry</td><td>0.05</td><td>[0.45, 0.50)</td></tr>
|
||
<tr><td>pie</td><td>0.20</td><td>[0.50, 0.70)</td></tr>
|
||
<tr><td>juice</td><td>0.12</td><td>[0.70, 0.82)</td></tr>
|
||
<tr><td>tart</td><td>0.08</td><td>[0.82, 0.90)</td></tr>
|
||
<tr><td>sauce</td><td>0.05</td><td>[0.90, 0.95)</td></tr>
|
||
<tr><td>for</td><td>0.03</td><td>[0.95, 0.98)</td></tr>
|
||
<tr><td>with</td><td>0.015</td><td>[0.98, 0.995)</td></tr>
|
||
<tr><td>and</td><td>0.005</td><td>[0.995, 1.00)</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p>The probabilities sum to 1.0, and the intervals partition [0, 1).</p>
|
||
|
||
<h4 class="stegoExampleH4">Step 2: Alice encodes her first secret bit</h4>
|
||
<p>
|
||
Alice's first secret bit is <code>0</code> → "look in the <strong>first
|
||
half</strong> [0.00, 0.50)." The words in that half are apple, date,
|
||
banana, and cherry. Alice uses her next random number, <code>0.15</code>,
|
||
to pick within [0.00, 0.50). The value <code>0.15</code> falls in
|
||
[0.00, 0.20), which is <strong>apple</strong>.
|
||
</p>
|
||
<p class="stegoExampleOutput">Alice outputs: "apple" → text so far: "I like to eat apple"</p>
|
||
|
||
<h4 class="stegoExampleH4">Step 3: Alice runs the model again</h4>
|
||
<p>
|
||
Context is now <code>"I like to eat apple"</code>. After "apple", food
|
||
preparations like pie and tart become more likely:
|
||
</p>
|
||
<div class="stegoTableWrap">
|
||
<table class="stegoTable">
|
||
<thead><tr><th>Word</th><th>Probability</th><th>Interval</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>apple</td><td>0.15</td><td>[0.00, 0.15)</td></tr>
|
||
<tr><td>date</td><td>0.10</td><td>[0.15, 0.25)</td></tr>
|
||
<tr><td>banana</td><td>0.08</td><td>[0.25, 0.33)</td></tr>
|
||
<tr><td>cherry</td><td>0.07</td><td>[0.33, 0.40)</td></tr>
|
||
<tr><td>for</td><td>0.06</td><td>[0.40, 0.46)</td></tr>
|
||
<tr><td>with</td><td>0.04</td><td>[0.46, 0.50)</td></tr>
|
||
<tr><td>pie</td><td>0.20</td><td>[0.50, 0.70)</td></tr>
|
||
<tr><td>tart</td><td>0.16</td><td>[0.70, 0.86)</td></tr>
|
||
<tr><td>juice</td><td>0.08</td><td>[0.86, 0.94)</td></tr>
|
||
<tr><td>sauce</td><td>0.04</td><td>[0.94, 0.98)</td></tr>
|
||
<tr><td>and</td><td>0.02</td><td>[0.98, 1.00)</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p>
|
||
Alice's next bit is <code>1</code> → "look in the <strong>second half</strong>
|
||
[0.50, 1.00)." Random number <code>0.62</code> → scaled:
|
||
<code>0.50 + 0.62 × 0.50 = 0.81</code>, which falls in [0.70, 0.86) →
|
||
<strong>tart</strong>.
|
||
</p>
|
||
<p class="stegoExampleOutput">Alice outputs: "tart" → text so far: "I like to eat apple tart"</p>
|
||
|
||
<h4 class="stegoExampleH4">Step 4: Alice runs the model again</h4>
|
||
<p>
|
||
Context: <code>"I like to eat apple tart"</code>. After "apple tart",
|
||
connectors like "with" and "and" become most likely:
|
||
</p>
|
||
<div class="stegoTableWrap">
|
||
<table class="stegoTable">
|
||
<thead><tr><th>Word</th><th>Probability</th><th>Interval</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>apple</td><td>0.10</td><td>[0.00, 0.10)</td></tr>
|
||
<tr><td>date</td><td>0.08</td><td>[0.10, 0.18)</td></tr>
|
||
<tr><td>banana</td><td>0.07</td><td>[0.18, 0.25)</td></tr>
|
||
<tr><td>cherry</td><td>0.06</td><td>[0.25, 0.31)</td></tr>
|
||
<tr><td>pie</td><td>0.08</td><td>[0.31, 0.39)</td></tr>
|
||
<tr><td>juice</td><td>0.06</td><td>[0.39, 0.45)</td></tr>
|
||
<tr><td>for</td><td>0.05</td><td>[0.45, 0.50)</td></tr>
|
||
<tr><td>tart</td><td>0.06</td><td>[0.50, 0.56)</td></tr>
|
||
<tr><td>sauce</td><td>0.04</td><td>[0.56, 0.60)</td></tr>
|
||
<tr><td>with</td><td>0.18</td><td>[0.60, 0.78)</td></tr>
|
||
<tr><td>and</td><td>0.22</td><td>[0.78, 1.00)</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p>
|
||
Alice's next bit is <code>1</code> → second half [0.50, 1.00). Random
|
||
number <code>0.40</code> → scaled: <code>0.50 + 0.40 × 0.50 = 0.70</code>,
|
||
falls in [0.60, 0.78) → <strong>with</strong>.
|
||
</p>
|
||
<p class="stegoExampleOutput">Alice outputs: "with" → text so far: "I like to eat apple tart with"</p>
|
||
|
||
<h4 class="stegoExampleH4">Alice is done</h4>
|
||
<p>
|
||
Alice sends Bob the text: <strong>"I like to eat apple tart with"</strong>
|
||
(plus whatever padding she wants to make it look like a complete
|
||
sentence). To any observer, this looks like someone generated a
|
||
sentence about food. Nothing suspicious.
|
||
</p>
|
||
|
||
<h3 class="stegoExampleH3">Decoding (Bob's Side)</h3>
|
||
<p>
|
||
Bob has the received text, the same model, the same shared context
|
||
<code>"I like to eat"</code>, and the same shared key (so the same
|
||
random stream: <code>0.15, 0.62, 0.40, ...</code>).
|
||
</p>
|
||
|
||
<h4 class="stegoExampleH4">Step D1: Bob runs the model</h4>
|
||
<p>
|
||
Bob feeds <code>"I like to eat"</code> into the model and gets the
|
||
<strong>same</strong> distribution Alice got. He sees Alice chose
|
||
<strong>apple</strong>, interval [0.00, 0.20). "Which half?" First half
|
||
[0.00, 0.50) → <strong>bit <code>0</code></strong> ✓
|
||
</p>
|
||
|
||
<h4 class="stegoExampleH4">Step D2: Bob runs the model again</h4>
|
||
<p>
|
||
Context: <code>"I like to eat apple"</code>. Same distribution as Alice's
|
||
Step 3. Bob sees Alice chose <strong>tart</strong>, interval [0.70, 0.86).
|
||
"Which half?" Second half [0.50, 1.00) → <strong>bit <code>1</code></strong> ✓
|
||
</p>
|
||
|
||
<h4 class="stegoExampleH4">Step D3: Bob runs the model again</h4>
|
||
<p>
|
||
Context: <code>"I like to eat apple tart"</code>. Same distribution as
|
||
Alice's Step 4. Bob sees Alice chose <strong>with</strong>, interval
|
||
[0.60, 0.78). "Which half?" Second half [0.50, 1.00) →
|
||
<strong>bit <code>1</code></strong> ✓
|
||
</p>
|
||
|
||
<h3 class="stegoExampleH3">Result</h3>
|
||
<p>
|
||
Bob has recovered: <code>0 1 1</code> — exactly the message Alice sent.
|
||
</p>
|
||
|
||
<h3 class="stegoExampleH3">Key Points</h3>
|
||
<ol>
|
||
<li><strong>The LLM never saw the secret message.</strong> The secret bits controlled which word was picked from the distribution. The LLM just produced distributions.</li>
|
||
<li><strong>The output looks natural.</strong> "I like to eat apple tart with" is a perfectly normal sentence. A censor seeing this has no reason to be suspicious.</li>
|
||
<li><strong>Both sides run the same model with the same context.</strong> This is why they get the same distributions. If Bob had a different model or different context, decoding would fail.</li>
|
||
<li><strong>The shared key provides the random stream.</strong> Without it, a censor could run the same model and try to decode. With the key, the censor can't reproduce the random choices.</li>
|
||
<li><strong>Each token encodes roughly 1 bit</strong> in this toy example (2-way split). With arithmetic coding over a 50,000-word vocabulary, you can encode ~2–4 bits per token.</li>
|
||
<li><strong>The "half" splitting is the simplified version.</strong> The real scheme (from the <a href="https://eprint.iacr.org/2021/686" target="_blank" rel="noopener">Meteor paper</a>) uses full arithmetic coding, which is more efficient. But the principle is the same: secret bits steer selection within the model's probability distribution.</li>
|
||
</ol>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Q4: What is your inspiration for this? -->
|
||
<div class="stegoFaqItem">
|
||
<button class="stegoCollapsibleToggle stegoFaqToggle" aria-expanded="false">
|
||
What is your inspiration for this? <span class="chevron">▸</span>
|
||
</button>
|
||
<div class="stegoCollapsibleContent stegoHidden">
|
||
<p>
|
||
The direct inspiration for this project is a
|
||
<a href="https://primal.net/e/nevent1qqstxmgsd0egtq57gj8mghckthsv79dhegh7xh97hwtwve6l56g4s9sqtjd7k" target="_blank" rel="noopener">post by waxwing on Nostr</a>
|
||
that walks through the core idea of using a language model's
|
||
next-token probability distribution as a steganographic carrier
|
||
channel. The post explains how secret bits can steer token selection
|
||
while preserving the model's output distribution, making the
|
||
resulting cover text statistically indistinguishable from normal
|
||
model output.
|
||
</p>
|
||
<p>
|
||
That post references the academic paper that formalized this
|
||
approach:
|
||
</p>
|
||
<ul>
|
||
<li>
|
||
<strong><a href="https://eprint.iacr.org/2021/686" target="_blank" rel="noopener">Meteor: A Simple and Practical Steganographic Protocol for LLMs</a></strong>
|
||
— this paper introduces the entropy-coding mechanism that makes
|
||
the output distribution-preserving. Instead of a naive 2-way
|
||
split, the full scheme uses arithmetic coding over the model's
|
||
probability distribution, achieving roughly 2–4 bits per token
|
||
while remaining information-theoretically undetectable to a censor
|
||
who only observes the channel.
|
||
</li>
|
||
</ul>
|
||
<p>
|
||
The half-splitting scheme implemented in this demo is a simplified
|
||
version of Meteor's approach: it guarantees exactly one bit per
|
||
token, is symmetric (encoder and decoder run the same logic), and
|
||
produces text that's indistinguishable from normal model output to
|
||
a casual reader. The full arithmetic-coding version from the paper
|
||
is more efficient but the core principle is identical — secret bits
|
||
steer selection within the model's probability distribution.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
</main>
|
||
|
||
<footer class="stegoFooter">
|
||
<p>Runs entirely in your browser via <a href="https://github.com/xenova/transformers.js" target="_blank" rel="noopener">Transformers.js</a> · GPT-2 model loaded locally</p>
|
||
</footer>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================================================================
|
||
FOOTER
|
||
================================================================
|
||
Three-section footer layout:
|
||
- Left: Relay status animations (HamburgerMorphing instances)
|
||
- Center: General status information
|
||
- Right: Additional information
|
||
================================================================ -->
|
||
<div id="divFooter">
|
||
<div id="divFooterLeft" class="divFooterBox"></div>
|
||
<div id="divFooterCenter" class="divFooterBox"></div>
|
||
<div id="divFooterRight" class="divFooterBox"></div>
|
||
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
|
||
</div>
|
||
|
||
<!-- ================================================================
|
||
SIDENAV
|
||
================================================================
|
||
Slide-out navigation panel. Opens from left when hamburger clicked.
|
||
Uses flexbox layout to pin version bar to bottom.
|
||
Includes a version bar footer with theme toggle and logout buttons.
|
||
================================================================ -->
|
||
<div id="divSideNav">
|
||
<div id="divSideNavHeader">
|
||
<!-- No close button - use main hamburger to close -->
|
||
</div>
|
||
|
||
<div id="divSideNavBody">
|
||
<div id="divFiles"></div>
|
||
</div>
|
||
|
||
<div id="divAiSection" class="sidenavSection">
|
||
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
|
||
<div id="divAiList" class="sidenavSectionList">
|
||
<div id="divAiProvidersList">No saved providers yet.</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<div id="divRelaySection">
|
||
<div id="divRelaySectionTitle">
|
||
リレー
|
||
</div>
|
||
<div id="divRelayList">
|
||
Loading relays...
|
||
</div>
|
||
</div>
|
||
|
||
<div id="divBlossomSection">
|
||
|
||
<div id="divBlossomSectionTitle">ブロッサム</div>
|
||
|
||
<div id="divBlossomList">Loading blossom servers...</div>
|
||
|
||
</div>
|
||
|
||
|
||
<div id="divVersionBar">
|
||
<span id="versionDisplay">v0.0.1</span>
|
||
<div id="divVersionBarButtons">
|
||
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
|
||
<div id="themeToggleHamburgerContainer"></div>
|
||
</button>
|
||
<button id="logoutButton" title="Logout">
|
||
<div id="logoutHamburgerContainer"></div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================================================================
|
||
REQUIRED SCRIPTS
|
||
================================================================
|
||
These scripts must be loaded in this order:
|
||
1. nostr.bundle.js - Nostr tools library
|
||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||
================================================================ -->
|
||
<script src="./nostr.bundle.js"></script>
|
||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||
|
||
<script type="module">
|
||
/* ================================================================
|
||
IMPORTS
|
||
================================================================
|
||
Import shared NDK functionality from init-ndk.mjs:
|
||
- initNDKPage() - Initialize authentication and worker
|
||
- getPubkey() - Get current user's pubkey
|
||
- subscribe() - Create NDK subscriptions
|
||
- publishEvent() - Publish events via NDK
|
||
- disconnect() - Disconnect from worker
|
||
- getRelayData() - Get relay connection data
|
||
- getRelayStats() - Get relay activity statistics
|
||
|
||
Import HamburgerMorphing for animated icons
|
||
================================================================ */
|
||
import {
|
||
initNDKPage,
|
||
getPubkey, injectHeaderAvatar, injectHeaderLoginButton,
|
||
subscribe,
|
||
publishEvent,
|
||
disconnect,
|
||
getVersion,
|
||
updateVersionDisplay,
|
||
getUserSettings,
|
||
patchUserSettings,
|
||
onUserSettings
|
||
} from './js/init-ndk.mjs';
|
||
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
|
||
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
|
||
|
||
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
|
||
|
||
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
|
||
// Version will be loaded asynchronously
|
||
const versionInfo = await getVersion();
|
||
const VERSION = versionInfo.VERSION;
|
||
console.log(`[llm-steganography.html ${VERSION}] Loading...`);
|
||
|
||
/* ================================================================
|
||
GLOBAL VARIABLES
|
||
================================================================
|
||
Track state for hamburger menu, relay status, and theme.
|
||
================================================================ */
|
||
let updateIntervalId = null;
|
||
let currentPubkey = null;
|
||
|
||
/*
|
||
AUTH STATE MODEL (Template reference)
|
||
------------------------------------------------------------------
|
||
This template now demonstrates three auth modes for standalone pages:
|
||
|
||
- required (default):
|
||
Behaves like existing pages: login is required immediately.
|
||
|
||
- optional:
|
||
Page can render public/read-only data without login, but can still
|
||
prompt login later for user actions (publish, settings, etc).
|
||
|
||
- none:
|
||
Never auto-login on load (pure public page).
|
||
|
||
URL behavior in this template:
|
||
- If ?auth=required|optional|none is present, it wins.
|
||
- Otherwise, if URL includes ?npub=... or ?pubkey=..., mode defaults
|
||
to optional because pages with explicit profile targets are commonly
|
||
public-readable.
|
||
- Otherwise, mode defaults to required.
|
||
*/
|
||
let isAuthenticated = false;
|
||
let authMode = 'required';
|
||
let authedPageInitialized = false;
|
||
let relayActivityListenersBound = false;
|
||
|
||
// Hamburger menu
|
||
let hamburgerInstance = null;
|
||
let isNavOpen = false;
|
||
|
||
// Version bar buttons
|
||
let logoutHamburger = null;
|
||
let themeToggleHamburger = null;
|
||
let isDarkMode = false;
|
||
|
||
// App-wide user settings (NIP-78 kind 30078, d:user-settings)
|
||
let pageSettings = {};
|
||
let unsubscribeUserSettings = null;
|
||
|
||
/* ================================================================
|
||
DOM VARIABLES
|
||
================================================================
|
||
Cache DOM element references for better performance.
|
||
================================================================ */
|
||
const divBody = document.getElementById("divBody");
|
||
const divSideNav = document.getElementById("divSideNav");
|
||
const divSideNavBody = document.getElementById("divSideNavBody");
|
||
const divFooterCenter = document.getElementById("divFooterCenter");
|
||
const divFooterRight = document.getElementById("divFooterRight");
|
||
|
||
/* ================================================================
|
||
HAMBURGER MENU
|
||
================================================================
|
||
Initialize and control the animated hamburger menu.
|
||
================================================================ */
|
||
function initHamburgerMenu() {
|
||
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
|
||
foreground: 'var(--primary-color)',
|
||
background: 'var(--secondary-color)',
|
||
hover: 'var(--accent-color)'
|
||
});
|
||
hamburgerInstance.animateTo('burger');
|
||
}
|
||
|
||
/* ================================================================
|
||
SIDENAV FUNCTIONS
|
||
================================================================
|
||
Open/close sidenav with hamburger morphing animation.
|
||
================================================================ */
|
||
function openNav() {
|
||
divSideNav.style.zIndex = 3;
|
||
divSideNav.style.width = "clamp(400px, 50vw, 600px)";
|
||
isNavOpen = true;
|
||
if (hamburgerInstance) {
|
||
hamburgerInstance.animateTo('arrow_left');
|
||
}
|
||
|
||
|
||
// Initialize version bar buttons when sidenav opens (lazy load)
|
||
if (!logoutHamburger) {
|
||
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
|
||
size: 24,
|
||
foreground: 'var(--primary-color)',
|
||
background: 'var(--secondary-color)',
|
||
hover: 'var(--accent-color)'
|
||
});
|
||
logoutHamburger.animateTo('x');
|
||
}
|
||
|
||
if (!themeToggleHamburger) {
|
||
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
|
||
size: 24,
|
||
foreground: 'var(--primary-color)',
|
||
background: 'var(--secondary-color)',
|
||
hover: 'var(--accent-color)'
|
||
});
|
||
|
||
// Determine current theme
|
||
const savedTheme = localStorage.getItem('theme');
|
||
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
|
||
const initialShape = isDarkMode ? 'moon' : 'circle';
|
||
themeToggleHamburger.animateTo(initialShape);
|
||
}
|
||
}
|
||
|
||
function closeNav() {
|
||
divSideNav.style.width = "0vw";
|
||
divSideNav.style.zIndex = -1;
|
||
isNavOpen = false;
|
||
if (hamburgerInstance) {
|
||
hamburgerInstance.animateTo('burger');
|
||
}
|
||
}
|
||
|
||
function toggleNav() {
|
||
if (isNavOpen) {
|
||
closeNav();
|
||
} else {
|
||
openNav();
|
||
}
|
||
}
|
||
|
||
|
||
/* ================================================================
|
||
AUTH MODE HELPERS
|
||
================================================================
|
||
These functions are meant as reusable guidance for future pages.
|
||
================================================================ */
|
||
function hasTargetPubkeyInUrl() {
|
||
const params = new URLSearchParams(window.location.search || '');
|
||
const npub = String(params.get('npub') || '').trim();
|
||
const pubkey = String(params.get('pubkey') || '').trim();
|
||
return Boolean(npub || pubkey);
|
||
}
|
||
|
||
function resolveAuthModeFromUrl() {
|
||
const params = new URLSearchParams(window.location.search || '');
|
||
const explicitAuth = String(params.get('auth') || '').trim().toLowerCase();
|
||
if (explicitAuth === 'required' || explicitAuth === 'optional' || explicitAuth === 'none') {
|
||
return explicitAuth;
|
||
}
|
||
|
||
// This page is a standalone demo that does not require Nostr auth.
|
||
// Default to 'none' so visitors are never prompted to log in; the
|
||
// sidenav logout button doubles as a "Sign in" entry point if a user
|
||
// wants to access relay/blossom/AI sections.
|
||
return 'none';
|
||
}
|
||
|
||
function isAuthRequiredError(error) {
|
||
const message = String(error?.message || error || '').toLowerCase();
|
||
return message.includes('authentication required');
|
||
}
|
||
|
||
async function initializeAuthentication(mode) {
|
||
// required: existing behavior, throw if auth fails.
|
||
if (mode === 'required') {
|
||
await initNDKPage();
|
||
currentPubkey = await getPubkey();
|
||
isAuthenticated = true;
|
||
return;
|
||
}
|
||
|
||
// none: public page, no login attempt on load.
|
||
if (mode === 'none') {
|
||
isAuthenticated = false;
|
||
currentPubkey = null;
|
||
return;
|
||
}
|
||
|
||
// optional: try silent/normal init; if auth required, continue public.
|
||
try {
|
||
await initNDKPage();
|
||
currentPubkey = await getPubkey();
|
||
isAuthenticated = true;
|
||
} catch (error) {
|
||
if (isAuthRequiredError(error)) {
|
||
console.log('[template.html] Optional auth mode: continuing unauthenticated');
|
||
isAuthenticated = false;
|
||
currentPubkey = null;
|
||
return;
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function initializeAuthenticatedPageFeatures() {
|
||
if (!isAuthenticated) {
|
||
await injectHeaderLoginButton();
|
||
return;
|
||
}
|
||
if (authedPageInitialized) return;
|
||
|
||
await injectHeaderAvatar(currentPubkey);
|
||
console.log('[template.html] Authenticated as:', currentPubkey);
|
||
|
||
// Hydrate app-wide user settings for this page
|
||
try {
|
||
pageSettings = await getUserSettings();
|
||
} catch (error) {
|
||
console.warn('[template.html] getUserSettings failed:', error);
|
||
pageSettings = {};
|
||
}
|
||
|
||
// Subscribe to live user settings updates (cross-tab + publish echoes)
|
||
if (!unsubscribeUserSettings) {
|
||
unsubscribeUserSettings = onUserSettings((settings) => {
|
||
pageSettings = settings || {};
|
||
// TODO: Re-render page-specific UI from pageSettings here.
|
||
});
|
||
}
|
||
|
||
// Initialize relay-dependent UI only once authenticated.
|
||
initFooterRelayStatus();
|
||
initSidenavRelaySection();
|
||
await initBlossomSection();
|
||
initAiSectionWithLocalConfig();
|
||
await UpdateFooter();
|
||
|
||
if (!updateIntervalId) {
|
||
updateIntervalId = setInterval(UpdateFooter, 1000);
|
||
}
|
||
|
||
// Relay activity listeners only matter after worker init/auth.
|
||
if (!relayActivityListenersBound) {
|
||
window.addEventListener('ndkRelayActivity', (event) => {
|
||
const { relayUrl, activity, stats } = event.detail;
|
||
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`, stats);
|
||
setRelayActivityState(relayUrl, activity);
|
||
});
|
||
|
||
window.addEventListener('message', (event) => {
|
||
if (event.data && event.data.type === 'relayActivity') {
|
||
const { relayUrl, activity } = event.data;
|
||
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`);
|
||
setRelayActivityState(relayUrl, activity);
|
||
}
|
||
});
|
||
|
||
relayActivityListenersBound = true;
|
||
}
|
||
|
||
authedPageInitialized = true;
|
||
}
|
||
|
||
async function promptLoginIfNeeded() {
|
||
if (isAuthenticated) return true;
|
||
|
||
await initNDKPage();
|
||
currentPubkey = await getPubkey();
|
||
isAuthenticated = true;
|
||
await initializeAuthenticatedPageFeatures();
|
||
return true;
|
||
}
|
||
|
||
/* ================================================================
|
||
UPDATE FOOTER
|
||
================================================================
|
||
Update footer sections with relay status, pubkey, and other info.
|
||
Called periodically by update loop.
|
||
================================================================ */
|
||
const UpdateFooter = async () => {
|
||
|
||
try {
|
||
// Update relay status visuals in footer and sidenav
|
||
await updateFooterRelayStatus();
|
||
await updateSidenavRelaySection();
|
||
|
||
await updateBlossomSection();
|
||
// Clear center and right sections
|
||
divFooterCenter.innerHTML = '';
|
||
divFooterRight.innerHTML = '';
|
||
} catch (error) {
|
||
console.error('[template.html] Error updating footer:', error);
|
||
}
|
||
};
|
||
|
||
/* ================================================================
|
||
LOGOUT
|
||
================================================================
|
||
Complete logout process:
|
||
1. Stop update loop
|
||
2. Disconnect from NDK worker
|
||
3. Logout from nostr-login-lite
|
||
4. Clear all storage (localStorage, sessionStorage, IndexedDB)
|
||
5. Reload page
|
||
================================================================ */
|
||
const Logout = async () => {
|
||
console.log("[template.html] Starting logout process...");
|
||
|
||
// Stop the update loop
|
||
if (updateIntervalId) {
|
||
clearInterval(updateIntervalId);
|
||
updateIntervalId = null;
|
||
}
|
||
|
||
// Disconnect from worker
|
||
disconnect();
|
||
|
||
// Logout from nostr-login-lite
|
||
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
|
||
await window.NOSTR_LOGIN_LITE.logout();
|
||
}
|
||
|
||
// Clear all storage
|
||
localStorage.clear();
|
||
sessionStorage.clear();
|
||
|
||
// Clear IndexedDB
|
||
if (window.indexedDB) {
|
||
const databases = await window.indexedDB.databases();
|
||
for (const db of databases) {
|
||
if (db.name) {
|
||
window.indexedDB.deleteDatabase(db.name);
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log("[template.html] Logged out, reloading page");
|
||
location.reload(true);
|
||
};
|
||
|
||
/* ================================================================
|
||
EVENT LISTENERS
|
||
================================================================
|
||
Wire up UI interactions.
|
||
Main hamburger button click handler is set up in main() after initialization.
|
||
================================================================ */
|
||
|
||
/* ================================================================
|
||
SUBSCRIPTION EXAMPLE
|
||
================================================================
|
||
Example of how to subscribe to Nostr events:
|
||
|
||
const sub = subscribe(
|
||
{ kinds: [1], authors: [pubkey], limit: 10 },
|
||
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
|
||
);
|
||
|
||
Cache usage options:
|
||
- 'CACHE_FIRST' - Check cache first, then relays
|
||
- 'ONLY_RELAY' - Only query relays
|
||
- 'ONLY_CACHE' - Only query cache
|
||
- 'PARALLEL' - Query cache and relays simultaneously
|
||
|
||
Listen for events via window events:
|
||
window.addEventListener('ndkEvent', (event) => {
|
||
const evt = event.detail;
|
||
console.log('Received event:', evt);
|
||
});
|
||
================================================================ */
|
||
|
||
/* ================================================================
|
||
PUBLISH EXAMPLE
|
||
================================================================
|
||
Example of how to publish a Nostr event:
|
||
|
||
const event = {
|
||
created_at: Math.floor(Date.now() / 1000),
|
||
kind: 1,
|
||
tags: [],
|
||
content: "Hello, Nostr!"
|
||
};
|
||
|
||
try {
|
||
const result = await publishEvent(event);
|
||
console.log("✅ Published to:", result.relayResults.successful);
|
||
console.log("❌ Failed:", result.relayResults.failed);
|
||
console.log("Total relays:", result.totalRelays);
|
||
} catch (error) {
|
||
console.error("Publish error:", error);
|
||
}
|
||
|
||
Note: Events are automatically signed by the NDK worker using
|
||
the message-based signer (which calls window.nostr.signEvent).
|
||
================================================================ */
|
||
|
||
/* ================================================================
|
||
USER SETTINGS EXAMPLE (NIP-78)
|
||
================================================================
|
||
Read/subscribe/write helper pattern for all pages:
|
||
|
||
// Read latest merged settings (cache + relay hydrated by worker)
|
||
const settings = await getUserSettings();
|
||
|
||
// Subscribe to cross-tab updates
|
||
const unsubscribe = onUserSettings((nextSettings) => {
|
||
// Re-render page from nextSettings
|
||
});
|
||
|
||
// Patch only your feature namespace
|
||
await patchUserSettings({
|
||
myFeature: {
|
||
someFlag: true
|
||
}
|
||
});
|
||
|
||
// On page teardown (if applicable)
|
||
// unsubscribe();
|
||
================================================================ */
|
||
|
||
/* ================================================================
|
||
INITIALIZATION
|
||
================================================================
|
||
Main initialization sequence:
|
||
1. Initialize hamburger menu
|
||
2. Set up hamburger click handler
|
||
3. Resolve auth mode from URL/query policy
|
||
4. Initialize authentication based on mode
|
||
5. Initialize authenticated-only features (if signed in)
|
||
6. Set up version bar button listeners
|
||
7. Restore sidenav state
|
||
8. Update version display
|
||
|
||
Notes:
|
||
- required mode = existing behavior (prompt login on load)
|
||
- optional mode = allow public load, login later on demand
|
||
- none mode = no auto-login on load
|
||
================================================================ */
|
||
(async function main() {
|
||
console.log("[llm-steganography.html] Starting initialization...");
|
||
|
||
try {
|
||
// Initialize hamburger menu first
|
||
initHamburgerMenu();
|
||
|
||
// Add click handler to hamburger
|
||
const divSvgHam = document.getElementById('divSvgHam');
|
||
if (divSvgHam) {
|
||
divSvgHam.addEventListener('click', toggleNav);
|
||
}
|
||
|
||
// Initialize version bar buttons
|
||
const themeToggleButton = document.getElementById('themeToggleButton');
|
||
const logoutButton = document.getElementById('logoutButton');
|
||
|
||
if (themeToggleButton) {
|
||
themeToggleButton.addEventListener('click', () => {
|
||
isDarkMode = !isDarkMode;
|
||
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
|
||
document.documentElement.classList.toggle('dark-mode', isDarkMode);
|
||
document.body.classList.toggle('dark-mode', isDarkMode);
|
||
if (themeToggleHamburger) {
|
||
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
|
||
}
|
||
});
|
||
}
|
||
|
||
if (logoutButton) {
|
||
logoutButton.addEventListener('click', async () => {
|
||
try {
|
||
// In optional/none modes this doubles as a "Sign in" entry point.
|
||
if (!isAuthenticated) {
|
||
await promptLoginIfNeeded();
|
||
return;
|
||
}
|
||
await Logout();
|
||
} catch (error) {
|
||
console.error('Logout/login action failed:', error);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Resolve and initialize page auth policy.
|
||
authMode = resolveAuthModeFromUrl();
|
||
console.log('[template.html] Resolved auth mode:', authMode);
|
||
await initializeAuthentication(authMode);
|
||
|
||
// Initialize authenticated features only when signed in.
|
||
await initializeAuthenticatedPageFeatures();
|
||
|
||
/* ============================================================
|
||
EXAMPLE: Subscribe to events
|
||
============================================================
|
||
Uncomment to subscribe to user's notes:
|
||
|
||
const notesSub = subscribe(
|
||
{ kinds: [1], authors: [currentPubkey], limit: 10 },
|
||
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
|
||
);
|
||
console.log("[template.html] Subscribed to kind 1");
|
||
============================================================ */
|
||
|
||
/* ============================================================
|
||
EXAMPLE: Listen for events from worker
|
||
============================================================
|
||
Uncomment to handle incoming events:
|
||
|
||
window.addEventListener('ndkEvent', (event) => {
|
||
const evt = event.detail;
|
||
console.log("[template.html] Received event:", evt.kind, evt.pubkey);
|
||
|
||
if (evt.pubkey === currentPubkey) {
|
||
if (evt.kind === 1) {
|
||
// Handle note event
|
||
console.log("Note:", evt.content);
|
||
}
|
||
}
|
||
});
|
||
============================================================ */
|
||
|
||
/* ============================================================
|
||
EXAMPLE: Listen for cached profile
|
||
============================================================
|
||
Uncomment to handle cached profile data:
|
||
|
||
window.addEventListener('ndkProfile', (event) => {
|
||
console.log("[template.html] Cached profile:", event.detail);
|
||
// event.detail contains profile object (name, about, etc.)
|
||
});
|
||
============================================================ */
|
||
|
||
|
||
// Optional UX note for public mode pages.
|
||
if (!isAuthenticated && (authMode === 'optional' || authMode === 'none')) {
|
||
divFooterCenter.textContent = 'Public mode';
|
||
divFooterRight.textContent = 'Sign in from side menu for private features';
|
||
}
|
||
|
||
// Update version display
|
||
await updateVersionDisplay();
|
||
|
||
console.log('[template.html] Initialization complete');
|
||
} catch (error) {
|
||
console.error('[llm-steganography.html] Initialization failed:', error);
|
||
divBody.innerHTML = `<div style="text-align: center; padding: 50px;">
|
||
<div style="font-size: 24px; margin-bottom: 20px; color: red;">❌ Authentication Error</div>
|
||
<div style="font-size: 16px; color: #666;">${error.message}</div>
|
||
<div style="margin-top: 20px;">
|
||
<button onclick="location.reload()" style="padding: 10px 20px; font-size: 16px;">Retry</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
})();
|
||
|
||
/* ================================================================
|
||
WORKER MESSAGE TYPES
|
||
================================================================
|
||
The NDK worker can send these message types:
|
||
|
||
1. 'response' - Response to init/subscribe/publish requests
|
||
- data.profile - User profile (from init)
|
||
- data.relays - User relays (from init)
|
||
- data.success - Publish success status
|
||
- data.relayResults - Relay publish results
|
||
|
||
2. 'event' - Nostr event from subscription
|
||
- Dispatched as 'ndkEvent' window event
|
||
- event.detail contains the Nostr event
|
||
|
||
3. 'eose' - End of stored events for subscription
|
||
- Dispatched as 'ndkEose' window event
|
||
- event.detail.subId contains subscription ID
|
||
|
||
4. 'signRequest' - Request to sign event/encrypt/decrypt
|
||
- Handled automatically by init-ndk.mjs
|
||
- Calls window.nostr methods and sends response
|
||
|
||
5. 'error' - Error from worker
|
||
- Logged to console automatically
|
||
|
||
6. 'relayActivity' - Relay read/write activity notification
|
||
- Dispatched as 'ndkRelayActivity' window event
|
||
- Used to animate relay status icons in footer
|
||
================================================================ */
|
||
|
||
/* ================================================================
|
||
DISTRIBUTED ARCHITECTURE NOTES
|
||
================================================================
|
||
Each page is independently accessible and self-contained:
|
||
|
||
1. Authentication persists via nostr-login-lite localStorage
|
||
- Login once on any page
|
||
- All other pages automatically authenticated
|
||
|
||
2. NDK SharedWorker is shared across all tabs/pages
|
||
- Single NDK instance manages all connections
|
||
- Subscriptions from all pages handled by one worker
|
||
- Events broadcast to all connected pages
|
||
|
||
3. Dexie cache is shared across all pages
|
||
- IndexedDB persists across sessions
|
||
- Cache-first queries are fast
|
||
- Reduces relay load
|
||
|
||
4. User settings are centralized and shared
|
||
- Worker hydrates kind 30078 (`d:user-settings`) on init
|
||
- Pages read via getUserSettings()
|
||
- Pages patch via patchUserSettings({ featureNamespace: ... })
|
||
- Pages subscribe via onUserSettings() for live updates
|
||
|
||
5. Each page can be distributed independently
|
||
- Copy template.html and customize
|
||
- No dependencies on other pages
|
||
- Works standalone or as part of suite
|
||
|
||
6. Message-based signer bridges worker and page
|
||
- Worker's NDK uses MessageBasedSigner
|
||
- Signer sends sign requests to page
|
||
- Page calls window.nostr.signEvent()
|
||
- Response sent back to worker
|
||
- NDK completes signing and publishing
|
||
|
||
7. Relay status visualization
|
||
- Footer left section shows connected relays
|
||
- Each relay has animated icon (HamburgerMorphing)
|
||
- Icons morph based on activity (read/write)
|
||
- Temporary animations show real-time activity
|
||
================================================================ */
|
||
</script>
|
||
|
||
<!-- ================================================================
|
||
LLM STEGANOGRAPHY DEMO
|
||
================================================================
|
||
Loads GPT-2 via Transformers.js and wires up the encode/decode UI.
|
||
Kept as a separate module so it doesn't interfere with the page's
|
||
NDK/auth initialization above.
|
||
================================================================ -->
|
||
<script type="module">
|
||
import {
|
||
AutoTokenizer,
|
||
AutoModelForCausalLM,
|
||
env,
|
||
} from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.0.0";
|
||
import { encode, decode } from "./js/stego.mjs";
|
||
|
||
// Use locally-hosted model files (./models/Xenova/gpt2/) so the page
|
||
// works over Tor/onion origins where HuggingFace CORS is blocked.
|
||
const MODEL_ID = "Xenova/gpt2";
|
||
env.allowLocalModels = true;
|
||
env.localModelPath = "./models/";
|
||
env.allowRemoteModels = false;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DOM references
|
||
// ---------------------------------------------------------------------------
|
||
const loadingSection = document.getElementById("stegoLoadingSection");
|
||
const loadingStatus = document.getElementById("stegoLoadingStatus");
|
||
const progressBar = document.getElementById("stegoProgressBar");
|
||
const progressFiles = document.getElementById("stegoProgressFiles");
|
||
|
||
const app = document.getElementById("stegoApp");
|
||
|
||
const contextInput = document.getElementById("stegoContextInput");
|
||
const keyInput = document.getElementById("stegoKeyInput");
|
||
|
||
const secretInput = document.getElementById("stegoSecretInput");
|
||
const encodeBtn = document.getElementById("stegoEncodeBtn");
|
||
const encodeStatus = document.getElementById("stegoEncodeStatus");
|
||
const coverOutput = document.getElementById("stegoCoverOutput");
|
||
const encodeInfo = document.getElementById("stegoEncodeInfo");
|
||
const encodeProgress = document.getElementById("stegoEncodeProgress");
|
||
const encodeCurrentChar = document.getElementById("stegoEncodeCurrentChar");
|
||
const encodeCharIndex = document.getElementById("stegoEncodeCharIndex");
|
||
const encodeTotalChars = document.getElementById("stegoEncodeTotalChars");
|
||
const encodeBitInfo = document.getElementById("stegoEncodeBitInfo");
|
||
const encodeProgressBar = document.getElementById("stegoEncodeProgressBar");
|
||
|
||
const decodeBtn = document.getElementById("stegoDecodeBtn");
|
||
const decodeStatus = document.getElementById("stegoDecodeStatus");
|
||
const recoveredOutput = document.getElementById("stegoRecoveredOutput");
|
||
const matchIndicator = document.getElementById("stegoMatchIndicator");
|
||
const decodeProgress = document.getElementById("stegoDecodeProgress");
|
||
const decodeCurrentToken = document.getElementById("stegoDecodeCurrentToken");
|
||
const decodeRecoveredBit = document.getElementById("stegoDecodeRecoveredBit");
|
||
const decodeBitInfo = document.getElementById("stegoDecodeBitInfo");
|
||
const decodeProgressBar = document.getElementById("stegoDecodeProgressBar");
|
||
const decodeRecoveredChars = document.getElementById("stegoDecodeRecoveredChars");
|
||
|
||
// Legacy single-toggle references removed — FAQ section now uses
|
||
// multiple .stegoFaqToggle buttons handled generically below.
|
||
|
||
// State
|
||
let model = null;
|
||
let tokenizer = null;
|
||
let lastCoverText = null;
|
||
let lastNumBits = null;
|
||
let lastSecret = null;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Collapsible FAQ toggles
|
||
// ---------------------------------------------------------------------------
|
||
// Each .stegoFaqToggle button toggles the visibility of the next
|
||
// .stegoCollapsibleContent sibling inside its .stegoFaqItem container.
|
||
document.querySelectorAll(".stegoFaqToggle").forEach((toggle) => {
|
||
toggle.addEventListener("click", () => {
|
||
const expanded = toggle.getAttribute("aria-expanded") === "true";
|
||
toggle.setAttribute("aria-expanded", String(!expanded));
|
||
const content = toggle.nextElementSibling;
|
||
if (content) {
|
||
content.classList.toggle("stegoHidden", expanded);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Model loading with progress
|
||
// ---------------------------------------------------------------------------
|
||
async function loadModel() {
|
||
loadingStatus.textContent = "Loading model...";
|
||
|
||
const progressCallback = (info) => {
|
||
if (info.status === "initiate") {
|
||
addProgressFile(info.file, "queued");
|
||
} else if (info.status === "download") {
|
||
addProgressFile(info.file, "downloading", info.progress);
|
||
} else if (info.status === "progress") {
|
||
updateProgressFile(info.file, info.progress);
|
||
updateOverallProgress();
|
||
} else if (info.status === "done") {
|
||
updateProgressFile(info.file, 100, true);
|
||
updateOverallProgress();
|
||
}
|
||
};
|
||
|
||
try {
|
||
const device = await maybeGetWebGPU();
|
||
if (device) {
|
||
env.backends.onnx.wasm.proxy = false;
|
||
loadingStatus.textContent = "WebGPU detected — loading model...";
|
||
} else {
|
||
loadingStatus.textContent = "Loading model (WASM backend)...";
|
||
}
|
||
|
||
tokenizer = await AutoTokenizer.from_pretrained(MODEL_ID, {
|
||
progress_callback: progressCallback,
|
||
});
|
||
model = await AutoModelForCausalLM.from_pretrained(MODEL_ID, {
|
||
progress_callback: progressCallback,
|
||
dtype: "int8",
|
||
});
|
||
|
||
loadingStatus.textContent = "Model ready!";
|
||
progressBar.style.width = "100%";
|
||
setTimeout(() => {
|
||
loadingSection.classList.add("stegoHidden");
|
||
app.classList.remove("stegoHidden");
|
||
}, 400);
|
||
} catch (err) {
|
||
loadingStatus.textContent = "Failed to load model.";
|
||
console.error(err);
|
||
addProgressFile(`Error: ${err.message || err}`, "error");
|
||
}
|
||
}
|
||
|
||
async function maybeGetWebGPU() {
|
||
try {
|
||
if (typeof navigator !== "undefined" && navigator.gpu) {
|
||
const adapter = await navigator.gpu.requestAdapter();
|
||
return adapter || null;
|
||
}
|
||
} catch (_) {}
|
||
return null;
|
||
}
|
||
|
||
const fileProgress = new Map();
|
||
|
||
function addProgressFile(file, state, progress = 0) {
|
||
if (!file) return;
|
||
if (!fileProgress.has(file)) {
|
||
const div = document.createElement("div");
|
||
div.dataset.file = file;
|
||
progressFiles.appendChild(div);
|
||
fileProgress.set(file, div);
|
||
}
|
||
updateProgressFile(file, progress, state === "done");
|
||
}
|
||
|
||
function updateProgressFile(file, progress, done = false) {
|
||
const div = fileProgress.get(file);
|
||
if (!div) return;
|
||
const pct = done ? 100 : Math.round(progress || 0);
|
||
const name = file.split("/").pop();
|
||
div.textContent = `${name} — ${pct}%`;
|
||
}
|
||
|
||
function updateOverallProgress() {
|
||
if (fileProgress.size === 0) return;
|
||
let total = 0;
|
||
for (const div of fileProgress.values()) {
|
||
const m = div.textContent.match(/(\d+)%/);
|
||
if (m) total += parseInt(m[1], 10);
|
||
}
|
||
const avg = total / fileProgress.size;
|
||
progressBar.style.width = `${Math.min(100, avg)}%`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Encode button
|
||
// ---------------------------------------------------------------------------
|
||
encodeBtn.addEventListener("click", async () => {
|
||
if (!model || !tokenizer) return;
|
||
const secret = secretInput.value;
|
||
const context = contextInput.value;
|
||
const key = parseInt(keyInput.value, 10);
|
||
|
||
if (!secret) {
|
||
encodeStatus.textContent = "Please enter a secret message.";
|
||
return;
|
||
}
|
||
if (Number.isNaN(key)) {
|
||
encodeStatus.textContent = "Shared key must be an integer.";
|
||
return;
|
||
}
|
||
|
||
setBusy(true);
|
||
encodeStatus.textContent = "Encoding...";
|
||
coverOutput.value = "";
|
||
encodeInfo.textContent = "";
|
||
matchIndicator.textContent = "";
|
||
recoveredOutput.value = "";
|
||
encodeProgress.classList.remove("stegoHidden");
|
||
encodeProgressBar.style.width = "0%";
|
||
encodeTotalChars.textContent = String(secret.length);
|
||
encodeCharIndex.textContent = "0";
|
||
encodeCurrentChar.textContent = "";
|
||
encodeBitInfo.textContent = `bit 0 of ${secret.length * 8}`;
|
||
|
||
try {
|
||
await new Promise(r => setTimeout(r, 0));
|
||
const result = await encode(
|
||
model, tokenizer, secret, context, key, 3,
|
||
(textSoFar, bitNum, totalBits, currentChar, charIndex, totalChars) => {
|
||
coverOutput.value = textSoFar;
|
||
encodeCurrentChar.textContent = currentChar;
|
||
encodeCharIndex.textContent = String(charIndex);
|
||
encodeTotalChars.textContent = String(totalChars);
|
||
encodeBitInfo.textContent = `bit ${bitNum} of ${totalBits}`;
|
||
const pct = totalBits > 0 ? (bitNum / totalBits) * 100 : 0;
|
||
encodeProgressBar.style.width = `${pct}%`;
|
||
encodeStatus.textContent =
|
||
`Encoding character "${currentChar}" (${charIndex}/${totalChars}) — bit ${bitNum} of ${totalBits}`;
|
||
}
|
||
);
|
||
lastCoverText = result.coverText;
|
||
lastNumBits = result.numBits;
|
||
lastSecret = secret;
|
||
coverOutput.value = result.coverText;
|
||
encodeProgressBar.style.width = "100%";
|
||
const genTokens = result.tokenCount - tokenizeContextLength(context);
|
||
const dataTokens = genTokens - 3;
|
||
encodeInfo.textContent =
|
||
`Encoded "${secret}" (${result.numBits} bits) in ${dataTokens} tokens ` +
|
||
`+ 3 padding tokens. Total tokens: ${result.tokenCount}.`;
|
||
encodeStatus.textContent = "Encoding complete.";
|
||
encodeProgress.classList.add("stegoHidden");
|
||
} catch (err) {
|
||
console.error(err);
|
||
encodeStatus.textContent = `Error: ${err.message || err}`;
|
||
encodeProgress.classList.add("stegoHidden");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Decode button
|
||
// ---------------------------------------------------------------------------
|
||
decodeBtn.addEventListener("click", async () => {
|
||
if (!model || !tokenizer) return;
|
||
if (!lastCoverText || lastNumBits == null) {
|
||
decodeStatus.textContent = "Encode a message first.";
|
||
return;
|
||
}
|
||
const context = contextInput.value;
|
||
const key = parseInt(keyInput.value, 10);
|
||
if (Number.isNaN(key)) {
|
||
decodeStatus.textContent = "Shared key must be an integer.";
|
||
return;
|
||
}
|
||
|
||
setBusy(true);
|
||
decodeStatus.textContent = "Decoding...";
|
||
recoveredOutput.value = "";
|
||
matchIndicator.textContent = "";
|
||
matchIndicator.className = "stegoInfo";
|
||
decodeProgress.classList.remove("stegoHidden");
|
||
decodeProgressBar.style.width = "0%";
|
||
decodeCurrentToken.textContent = "";
|
||
decodeRecoveredBit.textContent = "-";
|
||
decodeBitInfo.textContent = `bit 0 of ${lastNumBits}`;
|
||
decodeRecoveredChars.textContent = "";
|
||
|
||
try {
|
||
await new Promise(r => setTimeout(r, 0));
|
||
const recovered = await decode(
|
||
model, tokenizer, lastCoverText, context, key, lastNumBits, 3,
|
||
(bitsRecovered, totalBits, recoveredChars, currentToken, recoveredBit) => {
|
||
decodeCurrentToken.textContent = `"${currentToken}"`;
|
||
decodeRecoveredBit.textContent = String(recoveredBit);
|
||
decodeBitInfo.textContent = `Decoding... bit ${bitsRecovered} of ${totalBits}`;
|
||
decodeRecoveredChars.textContent = recoveredChars;
|
||
const pct = totalBits > 0 ? (bitsRecovered / totalBits) * 100 : 0;
|
||
decodeProgressBar.style.width = `${pct}%`;
|
||
decodeStatus.textContent =
|
||
`Processing token: "${currentToken}" → bit ${recoveredBit}`;
|
||
}
|
||
);
|
||
recoveredOutput.value = recovered;
|
||
decodeProgressBar.style.width = "100%";
|
||
const dataTokens = lastNumBits;
|
||
if (recovered === lastSecret) {
|
||
matchIndicator.textContent =
|
||
`Decoded ${lastNumBits} bits from ${dataTokens} tokens\n` +
|
||
`Recovered message: ${recovered}\n` +
|
||
`✓ Match!`;
|
||
matchIndicator.className = "stegoInfo match-success";
|
||
} else {
|
||
matchIndicator.textContent =
|
||
`Decoded ${lastNumBits} bits from ${dataTokens} tokens\n` +
|
||
`Recovered message: ${recovered}\n` +
|
||
`✗ Mismatch. Expected "${lastSecret}", got "${recovered}".`;
|
||
matchIndicator.className = "stegoInfo match-fail";
|
||
}
|
||
decodeStatus.textContent = "Decoding complete.";
|
||
decodeProgress.classList.add("stegoHidden");
|
||
} catch (err) {
|
||
console.error(err);
|
||
decodeStatus.textContent = `Error: ${err.message || err}`;
|
||
decodeProgress.classList.add("stegoHidden");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
function setBusy(busy) {
|
||
encodeBtn.disabled = busy;
|
||
decodeBtn.disabled = busy;
|
||
}
|
||
|
||
function tokenizeContextLength(context) {
|
||
if (!tokenizer) return 0;
|
||
try {
|
||
const inputs = tokenizer(context, { add_special_tokens: false });
|
||
let ids = inputs?.input_ids ?? inputs;
|
||
if (ids && typeof ids.data !== "undefined") return ids.data.length;
|
||
if (Array.isArray(ids)) {
|
||
return Array.isArray(ids[0]) ? ids[0].length : ids.length;
|
||
}
|
||
return 0;
|
||
} catch (_) {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
// Kick off model loading on page load
|
||
loadModel();
|
||
</script>
|
||
</body>
|
||
|
||
</html>
|