the coolest token pot ever
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: janky chat ui

Kieran Klukas (May 24, 2026, 3:39 PM EDT) ca94309c 81377c42

+1426 -49
+7 -5
server/cmd/server/main.go
··· 162 162 r.Post("/auth/logout", hcaLogoutHandler(authSvc, cfg.IsProduction())) 163 163 164 164 apiSrv := &web.Server{ 165 - Q: q, 166 - Auth: authSvc, 167 - Ledger: ledg, 168 - Hub: hub, 169 - Pool: keyPool, 165 + Q: q, 166 + Auth: authSvc, 167 + Ledger: ledg, 168 + Hub: hub, 169 + Pool: keyPool, 170 + Provider: pioneer, 171 + FreeProvider: freeProvider, 170 172 } 171 173 v1Srv := &v1.Server{ 172 174 Q: q,
+289
server/internal/api/web/chat.go
··· 1 + package web 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "encoding/json" 7 + "fmt" 8 + "net/http" 9 + "strings" 10 + "time" 11 + 12 + "github.com/google/uuid" 13 + 14 + apimw "github.com/taciturnaxolotl/potluck/internal/api/middleware" 15 + "github.com/taciturnaxolotl/potluck/internal/pool" 16 + "github.com/taciturnaxolotl/potluck/internal/provider" 17 + "github.com/taciturnaxolotl/potluck/internal/store" 18 + ) 19 + 20 + type chatReq struct { 21 + ConversationID string `json:"conversation_id"` 22 + Title string `json:"title"` 23 + Model string `json:"model"` 24 + Messages []chatMsg `json:"messages"` 25 + } 26 + 27 + type chatMsg struct { 28 + Role string `json:"role"` 29 + Content string `json:"content"` 30 + ClientID string `json:"client_id,omitempty"` 31 + } 32 + 33 + // handleChat streams a chat completion for the web UI. 34 + // 35 + // SSE event shapes: 36 + // 37 + // {"type":"start","conversation_id":"...","user_message_id":"...","assistant_message_id":"..."} 38 + // {"type":"delta","content":"..."} 39 + // {"type":"done"} 40 + // {"type":"error","message":"..."} 41 + // 42 + // Models prefixed "free/" bypass the pool gate; all others go through the 43 + // shared pool and incur normal spend tracking. 44 + func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { 45 + u, _ := currentUser(r) 46 + 47 + var req chatReq 48 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 49 + writeErr(w, 400, "invalid_request", err.Error()) 50 + return 51 + } 52 + if req.Model == "" || len(req.Messages) == 0 { 53 + writeErr(w, 400, "invalid_request", "model and messages are required") 54 + return 55 + } 56 + 57 + isFree := strings.HasPrefix(req.Model, "free/") 58 + 59 + if !isFree { 60 + if gr := apimw.CheckPoolGate(r.Context(), s.Q, s.Pool.HasHealthyKey, u); gr != nil { 61 + writeErr(w, gr.Status, gr.Code, gr.Message) 62 + return 63 + } 64 + } else if s.FreeProvider == nil { 65 + writeErr(w, 400, "invalid_request", "free provider not configured") 66 + return 67 + } 68 + 69 + now := time.Now().Unix() 70 + 71 + // Resolve conversation — create if not supplied. 72 + convID := req.ConversationID 73 + if convID == "" { 74 + title := req.Title 75 + if title == "" { 76 + for _, m := range req.Messages { 77 + if m.Role == "user" && m.Content != "" { 78 + title = truncateTitle(m.Content, 60) 79 + break 80 + } 81 + } 82 + } 83 + if title == "" { 84 + title = "New conversation" 85 + } 86 + conv, err := s.Q.CreateConversation(r.Context(), store.CreateConversationParams{ 87 + ID: uuid.NewString(), 88 + UserID: u.ID, 89 + Title: title, 90 + CreatedAt: now, 91 + UpdatedAt: now, 92 + }) 93 + if err != nil { 94 + writeErr(w, 500, "internal", err.Error()) 95 + return 96 + } 97 + convID = conv.ID 98 + } else { 99 + if _, err := s.Q.GetConversation(r.Context(), store.GetConversationParams{ID: convID, UserID: u.ID}); err != nil { 100 + writeErr(w, 404, "not_found", "conversation not found") 101 + return 102 + } 103 + } 104 + 105 + // Upsert the last user message (deduplicated by client_id). 106 + var userMsgID string 107 + for i := len(req.Messages) - 1; i >= 0; i-- { 108 + m := req.Messages[i] 109 + if m.Role != "user" { 110 + continue 111 + } 112 + cid := m.ClientID 113 + if cid == "" { 114 + cid = uuid.NewString() 115 + } 116 + msg, err := s.Q.UpsertMessage(r.Context(), store.UpsertMessageParams{ 117 + ID: uuid.NewString(), 118 + ConversationID: convID, 119 + ClientID: sql.NullString{String: cid, Valid: true}, 120 + Role: "user", 121 + Content: m.Content, 122 + Model: sql.NullString{}, 123 + CreatedAt: now, 124 + }) 125 + if err == nil { 126 + userMsgID = msg.ID 127 + } 128 + break 129 + } 130 + 131 + // Create the assistant message placeholder — content appended after streaming. 132 + assistantMsgID := uuid.NewString() 133 + _, _ = s.Q.UpsertMessage(r.Context(), store.UpsertMessageParams{ 134 + ID: assistantMsgID, 135 + ConversationID: convID, 136 + ClientID: sql.NullString{}, 137 + Role: "assistant", 138 + Content: "", 139 + Model: sql.NullString{String: req.Model, Valid: true}, 140 + CreatedAt: now + 1, 141 + }) 142 + 143 + // Pick provider and upstream model name. 144 + upstreamModel := req.Model 145 + var pc *provider.Client 146 + var sel *pool.Selection 147 + var reqID string 148 + 149 + if isFree { 150 + pc = s.FreeProvider 151 + upstreamModel = strings.TrimPrefix(req.Model, "free/") 152 + } else { 153 + var err error 154 + sel, err = s.Pool.PickForUser(r.Context(), u.ID) 155 + if err != nil { 156 + writeErr(w, 503, "no_pool_keys", "no active pool keys available") 157 + return 158 + } 159 + pc = &provider.Client{ 160 + BaseURL: s.Provider.BaseURL, 161 + APIKey: sel.APIKey(), 162 + HTTP: s.Provider.HTTP, 163 + } 164 + reqID = uuid.NewString() 165 + _, _ = s.Q.CreatePotluckRequest(r.Context(), store.CreatePotluckRequestParams{ 166 + ID: reqID, 167 + UserID: u.ID, 168 + ApiKeyID: sql.NullString{}, 169 + PoolKeyID: sql.NullString{String: sel.KeyID(), Valid: sel.KeyID() != ""}, 170 + Surface: "web", 171 + Model: req.Model, 172 + StartedAt: now, 173 + }) 174 + } 175 + 176 + provMsgs := make([]provider.ChatMessage, len(req.Messages)) 177 + for i, m := range req.Messages { 178 + provMsgs[i] = provider.ChatMessage{Role: m.Role, Content: m.Content} 179 + } 180 + 181 + chunks, errs, err := pc.StreamChat(r.Context(), provider.ChatRequest{ 182 + Model: upstreamModel, 183 + Messages: provMsgs, 184 + }) 185 + if err != nil { 186 + writeErr(w, 502, "provider_down", err.Error()) 187 + if !isFree && reqID != "" { 188 + go finishWebReq(s.Q, reqID, 0, 0, 0, "error") 189 + } 190 + return 191 + } 192 + 193 + w.Header().Set("Content-Type", "text/event-stream") 194 + w.Header().Set("Cache-Control", "no-cache") 195 + w.Header().Set("Connection", "keep-alive") 196 + w.WriteHeader(200) 197 + flusher, _ := w.(http.Flusher) 198 + 199 + emit := func(v any) { 200 + b, _ := json.Marshal(v) 201 + _, _ = fmt.Fprintf(w, "data: %s\n\n", b) 202 + if flusher != nil { 203 + flusher.Flush() 204 + } 205 + } 206 + 207 + emit(map[string]any{ 208 + "type": "start", 209 + "conversation_id": convID, 210 + "user_message_id": userMsgID, 211 + "assistant_message_id": assistantMsgID, 212 + }) 213 + 214 + var full strings.Builder 215 + var usage *provider.Usage 216 + 217 + for { 218 + select { 219 + case <-r.Context().Done(): 220 + if !isFree && reqID != "" { 221 + go finishWebReq(s.Q, reqID, 0, 0, 0, "canceled") 222 + } 223 + return 224 + case ch, ok := <-chunks: 225 + if ch.Usage != nil { 226 + usage = ch.Usage 227 + } 228 + if !ok || ch.Done { 229 + payload := map[string]any{"type": "done"} 230 + if usage != nil { 231 + payload["usage"] = map[string]any{ 232 + "prompt_tokens": usage.PromptTokens, 233 + "completion_tokens": usage.CompletionTokens, 234 + "total_tokens": usage.TotalTokens, 235 + } 236 + } 237 + emit(payload) 238 + go s.finalizeChatMsg(convID, assistantMsgID, full.String(), isFree, sel, reqID, usage) 239 + return 240 + } 241 + if ch.Delta != "" { 242 + full.WriteString(ch.Delta) 243 + emit(map[string]any{"type": "delta", "content": ch.Delta}) 244 + } 245 + case e := <-errs: 246 + if e != nil { 247 + emit(map[string]any{"type": "error", "message": e.Error()}) 248 + if !isFree && reqID != "" { 249 + go finishWebReq(s.Q, reqID, 0, 0, 0, "error") 250 + } 251 + return 252 + } 253 + } 254 + } 255 + } 256 + 257 + func (s *Server) finalizeChatMsg(convID, aID, content string, isFree bool, sel *pool.Selection, reqID string, usage *provider.Usage) { 258 + ctx := context.Background() 259 + _ = s.Q.AppendAssistantContent(ctx, store.AppendAssistantContentParams{Content: content, ID: aID}) 260 + _ = s.Q.TouchConversation(ctx, store.TouchConversationParams{UpdatedAt: time.Now().Unix(), ID: convID}) 261 + if !isFree && sel != nil { 262 + var p, c, t int64 263 + if usage != nil { 264 + p, c, t = int64(usage.PromptTokens), int64(usage.CompletionTokens), int64(usage.TotalTokens) 265 + } 266 + finishWebReq(s.Q, reqID, p, c, t, "done") 267 + _ = s.Pool.RecordSpend(ctx, sel, 0) 268 + } 269 + } 270 + 271 + func finishWebReq(q *store.Queries, id string, p, c, t int64, status string) { 272 + now := time.Now().Unix() 273 + _ = q.FinishPotluckRequest(context.Background(), store.FinishPotluckRequestParams{ 274 + FinishedAt: sql.NullInt64{Int64: now, Valid: true}, 275 + PromptTokens: sql.NullInt64{Int64: p, Valid: p > 0}, 276 + CompletionTokens: sql.NullInt64{Int64: c, Valid: c > 0}, 277 + TotalTokens: sql.NullInt64{Int64: t, Valid: t > 0}, 278 + Status: status, 279 + ID: id, 280 + }) 281 + } 282 + 283 + func truncateTitle(s string, n int) string { 284 + r := []rune(s) 285 + if len(r) <= n { 286 + return s 287 + } 288 + return string(r[:n]) + "…" 289 + }
+26 -16
server/internal/api/web/web.go
··· 21 21 "github.com/taciturnaxolotl/potluck/internal/auth" 22 22 "github.com/taciturnaxolotl/potluck/internal/ledger" 23 23 "github.com/taciturnaxolotl/potluck/internal/pool" 24 + "github.com/taciturnaxolotl/potluck/internal/provider" 24 25 "github.com/taciturnaxolotl/potluck/internal/store" 25 26 "github.com/taciturnaxolotl/potluck/internal/stream" 26 27 ) 27 28 28 29 // Server bundles the deps the web handlers need. 29 30 type Server struct { 30 - Q *store.Queries 31 - Auth *auth.Service 32 - Ledger *ledger.Service 33 - Hub *stream.Hub 34 - Pool *pool.Manager 31 + Q *store.Queries 32 + Auth *auth.Service 33 + Ledger *ledger.Service 34 + Hub *stream.Hub 35 + Pool *pool.Manager 36 + Provider *provider.Client // Pioneer upstream 37 + FreeProvider *provider.Client // self-hosted free endpoint; nil if not configured 35 38 } 36 39 37 40 // Mount registers /api/* routes on r. The caller wraps with cookie-auth ··· 52 55 r.Get("/conversations", s.handleListConversations) 53 56 r.Post("/conversations", s.handleCreateConversation) 54 57 r.Get("/conversations/{id}", s.handleGetConversation) 58 + r.Delete("/conversations/{id}", s.handleDeleteConversation) 55 59 r.Get("/conversations/{id}/messages", s.handleListMessages) 56 60 57 61 r.Get("/keys", s.handleListKeys) ··· 67 71 r.Post("/pool-keys/{id}/sync", s.handleSyncPoolKeySpend) 68 72 r.Delete("/pool-keys/{id}", s.handleDeletePoolKey) 69 73 70 - r.Group(func(r chi.Router) { 71 - r.Use(apimw.PoolGate(s.Q, s.Pool.HasHealthyKey, writeErr)) 72 - r.Post("/chat", s.handleChat) 73 - }) 74 + // /chat has its own pool-gate check inside the handler so free/ models 75 + // can bypass it without middleware running before the body is read. 76 + r.Post("/chat", s.handleChat) 74 77 r.Get("/streams/{id}/events", s.handleStreamEvents) 75 78 76 79 s.mountAdmin(r) ··· 280 283 writeJSON(w, 200, conv) 281 284 } 282 285 286 + func (s *Server) handleDeleteConversation(w http.ResponseWriter, r *http.Request) { 287 + u, _ := currentUser(r) 288 + id := chi.URLParam(r, "id") 289 + if err := s.Q.ArchiveConversation(r.Context(), store.ArchiveConversationParams{ 290 + ArchivedAt: sqlNullInt64(time.Now().Unix()), 291 + ID: id, 292 + UserID: u.ID, 293 + }); err != nil { 294 + writeErr(w, 500, "internal", err.Error()) 295 + return 296 + } 297 + w.WriteHeader(204) 298 + } 299 + 283 300 func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) { 284 301 u, _ := currentUser(r) 285 302 id := chi.URLParam(r, "id") ··· 380 397 } 381 398 382 399 // ---- chat / streams ---------------------------------------------------- 383 - 384 - // handleChat is a stub for the streaming endpoint. The full implementation 385 - // wires provider.Client → stream.Producer → DB and returns the stream id. 386 - // See design/streaming.md. 387 - func (s *Server) handleChat(w http.ResponseWriter, _ *http.Request) { 388 - writeErr(w, 501, "not_implemented", "chat streaming pending wiring") 389 - } 390 400 391 401 // handleStreamEvents serves SSE events for a given stream id, optionally 392 402 // resuming from ?after_seq=N. Stub: replay from DB only.
+1
web/src/lib/api.ts
··· 91 91 export const createConversation = (title: string) => 92 92 api.post<Conversation>('/api/conversations', { title }); 93 93 export const listMessages = (id: string) => api.get<Message[]>(`/api/conversations/${id}/messages`); 94 + export const deleteConversation = (id: string) => api.del<void>(`/api/conversations/${id}`); 94 95 95 96 export type PoolStats = { 96 97 balance_micros: number;
+4
web/src/lib/db.ts
··· 25 25 model: string | null; 26 26 created_at: number; 27 27 pending?: boolean; // optimistic; cleared on server upsert 28 + // generation stats — only present on messages generated in this client 29 + ttft?: number; // ms to first token 30 + tps?: number; // completion tokens per second 31 + tokens?: number; // completion token count 28 32 } 29 33 30 34 class PotluckDB extends Dexie {
+122 -5
web/src/routes/+layout.svelte
··· 8 8 9 9 import { page } from '$app/state'; 10 10 import { cycleTheme, currentTheme, type Theme } from '$lib/theme'; 11 - import { me, balance, type User } from '$lib/api'; 11 + import { me, balance, deleteConversation, type User } from '$lib/api'; 12 12 import { auth } from '$lib/auth.svelte'; 13 + import { db, type DBConversation } from '$lib/db'; 14 + import { liveQuery } from 'dexie'; 15 + import { goto } from '$app/navigation'; 13 16 import type { Snippet } from 'svelte'; 14 17 15 18 let { children }: { children: Snippet } = $props(); ··· 17 20 let theme = $state<Theme>('auto'); 18 21 let bal = $state<{ balance_usd: string } | null>(null); 19 22 let authChecked = $state(false); 23 + 24 + let chatConvs = $state<DBConversation[]>([]); 25 + let onChat = $derived(page.url.pathname.startsWith('/chat')); 26 + 27 + $effect(() => { 28 + if (!onChat) return; 29 + const sub = liveQuery(() => 30 + db.conversations.orderBy('updated_at').reverse().toArray() 31 + ).subscribe({ next: (r) => (chatConvs = r), error: () => {} }); 32 + return () => sub.unsubscribe(); 33 + }); 20 34 21 35 $effect(() => { 22 36 theme = currentTheme(); ··· 146 160 { label: 'models', href: '/models', section: 'the pot' }, 147 161 { label: 'usage', href: '/usage', section: 'the pot' }, 148 162 { label: 'pool', href: '/pool', section: 'the pot' }, 149 - { label: 'docs', href: '/docs', section: 'the pot' }, 150 - { label: 'conversations', href: '/chat', section: 'yours' }, 151 - { label: 'settings', href: '/settings', section: 'yours' } 163 + { label: 'docs', href: '/docs', section: 'the pot' } 152 164 ]; 153 165 154 166 const adminNavItems: NavItem[] = [ ··· 172 184 function isActive(href: string) { 173 185 return page.url.pathname === href || page.url.pathname.startsWith(href + '/'); 174 186 } 187 + 188 + async function deleteThread(id: string, e: MouseEvent) { 189 + e.preventDefault(); 190 + e.stopPropagation(); 191 + deleteConversation(id).catch(() => {}); // best-effort server sync 192 + await db.conversations.delete(id); 193 + await db.messages.where('conversation_id').equals(id).delete(); 194 + if (page.url.searchParams.get('c') === id) { 195 + goto('/chat', { replaceState: true }); 196 + } 197 + } 175 198 </script> 176 199 177 200 {#if isError} ··· 196 219 {/each} 197 220 {/each} 198 221 222 + <!-- conversations section — always visible --> 223 + <div class="nav-section">conversations</div> 224 + <a 225 + class="nav-item" 226 + class:active={page.url.pathname === '/chat' && !page.url.searchParams.get('c')} 227 + href="/chat" 228 + data-sveltekit-preload-data="hover" 229 + >converse</a> 230 + {#if onChat} 231 + {#each chatConvs as conv (conv.id)} 232 + <div 233 + class="nav-item nav-conv-row" 234 + class:active={page.url.searchParams.get('c') === conv.id} 235 + > 236 + <a 237 + class="nav-conv-link" 238 + href="/chat?c={conv.id}" 239 + title={conv.title} 240 + data-sveltekit-preload-data="tap" 241 + >{conv.title}</a> 242 + <button 243 + class="conv-del-btn" 244 + onclick={(e) => deleteThread(conv.id, e)} 245 + title="Delete thread" 246 + aria-label="Delete thread" 247 + >×</button> 248 + </div> 249 + {/each} 250 + {/if} 251 + 199 252 <div class="sidebar-foot"> 200 253 {#if auth.user} 201 254 <a class="who" href="/settings"> ··· 217 270 </div> 218 271 </aside> 219 272 220 - <main class="main"> 273 + <main class="main" class:chat-route={onChat}> 221 274 {@render children()} 222 275 </main> 223 276 </div> ··· 389 442 .main { 390 443 padding: 2rem 2.25rem; 391 444 overflow-y: auto; 445 + } 446 + .main.chat-route { 447 + padding: 0; 448 + overflow: hidden; 449 + } 450 + 451 + /* ---- thread sub-items ------------------------------------------------ */ 452 + .nav-conv-row { 453 + display: flex; 454 + align-items: center; 455 + padding: 0; 456 + margin-bottom: 1px; 457 + border-radius: var(--radius-sm); 458 + cursor: pointer; 459 + opacity: 0.65; 460 + transition: background 80ms ease, opacity 80ms ease; 461 + } 462 + .nav-conv-row:hover { opacity: 1; background: rgba(255, 217, 218, 0.04); } 463 + .nav-conv-row.active { 464 + background: var(--accent); 465 + color: var(--text-on-accent); 466 + font-weight: 500; 467 + opacity: 1; 468 + } 469 + .nav-conv-link { 470 + flex: 1; 471 + min-width: 0; 472 + padding: 5px 6px 5px 8px; 473 + font-size: 12.5px; 474 + color: inherit; 475 + text-decoration: none; 476 + white-space: nowrap; 477 + overflow: hidden; 478 + text-overflow: ellipsis; 479 + } 480 + .nav-conv-link:hover { text-decoration: none; } 481 + .conv-del-btn { 482 + flex-shrink: 0; 483 + display: none; 484 + align-items: center; 485 + justify-content: center; 486 + width: 20px; 487 + height: 20px; 488 + margin-right: 3px; 489 + background: none; 490 + border: none; 491 + border-radius: 3px; 492 + color: inherit; 493 + font-size: 13px; 494 + line-height: 1; 495 + cursor: pointer; 496 + opacity: 0.6; 497 + transition: opacity 60ms, background 60ms; 498 + } 499 + .nav-conv-row:hover .conv-del-btn { display: flex; } 500 + .conv-del-btn:hover { 501 + opacity: 1; 502 + background: rgba(255,255,255,0.15); 503 + } 504 + .nav-conv-empty { 505 + display: block; 506 + font-size: 11.5px; 507 + color: var(--text-faint); 508 + padding: 4px 10px 4px 18px; 392 509 } 393 510 394 511 /* ---- splash shell --------------------------------------------------- */
+977 -23
web/src/routes/chat/+page.svelte
··· 1 - <article> 2 - <div class="eyebrow">yours</div> 3 - <h1 class="display">conversations</h1> 4 - <p class="lede">chat history and context threads. coming soon.</p> 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { liveQuery } from 'dexie'; 4 + import { goto } from '$app/navigation'; 5 + import { page } from '$app/state'; 6 + import { v4 as uuid } from 'uuid'; 7 + import { db, type DBMessage } from '$lib/db'; 8 + import { listConversations, listMessages, listModels, type Model } from '$lib/api'; 9 + 10 + // ── state ───────────────────────────────────────────────────────────────── 11 + 12 + let activeConvId = $state<string | null>(null); 13 + let input = $state(''); 14 + let streaming = $state(false); 15 + let streamingMsgId = $state<string | null>(null); 16 + 17 + let models = $state<Model[]>([]); 18 + let selectedModel = $state(''); 19 + let modelPickerOpen = $state(false); 20 + 21 + let messages = $state<DBMessage[]>([]); 22 + 23 + let msgsEl = $state<HTMLElement | null>(null); 24 + let inputEl = $state<HTMLTextAreaElement | null>(null); 25 + let pickerEl = $state<HTMLElement | null>(null); 26 + let atBottom = true; 27 + let errorMsg = $state<string | null>(null); 28 + 29 + // ── streaming stats ─────────────────────────────────────────────────────── 30 + let streamStartMs = $state(0); 31 + let streamFirstTokenMs = $state(0); // 0 = not yet arrived 32 + let streamElapsedMs = $state(0); // ticking while streaming 33 + let elapsedInterval = 0; 34 + 35 + $effect(() => { 36 + if (streaming && streamFirstTokenMs > 0) { 37 + elapsedInterval = setInterval(() => { 38 + streamElapsedMs = Date.now() - streamFirstTokenMs; 39 + }, 100) as unknown as number; 40 + } else { 41 + clearInterval(elapsedInterval); 42 + } 43 + return () => clearInterval(elapsedInterval); 44 + }); 45 + 46 + // ── reactive IDB message query ──────────────────────────────────────────── 47 + 48 + $effect(() => { 49 + const id = activeConvId; 50 + if (!id) { 51 + messages = []; 52 + return; 53 + } 54 + const sub = liveQuery(() => 55 + db.messages.where('conversation_id').equals(id).sortBy('created_at') 56 + ).subscribe({ next: (r) => (messages = r as DBMessage[]), error: () => {} }); 57 + return () => sub.unsubscribe(); 58 + }); 59 + 60 + // ── URL-reactive conversation selection ─────────────────────────────────── 61 + 62 + let urlConvId = $derived(page.url.searchParams.get('c')); 63 + 64 + $effect(() => { 65 + const id = urlConvId; 66 + if (id === activeConvId) return; 67 + activeConvId = id; 68 + atBottom = true; 69 + if (!id) return; 70 + db.messages 71 + .where('conversation_id') 72 + .equals(id) 73 + .count() 74 + .then((n) => syncMessages(id)); 75 + }); 76 + 77 + // ── auto-scroll ─────────────────────────────────────────────────────────── 5 78 6 - <div class="card"> 7 - <p class="muted"> 8 - the chat UI isn't wired yet. once it is, your conversations will live here: 9 - resumable across devices, stored locally in IndexedDB with server-side backup. 10 - </p> 11 - <p class="muted"> 12 - in the meantime, hit the api directly from your terminal or editor. 13 - </p> 79 + $effect(() => { 80 + void messages; 81 + if (atBottom && msgsEl) { 82 + msgsEl.scrollTop = msgsEl.scrollHeight; 83 + } 84 + }); 85 + 86 + function onMsgsScroll() { 87 + if (!msgsEl) return; 88 + atBottom = msgsEl.scrollTop + msgsEl.clientHeight >= msgsEl.scrollHeight - 40; 89 + } 90 + 91 + // ── server sync ─────────────────────────────────────────────────────────── 92 + 93 + async function syncConversations() { 94 + try { 95 + const rows = await listConversations(); 96 + await db.conversations.bulkPut( 97 + rows.map((c) => ({ id: c.id, title: c.title, updated_at: c.updated_at, archived_at: c.archived_at })) 98 + ); 99 + } catch { 100 + /* silent */ 101 + } 102 + } 103 + 104 + async function syncMessages(id: string) { 105 + try { 106 + const rows = await listMessages(id); 107 + await db.messages.bulkPut( 108 + rows.map((m) => ({ 109 + id: m.id, 110 + conversation_id: m.conversation_id, 111 + client_id: m.client_id, 112 + role: m.role as DBMessage['role'], 113 + content: m.content, 114 + model: m.model, 115 + created_at: m.created_at 116 + })) 117 + ); 118 + } catch { 119 + /* silent */ 120 + } 121 + } 122 + 123 + // ── lifecycle ───────────────────────────────────────────────────────────── 124 + 125 + onMount(async () => { 126 + const saved = localStorage.getItem('chat:model'); 127 + if (saved) selectedModel = saved; 128 + 129 + try { 130 + const result = await listModels(); 131 + models = result.models; 132 + if (!selectedModel && models.length > 0) { 133 + selectedModel = models[0].id; 134 + } 135 + } catch { 136 + /* silent */ 137 + } 138 + 139 + // Sync conversations so the layout sidebar has data. 140 + syncConversations(); 141 + }); 142 + 143 + // ── model selection ─────────────────────────────────────────────────────── 144 + 145 + let freeModels = $derived(models.filter((m) => m.id.startsWith('free/'))); 146 + let paidModels = $derived(models.filter((m) => !m.id.startsWith('free/'))); 147 + 148 + function saveModel() { 149 + localStorage.setItem('chat:model', selectedModel); 150 + } 151 + 152 + function modelDisplay(id: string) { 153 + if (!id) return 'pick model'; 154 + const m = models.find((x) => x.id === id); 155 + if (m) return m.label || id.replace(/^free\//, ''); 156 + return id.replace(/^free\//, ''); 157 + } 158 + 159 + function pickModel(id: string) { 160 + selectedModel = id; 161 + saveModel(); 162 + modelPickerOpen = false; 163 + inputEl?.focus(); 164 + } 165 + 166 + function onWindowClick(e: MouseEvent) { 167 + if (modelPickerOpen && pickerEl && !pickerEl.contains(e.target as Node)) { 168 + modelPickerOpen = false; 169 + } 170 + } 171 + 172 + // ── input ───────────────────────────────────────────────────────────────── 173 + 174 + function handleKeydown(e: KeyboardEvent) { 175 + if (e.key === 'Enter' && !e.shiftKey) { 176 + e.preventDefault(); 177 + sendMessage(); 178 + } 179 + if (e.key === 'Escape') { 180 + modelPickerOpen = false; 181 + } 182 + } 183 + 184 + function resize(el: HTMLTextAreaElement) { 185 + el.style.height = 'auto'; 186 + el.style.height = Math.min(el.scrollHeight, 200) + 'px'; 187 + } 188 + 189 + // ── send / stream ───────────────────────────────────────────────────────── 190 + 191 + async function sendMessage() { 192 + const content = input.trim(); 193 + if (!content || streaming || !selectedModel) return; 194 + 195 + input = ''; 196 + if (inputEl) resize(inputEl); 197 + streaming = true; 198 + atBottom = true; 199 + errorMsg = null; 200 + streamStartMs = Date.now(); 201 + streamFirstTokenMs = 0; 202 + streamElapsedMs = 0; 203 + 204 + const clientId = uuid(); 205 + const now = Math.floor(Date.now() / 1000); 206 + 207 + let convId = activeConvId; 208 + let isNewConv = false; 209 + if (!convId) { 210 + convId = uuid(); 211 + isNewConv = true; 212 + await db.conversations.put({ 213 + id: convId, 214 + title: content.slice(0, 60), 215 + updated_at: now, 216 + archived_at: null 217 + }); 218 + activeConvId = convId; 219 + goto(`/chat?c=${convId}`, { replaceState: true, noScroll: true, keepFocus: true }); 220 + } 221 + 222 + const tmpUserId = uuid(); 223 + await db.messages.put({ 224 + id: tmpUserId, 225 + conversation_id: convId, 226 + client_id: clientId, 227 + role: 'user', 228 + content, 229 + model: null, 230 + created_at: now, 231 + pending: true 232 + }); 233 + 234 + const tmpAssistantId = uuid(); 235 + streamingMsgId = tmpAssistantId; 236 + await db.messages.put({ 237 + id: tmpAssistantId, 238 + conversation_id: convId, 239 + client_id: null, 240 + role: 'assistant', 241 + content: '', 242 + model: selectedModel, 243 + created_at: now + 1, 244 + pending: true 245 + }); 246 + 247 + const msgHistory = await db.messages 248 + .where('conversation_id') 249 + .equals(convId) 250 + .sortBy('created_at'); 251 + 252 + const apiMessages = msgHistory 253 + .filter((m) => (m.role === 'user' || m.role === 'assistant') && (!m.pending || m.id === tmpUserId)) 254 + .map((m) => ({ 255 + role: m.role, 256 + content: m.content, 257 + ...(m.id === tmpUserId ? { client_id: clientId } : {}) 258 + })); 259 + 260 + let resolvedUserId = tmpUserId; 261 + let resolvedAssistantId = tmpAssistantId; 262 + let accContent = ''; 263 + 264 + try { 265 + const res = await fetch('/api/chat', { 266 + method: 'POST', 267 + headers: { 'content-type': 'application/json' }, 268 + credentials: 'include', 269 + body: JSON.stringify({ 270 + ...(isNewConv ? {} : { conversation_id: convId }), 271 + ...(isNewConv ? { title: content.slice(0, 60) } : {}), 272 + model: selectedModel, 273 + messages: apiMessages 274 + }) 275 + }); 276 + 277 + if (!res.ok || !res.body) { 278 + const j = await res.json().catch(() => ({})); 279 + throw new Error((j as any)?.error?.message || `HTTP ${res.status}`); 280 + } 281 + 282 + const reader = res.body.pipeThrough(new TextDecoderStream()).getReader(); 283 + let buf = ''; 284 + 285 + outer: for (;;) { 286 + const { value, done } = await reader.read(); 287 + if (done) break; 288 + buf += value; 289 + 290 + let idx: number; 291 + while ((idx = buf.indexOf('\n\n')) >= 0) { 292 + const frame = buf.slice(0, idx); 293 + buf = buf.slice(idx + 2); 294 + 295 + const data = frame 296 + .split('\n') 297 + .find((l) => l.startsWith('data:')) 298 + ?.slice(5) 299 + .trim(); 300 + if (!data) continue; 301 + 302 + let ev: Record<string, any>; 303 + try { 304 + ev = JSON.parse(data); 305 + } catch { 306 + continue; 307 + } 308 + 309 + switch (ev.type) { 310 + case 'start': { 311 + const serverConvId: string = ev.conversation_id; 312 + const serverUserId: string = ev.user_message_id; 313 + const serverAssistantId: string = ev.assistant_message_id; 314 + 315 + if (isNewConv && serverConvId && serverConvId !== convId) { 316 + const tmpConv = await db.conversations.get(convId); 317 + if (tmpConv) { 318 + await db.conversations.delete(convId); 319 + await db.conversations.put({ ...tmpConv, id: serverConvId }); 320 + const affected = await db.messages 321 + .where('conversation_id') 322 + .equals(convId) 323 + .toArray(); 324 + await db.messages.bulkPut(affected.map((m) => ({ ...m, conversation_id: serverConvId }))); 325 + await db.messages.where('conversation_id').equals(convId).delete(); 326 + } 327 + convId = serverConvId; 328 + activeConvId = serverConvId; 329 + goto(`/chat?c=${serverConvId}`, { replaceState: true, noScroll: true, keepFocus: true }); 330 + } 331 + 332 + if (serverUserId && serverUserId !== tmpUserId) { 333 + const old = await db.messages.get(tmpUserId); 334 + if (old) { 335 + await db.messages.delete(tmpUserId); 336 + await db.messages.put({ ...old, id: serverUserId, pending: false }); 337 + resolvedUserId = serverUserId; 338 + } 339 + } else { 340 + await db.messages.where({ id: resolvedUserId }).modify({ pending: false }); 341 + } 342 + 343 + if (serverAssistantId && serverAssistantId !== tmpAssistantId) { 344 + const old = await db.messages.get(tmpAssistantId); 345 + if (old) { 346 + await db.messages.delete(tmpAssistantId); 347 + await db.messages.put({ ...old, id: serverAssistantId, conversation_id: convId }); 348 + resolvedAssistantId = serverAssistantId; 349 + streamingMsgId = serverAssistantId; 350 + } 351 + } 352 + break; 353 + } 354 + 355 + case 'delta': { 356 + if (!streamFirstTokenMs) { 357 + streamFirstTokenMs = Date.now(); 358 + } 359 + accContent += ev.content as string; 360 + await db.messages.put({ 361 + id: resolvedAssistantId, 362 + conversation_id: convId, 363 + client_id: null, 364 + role: 'assistant', 365 + content: accContent, 366 + model: selectedModel, 367 + created_at: now + 1, 368 + pending: true 369 + }); 370 + break; 371 + } 372 + 373 + case 'done': { 374 + const completionTokens: number | undefined = (ev.usage as any)?.completion_tokens; 375 + const ttft = streamFirstTokenMs ? streamFirstTokenMs - streamStartMs : undefined; 376 + const doneMs = Date.now(); 377 + const tps = 378 + completionTokens && streamFirstTokenMs 379 + ? completionTokens / ((doneMs - streamFirstTokenMs) / 1000) 380 + : undefined; 381 + await db.messages.where({ id: resolvedAssistantId }).modify({ 382 + pending: false, 383 + ttft, 384 + tps, 385 + tokens: completionTokens 386 + }); 387 + await db.conversations.where({ id: convId }).modify({ updated_at: Math.floor(doneMs / 1000) }); 388 + break outer; 389 + } 390 + 391 + case 'error': { 392 + await db.messages.delete(resolvedAssistantId); 393 + errorMsg = ev.message as string; 394 + break outer; 395 + } 396 + } 397 + } 398 + } 399 + } catch (err) { 400 + await db.messages.delete(resolvedAssistantId); 401 + errorMsg = err instanceof Error ? err.message : 'Something went wrong'; 402 + } finally { 403 + streaming = false; 404 + streamingMsgId = null; 405 + } 406 + } 407 + 408 + // ── markdown renderer ───────────────────────────────────────────────────── 409 + 410 + function renderMarkdown(text: string): string { 411 + if (!text) return ''; 412 + let s = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 413 + s = s.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { 414 + const l = lang ? ` data-lang="${lang}"` : ''; 415 + return `<pre class="md-code"${l}><code>${code.trimEnd()}</code></pre>`; 416 + }); 417 + s = s.replace(/`([^`\n]+)`/g, '<code class="md-inline-code">$1</code>'); 418 + const lines = s.split('\n'); 419 + const out: string[] = []; 420 + let inUl = false; 421 + let inOl = false; 422 + const closeList = () => { 423 + if (inUl) { out.push('</ul>'); inUl = false; } 424 + if (inOl) { out.push('</ol>'); inOl = false; } 425 + }; 426 + for (const line of lines) { 427 + if (/^#{1,3} /.test(line)) { 428 + closeList(); 429 + const level = (line.match(/^(#+) /)![1]).length; 430 + out.push(`<h${level} class="md-h">${line.replace(/^#+\s/, '')}</h${level}>`); 431 + } else if (/^[-*] /.test(line)) { 432 + if (inOl) { out.push('</ol>'); inOl = false; } 433 + if (!inUl) { out.push('<ul class="md-ul">'); inUl = true; } 434 + out.push(`<li>${line.slice(2)}</li>`); 435 + } else if (/^\d+\. /.test(line)) { 436 + if (inUl) { out.push('</ul>'); inUl = false; } 437 + if (!inOl) { out.push('<ol class="md-ol">'); inOl = true; } 438 + out.push(`<li>${line.replace(/^\d+\. /, '')}</li>`); 439 + } else if (line.trim() === '') { 440 + closeList(); 441 + out.push('<br>'); 442 + } else { 443 + closeList(); 444 + let l = line; 445 + l = l.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>'); 446 + l = l.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>'); 447 + out.push(l); 448 + } 449 + } 450 + closeList(); 451 + return out.join('\n'); 452 + } 453 + 454 + // ── time formatting ─────────────────────────────────────────────────────── 455 + 456 + function relTime(unix: number): string { 457 + const s = Math.floor(Date.now() / 1000) - unix; 458 + if (s < 60) return 'now'; 459 + if (s < 3600) return `${Math.floor(s / 60)}m`; 460 + if (s < 86400) return `${Math.floor(s / 3600)}h`; 461 + if (s < 604800) return `${Math.floor(s / 86400)}d`; 462 + return new Date(unix * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); 463 + } 464 + </script> 465 + 466 + <svelte:window onclick={onWindowClick} /> 467 + 468 + <svelte:head> 469 + <title>chat · potluck</title> 470 + </svelte:head> 471 + 472 + <div class="chat"> 473 + <!-- Messages --> 474 + <div 475 + class="msgs" 476 + bind:this={msgsEl} 477 + onscroll={onMsgsScroll} 478 + > 479 + {#if !activeConvId} 480 + <div class="welcome"> 481 + <p class="welcome-title">What do you want to make today?</p> 482 + <p class="welcome-sub">Pick a model and start typing.</p> 483 + </div> 484 + {:else if messages.length === 0} 485 + <div class="welcome"> 486 + <p class="welcome-sub">Send a message to start.</p> 487 + </div> 488 + {:else} 489 + {#each messages as msg (msg.id)} 490 + {#if msg.role === 'user'} 491 + <div class="msg user"> 492 + <div class="user-bubble">{msg.content}</div> 493 + </div> 494 + {:else if msg.role === 'assistant'} 495 + <div class="msg assistant"> 496 + <div class="assistant-body"> 497 + <div class="assistant-bubble" class:pending={msg.pending}> 498 + {#if msg.pending && msg.id === streamingMsgId} 499 + <span class="stream-text">{msg.content}</span><span class="cursor" aria-hidden="true">▋</span> 500 + {:else if msg.content} 501 + {@html renderMarkdown(msg.content)} 502 + {:else if msg.pending} 503 + <span class="cursor" aria-hidden="true">▋</span> 504 + {/if} 505 + </div> 506 + <div class="msg-meta"> 507 + {#if msg.model} 508 + <span class="meta-model">{msg.model.replace('free/', '')}</span> 509 + {/if} 510 + {#if msg.id === streamingMsgId && streamFirstTokenMs > 0} 511 + <span class="meta-stat">{((streamFirstTokenMs - streamStartMs) / 1000).toFixed(2)}s ttft</span> 512 + {#if streaming} 513 + <span class="meta-stat">{(streamElapsedMs / 1000).toFixed(1)}s</span> 514 + {/if} 515 + {:else if msg.ttft} 516 + <span class="meta-stat">{(msg.ttft / 1000).toFixed(2)}s ttft</span> 517 + {/if} 518 + {#if msg.tps && msg.id !== streamingMsgId} 519 + <span class="meta-stat">{msg.tps.toFixed(0)} tok/s</span> 520 + {/if} 521 + {#if msg.tokens && msg.id !== streamingMsgId} 522 + <span class="meta-stat">{msg.tokens} tok</span> 523 + {/if} 524 + </div> 525 + </div> 526 + </div> 527 + {/if} 528 + {/each} 529 + {/if} 14 530 </div> 15 - </article> 531 + 532 + <!-- Error warning --> 533 + {#if errorMsg} 534 + <div class="error-banner" role="alert"> 535 + <span>{errorMsg}</span> 536 + <button class="dismiss-btn" onclick={() => (errorMsg = null)} aria-label="Dismiss">✕</button> 537 + </div> 538 + {/if} 539 + 540 + <!-- Input --> 541 + <div class="input-bar"> 542 + <div class="input-wrap"> 543 + <textarea 544 + class="input" 545 + bind:value={input} 546 + bind:this={inputEl} 547 + onkeydown={handleKeydown} 548 + oninput={(e) => resize(e.currentTarget as HTMLTextAreaElement)} 549 + placeholder={streaming ? '' : 'Message…'} 550 + disabled={streaming} 551 + rows={1} 552 + ></textarea> 553 + <div class="input-footer"> 554 + <!-- Model picker --> 555 + <div class="model-picker" bind:this={pickerEl}> 556 + <button 557 + class="model-chip" 558 + onclick={() => (modelPickerOpen = !modelPickerOpen)} 559 + disabled={streaming} 560 + aria-haspopup="listbox" 561 + aria-expanded={modelPickerOpen} 562 + > 563 + {#if selectedModel?.startsWith('free/')} 564 + <span class="tier-dot free" aria-hidden="true"></span> 565 + {:else if selectedModel} 566 + <span class="tier-dot pool" aria-hidden="true"></span> 567 + {/if} 568 + <span class="chip-label">{modelDisplay(selectedModel)}</span> 569 + <svg class="chip-caret" width="8" height="5" viewBox="0 0 8 5" fill="none" aria-hidden="true"> 570 + <path d="M1 1l3 3 3-3" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/> 571 + </svg> 572 + </button> 573 + 574 + {#if modelPickerOpen} 575 + <div class="model-menu" role="listbox"> 576 + {#if freeModels.length > 0} 577 + <div class="model-group-hdr">free</div> 578 + {#each freeModels as m (m.id)} 579 + <button 580 + class="model-opt" 581 + class:sel={selectedModel === m.id} 582 + role="option" 583 + aria-selected={selectedModel === m.id} 584 + onclick={() => pickModel(m.id)} 585 + >{m.label || m.id.replace('free/', '')}</button> 586 + {/each} 587 + {/if} 588 + {#if paidModels.length > 0} 589 + <div class="model-group-hdr">pool</div> 590 + {#each paidModels as m (m.id)} 591 + <button 592 + class="model-opt" 593 + class:sel={selectedModel === m.id} 594 + role="option" 595 + aria-selected={selectedModel === m.id} 596 + onclick={() => pickModel(m.id)} 597 + >{m.label || m.id}</button> 598 + {/each} 599 + {/if} 600 + {#if models.length === 0} 601 + <span class="model-opt" style="opacity:0.5;cursor:default">loading…</span> 602 + {/if} 603 + </div> 604 + {/if} 605 + </div> 606 + 607 + <button 608 + class="send-btn" 609 + onclick={sendMessage} 610 + disabled={streaming || !input.trim() || !selectedModel} 611 + aria-label="Send" 612 + > 613 + {#if streaming} 614 + <span class="spinner"></span> 615 + {:else} 616 + <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true"> 617 + <path d="M8 13V3M3 8l5-5 5 5" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/> 618 + </svg> 619 + {/if} 620 + </button> 621 + </div> 622 + </div> 623 + </div> 624 + </div> 16 625 17 626 <style> 18 - article { 19 - max-width: 40rem; 627 + .chat { 628 + display: flex; 629 + flex-direction: column; 630 + height: 100dvh; 631 + overflow: hidden; 632 + background: var(--bg-page); 633 + } 634 + 635 + /* ── messages ───────────────────────────────────────────────────────────── */ 636 + .msgs { 637 + flex: 1; 638 + overflow-y: auto; 639 + padding: 2rem 2.5rem; 640 + display: flex; 641 + flex-direction: column; 642 + gap: 1rem; 643 + } 644 + 645 + .welcome { 646 + flex: 1; 647 + display: flex; 648 + flex-direction: column; 649 + align-items: center; 650 + justify-content: center; 651 + gap: 0.35rem; 652 + text-align: center; 653 + opacity: 0.55; 20 654 } 21 - .muted { 655 + .welcome-title { 656 + font-family: var(--font-serif); 657 + font-variation-settings: var(--fraunces-text); 658 + font-size: 1.05rem; 659 + color: var(--text); 660 + margin: 0; 661 + } 662 + .welcome-sub { 663 + font-size: 0.82rem; 22 664 color: var(--text-muted); 23 - font-size: 0.95rem; 665 + margin: 0; 666 + } 667 + 668 + .msg { 669 + display: flex; 670 + width: 100%; 671 + } 672 + .msg.user { 673 + justify-content: flex-end; 674 + } 675 + .msg.assistant { 676 + justify-content: flex-start; 677 + } 678 + 679 + /* User bubble — soft warm pill, not the big accent block */ 680 + .user-bubble { 681 + max-width: min(68%, 44rem); 682 + background: color-mix(in srgb, var(--accent) 9%, var(--bg-surface)); 683 + border: 1px solid color-mix(in srgb, var(--accent) 18%, var(--border)); 684 + color: var(--text); 685 + border-radius: 14px; 686 + border-bottom-right-radius: 3px; 687 + padding: 0.55rem 0.9rem; 688 + font-size: 0.9rem; 24 689 line-height: 1.6; 25 - margin: 0; 690 + white-space: pre-wrap; 691 + word-break: break-word; 692 + } 693 + 694 + /* Assistant — full-width prose with left accent rule */ 695 + .assistant-body { 696 + max-width: min(82%, 56rem); 697 + display: flex; 698 + flex-direction: column; 699 + gap: 0.3rem; 700 + } 701 + 702 + .assistant-bubble { 703 + color: var(--text); 704 + font-size: 0.9rem; 705 + line-height: 1.65; 706 + word-break: break-word; 26 707 } 27 - .card { 708 + .assistant-bubble.pending { 709 + opacity: 0.8; 710 + } 711 + 712 + .msg-meta { 713 + display: flex; 714 + align-items: center; 715 + gap: 0.5rem; 716 + flex-wrap: wrap; 717 + } 718 + .meta-model { 719 + font-family: var(--font-mono); 720 + font-size: 0.68rem; 721 + color: var(--text-faint); 722 + letter-spacing: 0.02em; 723 + } 724 + .meta-stat { 725 + font-family: var(--font-mono); 726 + font-size: 0.68rem; 727 + color: var(--text-faint); 728 + } 729 + .meta-stat::before { 730 + content: '·'; 731 + margin-right: 0.5rem; 732 + opacity: 0.5; 733 + } 734 + 735 + .stream-text { 736 + white-space: pre-wrap; 737 + } 738 + 739 + @keyframes blink { 740 + 0%, 100% { opacity: 1; } 741 + 50% { opacity: 0; } 742 + } 743 + .cursor { 744 + animation: blink 900ms step-end infinite; 745 + color: var(--accent); 746 + font-size: 0.85em; 747 + margin-left: 1px; 748 + } 749 + 750 + /* Markdown inside assistant bubbles */ 751 + .assistant-bubble :global(.md-h) { 752 + margin: 0.5em 0 0.25em; 753 + font-size: 1em; 754 + font-weight: 600; 755 + } 756 + .assistant-bubble :global(.md-code) { 757 + display: block; 758 + background: var(--bg-code); 28 759 border: 1px solid var(--border); 29 - border-radius: var(--radius-md); 30 - background: var(--bg-surface); 31 - padding: 1.25rem; 760 + border-radius: var(--radius-sm); 761 + padding: 0.65rem 0.85rem; 762 + font-family: var(--font-mono); 763 + font-size: 0.8rem; 764 + overflow-x: auto; 765 + margin: 0.4em 0; 766 + white-space: pre; 767 + } 768 + .assistant-bubble :global(.md-inline-code) { 769 + font-family: var(--font-mono); 770 + font-size: 0.82em; 771 + background: var(--bg-code); 772 + border: 1px solid var(--border); 773 + border-radius: 3px; 774 + padding: 1px 4px; 775 + } 776 + .assistant-bubble :global(.md-ul), 777 + .assistant-bubble :global(.md-ol) { 778 + margin: 0.3em 0; 779 + padding-left: 1.4em; 780 + } 781 + .assistant-bubble :global(li) { 782 + margin-bottom: 0.15em; 783 + } 784 + .assistant-bubble :global(strong) { font-weight: 600; } 785 + .assistant-bubble :global(em) { font-style: italic; } 786 + 787 + /* ── error banner ───────────────────────────────────────────────────────── */ 788 + .error-banner { 789 + flex-shrink: 0; 790 + display: flex; 791 + align-items: center; 792 + gap: 0.5rem; 793 + padding: 0.45rem 1.5rem; 794 + background: color-mix(in srgb, #f97316 10%, var(--bg-page)); 795 + border-top: 1px solid color-mix(in srgb, #f97316 25%, transparent); 796 + color: #c2410c; 797 + font-size: 0.8rem; 798 + } 799 + .error-banner span { flex: 1; } 800 + .dismiss-btn { 801 + background: none; 802 + border: none; 803 + cursor: pointer; 804 + color: inherit; 805 + opacity: 0.65; 806 + font-size: 0.72rem; 807 + padding: 2px 4px; 808 + line-height: 1; 809 + } 810 + .dismiss-btn:hover { opacity: 1; } 811 + 812 + /* ── input area ─────────────────────────────────────────────────────────── */ 813 + .input-bar { 814 + flex-shrink: 0; 815 + padding: 0.75rem 1.5rem 1.25rem; 816 + background: var(--bg-page); 817 + } 818 + 819 + .input-wrap { 32 820 display: flex; 33 821 flex-direction: column; 34 - gap: 0.75rem; 822 + background: var(--bg-surface); 823 + border: 1px solid var(--border); 824 + border-radius: 12px; 825 + padding: 0.65rem 0.65rem 0.45rem 0.9rem; 826 + transition: border-color 100ms ease; 827 + } 828 + .input-wrap:focus-within { 829 + border-color: color-mix(in srgb, var(--accent) 60%, transparent); 830 + } 831 + 832 + .input { 833 + flex: 1; 834 + background: none; 835 + border: none; 836 + outline: none; 837 + resize: none; 838 + font-family: var(--font-sans); 839 + font-size: 0.9rem; 840 + color: var(--text); 841 + line-height: 1.5; 842 + max-height: 200px; 843 + overflow-y: auto; 844 + padding: 0; 845 + min-height: 1.35rem; 846 + } 847 + .input::placeholder { color: var(--text-faint); } 848 + .input:disabled { opacity: 0.5; } 849 + 850 + .input-footer { 851 + display: flex; 852 + align-items: center; 853 + justify-content: space-between; 854 + margin-top: 0.4rem; 855 + } 856 + 857 + /* ── model picker ───────────────────────────────────────────────────────── */ 858 + .model-picker { 859 + position: relative; 860 + } 861 + 862 + .model-chip { 863 + display: inline-flex; 864 + align-items: center; 865 + gap: 0.3rem; 866 + background: none; 867 + border: 1px solid var(--border); 868 + border-radius: 5px; 869 + padding: 0.18rem 0.5rem 0.18rem 0.4rem; 870 + cursor: pointer; 871 + font-family: var(--font-mono); 872 + font-size: 0.7rem; 873 + color: var(--text-muted); 874 + transition: border-color 80ms, color 80ms; 875 + white-space: nowrap; 876 + } 877 + .model-chip:hover:not(:disabled) { 878 + border-color: color-mix(in srgb, var(--accent) 50%, transparent); 879 + color: var(--text); 880 + } 881 + .model-chip:disabled { 882 + opacity: 0.5; 883 + cursor: default; 884 + } 885 + 886 + .tier-dot { 887 + width: 5px; 888 + height: 5px; 889 + border-radius: 50%; 890 + flex-shrink: 0; 891 + } 892 + .tier-dot.free { background: #4ade80; } 893 + .tier-dot.pool { background: var(--accent); } 894 + 895 + .chip-label { 896 + max-width: 180px; 897 + overflow: hidden; 898 + text-overflow: ellipsis; 899 + } 900 + 901 + .chip-caret { 902 + opacity: 0.5; 903 + flex-shrink: 0; 904 + } 905 + 906 + .model-menu { 907 + position: absolute; 908 + bottom: calc(100% + 6px); 909 + left: 0; 910 + background: var(--bg-surface); 911 + border: 1px solid var(--border); 912 + border-radius: var(--radius-md); 913 + padding: 0.3rem; 914 + min-width: 190px; 915 + max-height: 260px; 916 + overflow-y: auto; 917 + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12); 918 + z-index: 200; 919 + } 920 + 921 + .model-group-hdr { 922 + font-family: var(--font-sans); 923 + font-size: 10px; 924 + font-weight: 600; 925 + letter-spacing: 0.14em; 926 + text-transform: uppercase; 927 + color: var(--text-muted); 928 + padding: 0.4rem 0.5rem 0.2rem; 929 + } 930 + 931 + .model-opt { 932 + display: block; 933 + width: 100%; 934 + text-align: left; 935 + background: none; 936 + border: none; 937 + border-radius: var(--radius-sm); 938 + padding: 0.32rem 0.5rem; 939 + font-family: var(--font-mono); 940 + font-size: 0.75rem; 941 + color: var(--text); 942 + cursor: pointer; 943 + transition: background 60ms; 944 + white-space: nowrap; 945 + overflow: hidden; 946 + text-overflow: ellipsis; 947 + } 948 + .model-opt:hover { background: var(--bg-page); } 949 + .model-opt.sel { 950 + color: var(--accent); 951 + font-weight: 500; 952 + } 953 + 954 + /* ── send button ────────────────────────────────────────────────────────── */ 955 + .send-btn { 956 + flex-shrink: 0; 957 + width: 30px; 958 + height: 30px; 959 + display: flex; 960 + align-items: center; 961 + justify-content: center; 962 + background: var(--accent); 963 + color: var(--text-on-accent); 964 + border: none; 965 + border-radius: 7px; 966 + cursor: pointer; 967 + transition: filter 80ms ease, opacity 80ms ease; 968 + } 969 + .send-btn:hover:not(:disabled) { filter: brightness(1.1); } 970 + .send-btn:disabled { opacity: 0.35; cursor: not-allowed; } 971 + 972 + @keyframes spin { 973 + to { transform: rotate(360deg); } 974 + } 975 + .spinner { 976 + width: 12px; 977 + height: 12px; 978 + border: 1.5px solid rgba(255, 217, 218, 0.3); 979 + border-top-color: var(--text-on-accent); 980 + border-radius: 50%; 981 + animation: spin 700ms linear infinite; 982 + } 983 + 984 + /* ── responsive ─────────────────────────────────────────────────────────── */ 985 + @media (max-width: 720px) { 986 + .msgs { padding: 1rem; } 987 + .msg { max-width: 92%; } 988 + .input-bar { padding: 0.5rem 0.75rem 0.85rem; } 35 989 } 36 990 </style>