diff --git a/.gitignore b/.gitignore index 221163b097..facd17d62f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ *.a .DS_Store .idea +mistralrs-web-chat/cache \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index adbd62f629..588ce6ec54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -525,9 +525,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.40" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", @@ -2719,6 +2719,7 @@ dependencies = [ "anyhow", "axum", "axum_static", + "chrono", "clap", "futures-util", "image", diff --git a/mistralrs-web-chat/Cargo.toml b/mistralrs-web-chat/Cargo.toml index 45200ab5b0..551ff9b817 100644 --- a/mistralrs-web-chat/Cargo.toml +++ b/mistralrs-web-chat/Cargo.toml @@ -28,6 +28,7 @@ uuid = "1.17.0" image.workspace = true clap = { workspace = true, features = ["derive"] } indexmap.workspace = true +chrono = "0.4.41" [features] cuda = ["mistralrs/cuda"] diff --git a/mistralrs-web-chat/src/main.rs b/mistralrs-web-chat/src/main.rs index 718543bc60..b35d1582c8 100644 --- a/mistralrs-web-chat/src/main.rs +++ b/mistralrs-web-chat/src/main.rs @@ -1,5 +1,4 @@ use anyhow::Result; -const CLEAR_CMD: &str = "__CLEAR__"; use axum::{ extract::ws::{Message, WebSocket, WebSocketUpgrade}, extract::DefaultBodyLimit, @@ -9,6 +8,7 @@ use axum::{ routing::{get, get_service, post}, Json, Router, }; +use chrono::Utc; use clap::Parser; use futures_util::stream::StreamExt; use indexmap::IndexMap; @@ -17,14 +17,18 @@ use mistralrs::{ VisionMessages, VisionModelBuilder, }; use serde::Deserialize; +use serde::Serialize; use serde_json::{json, Value}; use std::mem; use std::{net::SocketAddr, sync::Arc}; +use tokio::fs; use tokio::{net::TcpListener, sync::RwLock}; use tower_http::services::ServeDir; use tracing::error; use uuid::Uuid; +const CLEAR_CMD: &str = "__CLEAR__"; + #[derive(Parser, Debug)] #[command(author, version, about)] struct Cli { @@ -52,9 +56,28 @@ enum LoadedModel { Vision(Arc), } +#[derive(Serialize, Deserialize)] +struct ChatMessage { + role: String, + content: String, +} + +#[derive(Serialize, Deserialize)] +struct ChatFile { + #[serde(default)] + title: Option, + model: String, + kind: String, + created_at: String, + messages: Vec, +} + struct AppState { models: IndexMap, current: RwLock>, + chats_dir: String, + current_chat: RwLock>, + next_chat_id: RwLock, } #[tokio::main] @@ -114,9 +137,30 @@ async fn main() -> Result<()> { models.insert(name, LoadedModel::Vision(Arc::new(m))); } + let chats_dir = format!("{}/cache/chats", env!("CARGO_MANIFEST_DIR")); + tokio::fs::create_dir_all(&chats_dir).await?; + let mut next_id = 1u32; + if let Ok(mut dir) = fs::read_dir(&chats_dir).await { + while let Ok(Some(entry)) = dir.next_entry().await { + if let Some(name) = entry.file_name().to_str() { + if let Some(num) = name + .strip_prefix("chat_") + .and_then(|s| s.strip_suffix(".json")) + { + if let Ok(n) = num.parse::() { + next_id = next_id.max(n + 1); + } + } + } + } + } + let app_state = Arc::new(AppState { models, current: RwLock::new(None), + chats_dir, + current_chat: RwLock::new(None), + next_chat_id: RwLock::new(next_id), }); let app = Router::new() @@ -124,6 +168,11 @@ async fn main() -> Result<()> { .route("/api/upload_image", post(upload_image)) .route("/api/list_models", get(list_models)) .route("/api/select_model", post(select_model)) + .route("/api/list_chats", get(list_chats)) + .route("/api/new_chat", post(new_chat)) + .route("/api/delete_chat", post(delete_chat)) + .route("/api/load_chat", post(load_chat)) + .route("/api/rename_chat", post(rename_chat)) .nest_service( "/", get_service(ServeDir::new(concat!( @@ -141,7 +190,7 @@ async fn main() -> Result<()> { Ok(()) } -/// Accepts multipart image upload, stores it under `static/uploads/`, and returns its URL. +/// Accepts multipart image upload, stores it under `cache/uploads/`, and returns its URL. async fn upload_image( State(_app): State>, mut multipart: Multipart, @@ -168,7 +217,7 @@ async fn upload_image( }; // Ensure dir exists - let upload_dir = format!("{}/static/uploads", env!("CARGO_MANIFEST_DIR")); + let upload_dir = format!("{}/cache/uploads", env!("CARGO_MANIFEST_DIR")); if let Err(e) = tokio::fs::create_dir_all(&upload_dir).await { error!("mkdir error: {}", e); return (StatusCode::INTERNAL_SERVER_ERROR, "server error").into_response(); @@ -213,15 +262,224 @@ async fn select_model( State(app): State>, Json(req): Json, ) -> impl IntoResponse { - if app.models.contains_key(&req.name) { - let mut cur = app.current.write().await; - *cur = Some(req.name.clone()); + if let Some(model_loaded) = app.models.get(&req.name) { + { + let mut cur = app.current.write().await; + *cur = Some(req.name.clone()); + } + // --- sync the active chat file so future loads use the correct model --- + if let Some(chat_id) = app.current_chat.read().await.clone() { + let path = format!("{}/{}.json", app.chats_dir, chat_id); + if let Ok(data) = fs::read(&path).await { + if let Ok(mut chat) = serde_json::from_slice::(&data) { + chat.model = req.name.clone(); + chat.kind = match model_loaded { + LoadedModel::Text(_) => "text".into(), + LoadedModel::Vision(_) => "vision".into(), + }; + // ignore write errors; not fatal for select_model + if let Ok(bytes) = serde_json::to_vec_pretty(&chat) { + let _ = tokio::fs::write(&path, bytes).await; + } + } + } + } (StatusCode::OK, "Model selected").into_response() } else { (StatusCode::NOT_FOUND, "Model not found").into_response() } } +/// ---------- Chat‑history helpers & endpoints ---------- +async fn list_chats(State(app): State>) -> impl IntoResponse { + let mut chats = Vec::new(); + if let Ok(mut dir) = fs::read_dir(&app.chats_dir).await { + while let Ok(Some(entry)) = dir.next_entry().await { + if let Some(name) = entry.file_name().to_str() { + if name.ends_with(".json") { + let id = name.trim_end_matches(".json"); + let data = fs::read(format!("{}/{}", app.chats_dir, name)).await.ok(); + let (title, created) = data + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .map(|c| (c.title, c.created_at)) + .map(|(title, created)| (title.unwrap_or_default(), created)) + .unwrap_or_else(|| (String::new(), String::new())); + chats.push(json!({ "id": id, "title": title, "created_at": created })); + } + } + } + } + Json(json!({ "chats": chats })) +} + +#[derive(Deserialize)] +struct NewChatRequest { + model: String, +} + +async fn new_chat( + State(app): State>, + Json(req): Json, +) -> impl IntoResponse { + let mut id_guard = app.next_chat_id.write().await; + let id = *id_guard; + *id_guard += 1; + drop(id_guard); + + let chat_id = format!("chat_{}", id); + let path = format!("{}/{}.json", app.chats_dir, chat_id); + + let kind = if let Some(m) = app.models.get(&req.model) { + match m { + LoadedModel::Text(_) => "text", + LoadedModel::Vision(_) => "vision", + } + } else { + "text" + } + .to_string(); + + let chat = ChatFile { + title: None, + model: req.model.clone(), + kind, + created_at: Utc::now().to_rfc3339(), + messages: Vec::new(), + }; + let _ = fs::write(&path, serde_json::to_vec_pretty(&chat).unwrap()).await; + + { + let mut cur_chat = app.current_chat.write().await; + *cur_chat = Some(chat_id.clone()); + let mut cur_model = app.current.write().await; + *cur_model = Some(req.model.clone()); + } + + Json(json!({ "id": chat_id })) +} +#[derive(Deserialize)] +struct DeleteChatRequest { + id: String, +} + +async fn delete_chat( + State(app): State>, + Json(req): Json, +) -> impl IntoResponse { + let path = format!("{}/{}.json", app.chats_dir, req.id); + if let Err(e) = tokio::fs::remove_file(&path).await { + error!("delete chat error: {}", e); + return (StatusCode::NOT_FOUND, "chat not found").into_response(); + } + { + let mut cur_chat = app.current_chat.write().await; + if cur_chat.as_ref() == Some(&req.id) { + *cur_chat = None; + let mut cur_model = app.current.write().await; + *cur_model = None; + } + } + (StatusCode::OK, "Deleted").into_response() +} + +#[derive(Deserialize)] +struct LoadChatRequest { + id: String, +} + +async fn load_chat( + State(app): State>, + Json(req): Json, +) -> impl IntoResponse { + let path = format!("{}/{}.json", app.chats_dir, req.id); + match fs::read(&path).await { + Ok(data) => match serde_json::from_slice::(&data) { + Ok(chat) => { + { + let mut cur_chat = app.current_chat.write().await; + *cur_chat = Some(req.id.clone()); + if app.models.contains_key(&chat.model) { + let mut cur_model = app.current.write().await; + *cur_model = Some(chat.model.clone()); + } + } + Json(json!({ + "id": req.id, + "title": chat.title.clone().unwrap_or_default(), + "model": chat.model, + "kind": chat.kind, + "created_at": chat.created_at.clone(), + "messages": chat.messages + })) + .into_response() + } + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "corrupt chat").into_response(), + }, + Err(_) => (StatusCode::NOT_FOUND, "chat not found").into_response(), + } +} + +#[derive(Deserialize)] +struct RenameChatRequest { + id: String, + title: String, +} + +async fn rename_chat( + State(app): State>, + Json(req): Json, +) -> impl IntoResponse { + let path = format!("{}/{}.json", app.chats_dir, req.id); + if let Ok(data) = fs::read(&path).await { + if let Ok(mut chat) = serde_json::from_slice::(&data) { + chat.title = Some(req.title.clone()); + if let Ok(bytes) = serde_json::to_vec_pretty(&chat) { + let _ = tokio::fs::write(&path, bytes).await; + return (StatusCode::OK, "Renamed").into_response(); + } + } + } + (StatusCode::INTERNAL_SERVER_ERROR, "rename failed").into_response() +} + +async fn append_chat_message(app: &Arc, role: &str, content: &str) -> Result<()> { + // Ignore replay helpers sent from the front‑end, + // which look like: {"restore":{...}} + if content.trim_start().starts_with("{\"restore\":") { + return Ok(()); + } + let chat_opt = app.current_chat.read().await.clone(); + let Some(chat_id) = chat_opt else { + return Ok(()); + }; + let path = format!("{}/{}.json", app.chats_dir, chat_id); + + let mut chat: ChatFile = if let Ok(data) = fs::read(&path).await { + serde_json::from_slice(&data).unwrap_or(ChatFile { + title: None, + model: app.current.read().await.clone().unwrap_or_default(), + kind: String::new(), + created_at: Utc::now().to_rfc3339(), + messages: Vec::new(), + }) + } else { + ChatFile { + title: None, + model: app.current.read().await.clone().unwrap_or_default(), + kind: String::new(), + created_at: Utc::now().to_rfc3339(), + messages: Vec::new(), + } + }; + + chat.messages.push(ChatMessage { + role: role.into(), + content: content.into(), + }); + fs::write(&path, serde_json::to_vec_pretty(&chat)?).await?; + Ok(()) +} + /// Upgrades an HTTP request to a WebSocket connection. async fn ws_handler(ws: WebSocketUpgrade, State(app): State>) -> impl IntoResponse { ws.on_upgrade(move |socket| handle_socket(socket, app)) @@ -291,6 +549,44 @@ async fn handle_socket(mut socket: WebSocket, app: Arc) { } continue; } + // Handle front‑end replay helper messages without triggering inference + if user_msg.trim_start().starts_with("{\"restore\":") { + if let Ok(val) = serde_json::from_str::(&user_msg) { + if let Some(obj) = val.get("restore") { + if let (Some(role), Some(content)) = ( + obj.get("role").and_then(|v| v.as_str()), + obj.get("content").and_then(|v| v.as_str()), + ) { + // Push into the local context so the model sees the history + match app.current.read().await.as_deref() { + Some(model_name) if app.models.get(model_name).is_some() => { + match app.models.get(model_name).unwrap() { + LoadedModel::Text(_) => { + let role_enum = if role == "assistant" { + TextMessageRole::Assistant + } else { + TextMessageRole::User + }; + text_msgs = text_msgs.add_message(role_enum, content); + } + LoadedModel::Vision(_) => { + let role_enum = if role == "assistant" { + TextMessageRole::Assistant + } else { + TextMessageRole::User + }; + vision_msgs = vision_msgs.add_message(role_enum, content); + } + } + } + _ => {} + } + } + } + } + // Skip normal handling (no saving, no inference) + continue; + } let model_name_opt = { app.current.read().await.clone() }; let Some(model_name) = model_name_opt else { let _ = socket @@ -310,6 +606,10 @@ async fn handle_socket(mut socket: WebSocket, app: Arc) { match model_loaded { LoadedModel::Text(model) => { text_msgs = text_msgs.add_message(TextMessageRole::User, &user_msg); + if let Err(e) = append_chat_message(&app, "user", &user_msg).await { + error!("chat save error: {}", e); + } + let mut assistant_content = String::new(); let msgs_snapshot = text_msgs.clone(); streaming = true; @@ -318,12 +618,16 @@ async fn handle_socket(mut socket: WebSocket, app: Arc) { msgs_snapshot, &mut socket, |tok| { + assistant_content = tok.to_string(); let cur = mem::take(&mut text_msgs); text_msgs = cur.add_message(TextMessageRole::Assistant, tok); }, || streaming = false, ) .await; + if !assistant_content.is_empty() { + let _ = append_chat_message(&app, "assistant", &assistant_content).await; + } if let Err(e) = stream_res { error!("stream error: {}", e); } @@ -355,11 +659,17 @@ async fn handle_socket(mut socket: WebSocket, app: Arc) { } else { // Fallback: treat whole JSON as text vision_msgs = vision_msgs.add_message(TextMessageRole::User, &user_msg); + if let Err(e) = append_chat_message(&app, "user", &user_msg).await { + error!("chat save error: {}", e); + } } } else { // Plain-text prompt arrives here if image_buffer.is_empty() { vision_msgs = vision_msgs.add_message(TextMessageRole::User, &user_msg); + if let Err(e) = append_chat_message(&app, "user", &user_msg).await { + error!("chat save error: {}", e); + } } else { match vision_msgs.clone().add_image_message( TextMessageRole::User, @@ -381,17 +691,22 @@ async fn handle_socket(mut socket: WebSocket, app: Arc) { let msgs_snapshot = vision_msgs.clone(); streaming = true; + let mut assistant_content = String::new(); let stream_res = stream_and_forward( &model, msgs_snapshot, &mut socket, |tok| { + assistant_content = tok.to_string(); let cur = mem::take(&mut vision_msgs); vision_msgs = cur.add_message(TextMessageRole::Assistant, tok); }, || streaming = false, ) .await; + if !assistant_content.is_empty() { + let _ = append_chat_message(&app, "assistant", &assistant_content).await; + } if let Err(e) = stream_res { error!("stream error: {}", e); } diff --git a/mistralrs-web-chat/static/index.html b/mistralrs-web-chat/static/index.html index 27f41e51a8..0e50610a89 100644 --- a/mistralrs-web-chat/static/index.html +++ b/mistralrs-web-chat/static/index.html @@ -6,24 +6,119 @@ Mistral.rs Chat
-

Mistral.rs Chat

+

Mistral.rs Chat

@@ -241,24 +276,43 @@

Mistral.rs Chat

const form = document.getElementById('form'); const modelSelect = document.getElementById('modelSelect'); const clearBtn = document.getElementById('clearBtn'); + const newChatBtn = document.getElementById('newChatBtn'); + const renameBtn = document.getElementById('renameBtn'); + const deleteBtn = document.getElementById('deleteBtn'); + const chatList = document.getElementById('chatList'); const imageInput = document.getElementById('imageInput'); const imageLabel = document.getElementById('imageLabel'); - const loadStatus = document.getElementById('loadStatus'); const mainArea = document.getElementById('main'); + const ws = new WebSocket(`ws://${location.host}/ws`); - const ws = new WebSocket(`ws://${location.host}/ws`); - window.chatSocket = ws; + // track which chat is currently loaded + let pendingClear = false; + let currentChatId = null; + const CLEAR_CMD = "__CLEAR__"; const models = Object.create(null); // name → kind let prevModel = null; - /* ---------- helpers ---------- */ - const CLEAR_CMD = "__CLEAR__"; - let pendingClear = false; - function renderMarkdown(src){ - const esc = src.replace(/&/g,"&").replace(//g,">"); - return marked.parse(esc); + // Helper: escape & < > once + const escape = s => + s.replace(/&(?![a-zA-Z0-9#]+;)/g,'&') + .replace(//g,'>'); + + // Split the markdown into segments that are either + // (1) fenced code blocks ``` ... ``` + // (2) inline code `...` + // (3) normal text (everything else) + // + // We only escape (3). + const pattern = /(```[\s\S]*?```|`[^`]*`)/g; + const escaped = src.split(pattern).map(seg=>{ + // segments that start with back‑tick are code, keep raw + return seg.startsWith('`') ? seg : escape(seg); + }).join(''); + + return marked.parse(escaped); } function append(html,cls=''){ const d=document.createElement('div'); @@ -269,11 +323,36 @@

Mistral.rs Chat

} function addCopyBtns(el){ el.querySelectorAll('pre').forEach(pre=>{ - if(pre.querySelector('.copy-btn')) return; - const b=document.createElement('button'); - b.textContent='Copy'; b.className='copy-btn'; - b.onclick=()=>{navigator.clipboard.writeText((pre.querySelector('code')||pre).innerText.trim()); b.textContent='✔'; setTimeout(()=>b.textContent='Copy',800);}; - pre.appendChild(b); + if(pre.querySelector('.copy-btn')) return; // avoid duplicates + const btn = document.createElement('button'); + btn.textContent = 'Copy'; + btn.className = 'copy-btn'; + + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const codeEl = pre.querySelector('code') || pre; + const text = codeEl.innerText.trim(); + + try { + await navigator.clipboard.writeText(text); // modern clipboard API + } catch { + /* Fallback for older browsers */ + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + try { document.execCommand('copy'); } catch (_) {} + document.body.removeChild(ta); + } + + btn.textContent = '✔'; + setTimeout(() => (btn.textContent = 'Copy'), 800); + }); + + pre.appendChild(btn); }); } function fixLinks(el){ el.querySelectorAll('a[href]').forEach(a=>{a.target='_blank';a.rel='noopener noreferrer';}); } @@ -282,39 +361,105 @@

Mistral.rs Chat

if(kind!=='vision') imageInput.value=''; } - /* ---------- model discovery ---------- */ - async function refreshModels(){ - try{ - const res=await fetch('/api/list_models'); const data=await res.json(); - modelSelect.innerHTML=''; Object.keys(models).forEach(k=>delete models[k]); - data.models.forEach(m=>{ - models[m.name]=m.kind; - const opt=document.createElement('option'); - opt.value=m.name; opt.textContent=(m.kind==='vision'?'🖼️ ':'📝 ')+m.name; - modelSelect.appendChild(opt); - }); - if(modelSelect.options.length){ - modelSelect.selectedIndex=0; - prevModel=modelSelect.value; - updateImageVisibility(models[prevModel]); - await selectModel(prevModel,false); + async function refreshChatList(){ + const res = await fetch('/api/list_chats'); + const data = await res.json(); + // Sort chats by creation timestamp, newest first + data.chats.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + chatList.innerHTML=''; + data.chats.forEach((c,idx)=>{ + const li = document.createElement('li'); + li.dataset.id = c.id; + li.onclick = () => loadChat(c.id); + // Highlight active chat + if (c.id === currentChatId) { + li.classList.add('active'); } - }catch(e){ loadStatus.textContent='Unable to fetch model list'; } + + // Determine label: use title if set, otherwise reverse-number by creation order + const display = c.title && c.title.trim() + ? c.title + : `Chat #${data.chats.length - idx}`; + + const titleDiv = document.createElement('div'); + titleDiv.textContent = display; + + const dateDiv = document.createElement('div'); + dateDiv.textContent = new Date(c.created_at).toLocaleString(); + dateDiv.style.fontSize = '0.8em'; + dateDiv.style.color = 'var(--text-muted)'; + + li.appendChild(titleDiv); + li.appendChild(dateDiv); + chatList.appendChild(li); + }); + } + + async function loadChat(id){ + if(!maybeClearChat(true)) return; + const res = await fetch('/api/load_chat',{ + method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({id}) + }); + if(!res.ok){ alert('Failed to load chat'); return; } + const data = await res.json(); + currentChatId = data.id; + document.querySelectorAll('#chatList li').forEach(li => li.classList.remove('active')); + const activeLi = document.querySelector(`#chatList li[data-id="${id}"]`); + if (activeLi) activeLi.classList.add('active'); + if(data.model && models[data.model]){ + modelSelect.value = data.model; + prevModel = data.model; + updateImageVisibility(models[data.model]); + } + log.innerHTML=''; + data.messages.forEach(m=>{ + append(renderMarkdown(m.content), m.role==='user'?'user':'assistant'); + if(ws.readyState===WebSocket.OPEN){ + ws.send(JSON.stringify({restore:m})); + } + }); + } + + async function refreshModels(){ + const res=await fetch('/api/list_models'); + const data=await res.json(); + modelSelect.innerHTML=''; + Object.keys(models).forEach(k=>delete models[k]); + data.models.forEach(m=>{ + models[m.name]=m.kind; + const opt=document.createElement('option'); + opt.value=m.name; opt.textContent=(m.kind==='vision'?'🖼️ ':'📝 ')+m.name; + modelSelect.appendChild(opt); + }); + if(modelSelect.options.length){ + modelSelect.selectedIndex=0; + prevModel=modelSelect.value; + updateImageVisibility(models[prevModel]); + await selectModel(prevModel,false); + } + await refreshChatList(); + if (!currentChatId) newChatBtn.click(); } - function maybeClearChat(){ + function maybeClearChat(skipConfirm = false){ if(log.children.length===0) return true; - if(!confirm('Switching model will clear the chat. Continue?')) return false; - pendingClear = true; - if (ws.readyState === WebSocket.OPEN) ws.send(CLEAR_CMD); - return true; + if(skipConfirm || confirm('Clear the current draft conversation?')){ + pendingClear = true; + if(ws.readyState===WebSocket.OPEN) ws.send(CLEAR_CMD); + log.innerHTML=''; + return true; + } + return false; } - async function selectModel(name,clearConfirmed){ - if(!clearConfirmed && log.children.length){ /* safeguard */ } - try{ - await fetch('/api/select_model',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})}); - }catch{} + async function selectModel(name, notify=true){ + await fetch('/api/select_model',{ + method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({name}) + }); } modelSelect.addEventListener('change',async()=>{ @@ -323,37 +468,79 @@

Mistral.rs Chat

if(!maybeClearChat()){ modelSelect.value=prevModel; return; } updateImageVisibility(models[name]); prevModel=name; - await selectModel(name,true); + await selectModel(name); + }); + + newChatBtn.addEventListener('click', async () => { + if (!prevModel) { alert('Select a model first'); return; } + const res = await fetch('/api/new_chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: prevModel }) + }); + if (!res.ok) { alert('Failed to create new chat'); return; } + const { id } = await res.json(); + currentChatId = id; + log.innerHTML = ''; + await refreshChatList(); }); - /* ---------- Clear chat button ---------- */ clearBtn.addEventListener('click', () => { if (log.children.length === 0) return; if (!confirm('Clear the chat history?')) return; pendingClear = true; if (ws.readyState === WebSocket.OPEN) ws.send(CLEAR_CMD); + log.innerHTML=''; }); - /* ---------- textarea auto-resize ---------- */ + renameBtn.addEventListener('click', async () => { + if (!currentChatId) { alert('No chat selected'); return; } + const newTitle = prompt('Enter new chat name:', ''); + if (newTitle && newTitle.trim()) { + const res = await fetch('/api/rename_chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: currentChatId, title: newTitle.trim() }) + }); + if (res.ok) { + await refreshChatList(); + } else { + alert('Failed to rename chat'); + } + } + }); + + deleteBtn.addEventListener('click', async () => { + if (!currentChatId) { alert('No chat selected'); return; } + if (!confirm('Delete this chat permanently?')) return; + const res = await fetch('/api/delete_chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: currentChatId }) + }); + if (!res.ok) { alert('Failed to delete chat'); return; } + currentChatId = null; + log.innerHTML=''; + await refreshChatList(); + // Move to newest chat if any, otherwise create a fresh one + const firstLi = chatList.querySelector('li'); + if(firstLi){ + loadChat(firstLi.dataset.id); + }else if(prevModel){ + newChatBtn.click(); + } + }); + + // textarea auto-resize const maxH=parseFloat(getComputedStyle(input).lineHeight)*10; function fit(){ input.style.height='auto'; input.style.height=Math.min(input.scrollHeight,maxH)+'px'; } input.addEventListener('input',fit); fit(); - /* ---------- websocket streaming ---------- */ + // websocket streaming let assistantBuf='',assistantDiv=null; ws.addEventListener('message',ev=>{ - if (ev.data === '[Context cleared]') { - if (pendingClear) { - log.innerHTML = ''; - pendingClear = false; - } - return; - } - if (ev.data === 'Cannot clear while assistant is replying.') { - pendingClear = false; - alert(ev.data); - return; - } + if(ev.data==='[Context cleared]'){ pendingClear=false; return; } + if(ev.data==='Cannot clear while assistant is replying.'){ pendingClear=false; alert(ev.data); return; } if(!assistantDiv) assistantDiv=append('', 'assistant'); assistantBuf+=ev.data; assistantDiv.innerHTML=renderMarkdown(assistantBuf); @@ -361,88 +548,45 @@

Mistral.rs Chat

log.scrollTop=log.scrollHeight; }); - /* ---------- send user messages (Ctrl+Enter or submit button only) ---------- */ - function sendMessage() { - const msg = input.value.trim(); - if (!msg) return; - append(renderMarkdown(msg), 'user'); - assistantBuf = ''; - assistantDiv = null; + // send user messages + function sendMessage(){ + const msg=input.value.trim(); + if(!msg) return; + append(renderMarkdown(msg),'user'); + assistantBuf=''; assistantDiv=null; ws.send(msg); - input.value = ''; - fit(); + input.value=''; fit(); } - - // Prevent Enter from submitting the form unless Ctrl+Enter/Cmd+Enter or button click - form.addEventListener('submit', ev => { - ev.preventDefault(); - sendMessage(); - }); - - // Allow Ctrl+Enter (or Cmd+Enter) in textarea to send, but not regular Enter - input.addEventListener('keydown', function(ev) { - if (ev.key === 'Enter' && !ev.shiftKey) { - if (ev.ctrlKey || ev.metaKey) { - ev.preventDefault(); - sendMessage(); - } else { - // Prevent default Enter from submitting form or adding newline - ev.preventDefault(); - } + form.addEventListener('submit',ev=>{ev.preventDefault();sendMessage();}); + input.addEventListener('keydown',ev=>{ + if(ev.key==='Enter'&&!ev.shiftKey){ + if(ev.ctrlKey||ev.metaKey){ev.preventDefault();sendMessage();} + else ev.preventDefault(); } }); - /* ---------- image upload: file input ---------- */ - imageInput.addEventListener('change', async () => { - const f = imageInput.files[0]; - if (!f) return; - - const img = document.createElement('img'); - img.src = URL.createObjectURL(f); - img.classList.add('chat-preview'); - const container = document.getElementById('image-container'); - container.appendChild(img); - - const fd = new FormData(); - fd.append('image', f); - const r = await fetch('/api/upload_image', { method: 'POST', body: fd }); - if (r.ok && ws.readyState === WebSocket.OPEN) { - const j = await r.json(); - ws.send(JSON.stringify({ image: j.url })); - } - imageInput.value = ''; + // image upload + imageInput.addEventListener('change',async()=>{ + const f=imageInput.files[0]; if(!f) return; + const img=document.createElement('img'); img.src=URL.createObjectURL(f); img.classList.add('chat-preview'); + document.getElementById('image-container').appendChild(img); + const fd=new FormData(); fd.append('image',f); + const r=await fetch('/api/upload_image',{method:'POST',body:fd}); + if(r.ok&&ws.readyState===WebSocket.OPEN){const j=await r.json();ws.send(JSON.stringify({image:j.url}));} + imageInput.value=''; }); - /* ---------- image upload: drag and drop ---------- */ - mainArea.addEventListener('dragover', (e) => { - e.preventDefault(); - mainArea.classList.add('drag-over'); - }); - mainArea.addEventListener('dragleave', (e) => { - e.preventDefault(); - mainArea.classList.remove('drag-over'); - }); - mainArea.addEventListener('drop', async (e) => { - e.preventDefault(); - mainArea.classList.remove('drag-over'); - const dt = e.dataTransfer; - if (!dt || !dt.files || !dt.files.length) return; - const f = Array.from(dt.files).find(file => file.type.startsWith('image/')); - if (!f) return; - - const img = document.createElement('img'); - img.src = URL.createObjectURL(f); - img.classList.add('chat-preview'); - const container = document.getElementById('image-container'); - container.appendChild(img); - - const fd = new FormData(); - fd.append('image', f); - const r = await fetch('/api/upload_image', { method: 'POST', body: fd }); - if (r.ok && ws.readyState === WebSocket.OPEN) { - const j = await r.json(); - ws.send(JSON.stringify({ image: j.url })); - } + // drag & drop + mainArea.addEventListener('dragover',e=>{e.preventDefault();mainArea.classList.add('drag-over');}); + mainArea.addEventListener('dragleave',e=>{e.preventDefault();mainArea.classList.remove('drag-over');}); + mainArea.addEventListener('drop',async e=>{ + e.preventDefault(); mainArea.classList.remove('drag-over'); + const f=Array.from(e.dataTransfer.files).find(f=>f.type.startsWith('image/')); if(!f) return; + const img=document.createElement('img'); img.src=URL.createObjectURL(f); img.classList.add('chat-preview'); + document.getElementById('image-container').appendChild(img); + const fd=new FormData(); fd.append('image',f); + const r=await fetch('/api/upload_image',{method:'POST',body:fd}); + if(r.ok&&ws.readyState===WebSocket.OPEN){const j=await r.json();ws.send(JSON.stringify({image:j.url}));} }); refreshModels();