diff --git a/dsh/app.py b/dsh/app.py index e3afdc3..0f545a7 100644 --- a/dsh/app.py +++ b/dsh/app.py @@ -93,16 +93,20 @@ def load_session(sid): d = json.load(fh) msgs = [m for m in d.get("messages", []) if m.get("role") in ("user", "assistant")] return {"id": d.get("id", sid or "main"), "name": d.get("name", "Conversation"), - "messages": msgs[-HISTORY_LIMIT:]} + "messages": msgs[-HISTORY_LIMIT:], + "model": d.get("model") or LLM_MODEL} except Exception: - return {"id": sid or "main", "name": "Conversation", "messages": []} + return {"id": sid or "main", "name": "Conversation", "messages": [], + "model": LLM_MODEL} -def save_session(sid, msgs): +def save_session(sid, msgs, model=None): _sdir() sid = (sid or "main") with _history_lock: d = load_session(sid) + if model: + d["model"] = model d["messages"] = msgs[-HISTORY_LIMIT:] d["updated"] = datetime.now(timezone.utc).isoformat() if not d.get("name") or d["name"] in ("Conversation", "New conversation"): @@ -213,7 +217,8 @@ async def index(request: Request) -> HTMLResponse: @app.post("/api/chat") async def chat(message: Annotated[str, Form()], image: Annotated[str, Form()] = "", - session_id: Annotated[str, Form()] = "") -> StreamingResponse: + session_id: Annotated[str, Form()] = "", + model: Annotated[str, Form()] = "") -> StreamingResponse: """Stream an answer as SSE. When `image` is a data:image URI the vision model (OpenRouter) is used instead of the text LLM (image ingest).""" use_vision = bool(image and image.startswith("data:image/")) @@ -221,6 +226,8 @@ async def chat(message: Annotated[str, Form()], async def event_stream(): sid = session_id or "main" session = load_session(sid) + if model and model in MODEL_CHOICES: + session["model"] = model hist = session["messages"] hist.append({"role": "user", "content": message}) if use_vision: @@ -242,7 +249,7 @@ async def chat(message: Annotated[str, Form()], base = LLM_BASE key = LLM_KEY payload = { - "model": LLM_MODEL, + "model": session.get("model") or LLM_MODEL, "messages": [{"role": "system", "content": SYSTEM_PROMPT}] + hist, "stream": True, "temperature": 0.7, @@ -276,7 +283,7 @@ async def chat(message: Annotated[str, Form()], return if full: hist.append({"role": "assistant", "content": "".join(full)}) - save_session(sid, hist) + save_session(sid, hist, session.get("model")) # always persist user turn + chosen model return StreamingResponse( event_stream(), @@ -314,6 +321,16 @@ async def generate_image(prompt: Annotated[str, Form()]) -> dict: return {"ok": False, "error": f"{type(exc).__name__}: {exc}"} + +MODEL_CHOICES = ["auto/best-chat", "auto/best-fast", "auto/best-reasoning", + "auto/best-coding", "auto/best-vision"] + + +@app.get("/api/models") +async def models_list() -> dict: + return {"ok": True, "default": LLM_MODEL, "current": MODEL_CHOICES} + + @app.get("/api/sessions") async def sessions_list() -> dict: return {"ok": True, "sessions": list_sessions()} @@ -336,7 +353,8 @@ async def sessions_new() -> dict: @app.get("/api/sessions/{sid}") async def sessions_get(sid: str) -> dict: s = load_session(sid) - return {"ok": True, "id": s["id"], "name": s["name"], "messages": s["messages"]} + return {"ok": True, "id": s["id"], "name": s["name"], "messages": s["messages"], + "model": s.get("model") or LLM_MODEL} @app.post("/api/sessions/{sid}/delete") diff --git a/dsh/templates/chat.html b/dsh/templates/chat.html index 4829c5d..ebfc5c6 100644 --- a/dsh/templates/chat.html +++ b/dsh/templates/chat.html @@ -11,12 +11,12 @@ header.top { flex: none; } .app { display: flex; flex: 1; min-height: 0; } #sidebar { - width: 240px; flex: none; border-right: 1px solid var(--hairline, #e6e6e6); + 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 { margin: 6px 12px 8px; 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; } + #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); @@ -25,9 +25,17 @@ #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; } - #sessions li .del:hover { opacity: 1; } .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; } @@ -40,12 +48,17 @@
+
+ + +
@@ -67,6 +80,7 @@ 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) { @@ -75,32 +89,27 @@ 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 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 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 conversation'; + 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)); @@ -108,23 +117,32 @@ }); } + 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 = m; + if (m === d.default) o.selected = true; + modelSel.appendChild(o); + }); + } catch (_) {} + } + async function loadSessions() { try { - const r = await fetch('/api/sessions'); const d = await r.json(); + 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(); + 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) { curSid = id; localStorage.setItem('dshSid', id); try { - const r = await fetch('/api/sessions/' + id); const d = await r.json(); + 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 || []); } @@ -134,14 +152,24 @@ 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(); + }); + } 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; @@ -154,7 +182,6 @@ } 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('genImg').addEventListener('click', async () => { @@ -170,38 +197,32 @@ logEl.scrollTop = logEl.scrollHeight; }); - 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); - }); + 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 || ''; + const text = input.value.trim(); const img = window.__img || ''; if (!text && !img) return; - if (!curSid) { await newSession(); } + 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); } - addBubble('bot', ''); - const bubble = logEl.lastElementChild.querySelector('.bubble'); + const bubble = addBubble('bot', ''); bubble.textContent = '…thinking'; + let started = false; try { - const body = new URLSearchParams({ message: text, session_id: curSid }); + 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) appendChar(bubble, data.c); else if (data.e) appendChar(bubble, '\n[error: ' + data.e + ']'); } catch (_) {} } } + 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) { appendChar(bubble, '\n[connection error]'); } - loadSessions(); // reflect updated ordering/count + rename + } catch (err) { if (!started) bubble.textContent = ''; appendChar(bubble, '\n[connection error]'); } + loadSessions(); }); - loadSessions(); + fetchModels(); loadSessions(); \ No newline at end of file