dsh: model selector (per-session, tiered), thinking indicator, copy-per-message, download .md
This commit is contained in:
32
dsh/app.py
32
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")
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -40,12 +48,17 @@
|
||||
<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>
|
||||
|
||||
<div class="modelrow" style="padding:6px 12px 2px;gap:8px">
|
||||
<label for="modelSel">Model:</label>
|
||||
<select id="modelSel"></select>
|
||||
</div>
|
||||
<form id="chat" class="composer" autocomplete="off">
|
||||
<input id="input" type="text" placeholder="Type your message…" autofocus />
|
||||
<button type="submit">Send</button>
|
||||
@@ -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();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user