feat: localchat, a Go + Templ + HTMX chat app for local Gemma models

Streams replies over SSE from any OpenAI-compatible server (oMLX,
llama.cpp, Ollama, ...). Single binary that can install itself as an
OS service; docker compose bundles llama.cpp + Gemma 4 E2B.
This commit is contained in:
bdeb1337 committed 2026-10-05 07:25:42 +02:00
commit bb71b49fde
28 files changed
+2459

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
:root {
--bg: #f7f7f5;
--surface: #ffffff;
--text: #1d1d1f;
--muted: #6e6e73;
--border: #e3e3e0;
--accent: #3b6cf6;
--accent-text: #ffffff;
--user-bg: #3b6cf6;
--user-text: #ffffff;
--code-bg: #f0f0ed;
--ok: #22a356;
--bad: #d64545;
--radius: 14px;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #131315;
--surface: #1d1d20;
--text: #ececef;
--muted: #9a9aa2;
--border: #2c2c31;
--accent: #6b8fff;
--user-bg: #3d5fd9;
--code-bg: #26262b;
}
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
display: grid;
grid-template-rows: auto 1fr auto;
max-width: 820px;
margin: 0 auto;
background: var(--bg);
color: var(--text);
font: 16px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
header h1 { font-size: 1.05rem; margin: 0; flex: 1; }
header form { margin: 0; }
.health { display: flex; align-items: center; gap: 6px; font-size: .85rem; color: var(--muted); }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.dot.ok { background: var(--ok); }
.dot.bad { background: var(--bad); }
#messages {
overflow-y: auto;
padding: 20px 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.empty { margin: auto; text-align: center; color: var(--muted); }
.empty .big { font-size: 2rem; margin: 0; }
.empty:not(:only-child) { display: none; }
.msg { display: flex; }
.msg.user { justify-content: flex-end; }
.bubble {
max-width: min(85%, 680px);
padding: 10px 14px;
border-radius: var(--radius);
background: var(--surface);
border: 1px solid var(--border);
overflow-wrap: anywhere;
}
.msg.user .bubble {
background: var(--user-bg);
color: var(--user-text);
border-color: transparent;
border-bottom-right-radius: 4px;
}
.msg.assistant .bubble { border-bottom-left-radius: 4px; }
.content.plain { white-space: pre-wrap; }
.markdown > :first-child { margin-top: 0; }
.markdown > :last-child { margin-bottom: 0; }
.markdown pre {
background: var(--code-bg);
padding: 10px 12px;
border-radius: 8px;
overflow-x: auto;
font-size: .9em;
tab-size: 4;
}
.markdown code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.markdown :not(pre) > code { background: var(--code-bg); padding: 1px 5px; border-radius: 5px; font-size: .9em; }
.markdown table { border-collapse: collapse; }
.markdown th, .markdown td { border: 1px solid var(--border); padding: 4px 8px; }
.markdown a { color: var(--accent); }
.error { color: var(--bad); font-size: .9rem; margin: 6px 0 0; }
/* typing indicator while waiting for the first token */
.streaming:empty::after {
content: "● ● ●";
letter-spacing: 2px;
color: var(--muted);
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse { 50% { opacity: .3; } }
#composer {
display: flex;
gap: 8px;
padding: 12px 16px 18px;
border-top: 1px solid var(--border);
}
#composer textarea {
flex: 1;
resize: none;
max-height: 200px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
font: inherit;
}
#composer textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
button {
padding: 0 18px;
border: 0;
border-radius: var(--radius);
background: var(--accent);
color: var(--accent-text);
font: inherit;
font-weight: 600;
cursor: pointer;
}
button:disabled { opacity: .5; cursor: progress; }
button.ghost {
padding: 6px 12px;
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
font-weight: 500;
font-size: .85rem;
}
+39
View File
@@ -0,0 +1,39 @@
// Small UX touches on top of HTMX: Enter to send, auto-growing textarea,
// clearing the input after sending, and keeping the newest message in view.
(() => {
const form = document.getElementById("composer");
const input = form.querySelector("textarea");
const messages = document.getElementById("messages");
const grow = () => {
input.style.height = "auto";
input.style.height = input.scrollHeight + "px";
};
const nearBottom = () =>
messages.scrollHeight - messages.scrollTop - messages.clientHeight < 120;
const scrollDown = () => (messages.scrollTop = messages.scrollHeight);
input.addEventListener("input", grow);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
if (input.value.trim()) form.requestSubmit();
}
});
form.addEventListener("htmx:afterRequest", (e) => {
if (e.detail.successful) {
form.reset();
grow();
scrollDown();
}
});
// Follow streaming tokens, unless the user scrolled up to read.
let follow = true;
messages.addEventListener("scroll", () => (follow = nearBottom()));
document.body.addEventListener("htmx:sseMessage", () => follow && scrollDown());
document.body.addEventListener("htmx:afterSwap", () => follow && scrollDown());
scrollDown();
})();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect x="2" y="4" width="28" height="20" rx="7" fill="#3b6cf6"/><path d="M9 24l-3 6 9-6z" fill="#3b6cf6"/><circle cx="10" cy="14" r="2" fill="#fff"/><circle cx="16" cy="14" r="2" fill="#fff"/><circle cx="22" cy="14" r="2" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 302 B

