1700 lines
65 KiB
HTML
1700 lines
65 KiB
HTML
<!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 | Tab autocomplete | F2 config | F1 help | 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
// Update prompt username
|
||
setInterval(() => {
|
||
document.getElementById('inputPrompt').textContent = (cfg.username || 'user') + ' $';
|
||
}, 2000);
|
||
document.getElementById('inputPrompt').textContent = (cfg.username || 'user') + ' $';
|
||
</script>
|
||
</body>
|
||
</html> |