Files
family_home_lab/dsh/templates/chat.html

427 lines
24 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, #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 &amp; 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>