Files
ben.de-roo.org/gpt/index.html
T
2026-05-14 13:47:01 +02:00

1700 lines
65 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NEURAL-NET TERMINAL v3.0</title>
<style>
:root {
--green: #33ff66;
--green-dim: #1a8033;
--green-bright: #88ffaa;
--green-glow: rgba(51,255,102,0.15);
--red: #ff4444;
--red-dim: #882222;
--amber: #ffaa33;
--amber-dim: #886622;
--cyan: #33ffee;
--cyan-dim: #1a8877;
--bg: #050a05;
--bg-panel: #080f08;
--border: #1a3d1a;
--border-bright:#33ff66;
/* theme variables switched by JS */
--t-fg: var(--green);
--t-fg-dim: var(--green-dim);
--t-fg-br: var(--green-bright);
--t-glow: var(--green-glow);
--t-border: var(--border);
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
background: var(--bg);
color: var(--t-fg);
font-family: 'Courier New', Consolas, monospace;
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
overflow: hidden;
transition: color .3s;
}
/* ── CRT OVERLAY ───────────────────────── */
.crt {
position: fixed; inset: 0;
pointer-events: none; z-index: 1000;
}
.crt::before {
content: '';
position: absolute; inset: 0;
background: repeating-linear-gradient(
0deg,
rgba(0,0,0,.18) 0px, rgba(0,0,0,.18) 1px,
transparent 1px, transparent 2px
);
}
.crt::after {
content: '';
position: absolute; inset: 0;
background: radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,.55) 100%);
}
/* ── LAYOUT ────────────────────────────── */
.terminal {
height: 100vh;
display: flex;
flex-direction: column;
padding: 8px;
animation: flicker 10s infinite;
position: relative;
}
@keyframes flicker {
0%,89%,91%,100% { opacity:1; }
90% { opacity:.82; }
}
/* ── HEADER ────────────────────────────── */
.header {
border: 1px solid var(--t-fg-dim);
padding: 6px 12px;
margin-bottom: 6px;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
background: var(--bg-panel);
}
.header-title {
font-weight: bold;
letter-spacing: 3px;
font-size: 13px;
text-shadow: 0 0 10px var(--t-fg);
}
.header-right {
display: flex;
gap: 20px;
font-size: 11px;
color: var(--t-fg-dim);
align-items: center;
}
.status-indicator { display:flex; align-items:center; gap:5px; }
.status-dot {
width:8px; height:8px; border-radius:50%;
background: var(--red);
box-shadow: 0 0 6px var(--red);
transition: background .4s, box-shadow .4s;
}
.status-dot.online {
background: var(--t-fg);
box-shadow: 0 0 8px var(--t-fg);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,100%{ box-shadow: 0 0 6px var(--t-fg); }
50% { box-shadow: 0 0 14px var(--t-fg); }
}
.header-model-tag {
padding: 2px 7px;
border: 1px solid var(--t-fg-dim);
font-size: 10px;
letter-spacing:1px;
color: var(--t-fg-dim);
}
/* ── BODY SPLIT ────────────────────────── */
.body-area {
flex: 1;
display: flex;
gap: 6px;
overflow: hidden;
min-height: 0;
}
/* ── SIDEBAR ───────────────────────────── */
.sidebar {
width: 200px;
flex-shrink: 0;
border: 1px solid var(--t-fg-dim);
background: var(--bg-panel);
display: flex;
flex-direction: column;
overflow: hidden;
font-size: 12px;
}
.sidebar-section {
border-bottom: 1px solid var(--t-fg-dim);
padding: 6px 8px;
}
.sidebar-section-title {
color: var(--t-fg-dim);
font-size: 10px;
letter-spacing:2px;
margin-bottom: 5px;
}
.sidebar-item {
padding: 3px 0;
cursor: pointer;
color: var(--t-fg-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color .15s;
}
.sidebar-item:hover { color: var(--t-fg); }
.sidebar-item.active { color: var(--t-fg-br); }
.sidebar-item::before { content: '▸ '; opacity:.5; }
.sidebar-stats { padding: 6px 8px; flex:1; overflow-y:auto; scrollbar-width:none; }
.stat-row { display:flex; justify-content:space-between; margin-bottom:3px; }
.stat-label { color: var(--t-fg-dim); }
.stat-val { color: var(--t-fg); }
.stat-bar-wrap { margin: 3px 0 6px; height:4px; background: var(--t-border); }
.stat-bar { height:4px; background: var(--t-fg); transition:width .5s; }
/* ── OUTPUT ────────────────────────────── */
.output {
flex:1;
overflow-y: auto;
border: 1px solid var(--t-fg-dim);
padding: 10px 12px;
background: var(--bg);
scrollbar-width: thin;
scrollbar-color: var(--t-fg-dim) transparent;
}
.output-line {
margin-bottom: 3px;
word-wrap: break-word;
line-height: 1.55;
animation: linein .08s ease-out;
}
@keyframes linein {
from { opacity:0; transform:translateX(-4px); }
to { opacity:1; transform:translateX(0); }
}
.output-line.user { color: var(--t-fg-br); }
.output-line.user::before { content:'> '; color:var(--t-fg-dim); }
.output-line.system { color: var(--t-fg-dim); font-style:italic; }
.output-line.ai { color: var(--t-fg); padding-left:2ch; }
.output-line.error { color: var(--red); }
.output-line.warn { color: var(--amber); }
.output-line.info { color: var(--cyan); }
.output-line.success{ color: var(--green-bright); }
.output-line.divider{ color: var(--t-fg-dim); user-select:none; }
.typing-cursor::after {
content:'█';
animation: blink .7s step-end infinite;
}
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
/* ── TABS ──────────────────────────────── */
.tab-bar {
display: flex;
gap: 4px;
margin-bottom: 6px;
flex-shrink: 0;
}
.tab {
padding: 4px 14px;
border: 1px solid var(--t-fg-dim);
font-family: inherit;
font-size: 12px;
cursor: pointer;
background: var(--bg-panel);
color: var(--t-fg-dim);
letter-spacing:1px;
transition: all .15s;
}
.tab:hover { color: var(--t-fg); border-color: var(--t-fg); }
.tab.active {
color: var(--t-fg);
border-color: var(--t-fg);
background: var(--t-glow);
}
.tab-content { display:none; }
.tab-content.active { display:flex; flex:1; min-height:0; overflow:hidden; }
/* ── INPUT AREA ────────────────────────── */
.input-wrap {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.input-area {
border: 1px solid var(--t-fg-dim);
padding: 7px 12px;
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-panel);
transition: border-color .2s;
}
.input-area:focus-within { border-color: var(--t-fg); }
.input-prompt { color: var(--t-fg); font-weight:bold; flex-shrink:0; }
.input-field {
flex:1;
background: transparent; border:none; outline:none;
color: var(--t-fg);
font-family:inherit; font-size:inherit;
caret-color: var(--t-fg);
}
.input-field::placeholder { color:var(--t-fg-dim); }
.input-hint {
font-size: 11px;
color: var(--t-fg-dim);
display: flex;
justify-content: space-between;
padding: 0 2px;
}
.token-counter { color: var(--t-fg-dim); }
.token-counter.warn { color: var(--amber); }
/* ── FOOTER ────────────────────────────── */
.footer {
display:flex; justify-content:space-between;
padding: 4px 0;
font-size: 11px;
color: var(--t-fg-dim);
flex-shrink: 0;
}
/* ── MODAL ─────────────────────────────── */
.modal-backdrop {
display:none;
position:fixed; inset:0;
background: rgba(0,0,0,.7);
z-index: 500;
}
.modal-backdrop.visible { display:flex; align-items:center; justify-content:center; }
.modal {
background: var(--bg);
border: 2px solid var(--t-fg);
padding: 20px;
min-width: 440px;
max-width: 92vw;
max-height: 90vh;
overflow-y:auto;
scrollbar-width:thin;
scrollbar-color: var(--t-fg-dim) transparent;
}
.modal h2 { margin-bottom:14px; font-size:13px; letter-spacing:3px; }
.modal-tabs { display:flex; gap:4px; margin-bottom:16px; }
.modal-tab {
padding:3px 12px;
border:1px solid var(--t-fg-dim);
font-family:inherit; font-size:11px;
cursor:pointer; background:transparent;
color:var(--t-fg-dim); letter-spacing:1px;
}
.modal-tab.active { color:var(--t-fg); border-color:var(--t-fg); background:var(--t-glow); }
.modal-section { display:none; }
.modal-section.active { display:block; }
.cfg-group { margin-bottom:11px; }
.cfg-group label { display:block; margin-bottom:3px; color:var(--t-fg-dim); font-size:11px; letter-spacing:1px; }
.cfg-group input,
.cfg-group select,
.cfg-group textarea {
width:100%;
background: var(--bg-panel);
border: 1px solid var(--t-fg-dim);
color: var(--t-fg);
padding: 6px 8px;
font-family:inherit; font-size:13px;
transition: border-color .2s;
}
.cfg-group input:focus,
.cfg-group select:focus,
.cfg-group textarea:focus { outline:none; border-color:var(--t-fg); }
.cfg-group textarea { resize:vertical; min-height:70px; }
.cfg-group input[type=range] {
padding:0; border:none; background:transparent;
accent-color: var(--t-fg);
}
.range-row { display:flex; gap:10px; align-items:center; }
.range-val { color:var(--t-fg); min-width:30px; text-align:right; }
.cfg-buttons { display:flex; gap:8px; margin-top:16px; }
.cfg-btn {
flex:1; background:transparent;
border:1px solid var(--t-fg-dim);
color:var(--t-fg); padding:8px;
font-family:inherit; cursor:pointer; letter-spacing:1px; font-size:12px;
transition: all .15s;
}
.cfg-btn:hover { background:var(--t-fg-dim); color:var(--bg); }
.cfg-btn.primary { border-color:var(--t-fg); }
.cfg-btn.danger { border-color:var(--red); color:var(--red); }
.cfg-btn.danger:hover { background:var(--red); color:var(--bg); }
.toggle-row {
display:flex; justify-content:space-between; align-items:center;
margin-bottom:8px;
}
.toggle-label { color:var(--t-fg-dim); font-size:12px; }
.toggle {
position:relative; width:36px; height:18px;
display:inline-block;
}
.toggle input { opacity:0; width:0; height:0; }
.toggle-slider {
position:absolute; inset:0; cursor:pointer;
background: var(--t-fg-dim);
transition:.3s;
}
.toggle-slider::before {
content:''; position:absolute;
width:12px; height:12px;
left:3px; bottom:3px;
background:var(--bg); transition:.3s;
}
.toggle input:checked + .toggle-slider { background:var(--t-fg); }
.toggle input:checked + .toggle-slider::before { transform:translateX(18px); }
.badge {
font-size:10px; padding:1px 6px;
border:1px solid var(--t-fg-dim);
color:var(--t-fg-dim); letter-spacing:1px;
}
.badge.ok { border-color:var(--t-fg); color:var(--t-fg); }
.badge.bad { border-color:var(--red); color:var(--red); }
/* ── HISTORY PANEL ─────────────────────── */
.history-list { font-size:12px; }
.hist-entry {
padding: 5px 6px;
border-bottom: 1px solid var(--t-border);
cursor:pointer;
display:flex; justify-content:space-between;
gap:10px;
}
.hist-entry:hover { background: var(--t-glow); }
.hist-text { color:var(--t-fg-dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; }
.hist-time { color:var(--t-fg-dim); font-size:10px; flex-shrink:0; opacity:.6; }
/* ── NOTIFICATIONS ─────────────────────── */
.notif-tray {
position:fixed; top:50px; right:14px;
display:flex; flex-direction:column; gap:5px;
z-index:900; pointer-events:none;
}
.notif {
background:var(--bg-panel);
border:1px solid var(--t-fg);
padding: 7px 14px;
font-size:12px;
opacity:0;
transform:translateX(30px);
animation: notifIn .3s forwards, notifOut .4s 2.5s forwards;
max-width:260px;
}
@keyframes notifIn { to{ opacity:1; transform:translateX(0); } }
@keyframes notifOut { to{ opacity:0; transform:translateX(30px); } }
/* ── MEMORY CHIPS ──────────────────────── */
.memory-chip {
display:inline-block;
padding:1px 7px;
border:1px solid var(--t-fg-dim);
color:var(--t-fg-dim);
font-size:11px;
margin:2px 2px 2px 0;
cursor:pointer;
transition:all .15s;
}
.memory-chip:hover { border-color:var(--t-fg); color:var(--t-fg); }
/* ── SCROLLBAR ─────────────────────────── */
.output::-webkit-scrollbar { width:5px; }
.output::-webkit-scrollbar-thumb { background:var(--t-fg-dim); }
.output::-webkit-scrollbar-track { background:transparent; }
/* ── THEME AMBER ───────────────────────── */
body.theme-amber {
--t-fg: var(--amber);
--t-fg-dim: var(--amber-dim);
--t-fg-br: #ffcc88;
--t-glow: rgba(255,170,51,.12);
--t-border: #3d2e1a;
}
body.theme-cyan {
--t-fg: var(--cyan);
--t-fg-dim: var(--cyan-dim);
--t-fg-br: #88ffee;
--t-glow: rgba(51,255,238,.1);
--t-border: #1a3d3a;
}
body.theme-red {
--t-fg: var(--red);
--t-fg-dim: var(--red-dim);
--t-fg-br: #ff8888;
--t-glow: rgba(255,68,68,.1);
--t-border: #3d1a1a;
}
/* ── SCANLINE INTENSITY ────────────────── */
body.scanlines-off .crt::before { display:none; }
body.scanlines-low .crt::before { opacity:.4; }
body.vignette-off .crt::after { display:none; }
/* ── PROGRESS BAR ──────────────────────── */
.progress-wrap {
height:3px; background:var(--t-border); margin:4px 0;
display:none;
}
.progress-wrap.active { display:block; }
.progress-bar {
height:100%; background:var(--t-fg);
width:0; transition:width .3s;
box-shadow:0 0 8px var(--t-fg);
}
/* ── MACRO BUTTONS ─────────────────────── */
.macro-row {
display:flex; gap:4px; flex-wrap:wrap; flex-shrink:0; margin-top:4px;
}
.macro-btn {
padding:2px 9px;
border:1px solid var(--t-fg-dim);
background:transparent; color:var(--t-fg-dim);
font-family:inherit; font-size:11px; cursor:pointer; letter-spacing:.5px;
transition:all .15s;
}
.macro-btn:hover { color:var(--t-fg); border-color:var(--t-fg); }
</style>
</head>
<body class="theme-green">
<div class="crt"></div>
<div class="notif-tray" id="notifTray"></div>
<div class="terminal">
<!-- HEADER -->
<div class="header">
<span class="header-title">NEURAL-NET TERMINAL v3.0</span>
<div class="header-right">
<span class="header-model-tag" id="modelTag">NO MODEL</span>
<div class="status-indicator">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">OFFLINE</span>
</div>
<span style="cursor:pointer;color:var(--t-fg-dim)" onclick="openConfig()" title="Config [F2]"></span>
</div>
</div>
<!-- TABS -->
<div class="tab-bar">
<button class="tab active" data-tab="chat">CHAT</button>
<button class="tab" data-tab="history">HISTORY</button>
<button class="tab" data-tab="memory">MEMORY</button>
<button class="tab" data-tab="debug">DEBUG</button>
</div>
<!-- BODY AREA -->
<div class="body-area">
<!-- ── CHAT TAB ── -->
<div class="tab-content active" id="tab-chat">
<div class="sidebar" id="sidebar">
<div class="sidebar-section">
<div class="sidebar-section-title">SESSIONS</div>
<div id="sessionList"></div>
</div>
<div class="sidebar-stats" id="sidebarStats"></div>
</div>
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;gap:4px;">
<div class="output" id="output"></div>
<div class="progress-wrap" id="progressWrap">
<div class="progress-bar" id="progressBar"></div>
</div>
</div>
</div>
<!-- ── HISTORY TAB ── -->
<div class="tab-content" id="tab-history">
<div style="flex:1;border:1px solid var(--t-fg-dim);overflow-y:auto;background:var(--bg-panel)">
<div class="history-list" id="historyList"></div>
</div>
</div>
<!-- ── MEMORY TAB ── -->
<div class="tab-content" id="tab-memory">
<div style="flex:1;border:1px solid var(--t-fg-dim);padding:10px;background:var(--bg-panel);overflow-y:auto;">
<div class="sidebar-section-title" style="margin-bottom:8px">CONTEXT MEMORY</div>
<div id="memoryChips"></div>
<div style="margin-top:12px;color:var(--t-fg-dim);font-size:11px" id="memoryInfo"></div>
</div>
</div>
<!-- ── DEBUG TAB ── -->
<div class="tab-content" id="tab-debug">
<div style="flex:1;border:1px solid var(--t-fg-dim);padding:10px;background:var(--bg-panel);overflow-y:auto;font-size:12px;">
<div class="sidebar-section-title" style="margin-bottom:8px">LAST REQUEST / RESPONSE</div>
<pre id="debugPre" style="white-space:pre-wrap;color:var(--t-fg-dim);word-break:break-all"></pre>
</div>
</div>
</div><!-- /body-area -->
<!-- INPUT -->
<div class="input-wrap">
<div class="input-area">
<span class="input-prompt" id="inputPrompt">$</span>
<input type="text" class="input-field" id="inputField" placeholder="Enter command or message..." autocomplete="off" spellcheck="false">
<span class="token-counter" id="tokenCounter">0t</span>
</div>
<div class="input-hint">
<span>↑↓ history &nbsp;|&nbsp; Tab autocomplete &nbsp;|&nbsp; F2 config &nbsp;|&nbsp; F1 help &nbsp;|&nbsp; Shift+Enter multi-line</span>
<span id="charCount">0 / ∞</span>
</div>
<div class="macro-row" id="macroRow"></div>
</div>
<div class="footer">
<span id="footerLeft">CMDS: help | config | clear | theme | session | memory | stats | macro</span>
<span id="timeDisplay"></span>
</div>
</div>
<!-- ══════════════════════════════════
CONFIG MODAL
══════════════════════════════════ -->
<div class="modal-backdrop" id="configBackdrop">
<div class="modal">
<h2>[ NEURAL-NET CONFIGURATION ]</h2>
<div class="modal-tabs">
<button class="modal-tab active" data-section="connection">CONNECTION</button>
<button class="modal-tab" data-section="params">PARAMETERS</button>
<button class="modal-tab" data-section="system">SYSTEM PROMPT</button>
<button class="modal-tab" data-section="interface">INTERFACE</button>
<button class="modal-tab" data-section="macros">MACROS</button>
<button class="modal-tab" data-section="voice">VOICE</button>
</div>
<!-- CONNECTION -->
<div class="modal-section active" id="section-connection">
<div class="cfg-group">
<label>PRESET</label>
<select id="presetSelect">
<option value="">-- Select Preset --</option>
<option value="ollama">Ollama (localhost:11434)</option>
<option value="lmstudio">LM Studio (localhost:1234)</option>
<option value="gpt4all">GPT4All (localhost:4891)</option>
<option value="catgpt">Cat GPT (LAN)</option>
<option value="openai">OpenAI API</option>
<option value="claude">Claude (Anthropic)</option>
<option value="custom">Custom</option>
</select>
</div>
<div class="cfg-group">
<label>ENDPOINT URL</label>
<input type="text" id="endpointInput" placeholder="http://localhost:11434/v1/chat/completions">
</div>
<div class="cfg-group">
<label>MODEL NAME</label>
<input type="text" id="modelInput" placeholder="llama3">
</div>
<div class="cfg-group">
<label>API KEY <span style="opacity:.5">(optional)</span></label>
<input type="password" id="apiKeyInput" placeholder="sk-..." autocomplete="off" data-form-type="other">
</div>
<div class="cfg-group">
<label>API TYPE</label>
<select id="apiTypeInput">
<option value="openai">OpenAI-compatible</option>
<option value="claude">Anthropic Claude</option>
<option value="ollama">Ollama native</option>
</select>
</div>
<div class="toggle-row">
<span class="toggle-label">STREAMING</span>
<label class="toggle"><input type="checkbox" id="streamToggle" checked><span class="toggle-slider"></span></label>
</div>
<div id="connectionBadge" class="badge" style="display:inline-block">UNTESTED</div>
</div>
<!-- PARAMETERS -->
<div class="modal-section" id="section-params">
<div class="cfg-group">
<label>TEMPERATURE</label>
<div class="range-row">
<input type="range" id="tempRange" min="0" max="2" step="0.05" value="0.7">
<span class="range-val" id="tempVal">0.70</span>
</div>
</div>
<div class="cfg-group">
<label>MAX TOKENS</label>
<div class="range-row">
<input type="range" id="maxTokRange" min="64" max="8192" step="64" value="2048">
<span class="range-val" id="maxTokVal">2048</span>
</div>
</div>
<div class="cfg-group">
<label>TOP-P</label>
<div class="range-row">
<input type="range" id="topPRange" min="0" max="1" step="0.05" value="1">
<span class="range-val" id="topPVal">1.00</span>
</div>
</div>
<div class="cfg-group">
<label>FREQUENCY PENALTY</label>
<div class="range-row">
<input type="range" id="freqRange" min="-2" max="2" step="0.1" value="0">
<span class="range-val" id="freqVal">0.0</span>
</div>
</div>
<div class="cfg-group">
<label>CONTEXT WINDOW (messages to keep)</label>
<div class="range-row">
<input type="range" id="ctxRange" min="1" max="50" step="1" value="10">
<span class="range-val" id="ctxVal">10</span>
</div>
</div>
<div class="toggle-row">
<span class="toggle-label">SEND FULL HISTORY</span>
<label class="toggle"><input type="checkbox" id="historyToggle" checked><span class="toggle-slider"></span></label>
</div>
</div>
<!-- SYSTEM PROMPT -->
<div class="modal-section" id="section-system">
<div class="cfg-group">
<label>SYSTEM PROMPT</label>
<textarea id="systemPromptInput" rows="8" placeholder="You are a helpful assistant..."></textarea>
</div>
<div style="font-size:11px;color:var(--t-fg-dim);margin-top:4px">
Supports: {DATE}, {TIME}, {MODEL}, {USER}
</div>
<div class="cfg-group" style="margin-top:12px">
<label>USERNAME (shown in prompt)</label>
<input type="text" id="usernameInput" placeholder="user" maxlength="20">
</div>
</div>
<!-- INTERFACE -->
<div class="modal-section" id="section-interface">
<div class="cfg-group">
<label>COLOR THEME</label>
<select id="themeSelect">
<option value="green">Phosphor Green</option>
<option value="amber">Amber</option>
<option value="cyan">Cyan</option>
<option value="red">Red Alert</option>
</select>
</div>
<div class="cfg-group">
<label>SCANLINES</label>
<select id="scanlinesSelect">
<option value="">Normal</option>
<option value="scanlines-low">Low</option>
<option value="scanlines-off">Off</option>
</select>
</div>
<div class="toggle-row">
<span class="toggle-label">VIGNETTE</span>
<label class="toggle"><input type="checkbox" id="vignetteToggle" checked><span class="toggle-slider"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">TIMESTAMPS ON MESSAGES</span>
<label class="toggle"><input type="checkbox" id="timestampToggle"><span class="toggle-slider"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">SHOW SIDEBAR</span>
<label class="toggle"><input type="checkbox" id="sidebarToggle" checked><span class="toggle-slider"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">SOUND EFFECTS</span>
<label class="toggle"><input type="checkbox" id="soundToggle"><span class="toggle-slider"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">SHOW MACRO BAR</span>
<label class="toggle"><input type="checkbox" id="macroBarToggle" checked><span class="toggle-slider"></span></label>
</div>
</div>
<!-- MACROS -->
<div class="modal-section" id="section-macros">
<div style="font-size:11px;color:var(--t-fg-dim);margin-bottom:10px">
Quick-send buttons shown below the input. One per line: label|prompt
</div>
<div class="cfg-group">
<textarea id="macrosInput" rows="10" placeholder="Hello|Hello, who are you?
Summarize|Summarize our conversation so far.
Date|What is today's date?"></textarea>
</div>
</div>
<!-- VOICE -->
<div class="modal-section" id="section-voice">
<div class="toggle-row">
<span class="toggle-label">ENABLE VOICE READOUT</span>
<label class="toggle"><input type="checkbox" id="ttsToggle"><span class="toggle-slider"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">READ USER MESSAGES TOO</span>
<label class="toggle"><input type="checkbox" id="ttsUserToggle"><span class="toggle-slider"></span></label>
</div>
<div class="cfg-group" style="margin-top:8px">
<label>VOICE</label>
<select id="ttsVoiceSelect"><option value="">-- Loading voices... --</option></select>
</div>
<div class="cfg-group">
<label>RATE (speed)</label>
<div class="range-row">
<input type="range" id="ttsRateRange" min="0.5" max="2.5" step="0.05" value="1">
<span class="range-val" id="ttsRateVal">1.00</span>
</div>
</div>
<div class="cfg-group">
<label>PITCH</label>
<div class="range-row">
<input type="range" id="ttsPitchRange" min="0" max="2" step="0.05" value="1">
<span class="range-val" id="ttsPitchVal">1.00</span>
</div>
</div>
<div class="cfg-group">
<label>VOLUME</label>
<div class="range-row">
<input type="range" id="ttsVolRange" min="0" max="1" step="0.05" value="1">
<span class="range-val" id="ttsVolVal">1.00</span>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:10px">
<button class="cfg-btn" onclick="ttsTest()">▶ TEST VOICE</button>
<button class="cfg-btn" onclick="ttsStop()">■ STOP</button>
</div>
<div style="font-size:11px;color:var(--t-fg-dim);margin-top:8px" id="ttsStatus">
Web Speech API — voices depend on your browser & OS.
</div>
</div>
<div class="cfg-buttons">
<button class="cfg-btn danger" onclick="resetConfig()">RESET</button>
<button class="cfg-btn" onclick="closeConfig()">CANCEL</button>
<button class="cfg-btn" onclick="testConnection()">TEST</button>
<button class="cfg-btn primary" onclick="saveConfig()">SAVE</button>
</div>
</div>
</div>
<script>
/* ══════════════════════════════════════════
STATE
══════════════════════════════════════════ */
const DEFAULT_CONFIG = {
endpoint: '',
model: '',
apiKey: '',
apiType: 'openai',
stream: true,
temperature: 0.7,
maxTokens: 2048,
topP: 1.0,
freqPenalty: 0.0,
contextSize: 10,
sendHistory: true,
systemPrompt: '',
username: 'user',
theme: 'green',
scanlines: '',
vignette: true,
timestamps: false,
sidebar: true,
sound: false,
macroBar: true,
macros: 'Hello|Hello! Who are you?\nHelp|What can you help me with?\nDate|What is today\'s date?',
enabled: false,
tts: false,
ttsUser: false,
ttsVoice: '',
ttsRate: 1.0,
ttsPitch: 1.0,
ttsVol: 1.0,
};
let cfg = { ...DEFAULT_CONFIG };
try { Object.assign(cfg, JSON.parse(localStorage.getItem('nt3_config') || '{}')); } catch(e){}
// Conversation history per session
let sessions = []; // [{id, name, messages: [{role,content,ts}]}]
let activeSession = null;
let isProcessing = false;
let inputHistory = [];
let inputHistIdx = -1;
let stats = { sent: 0, received: 0, errors: 0, totalTokens: 0, uptime: Date.now() };
let debugLog = '';
let lastPrefill = '';
try { sessions = JSON.parse(localStorage.getItem('nt3_sessions') || '[]'); } catch(e){}
if (!sessions.length) newSession(false);
else activeSession = sessions[sessions.length - 1];
/* ══════════════════════════════════════════
DOM REFS
══════════════════════════════════════════ */
const outputEl = document.getElementById('output');
const inputField = document.getElementById('inputField');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const timeDisplay = document.getElementById('timeDisplay');
const modelTag = document.getElementById('modelTag');
const tokenCounter = document.getElementById('tokenCounter');
const charCount = document.getElementById('charCount');
const progressWrap = document.getElementById('progressWrap');
const progressBar = document.getElementById('progressBar');
const macroRow = document.getElementById('macroRow');
const sidebar = document.getElementById('sidebar');
const debugPre = document.getElementById('debugPre');
/* ══════════════════════════════════════════
TTS ENGINE
══════════════════════════════════════════ */
let ttsVoices = [];
let ttsSpeaking = false;
function ttsLoadVoices() {
ttsVoices = speechSynthesis.getVoices();
const sel = document.getElementById('ttsVoiceSelect');
if (!sel) return;
sel.innerHTML = '<option value="">System default</option>';
ttsVoices.forEach((v, i) => {
const opt = document.createElement('option');
opt.value = v.name;
opt.textContent = `${v.name} (${v.lang})`;
if (cfg.ttsVoice && v.name === cfg.ttsVoice) opt.selected = true;
sel.appendChild(opt);
});
}
if ('speechSynthesis' in window) {
speechSynthesis.onvoiceschanged = ttsLoadVoices;
ttsLoadVoices();
} else {
console.warn('Web Speech API not supported.');
}
function ttsSpeak(text, isUser = false) {
if (!cfg.tts) return;
if (isUser && !cfg.ttsUser) return;
if (!('speechSynthesis' in window)) return;
// Strip markdown-ish symbols so they don't get read aloud
const clean = text
.replace(/```[\s\S]*?```/g, 'code block')
.replace(/`[^`]+`/g, 'code')
.replace(/[*_#>\[\]]/g, '')
.replace(/https?:\/\/\S+/g, 'link')
.trim();
if (!clean) return;
const utt = new SpeechSynthesisUtterance(clean);
const voice = ttsVoices.find(v => v.name === cfg.ttsVoice);
if (voice) utt.voice = voice;
utt.rate = cfg.ttsRate;
utt.pitch = cfg.ttsPitch;
utt.volume = cfg.ttsVol;
utt.onstart = () => { ttsSpeaking = true; updateTtsIndicator(true); };
utt.onend = () => { ttsSpeaking = false; updateTtsIndicator(false); };
utt.onerror = () => { ttsSpeaking = false; updateTtsIndicator(false); };
speechSynthesis.cancel(); // stop any ongoing
speechSynthesis.speak(utt);
}
function ttsStop() {
if ('speechSynthesis' in window) speechSynthesis.cancel();
ttsSpeaking = false;
updateTtsIndicator(false);
}
function ttsTest() {
// Re-read config values from sliders directly so test is live
const rate = parseFloat(document.getElementById('ttsRateRange')?.value ?? cfg.ttsRate);
const pitch = parseFloat(document.getElementById('ttsPitchRange')?.value ?? cfg.ttsPitch);
const vol = parseFloat(document.getElementById('ttsVolRange')?.value ?? cfg.ttsVol);
const voice = document.getElementById('ttsVoiceSelect')?.value ?? cfg.ttsVoice;
const utt = new SpeechSynthesisUtterance('Neural Net Terminal online. Voice readout active. All systems nominal.');
const v = ttsVoices.find(v => v.name === voice);
if (v) utt.voice = v;
utt.rate = rate; utt.pitch = pitch; utt.volume = vol;
speechSynthesis.cancel();
speechSynthesis.speak(utt);
}
function updateTtsIndicator(active) {
const el = document.getElementById('ttsIndicator');
if (!el) return;
el.style.opacity = active ? '1' : '0.3';
el.style.animation = active ? 'pulse 1s ease-in-out infinite' : 'none';
}
applyTheme();
applyInterface();
updateStatus();
renderSessions();
renderSidebar();
renderMemory();
renderMacros();
renderHistoryTab();
bootMessages();
/* ══════════════════════════════════════════
BOOT
══════════════════════════════════════════ */
function bootMessages() {
outputEl.innerHTML = '';
addLine('NEURAL-NET TERMINAL v3.0', 'system');
addLine('Kernel: nt-core 3.0.1-stable | Build: 2025.05', 'system');
addLine('Type [help] for commands. [F2] or [config] to configure backend.', 'system');
if (cfg.enabled) {
addLine(`Backend: ${cfg.endpoint} | Model: ${cfg.model}`, 'system');
}
addLine('─'.repeat(60), 'divider');
// Replay last session messages
if (activeSession && activeSession.messages.length) {
for (const m of activeSession.messages) {
const role = m.role === 'user' ? 'user' : 'ai';
addLine((cfg.timestamps ? `[${m.ts||'??:??:??'}] ` : '') + m.content, role);
}
addLine('─'.repeat(60), 'divider');
}
}
/* ══════════════════════════════════════════
OUTPUT
══════════════════════════════════════════ */
function addLine(text, type = '') {
const el = document.createElement('div');
el.className = 'output-line' + (type ? ' ' + type : '');
el.textContent = text;
outputEl.appendChild(el);
outputEl.scrollTop = outputEl.scrollHeight;
return el;
}
function addStreamLine() {
const el = document.createElement('div');
el.className = 'output-line ai typing-cursor';
el.textContent = '';
outputEl.appendChild(el);
outputEl.scrollTop = outputEl.scrollHeight;
return el;
}
/* ══════════════════════════════════════════
SESSIONS
══════════════════════════════════════════ */
function newSession(save = true) {
const sess = {
id: Date.now().toString(36),
name: 'Session ' + (sessions.length + 1),
messages: [],
created: new Date().toISOString(),
};
sessions.push(sess);
activeSession = sess;
if (save) {
saveSessions();
renderSessions();
bootMessages();
notify('New session created');
}
return sess;
}
function switchSession(id) {
const s = sessions.find(s => s.id === id);
if (s) { activeSession = s; bootMessages(); renderSessions(); }
}
function deleteSession(id) {
sessions = sessions.filter(s => s.id !== id);
if (!sessions.length) newSession(false);
activeSession = sessions[sessions.length - 1];
saveSessions();
renderSessions();
bootMessages();
}
function saveSessions() {
try { localStorage.setItem('nt3_sessions', JSON.stringify(sessions)); } catch(e){}
}
function renderSessions() {
const el = document.getElementById('sessionList');
el.innerHTML = '';
for (const s of sessions) {
const d = document.createElement('div');
d.className = 'sidebar-item' + (s.id === activeSession?.id ? ' active' : '');
d.textContent = s.name;
d.title = s.name;
d.onclick = () => switchSession(s.id);
el.appendChild(d);
}
}
/* ══════════════════════════════════════════
SIDEBAR STATS
══════════════════════════════════════════ */
function renderSidebar() {
const el = document.getElementById('sidebarStats');
const up = Math.floor((Date.now() - stats.uptime) / 1000);
el.innerHTML = `
<div class="sidebar-section-title">STATS</div>
<div class="stat-row"><span class="stat-label">SENT</span><span class="stat-val">${stats.sent}</span></div>
<div class="stat-row"><span class="stat-label">RECV</span><span class="stat-val">${stats.received}</span></div>
<div class="stat-row"><span class="stat-label">ERRORS</span><span class="stat-val">${stats.errors}</span></div>
<div class="stat-row"><span class="stat-label">TOKENS</span><span class="stat-val">~${stats.totalTokens}</span></div>
<div class="stat-row"><span class="stat-label">UPTIME</span><span class="stat-val">${fmtUptime(up)}</span></div>
<div style="margin-top:8px">
<div class="sidebar-section-title">CTX USAGE</div>
<div class="stat-bar-wrap"><div class="stat-bar" id="ctxBar" style="width:${Math.min(100, (activeSession?.messages.length||0) / cfg.contextSize * 100)}%"></div></div>
<div class="stat-row"><span class="stat-label">MSG</span><span class="stat-val">${activeSession?.messages.length||0}/${cfg.contextSize}</span></div>
</div>`;
}
setInterval(renderSidebar, 3000);
function fmtUptime(s) {
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), ss = s%60;
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(ss).padStart(2,'0')}`;
}
/* ══════════════════════════════════════════
MEMORY
══════════════════════════════════════════ */
function renderMemory() {
const chips = document.getElementById('memoryChips');
const info = document.getElementById('memoryInfo');
const msgs = activeSession?.messages || [];
chips.innerHTML = '';
if (!msgs.length) { info.textContent = 'No messages in memory.'; return; }
msgs.forEach((m, i) => {
const c = document.createElement('span');
c.className = 'memory-chip';
c.textContent = `[${m.role[0].toUpperCase()}] ${m.content.slice(0,30)}${m.content.length>30?'…':''}`;
c.title = m.content;
c.onclick = () => { inputField.value = m.content; inputField.focus(); };
chips.appendChild(c);
});
info.textContent = `${msgs.length} messages in context. Click a chip to re-use.`;
}
/* ══════════════════════════════════════════
MACROS
══════════════════════════════════════════ */
function renderMacros() {
macroRow.innerHTML = '';
if (!cfg.macroBar) { macroRow.style.display = 'none'; return; }
macroRow.style.display = 'flex';
cfg.macros.split('\n').filter(Boolean).forEach(line => {
const [label, ...rest] = line.split('|');
const prompt = rest.join('|') || label;
if (!label) return;
const btn = document.createElement('button');
btn.className = 'macro-btn';
btn.textContent = label.trim();
btn.onclick = () => { inputField.value = prompt.trim(); inputField.focus(); };
macroRow.appendChild(btn);
});
}
/* ══════════════════════════════════════════
HISTORY TAB
══════════════════════════════════════════ */
function renderHistoryTab() {
const el = document.getElementById('historyList');
el.innerHTML = '';
const msgs = activeSession?.messages || [];
if (!msgs.length) {
el.innerHTML = '<div style="padding:10px;color:var(--t-fg-dim);font-size:12px">No history yet.</div>';
return;
}
[...msgs].reverse().forEach(m => {
const d = document.createElement('div');
d.className = 'hist-entry';
d.innerHTML = `<span class="hist-text">${escHtml(m.content)}</span><span class="hist-time">${m.ts||''} ${m.role}</span>`;
d.onclick = () => { inputField.value = m.content; inputField.focus(); switchTab('chat'); };
el.appendChild(d);
});
}
/* ══════════════════════════════════════════
TABS
══════════════════════════════════════════ */
document.querySelectorAll('.tab').forEach(btn => {
btn.onclick = () => switchTab(btn.dataset.tab);
});
function switchTab(name) {
document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + name));
if (name === 'history') renderHistoryTab();
if (name === 'memory') renderMemory();
}
/* ══════════════════════════════════════════
NOTIFICATIONS
══════════════════════════════════════════ */
function notify(msg, type = '') {
const tray = document.getElementById('notifTray');
const el = document.createElement('div');
el.className = 'notif';
el.textContent = msg;
if (type === 'error') el.style.borderColor = 'var(--red)';
tray.appendChild(el);
setTimeout(() => el.remove(), 3200);
}
/* ══════════════════════════════════════════
SOUND
══════════════════════════════════════════ */
function playBeep(freq = 880, dur = 0.07) {
if (!cfg.sound) return;
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain); gain.connect(ctx.destination);
osc.type = 'square'; osc.frequency.value = freq;
gain.gain.setValueAtTime(0.08, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur);
osc.start(); osc.stop(ctx.currentTime + dur);
} catch(e) {}
}
/* ══════════════════════════════════════════
STATUS
══════════════════════════════════════════ */
function updateStatus() {
cfg.enabled = !!(cfg.endpoint && cfg.model);
statusDot.className = 'status-dot' + (cfg.enabled ? ' online' : '');
statusText.textContent = cfg.enabled ? 'ONLINE' : 'OFFLINE';
modelTag.textContent = cfg.model || 'NO MODEL';
}
/* ══════════════════════════════════════════
THEME / INTERFACE
══════════════════════════════════════════ */
function applyTheme() {
document.body.className = '';
document.body.classList.add('theme-' + (cfg.theme || 'green'));
if (cfg.scanlines) document.body.classList.add(cfg.scanlines);
if (!cfg.vignette) document.body.classList.add('vignette-off');
}
function applyInterface() {
sidebar.style.display = cfg.sidebar ? '' : 'none';
renderMacros();
}
/* ══════════════════════════════════════════
TIME
══════════════════════════════════════════ */
function nowTime() {
return new Date().toLocaleTimeString('en-GB', { hour12: false });
}
function updateTime() { timeDisplay.textContent = nowTime(); }
setInterval(updateTime, 1000);
updateTime();
/* ══════════════════════════════════════════
INPUT HANDLING
══════════════════════════════════════════ */
inputField.addEventListener('input', () => {
const len = inputField.value.length;
charCount.textContent = `${len} / ∞`;
const est = Math.ceil(len / 4);
tokenCounter.textContent = `~${est}t`;
tokenCounter.className = 'token-counter' + (est > 1000 ? ' warn' : '');
});
inputField.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey && !isProcessing) {
e.preventDefault();
const val = inputField.value.trim();
if (val) { processCommand(val); inputField.value = ''; charCount.textContent = '0 / ∞'; tokenCounter.textContent = '~0t'; }
}
// History nav
if (e.key === 'ArrowUp') {
e.preventDefault();
if (inputHistIdx < inputHistory.length - 1) { inputHistIdx++; inputField.value = inputHistory[inputHistIdx]; }
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (inputHistIdx > 0) { inputHistIdx--; inputField.value = inputHistory[inputHistIdx]; }
else { inputHistIdx = -1; inputField.value = lastPrefill; }
}
// Tab autocomplete
if (e.key === 'Tab') {
e.preventDefault();
const cmds = ['help','config','clear','date','status','theme','session','memory','stats','macro','new','debug','export','quit','reset'];
const v = inputField.value.toLowerCase();
const match = cmds.find(c => c.startsWith(v) && c !== v);
if (match) inputField.value = match;
}
});
/* ══════════════════════════════════════════
COMMANDS
══════════════════════════════════════════ */
function processCommand(input) {
const trimmed = input.trim();
if (!trimmed) return;
inputHistory.unshift(trimmed);
inputHistIdx = -1;
lastPrefill = '';
addLine(trimmed, 'user');
playBeep(660, 0.05);
const lc = trimmed.toLowerCase();
const args = trimmed.split(/\s+/).slice(1);
switch (true) {
case lc === 'help':
showHelp(); break;
case lc === 'config':
openConfig(); break;
case lc === 'clear':
outputEl.innerHTML = '';
addLine('Buffer cleared.', 'system'); break;
case lc === 'date':
addLine(new Date().toString(), 'system'); break;
case lc === 'status':
addLine(`Endpoint: ${cfg.endpoint||'none'} | Model: ${cfg.model||'none'} | Connected: ${cfg.enabled}`, 'system');
addLine(`Sessions: ${sessions.length} | Active: ${activeSession?.name} | Msgs: ${activeSession?.messages.length}`, 'system');
break;
case lc === 'stats':
addLine(`Sent: ${stats.sent} | Received: ${stats.received} | Errors: ${stats.errors} | ~Tokens: ${stats.totalTokens}`, 'system');
addLine(`Uptime: ${fmtUptime(Math.floor((Date.now()-stats.uptime)/1000))}`, 'system');
break;
case lc === 'new' || lc === 'session new':
newSession(); break;
case lc.startsWith('session delete'):
deleteSession(args[1] || activeSession?.id);
break;
case lc === 'session list':
sessions.forEach(s => addLine(`[${s.id}] ${s.name} (${s.messages.length} msgs)`, 'system'));
break;
case lc.startsWith('theme '): {
const themes = ['green','amber','cyan','red'];
const t = args[0]?.toLowerCase();
if (themes.includes(t)) { cfg.theme = t; applyTheme(); saveConfig_(false); notify(`Theme: ${t}`); }
else addLine(`Available themes: ${themes.join(', ')}`, 'warn');
break;
}
case lc === 'memory clear':
if (activeSession) { activeSession.messages = []; saveSessions(); renderMemory(); }
addLine('Context memory cleared.', 'system'); break;
case lc === 'memory show':
switchTab('memory'); break;
case lc === 'debug':
switchTab('debug'); break;
case lc === 'history':
switchTab('history'); break;
case lc === 'export': {
const blob = new Blob([JSON.stringify(activeSession, null, 2)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `nt3-session-${activeSession?.id}.json`;
a.click();
addLine('Session exported.', 'success');
break;
}
case lc === 'macro list':
cfg.macros.split('\n').filter(Boolean).forEach(l => addLine(l, 'system'));
break;
case lc === 'reset':
if (confirm('Reset all config and sessions?')) {
localStorage.clear();
location.reload();
}
break;
default:
queryAI(trimmed);
}
}
function showHelp() {
const cmds = [
['help', 'Show this help'],
['config', 'Open config panel [F2]'],
['clear', 'Clear output buffer'],
['new', 'New chat session'],
['session list', 'List sessions'],
['session delete', 'Delete current session'],
['theme <name>', 'Switch theme: green amber cyan red'],
['memory clear', 'Clear context memory'],
['memory show', 'Switch to memory tab'],
['history', 'Switch to history tab'],
['debug', 'Switch to debug tab'],
['export', 'Export session as JSON'],
['stats', 'Show runtime statistics'],
['status', 'Show connection status'],
['date', 'Print current date/time'],
['reset', 'Factory reset'],
['macro list', 'List all macros'],
];
addLine('─'.repeat(60), 'divider');
cmds.forEach(([cmd, desc]) => {
addLine(` ${cmd.padEnd(24)} ${desc}`, 'system');
});
addLine('─'.repeat(60), 'divider');
}
/* ══════════════════════════════════════════
AI QUERY
══════════════════════════════════════════ */
async function queryAI(message) {
if (!cfg.enabled) {
addLine('Backend offline. Use [config] to connect.', 'error');
return;
}
if (isProcessing) { addLine('Already processing. Please wait.', 'warn'); return; }
isProcessing = true;
stats.sent++;
// Add to session memory
const ts = nowTime();
activeSession.messages.push({ role: 'user', content: message, ts });
saveSessions();
// Expand system prompt vars
const sysprompt = (cfg.systemPrompt || '')
.replace(/{DATE}/g, new Date().toLocaleDateString())
.replace(/{TIME}/g, ts)
.replace(/{MODEL}/g, cfg.model)
.replace(/{USER}/g, cfg.username || 'user');
// Build messages array
let history = cfg.sendHistory
? activeSession.messages.slice(-(cfg.contextSize))
: [{ role: 'user', content: message, ts }];
const messages = [
...(sysprompt ? [{ role: 'system', content: sysprompt }] : []),
...history.map(m => ({ role: m.role, content: m.content })),
];
// Progress bar fake
progressWrap.classList.add('active');
progressBar.style.width = '30%';
const progressInt = setInterval(() => {
const cur = parseFloat(progressBar.style.width);
if (cur < 90) progressBar.style.width = (cur + (90 - cur) * 0.07) + '%';
}, 200);
const headers = { 'Content-Type': 'application/json' };
if (cfg.apiKey) {
if (cfg.apiType === 'claude') headers['x-api-key'] = cfg.apiKey;
else headers['Authorization'] = 'Bearer ' + cfg.apiKey;
}
if (cfg.apiType === 'claude') headers['anthropic-version'] = '2023-06-01';
const body = cfg.apiType === 'claude'
? JSON.stringify({ model: cfg.model, max_tokens: cfg.maxTokens, messages: messages.filter(m=>m.role!=='system'),
system: sysprompt || undefined, temperature: cfg.temperature, stream: cfg.stream })
: JSON.stringify({ model: cfg.model, messages, temperature: cfg.temperature,
max_tokens: cfg.maxTokens, top_p: cfg.topP, frequency_penalty: cfg.freqPenalty,
stream: cfg.stream });
debugLog = `REQUEST (${new Date().toISOString()}):\n` + body + '\n\n';
let fullResponse = '';
try {
const resp = await fetch(cfg.endpoint, { method: 'POST', headers, body });
if (!resp.ok) {
const txt = await resp.text();
throw new Error(`HTTP ${resp.status}: ${txt.slice(0,200)}`);
}
if (cfg.stream) {
const streamLine = addStreamLine();
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (data === '[DONE]') continue;
try {
const j = JSON.parse(data);
const delta = cfg.apiType === 'claude'
? j.delta?.text || ''
: j.choices?.[0]?.delta?.content || '';
if (delta) { fullResponse += delta; streamLine.textContent += delta; streamLine.classList.remove('typing-cursor'); }
if (j.usage) stats.totalTokens += j.usage.total_tokens || 0;
} catch(e) {}
}
outputEl.scrollTop = outputEl.scrollHeight;
}
streamLine.classList.remove('typing-cursor');
} else {
const j = await resp.json();
debugLog += `RESPONSE:\n` + JSON.stringify(j, null, 2);
debugPre.textContent = debugLog;
if (cfg.apiType === 'claude') {
fullResponse = j.content?.[0]?.text || j.error?.message || 'No response.';
stats.totalTokens += (j.usage?.input_tokens || 0) + (j.usage?.output_tokens || 0);
} else {
if (j.choices?.[0]?.message?.content) {
fullResponse = j.choices[0].message.content;
stats.totalTokens += j.usage?.total_tokens || 0;
} else if (j.error) {
throw new Error(j.error.message || JSON.stringify(j.error));
} else {
fullResponse = 'Unexpected response format.';
}
}
const timePrefix = cfg.timestamps ? `[${ts}] ` : '';
addLine(timePrefix + fullResponse, 'ai');
}
// Save assistant reply
activeSession.messages.push({ role: 'assistant', content: fullResponse, ts: nowTime() });
saveSessions();
stats.received++;
playBeep(440, 0.08);
} catch(e) {
addLine('Error: ' + e.message, 'error');
stats.errors++;
notify(e.message.slice(0, 60), 'error');
debugLog += `\nERROR: ${e.message}`;
debugPre.textContent = debugLog;
}
clearInterval(progressInt);
progressBar.style.width = '100%';
setTimeout(() => { progressWrap.classList.remove('active'); progressBar.style.width = '0'; }, 400);
isProcessing = false;
}
/* ══════════════════════════════════════════
CONFIG MODAL
══════════════════════════════════════════ */
// Modal tab switching
document.querySelectorAll('.modal-tab').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('.modal-tab').forEach(b => b.classList.toggle('active', b === btn));
document.querySelectorAll('.modal-section').forEach(s => s.classList.toggle('active', s.id === 'section-' + btn.dataset.section));
};
});
// Range inputs
[['tempRange','tempVal',2],['topPRange','topPVal',2],['freqRange','freqVal',1]].forEach(([id,vid,dec]) => {
const r = document.getElementById(id), v = document.getElementById(vid);
r.oninput = () => v.textContent = parseFloat(r.value).toFixed(dec);
});
['maxTokRange','ctxRange'].forEach(id => {
const r = document.getElementById(id), v = document.getElementById(id.replace('Range','Val'));
r.oninput = () => v.textContent = r.value;
});
// Preset select
document.getElementById('presetSelect').onchange = function() {
const p = {
ollama: { endpoint:'http://localhost:11434/v1/chat/completions', model:'llama3', type:'openai' },
lmstudio: { endpoint:'http://localhost:1234/v1/chat/completions', model:'local-model', type:'openai' },
gpt4all: { endpoint:'http://localhost:4891/v1/chat/completions', model:'gpt4all-j', type:'openai' },
catgpt: { endpoint:'http://192.168.254.36:4891/v1/chat/completions', model:'cat-gpt-4o-mini-miauw', type:'openai' },
openai: { endpoint:'https://api.openai.com/v1/chat/completions', model:'gpt-4o-mini', type:'openai' },
claude: { endpoint:'https://api.anthropic.com/v1/messages', model:'claude-sonnet-4-20250514', type:'claude' },
}[this.value];
if (p) {
document.getElementById('endpointInput').value = p.endpoint;
document.getElementById('modelInput').value = p.model;
document.getElementById('apiTypeInput').value = p.type;
}
};
function openConfig() {
document.getElementById('endpointInput').value = cfg.endpoint;
document.getElementById('modelInput').value = cfg.model;
document.getElementById('apiKeyInput').value = cfg.apiKey;
document.getElementById('apiTypeInput').value = cfg.apiType;
document.getElementById('streamToggle').checked = cfg.stream;
document.getElementById('tempRange').value = cfg.temperature;
document.getElementById('tempVal').textContent = cfg.temperature.toFixed(2);
document.getElementById('maxTokRange').value = cfg.maxTokens;
document.getElementById('maxTokVal').textContent = cfg.maxTokens;
document.getElementById('topPRange').value = cfg.topP;
document.getElementById('topPVal').textContent = cfg.topP.toFixed(2);
document.getElementById('freqRange').value = cfg.freqPenalty;
document.getElementById('freqVal').textContent = cfg.freqPenalty.toFixed(1);
document.getElementById('ctxRange').value = cfg.contextSize;
document.getElementById('ctxVal').textContent = cfg.contextSize;
document.getElementById('historyToggle').checked = cfg.sendHistory;
document.getElementById('systemPromptInput').value = cfg.systemPrompt;
document.getElementById('usernameInput').value = cfg.username;
document.getElementById('themeSelect').value = cfg.theme;
document.getElementById('scanlinesSelect').value = cfg.scanlines;
document.getElementById('vignetteToggle').checked = cfg.vignette;
document.getElementById('timestampToggle').checked = cfg.timestamps;
document.getElementById('sidebarToggle').checked = cfg.sidebar;
document.getElementById('soundToggle').checked = cfg.sound;
document.getElementById('macroBarToggle').checked = cfg.macroBar;
document.getElementById('macrosInput').value = cfg.macros;
document.getElementById('connectionBadge').className = 'badge';
document.getElementById('connectionBadge').textContent = 'UNTESTED';
document.getElementById('configBackdrop').classList.add('visible');
}
function closeConfig() {
document.getElementById('configBackdrop').classList.remove('visible');
inputField.focus();
}
function saveConfig_(notify_ = true) {
cfg.endpoint = document.getElementById('endpointInput')?.value.trim() ?? cfg.endpoint;
cfg.model = document.getElementById('modelInput')?.value.trim() ?? cfg.model;
cfg.apiKey = document.getElementById('apiKeyInput')?.value.trim() ?? cfg.apiKey;
cfg.apiType = document.getElementById('apiTypeInput')?.value ?? cfg.apiType;
cfg.stream = document.getElementById('streamToggle')?.checked ?? cfg.stream;
cfg.temperature = parseFloat(document.getElementById('tempRange')?.value) ?? cfg.temperature;
cfg.maxTokens = parseInt(document.getElementById('maxTokRange')?.value) ?? cfg.maxTokens;
cfg.topP = parseFloat(document.getElementById('topPRange')?.value) ?? cfg.topP;
cfg.freqPenalty = parseFloat(document.getElementById('freqRange')?.value) ?? cfg.freqPenalty;
cfg.contextSize = parseInt(document.getElementById('ctxRange')?.value) ?? cfg.contextSize;
cfg.sendHistory = document.getElementById('historyToggle')?.checked ?? cfg.sendHistory;
cfg.systemPrompt = document.getElementById('systemPromptInput')?.value ?? cfg.systemPrompt;
cfg.username = document.getElementById('usernameInput')?.value.trim() ?? cfg.username;
cfg.theme = document.getElementById('themeSelect')?.value ?? cfg.theme;
cfg.scanlines = document.getElementById('scanlinesSelect')?.value ?? cfg.scanlines;
cfg.vignette = document.getElementById('vignetteToggle')?.checked ?? cfg.vignette;
cfg.timestamps = document.getElementById('timestampToggle')?.checked ?? cfg.timestamps;
cfg.sidebar = document.getElementById('sidebarToggle')?.checked ?? cfg.sidebar;
cfg.sound = document.getElementById('soundToggle')?.checked ?? cfg.sound;
cfg.macroBar = document.getElementById('macroBarToggle')?.checked ?? cfg.macroBar;
cfg.macros = document.getElementById('macrosInput')?.value ?? cfg.macros;
try { localStorage.setItem('nt3_config', JSON.stringify(cfg)); } catch(e){}
applyTheme();
applyInterface();
updateStatus();
renderMacros();
if (notify_) notify('Config saved.');
}
function saveConfig() { saveConfig_(true); closeConfig(); }
function resetConfig() {
if (confirm('Reset all settings to defaults?')) {
Object.assign(cfg, DEFAULT_CONFIG);
try { localStorage.removeItem('nt3_config'); } catch(e){}
applyTheme(); applyInterface(); updateStatus();
closeConfig();
notify('Config reset.');
}
}
async function testConnection() {
const endpoint = document.getElementById('endpointInput').value.trim();
const apiKey = document.getElementById('apiKeyInput').value.trim();
const apiType = document.getElementById('apiTypeInput').value;
const badge = document.getElementById('connectionBadge');
if (!endpoint) { alert('No endpoint entered.'); return; }
badge.className = 'badge'; badge.textContent = 'TESTING...';
try {
const testUrl = endpoint.replace('/chat/completions','').replace('/messages','') + '/models';
const headers = {};
if (apiKey) {
if (apiType === 'claude') { headers['x-api-key'] = apiKey; headers['anthropic-version'] = '2023-06-01'; }
else headers['Authorization'] = 'Bearer ' + apiKey;
}
const r = await fetch(testUrl, { method:'GET', headers });
if (r.ok) { badge.className = 'badge ok'; badge.textContent = 'OK ' + r.status; }
else { badge.className = 'badge bad'; badge.textContent = 'FAIL ' + r.status; }
} catch(e) {
badge.className = 'badge bad';
badge.textContent = 'ERROR: ' + e.message.slice(0,30);
}
}
/* ══════════════════════════════════════════
KEYBOARD SHORTCUTS
══════════════════════════════════════════ */
document.addEventListener('keydown', e => {
if (e.key === 'F2') { e.preventDefault(); openConfig(); }
if (e.key === 'F1') { e.preventDefault(); processCommand('help'); }
if (e.key === 'Escape') { closeConfig(); inputField.focus(); }
// Click outside config closes it
});
document.getElementById('configBackdrop').addEventListener('click', e => {
if (e.target === document.getElementById('configBackdrop')) closeConfig();
});
document.addEventListener('click', e => {
if (!document.getElementById('configBackdrop').classList.contains('visible')) inputField.focus();
});
/* ══════════════════════════════════════════
UTILS
══════════════════════════════════════════ */
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// Update prompt username
setInterval(() => {
document.getElementById('inputPrompt').textContent = (cfg.username || 'user') + ' $';
}, 2000);
document.getElementById('inputPrompt').textContent = (cfg.username || 'user') + ' $';
</script>
</body>
</html>