Files
family_home_lab/dsh/templates/chat.html

341 lines
20 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">
<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 { 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; justify-content: flex-end; }
#sendBtn { border: none; border-radius: 8px; padding: 9px 20px; 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; }
.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">{{ 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">
<textarea id="input" rows="2" placeholder="Type your message…" autofocus></textarea>
<div class="send-row"><button type="submit" id="sendBtn">Send</button></div>
</form>
<div id="toolsPanel">
<button type="button" id="toolsToggle"><span id="toolsArrow">▾</span> Model &amp; tools</button>
<div id="toolsBody">
<div class="modelrow"><label for="modelSel">Model:</label><select id="modelSel"></select></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>
<input id="genInput" type="text" placeholder="Describe an image to generate…">
<button type="button" id="genImg">Generate image</button>
<input type="file" id="imgIn" accept="image/*" title="Attach an image">
<input type="file" id="vidIn" accept="video/*" title="Analyze a video / screen recording (GLM)">
<button type="button" id="genVid">Analyze video</button>
</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); });
li.append(t, n, d);
li.addEventListener('click', () => openSession(s.id));
listEl.appendChild(li);
});
}
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) o.selected = true;
modelSel.appendChild(o);
});
} 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'));
document.getElementById('genVid').addEventListener('click', async () => {
const f = document.getElementById('vidIn').files[0];
if (!f) return;
addBubble('user', 'Analyze video: ' + f.name); addBubble('bot', '');
const b = logEl.lastElementChild.querySelector('.bubble'); b.textContent = '…analyzing';
const fd = new FormData(); fd.append('file', f);
try {
const res = await fetch('/api/tool/video', { method: 'POST', body: fd });
const d = await res.json();
b.textContent = d.ok ? (d.summary || '') : '[error: ' + (d.error || 'unknown') + ']';
} 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.textContent = 'saved: ' + data.path; 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>