427 lines
24 KiB
HTML
427 lines
24 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>DeepSeek Harness — {{ DSH_USER }}</title>
|
||
<link rel="stylesheet" href="/static/app.css">
|
||
<style>
|
||
:root {
|
||
--canvas-soft: #f7f7f7; --surface: #ffffff; --surface-2: #f2f2f1; --hairline: #e6e6e6;
|
||
--ink: #111111; --ink-secondary: #333333; --ink-muted: #666666; --ink-faint: #999999; --primary: #0075de;
|
||
}
|
||
[data-theme="dark"] {
|
||
--canvas-soft: #141518; --surface: #1c1e22; --surface-2: #24262c; --hairline: #33363d;
|
||
--ink: #f5f6f7; --ink-secondary: #dcdee2; --ink-muted: #a9adb6; --ink-faint: #727680; --primary: #3d8be0;
|
||
--canvas: #141518; --panel: #1c1e22; --accent: #3d8be0; /* dsh app.css var names */
|
||
}
|
||
[data-theme="dark"] .log, [data-theme="dark"] body { scrollbar-color: #4a4d55 #141518; }
|
||
[data-theme="dark"] .tools-row button, [data-theme="dark"] .tools-row input[type=text],
|
||
[data-theme="dark"] .tools-row input[type=file], [data-theme="dark"] #newBtn, [data-theme="dark"] #dlBtn,
|
||
[data-theme="dark"] .modelrow select, [data-theme="dark"] #input {
|
||
background: #24262c; color: #f5f6f7; border-color: #3a3d45;
|
||
}
|
||
[data-theme="dark"] #toolsToggle { color: #a9adb6; }
|
||
[data-theme="dark"] .tools-row button { color: #f5f6f7; }
|
||
html, body { height: 100%; margin: 0; }
|
||
body { display: flex; flex-direction: column; background: var(--surface, #fff); color: var(--ink, #111); }
|
||
header.top { flex: none; }
|
||
.app { display: flex; flex: 1; min-height: 0; }
|
||
#sidebar {
|
||
width: 230px; flex: none; border-right: 1px solid var(--hairline, #e6e6e6);
|
||
display: flex; flex-direction: column; background: var(--canvas-soft, #f7f7f7);
|
||
}
|
||
#sidebar h2 { font: 600 12px/1.3 var(--font-family, sans-serif); color: var(--ink-faint, #999); margin: 12px 12px 6px; }
|
||
#newBtn, #dlBtn { margin: 4px 12px; padding: 7px 10px; border: 1px solid var(--hairline, #e6e6e6); border-radius: 8px; background: #fff; cursor: pointer; text-align: left; }
|
||
#sessions { list-style: none; margin: 0; padding: 0 8px; overflow: auto; flex: 1; }
|
||
#sessions li {
|
||
display: flex; align-items: center; gap: 6px; padding: 7px 8px; margin: 2px 0;
|
||
border-radius: 8px; cursor: pointer; font-size: 13px; color: var(--ink-secondary, #333);
|
||
}
|
||
#sessions li.sel { background: var(--primary, #0075de); color: #fff; }
|
||
#sessions li .t { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
#sessions li .n { font-size: 11px; opacity: .7; }
|
||
#sessions li .del, #sessions li .ren { background: none; border: none; cursor: pointer; color: inherit; opacity: .5; font-size: 13px; }
|
||
.chat-col { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||
.log { flex: 1; overflow: auto; }
|
||
.bubble { position: relative; white-space: pre-wrap; }
|
||
.bubble .copy {
|
||
position: absolute; top: 4px; right: 4px; display: none;
|
||
font-size: 11px; padding: 2px 6px; border: 1px solid var(--hairline, #e6e6e6); border-radius: 6px; background: #fff; cursor: pointer; color: #555;
|
||
}
|
||
.msg:hover .bubble .copy { display: block; }
|
||
.modelrow { display: flex; gap: 8px; align-items: center; }
|
||
.modelrow select { border: 1px solid var(--hairline, #e6e6e6); border-radius: 8px; padding: 6px 8px; font-size: 13px; }
|
||
|
||
.modelrow label { font-size: 12px; color: #777; }
|
||
|
||
|
||
.composer { display: flex; flex-direction: column; gap: 8px; padding: 8px 12px; }
|
||
#input {
|
||
width: 100%; min-height: 2.9em; padding: 9px 11px; box-sizing: border-box;
|
||
border: 1px solid var(--hairline, #e6e6e6); border-radius: 8px;
|
||
font: 15px/1.4 var(--font-family, sans-serif); resize: vertical;
|
||
}
|
||
.send-row { display: flex; align-items: stretch; gap: 6px; }
|
||
.send-row #input { flex: 1 1 auto; }
|
||
.attach { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--hairline, #e6e6e6); border-radius: 8px; padding: 0 9px; cursor: pointer; font-size: 16px; color: var(--ink, #111); }
|
||
#sendBtn { border: none; border-radius: 8px; padding: 0 18px; cursor: pointer; background: var(--primary, #0075de); color: #fff; font-size: 14px; }
|
||
#toolsPanel { border-top: 1px solid var(--hairline, #e6e6e6); }
|
||
#toolsToggle {
|
||
width: 100%; text-align: left; background: none; border: none; cursor: pointer;
|
||
padding: 8px 12px; color: #666; font-size: 12px; display: flex; align-items: center; gap: 6px;
|
||
}
|
||
#toolsArrow { font-size: 11px; }
|
||
#toolsBody { padding: 0 12px 10px; }
|
||
#toolsBody.hidden { display: none; }
|
||
.tool-group { margin-top: 8px; border: 1px solid var(--hairline, #e6e6e6); border-radius: 10px; padding: 6px 10px; background: rgba(0,0,0,.02); }
|
||
.tool-group-title { font-size: 11px; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: .4px; margin-bottom: 2px; }
|
||
.tools-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
|
||
.tools-row input[type=text], .tools-row input[type=file] { flex: 2 1 170px; border: 1px solid var(--hairline, #e6e6e6); border-radius: 8px; padding: 6px 10px; font-size: 13px; }
|
||
.tools-row button { flex: 0 0 auto; border: 1px solid var(--hairline, #e6e6e6); border-radius: 8px; padding: 6px 10px; cursor: pointer; background: #fff; }
|
||
.modelrow { display: flex; gap: 8px; align-items: center; }
|
||
.modelrow select { border: 1px solid var(--hairline, #e6e6e6); border-radius: 8px; padding: 5px 8px; font-size: 13px; }
|
||
@media (max-width: 720px) {
|
||
#menuBtn { display: inline-flex !important; }
|
||
#sidebar {
|
||
position: fixed; top: 0; left: 0; bottom: 0; width: 230px; z-index: 40;
|
||
transform: translateX(-100%); transition: transform .2s ease;
|
||
box-shadow: 2px 0 14px rgba(0,0,0,.18);
|
||
}
|
||
#sidebar.open { transform: translateX(0); }
|
||
.chat-col { flex: 1; min-width: 0; }
|
||
.tools-row { flex-direction: column; }
|
||
.tools-row input[type=text], .tools-row input[type=file], .tools-row button { flex: 1 1 100% !important; width: 100% !important; }
|
||
.send-row { justify-content: flex-end; }
|
||
#input { font-size: 16px; } /* avoid iOS auto-zoom */
|
||
}
|
||
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header class="top" style="display:flex;align-items:center;gap:8px;padding:10px 14px">
|
||
<button id="menuBtn" type="button" aria-label="Conversations" title="Conversations" style="display:none;align-items:center;justify-content:center;background:none;border:1px solid var(--hairline, #e6e6e6);border-radius:8px;padding:4px 9px;font-size:17px;cursor:pointer;line-height:1">☰</button>
|
||
<button id="themeBtn" type="button" title="Toggle dark mode" style="background:none;border:1px solid var(--hairline, #e6e6e6);border-radius:8px;padding:4px 8px;font-size:16px;cursor:pointer;color:var(--ink, #111)">🌙</button>
|
||
<div class="logo">DeepSeek Harness</div>
|
||
<div class="who" style="margin-left:auto">{{ DSH_SLOT }} · <span class="model" id="whoModel">{{ DSH_MODEL }}</span></div>
|
||
</header>
|
||
|
||
<div class="app">
|
||
<aside id="sidebar">
|
||
<h2>Conversations</h2>
|
||
<button id="newBtn" type="button">+ New conversation</button>
|
||
<button id="dlBtn" type="button">⬇ Download current (.md)</button>
|
||
<ul id="sessions"></ul>
|
||
</aside>
|
||
|
||
<div class="chat-col">
|
||
<main id="log" class="log" aria-live="polite"></main>
|
||
|
||
<form id="chat" class="composer" autocomplete="off">
|
||
<div class="send-row">
|
||
<label class="attach" title="Attach an image to your message">🖼
|
||
<input type="file" id="imgIn" accept="image/*" style="display:none">
|
||
</label>
|
||
<textarea id="input" rows="2" placeholder="Type your message…" autofocus></textarea>
|
||
<button type="submit" id="sendBtn">Send</button>
|
||
</div>
|
||
</form>
|
||
<div id="toolsPanel">
|
||
<button type="button" id="toolsToggle"><span id="toolsArrow">▾</span> Model & tools</button>
|
||
<div id="toolsBody">
|
||
<div class="modelrow"><label for="modelSel">Model:</label><select id="modelSel"></select></div>
|
||
|
||
<div class="tool-group">
|
||
<div class="tool-group-title">Ask with a file</div>
|
||
<div class="tools-row">
|
||
<input type="file" id="askIn" accept="audio/*,video/*,image/*,.txt,.md,.json,.log,.csv" title="Any file: audio, video, image, text">
|
||
<input id="askQ" type="text" placeholder="What should I do? e.g. transcribe this, summarise, what's in this…">
|
||
<button type="button" id="askBtn">Ask</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tool-group">
|
||
<div class="tool-group-title">Transcribe audio → text</div>
|
||
<div class="tools-row">
|
||
<input type="file" id="audioIn" accept="audio/*,.mp4,.mov,.m4a" title="Audio or video with speech">
|
||
<button type="button" id="transcribeBtn">Transcribe</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tool-group">
|
||
<div class="tool-group-title">Generate image</div>
|
||
<div class="tools-row">
|
||
<input id="genInput" type="text" placeholder="Describe an image to generate…">
|
||
<button type="button" id="genImg">Generate</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tool-group">
|
||
<div class="tool-group-title">Workspace file / web page (legacy)</div>
|
||
<div class="tools-row">
|
||
<input id="urlInput" type="text" placeholder="Paste a URL to summarise…">
|
||
<button type="button" id="sumWeb">Summarise page</button>
|
||
<input id="fileInput" type="text" placeholder="Workspace file (e.g. notes.md)…">
|
||
<button type="button" id="sumDocs">Summarise file</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const logEl = document.getElementById('log');
|
||
const form = document.getElementById('chat');
|
||
const input = document.getElementById('input');
|
||
const listEl = document.getElementById('sessions');
|
||
const modelSel = document.getElementById('modelSel');
|
||
let curSid = localStorage.getItem('dshSid') || null;
|
||
|
||
function addBubble(role, text) {
|
||
const row = document.createElement('div');
|
||
row.className = 'msg ' + (role === 'user' ? 'user' : 'bot');
|
||
const b = document.createElement('div');
|
||
b.className = 'bubble';
|
||
b.textContent = text || '';
|
||
const cp = document.createElement('button'); cp.className = 'copy'; cp.textContent = 'copy';
|
||
cp.addEventListener('click', () => {
|
||
navigator.clipboard.writeText(b.textContent).then(() => { cp.textContent = '✓'; setTimeout(() => cp.textContent = 'copy', 1200); });
|
||
});
|
||
b.appendChild(cp);
|
||
row.appendChild(b);
|
||
logEl.appendChild(row);
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
return b;
|
||
}
|
||
function appendChar(bubble, ch) { bubble.textContent += ch; logEl.scrollTop = logEl.scrollHeight; }
|
||
function showGreeting() { if (!logEl.childElementCount) addBubble('bot', 'Hi {{ DSH_USER }} 👋 Ask me anything — pick a conversation on the left or start a new one.'); }
|
||
function renderMessages(msgs) { logEl.innerHTML = ''; (msgs || []).forEach(m => { if (m.role === 'user') addBubble('user', m.content); else if (m.role === 'assistant') addBubble('bot', m.content); }); showGreeting(); }
|
||
|
||
function renderList(sessions) {
|
||
listEl.innerHTML = '';
|
||
sessions.forEach(s => {
|
||
const li = document.createElement('li'); if (s.id === curSid) li.className = 'sel';
|
||
const t = document.createElement('span'); t.className = 't'; t.textContent = s.name || 'Conversation';
|
||
const n = document.createElement('span'); n.className = 'n'; n.textContent = s.count;
|
||
const d = document.createElement('button'); d.className = 'del'; d.textContent = '✕'; d.title = 'Delete';
|
||
d.addEventListener('click', (e) => { e.stopPropagation(); deleteSession(s.id); });
|
||
const r = document.createElement('button'); r.className = 'ren'; r.textContent = '✎'; r.title = 'Rename';
|
||
r.addEventListener('click', (e) => { e.stopPropagation(); renameSession(s.id); });
|
||
li.append(t, n, r, d);
|
||
li.addEventListener('click', () => openSession(s.id));
|
||
listEl.appendChild(li);
|
||
});
|
||
}
|
||
|
||
function whoModel() {
|
||
const m = modelSel.value || '';
|
||
const label = [...modelSel.options].find(o => o.value === m);
|
||
const el = document.getElementById('whoModel');
|
||
if (el) el.textContent = label ? label.textContent : m;
|
||
}
|
||
async function renameSession(id) {
|
||
const s = await (await fetch('/api/sessions/' + id)).json();
|
||
const name = prompt('Rename conversation:', s.name || '');
|
||
if (name === null) return;
|
||
const fd = new URLSearchParams({ name });
|
||
const d = await (await fetch('/api/sessions/' + id + '/rename', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: fd })).json();
|
||
if (d.ok) loadSessions(); else alert('Rename failed: ' + (d.error || 'unknown'));
|
||
}
|
||
|
||
async function fetchModels() {
|
||
try {
|
||
const d = await (await fetch('/api/models')).json();
|
||
(d.current || []).forEach(m => {
|
||
const o = document.createElement('option'); o.value = m; o.textContent = (d.labels && d.labels[m]) || m;
|
||
if (m === (d.default || (d.current && d.current[0]))) o.selected = true;
|
||
modelSel.appendChild(o);
|
||
});
|
||
modelSel.addEventListener('change', () => { whoModel(); });
|
||
whoModel();
|
||
} catch (_) {}
|
||
}
|
||
|
||
async function loadSessions() {
|
||
try {
|
||
const d = await (await fetch('/api/sessions')).json();
|
||
renderList(d.sessions || []);
|
||
if (!curSid || !(d.sessions || []).some(s => s.id === curSid)) curSid = (d.sessions && d.sessions[0] && d.sessions[0].id) || null;
|
||
if (curSid) { localStorage.setItem('dshSid', curSid); openSession(curSid); } else showGreeting();
|
||
} catch (_) { showGreeting(); }
|
||
}
|
||
|
||
async function openSession(id) {
|
||
closeDrawer(); curSid = id; localStorage.setItem('dshSid', id);
|
||
try {
|
||
const d = await (await fetch('/api/sessions/' + id)).json();
|
||
renderMessages(d.messages);
|
||
if (d.model && [...modelSel.options].some(o => o.value === d.model)) modelSel.value = d.model;
|
||
} catch (_) { renderMessages([]); }
|
||
renderList((await (await fetch('/api/sessions')).json()).sessions || []);
|
||
}
|
||
|
||
async function newSession() {
|
||
const d = await (await fetch('/api/sessions', { method: 'POST' })).json();
|
||
curSid = d.id; localStorage.setItem('dshSid', d.id);
|
||
renderMessages([]); loadSessions();
|
||
}
|
||
async function deleteSession(id) {
|
||
await fetch('/api/sessions/' + id + '/delete', { method: 'POST' });
|
||
if (curSid === id) { curSid = null; localStorage.removeItem('dshSid'); }
|
||
loadSessions();
|
||
}
|
||
function downloadSession() {
|
||
if (!curSid) return;
|
||
fetch('/api/sessions/' + curSid).then(r => r.json()).then(d => {
|
||
let md = "# " + (d.name || 'Conversation') + "\n\n";
|
||
(d.messages || []).forEach(m => { md += (m.role === 'user' ? '**You:** ' : '**Assistant:** ') + m.content + '\n\n'; });
|
||
const blob = new Blob([md], { type: 'text/markdown' });
|
||
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
||
a.download = ((d.name || 'conversation').replace(/[^\w]+/g, '_')) + '.md'; a.click();
|
||
});
|
||
}
|
||
|
||
const sidebar = document.getElementById('sidebar');
|
||
document.getElementById('menuBtn').addEventListener('click', () => sidebar.classList.toggle('open'));
|
||
function closeDrawer() { if (sidebar.classList.contains('open')) sidebar.classList.remove('open'); }
|
||
document.getElementById('newBtn').addEventListener('click', newSession);
|
||
document.getElementById('dlBtn').addEventListener('click', downloadSession);
|
||
|
||
async function runTool(endpoint, fieldId, label, bodyKey) {
|
||
const val = document.getElementById(fieldId).value.trim(); if (!val) return;
|
||
addBubble('user', label + ': ' + val); addBubble('bot', '');
|
||
const bubble = logEl.lastElementChild.querySelector('.bubble'); bubble.textContent = '…thinking';
|
||
try {
|
||
const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ [bodyKey]: val }) });
|
||
const data = await res.json();
|
||
bubble.textContent = data.ok ? (data.summary || '') : '[error: ' + (data.error || 'unknown') + ']';
|
||
} catch (err) { bubble.textContent = '[error: ' + err + ']'; }
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
}
|
||
document.getElementById('sumWeb').addEventListener('click', () => runTool('/api/summarize/web', 'urlInput', 'Summarise page', 'url'));
|
||
document.getElementById('sumDocs').addEventListener('click', () => runTool('/api/summarize/docs', 'fileInput', 'Summarise file', 'path'));
|
||
|
||
function downloadLink(path, label) {
|
||
const a = document.createElement('a'); a.href = '/api/files/' + path; a.download = ''; a.textContent = label;
|
||
a.style.cssText = 'font-size:12px;color:var(--accent,#6db3f2);text-decoration:underline'; return a;
|
||
}
|
||
async function postForm(url, body) {
|
||
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(body) });
|
||
return res.json();
|
||
}
|
||
function fail(bubble, data) { bubble.textContent = '[error: ' + (data.error || 'unknown') + ']'; }
|
||
document.getElementById('askBtn').addEventListener('click', async () => {
|
||
const f = document.getElementById('askIn').files[0]; const q = document.getElementById('askQ').value.trim();
|
||
if (!f) return;
|
||
addBubble('user', (q ? 'Do: ' + q + ' — ' : '') + f.name); addBubble('bot', '');
|
||
const b = logEl.lastElementChild.querySelector('.bubble'); b.textContent = '…working on ' + f.name;
|
||
const fd = new FormData(); fd.append('file', f);
|
||
let path = null;
|
||
try {
|
||
const up = await fetch('/api/tool/upload', { method: 'POST', body: fd }); const u = await up.json();
|
||
path = u.path; if (!u.ok) return fail(b, u);
|
||
const d = await postForm('/api/tool/askfile', { path, question: q });
|
||
if (!d.ok) return fail(b, d);
|
||
b.textContent = d.error ? '' : (d.answer || '');
|
||
if (d.error) { b.textContent = d.error; return; }
|
||
if (d.transcript && !d.answer) b.textContent = d.transcript;
|
||
} catch (err) { b.textContent = '[error: ' + err + ']'; }
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
});
|
||
document.getElementById('transcribeBtn').addEventListener('click', async () => {
|
||
const f = document.getElementById('audioIn').files[0]; if (!f) return;
|
||
addBubble('user', 'Transcribe: ' + f.name); addBubble('bot', '');
|
||
const b = logEl.lastElementChild.querySelector('.bubble'); b.textContent = '…transcribing (whisper)';
|
||
const fd = new FormData(); fd.append('file', f);
|
||
try {
|
||
const res = await fetch('/api/tool/transcribe', { method: 'POST', body: fd });
|
||
const d = await res.json();
|
||
if (!d.ok) return fail(b, d);
|
||
b.textContent = d.transcript ? d.transcript : (d.note || 'No speech detected.');
|
||
if (d.transcript) {
|
||
const dl = document.createElement('div');
|
||
dl.appendChild(downloadLink(d.txt, '⬇ download transcript (.txt)'));
|
||
dl.appendChild(document.createTextNode(' · '));
|
||
dl.appendChild(downloadLink(d.path, '⬇ audio'));
|
||
b.appendChild(dl);
|
||
}
|
||
} catch (err) { b.textContent = '[error: ' + err + ']'; }
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
});
|
||
document.getElementById('genImg').addEventListener('click', async () => {
|
||
const val = document.getElementById('genInput').value.trim(); if (!val) return;
|
||
addBubble('user', 'Generate: ' + val); addBubble('bot', '');
|
||
const b = logEl.lastElementChild.querySelector('.bubble'); b.textContent = '…generating';
|
||
try {
|
||
const res = await fetch('/api/tool/image', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ prompt: val }) });
|
||
const data = await res.json();
|
||
if (data.ok) { b.textContent = ''; const im = document.createElement('img'); im.src = data.b64; im.style.cssText = 'max-width:320px;border-radius:8px;display:block'; b.appendChild(im); const cap = document.createElement('div'); cap.appendChild(downloadLink(data.path, '⬇ download image')); b.appendChild(cap); }
|
||
else b.textContent = '[error: ' + (data.error || 'unknown') + ']';
|
||
} catch (err) { b.textContent = '[error: ' + err + ']'; }
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
});
|
||
|
||
// Ctrl/Cmd+Enter sends in the multiline box
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); form.requestSubmit(); }
|
||
});
|
||
|
||
// collapsible model & tools panel (persisted)
|
||
const toolsBody = document.getElementById('toolsBody');
|
||
const toolsArrow = document.getElementById('toolsArrow');
|
||
const toolsToggle = document.getElementById('toolsToggle');
|
||
function applyTools(open) {
|
||
if (open) { toolsBody.classList.remove('hidden'); toolsArrow.textContent = '▾'; }
|
||
else { toolsBody.classList.add('hidden'); toolsArrow.textContent = '▴'; }
|
||
localStorage.setItem('dshTools', open ? '1' : '0');
|
||
}
|
||
applyTools(localStorage.getItem('dshTools') !== '0');
|
||
toolsToggle.addEventListener('click', () => applyTools(toolsBody.classList.contains('hidden')));
|
||
|
||
const imgIn = document.getElementById('imgIn'); window.__img = null;
|
||
imgIn.addEventListener('change', () => { const f = imgIn.files[0]; if (!f) return; const r = new FileReader(); r.onload = () => { window.__img = r.result; imgIn.title = f.name + ' ✓'; }; r.readAsDataURL(f); });
|
||
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const text = input.value.trim(); const img = window.__img || '';
|
||
if (!text && !img) return;
|
||
if (!curSid) await newSession();
|
||
input.value = ''; window.__img = null; imgIn.value = '';
|
||
const model = modelSel.value || '';
|
||
addBubble('user', text || '(attached image)');
|
||
if (img) { const im = document.createElement('img'); im.src = img; im.style.cssText = 'max-width:180px;border-radius:8px;display:block;margin-top:6px'; logEl.lastElementChild.querySelector('.bubble').appendChild(im); }
|
||
const bubble = addBubble('bot', ''); bubble.textContent = '…thinking';
|
||
let started = false;
|
||
try {
|
||
const body = new URLSearchParams({ message: text, session_id: curSid, model });
|
||
if (img) body.set('image', img);
|
||
const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body });
|
||
const reader = res.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 parts = buf.split('\n\n'); buf = parts.pop(); for (const part of parts) { if (!part.startsWith('data: ')) continue; try { const data = JSON.parse(part.slice(6)); if (data.c) { if (!started) { bubble.textContent = ''; started = true; } appendChar(bubble, data.c); } else if (data.e) { if (!started) bubble.textContent = ''; started = true; appendChar(bubble, '\n[error: ' + data.e + ']'); } } catch (_) {} } }
|
||
addBubble('bot', '').remove();
|
||
} catch (err) { if (!started) bubble.textContent = ''; appendChar(bubble, '\n[connection error]'); }
|
||
loadSessions();
|
||
});
|
||
|
||
fetchModels(); loadSessions();
|
||
|
||
// dark mode toggle (dsh standalone page)
|
||
(function () {
|
||
var root = document.documentElement;
|
||
var saved = null; try { saved = localStorage.getItem('dshTheme'); } catch(_) {}
|
||
if (saved === 'dark' || (!saved && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)) root.setAttribute('data-theme', 'dark');
|
||
var b = document.getElementById('themeBtn');
|
||
if (b) {
|
||
function sync(){ b.textContent = root.getAttribute('data-theme') === 'dark' ? '☀️' : '🌙'; }
|
||
b.addEventListener('click', function () {
|
||
if (root.getAttribute('data-theme') === 'dark') { root.removeAttribute('data-theme'); try{localStorage.setItem('dshTheme','light');}catch(_){} }
|
||
else { root.setAttribute('data-theme','dark'); try{localStorage.setItem('dshTheme','dark');}catch(_){} }
|
||
sync();
|
||
});
|
||
sync();
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html> |