Files
client/www/llm-steganography.html

1676 lines
63 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(--muted-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(--muted-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(--muted-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(--muted-color);
font-size: 13px;
margin: 8px 0;
min-height: 1.2em;
}
.stegoInfo {
font-size: 13px;
color: var(--muted-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(--muted-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;
}
.stegoFooter {
text-align: center;
color: var(--muted-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>
Suppose the context is <code>"I like to eat"</code> and your secret
message is <code>"HI"</code>. The letter <code>H</code> is byte
<code>0x48</code> = bits <code>01001000</code>, and <code>I</code> is
<code>0x49</code> = bits <code>01001001</code>. So the encoder needs to
hide 16 bits total.
</p>
<p>
For the <strong>first bit (0)</strong>, GPT-2 looks at
<code>"I like to eat"</code> and ranks every possible next token by
probability. It splits that ranked list at the 50% cumulative-probability
mark into a "high-prob" half and a "low-prob" half. Since the bit is
<code>0</code>, the encoder samples a token from the <em>high-prob</em>
half — say <code>" pizza"</code>. The cover text is now
<code>"I like to eat pizza"</code>.
</p>
<p>
For the <strong>second bit (1)</strong>, GPT-2 now looks at
<code>"I like to eat pizza"</code> and produces a fresh distribution.
The bit is <code>1</code>, so the encoder samples from the
<em>low-prob</em> half — maybe <code>" and"</code>. Cover text:
<code>"I like to eat pizza and"</code>.
</p>
<p>
This continues for all 16 bits, then a few padding tokens are added so
the text ends naturally. The final cover text might read something like:
</p>
<p style="font-family: var(--font-mono, monospace); background: var(--background-color); padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border-color);">
"I like to eat pizza and pasta with my friends on weekends."
</p>
<p>
To anyone else, that's just a normal sentence. But the decoder — who
knows the same context, key, and model — re-runs GPT-2 at each step,
checks which half each token fell into, and recovers the bits
<code>01001000 01001001</code><code>"HI"</code>.
</p>
</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>
This project is inspired by research into <strong>linguistic
steganography</strong> — the art of hiding information within natural
language. Traditional steganography hides data in images, audio, or
file metadata, but text-based steganography is harder because text is
compact and every character is visible.
</p>
<p>
The breakthrough idea is to use a <strong>language model's own
probability distribution</strong> as the carrier channel. Instead of
tweaking pixels or LSBs, you steer which token the model emits next,
encoding one secret bit per token. Key inspirations include:
</p>
<ul>
<li><strong>Entropy coding approaches</strong> (e.g. Cachin's
information-theoretic steganography) that treat the cover as a
probability distribution and map secret bits onto it.</li>
<li><strong>"Dissociated Press" / Markov-chain steganography</strong>
— early experiments that used statistical text generation to hide
messages, limited by the poor quality of pre-LLM language models.</li>
<li><strong>Modern LLM steganography research</strong> — papers that
use GPT-style models to achieve near-perfect cover text quality
while encoding multiple bits per token via arithmetic or
half-splitting codes.</li>
</ul>
<p>
The half-splitting scheme used here is one of the simplest robust
approaches: 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.
</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";
const MODEL_ID = "Xenova/gpt2";
env.allowLocalModels = 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>