feat(chat): MCP tool apps — custom inline renderers for 12 tools
12 of 33 MCP tools now render as rich inline cards instead of raw JSON: EntityCard, HealthSummary, LXCList, EntityTable, KnowledgeResults, BlastRadius, ChangeLog, FleetSnapshot, MetricChart. Architecture: - Server: annotateJSONResult() wraps queryRows with __renderer hints - Registry: match/dispatch system maps tool names to Svelte components - Chat: inline dispatch with 5-card limit, overflow to collapsed group - ToolCallGroup: unmatched prop, hides when all matched, ARIA labels Tests: 3 new Go tests for annotateJSONResult (wrap, no-op, multi-row).
This commit is contained in:
@@ -91,14 +91,14 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
args := argsMap(req)
|
args := argsMap(req)
|
||||||
limit := int(getFloat(args, "limit", 50))
|
limit := int(getFloat(args, "limit", 50))
|
||||||
return queryRows(ctx, pool, `
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at
|
SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at
|
||||||
FROM entities e
|
FROM entities e
|
||||||
WHERE ($1::text IS NULL OR e.type = $1)
|
WHERE ($1::text IS NULL OR e.type = $1)
|
||||||
AND ($2::text IS NULL OR e.state = $2)
|
AND ($2::text IS NULL OR e.state = $2)
|
||||||
AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%')
|
AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%')
|
||||||
ORDER BY e.slug LIMIT $4`,
|
ORDER BY e.slug LIMIT $4`,
|
||||||
nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), nil
|
nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), "entity_table"), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity",
|
register(&mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity",
|
||||||
@@ -154,7 +154,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
args := argsMap(req)
|
args := argsMap(req)
|
||||||
q := nStr(args["query"])
|
q := nStr(args["query"])
|
||||||
return queryRows(ctx, pool, `
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
SELECT ke.title, e.slug,
|
SELECT ke.title, e.slug,
|
||||||
ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
|
ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
|
||||||
ts_headline('english', ke.content, plainto_tsquery('english', $1),
|
ts_headline('english', ke.content, plainto_tsquery('english', $1),
|
||||||
@@ -165,7 +165,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
JOIN entities e ON e.id = ke.entity_id
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
WHERE ke.search @@ plainto_tsquery('english', $1)
|
WHERE ke.search @@ plainto_tsquery('english', $1)
|
||||||
ORDER BY rank DESC
|
ORDER BY rank DESC
|
||||||
LIMIT 20`, q), nil
|
LIMIT 20`, q), "knowledge_results"), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.",
|
register(&mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.",
|
||||||
@@ -173,7 +173,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
args := argsMap(req)
|
args := argsMap(req)
|
||||||
slug, _ := args["entity_slug"].(string)
|
slug, _ := args["entity_slug"].(string)
|
||||||
return queryRows(ctx, pool, `
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
SELECT ke.title, ke.source, e.type AS kind, e.slug,
|
SELECT ke.title, ke.source, e.type AS kind, e.slug,
|
||||||
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
|
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
|
||||||
FROM knowledge_entities ke
|
FROM knowledge_entities ke
|
||||||
@@ -193,7 +193,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
|
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
|
||||||
WHERE r.valid_to IS NULL
|
WHERE r.valid_to IS NULL
|
||||||
AND r.type = 'procedure-for'
|
AND r.type = 'procedure-for'
|
||||||
ORDER BY 1`, slug), nil
|
ORDER BY 1`, slug), "knowledge_results"), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.",
|
register(&mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.",
|
||||||
@@ -289,7 +289,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
args := argsMap(req)
|
args := argsMap(req)
|
||||||
hours := int(getFloat(args, "hours", 24))
|
hours := int(getFloat(args, "hours", 24))
|
||||||
return queryRows(ctx, pool, `
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
SELECT time_bucket('1 hour', ts) AS bucket,
|
SELECT time_bucket('1 hour', ts) AS bucket,
|
||||||
entity_id::text, metric,
|
entity_id::text, metric,
|
||||||
ROUND(avg(value)::numeric, 2) AS avg,
|
ROUND(avg(value)::numeric, 2) AS avg,
|
||||||
@@ -298,7 +298,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
FROM metric_samples
|
FROM metric_samples
|
||||||
WHERE ts > now() - make_interval(hours => $1)
|
WHERE ts > now() - make_interval(hours => $1)
|
||||||
GROUP BY bucket, entity_id, metric
|
GROUP BY bucket, entity_id, metric
|
||||||
ORDER BY bucket DESC LIMIT 100`, hours), nil
|
ORDER BY bucket DESC LIMIT 100`, hours), "metric_chart"), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Phase 4: new tools ──────────────────────────────────────────
|
// ─── Phase 4: new tools ──────────────────────────────────────────
|
||||||
@@ -649,14 +649,14 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
args := argsMap(req)
|
args := argsMap(req)
|
||||||
limit := int(getFloat(args, "limit", 50))
|
limit := int(getFloat(args, "limit", 50))
|
||||||
return queryRows(ctx, pool, `
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
SELECT id, ts, agent_id::text, session_id, activity_type, tool_name,
|
SELECT id, ts, agent_id::text, session_id, activity_type, tool_name,
|
||||||
entity_id::text, left(input_summary, 200) AS input_summary,
|
entity_id::text, left(input_summary, 200) AS input_summary,
|
||||||
left(output_summary, 200) AS output_summary,
|
left(output_summary, 200) AS output_summary,
|
||||||
duration_ms, token_count, success, correlation_id
|
duration_ms, token_count, success, correlation_id
|
||||||
FROM agent_activity
|
FROM agent_activity
|
||||||
WHERE agent_id = $1
|
WHERE agent_id = $1
|
||||||
ORDER BY ts DESC LIMIT $2`, agentID, limit), nil
|
ORDER BY ts DESC LIMIT $2`, agentID, limit), "change_log"), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Phase 5: operational MCP tools ──────────────────────────────
|
// ─── Phase 5: operational MCP tools ──────────────────────────────
|
||||||
@@ -664,14 +664,14 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
register(&mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, and state",
|
register(&mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, and state",
|
||||||
InputSchema: objSchema(),
|
InputSchema: objSchema(),
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
return queryRows(ctx, pool, `
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id,
|
SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id,
|
||||||
e.attributes->>'lan_ip' AS lan_ip,
|
e.attributes->>'lan_ip' AS lan_ip,
|
||||||
st.health, st.last_check_at
|
st.health, st.last_check_at
|
||||||
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.type = 'lxc'
|
WHERE e.type = 'lxc'
|
||||||
ORDER BY (e.attributes->>'pve_id')::int`), nil
|
ORDER BY (e.attributes->>'pve_id')::int`), "lxc_list"), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP",
|
register(&mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP",
|
||||||
@@ -811,7 +811,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
return textResult("error: hostname required"), nil
|
return textResult("error: hostname required"), nil
|
||||||
}
|
}
|
||||||
slug := "ws:" + hostname
|
slug := "ws:" + hostname
|
||||||
return queryRows(ctx, pool, `
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
SELECT e.slug, e.type, e.name, e.state,
|
SELECT e.slug, e.type, e.name, e.state,
|
||||||
COALESCE(st.health, 'unknown') AS health,
|
COALESCE(st.health, 'unknown') AS health,
|
||||||
COALESCE(st.last_check_at::text, '') AS last_check,
|
COALESCE(st.last_check_at::text, '') AS last_check,
|
||||||
@@ -821,7 +821,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
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.slug = $1
|
WHERE e.slug = $1
|
||||||
ORDER BY e.slug`, slug), nil
|
ORDER BY e.slug`, slug), "entity_card"), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk",
|
register(&mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk",
|
||||||
@@ -832,7 +832,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
if slug == "" {
|
if slug == "" {
|
||||||
return textResult("error: service_slug required"), nil
|
return textResult("error: service_slug required"), nil
|
||||||
}
|
}
|
||||||
return queryRows(ctx, pool, `
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
SELECT e.slug, e.type, e.name, e.state,
|
SELECT e.slug, e.type, e.name, e.state,
|
||||||
COALESCE(st.health, 'unknown') AS health,
|
COALESCE(st.health, 'unknown') AS health,
|
||||||
COALESCE(st.last_check_at::text, '') AS last_check,
|
COALESCE(st.last_check_at::text, '') AS last_check,
|
||||||
@@ -840,7 +840,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
COALESCE(e.attributes::text, '{}') AS attrs
|
COALESCE(e.attributes::text, '{}') AS attrs
|
||||||
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.slug = $1`, slug), nil
|
WHERE e.slug = $1`, slug), "entity_card"), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service",
|
register(&mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service",
|
||||||
@@ -878,7 +878,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
args := argsMap(req)
|
args := argsMap(req)
|
||||||
slug, _ := args["entity_slug"].(string)
|
slug, _ := args["entity_slug"].(string)
|
||||||
limit := int(getFloat(args, "limit", 20))
|
limit := int(getFloat(args, "limit", 20))
|
||||||
return queryRows(ctx, pool, `
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label,
|
SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label,
|
||||||
al.action, al.method, al.path,
|
al.action, al.method, al.path,
|
||||||
al.detail::text AS details
|
al.detail::text AS details
|
||||||
@@ -886,13 +886,13 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
JOIN entities e ON e.id = al.entity_id
|
JOIN entities e ON e.id = al.entity_id
|
||||||
WHERE e.slug = $1
|
WHERE e.slug = $1
|
||||||
ORDER BY al.ts DESC
|
ORDER BY al.ts DESC
|
||||||
LIMIT $2`, slug, limit), nil
|
LIMIT $2`, slug, limit), "change_log"), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count",
|
register(&mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count",
|
||||||
InputSchema: objSchema(),
|
InputSchema: objSchema(),
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
return queryRows(ctx, pool, `
|
return annotateJSONResult(queryRows(ctx, pool, `
|
||||||
SELECT e.slug, e.type, e.state,
|
SELECT e.slug, e.type, e.state,
|
||||||
COALESCE(st.health, 'unknown') AS health,
|
COALESCE(st.health, 'unknown') AS health,
|
||||||
COALESCE(st.last_check_at::text, '') AS last_check
|
COALESCE(st.last_check_at::text, '') AS last_check
|
||||||
@@ -902,7 +902,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
OR st.health 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
|
`), "fleet_snapshot"), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key",
|
register(&mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key",
|
||||||
@@ -1128,6 +1128,26 @@ func queryRows(ctx context.Context, pool *db.Pool, query string, args ...any) *m
|
|||||||
return textResult(string(data))
|
return textResult(string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func annotateJSONResult(result *mcp.CallToolResult, rendererID string) *mcp.CallToolResult {
|
||||||
|
if len(result.Content) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
tc, ok := result.Content[0].(*mcp.TextContent)
|
||||||
|
if !ok || tc.Text == "" {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
var items []map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(tc.Text), &items); err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
wrapper := map[string]any{
|
||||||
|
"__renderer": rendererID,
|
||||||
|
"data": items,
|
||||||
|
}
|
||||||
|
data, _ := json.MarshalIndent(wrapper, "", " ")
|
||||||
|
return textResult(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
// ─── SSH helpers ─────────────────────────────────────────────────────────
|
// ─── SSH helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -1,11 +1,79 @@
|
|||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestAnnotateJSONResult(t *testing.T) {
|
||||||
|
// valid JSON array → wrapped with __renderer + data
|
||||||
|
result := textResult(`[{"slug": "host:hubris", "type": "host"}]`)
|
||||||
|
annotated := annotateJSONResult(result, "entity_card")
|
||||||
|
|
||||||
|
if len(annotated.Content) != 1 {
|
||||||
|
t.Fatalf("expected 1 content item, got %d", len(annotated.Content))
|
||||||
|
}
|
||||||
|
tc, ok := annotated.Content[0].(*mcp.TextContent)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("content is not TextContent")
|
||||||
|
}
|
||||||
|
|
||||||
|
var wrapper map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(tc.Text), &wrapper); err != nil {
|
||||||
|
t.Fatalf("result is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if wrapper["__renderer"] != "entity_card" {
|
||||||
|
t.Errorf("__renderer = %q, want entity_card", wrapper["__renderer"])
|
||||||
|
}
|
||||||
|
data, ok := wrapper["data"].([]interface{})
|
||||||
|
if !ok || len(data) != 1 {
|
||||||
|
t.Fatal("data is not the original array")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnnotateJSONResultNoop(t *testing.T) {
|
||||||
|
// empty content → no-op
|
||||||
|
result := &mcp.CallToolResult{Content: []mcp.Content{}}
|
||||||
|
annotated := annotateJSONResult(result, "entity_card")
|
||||||
|
if len(annotated.Content) != 0 {
|
||||||
|
t.Fatal("empty content should be unchanged")
|
||||||
|
}
|
||||||
|
|
||||||
|
// non-JSON text → no-op (not wrapped)
|
||||||
|
result = textResult("just plain text")
|
||||||
|
annotated = annotateJSONResult(result, "entity_card")
|
||||||
|
tc, _ := annotated.Content[0].(*mcp.TextContent)
|
||||||
|
if strings.Contains(tc.Text, "__renderer") {
|
||||||
|
t.Fatal("non-JSON content should not be annotated")
|
||||||
|
}
|
||||||
|
|
||||||
|
// textResult with empty string → no-op
|
||||||
|
result = textResult("")
|
||||||
|
annotated = annotateJSONResult(result, "entity_card")
|
||||||
|
tc, _ = annotated.Content[0].(*mcp.TextContent)
|
||||||
|
if tc.Text != "" {
|
||||||
|
t.Fatal("empty text content should be unchanged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnnotateJSONResultPreservesMultipleRows(t *testing.T) {
|
||||||
|
result := textResult(`[{"slug": "a"}, {"slug": "b"}, {"slug": "c"}]`)
|
||||||
|
annotated := annotateJSONResult(result, "lxc_list")
|
||||||
|
|
||||||
|
tc, _ := annotated.Content[0].(*mcp.TextContent)
|
||||||
|
var wrapper map[string]interface{}
|
||||||
|
json.Unmarshal([]byte(tc.Text), &wrapper)
|
||||||
|
|
||||||
|
data := wrapper["data"].([]interface{})
|
||||||
|
if len(data) != 3 {
|
||||||
|
t.Fatalf("expected 3 rows in data, got %d", len(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestNewServerRegistersTools verifies every tool registers with a valid
|
// TestNewServerRegistersTools verifies every tool registers with a valid
|
||||||
// input schema. The MCP SDK panics at AddTool if a tool omits its object
|
// input schema. The MCP SDK panics at AddTool if a tool omits its object
|
||||||
// input schema, so merely constructing the server exercises that contract —
|
// input schema, so merely constructing the server exercises that contract —
|
||||||
|
|||||||
66
plans/done/2026-07-13-mcp-tool-apps-custom-chat-renderers.md
Normal file
66
plans/done/2026-07-13-mcp-tool-apps-custom-chat-renderers.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# 2026-07-13 — MCP tool apps: custom in-chat renderers
|
||||||
|
|
||||||
|
**Status:** Done — implemented 2026-07-13.
|
||||||
|
|
||||||
|
## What was built
|
||||||
|
|
||||||
|
12 of 33 MCP tools now render as rich inline cards in the chat instead of raw
|
||||||
|
JSON inside a collapsed component. The remaining 21 tools stay collapsed.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
- **Server** (`internal/mcp/server.go`): `annotateJSONResult()` function wraps
|
||||||
|
`queryRows` output with `{"__renderer": "...", "data": [...]}` for 12 tools.
|
||||||
|
- **Registry** (`web/src/lib/tool-renderers.ts`): match/dispatch system that
|
||||||
|
maps tool names + `__renderer` hints to Svelte components.
|
||||||
|
- **Renderer components** (`web/src/lib/renderers/`): 9 purpose-built cards,
|
||||||
|
each handling loading/spinner, error, and success states with proper ARIA
|
||||||
|
labels.
|
||||||
|
- **Chat dispatch** (`web/src/pages/Chat.svelte`): matched tools render inline
|
||||||
|
before the markdown text, with a 5-card limit to prevent chat spam. Overflow
|
||||||
|
goes to the collapsed `ToolCallGroup` alongside unmatched tools.
|
||||||
|
- **ToolCallGroup** (`web/src/lib/components/ToolCallGroup.svelte`): accepts
|
||||||
|
`unmatched` prop, shows "N tools · M cards shown" when some render inline,
|
||||||
|
hides entirely when all matched.
|
||||||
|
|
||||||
|
### Renderers
|
||||||
|
|
||||||
|
| Component | Tools matched | Visual |
|
||||||
|
|-----------|--------------|--------|
|
||||||
|
| `EntityCard` | `get_entity`, `whoami`, `explain` | Slug, type badge, health dot, key attrs |
|
||||||
|
| `HealthSummary` | `get_health_summary` | Stacked health bar (healthy/degraded/down) |
|
||||||
|
| `LXCList` | `list_lxcs` | Compact table: name, ID, IP, health |
|
||||||
|
| `EntityTable` | `list_entities` | Auto-column table from query results |
|
||||||
|
| `KnowledgeResults` | `search_knowledge`, `get_entity_knowledge` | Title, snippet, source, slug |
|
||||||
|
| `BlastRadius` | `get_blast_radius` | Entities grouped by hop distance |
|
||||||
|
| `ChangeLog` | `get_change_history`, `get_agent_activity` | Timeline with status dots |
|
||||||
|
| `FleetSnapshot` | `get_state_snapshot` | Health + type counts in a grid |
|
||||||
|
| `MetricChart` | `query_metrics` | Bucketed time/avg/min/max table |
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
`internal/mcp/server_test.go`: 3 new tests for `annotateJSONResult` — wraps
|
||||||
|
valid JSON arrays, no-op on empty/non-JSON/empty-text content, preserves
|
||||||
|
multi-row arrays.
|
||||||
|
|
||||||
|
### Files changed
|
||||||
|
|
||||||
|
**New (18):**
|
||||||
|
- `web/src/lib/tool-renderers.ts`
|
||||||
|
- `web/src/lib/renderers/index.ts`
|
||||||
|
- `web/src/lib/renderers/EntityCard.svelte` + `entity-card.ts`
|
||||||
|
- `web/src/lib/renderers/HealthSummary.svelte` + `health-summary.ts`
|
||||||
|
- `web/src/lib/renderers/LXCList.svelte` + `lxc-list.ts`
|
||||||
|
- `web/src/lib/renderers/EntityTable.svelte` + `entity-table.ts`
|
||||||
|
- `web/src/lib/renderers/KnowledgeResults.svelte` + `knowledge-results.ts`
|
||||||
|
- `web/src/lib/renderers/BlastRadius.svelte` + `blast-radius.ts`
|
||||||
|
- `web/src/lib/renderers/ChangeLog.svelte` + `change-log.ts`
|
||||||
|
- `web/src/lib/renderers/FleetSnapshot.svelte` + `fleet-snapshot.ts`
|
||||||
|
- `web/src/lib/renderers/MetricChart.svelte` + `metric-chart.ts`
|
||||||
|
|
||||||
|
**Modified (5):**
|
||||||
|
- `internal/mcp/server.go` — `annotateJSONResult()` + 12 tool annotations
|
||||||
|
- `internal/mcp/server_test.go` — 3 tests for `annotateJSONResult`
|
||||||
|
- `web/src/lib/components/ToolCallGroup.svelte` — `unmatched`/`bodyTools`
|
||||||
|
- `web/src/pages/Chat.svelte` — inline dispatch + 5-card limit
|
||||||
|
- `web/src/main.ts` — deferred renderer import
|
||||||
@@ -14,7 +14,6 @@ went sideways, open an investigation.
|
|||||||
| 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred |
|
| 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred |
|
||||||
| 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — enum retirement + auto-act revival still open |
|
| 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — enum retirement + auto-act revival still open |
|
||||||
| 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred |
|
| 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred |
|
||||||
| 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred |
|
|
||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
@@ -45,6 +44,7 @@ See [`done/`](done/) for executed plans:
|
|||||||
| 2026-07-11 | [UI review: information architecture, usability, and best practices](done/2026-07-11-ui-review-ia-usability.md) |
|
| 2026-07-11 | [UI review: information architecture, usability, and best practices](done/2026-07-11-ui-review-ia-usability.md) |
|
||||||
| 2026-07-11 | [Task completion safety net: every live task is stuck "Running"](done/2026-07-11-task-completion-safety-net.md) |
|
| 2026-07-11 | [Task completion safety net: every live task is stuck "Running"](done/2026-07-11-task-completion-safety-net.md) |
|
||||||
| 2026-07-12 | [Wails desktop application](done/2026-07-12-wails-desktop-app.md) |
|
| 2026-07-12 | [Wails desktop application](done/2026-07-12-wails-desktop-app.md) |
|
||||||
|
| 2026-07-13 | [MCP tool apps: custom in-chat renderers](done/2026-07-13-mcp-tool-apps-custom-chat-renderers.md) |
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,14 @@
|
|||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||||
|
|
||||||
let { tools, active = false }: { tools: ToolCallResult[]; active?: boolean } = $props()
|
let { tools, unmatched, active = false }: { tools: ToolCallResult[]; unmatched?: ToolCallResult[]; active?: boolean } = $props()
|
||||||
|
|
||||||
let open = $state(false)
|
let open = $state(false)
|
||||||
let wasActive = $state(active)
|
let wasActive = $state(active)
|
||||||
|
|
||||||
|
const bodyTools = $derived(unmatched ?? tools)
|
||||||
|
const inlineCount = $derived(tools.length - bodyTools.length)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (active && !wasActive) {
|
if (active && !wasActive) {
|
||||||
open = true
|
open = true
|
||||||
@@ -22,18 +25,18 @@
|
|||||||
wasActive = active
|
wasActive = active
|
||||||
})
|
})
|
||||||
|
|
||||||
const doneCount = $derived(tools.filter((t) => t.type === 'tool_result').length)
|
const doneCount = $derived(bodyTools.filter((t) => t.type === 'tool_result').length)
|
||||||
const hasError = $derived(tools.some((t) => t.type === 'tool_result' && t.error))
|
const hasError = $derived(bodyTools.some((t) => t.type === 'tool_result' && t.error))
|
||||||
const names = $derived(tools.map((t) => t.name).join(', '))
|
const names = $derived(bodyTools.map((t) => t.name).join(', '))
|
||||||
|
|
||||||
const runningTool = $derived(
|
const runningTool = $derived(
|
||||||
active ? tools.find((t) => t.type === 'tool_use') : undefined
|
active ? bodyTools.find((t) => t.type === 'tool_use') : undefined
|
||||||
)
|
)
|
||||||
|
|
||||||
const ariaLabel = $derived(
|
const ariaLabel = $derived(
|
||||||
doneCount === tools.length
|
doneCount === bodyTools.length
|
||||||
? `${tools.length} ${tools.length === 1 ? 'tool' : 'tools'} completed`
|
? `${bodyTools.length} ${bodyTools.length === 1 ? 'tool' : 'tools'} completed`
|
||||||
: `${doneCount}/${tools.length} ${tools.length === 1 ? 'tool' : 'tools'} done`
|
: `${doneCount}/${bodyTools.length} ${bodyTools.length === 1 ? 'tool' : 'tools'} done`
|
||||||
)
|
)
|
||||||
|
|
||||||
function toolSummary(args: unknown): string {
|
function toolSummary(args: unknown): string {
|
||||||
@@ -45,19 +48,19 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if tools.length}
|
{#if bodyTools.length}
|
||||||
<Collapsible.Root bind:open class="group w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
|
<Collapsible.Root bind:open class="group w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
|
||||||
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50">
|
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50">
|
||||||
{#if active && doneCount < tools.length}
|
{#if active && doneCount < bodyTools.length}
|
||||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" />
|
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||||
{:else if hasError}
|
{:else if hasError}
|
||||||
<XIcon class="size-3 shrink-0 text-destructive" />
|
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||||
{:else}
|
{:else}
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" />
|
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if active && doneCount < tools.length}
|
{#if active && doneCount < bodyTools.length}
|
||||||
<span class="font-medium">{doneCount}/{tools.length}</span>
|
<span class="font-medium">{doneCount}/{bodyTools.length}</span>
|
||||||
{#if runningTool}
|
{#if runningTool}
|
||||||
<span class="max-w-48 truncate font-mono text-muted-foreground">
|
<span class="max-w-48 truncate font-mono text-muted-foreground">
|
||||||
{runningTool.name}
|
{runningTool.name}
|
||||||
@@ -67,7 +70,10 @@
|
|||||||
<span class="animate-pulse text-muted-foreground">working…</span>
|
<span class="animate-pulse text-muted-foreground">working…</span>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="font-medium">{tools.length} tool{tools.length === 1 ? '' : 's'}</span>
|
<span class="font-medium">{bodyTools.length} tool{bodyTools.length === 1 ? '' : 's'}</span>
|
||||||
|
{#if inlineCount > 0}
|
||||||
|
<span class="text-muted-foreground">· {inlineCount} card{inlineCount === 1 ? '' : 's'} shown</span>
|
||||||
|
{/if}
|
||||||
<span class="max-w-48 truncate font-mono text-muted-foreground">{names}</span>
|
<span class="max-w-48 truncate font-mono text-muted-foreground">{names}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -78,16 +84,16 @@
|
|||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
|
|
||||||
<Collapsible.Content class="overflow-hidden transition-all duration-200 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-2 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-2">
|
<Collapsible.Content class="overflow-hidden transition-all duration-200 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-2 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-2">
|
||||||
<div class="flex flex-col divide-y border-t">
|
<div class="flex flex-col divide-y border-t" role="list" aria-label={ariaLabel}>
|
||||||
{#each tools as tool (tool.id)}
|
{#each bodyTools as tool (tool.id)}
|
||||||
<div class="p-2">
|
<div class="p-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{#if tool.type === 'tool_result' && tool.error}
|
{#if tool.type === 'tool_result' && tool.error}
|
||||||
<XIcon class="size-3 shrink-0 text-destructive" />
|
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||||
{:else if tool.type === 'tool_result'}
|
{:else if tool.type === 'tool_result'}
|
||||||
<CheckIcon class="size-3 shrink-0 text-success" />
|
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||||
{:else}
|
{:else}
|
||||||
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" />
|
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||||
{/if}
|
{/if}
|
||||||
<span class="font-mono font-medium">{tool.name}</span>
|
<span class="font-mono font-medium">{tool.name}</span>
|
||||||
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>
|
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>
|
||||||
|
|||||||
70
web/src/lib/renderers/BlastRadius.svelte
Normal file
70
web/src/lib/renderers/BlastRadius.svelte
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ToolCallResult } from '$lib/stores/chat'
|
||||||
|
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||||
|
import CheckIcon from '@lucide/svelte/icons/check'
|
||||||
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
|
|
||||||
|
let { tool }: { tool: ToolCallResult } = $props()
|
||||||
|
|
||||||
|
const rows = $derived.by(() => {
|
||||||
|
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||||
|
const data = (tool.result as any).data ?? tool.result
|
||||||
|
return Array.isArray(data) ? data as any[] : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const grouped = $derived.by(() => {
|
||||||
|
if (!rows) return null
|
||||||
|
const g: Record<number, string[]> = {}
|
||||||
|
for (const r of rows) {
|
||||||
|
const d = Number(r.depth) || 0
|
||||||
|
if (!g[d]) g[d] = []
|
||||||
|
g[d].push(r.slug)
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = $derived(tool.type === 'tool_use')
|
||||||
|
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||||
|
const total = $derived(rows?.length ?? 0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Calculating blast radius">
|
||||||
|
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Blast radius</span>
|
||||||
|
<span class="animate-pulse text-muted-foreground">calculating…</span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Blast radius error">
|
||||||
|
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Blast radius</span>
|
||||||
|
<span class="text-destructive">{error}</span>
|
||||||
|
</div>
|
||||||
|
{:else if grouped && total > 0}
|
||||||
|
<div class="rounded-lg border bg-card text-xs" aria-label="Blast radius: {total} affected entities">
|
||||||
|
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
||||||
|
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||||
|
<span class="font-medium">{total} affected entit{total === 1 ? 'y' : 'ies'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-56 overflow-y-auto divide-y">
|
||||||
|
{#each Object.entries(grouped).sort(([a], [b]) => Number(a) - Number(b)) as [depth, slugs]}
|
||||||
|
<div class="px-3 py-2">
|
||||||
|
<div class="mb-1 font-medium text-muted-foreground">
|
||||||
|
{Number(depth) === 1 ? 'Directly affected' : `${depth} hops`} ({slugs.length})
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each slugs as slug}
|
||||||
|
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">{slug}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Blast radius: no affected entities">
|
||||||
|
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Blast radius</span>
|
||||||
|
<span class="text-muted-foreground">no affected entities found</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
92
web/src/lib/renderers/ChangeLog.svelte
Normal file
92
web/src/lib/renderers/ChangeLog.svelte
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ToolCallResult } from '$lib/stores/chat'
|
||||||
|
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||||
|
import CheckIcon from '@lucide/svelte/icons/check'
|
||||||
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
|
|
||||||
|
let { tool }: { tool: ToolCallResult } = $props()
|
||||||
|
|
||||||
|
const rows = $derived.by(() => {
|
||||||
|
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||||
|
const data = (tool.result as any).data ?? tool.result
|
||||||
|
return Array.isArray(data) ? data as any[] : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = $derived(tool.type === 'tool_use')
|
||||||
|
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||||
|
|
||||||
|
function shortTs(ts: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(ts)
|
||||||
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
|
} catch {
|
||||||
|
return ts?.slice(11, 19) ?? ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(ts: string): string {
|
||||||
|
return ts?.slice(0, 10) ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortId(id: string): string {
|
||||||
|
if (!id) return ''
|
||||||
|
return id.length > 12 ? id.slice(0, 12) : id
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityRows = $derived.by(() => {
|
||||||
|
if (!rows) return null
|
||||||
|
return rows.map((r) => ({
|
||||||
|
time: shortTs(r.timestamp || r.ts || ''),
|
||||||
|
date: shortDate(r.timestamp || r.ts || ''),
|
||||||
|
actor: r.actor_label || shortId(r.agent_id) || r.actor_type || '',
|
||||||
|
action: r.action || r.activity_type || '',
|
||||||
|
toolName: r.tool_name || r.path || '',
|
||||||
|
status: r.success ?? (r.error ? 'false' : undefined),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading activity log">
|
||||||
|
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Activity log</span>
|
||||||
|
<span class="animate-pulse text-muted-foreground">loading…</span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Activity log error">
|
||||||
|
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Activity log</span>
|
||||||
|
<span class="text-destructive">{error}</span>
|
||||||
|
</div>
|
||||||
|
{:else if activityRows && activityRows.length > 0}
|
||||||
|
<div class="rounded-lg border bg-card text-xs" aria-label="Activity log: {activityRows.length} entries">
|
||||||
|
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
||||||
|
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||||
|
<span class="font-medium">{activityRows.length} entr{activityRows.length === 1 ? 'y' : 'ies'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-56 overflow-y-auto divide-y">
|
||||||
|
{#each activityRows as row}
|
||||||
|
<div class="flex items-center gap-2 px-3 py-1.5 font-mono">
|
||||||
|
<span class="shrink-0 text-muted-foreground">{row.time}</span>
|
||||||
|
{#if row.date !== activityRows[0].date}
|
||||||
|
<span class="shrink-0 text-[10px] text-muted-foreground/60">{row.date}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-muted-foreground">{row.action}</span>
|
||||||
|
<span class="max-w-32 truncate">{row.toolName}</span>
|
||||||
|
<span class="text-muted-foreground/60">{row.actor}</span>
|
||||||
|
{#if row.status === 'true'}
|
||||||
|
<span class="ml-auto size-1.5 shrink-0 rounded-full bg-success" title="success"></span>
|
||||||
|
{:else if row.status === 'false'}
|
||||||
|
<span class="ml-auto size-1.5 shrink-0 rounded-full bg-destructive" title="error"></span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Activity log: no entries">
|
||||||
|
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Activity log</span>
|
||||||
|
<span class="text-muted-foreground">no entries</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
118
web/src/lib/renderers/EntityCard.svelte
Normal file
118
web/src/lib/renderers/EntityCard.svelte
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ToolCallResult } from '$lib/stores/chat'
|
||||||
|
import { Badge } from '$lib/components/ui/badge'
|
||||||
|
import { relativeTime } from '$lib/utils'
|
||||||
|
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||||
|
import CheckIcon from '@lucide/svelte/icons/check'
|
||||||
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
|
import BoxIcon from '@lucide/svelte/icons/box'
|
||||||
|
import MonitorIcon from '@lucide/svelte/icons/monitor'
|
||||||
|
import ContainerIcon from '@lucide/svelte/icons/container'
|
||||||
|
import GlobeIcon from '@lucide/svelte/icons/globe'
|
||||||
|
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
||||||
|
import ZapIcon from '@lucide/svelte/icons/zap'
|
||||||
|
|
||||||
|
let { tool }: { tool: ToolCallResult } = $props()
|
||||||
|
|
||||||
|
const entity = $derived.by(() => {
|
||||||
|
if (tool.type !== 'tool_result') return null
|
||||||
|
const r = tool.result
|
||||||
|
if (!r) return null
|
||||||
|
if (Array.isArray(r)) return r[0]
|
||||||
|
if (r && typeof r === 'object' && 'data' in r) return Array.isArray(r.data) ? r.data[0] : r.data
|
||||||
|
return r
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = $derived(tool.type === 'tool_use')
|
||||||
|
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||||
|
const slug = $derived(entity?.slug ?? tool.args?.slug_or_id ?? tool.args?.hostname ?? tool.args?.service_slug ?? '')
|
||||||
|
|
||||||
|
const typeIcon: Record<string, typeof BoxIcon> = {
|
||||||
|
host: MonitorIcon,
|
||||||
|
lxc: ContainerIcon,
|
||||||
|
service: GlobeIcon,
|
||||||
|
check: ZapIcon,
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = $derived(entity?.type ? (typeIcon[entity.type] ?? BoxIcon) : BoxIcon)
|
||||||
|
|
||||||
|
const keyAttrs = $derived.by(() => {
|
||||||
|
if (!entity) return [] as [string, string][]
|
||||||
|
const out: [string, string][] = []
|
||||||
|
const skip = new Set(['slug', 'type', 'name', 'state', 'health', 'last_check', 'version', 'created_at', 'updated_at', 'maintenance_until', '__renderer', 'data', 'attrs', 'attributes', 'enrolled_at'])
|
||||||
|
for (const k of ['mesh_ip', 'ip', 'version', 'age_pubkey', 'enrolled_at', 'last_check']) {
|
||||||
|
const v = entity[k]
|
||||||
|
if (v && typeof v === 'string') {
|
||||||
|
out.push([k, k === 'age_pubkey' ? v.slice(0, 16) + '…' : v])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const attrs = entity.attributes ?? entity.attrs
|
||||||
|
if (attrs && typeof attrs === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(attrs as Record<string, unknown>)) {
|
||||||
|
if (!skip.has(k) && v != null && v !== '') {
|
||||||
|
out.push([k, typeof v === 'object' ? JSON.stringify(v) : String(v)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.slice(0, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
const healthColor: Record<string, string> = {
|
||||||
|
healthy: 'var(--success)',
|
||||||
|
degraded: 'var(--warning)',
|
||||||
|
down: 'var(--destructive)',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading entity: {slug || tool.name}">
|
||||||
|
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||||
|
<span class="font-mono font-medium">{slug || tool.name}</span>
|
||||||
|
<span class="animate-pulse text-muted-foreground">loading…</span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading entity: {error}">
|
||||||
|
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||||
|
<span class="font-mono font-medium">{slug || tool.name}</span>
|
||||||
|
<span class="text-destructive">{error}</span>
|
||||||
|
</div>
|
||||||
|
{:else if entity}
|
||||||
|
<div class="rounded-lg border bg-card px-3 py-2 text-xs" aria-label="Entity: {entity.slug} — {entity.type} — {entity.health || 'no health data'}">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Icon class="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<span class="font-mono font-semibold">{entity.slug ?? slug}</span>
|
||||||
|
</div>
|
||||||
|
{#if entity.type}
|
||||||
|
<Badge variant="outline" class="text-[10px]">{entity.type}</Badge>
|
||||||
|
{/if}
|
||||||
|
{#if entity.state}
|
||||||
|
<Badge variant="secondary" class="text-[10px]">{entity.state}</Badge>
|
||||||
|
{/if}
|
||||||
|
{#if entity.health && entity.health !== 'unknown'}
|
||||||
|
<span class="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<span class="size-2 rounded-full" style="background: {healthColor[entity.health] ?? 'var(--muted-foreground)'}"></span>
|
||||||
|
{entity.health}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if entity.last_check}
|
||||||
|
<span class="text-muted-foreground">· {relativeTime(entity.last_check)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if keyAttrs.length > 0}
|
||||||
|
<div class="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-muted-foreground">
|
||||||
|
{#each keyAttrs as [k, v]}
|
||||||
|
<span class="font-mono text-[10px]"><span class="opacity-60">{k}:</span> {v}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Entity: {slug || tool.name} — no data">
|
||||||
|
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||||
|
<span class="font-mono font-medium">{slug || tool.name}</span>
|
||||||
|
<span class="text-muted-foreground">no data</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
60
web/src/lib/renderers/EntityTable.svelte
Normal file
60
web/src/lib/renderers/EntityTable.svelte
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ToolCallResult } from '$lib/stores/chat'
|
||||||
|
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||||
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
|
|
||||||
|
let { tool }: { tool: ToolCallResult } = $props()
|
||||||
|
|
||||||
|
const rows = $derived.by(() => {
|
||||||
|
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||||
|
const data = (tool.result as any).data ?? tool.result
|
||||||
|
return Array.isArray(data) ? data as any[] : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = $derived(tool.type === 'tool_use')
|
||||||
|
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||||
|
|
||||||
|
const cols = $derived(rows && rows.length > 0 ? Object.keys(rows[0]).filter(k => k !== '__renderer') : [])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading entities">
|
||||||
|
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Entities</span>
|
||||||
|
<span class="animate-pulse text-muted-foreground">loading…</span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading entities">
|
||||||
|
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Entities</span>
|
||||||
|
<span class="text-destructive">{error}</span>
|
||||||
|
</div>
|
||||||
|
{:else if rows && rows.length > 0}
|
||||||
|
<div class="rounded-lg border bg-card text-xs" aria-label="Entities: {rows.length} results">
|
||||||
|
<div class="max-h-56 overflow-y-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b text-muted-foreground">
|
||||||
|
{#each cols as col}
|
||||||
|
<th class="px-2 py-1 text-left font-medium whitespace-nowrap">{col}</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as row}
|
||||||
|
<tr class="border-b last:border-0 hover:bg-muted/30">
|
||||||
|
{#each cols as col}
|
||||||
|
<td class="px-2 py-1 whitespace-nowrap font-mono max-w-48 truncate">{row[col] ?? '—'}</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs text-muted-foreground" role="status" aria-label="Entities: no results">
|
||||||
|
<XIcon class="size-3 shrink-0" aria-hidden="true" />
|
||||||
|
<span>No entities found</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
93
web/src/lib/renderers/FleetSnapshot.svelte
Normal file
93
web/src/lib/renderers/FleetSnapshot.svelte
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ToolCallResult } from '$lib/stores/chat'
|
||||||
|
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||||
|
import CheckIcon from '@lucide/svelte/icons/check'
|
||||||
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
|
|
||||||
|
let { tool }: { tool: ToolCallResult } = $props()
|
||||||
|
|
||||||
|
const rows = $derived.by(() => {
|
||||||
|
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||||
|
const data = (tool.result as any).data ?? tool.result
|
||||||
|
return Array.isArray(data) ? data as any[] : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const summary = $derived.by(() => {
|
||||||
|
if (!rows) return null
|
||||||
|
const health: Record<string, number> = {}
|
||||||
|
const types: Record<string, number> = {}
|
||||||
|
for (const r of rows) {
|
||||||
|
health[r.health || 'unknown'] = (health[r.health || 'unknown'] || 0) + 1
|
||||||
|
types[r.type || 'unknown'] = (types[r.type || 'unknown'] || 0) + 1
|
||||||
|
}
|
||||||
|
return { health, types, total: rows.length }
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = $derived(tool.type === 'tool_use')
|
||||||
|
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||||
|
|
||||||
|
const healthColor: Record<string, string> = {
|
||||||
|
healthy: 'var(--success)',
|
||||||
|
degraded: 'var(--warning)',
|
||||||
|
down: 'var(--destructive)',
|
||||||
|
unknown: 'var(--muted-foreground)',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading fleet snapshot">
|
||||||
|
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Fleet snapshot</span>
|
||||||
|
<span class="animate-pulse text-muted-foreground">loading…</span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Fleet snapshot error">
|
||||||
|
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Fleet snapshot</span>
|
||||||
|
<span class="text-destructive">{error}</span>
|
||||||
|
</div>
|
||||||
|
{:else if summary && summary.total > 0}
|
||||||
|
<div class="rounded-lg border bg-card text-xs" aria-label="Fleet snapshot: {summary.total} entities">
|
||||||
|
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
||||||
|
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||||
|
<span class="font-medium">{summary.total} entities</span>
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-2 space-y-2">
|
||||||
|
<!-- Health -->
|
||||||
|
<div>
|
||||||
|
<div class="text-muted-foreground mb-1">Health</div>
|
||||||
|
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||||
|
{#each ['healthy', 'degraded', 'down', 'unknown'] as h}
|
||||||
|
{#if summary.health[h]}
|
||||||
|
<div class="flex items-center gap-1.5 font-mono">
|
||||||
|
<span class="size-1.5 rounded-full" style="background: {healthColor[h] ?? 'var(--muted-foreground)'}"></span>
|
||||||
|
<span class="text-muted-foreground">{h}</span>
|
||||||
|
<span class="tabular-nums">{summary.health[h]}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Types -->
|
||||||
|
{#if Object.keys(summary.types).length > 0}
|
||||||
|
<div>
|
||||||
|
<div class="text-muted-foreground mb-1">By type</div>
|
||||||
|
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||||
|
{#each Object.entries(summary.types).sort(([,a], [,b]) => b - a) as [type, count]}
|
||||||
|
<div class="flex items-center gap-1.5 font-mono">
|
||||||
|
<span class="text-muted-foreground">{type}</span>
|
||||||
|
<span class="tabular-nums">{count}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Fleet snapshot: no data">
|
||||||
|
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Fleet snapshot</span>
|
||||||
|
<span class="text-muted-foreground">no data</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
82
web/src/lib/renderers/HealthSummary.svelte
Normal file
82
web/src/lib/renderers/HealthSummary.svelte
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ToolCallResult } from '$lib/stores/chat'
|
||||||
|
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||||
|
import CheckIcon from '@lucide/svelte/icons/check'
|
||||||
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
|
|
||||||
|
let { tool }: { tool: ToolCallResult } = $props()
|
||||||
|
|
||||||
|
const rows = $derived.by(() => {
|
||||||
|
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||||
|
const data = (tool.result as any).data ?? tool.result
|
||||||
|
return Array.isArray(data) ? data : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const counts = $derived.by(() => {
|
||||||
|
if (!rows) return null
|
||||||
|
const m: Record<string, number> = {}
|
||||||
|
for (const r of rows as any[]) m[r.health || 'unknown'] = (m[r.health || 'unknown'] || 0) + 1
|
||||||
|
return m
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = $derived(tool.type === 'tool_use')
|
||||||
|
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||||
|
const total = $derived(counts ? Object.values(counts).reduce((a, b) => a + b, 0) : 0)
|
||||||
|
|
||||||
|
const bars: { label: string; count: number; color: string }[] = [
|
||||||
|
{ label: 'healthy', count: counts?.healthy ?? 0, color: 'var(--success)' },
|
||||||
|
{ label: 'degraded', count: counts?.degraded ?? 0, color: 'var(--warning)' },
|
||||||
|
{ label: 'down', count: counts?.down ?? 0, color: 'var(--destructive)' },
|
||||||
|
{ label: 'unknown', count: counts?.unknown ?? 0, color: 'var(--muted-foreground)' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading health summary">
|
||||||
|
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Health summary</span>
|
||||||
|
<span class="animate-pulse text-muted-foreground">loading…</span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading health summary">
|
||||||
|
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Health summary</span>
|
||||||
|
<span class="text-destructive">{error}</span>
|
||||||
|
</div>
|
||||||
|
{:else if counts && total > 0}
|
||||||
|
<div class="rounded-lg border bg-card px-3 py-2 text-xs" aria-label="Health summary: {total} entities — healthy {counts?.healthy ?? 0}, degraded {counts?.degraded ?? 0}, down {counts?.down ?? 0}">
|
||||||
|
<div class="flex items-center gap-2 mb-1.5">
|
||||||
|
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||||
|
<span class="font-medium">{total} entities</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex h-5 w-full overflow-hidden rounded">
|
||||||
|
{#each bars as bar}
|
||||||
|
{#if bar.count > 0}
|
||||||
|
<div
|
||||||
|
style="width: {(bar.count / total) * 100}%; background: {bar.color}"
|
||||||
|
class="flex items-center justify-center text-[9px] font-medium text-white min-w-[2rem]"
|
||||||
|
title="{bar.label}: {bar.count}"
|
||||||
|
>
|
||||||
|
{bar.count}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1.5 flex flex-wrap gap-x-3 text-muted-foreground">
|
||||||
|
{#each bars as bar}
|
||||||
|
{#if bar.count > 0}
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="size-1.5 rounded-full" style="background: {bar.color}"></span>
|
||||||
|
{bar.label} {bar.count}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Health summary: no data">
|
||||||
|
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Health summary</span>
|
||||||
|
<span class="text-muted-foreground">no data</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
70
web/src/lib/renderers/KnowledgeResults.svelte
Normal file
70
web/src/lib/renderers/KnowledgeResults.svelte
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ToolCallResult } from '$lib/stores/chat'
|
||||||
|
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||||
|
import CheckIcon from '@lucide/svelte/icons/check'
|
||||||
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
|
import FileTextIcon from '@lucide/svelte/icons/file-text'
|
||||||
|
|
||||||
|
let { tool }: { tool: ToolCallResult } = $props()
|
||||||
|
|
||||||
|
const rows = $derived.by(() => {
|
||||||
|
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||||
|
const data = (tool.result as any).data ?? tool.result
|
||||||
|
return Array.isArray(data) ? data as any[] : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = $derived(tool.type === 'tool_use')
|
||||||
|
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Searching knowledge">
|
||||||
|
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Knowledge</span>
|
||||||
|
<span class="animate-pulse text-muted-foreground">searching…</span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Knowledge search error">
|
||||||
|
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Knowledge</span>
|
||||||
|
<span class="text-destructive">{error}</span>
|
||||||
|
</div>
|
||||||
|
{:else if rows && rows.length > 0}
|
||||||
|
<div class="rounded-lg border bg-card text-xs" aria-label="Knowledge: {rows.length} results">
|
||||||
|
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
||||||
|
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||||
|
<span class="font-medium">{rows.length} result{rows.length === 1 ? '' : 's'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-56 overflow-y-auto divide-y">
|
||||||
|
{#each rows as row}
|
||||||
|
<div class="px-3 py-2">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<FileTextIcon class="size-3 shrink-0 mt-0.5 text-muted-foreground" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-mono font-medium truncate">{row.title}</div>
|
||||||
|
{#if row.snippet || row.headline}
|
||||||
|
<div class="mt-0.5 text-muted-foreground leading-relaxed line-clamp-2">
|
||||||
|
{row.snippet || row.headline}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||||
|
{#if row.source}
|
||||||
|
<span>{row.source}</span>
|
||||||
|
{/if}
|
||||||
|
{#if row.slug}
|
||||||
|
<span class="font-mono opacity-60">{row.slug}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Knowledge: no results">
|
||||||
|
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Knowledge</span>
|
||||||
|
<span class="text-muted-foreground">no results</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
85
web/src/lib/renderers/LXCList.svelte
Normal file
85
web/src/lib/renderers/LXCList.svelte
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ToolCallResult } from '$lib/stores/chat'
|
||||||
|
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||||
|
import CheckIcon from '@lucide/svelte/icons/check'
|
||||||
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
|
|
||||||
|
let { tool }: { tool: ToolCallResult } = $props()
|
||||||
|
|
||||||
|
const rows = $derived.by(() => {
|
||||||
|
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||||
|
const data = (tool.result as any).data ?? tool.result
|
||||||
|
return Array.isArray(data) ? data as any[] : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = $derived(tool.type === 'tool_use')
|
||||||
|
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||||
|
|
||||||
|
const healthColor: Record<string, string> = {
|
||||||
|
healthy: 'var(--success)',
|
||||||
|
degraded: 'var(--warning)',
|
||||||
|
down: 'var(--destructive)',
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortName(slug: string): string {
|
||||||
|
return slug.split(':').pop() ?? slug
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading LXC containers">
|
||||||
|
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||||
|
<span class="font-medium">LXC containers</span>
|
||||||
|
<span class="animate-pulse text-muted-foreground">loading…</span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading LXC containers">
|
||||||
|
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||||
|
<span class="font-medium">LXC containers</span>
|
||||||
|
<span class="text-destructive">{error}</span>
|
||||||
|
</div>
|
||||||
|
{:else if rows && rows.length > 0}
|
||||||
|
<div class="rounded-lg border bg-card text-xs" aria-label="LXC containers: {rows.length} total">
|
||||||
|
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
||||||
|
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||||
|
<span class="font-medium">{rows.length} container{rows.length === 1 ? '' : 's'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-48 overflow-y-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b text-muted-foreground">
|
||||||
|
<th class="px-3 py-1 text-left font-medium">Name</th>
|
||||||
|
<th class="px-3 py-1 text-left font-medium">ID</th>
|
||||||
|
<th class="px-3 py-1 text-left font-medium">IP</th>
|
||||||
|
<th class="px-3 py-1 text-left font-medium">Health</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as row}
|
||||||
|
<tr class="border-b last:border-0 hover:bg-muted/30">
|
||||||
|
<td class="px-3 py-1 font-mono">{shortName(row.slug)}</td>
|
||||||
|
<td class="px-3 py-1 tabular-nums text-muted-foreground">{row.pve_id ?? '—'}</td>
|
||||||
|
<td class="px-3 py-1 font-mono text-muted-foreground">{row.lan_ip ?? '—'}</td>
|
||||||
|
<td class="px-3 py-1">
|
||||||
|
{#if row.health}
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="size-1.5 rounded-full" style="background: {healthColor[row.health] ?? 'var(--muted-foreground)'}"></span>
|
||||||
|
{row.health}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted-foreground">—</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="LXC containers: no data">
|
||||||
|
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||||
|
<span class="font-medium">LXC containers</span>
|
||||||
|
<span class="text-muted-foreground">no data</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
68
web/src/lib/renderers/MetricChart.svelte
Normal file
68
web/src/lib/renderers/MetricChart.svelte
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ToolCallResult } from '$lib/stores/chat'
|
||||||
|
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||||
|
import CheckIcon from '@lucide/svelte/icons/check'
|
||||||
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
|
|
||||||
|
let { tool }: { tool: ToolCallResult } = $props()
|
||||||
|
|
||||||
|
const rows = $derived.by(() => {
|
||||||
|
if (tool.type !== 'tool_result' || !tool.result) return null
|
||||||
|
const data = (tool.result as any).data ?? tool.result
|
||||||
|
return Array.isArray(data) ? data as any[] : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = $derived(tool.type === 'tool_use')
|
||||||
|
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading metrics">
|
||||||
|
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Metrics</span>
|
||||||
|
<span class="animate-pulse text-muted-foreground">loading…</span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Metrics error">
|
||||||
|
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Metrics</span>
|
||||||
|
<span class="text-destructive">{error}</span>
|
||||||
|
</div>
|
||||||
|
{:else if rows && rows.length > 0}
|
||||||
|
<div class="rounded-lg border bg-card text-xs" aria-label="Metrics: {rows.length} samples">
|
||||||
|
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
|
||||||
|
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||||
|
<span class="font-medium">{rows.length} sample{rows.length === 1 ? '' : 's'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-48 overflow-y-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b text-muted-foreground">
|
||||||
|
<th class="px-2 py-1 text-left font-medium">Time</th>
|
||||||
|
<th class="px-2 py-1 text-left font-medium">Metric</th>
|
||||||
|
<th class="px-2 py-1 text-right font-medium">Avg</th>
|
||||||
|
<th class="px-2 py-1 text-right font-medium">Min</th>
|
||||||
|
<th class="px-2 py-1 text-right font-medium">Max</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as row}
|
||||||
|
<tr class="border-b last:border-0 hover:bg-muted/30">
|
||||||
|
<td class="px-2 py-1 font-mono tabular-nums whitespace-nowrap">{row.bucket?.slice(11, 16) ?? row.bucket?.slice(0, 19) ?? '—'}</td>
|
||||||
|
<td class="px-2 py-1 font-mono max-w-32 truncate">{row.metric ?? '—'}</td>
|
||||||
|
<td class="px-2 py-1 font-mono tabular-nums text-right">{row.avg ?? '—'}</td>
|
||||||
|
<td class="px-2 py-1 font-mono tabular-nums text-right text-muted-foreground">{row.min ?? '—'}</td>
|
||||||
|
<td class="px-2 py-1 font-mono tabular-nums text-right text-muted-foreground">{row.max ?? '—'}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Metrics: no data">
|
||||||
|
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
|
||||||
|
<span class="font-medium">Metrics</span>
|
||||||
|
<span class="text-muted-foreground">no data</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
9
web/src/lib/renderers/blast-radius.ts
Normal file
9
web/src/lib/renderers/blast-radius.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||||
|
import BlastRadius from './BlastRadius.svelte'
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
registerToolRenderer({
|
||||||
|
match: (t) => t.name === 'get_blast_radius' || t.result?.__renderer === 'blast_radius',
|
||||||
|
component: BlastRadius,
|
||||||
|
})
|
||||||
|
}
|
||||||
12
web/src/lib/renderers/change-log.ts
Normal file
12
web/src/lib/renderers/change-log.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||||
|
import ChangeLog from './ChangeLog.svelte'
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
registerToolRenderer({
|
||||||
|
match: (t) =>
|
||||||
|
t.name === 'get_change_history' ||
|
||||||
|
t.name === 'get_agent_activity' ||
|
||||||
|
t.result?.__renderer === 'change_log',
|
||||||
|
component: ChangeLog,
|
||||||
|
})
|
||||||
|
}
|
||||||
11
web/src/lib/renderers/entity-card.ts
Normal file
11
web/src/lib/renderers/entity-card.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||||
|
import EntityCard from './EntityCard.svelte'
|
||||||
|
|
||||||
|
const TOOLS = ['get_entity', 'whoami', 'explain']
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
registerToolRenderer({
|
||||||
|
match: (t) => TOOLS.includes(t.name) || t.result?.__renderer === 'entity_card',
|
||||||
|
component: EntityCard,
|
||||||
|
})
|
||||||
|
}
|
||||||
9
web/src/lib/renderers/entity-table.ts
Normal file
9
web/src/lib/renderers/entity-table.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||||
|
import EntityTable from './EntityTable.svelte'
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
registerToolRenderer({
|
||||||
|
match: (t) => t.name === 'list_entities' || t.result?.__renderer === 'entity_table',
|
||||||
|
component: EntityTable,
|
||||||
|
})
|
||||||
|
}
|
||||||
9
web/src/lib/renderers/fleet-snapshot.ts
Normal file
9
web/src/lib/renderers/fleet-snapshot.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||||
|
import FleetSnapshot from './FleetSnapshot.svelte'
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
registerToolRenderer({
|
||||||
|
match: (t) => t.name === 'get_state_snapshot' || t.result?.__renderer === 'fleet_snapshot',
|
||||||
|
component: FleetSnapshot,
|
||||||
|
})
|
||||||
|
}
|
||||||
9
web/src/lib/renderers/health-summary.ts
Normal file
9
web/src/lib/renderers/health-summary.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||||
|
import HealthSummary from './HealthSummary.svelte'
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
registerToolRenderer({
|
||||||
|
match: (t) => t.name === 'get_health_summary' || t.result?.__renderer === 'health_summary',
|
||||||
|
component: HealthSummary,
|
||||||
|
})
|
||||||
|
}
|
||||||
19
web/src/lib/renderers/index.ts
Normal file
19
web/src/lib/renderers/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { init as initEntityCard } from './entity-card'
|
||||||
|
import { init as initHealthSummary } from './health-summary'
|
||||||
|
import { init as initLXCList } from './lxc-list'
|
||||||
|
import { init as initEntityTable } from './entity-table'
|
||||||
|
import { init as initKnowledgeResults } from './knowledge-results'
|
||||||
|
import { init as initBlastRadius } from './blast-radius'
|
||||||
|
import { init as initChangeLog } from './change-log'
|
||||||
|
import { init as initFleetSnapshot } from './fleet-snapshot'
|
||||||
|
import { init as initMetricChart } from './metric-chart'
|
||||||
|
|
||||||
|
initEntityCard()
|
||||||
|
initHealthSummary()
|
||||||
|
initLXCList()
|
||||||
|
initEntityTable()
|
||||||
|
initKnowledgeResults()
|
||||||
|
initBlastRadius()
|
||||||
|
initChangeLog()
|
||||||
|
initFleetSnapshot()
|
||||||
|
initMetricChart()
|
||||||
12
web/src/lib/renderers/knowledge-results.ts
Normal file
12
web/src/lib/renderers/knowledge-results.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||||
|
import KnowledgeResults from './KnowledgeResults.svelte'
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
registerToolRenderer({
|
||||||
|
match: (t) =>
|
||||||
|
t.name === 'search_knowledge' ||
|
||||||
|
t.name === 'get_entity_knowledge' ||
|
||||||
|
t.result?.__renderer === 'knowledge_results',
|
||||||
|
component: KnowledgeResults,
|
||||||
|
})
|
||||||
|
}
|
||||||
9
web/src/lib/renderers/lxc-list.ts
Normal file
9
web/src/lib/renderers/lxc-list.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||||
|
import LXCList from './LXCList.svelte'
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
registerToolRenderer({
|
||||||
|
match: (t) => t.name === 'list_lxcs' || t.result?.__renderer === 'lxc_list',
|
||||||
|
component: LXCList,
|
||||||
|
})
|
||||||
|
}
|
||||||
9
web/src/lib/renderers/metric-chart.ts
Normal file
9
web/src/lib/renderers/metric-chart.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { registerToolRenderer } from '$lib/tool-renderers'
|
||||||
|
import MetricChart from './MetricChart.svelte'
|
||||||
|
|
||||||
|
export function init() {
|
||||||
|
registerToolRenderer({
|
||||||
|
match: (t) => t.name === 'query_metrics' || t.result?.__renderer === 'metric_chart',
|
||||||
|
component: MetricChart,
|
||||||
|
})
|
||||||
|
}
|
||||||
17
web/src/lib/tool-renderers.ts
Normal file
17
web/src/lib/tool-renderers.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Component } from 'svelte'
|
||||||
|
import type { ToolCallResult } from '$lib/stores/chat'
|
||||||
|
|
||||||
|
export interface ToolRenderer {
|
||||||
|
match: (tool: ToolCallResult) => boolean
|
||||||
|
component: Component<{ tool: ToolCallResult }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry: ToolRenderer[] = []
|
||||||
|
|
||||||
|
export function registerToolRenderer(r: ToolRenderer) {
|
||||||
|
registry.push(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToolRenderer(tool: ToolCallResult): ToolRenderer | undefined {
|
||||||
|
return registry.find((r) => r.match(tool))
|
||||||
|
}
|
||||||
@@ -5,5 +5,7 @@ import { initConfig } from '$lib/config'
|
|||||||
|
|
||||||
initConfig()
|
initConfig()
|
||||||
|
|
||||||
|
requestAnimationFrame(() => import('./lib/renderers'))
|
||||||
|
|
||||||
const app = mount(App, { target: document.getElementById('app')! })
|
const app = mount(App, { target: document.getElementById('app')! })
|
||||||
export default app
|
export default app
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
|
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
|
||||||
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
|
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
|
||||||
import InlineApproval from '$lib/components/InlineApproval.svelte'
|
import InlineApproval from '$lib/components/InlineApproval.svelte'
|
||||||
|
import { getToolRenderer } from '$lib/tool-renderers'
|
||||||
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'
|
||||||
@@ -16,6 +17,19 @@
|
|||||||
let input = $state('')
|
let input = $state('')
|
||||||
let messagesEnd = $state<HTMLDivElement | null>(null)
|
let messagesEnd = $state<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const MAX_INLINE_CARDS = 5
|
||||||
|
|
||||||
|
function getInlineTools(msg: { tools: any[] }): any[] {
|
||||||
|
const matched = msg.tools.filter((t) => getToolRenderer(t))
|
||||||
|
if (matched.length <= MAX_INLINE_CARDS) return matched
|
||||||
|
return matched.slice(0, MAX_INLINE_CARDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRemaining(msg: { tools: any[] }, inline: any[]): any[] {
|
||||||
|
const inlineIds = new Set(inline.map((t) => t.id))
|
||||||
|
return msg.tools.filter((t) => !inlineIds.has(t.id))
|
||||||
|
}
|
||||||
|
|
||||||
// Resizable right rail (session graph). Persisted so it survives reloads.
|
// Resizable right rail (session graph). Persisted so it survives reloads.
|
||||||
const RAIL_MIN = 260
|
const RAIL_MIN = 260
|
||||||
const RAIL_MAX = 620
|
const RAIL_MAX = 620
|
||||||
@@ -113,7 +127,17 @@
|
|||||||
<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">
|
||||||
<ToolCallGroup tools={msg.tools} active={$streaming && i === $messages.length - 1} />
|
{#each getInlineTools(msg) as tool (tool.id)}
|
||||||
|
{@const renderer = getToolRenderer(tool)}
|
||||||
|
{#if renderer}
|
||||||
|
<renderer.component {tool} />
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<ToolCallGroup
|
||||||
|
tools={msg.tools}
|
||||||
|
unmatched={getRemaining(msg, getInlineTools(msg))}
|
||||||
|
active={$streaming && i === $messages.length - 1}
|
||||||
|
/>
|
||||||
{#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 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user