Compare commits
3 Commits
aa6017e0ca
...
claude/cha
| Author | SHA1 | Date | |
|---|---|---|---|
| 614c38ea7c | |||
| 22412d2fa3 | |||
| 5686b9de40 |
160
plans/2026-07-09-chat-sessions-improvements.md
Normal file
160
plans/2026-07-09-chat-sessions-improvements.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# 2026-07-09 — Chat sessions: reliability, cost, and session-management fixes
|
||||||
|
|
||||||
|
**Status:** Planned
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Fix concrete problems found by inspecting the *actual* production Nomos chat
|
||||||
|
data (`agent_sessions`/`agent_messages` on the `oikos` Postgres, 24 sessions /
|
||||||
|
52 messages as of 2026-07-09), not a code-review of the UI in the abstract.
|
||||||
|
Findings below are backed by real rows, not hypotheticals.
|
||||||
|
|
||||||
|
## How this was investigated
|
||||||
|
|
||||||
|
Queried `oikos-postgres-1` directly (`docker exec oikos-postgres-1 psql -U oikos
|
||||||
|
-d oikos`) since this runs on mac-mini, the same host as the production
|
||||||
|
containers. Pulled session list, per-message content sizes, tool-call
|
||||||
|
breakdowns, and cross-checked against `cmd/nomos/agent.go` to explain what was
|
||||||
|
observed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### 1. ~17% of turns come back completely empty, silently
|
||||||
|
|
||||||
|
4 of 24 sessions ("hi" ×3, "what services are healthy?") have an assistant
|
||||||
|
message with `text=""` and zero tool calls — the model returned a blank
|
||||||
|
completion. `cmd/nomos/agent.go:175-183` treats `len(msg.ToolCalls) == 0` as a
|
||||||
|
normal final answer and emits `text: ""` + `done`. No `error` event fires, so
|
||||||
|
[chat.ts](../web/src/lib/stores/chat.ts) never sets `$error`, and
|
||||||
|
[Chat.svelte](../web/src/pages/Chat.svelte) renders a permanently-blank
|
||||||
|
assistant bubble (the "…" typing dots only show while `$streaming` is true;
|
||||||
|
once `done` fires they vanish, leaving nothing). The user has no idea the turn
|
||||||
|
failed and no obvious way to retry — they have to notice the silence and
|
||||||
|
retype.
|
||||||
|
|
||||||
|
**Fix:** in `agent.chat()`, if `msg.Content == "" && len(msg.ToolCalls) == 0`,
|
||||||
|
treat it as a retryable failure: log it, retry once against the provider
|
||||||
|
before giving up, and if still empty, emit a real `error` event instead of an
|
||||||
|
empty `text`/`done` pair. On the frontend, surface a "Nomos didn't respond —
|
||||||
|
retry?" affordance on empty assistant messages rather than a silent blank
|
||||||
|
bubble.
|
||||||
|
|
||||||
|
### 2. A tool-heavy turn came back as a canned non-English refusal
|
||||||
|
|
||||||
|
The session "what are the termals of hubris?" ran 22 tool calls and then
|
||||||
|
returned, verbatim: `关于这个问题,我没有相关信息,您可以尝试问我其它问题,我会尽力为您解答~`
|
||||||
|
("I don't have relevant information on this, try asking me something else").
|
||||||
|
This is after successfully gathering data via tools — the model discarded its
|
||||||
|
own tool results and emitted a boilerplate deflection in the wrong language.
|
||||||
|
`NOMOS_MODEL` is currently a DeepSeek flash-tier model on OpenRouter, which is
|
||||||
|
consistent with this kind of degraded-tier fallback text leaking through.
|
||||||
|
|
||||||
|
**Fix:** add a response-quality guard in `agent.chat()` — if the final text
|
||||||
|
doesn't match the conversation's language/looks like a canned refusal (simple
|
||||||
|
heuristic: non-ASCII-majority reply to an ASCII-majority conversation, or
|
||||||
|
matches a small denylist of known refusal boilerplate), treat it like the
|
||||||
|
empty-response case (retry, then surface an error rather than showing it to
|
||||||
|
the operator as a real answer). Separately, reconsider whether the flash-tier
|
||||||
|
model is worth the latency/cost tradeoff given it's producing failures like
|
||||||
|
this in a small sample — worth an eval pass against a couple of alternative
|
||||||
|
OpenRouter models on the same 24 real prompts before deciding.
|
||||||
|
|
||||||
|
### 3. Simple fleet questions fan out into dozens of individual tool calls
|
||||||
|
|
||||||
|
"Are any of the proxmox hosts saturated?" (2 hosts) triggered **70 tool
|
||||||
|
calls** in one turn, 21 of them individual `get_lxc_state` calls — one per LXC
|
||||||
|
container — instead of using the already-available `list_lxcs()` bulk tool.
|
||||||
|
Similar pattern in "What should be updated with high priority?" (68 calls) and
|
||||||
|
"What needs updating?" (54 calls). Each `get_lxc_state` is a live `pct status`
|
||||||
|
SSH round-trip to the Proxmox host, so this is 21 sequential SSH round trips
|
||||||
|
to answer a question `list_lxcs()` already answers in one call. This is the
|
||||||
|
direct cause of both slow responses and the huge persisted payloads in
|
||||||
|
finding 4.
|
||||||
|
|
||||||
|
**Fix:** two angles, not mutually exclusive:
|
||||||
|
- **Prompt-level**: tighten the Nomos system prompt (`nomos/SOUL.md`) to
|
||||||
|
explicitly prefer bulk tools (`list_lxcs`, `get_state_snapshot`,
|
||||||
|
`query_metrics`) over per-entity tools when the question is fleet-wide, and
|
||||||
|
only fall back to `get_lxc_state`/`tail_log` for a specific named entity.
|
||||||
|
- **Tool-level**: `get_lxc_state` already exists per-slug; consider whether
|
||||||
|
`list_lxcs()`'s summary is actually sufficient for "saturated" (CPU/mem %
|
||||||
|
per container) — if it's missing that field, that's *why* the model loops
|
||||||
|
per-container, and the real fix is enriching `list_lxcs()` rather than
|
||||||
|
prompting around the gap.
|
||||||
|
|
||||||
|
### 4. Tool results are persisted raw and unbounded, inflating messages to 100KB+
|
||||||
|
|
||||||
|
Message content sizes in `agent_messages.content` (JSONB) range up to
|
||||||
|
**106KB** for a single assistant turn. Even a plain "hi" greeting produced a
|
||||||
|
44KB message, because `get_state_snapshot()`'s full result — every entity in
|
||||||
|
the DB, including ~15 `document:containers/*` rows that are all
|
||||||
|
`state: <nil>, health: unknown` and contribute nothing — gets embedded
|
||||||
|
verbatim in the `tool_calls[].result` field and stored as-is
|
||||||
|
(`cmd/nomos/store.go:68-76` just JSON-inserts whatever the tool returned).
|
||||||
|
This bloats the DB, and every time a session is opened via
|
||||||
|
[loadSessionMessages](../web/src/lib/stores/chat.ts#L56) or the
|
||||||
|
[SessionRail](../web/src/lib/components/SessionRail.svelte)/
|
||||||
|
[Sessions](../web/src/pages/Sessions.svelte) page loads history, the browser
|
||||||
|
downloads and parses all of it just to render a collapsed tool-call summary.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Filter `get_state_snapshot()`'s result server-side (in the MCP tool, not
|
||||||
|
the agent) to drop entities with no meaningful state/health signal, or add
|
||||||
|
a `type` filter param the agent can pass.
|
||||||
|
- In `store.saveMessage`, cap persisted tool-result size (e.g. truncate to a
|
||||||
|
few KB with a `"...truncated, N bytes"` marker) — the full result already
|
||||||
|
served its purpose informing that turn's answer; historical replay
|
||||||
|
(`agent.chat()`'s history-replay loop at `agent.go:122-137`) doesn't need
|
||||||
|
the full blob, just enough for the model to know what it already checked.
|
||||||
|
|
||||||
|
### 5. No session hygiene: duplicate/typo'd titles, no delete/archive
|
||||||
|
|
||||||
|
Session titles are the raw, unprocessed first user message
|
||||||
|
(`store.createSession`), with no dedup, normalization, or cleanup. Real
|
||||||
|
production titles include **6 sessions titled "hi"**, **2 titled "say ok"**,
|
||||||
|
and typos preserved verbatim ("what are the **termals** of hubris?", "whats
|
||||||
|
the **termans** of strong", "Are any of the **proxomox** hosts saturated?").
|
||||||
|
Neither [Sessions.svelte](../web/src/pages/Sessions.svelte) nor
|
||||||
|
[SessionRail.svelte](../web/src/lib/components/SessionRail.svelte) nor the
|
||||||
|
`store`/API layer (`cmd/nomos/store.go`, `web/src/lib/api.ts`) has any delete
|
||||||
|
or archive path — grepped the whole stack, confirmed absent. Throwaway test
|
||||||
|
sessions accumulate forever with no way to clean them from the UI.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Add `DELETE /sessions/{id}` to the nomos gateway + a matching store method
|
||||||
|
and wire a delete affordance into `SessionRail`/`Sessions` (hover trash
|
||||||
|
icon, confirm on click).
|
||||||
|
- Generate titles from the assistant's actual answer once the turn completes
|
||||||
|
(or a cheap follow-up summarization call) instead of the raw first message,
|
||||||
|
so distinct "hi" sessions become distinguishable by what was actually
|
||||||
|
discussed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation order
|
||||||
|
|
||||||
|
1. **Empty-response + refusal-leak guard** (`cmd/nomos/agent.go`) — highest
|
||||||
|
user-visible impact, smallest change, no schema/API changes.
|
||||||
|
2. **Bulk-tool prompting fix** (`nomos/SOUL.md`) — cheap, directly cuts
|
||||||
|
latency and tool-call volume; re-run the same 24 real prompts against the
|
||||||
|
updated prompt to confirm `get_lxc_state` fan-out drops.
|
||||||
|
3. **Tool-result truncation on persist** (`cmd/nomos/store.go`) — bounds
|
||||||
|
future DB growth; pair with a one-off cleanup pass on the 52 existing rows
|
||||||
|
if the table needs to be shrunk immediately.
|
||||||
|
4. **`get_state_snapshot` filtering** — coordinate with whichever MCP tool
|
||||||
|
file defines it; verify with `jsonb_pretty` on a fresh "hi" session that
|
||||||
|
payload drops well below the current ~44KB.
|
||||||
|
5. **Session delete + title generation** — UI + gateway change, lowest risk,
|
||||||
|
can ship independently of 1-4.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Re-run the same 24 real user prompts (recorded in this plan's investigation)
|
||||||
|
against the patched agent; confirm zero empty/refusal-leak responses and
|
||||||
|
`get_lxc_state`-style fan-out drops to O(hosts) not O(containers).
|
||||||
|
- `docker exec oikos-postgres-1 psql -U oikos -d oikos -c "SELECT max(length(content::text)) FROM agent_messages;"`
|
||||||
|
before/after — expect the ceiling to move from ~106KB to low single-digit KB.
|
||||||
|
- Manually delete a test session via the new UI affordance, confirm it's gone
|
||||||
|
from both `SessionRail` and the `agent_sessions` table.
|
||||||
@@ -13,6 +13,7 @@ went sideways, open an investigation.
|
|||||||
| 2026-07-08 | [Oikos gaps, broken things, and improvements](2026-07-08-oikos-gaps-and-improvements.md) | Planned |
|
| 2026-07-08 | [Oikos gaps, broken things, and improvements](2026-07-08-oikos-gaps-and-improvements.md) | Planned |
|
||||||
| 2026-07-08 | [Control room web UI](2026-07-08-control-room-webui.md) | In Progress |
|
| 2026-07-08 | [Control room web UI](2026-07-08-control-room-webui.md) | In Progress |
|
||||||
| 2026-07-08 | [Nomos resident agent (renames Hermes)](2026-07-08-nomos-resident-agent.md) | In Progress |
|
| 2026-07-08 | [Nomos resident agent (renames Hermes)](2026-07-08-nomos-resident-agent.md) | In Progress |
|
||||||
|
| 2026-07-09 | [Chat sessions: reliability, cost, and session-management fixes](2026-07-09-chat-sessions-improvements.md) | Planned |
|
||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg width="91" height="100" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
<svg width="91" height="100" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path fill="#58a6ff" d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z"/>
|
<path fill="#ffffff" d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 666 B After Width: | Height: | Size: 666 B |
@@ -107,6 +107,16 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
/* Tailwind's preflight resets <button> to cursor: default; every button
|
||||||
|
and native interactive element in this app is clickable, so restore
|
||||||
|
the pointer cursor app-wide instead of annotating each one. */
|
||||||
|
button:not(:disabled),
|
||||||
|
[role='button']:not([aria-disabled='true']),
|
||||||
|
a[href],
|
||||||
|
summary,
|
||||||
|
select {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte'
|
|
||||||
import { summary, pendingApprovals, subscribeContext, refreshContext } from '$lib/stores/context'
|
|
||||||
import { liveEvents } from '$lib/stores/events'
|
|
||||||
import { decideApproval } from '$lib/api'
|
|
||||||
import { Badge } from '$lib/components/ui/badge'
|
|
||||||
import { Button } from '$lib/components/ui/button'
|
|
||||||
import { Separator } from '$lib/components/ui/separator'
|
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
|
||||||
import { toast } from 'svelte-sonner'
|
|
||||||
|
|
||||||
let deciding = $state<string | null>(null)
|
|
||||||
|
|
||||||
onMount(() => subscribeContext())
|
|
||||||
|
|
||||||
async function decide(id: string, decision: 'approve' | 'deny') {
|
|
||||||
deciding = id
|
|
||||||
const result = await decideApproval(id, decision)
|
|
||||||
deciding = null
|
|
||||||
if (result) {
|
|
||||||
toast.success(`Approval ${decision === 'approve' ? 'approved' : 'denied'}`)
|
|
||||||
refreshContext()
|
|
||||||
} else {
|
|
||||||
toast.error('Decision failed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
|
|
||||||
if (sev === 'critical') return 'destructive'
|
|
||||||
if (sev === 'warning') return 'secondary'
|
|
||||||
return 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
const recentEvents = $derived($liveEvents.slice(0, 10))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<aside class="flex h-full w-72 shrink-0 flex-col gap-3 overflow-y-auto border-l bg-card/50 p-3">
|
|
||||||
{#if $summary}
|
|
||||||
<div>
|
|
||||||
<p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Fleet health</p>
|
|
||||||
<div class="flex items-center gap-3 text-xs">
|
|
||||||
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-success"></span>{$summary.health.healthy}</span>
|
|
||||||
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-warning"></span>{$summary.health.degraded}</span>
|
|
||||||
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-destructive"></span>{$summary.health.down}</span>
|
|
||||||
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-muted-foreground"></span>{$summary.health.unknown}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="mb-1.5 flex items-center justify-between">
|
|
||||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Pending approvals</p>
|
|
||||||
{#if $pendingApprovals.length}
|
|
||||||
<Badge variant="destructive" class="h-4 px-1.5 text-[10px]">{$pendingApprovals.length}</Badge>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
{#each $pendingApprovals.slice(0, 5) as approval (approval.id)}
|
|
||||||
<div class="rounded-md border bg-background p-2">
|
|
||||||
<p class="truncate font-mono text-[11px]">{approval.subject ?? approval.slug}</p>
|
|
||||||
<p class="mb-1.5 text-xs">{approval.action} <Badge variant="outline" class="ml-1 h-4 px-1 text-[10px]">{approval.risk_class}</Badge></p>
|
|
||||||
<div class="flex gap-1.5">
|
|
||||||
<Button size="sm" class="h-6 flex-1 text-xs" disabled={deciding === approval.id} onclick={() => decide(approval.id, 'approve')}>Approve</Button>
|
|
||||||
<Button size="sm" variant="destructive" class="h-6 flex-1 text-xs" disabled={deciding === approval.id} onclick={() => decide(approval.id, 'deny')}>Deny</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-xs text-muted-foreground">Nothing waiting on you.</p>
|
|
||||||
{/each}
|
|
||||||
{#if $pendingApprovals.length > 5}
|
|
||||||
<button type="button" class="text-left text-xs text-primary hover:underline" onclick={() => (location.hash = '#/ops')}>
|
|
||||||
+{$pendingApprovals.length - 5} more in Operations
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if $summary && Object.keys($summary.signals_by_severity).length}
|
|
||||||
<Separator />
|
|
||||||
<div>
|
|
||||||
<p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Open signals</p>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
{#each Object.entries($summary.signals_by_severity) as [severity, count]}
|
|
||||||
<button type="button" onclick={() => (location.hash = '#/signals')}>
|
|
||||||
<Badge variant={severityVariant(severity)} class="cursor-pointer">{severity}: {count}</Badge>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
<div class="flex min-h-0 flex-1 flex-col">
|
|
||||||
<p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Live events</p>
|
|
||||||
<ScrollArea class="min-h-0 flex-1">
|
|
||||||
<div class="flex flex-col gap-1.5 pr-2">
|
|
||||||
{#each recentEvents as ev (ev.id)}
|
|
||||||
<div class="text-[11px] leading-tight">
|
|
||||||
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleTimeString()}</span>
|
|
||||||
<span class="ml-1 {ev.severity === 'critical' ? 'text-destructive' : ev.severity === 'warning' ? 'text-warning' : ''}">{ev.type}</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-xs text-muted-foreground">Quiet for now.</p>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
441
web/src/lib/components/SessionGraph.svelte
Normal file
441
web/src/lib/components/SessionGraph.svelte
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, untrack } from 'svelte'
|
||||||
|
import {
|
||||||
|
forceSimulation,
|
||||||
|
forceLink,
|
||||||
|
forceManyBody,
|
||||||
|
forceCenter,
|
||||||
|
forceCollide,
|
||||||
|
forceX,
|
||||||
|
forceY,
|
||||||
|
type Simulation
|
||||||
|
} from 'd3-force'
|
||||||
|
import { fetchGraph, type Entity } from '$lib/api'
|
||||||
|
import { messages } from '$lib/stores/chat'
|
||||||
|
import { relativeTime } from '$lib/utils'
|
||||||
|
import { Badge } from '$lib/components/ui/badge'
|
||||||
|
import { Button } from '$lib/components/ui/button'
|
||||||
|
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
||||||
|
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
|
||||||
|
|
||||||
|
interface Node extends Entity {
|
||||||
|
x?: number
|
||||||
|
y?: number
|
||||||
|
vx?: number
|
||||||
|
vy?: number
|
||||||
|
fx?: number | null
|
||||||
|
fy?: number | null
|
||||||
|
degree: number
|
||||||
|
}
|
||||||
|
interface Edge {
|
||||||
|
source: string | Node
|
||||||
|
target: string | Node
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe/bookkeeping entity types are excluded — a health conversation
|
||||||
|
// mentions dozens of check:… slugs that would swamp the fleet topology.
|
||||||
|
const EXCLUDED = new Set(['check', 'execution'])
|
||||||
|
|
||||||
|
// Slug shape: lowercase type prefix, then one or more colon-separated
|
||||||
|
// segments (host:hubris, check:ping:8cf, lxc:caddy, investigation:foo/bar).
|
||||||
|
const SLUG_RE = /\b[a-z][a-z-]*:[a-z0-9][a-z0-9._/-]*(?::[a-z0-9._/-]+)*/g
|
||||||
|
|
||||||
|
let nodes = $state<Node[]>([])
|
||||||
|
let links = $state<Edge[]>([])
|
||||||
|
let selected = $state<Node | null>(null)
|
||||||
|
let sheetSlug = $state<string | null>(null)
|
||||||
|
let sheetOpen = $state(false)
|
||||||
|
|
||||||
|
let sim: Simulation<Node, Edge> | null = null
|
||||||
|
|
||||||
|
// Non-reactive caches (persist across message deltas). resolvedVersion is a
|
||||||
|
// reactive counter bumped when async resolution finishes, so the reconcile
|
||||||
|
// effect re-runs once entities come back.
|
||||||
|
const resolvedCache = new Map<string, Node | null>()
|
||||||
|
const edgeCache: { source: string; target: string; type: string }[] = []
|
||||||
|
const edgeKeys = new Set<string>()
|
||||||
|
const resolving = new Set<string>()
|
||||||
|
let resolvedVersion = $state(0)
|
||||||
|
|
||||||
|
// container size drives the simulation coordinate space (1:1 with pixels so
|
||||||
|
// node dragging maps cleanly regardless of the resizable panel width).
|
||||||
|
let container = $state<HTMLDivElement | null>(null)
|
||||||
|
let cw = $state(300)
|
||||||
|
let ch = $state(300)
|
||||||
|
|
||||||
|
function collectSlugs(value: unknown, out: Set<string>) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const m = value.match(SLUG_RE)
|
||||||
|
if (m) for (const s of m) out.add(s.replace(/[.,;)\]]+$/, ''))
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
for (const v of value) collectSlugs(v, out)
|
||||||
|
} else if (value && typeof value === 'object') {
|
||||||
|
for (const v of Object.values(value)) collectSlugs(v, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only pull from what the conversation is *about*: message text and the
|
||||||
|
// arguments the agent passed to tools — never bulk result rows (a single
|
||||||
|
// get_health_summary would otherwise dump all 168 entities into the graph).
|
||||||
|
const candidateSlugs = $derived.by(() => {
|
||||||
|
const out = new Set<string>()
|
||||||
|
for (const m of $messages) {
|
||||||
|
collectSlugs(m.text, out)
|
||||||
|
for (const t of m.tools) collectSlugs(t.args, out)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
async function resolveSlugs(slugs: string[]) {
|
||||||
|
const todo = slugs.filter((s) => !resolvedCache.has(s) && !resolving.has(s))
|
||||||
|
if (!todo.length) return
|
||||||
|
for (const s of todo) resolving.add(s)
|
||||||
|
await Promise.all(
|
||||||
|
todo.map(async (s) => {
|
||||||
|
try {
|
||||||
|
const g = await fetchGraph({ root: s, depth: 1 })
|
||||||
|
const root = g?.nodes.find((n) => n.slug === s) ?? null
|
||||||
|
resolvedCache.set(s, root ? { ...root, degree: 0 } : null)
|
||||||
|
if (g && root) {
|
||||||
|
for (const e of g.edges) {
|
||||||
|
const k = `${e.source}|${e.target}|${e.type}`
|
||||||
|
if (!edgeKeys.has(k)) {
|
||||||
|
edgeKeys.add(k)
|
||||||
|
edgeCache.push({ source: e.source, target: e.target, type: e.type })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
resolvedCache.set(s, null)
|
||||||
|
} finally {
|
||||||
|
resolving.delete(s)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
resolvedVersion++
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcile(cands: Set<string>) {
|
||||||
|
const desired: Node[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const s of cands) {
|
||||||
|
const e = resolvedCache.get(s)
|
||||||
|
if (e && !EXCLUDED.has(e.type) && !seen.has(e.slug)) {
|
||||||
|
seen.add(e.slug)
|
||||||
|
desired.push(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const desiredSlugs = new Set(desired.map((e) => e.slug))
|
||||||
|
const current = nodes
|
||||||
|
const curSlugs = new Set(current.map((n) => n.slug))
|
||||||
|
|
||||||
|
let changed = desiredSlugs.size !== curSlugs.size
|
||||||
|
if (!changed) for (const s of desiredSlugs) if (!curSlugs.has(s)) { changed = true; break }
|
||||||
|
if (!changed) return
|
||||||
|
|
||||||
|
const bySlug = new Map(current.map((n) => [n.slug, n]))
|
||||||
|
const ls = edgeCache
|
||||||
|
.filter((e) => desiredSlugs.has(e.source) && desiredSlugs.has(e.target))
|
||||||
|
.map((e) => ({ ...e }))
|
||||||
|
|
||||||
|
const deg = new Map<string, number>()
|
||||||
|
for (const l of ls) {
|
||||||
|
deg.set(l.source as string, (deg.get(l.source as string) ?? 0) + 1)
|
||||||
|
deg.set(l.target as string, (deg.get(l.target as string) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = desired.map((e) => {
|
||||||
|
const p = bySlug.get(e.slug)
|
||||||
|
return { ...e, x: p?.x, y: p?.y, vx: p?.vx, vy: p?.vy, degree: deg.get(e.slug) ?? 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
nodes = next
|
||||||
|
links = ls
|
||||||
|
if (selected && !desiredSlugs.has(selected.slug)) selected = null
|
||||||
|
buildSim()
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const cands = candidateSlugs
|
||||||
|
void resolvedVersion
|
||||||
|
const missing = [...cands].filter((s) => !resolvedCache.has(s))
|
||||||
|
if (missing.length) resolveSlugs(missing)
|
||||||
|
untrack(() => reconcile(cands))
|
||||||
|
})
|
||||||
|
|
||||||
|
function buildSim() {
|
||||||
|
sim?.stop()
|
||||||
|
if (!nodes.length) {
|
||||||
|
sim = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sim = forceSimulation(nodes)
|
||||||
|
.force('link', forceLink<Node, Edge>(links).id((n) => n.slug).distance(48).strength(0.5))
|
||||||
|
.force('charge', forceManyBody().strength(-150).distanceMax(240))
|
||||||
|
.force('center', forceCenter(cw / 2, ch / 2))
|
||||||
|
.force('collide', forceCollide<Node>((n) => nodeRadius(n) + 6))
|
||||||
|
.force('x', forceX(cw / 2).strength(0.06))
|
||||||
|
.force('y', forceY(ch / 2).strength(0.06))
|
||||||
|
.velocityDecay(0.34)
|
||||||
|
.alphaDecay(0.045)
|
||||||
|
.on('tick', () => {
|
||||||
|
nodes = [...nodes]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep the layout centred as the panel resizes
|
||||||
|
$effect(() => {
|
||||||
|
const w = cw
|
||||||
|
const h = ch
|
||||||
|
if (sim) {
|
||||||
|
sim.force('center', forceCenter(w / 2, h / 2))
|
||||||
|
sim.force('x', forceX(w / 2).strength(0.06))
|
||||||
|
sim.force('y', forceY(h / 2).strength(0.06))
|
||||||
|
sim.alpha(0.3).restart()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!container) return
|
||||||
|
const ro = new ResizeObserver((entries) => {
|
||||||
|
const r = entries[0].contentRect
|
||||||
|
cw = Math.max(r.width, 1)
|
||||||
|
ch = Math.max(r.height, 1)
|
||||||
|
})
|
||||||
|
ro.observe(container)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => sim?.stop())
|
||||||
|
|
||||||
|
const healthColor: Record<string, string> = {
|
||||||
|
healthy: 'var(--success)',
|
||||||
|
degraded: 'var(--warning)',
|
||||||
|
down: 'var(--destructive)',
|
||||||
|
stale: 'var(--warning)',
|
||||||
|
unknown: 'var(--muted-foreground)'
|
||||||
|
}
|
||||||
|
function nodeColor(n: Node): string {
|
||||||
|
return n.health ? healthColor[n.health] ?? 'var(--muted-foreground)' : 'var(--muted-foreground)'
|
||||||
|
}
|
||||||
|
function nodeRadius(n: Node): number {
|
||||||
|
return 6 + Math.min(Math.sqrt(n.degree) * 1.5, 6)
|
||||||
|
}
|
||||||
|
function shortName(slug: string): string {
|
||||||
|
return slug.split(':').pop() ?? slug
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpoint(end: string | Node): Node | undefined {
|
||||||
|
return typeof end === 'object' ? end : nodes.find((n) => n.slug === end)
|
||||||
|
}
|
||||||
|
function endpointSlug(end: string | Node): string {
|
||||||
|
return typeof end === 'object' ? end.slug : end
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── drag / select ───────────────────────────────────────────────────
|
||||||
|
let dragState: { node: Node; moved: boolean } | null = null
|
||||||
|
|
||||||
|
function toLocal(clientX: number, clientY: number) {
|
||||||
|
const rect = container!.getBoundingClientRect()
|
||||||
|
return { x: clientX - rect.left, y: clientY - rect.top }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNodeDown(e: PointerEvent, node: Node) {
|
||||||
|
e.stopPropagation()
|
||||||
|
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
||||||
|
dragState = { node, moved: false }
|
||||||
|
sim?.alphaTarget(0.2).restart()
|
||||||
|
}
|
||||||
|
function onMove(e: PointerEvent) {
|
||||||
|
if (!dragState) return
|
||||||
|
const p = toLocal(e.clientX, e.clientY)
|
||||||
|
dragState.node.fx = p.x
|
||||||
|
dragState.node.fy = p.y
|
||||||
|
dragState.moved = true
|
||||||
|
nodes = [...nodes]
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
if (!dragState) return
|
||||||
|
const { node, moved } = dragState
|
||||||
|
node.fx = null
|
||||||
|
node.fy = null
|
||||||
|
sim?.alphaTarget(0)
|
||||||
|
dragState = null
|
||||||
|
if (!moved) selected = selected?.slug === node.slug ? null : node
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedRelations = $derived(
|
||||||
|
selected
|
||||||
|
? links
|
||||||
|
.filter((l) => endpointSlug(l.source) === selected!.slug || endpointSlug(l.target) === selected!.slug)
|
||||||
|
.map((l) => {
|
||||||
|
const outgoing = endpointSlug(l.source) === selected!.slug
|
||||||
|
return { dir: outgoing ? '→' : '←', type: l.type, other: outgoing ? endpointSlug(l.target) : endpointSlug(l.source) }
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
|
||||||
|
function openFull() {
|
||||||
|
if (!selected) return
|
||||||
|
sheetSlug = selected.slug
|
||||||
|
sheetOpen = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="flex h-full min-h-0 flex-col bg-card/40">
|
||||||
|
<div class="flex shrink-0 items-center justify-between border-b px-3 py-2">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Session graph</p>
|
||||||
|
{#if nodes.length}
|
||||||
|
<span class="text-[11px] text-muted-foreground">{nodes.length} {nodes.length === 1 ? 'entity' : 'entities'}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div bind:this={container} class="relative min-h-0 flex-1 overflow-hidden">
|
||||||
|
{#if nodes.length === 0}
|
||||||
|
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-4 px-6 text-center">
|
||||||
|
<svg viewBox="0 0 120 120" class="size-24 text-muted-foreground/40" fill="none">
|
||||||
|
<circle cx="60" cy="60" r="6" fill="currentColor">
|
||||||
|
<animate attributeName="opacity" values="0.4;1;0.4" dur="2.4s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
<g stroke="currentColor" stroke-width="1" opacity="0.5">
|
||||||
|
<line x1="60" y1="60" x2="26" y2="34"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3s" repeatCount="indefinite" /></line>
|
||||||
|
<line x1="60" y1="60" x2="96" y2="40"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3.4s" repeatCount="indefinite" /></line>
|
||||||
|
<line x1="60" y1="60" x2="34" y2="92"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="2.8s" repeatCount="indefinite" /></line>
|
||||||
|
<line x1="60" y1="60" x2="92" y2="90"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3.1s" repeatCount="indefinite" /></line>
|
||||||
|
</g>
|
||||||
|
<g fill="currentColor">
|
||||||
|
<circle cx="26" cy="34" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3s" repeatCount="indefinite" /></circle>
|
||||||
|
<circle cx="96" cy="40" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3.4s" repeatCount="indefinite" /></circle>
|
||||||
|
<circle cx="34" cy="92" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="2.8s" repeatCount="indefinite" /></circle>
|
||||||
|
<circle cx="92" cy="90" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3.1s" repeatCount="indefinite" /></circle>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<p class="max-w-[16rem] text-xs leading-relaxed text-muted-foreground">
|
||||||
|
Entities Nomos explores in this conversation appear here, wired up by their relationships.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
width={cw}
|
||||||
|
height={ch}
|
||||||
|
viewBox="0 0 {cw} {ch}"
|
||||||
|
class="h-full w-full touch-none select-none"
|
||||||
|
role="application"
|
||||||
|
aria-label="Session entity graph"
|
||||||
|
onpointermove={onMove}
|
||||||
|
onpointerup={onUp}
|
||||||
|
onpointercancel={onUp}
|
||||||
|
>
|
||||||
|
<g>
|
||||||
|
{#each links as link}
|
||||||
|
{@const s = endpoint(link.source)}
|
||||||
|
{@const t = endpoint(link.target)}
|
||||||
|
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null}
|
||||||
|
{@const focus = selected && (s.slug === selected.slug || t.slug === selected.slug)}
|
||||||
|
<line
|
||||||
|
x1={s.x}
|
||||||
|
y1={s.y}
|
||||||
|
x2={t.x}
|
||||||
|
y2={t.y}
|
||||||
|
stroke="var(--muted-foreground)"
|
||||||
|
stroke-width={focus ? 1.6 : 1}
|
||||||
|
opacity={selected ? (focus ? 0.7 : 0.12) : 0.35}
|
||||||
|
>
|
||||||
|
<title>{link.type}</title>
|
||||||
|
</line>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
{#each nodes as node (node.slug)}
|
||||||
|
{#if node.x != null && node.y != null}
|
||||||
|
{@const r = nodeRadius(node)}
|
||||||
|
{@const isSel = selected?.slug === node.slug}
|
||||||
|
{@const dim = selected && !isSel && !selectedRelations.some((rel) => rel.other === node.slug)}
|
||||||
|
<g
|
||||||
|
transform="translate({node.x},{node.y})"
|
||||||
|
class="cursor-pointer"
|
||||||
|
opacity={dim ? 0.35 : 1}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onpointerdown={(e) => onNodeDown(e, node)}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && (selected = node)}
|
||||||
|
>
|
||||||
|
{#if isSel}
|
||||||
|
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
|
||||||
|
{/if}
|
||||||
|
<circle r={r} fill={nodeColor(node)} stroke={isSel ? 'var(--foreground)' : 'var(--background)'} stroke-width={isSel ? 2 : 1.5} />
|
||||||
|
<text
|
||||||
|
y={r + 10}
|
||||||
|
text-anchor="middle"
|
||||||
|
font-size="9"
|
||||||
|
fill={isSel ? 'var(--foreground)' : 'var(--muted-foreground)'}
|
||||||
|
paint-order="stroke"
|
||||||
|
stroke="var(--background)"
|
||||||
|
stroke-width="2.5"
|
||||||
|
class="pointer-events-none"
|
||||||
|
>
|
||||||
|
{shortName(node.slug)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selected}
|
||||||
|
<div class="max-h-[55%] shrink-0 space-y-3 overflow-y-auto border-t p-3 text-xs">
|
||||||
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span class="font-mono text-sm font-semibold">{selected.slug}</span>
|
||||||
|
<Badge variant="outline">{selected.type}</Badge>
|
||||||
|
{#if selected.state}<Badge variant="secondary">{selected.state}</Badge>{/if}
|
||||||
|
</div>
|
||||||
|
{#if selected.health}
|
||||||
|
<div class="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<span class="size-2 rounded-full" style="background: {nodeColor(selected)}"></span>
|
||||||
|
{selected.health} · checked {relativeTime(selected.last_check_at)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selected.attributes && Object.keys(selected.attributes).length}
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 font-medium text-muted-foreground">Attributes</p>
|
||||||
|
<dl class="flex flex-col gap-1">
|
||||||
|
{#each Object.entries(selected.attributes).slice(0, 6) as [key, value]}
|
||||||
|
<div class="flex items-start justify-between gap-3 border-b pb-1 last:border-0">
|
||||||
|
<dt class="shrink-0 font-mono text-muted-foreground">{key}</dt>
|
||||||
|
<dd class="min-w-0 flex-1 truncate text-right">{typeof value === 'object' ? JSON.stringify(value) : String(value)}</dd>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedRelations.length}
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 font-medium text-muted-foreground">Relations ({selectedRelations.length})</p>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
{#each selectedRelations as rel}
|
||||||
|
<div class="flex items-center gap-1 font-mono">
|
||||||
|
<span class="text-muted-foreground">{rel.dir} {rel.type} →</span>
|
||||||
|
<button type="button" class="truncate hover:underline" onclick={() => { const n = nodes.find((x) => x.slug === rel.other); if (n) selected = n }}>
|
||||||
|
{rel.other}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" class="w-full" onclick={openFull}>
|
||||||
|
<ExternalLinkIcon class="mr-1 size-3.5" />
|
||||||
|
Full detail
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<EntitySheet slug={sheetSlug} bind:open={sheetOpen} />
|
||||||
79
web/src/lib/components/ToolCallGroup.svelte
Normal file
79
web/src/lib/components/ToolCallGroup.svelte
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ToolCallResult } from '$lib/stores/chat'
|
||||||
|
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
||||||
|
import CheckIcon from '@lucide/svelte/icons/check'
|
||||||
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||||
|
|
||||||
|
// active = this message is the one currently streaming a round of tool
|
||||||
|
// calls. The group starts open while active (so progress is visible live)
|
||||||
|
// and auto-collapses the moment that round finishes; a loaded/historical
|
||||||
|
// message is never active, so it starts collapsed. Once the effect below
|
||||||
|
// fires the one-time auto-collapse, manual toggles are left alone.
|
||||||
|
let { tools, active = false }: { tools: ToolCallResult[]; active?: boolean } = $props()
|
||||||
|
|
||||||
|
let open = $state(active)
|
||||||
|
let wasActive = active
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (wasActive && !active) {
|
||||||
|
open = false
|
||||||
|
}
|
||||||
|
wasActive = active
|
||||||
|
})
|
||||||
|
|
||||||
|
const doneCount = $derived(tools.filter((t) => t.type === 'tool_result').length)
|
||||||
|
const hasError = $derived(tools.some((t) => t.type === 'tool_result' && t.error))
|
||||||
|
const inProgress = $derived(active && doneCount < tools.length)
|
||||||
|
const names = $derived(tools.map((t) => t.name).join(', '))
|
||||||
|
|
||||||
|
function toolSummary(args: unknown): string {
|
||||||
|
if (!args || typeof args !== 'object') return ''
|
||||||
|
return Object.entries(args as Record<string, unknown>)
|
||||||
|
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
||||||
|
.join(' ')
|
||||||
|
.slice(0, 80)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if tools.length}
|
||||||
|
<details bind:open class="group w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
|
||||||
|
<summary class="flex cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50 [&::-webkit-details-marker]:hidden">
|
||||||
|
{#if inProgress}
|
||||||
|
<WrenchIcon class="size-3 shrink-0 animate-pulse text-primary" />
|
||||||
|
{:else if hasError}
|
||||||
|
<XIcon class="size-3 shrink-0 text-destructive" />
|
||||||
|
{:else}
|
||||||
|
<CheckIcon class="size-3 shrink-0 text-success" />
|
||||||
|
{/if}
|
||||||
|
<span class="font-medium">{tools.length} tool{tools.length === 1 ? '' : 's'}</span>
|
||||||
|
<span class="max-w-64 truncate font-mono text-muted-foreground">{names}</span>
|
||||||
|
<ChevronDownIcon class="size-3 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
|
||||||
|
</summary>
|
||||||
|
<div class="flex flex-col divide-y border-t">
|
||||||
|
{#each tools as tool (tool.id)}
|
||||||
|
<div class="p-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if tool.type === 'tool_result' && tool.error}
|
||||||
|
<XIcon class="size-3 shrink-0 text-destructive" />
|
||||||
|
{:else if tool.type === 'tool_result'}
|
||||||
|
<CheckIcon class="size-3 shrink-0 text-success" />
|
||||||
|
{:else}
|
||||||
|
<WrenchIcon class="size-3 shrink-0 animate-pulse text-primary" />
|
||||||
|
{/if}
|
||||||
|
<span class="font-mono font-medium">{tool.name}</span>
|
||||||
|
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 max-h-48 overflow-y-auto rounded bg-background/60 p-2">
|
||||||
|
{#if tool.args}
|
||||||
|
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{JSON.stringify(tool.args, null, 2)}</pre>
|
||||||
|
{/if}
|
||||||
|
{#if tool.type === 'tool_result'}
|
||||||
|
<pre class="mt-1 whitespace-pre-wrap break-all font-mono text-[11px] {tool.error ? 'text-destructive' : ''}">{tool.error ?? JSON.stringify(tool.result, null, 2)}</pre>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
@@ -61,7 +61,10 @@
|
|||||||
"data-slot": "sidebar-menu-button",
|
"data-slot": "sidebar-menu-button",
|
||||||
"data-sidebar": "menu-button",
|
"data-sidebar": "menu-button",
|
||||||
"data-size": size,
|
"data-size": size,
|
||||||
"data-active": isActive,
|
// Tailwind's bare `data-active:` variant matches attribute *presence*,
|
||||||
|
// not its value — omit the attribute entirely when false instead of
|
||||||
|
// rendering data-active="false" (which the variant still matches).
|
||||||
|
"data-active": isActive || undefined,
|
||||||
...restProps,
|
...restProps,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -25,7 +25,10 @@
|
|||||||
"data-slot": "sidebar-menu-sub-button",
|
"data-slot": "sidebar-menu-sub-button",
|
||||||
"data-sidebar": "menu-sub-button",
|
"data-sidebar": "menu-sub-button",
|
||||||
"data-size": size,
|
"data-size": size,
|
||||||
"data-active": isActive,
|
// Tailwind's bare `data-active:` variant matches attribute *presence*,
|
||||||
|
// not its value — omit the attribute entirely when false instead of
|
||||||
|
// rendering data-active="false" (which the variant still matches).
|
||||||
|
"data-active": isActive || undefined,
|
||||||
...restProps,
|
...restProps,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
|
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
|
||||||
import ContextRail from '$lib/components/ContextRail.svelte'
|
|
||||||
import SessionRail from '$lib/components/SessionRail.svelte'
|
import SessionRail from '$lib/components/SessionRail.svelte'
|
||||||
|
import SessionGraph from '$lib/components/SessionGraph.svelte'
|
||||||
|
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
import { Textarea } from '$lib/components/ui/textarea'
|
import { Textarea } from '$lib/components/ui/textarea'
|
||||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||||
import SquareIcon from '@lucide/svelte/icons/square'
|
import SquareIcon from '@lucide/svelte/icons/square'
|
||||||
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
@@ -17,6 +15,35 @@
|
|||||||
let input = $state('')
|
let input = $state('')
|
||||||
let messagesEnd = $state<HTMLDivElement | null>(null)
|
let messagesEnd = $state<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
// Resizable right rail (session graph). Persisted so it survives reloads.
|
||||||
|
const RAIL_MIN = 260
|
||||||
|
const RAIL_MAX = 620
|
||||||
|
function loadRailWidth(): number {
|
||||||
|
if (typeof localStorage === 'undefined') return 320
|
||||||
|
const v = Number(localStorage.getItem('oikos-rail-width'))
|
||||||
|
return v >= RAIL_MIN && v <= RAIL_MAX ? v : 320
|
||||||
|
}
|
||||||
|
let railWidth = $state(loadRailWidth())
|
||||||
|
let resizing = $state(false)
|
||||||
|
|
||||||
|
function startResize(e: PointerEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
resizing = true
|
||||||
|
const startX = e.clientX
|
||||||
|
const startW = railWidth
|
||||||
|
function move(ev: PointerEvent) {
|
||||||
|
railWidth = Math.min(RAIL_MAX, Math.max(RAIL_MIN, startW + (startX - ev.clientX)))
|
||||||
|
}
|
||||||
|
function up() {
|
||||||
|
resizing = false
|
||||||
|
localStorage.setItem('oikos-rail-width', String(railWidth))
|
||||||
|
window.removeEventListener('pointermove', move)
|
||||||
|
window.removeEventListener('pointerup', up)
|
||||||
|
}
|
||||||
|
window.addEventListener('pointermove', move)
|
||||||
|
window.addEventListener('pointerup', up)
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void $messages
|
void $messages
|
||||||
void $streaming
|
void $streaming
|
||||||
@@ -52,14 +79,6 @@
|
|||||||
if ($streaming) return
|
if ($streaming) return
|
||||||
sendMessage(q)
|
sendMessage(q)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolSummary(args: unknown): string {
|
|
||||||
if (!args || typeof args !== 'object') return ''
|
|
||||||
return Object.entries(args as Record<string, unknown>)
|
|
||||||
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
|
||||||
.join(' ')
|
|
||||||
.slice(0, 80)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full min-h-0">
|
<div class="flex h-full min-h-0">
|
||||||
@@ -87,35 +106,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each $messages as msg (msg.id)}
|
{#each $messages as msg, i (msg.id)}
|
||||||
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
|
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
|
||||||
{#if msg.role === 'user'}
|
{#if msg.role === 'user'}
|
||||||
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap">{msg.text}</div>
|
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap">{msg.text}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex w-full flex-col gap-2">
|
<div class="flex w-full flex-col gap-2">
|
||||||
{#each msg.tools as tool (tool.id)}
|
<ToolCallGroup tools={msg.tools} active={$streaming && i === $messages.length - 1} />
|
||||||
<details class="w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
|
|
||||||
<summary class="flex cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50 [&::-webkit-details-marker]:hidden">
|
|
||||||
{#if tool.type === 'tool_result' && tool.error}
|
|
||||||
<XIcon class="size-3 shrink-0 text-destructive" />
|
|
||||||
{:else if tool.type === 'tool_result'}
|
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" />
|
|
||||||
{:else}
|
|
||||||
<WrenchIcon class="size-3 shrink-0 animate-pulse text-primary" />
|
|
||||||
{/if}
|
|
||||||
<span class="font-mono font-medium">{tool.name}</span>
|
|
||||||
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>
|
|
||||||
</summary>
|
|
||||||
<div class="max-h-48 overflow-y-auto border-t bg-background/60 p-2">
|
|
||||||
{#if tool.args}
|
|
||||||
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{JSON.stringify(tool.args, null, 2)}</pre>
|
|
||||||
{/if}
|
|
||||||
{#if tool.type === 'tool_result'}
|
|
||||||
<pre class="mt-1 whitespace-pre-wrap break-all font-mono text-[11px] {tool.error ? 'text-destructive' : ''}">{tool.error ?? JSON.stringify(tool.result, null, 2)}</pre>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
{/each}
|
|
||||||
{#if msg.text}
|
{#if msg.text}
|
||||||
<div class="prose-chat max-w-none text-sm leading-relaxed">
|
<div class="prose-chat max-w-none text-sm leading-relaxed">
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||||
@@ -174,8 +171,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showRail}
|
{#if showRail}
|
||||||
<div class="hidden xl:block">
|
<div class="hidden shrink-0 xl:flex" style="width: {railWidth}px">
|
||||||
<ContextRail />
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group/rz relative w-1.5 shrink-0 cursor-col-resize touch-none"
|
||||||
|
onpointerdown={startResize}
|
||||||
|
aria-label="Resize session graph"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors {resizing
|
||||||
|
? 'bg-primary/60'
|
||||||
|
: 'bg-border group-hover/rz:bg-primary/50'}"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<SessionGraph />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user