+1
View File
@@ -0,0 +1 @@
var htmx=function(){"use strict";const Y={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=pn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.11"};Y.onLoad=V;Y.process=Bt;Y.on=ye;Y.off=xe;Y.trigger=ae;Y.ajax=In;Y.find=a;Y.findAll=x;Y.closest=g;Y.remove=z;Y.addClass=w;Y.removeClass=S;Y.toggleClass=G;Y.takeClass=W;Y.swap=C;Y.defineExtension=zn;Y.removeExtension=Jn;Y.logAll=$;Y.logNone=_;Y.parseInterval=d;Y._=e;const n={addTriggerHandler:Et,bodyContains:ie,canAccessLocalStorage:U,findThisElement:we,filterValues:xn,swap:C,hasAttribute:s,getAttributeValue:f,getClosestAttributeValue:te,getClosestMatch:A,getExpressionVars:qn,getHeaders:yn,getInputValues:pn,getInternalData:re,getSwapSpecification:vn,getTriggerSpecs:lt,getTarget:Se,makeFragment:D,mergeObjects:se,makeSettleInfo:En,oobSwap:Te,querySelectorExt:ce,settleImmediately:Qt,shouldCancel:dt,triggerEvent:ae,triggerErrorEvent:ue,withExtensions:Vt};const he=["get","post","put","delete","patch"];const R=he.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function Q(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function f(e,t){return Q(e,t)||Q(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ee(){return document}function q(e,t){return e.getRootNode?e.getRootNode({composed:t}):ee()}function A(e,t){while(e&&!t(e)){e=u(e)}return e||null}function o(e,t,n){const r=f(t,n);const o=f(t,"hx-disinherit");var i=f(t,"hx-inherit");if(e!==t){if(Y.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function te(t,n){let r=null;A(t,function(e){return!!(r=o(t,le(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function N(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function I(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=ee().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Y.config.inlineScriptNonce){t.nonce=Y.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function k(e){if(!Y.config.allowScriptTags){e.querySelectorAll("script").forEach(e=>e.remove());return}Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){T(e)}finally{e.remove()}}})}function D(e){e=e.replace(/<hx-([a-z]+)((?:\s[^>]*)?)>/gi,'<template hx type="$1"$2>').replace(/<\/hx-[a-z]+>/gi,"</template>");const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=N(t);let r;if(n==="html"){r=new DocumentFragment;const i=I(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=I(t);L(r,i.body);r.title=i.title}else{const i=I('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){oLine truncated
+1
View File
@@ -0,0 +1 @@
(function(){var g;htmx.defineExtension("sse",{init:function(e){g=e;if(htmx.createEventSource==undefined){htmx.createEventSource=t}},getSelectors:function(){return["[sse-connect]","[data-sse-connect]","[sse-swap]","[data-sse-swap]"]},onEvent:function(e,t){var r=t.target||t.detail.elt;switch(e){case"htmx:beforeCleanupElement":var n=g.getInternalData(r);var s=n.sseEventSource;if(s){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"nodeReplaced"});n.sseEventSource.close()}return;case"htmx:afterProcessNode":i(r)}}});function t(e){return new EventSource(e,{withCredentials:true})}function a(n){if(g.getAttributeValue(n,"sse-swap")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var t=g.getAttributeValue(n,"sse-swap");var r=t.split(",");for(var i=0;i<r.length;i++){const u=r[i].trim();const c=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(u,c);return}if(!g.triggerEvent(n,"htmx:sseBeforeMessage",e)){return}f(n,e.data);g.triggerEvent(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=c;a.addEventListener(u,c)}}if(g.getAttributeValue(n,"hx-trigger")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var o=g.getTriggerSpecs(n);o.forEach(function(t){if(t.trigger.slice(0,4)!=="sse:"){return}var r=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(t.trigger.slice(4),r)}htmx.trigger(n,t.trigger,e);htmx.trigger(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=r;a.addEventListener(t.trigger.slice(4),r)})}}function i(e,t){if(e==null){return null}if(g.getAttributeValue(e,"sse-connect")){var r=g.getAttributeValue(e,"sse-connect");if(r==null){return}n(e,r,t)}a(e)}function n(r,e,n){var s=htmx.createEventSource(e);s.onerror=function(e){g.triggerErrorEvent(r,"htmx:sseError",{error:e,source:s});if(l(r)){return}if(s.readyState===EventSource.CLOSED){n=n||0;n=Math.max(Math.min(n*2,128),1);var t=n*500;window.setTimeout(function(){i(r,n)},t)}};s.onopen=function(e){g.triggerEvent(r,"htmx:sseOpen",{source:s});if(n&&n>0){const t=r.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]");for(let e=0;e<t.length;e++){a(t[e])}n=0}};g.getInternalData(r).sseEventSource=s;var t=g.getAttributeValue(r,"sse-close");if(t){s.addEventListener(t,function(){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"message"});s.close()})}}function l(e){if(!g.bodyContains(e)){var t=g.getInternalData(e).sseEventSource;if(t!=undefined){g.triggerEvent(e,"htmx:sseClose",{source:t,type:"nodeMissing"});t.close();return true}}return false}function f(t,r){g.withExtensions(t,function(e){r=e.transformResponse(r,null,t)});var e=g.getSwapSpecification(t);var n=g.getTarget(t);g.swap(n,r,e,{contextElement:t})}function v(e){return g.getInternalData(e).sseEventSource!=null}})();