fix: chat session reliability, cost, and hygiene (empty-response guard, tool truncation, delete, titles)
- 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:
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -15,6 +16,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const maxIterations = 15
|
const maxIterations = 15
|
||||||
|
const maxLLMRetries = 1
|
||||||
|
|
||||||
|
var refusalDenylist = []string{
|
||||||
|
"我没有相关信息",
|
||||||
|
"您可以尝试问我其它问题",
|
||||||
|
"我无法",
|
||||||
|
"抱歉,我无法",
|
||||||
|
"关于这个问题,我没有",
|
||||||
|
}
|
||||||
|
|
||||||
type agent struct {
|
type agent struct {
|
||||||
client *mcpClient
|
client *mcpClient
|
||||||
@@ -105,14 +115,6 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rebuild conversation context from persisted history so sessions are
|
|
||||||
// multi-turn. The current user turn is saved by the HTTP handler before
|
|
||||||
// this runs, so it is already included in the history for real sessions.
|
|
||||||
// Prior tool_use/tool_result pairs are replayed as a tool-calling
|
|
||||||
// assistant message followed by matching tool-role results, so the agent
|
|
||||||
// starts each turn already knowing what it already checked instead of
|
|
||||||
// re-querying the same tools from scratch. Ephemeral sessions (no store)
|
|
||||||
// fall back to the single incoming message.
|
|
||||||
system := a.system
|
system := a.system
|
||||||
if snapshot := a.fleetSnapshot(); snapshot != "" {
|
if snapshot := a.fleetSnapshot(); snapshot != "" {
|
||||||
system += "\n\n" + snapshot
|
system += "\n\n" + snapshot
|
||||||
@@ -147,30 +149,54 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
|
|||||||
Tools: tools,
|
Tools: tools,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream the completion, emitting token deltas as they arrive. The
|
var msg openai.ChatCompletionMessage
|
||||||
// accumulator reassembles the full message (content + tool calls) for
|
var acc openai.ChatCompletionAccumulator
|
||||||
// the loop's control flow.
|
|
||||||
stream := a.provider.Chat.Completions.NewStreaming(ctx, params, a.reqOpts...)
|
for attempt := 0; attempt <= maxLLMRetries; attempt++ {
|
||||||
acc := openai.ChatCompletionAccumulator{}
|
acc = openai.ChatCompletionAccumulator{}
|
||||||
for stream.Next() {
|
stream := a.provider.Chat.Completions.NewStreaming(ctx, params, a.reqOpts...)
|
||||||
chunk := stream.Current()
|
for stream.Next() {
|
||||||
acc.AddChunk(chunk)
|
chunk := stream.Current()
|
||||||
if len(chunk.Choices) > 0 {
|
acc.AddChunk(chunk)
|
||||||
if delta := chunk.Choices[0].Delta.Content; delta != "" {
|
if len(chunk.Choices) > 0 {
|
||||||
emit(agentEvent{Type: "text_delta", Data: delta, SessionID: sessionID, Iteration: i + 1})
|
if delta := chunk.Choices[0].Delta.Content; delta != "" {
|
||||||
|
emit(agentEvent{Type: "text_delta", Data: delta, SessionID: sessionID, Iteration: i + 1})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if err := stream.Err(); err != nil {
|
||||||
if err := stream.Err(); err != nil {
|
if attempt < maxLLMRetries {
|
||||||
emit(agentEvent{Type: "error", Data: fmt.Sprintf("llm: %v", err), SessionID: sessionID})
|
slog.Warn("nomos: llm stream error, retrying", "error", err, "attempt", attempt+1, "session", sessionID)
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
if len(acc.Choices) == 0 {
|
emit(agentEvent{Type: "error", Data: fmt.Sprintf("llm: %v", err), SessionID: sessionID})
|
||||||
emit(agentEvent{Type: "error", Data: "no choices in response", SessionID: sessionID})
|
return
|
||||||
return
|
}
|
||||||
}
|
if len(acc.Choices) == 0 {
|
||||||
|
if attempt < maxLLMRetries {
|
||||||
|
slog.Warn("nomos: no choices in response, retrying", "attempt", attempt+1, "session", sessionID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
emit(agentEvent{Type: "error", Data: "no choices in response", SessionID: sessionID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
msg := acc.Choices[0].Message
|
msg = acc.Choices[0].Message
|
||||||
|
|
||||||
|
if len(msg.ToolCalls) == 0 {
|
||||||
|
if isRefusalOrEmpty(msg.Content) {
|
||||||
|
if attempt < maxLLMRetries {
|
||||||
|
slog.Warn("nomos: empty or refusal response, retrying",
|
||||||
|
"session", sessionID, "iter", i+1, "attempt", attempt+1,
|
||||||
|
"content_len", len(msg.Content))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
emit(agentEvent{Type: "error", Data: "Nomos returned an empty or unusable response — please retry.", SessionID: sessionID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
if len(msg.ToolCalls) == 0 {
|
if len(msg.ToolCalls) == 0 {
|
||||||
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
|
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
|
||||||
@@ -388,6 +414,33 @@ func (a *agent) fleetSnapshot() string {
|
|||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isRefusalOrEmpty returns true when the LLM response is blank or looks like a
|
||||||
|
// canned non-English refusal to an English-language conversation. Flash-tier
|
||||||
|
// models occasionally emit Chinese boilerplate deflection instead of a real
|
||||||
|
// answer; this catches it before it reaches the UI.
|
||||||
|
func isRefusalOrEmpty(text string) bool {
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ascii, nonASCII := 0, 0
|
||||||
|
for _, r := range text {
|
||||||
|
if r <= 127 {
|
||||||
|
ascii++
|
||||||
|
} else {
|
||||||
|
nonASCII++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nonASCII > ascii {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, pattern := range refusalDenylist {
|
||||||
|
if strings.Contains(text, pattern) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) {
|
func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) {
|
||||||
defs, err := a.client.listToolsFull()
|
defs, err := a.client.listToolsFull()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -186,6 +186,15 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
|||||||
"tool_calls": toolCalls,
|
"tool_calls": toolCalls,
|
||||||
})
|
})
|
||||||
st.saveMessage(ctx, sessionID, "assistant", assistantMsg)
|
st.saveMessage(ctx, sessionID, "assistant", assistantMsg)
|
||||||
|
|
||||||
|
// Generate a meaningful title from the assistant's first answer
|
||||||
|
// instead of reusing the raw user message for every session.
|
||||||
|
if finalText != "" && sessionID != "ephemeral" {
|
||||||
|
title := truncate(finalText, 80)
|
||||||
|
if title != "" {
|
||||||
|
st.updateSessionTitle(ctx, sessionID, title)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
|
func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
|
||||||
@@ -220,13 +229,26 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
messages, err := st.getMessages(r.Context(), id)
|
switch r.Method {
|
||||||
if err != nil {
|
case http.MethodDelete:
|
||||||
http.Error(w, err.Error(), 500)
|
if err := st.deleteSession(r.Context(), id); err != nil {
|
||||||
return
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(204)
|
||||||
|
|
||||||
|
case http.MethodGet:
|
||||||
|
messages, err := st.getMessages(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"session_id": id, "messages": messages})
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", 405)
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{"session_id": id, "messages": messages})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agentSlug, mcpURL string) {
|
func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agentSlug, mcpURL string) {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxToolResultSize = 4096
|
||||||
|
|
||||||
type store struct {
|
type store struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
}
|
}
|
||||||
@@ -71,10 +73,45 @@ func (s *store) saveMessage(ctx context.Context, sessionID, role string, content
|
|||||||
}
|
}
|
||||||
_, err := s.pool.Exec(ctx,
|
_, err := s.pool.Exec(ctx,
|
||||||
`INSERT INTO agent_messages (session_id, role, content) VALUES ($1, $2, $3)`,
|
`INSERT INTO agent_messages (session_id, role, content) VALUES ($1, $2, $3)`,
|
||||||
sessionID, role, content)
|
sessionID, role, truncateToolResults(content))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func truncateToolResults(content json.RawMessage) json.RawMessage {
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(content, &m); err != nil {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
toolCalls, ok := m["tool_calls"].([]any)
|
||||||
|
if !ok || len(toolCalls) == 0 {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
for i, raw := range toolCalls {
|
||||||
|
tc, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result, ok := tc["result"]; ok {
|
||||||
|
resultJSON, _ := json.Marshal(result)
|
||||||
|
if len(resultJSON) > maxToolResultSize {
|
||||||
|
tc["result"] = string(resultJSON[:maxToolResultSize]) + fmt.Sprintf("...truncated (%d bytes total)", len(resultJSON))
|
||||||
|
toolCalls[i] = tc
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
m["tool_calls"] = toolCalls
|
||||||
|
out, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (s *store) touchSession(ctx context.Context, id string) {
|
func (s *store) touchSession(ctx context.Context, id string) {
|
||||||
if s != nil {
|
if s != nil {
|
||||||
s.pool.Exec(ctx, `UPDATE agent_sessions SET last_active_at=now() WHERE id=$1`, id)
|
s.pool.Exec(ctx, `UPDATE agent_sessions SET last_active_at=now() WHERE id=$1`, id)
|
||||||
@@ -126,6 +163,26 @@ func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, e
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *store) deleteSession(ctx context.Context, id string) error {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.pool.Exec(ctx, `DELETE FROM agent_messages WHERE session_id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.pool.Exec(ctx, `DELETE FROM agent_sessions WHERE id = $1`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) updateSessionTitle(ctx context.Context, id, title string) error {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.pool.Exec(ctx, `UPDATE agent_sessions SET title = $1 WHERE id = $2`, title, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// resolveAgentID looks up the UUID of the agent entity (e.g. "agent:nomos").
|
// resolveAgentID looks up the UUID of the agent entity (e.g. "agent:nomos").
|
||||||
// Returns uuid.Nil if the store is absent or the slug is unknown.
|
// Returns uuid.Nil if the store is absent or the slug is unknown.
|
||||||
func (s *store) resolveAgentID(ctx context.Context, slug string) uuid.UUID {
|
func (s *store) resolveAgentID(ctx context.Context, slug string) uuid.UUID {
|
||||||
|
|||||||
@@ -698,6 +698,8 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
COALESCE(st.last_check_at::text, '') AS last_check
|
COALESCE(st.last_check_at::text, '') AS last_check
|
||||||
FROM entities e
|
FROM entities e
|
||||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||||
|
WHERE e.state IS NOT NULL
|
||||||
|
OR st.health IS NOT NULL
|
||||||
ORDER BY st.health, e.slug
|
ORDER BY st.health, e.slug
|
||||||
LIMIT 200
|
LIMIT 200
|
||||||
`), nil
|
`), nil
|
||||||
|
|||||||
@@ -23,14 +23,30 @@ the actuator (a separate container with restricted SSH key) picks up.
|
|||||||
|
|
||||||
## Key MCP tools
|
## Key MCP tools
|
||||||
|
|
||||||
- `get_entity`, `list_entities` — resolve slugs to state
|
- `list_lxcs` — all LXC containers with host, IP, health (use for fleet-wide questions)
|
||||||
|
- `get_lxc_state` — per-container `pct status` (use only for a specific named container)
|
||||||
|
- `get_state_snapshot` — fleet health, disk, drift at a glance
|
||||||
|
- `get_health_summary` — fleet health counts
|
||||||
|
- `query_metrics` — time-series metrics (prefer over per-entity `get_trend` for fleet-wide)
|
||||||
|
- `list_entities` — resolve slugs to state (pass `type` filter when possible)
|
||||||
|
- `get_entity` — single-entity detail
|
||||||
- `get_blast_radius` — understand impact before requesting action
|
- `get_blast_radius` — understand impact before requesting action
|
||||||
- `get_health_summary` — fleet status at a glance
|
|
||||||
- `get_signal_history` — open alerts
|
- `get_signal_history` — open alerts
|
||||||
- `get_trend` — metric trends for decisions
|
- `get_trend` — metric trends for a specific entity (single-entity only)
|
||||||
- `request_execution` — the ONLY mutation path
|
- `request_execution` — the ONLY mutation path
|
||||||
- `get_agent_activity` — your own behavior log
|
- `get_agent_activity` — your own behavior log
|
||||||
|
|
||||||
|
### Tool selection rules
|
||||||
|
|
||||||
|
- **Fleet-wide questions** (e.g. "which hosts are saturated?", "what needs updating?"):
|
||||||
|
prefer bulk tools: `list_lxcs`, `get_health_summary`, `get_state_snapshot`,
|
||||||
|
`query_metrics`. Only fall back to per-entity tools (`get_lxc_state`, `tail_log`,
|
||||||
|
`get_trend`) for a specific named entity the user asked about.
|
||||||
|
- **One call > many calls**: each `get_lxc_state` is a live SSH round-trip.
|
||||||
|
`list_lxcs` answers the same question in one call. Use it.
|
||||||
|
- When a bulk tool's summary isn't enough for a specific entity, call the
|
||||||
|
per-entity tool for that one entity — not for every entity in the fleet.
|
||||||
|
|
||||||
## Policy awareness
|
## Policy awareness
|
||||||
|
|
||||||
Before calling `request_execution`:
|
Before calling `request_execution`:
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ export async function fetchMessages(sessionId: string): Promise<Message[]> {
|
|||||||
return data.messages ?? []
|
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 {
|
export interface ChatEvent {
|
||||||
type: string
|
type: string
|
||||||
data: any
|
data: any
|
||||||
|
|||||||
@@ -1,20 +1,39 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
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 { relativeTime } from '$lib/utils'
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||||
import PlusIcon from '@lucide/svelte/icons/plus'
|
import PlusIcon from '@lucide/svelte/icons/plus'
|
||||||
|
import Trash2Icon from '@lucide/svelte/icons/trash2'
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadSessions()
|
loadSessions()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Pick up sessions created/renamed elsewhere (e.g. after a turn completes).
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void $currentSession
|
void $currentSession
|
||||||
loadSessions()
|
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>
|
</script>
|
||||||
|
|
||||||
<aside class="flex h-full w-56 shrink-0 flex-col gap-2 overflow-y-auto border-r bg-card/50 p-2">
|
<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)}
|
{#each $sessions as session (session.id)}
|
||||||
<button
|
<button
|
||||||
type="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'}"
|
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={() => loadSessionMessages(session.id)}
|
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>
|
<span class="text-[11px] text-muted-foreground">{relativeTime(session.last_active_at)}</span>
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { writable, get } from 'svelte/store'
|
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'
|
import type { ChatEvent, Session, Message } from '$lib/api'
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
@@ -177,3 +177,12 @@ export function cancelStream() {
|
|||||||
streaming.set(false)
|
streaming.set(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteSession(sessionId: string) {
|
||||||
|
const ok = await apiDeleteSession(sessionId)
|
||||||
|
if (!ok) return
|
||||||
|
if (get(currentSession) === sessionId) {
|
||||||
|
newChat()
|
||||||
|
}
|
||||||
|
loadSessions()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,29 +1,59 @@
|
|||||||
<script lang="ts">
|
<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 { onMount } from 'svelte'
|
||||||
|
import Trash2Icon from '@lucide/svelte/icons/trash2'
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadSessions()
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="sessions-page">
|
<div class="sessions-page">
|
||||||
<h2>Sessions</h2>
|
<h2>Sessions</h2>
|
||||||
<div class="session-list">
|
<div class="session-list">
|
||||||
{#each $sessions as session (session.id)}
|
{#each $sessions as session (session.id)}
|
||||||
<button
|
<div
|
||||||
class="session-card"
|
class="session-card group"
|
||||||
class:active={$currentSession === session.id}
|
class:active={$currentSession === session.id}
|
||||||
onclick={() => {
|
|
||||||
loadSessionMessages(session.id)
|
|
||||||
location.hash = '#/chat'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div class="session-title">{session.title || 'Untitled'}</div>
|
<button
|
||||||
<div class="session-meta">
|
class="session-content"
|
||||||
{new Date(session.last_active_at).toLocaleString()}
|
onclick={() => {
|
||||||
</div>
|
loadSessionMessages(session.id)
|
||||||
</button>
|
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}
|
{:else}
|
||||||
<div class="empty">No sessions yet. Start chatting with Nomos.</div>
|
<div class="empty">No sessions yet. Start chatting with Nomos.</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -54,15 +84,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
text-align: left;
|
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +102,58 @@
|
|||||||
background: var(--bg-hover);
|
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 {
|
.session-title {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user