Skip to main content

batuta/serve/banco/
handlers_ui.rs

1//! Browser UI handlers — serve the embedded chat UI.
2//!
3//! Two modes:
4//! - `/` — Zero-JS HTML form that POSTs to `/ui/chat` (SSR)
5//! - `/ui/chat` — Server-side rendering: accepts form POST, returns full page with response
6
7use axum::{
8    extract::State,
9    http::{header, StatusCode},
10    response::{Html, IntoResponse},
11    Form,
12};
13use serde::Deserialize;
14
15use super::state::BancoState;
16
17/// GET / — serve the zero-JS chat UI.
18pub async fn index_handler(State(state): State<BancoState>) -> Html<String> {
19    Html(render_chat_page(&state, None, None))
20}
21
22/// Form data from the chat HTML form.
23#[derive(Deserialize)]
24pub struct ChatFormData {
25    pub message: String,
26}
27
28/// POST /ui/chat — server-rendered chat (zero JavaScript).
29pub async fn chat_form_handler(
30    State(state): State<BancoState>,
31    Form(form): Form<ChatFormData>,
32) -> Html<String> {
33    let prompt = form.message.trim().to_string();
34    if prompt.is_empty() {
35        return Html(render_chat_page(&state, None, None));
36    }
37
38    // Call the chat completion API internally
39    let request = super::types::BancoChatRequest {
40        model: None,
41        messages: vec![crate::serve::templates::ChatMessage::user(&prompt)],
42        max_tokens: 256,
43        temperature: 0.7,
44        top_p: 1.0,
45        stream: false,
46        conversation_id: None,
47        response_format: None,
48        rag: false,
49        rag_config: None,
50        attachments: vec![],
51        tools: None,
52        tool_choice: None,
53    };
54
55    let response = super::handlers_inference::try_inference(&state, &request)
56        .map(|(text, _, _)| text)
57        .unwrap_or_else(|| {
58            if state.model.is_loaded() {
59                "Model loaded but inference unavailable. Build with --features banco.".to_string()
60            } else {
61                "No model loaded. Use: POST /api/v1/models/load {\"model\": \"./model.gguf\"}"
62                    .to_string()
63            }
64        });
65
66    Html(render_chat_page(&state, Some(&prompt), Some(&response)))
67}
68
69/// Render the full chat page HTML (zero JavaScript).
70fn render_chat_page(state: &BancoState, prompt: Option<&str>, response: Option<&str>) -> String {
71    let model_status = if let Some(info) = state.model.info() {
72        format!("<span style='color:#4caf50'>&#9679;</span> {}", info.model_id)
73    } else {
74        "<span style='color:#f44336'>&#9679;</span> No model".to_string()
75    };
76
77    let messages_html = match (prompt, response) {
78        (Some(p), Some(r)) => format!(
79            "<div class='msg user'>{}</div><div class='msg assistant'>{}</div>",
80            html_escape(p),
81            html_escape(r)
82        ),
83        _ => "<div class='msg system'>Send a message to start chatting.</div>".to_string(),
84    };
85
86    format!(
87        r##"<!DOCTYPE html>
88<html lang="en">
89<head>
90<meta charset="utf-8">
91<meta name="viewport" content="width=device-width, initial-scale=1">
92<title>Banco — Local AI Workbench</title>
93<style>
94*{{margin:0;padding:0;box-sizing:border-box}}
95:root{{--bg:#1a1a2e;--surface:#16213e;--border:#0f3460;--accent:#e94560;--text:#e0e0e0;--dim:#888}}
96body{{font-family:-apple-system,system-ui,sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;flex-direction:column}}
97header{{background:var(--surface);padding:12px 20px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--border)}}
98header h1{{font-size:16px;font-weight:600;color:var(--accent)}}
99.model-info{{font-size:12px;color:var(--dim)}}
100.messages{{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:8px}}
101.msg{{padding:10px 14px;border-radius:8px;max-width:80%;line-height:1.5;white-space:pre-wrap;word-wrap:break-word}}
102.msg.user{{align-self:flex-end;background:var(--accent);color:white}}
103.msg.assistant{{align-self:flex-start;background:var(--surface);border:1px solid var(--border)}}
104.msg.system{{align-self:center;font-size:12px;color:var(--dim);font-style:italic}}
105.chat-form{{display:flex;gap:8px;padding:12px 20px;background:var(--surface);border-top:1px solid var(--border)}}
106.chat-form input[type=text]{{flex:1;padding:10px 14px;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);font-size:14px}}
107.chat-form button{{padding:10px 20px;background:var(--accent);color:white;border:none;border-radius:8px;cursor:pointer;font-size:14px}}
108.chat-form button:hover{{opacity:0.9}}
109footer{{padding:8px 20px;font-size:11px;color:var(--dim);background:var(--surface);border-top:1px solid var(--border);text-align:center}}
110footer a{{color:var(--accent);text-decoration:none}}
111</style>
112</head>
113<body>
114<header>
115  <h1>Banco</h1>
116  <span class="model-info">{model_status}</span>
117</header>
118<div class="messages">{messages_html}</div>
119<form class="chat-form" method="POST" action="/ui/chat">
120  <input name="message" type="text" placeholder="Type a message..." autocomplete="off" autofocus>
121  <button type="submit">Send</button>
122</form>
123<footer>
124  Zero-JS UI &middot; API: <a href="/api/v1/system">/api/v1/system</a> &middot;
125  Chat: POST <a href="/api/v1/chat/completions">/api/v1/chat/completions</a>
126</footer>
127</body>
128</html>"##,
129        model_status = model_status,
130        messages_html = messages_html
131    )
132}
133
134/// Minimal HTML escaping for user content.
135fn html_escape(s: &str) -> String {
136    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
137}
138
139/// GET /assets/* — serve static assets (CSS/JS/WASM).
140/// Currently returns 404 — scaffold for presentar WASM bundles.
141pub async fn assets_handler(
142    axum::extract::Path(path): axum::extract::Path<String>,
143) -> impl IntoResponse {
144    let _ = path;
145    (
146        StatusCode::NOT_FOUND,
147        [(header::CONTENT_TYPE, "text/plain")],
148        "Asset not found — UI is self-contained in index.html",
149    )
150}