Files
family_home_lab/dsh/templates/chat.html

306 lines
17 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>
html, body { height: 100%; margin: 0; }
body { display: flex; flex-direction: column; }
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 #ddd; 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 #ddd; 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 #ddd; 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 #ddd; 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 #ddd; border-radius: 8px; padding: 6px 10px; font-size: 13px; }
.tools-row button { flex: 0 0 auto; border: 1px solid #ddd; border-radius: 8px; padding: 6px 10px; cursor: pointer; background: #fff; }
.modelrow { display: flex; gap: 8px; align-items: center; }
.modelrow select { border: 1px solid #ddd; 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 #ddd;border-radius:8px;padding:4px 9px;font-size:17px;cursor:pointer;line-height:1">☰</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();
</script>
</body>
</html>