fix: chat session reliability, cost, and hygiene (empty-response guard, tool truncation, delete, titles)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

- Empty/refusal responses retried once, then surfaced as errors instead of silent blanks
- Chinese refusal boilerplate detected via denylist + non-ASCII heuristic
- Bulk-tool preference added to SOUL.md (list_lxcs over per-entity get_lxc_state)
- Tool results truncated to 4KB on persist; get_state_snapshot filters null-state entities
- Session delete (DELETE /sessions/{id} + confirm-on-second-click UI)
- Session titles auto-generated from assistant answer instead of raw user message
This commit is contained in:
2026-07-09 10:18:06 +02:00
parent 614c38ea7c
commit 49c37fe8b1
9 changed files with 335 additions and 60 deletions

View File

@@ -31,6 +31,11 @@ export async function fetchMessages(sessionId: string): Promise<Message[]> {
return data.messages ?? []
}
export async function deleteSession(sessionId: string): Promise<boolean> {
const res = await fetch(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
return res.ok
}
export interface ChatEvent {
type: string
data: any

View File

@@ -1,20 +1,39 @@
<script lang="ts">
import { onMount } from 'svelte'
import { sessions, currentSession, loadSessions, loadSessionMessages, newChat } from '$lib/stores/chat'
import { sessions, currentSession, loadSessions, loadSessionMessages, newChat, deleteSession } from '$lib/stores/chat'
import { relativeTime } from '$lib/utils'
import { Button } from '$lib/components/ui/button'
import { ScrollArea } from '$lib/components/ui/scroll-area'
import PlusIcon from '@lucide/svelte/icons/plus'
import Trash2Icon from '@lucide/svelte/icons/trash2'
onMount(() => {
loadSessions()
})
// Pick up sessions created/renamed elsewhere (e.g. after a turn completes).
$effect(() => {
void $currentSession
loadSessions()
})
let confirmDelete = $state<string | null>(null)
function handleDelete(e: MouseEvent, id: string) {
e.stopPropagation()
if (confirmDelete === id) {
deleteSession(id)
confirmDelete = null
} else {
confirmDelete = id
// Hide confirmation after 3s
setTimeout(() => { if (confirmDelete === id) confirmDelete = null }, 3000)
}
}
function handleClick(sessionId: string) {
confirmDelete = null
loadSessionMessages(sessionId)
}
</script>
<aside class="flex h-full w-56 shrink-0 flex-col gap-2 overflow-y-auto border-r bg-card/50 p-2">
@@ -27,10 +46,23 @@
{#each $sessions as session (session.id)}
<button
type="button"
class="flex flex-col items-start gap-0.5 rounded-md border px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/60 {$currentSession === session.id ? 'border-primary bg-muted/50' : 'border-transparent'}"
onclick={() => loadSessionMessages(session.id)}
class="group flex flex-col items-start gap-0.5 rounded-md border px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/60 {$currentSession === session.id ? 'border-primary bg-muted/50' : 'border-transparent'}"
onclick={() => handleClick(session.id)}
>
<span class="w-full truncate font-medium">{session.title || 'Untitled'}</span>
<span class="flex w-full items-center justify-between gap-1">
<span class="min-w-0 truncate font-medium">{session.title || 'Untitled'}</span>
<span
class="shrink-0 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/20 hover:text-destructive"
onclick={(e) => handleDelete(e, session.id)}
title={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
>
{#if confirmDelete === session.id}
<span class="text-[10px] font-semibold text-destructive">Sure?</span>
{:else}
<Trash2Icon class="size-3" />
{/if}
</span>
</span>
<span class="text-[11px] text-muted-foreground">{relativeTime(session.last_active_at)}</span>
</button>
{:else}

View File

@@ -1,5 +1,5 @@
import { writable, get } from 'svelte/store'
import { streamChat, fetchSessions, fetchMessages } from '$lib/api'
import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api'
import type { ChatEvent, Session, Message } from '$lib/api'
export interface ChatMessage {
@@ -177,3 +177,12 @@ export function cancelStream() {
streaming.set(false)
}
}
export async function deleteSession(sessionId: string) {
const ok = await apiDeleteSession(sessionId)
if (!ok) return
if (get(currentSession) === sessionId) {
newChat()
}
loadSessions()
}

View File

@@ -1,29 +1,59 @@
<script lang="ts">
import { sessions, loadSessions, loadSessionMessages, messages, currentSession } from '$lib/stores/chat'
import { sessions, loadSessions, loadSessionMessages, deleteSession, currentSession } from '$lib/stores/chat'
import { onMount } from 'svelte'
import Trash2Icon from '@lucide/svelte/icons/trash2'
onMount(() => {
loadSessions()
})
let confirmDelete = $state<string | null>(null)
function handleDelete(e: MouseEvent, id: string) {
e.stopPropagation()
if (confirmDelete === id) {
deleteSession(id)
confirmDelete = null
} else {
confirmDelete = id
setTimeout(() => { if (confirmDelete === id) confirmDelete = null }, 3000)
}
}
</script>
<div class="sessions-page">
<h2>Sessions</h2>
<div class="session-list">
{#each $sessions as session (session.id)}
<button
class="session-card"
<div
class="session-card group"
class:active={$currentSession === session.id}
onclick={() => {
loadSessionMessages(session.id)
location.hash = '#/chat'
}}
>
<div class="session-title">{session.title || 'Untitled'}</div>
<div class="session-meta">
{new Date(session.last_active_at).toLocaleString()}
</div>
</button>
<button
class="session-content"
onclick={() => {
loadSessionMessages(session.id)
location.hash = '#/chat'
}}
>
<div class="session-title">{session.title || 'Untitled'}</div>
<div class="session-meta">
{new Date(session.last_active_at).toLocaleString()}
</div>
</button>
<button
class="session-delete"
onclick={(e) => handleDelete(e, session.id)}
title={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
aria-label="Delete session"
>
{#if confirmDelete === session.id}
<span class="confirm-text">Delete?</span>
{:else}
<Trash2Icon class="icon" />
{/if}
</button>
</div>
{:else}
<div class="empty">No sessions yet. Start chatting with Nomos.</div>
{/each}
@@ -54,15 +84,12 @@
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;
}
@@ -75,6 +102,58 @@
background: var(--bg-hover);
}
.session-content {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: none;
border: none;
cursor: pointer;
color: inherit;
font-family: inherit;
font-size: inherit;
text-align: left;
}
.session-delete {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin-right: 4px;
padding: 0;
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
color: var(--text-muted);
opacity: 0;
transition: opacity 0.15s, background 0.15s;
}
.group:hover .session-delete {
opacity: 1;
}
.session-delete:hover {
background: var(--destructive-bg-subtle, rgba(239, 68, 68, 0.1));
color: var(--destructive, #ef4444);
}
.confirm-text {
font-size: 0.65rem;
font-weight: 700;
color: var(--destructive, #ef4444);
}
.icon {
width: 16px;
height: 16px;
}
.session-title {
font-weight: 500;
}