nomos+web: streaming, provider routing, event gap-fill, embedded UI; fix approval FK & session context
Agent (cmd/nomos): - Stream LLM tokens via NewStreaming; emit text_delta then final text. - OpenRouter provider routing: data_collection=deny (ZDR) + require_parameters; NOMOS_PROVIDER_SORT opt-in; Exacto via model suffix. - Multi-turn: reload session history into context; UI passes session id. - Fix agent_activity logging (agent_id/session_id) and mcpClient data race. Events (live control-room feed): - approval.created (mcp), approval.decided (api), execution.completed/failed (approved-action path), signal.raised/resolved + health.changed (scheduler, transition-gated). Fixes: - createApproval FK violation (reuse execution entity) — the agent's only write path; log the previously-swallowed errors. Web UI: - Embed web/dist via //go:embed (single binary); Dockerfile builds SPA into the Go stage; committed .gitkeep placeholder keeps backend-only builds green. - Caddy: Authentik-gated /agent/* -> nomos so the UI reaches the agent same-origin in production. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
155
web/src/App.svelte
Normal file
155
web/src/App.svelte
Normal file
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import Chat from '$lib/../pages/Chat.svelte'
|
||||
import Sessions from '$lib/../pages/Sessions.svelte'
|
||||
import { newChat } from '$lib/stores/chat'
|
||||
import { onMount } from 'svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
|
||||
let page = $state('chat')
|
||||
let drawerOpen = $state(false)
|
||||
|
||||
onMount(() => {
|
||||
function sync() {
|
||||
page = location.hash.slice(2) || 'chat'
|
||||
}
|
||||
sync()
|
||||
window.addEventListener('hashchange', sync)
|
||||
return () => window.removeEventListener('hashchange', sync)
|
||||
})
|
||||
|
||||
function navigate(p: string) {
|
||||
location.hash = '#/' + p
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="app">
|
||||
<nav class="sidebar">
|
||||
<div class="logo">Oikos</div>
|
||||
<button class="nav-btn" onclick={() => { newChat(); navigate('chat') }}>
|
||||
<span class="nav-icon">✚</span>
|
||||
<span>New</span>
|
||||
</button>
|
||||
<button class="nav-btn" class:active={page === 'chat'} onclick={() => navigate('chat')}>
|
||||
<span class="nav-icon">💬</span>
|
||||
<span>Chat</span>
|
||||
</button>
|
||||
<button class="nav-btn" class:active={page === 'sessions'} onclick={() => navigate('sessions')}>
|
||||
<span class="nav-icon">📋</span>
|
||||
<span>Sessions</span>
|
||||
</button>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
<button class="drawer-toggle" onclick={() => drawerOpen = !drawerOpen}>
|
||||
Chat {drawerOpen ? '▼' : '▲'}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<main class="main">
|
||||
{#if page === 'chat'}
|
||||
<Chat />
|
||||
{:else if page === 'sessions'}
|
||||
<Sessions />
|
||||
{:else}
|
||||
<Chat />
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
{#if drawerOpen}
|
||||
<aside class="drawer" transition:slide={{ axis: 'x' }}>
|
||||
<Chat />
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 56px;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-blue);
|
||||
margin-bottom: 0.5rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-family: inherit;
|
||||
font-size: 0.625rem;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
width: 44px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-btn.active {
|
||||
background: var(--bg-active);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.drawer-toggle {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-family: inherit;
|
||||
font-size: 0.625rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.drawer-toggle:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
width: 380px;
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
62
web/src/app.css
Normal file
62
web/src/app.css
Normal file
@@ -0,0 +1,62 @@
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--bg-surface: #161b22;
|
||||
--bg-deeper: #0a0e13;
|
||||
--bg-hover: #21262d;
|
||||
--bg-active: #292e36;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--text-muted: #8b949e;
|
||||
--accent-blue: #58a6ff;
|
||||
--accent-green: #3fb950;
|
||||
--accent-red: #f85149;
|
||||
--accent-orange: #d29922;
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
92
web/src/lib/api.ts
Normal file
92
web/src/lib/api.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
const BASE = '/agent'
|
||||
|
||||
export interface Session {
|
||||
id: string
|
||||
title: string
|
||||
actor: string
|
||||
created_at: string
|
||||
last_active_at: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
session_id: string
|
||||
role: string
|
||||
content: any
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export async function fetchSessions(): Promise<Session[]> {
|
||||
const res = await fetch(`${BASE}/sessions`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.sessions ?? []
|
||||
}
|
||||
|
||||
export async function fetchMessages(sessionId: string): Promise<Message[]> {
|
||||
const res = await fetch(`${BASE}/sessions/${sessionId}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.messages ?? []
|
||||
}
|
||||
|
||||
export interface ChatEvent {
|
||||
type: string
|
||||
data: any
|
||||
session_id?: string
|
||||
iteration?: number
|
||||
}
|
||||
|
||||
export function streamChat(
|
||||
message: string,
|
||||
sessionId: string | null,
|
||||
onEvent: (ev: ChatEvent) => void,
|
||||
onError: (err: string) => void,
|
||||
onDone: () => void
|
||||
): AbortController {
|
||||
const controller = new AbortController()
|
||||
|
||||
fetch(`${BASE}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, session_id: sessionId ?? undefined }),
|
||||
signal: controller.signal
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
onError(`HTTP ${res.status}`)
|
||||
return
|
||||
}
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) {
|
||||
onError('no response body')
|
||||
return
|
||||
}
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const ev: ChatEvent = JSON.parse(line.slice(6))
|
||||
onEvent(ev)
|
||||
} catch {
|
||||
// skip malformed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}).catch((err) => {
|
||||
onError(err.message)
|
||||
}).finally(() => {
|
||||
onDone()
|
||||
})
|
||||
|
||||
return controller
|
||||
}
|
||||
162
web/src/lib/stores/chat.ts
Normal file
162
web/src/lib/stores/chat.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { writable, get } from 'svelte/store'
|
||||
import { streamChat, fetchSessions, fetchMessages } from '$lib/api'
|
||||
import type { ChatEvent, Session, Message } from '$lib/api'
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
text: string
|
||||
tools: ToolCallResult[]
|
||||
}
|
||||
|
||||
export interface ToolCallResult {
|
||||
type: 'tool_use' | 'tool_result'
|
||||
name: string
|
||||
id?: string
|
||||
args?: any
|
||||
result?: any
|
||||
error?: string
|
||||
}
|
||||
|
||||
function mid(): string {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
export const messages = writable<ChatMessage[]>([])
|
||||
export const streaming = writable(false)
|
||||
export const currentSession = writable<string | null>(null)
|
||||
export const sessions = writable<Session[]>([])
|
||||
export const sessionMessages = writable<Message[]>([])
|
||||
export const error = writable<string | null>(null)
|
||||
|
||||
let activeController: AbortController | null = null
|
||||
|
||||
export async function loadSessions() {
|
||||
const list = await fetchSessions()
|
||||
sessions.set(list)
|
||||
}
|
||||
|
||||
export async function loadSessionMessages(sessionId: string) {
|
||||
currentSession.set(sessionId)
|
||||
const msgs = await fetchMessages(sessionId)
|
||||
sessionMessages.set(msgs)
|
||||
const chatMsgs: ChatMessage[] = msgs.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role as 'user' | 'assistant',
|
||||
text: m.content?.text ?? (typeof m.content === 'string' ? m.content : ''),
|
||||
tools: m.content?.tool_calls ?? []
|
||||
}))
|
||||
messages.set(chatMsgs)
|
||||
}
|
||||
|
||||
export function sendMessage(text: string) {
|
||||
error.set(null)
|
||||
streaming.set(true)
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: mid(),
|
||||
role: 'user',
|
||||
text,
|
||||
tools: []
|
||||
}
|
||||
messages.update((ms) => [...ms, userMsg])
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: mid(),
|
||||
role: 'assistant',
|
||||
text: '',
|
||||
tools: []
|
||||
}
|
||||
messages.update((ms) => [...ms, assistantMsg])
|
||||
|
||||
let activeTools: Map<string, ToolCallResult> = new Map()
|
||||
|
||||
activeController = streamChat(
|
||||
text,
|
||||
get(currentSession), // continue the active session so the agent keeps context
|
||||
(ev: ChatEvent) => {
|
||||
if (ev.type === 'session') {
|
||||
currentSession.set(ev.data)
|
||||
} else if (ev.type === 'tool_use') {
|
||||
const tr: ToolCallResult = {
|
||||
type: 'tool_use',
|
||||
name: ev.data.name,
|
||||
id: ev.data.id,
|
||||
args: ev.data.args
|
||||
}
|
||||
activeTools.set(ev.data.id, tr)
|
||||
messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') {
|
||||
last.tools = [...last.tools, tr]
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
} else if (ev.type === 'tool_result') {
|
||||
const existing = activeTools.get(ev.data.id)
|
||||
if (existing) {
|
||||
const updated: ToolCallResult = {
|
||||
...existing,
|
||||
type: 'tool_result',
|
||||
result: ev.data.result,
|
||||
error: ev.data.error
|
||||
}
|
||||
activeTools.set(ev.data.id, updated)
|
||||
messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') {
|
||||
last.tools = last.tools.map((t) =>
|
||||
t.id === ev.data.id ? updated : t
|
||||
)
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
}
|
||||
} else if (ev.type === 'text_delta') {
|
||||
messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') {
|
||||
last.text += ev.data
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
} else if (ev.type === 'text') {
|
||||
// Final authoritative content for the turn; replaces accumulated deltas.
|
||||
messages.update((ms) => {
|
||||
const last = ms[ms.length - 1]
|
||||
if (last && last.role === 'assistant') {
|
||||
last.text = ev.data
|
||||
}
|
||||
return [...ms]
|
||||
})
|
||||
} else if (ev.type === 'done') {
|
||||
currentSession.set(ev.data?.session_id ?? ev.session_id)
|
||||
} else if (ev.type === 'error') {
|
||||
error.set(ev.data)
|
||||
}
|
||||
},
|
||||
(err: string) => {
|
||||
error.set(err)
|
||||
},
|
||||
() => {
|
||||
streaming.set(false)
|
||||
activeController = null
|
||||
loadSessions()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function newChat() {
|
||||
cancelStream()
|
||||
currentSession.set(null)
|
||||
messages.set([])
|
||||
error.set(null)
|
||||
}
|
||||
|
||||
export function cancelStream() {
|
||||
if (activeController) {
|
||||
activeController.abort()
|
||||
activeController = null
|
||||
streaming.set(false)
|
||||
}
|
||||
}
|
||||
6
web/src/main.ts
Normal file
6
web/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { mount } from 'svelte'
|
||||
import App from './App.svelte'
|
||||
import './app.css'
|
||||
|
||||
const app = mount(App, { target: document.getElementById('app')! })
|
||||
export default app
|
||||
263
web/src/pages/Chat.svelte
Normal file
263
web/src/pages/Chat.svelte
Normal file
@@ -0,0 +1,263 @@
|
||||
<script lang="ts">
|
||||
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
|
||||
import { fly } from 'svelte/transition'
|
||||
|
||||
let input = ''
|
||||
let messagesEnd: HTMLDivElement
|
||||
$: $messages, $streaming, setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
|
||||
|
||||
function handleSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
const text = input.trim()
|
||||
if (!text || $streaming) return
|
||||
input = ''
|
||||
sendMessage(text)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit(e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chat">
|
||||
<div class="messages">
|
||||
{#each $messages as msg (msg.id)}
|
||||
<div class="message {msg.role}">
|
||||
<div class="role">{msg.role === 'user' ? 'You' : 'Nomos'}</div>
|
||||
{#if msg.text}
|
||||
<div class="text">{msg.text}</div>
|
||||
{/if}
|
||||
{#each msg.tools as tool (tool.id)}
|
||||
<div class="tool-chip" class:tool-use={tool.type === 'tool_use'} class:tool-result={tool.type === 'tool_result'}>
|
||||
<div class="tool-header" transition:fly={{ y: 4, duration: 150 }}>
|
||||
<span class="tool-icon">{tool.type === 'tool_use' ? '⚙' : '✓'}</span>
|
||||
<span class="tool-name">{tool.name}</span>
|
||||
</div>
|
||||
{#if tool.type === 'tool_use' && tool.args}
|
||||
<div class="tool-body">
|
||||
<pre>{JSON.stringify(tool.args, null, 2)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{#if tool.type === 'tool_result'}
|
||||
<div class="tool-body">
|
||||
{#if tool.error}
|
||||
<pre class="error">{tool.error}</pre>
|
||||
{:else}
|
||||
<pre>{JSON.stringify(tool.result, null, 2)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if !msg.text && msg.tools.length === 0 && msg.role === 'assistant'}
|
||||
<div class="thinking">Thinking<span class="dots"></span></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<div bind:this={messagesEnd}></div>
|
||||
</div>
|
||||
|
||||
{#if $error}
|
||||
<div class="error-banner" transition:fly={{ y: 8, duration: 200 }}>
|
||||
{$error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="input-bar" onsubmit={handleSubmit}>
|
||||
<textarea
|
||||
bind:value={input}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Ask Nomos anything..."
|
||||
rows={2}
|
||||
disabled={$streaming}
|
||||
></textarea>
|
||||
{#if $streaming}
|
||||
<button type="button" class="stop" onclick={cancelStream}>■</button>
|
||||
{:else}
|
||||
<button type="submit" disabled={!input.trim()}>→</button>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.role {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.text {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
max-width: 100%;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.thinking {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.dots::after {
|
||||
content: '';
|
||||
animation: dots 1.5s steps(4, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
0% { content: ''; }
|
||||
25% { content: '.'; }
|
||||
50% { content: '..'; }
|
||||
75% { content: '...'; }
|
||||
}
|
||||
|
||||
.tool-chip {
|
||||
margin-top: 0.25rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
font-size: 0.8125rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-chip.tool-use {
|
||||
border-left: 3px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
.tool-chip.tool-result {
|
||||
border-left: 3px solid var(--accent-green);
|
||||
}
|
||||
|
||||
.tool-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.tool-body {
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-deeper);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tool-body pre {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tool-body pre.error {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: var(--accent-red);
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.input-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.input-bar textarea {
|
||||
flex: 1;
|
||||
background: var(--bg-deeper);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
padding: 0.625rem 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-bar textarea:focus {
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.input-bar button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: flex-end;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.input-bar button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.input-bar button.stop {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
</style>
|
||||
90
web/src/pages/Sessions.svelte
Normal file
90
web/src/pages/Sessions.svelte
Normal file
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { sessions, loadSessions, loadSessionMessages, messages, currentSession } from '$lib/stores/chat'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
onMount(() => {
|
||||
loadSessions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="sessions-page">
|
||||
<h2>Sessions</h2>
|
||||
<div class="session-list">
|
||||
{#each $sessions as session (session.id)}
|
||||
<button
|
||||
class="session-card"
|
||||
class:active={$currentSession === session.id}
|
||||
onclick={() => loadSessionMessages(session.id)}
|
||||
>
|
||||
<div class="session-title">{session.title || 'Untitled'}</div>
|
||||
<div class="session-meta">
|
||||
{new Date(session.last_active_at).toLocaleString()}
|
||||
</div>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="empty">No sessions yet. Start chatting with Nomos.</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sessions-page {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.session-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.session-card:hover {
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.session-card.active {
|
||||
border-color: var(--accent-blue);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.session-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user