@reddb-io/ui 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/build/_app/immutable/assets/0.BWZTMKZW.css +1 -0
  2. package/build/_app/immutable/assets/AnalyticsView.BLAJezw4.css +1 -0
  3. package/build/_app/immutable/assets/{Workspace.CvP5De7i.css → Workspace.DM2E1WiF.css} +1 -1
  4. package/build/_app/immutable/chunks/{C9rXzYLu.js → B-yl15ta.js} +1 -1
  5. package/build/_app/immutable/chunks/B5euRbKa.js +1 -0
  6. package/build/_app/immutable/chunks/BRIMmWzq.js +1 -0
  7. package/build/_app/immutable/chunks/BgOTHNR5.js +1 -0
  8. package/build/_app/immutable/chunks/Bgb6fHGb.js +18 -0
  9. package/build/_app/immutable/chunks/Btd6PFkC.js +1 -0
  10. package/build/_app/immutable/chunks/C-kqMbRi.js +1 -0
  11. package/build/_app/immutable/chunks/C3SGj6Qd.js +2 -0
  12. package/build/_app/immutable/chunks/{C278awGv.js → C7kf23SQ.js} +1 -1
  13. package/build/_app/immutable/chunks/CGFL56WJ.js +1 -0
  14. package/build/_app/immutable/chunks/CHvQYbpl.js +1 -0
  15. package/build/_app/immutable/chunks/CO7K4bD2.js +1164 -0
  16. package/build/_app/immutable/chunks/CP65IkJc.js +1 -0
  17. package/build/_app/immutable/chunks/CxnvJShw.js +404 -0
  18. package/build/_app/immutable/chunks/D02PB2u7.js +1 -0
  19. package/build/_app/immutable/chunks/DCJ0QyYH.js +1 -0
  20. package/build/_app/immutable/chunks/DN7v3Tar.js +2 -0
  21. package/build/_app/immutable/chunks/DRlh90Cv.js +1 -0
  22. package/build/_app/immutable/entry/app.BeX4Tmu5.js +2 -0
  23. package/build/_app/immutable/entry/start.D7y1MZex.js +1 -0
  24. package/build/_app/immutable/nodes/0.C0e5KQVb.js +2 -0
  25. package/build/_app/immutable/nodes/1.ceiVuZbB.js +1 -0
  26. package/build/_app/immutable/nodes/10.DeFqKRR2.js +96 -0
  27. package/build/_app/immutable/nodes/12.CzMvqoYS.js +3 -0
  28. package/build/_app/immutable/nodes/14.CAG5_cGP.js +1 -0
  29. package/build/_app/immutable/nodes/15.CRDBqbiH.js +60 -0
  30. package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
  31. package/build/_app/version.json +1 -1
  32. package/build/index.html +18 -15
  33. package/dist/embed/AnalyticsView-SKyGxeAx.js +23337 -0
  34. package/dist/embed/AnalyticsView-SKyGxeAx.js.map +1 -0
  35. package/dist/embed/RedUiEmbedRoot-BzVzGAFK.js +31960 -0
  36. package/dist/embed/RedUiEmbedRoot-BzVzGAFK.js.map +1 -0
  37. package/dist/embed/event-BvRbkLjD.js +46 -0
  38. package/dist/embed/event-BvRbkLjD.js.map +1 -0
  39. package/dist/embed/index-0hA141D2.js +85 -0
  40. package/dist/embed/index-0hA141D2.js.map +1 -0
  41. package/dist/embed/index-BVxMkfHe.js +8 -0
  42. package/dist/embed/index-BVxMkfHe.js.map +1 -0
  43. package/dist/embed/index-BY6fc1oQ.js +856 -0
  44. package/dist/embed/index-BY6fc1oQ.js.map +1 -0
  45. package/dist/embed/index-client-BHUUkI8H.js +3304 -0
  46. package/dist/embed/index-client-BHUUkI8H.js.map +1 -0
  47. package/dist/embed/index.js +1 -1
  48. package/dist/embed/{sigma.esm-D5yoExsH.js → sigma.esm-BawyGZ3s.js} +2 -2
  49. package/dist/embed/{sigma.esm-D5yoExsH.js.map → sigma.esm-BawyGZ3s.js.map} +1 -1
  50. package/dist/embed/window-ZR8_mM8o.js +1889 -0
  51. package/dist/embed/window-ZR8_mM8o.js.map +1 -0
  52. package/package.json +7 -3
  53. package/src/app.css +86 -20
  54. package/src/lib/AnalyticsView.svelte +209 -0
  55. package/src/lib/AskView.svelte +255 -0
  56. package/src/lib/ClusterView.svelte +415 -95
  57. package/src/lib/CommandPalette.svelte +2 -0
  58. package/src/lib/ConnectDropdown.svelte +60 -3
  59. package/src/lib/DevConsole.svelte +155 -0
  60. package/src/lib/ErrorBoundary.fixture.svelte +11 -0
  61. package/src/lib/ErrorBoundary.fixtures.svelte.ts +5 -0
  62. package/src/lib/ErrorBoundary.svelte +78 -0
  63. package/src/lib/ErrorBoundaryCrashChild.fixture.svelte +9 -0
  64. package/src/lib/GlobalProgressBar.svelte +9 -0
  65. package/src/lib/LoadingOverlay.svelte +109 -0
  66. package/src/lib/MetricChart.svelte +85 -0
  67. package/src/lib/QueryEditor.svelte +48 -7
  68. package/src/lib/ResultsPane.svelte +76 -8
  69. package/src/lib/SchemaTree.svelte +25 -1
  70. package/src/lib/SecurityView.svelte +176 -65
  71. package/src/lib/SettingsView.svelte +1111 -254
  72. package/src/lib/StatusBar.svelte +19 -1
  73. package/src/lib/Topbar.svelte +29 -1
  74. package/src/lib/Workspace.svelte +136 -5
  75. package/src/lib/activity.svelte.ts +44 -35
  76. package/src/lib/collection-preview.ts +14 -0
  77. package/src/lib/connections.svelte.ts +167 -4
  78. package/src/lib/embed/RedUiEmbedRoot.svelte +13 -8
  79. package/src/lib/embed/index.ts +34 -1
  80. package/src/lib/index.ts +10 -0
  81. package/src/lib/loading.svelte.ts +142 -0
  82. package/src/lib/permission-gate.ts +120 -0
  83. package/src/lib/policies.ts +136 -0
  84. package/src/lib/query-tabs.svelte.ts +132 -53
  85. package/src/lib/reddb/analytics.ts +208 -0
  86. package/src/lib/reddb/ask.ts +123 -0
  87. package/src/lib/reddb/client.ts +292 -73
  88. package/src/lib/reddb/cluster-status.ts +306 -0
  89. package/src/lib/reddb/desktop-error.ts +89 -0
  90. package/src/lib/reddb/dev-console.ts +186 -0
  91. package/src/lib/reddb/index.ts +5 -0
  92. package/src/lib/reddb/local-url-provider.ts +28 -1
  93. package/src/lib/reddb/redwire-ws-spike.ts +590 -0
  94. package/src/lib/reddb/tauri-encrypted-store.ts +4 -9
  95. package/src/lib/router.svelte.ts +12 -0
  96. package/src/lib/settings-authoring-client.ts +336 -0
  97. package/src/lib/settings-sections.ts +292 -196
  98. package/src/lib/tabs.svelte.ts +5 -0
  99. package/src/lib/workspace-session.ts +81 -0
  100. package/build/_app/immutable/assets/0.DQDLkSdR.css +0 -1
  101. package/build/_app/immutable/chunks/65XjEwaF.js +0 -367
  102. package/build/_app/immutable/chunks/BGcIe2ep.js +0 -2
  103. package/build/_app/immutable/chunks/BjFADNkA.js +0 -992
  104. package/build/_app/immutable/chunks/BrvvOVaJ.js +0 -1
  105. package/build/_app/immutable/chunks/D9CTkMjK.js +0 -1
  106. package/build/_app/immutable/chunks/oiO4RLqp.js +0 -1
  107. package/build/_app/immutable/chunks/pDJnDxXM.js +0 -1
  108. package/build/_app/immutable/entry/app.DdaMXHAv.js +0 -2
  109. package/build/_app/immutable/entry/start.-MLBtQXB.js +0 -1
  110. package/build/_app/immutable/nodes/0.Ysoz3Y3h.js +0 -1
  111. package/build/_app/immutable/nodes/1.C2jbGTOJ.js +0 -1
  112. package/build/_app/immutable/nodes/12.DQG5zpEx.js +0 -60
  113. package/build/_app/immutable/nodes/8.CrHI4F9p.js +0 -134
  114. package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js +0 -28624
  115. package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js.map +0 -1
  116. package/dist/embed/index-DGhgtnaK.js +0 -577
  117. package/dist/embed/index-DGhgtnaK.js.map +0 -1
  118. package/dist/embed/index-client-ItCEU64C.js +0 -3087
  119. package/dist/embed/index-client-ItCEU64C.js.map +0 -1
  120. /package/build/_app/immutable/assets/{12.Dblyk0Kr.css → 15.Dblyk0Kr.css} +0 -0
  121. /package/build/_app/immutable/nodes/{10.CAG5_cGP.js → 13.CAG5_cGP.js} +0 -0
@@ -0,0 +1,209 @@
1
+ <script lang="ts">
2
+ import { Card, Badge } from '@reddb-io/ui-kit'
3
+ import PageHeader from '$lib/PageHeader.svelte'
4
+ import EmptyState from '$lib/EmptyState.svelte'
5
+ import MetricChart from '$lib/MetricChart.svelte'
6
+ import { activity } from '$lib/activity.svelte'
7
+ import { connection } from '$lib/connections.svelte'
8
+ import { secureStore } from '$lib/secureStore.svelte'
9
+ import {
10
+ promQLForMetric,
11
+ parsePromMatrix,
12
+ type MetricDescriptor,
13
+ type MetricKind,
14
+ type NormalizedSeries,
15
+ type ChartKind,
16
+ } from '#reddb'
17
+ import { LineChart, Lock, RefreshCw } from 'lucide-svelte'
18
+
19
+ let metrics = $state<MetricDescriptor[]>([])
20
+ let selectedPath = $state<string | null>(null)
21
+ let series = $state<NormalizedSeries[]>([])
22
+ let chartKind = $state<ChartKind>('line')
23
+ let windowLabel = $state('5m')
24
+ let loading = $state(false)
25
+ let chartLoading = $state(false)
26
+ let catalogError = $state<string | null>(null)
27
+ let seriesError = $state<string | null>(null)
28
+
29
+ const activeUrl = $derived(connection.active.url)
30
+ const connected = $derived(connection.connected)
31
+ const selected = $derived(metrics.find((m) => m.path === selectedPath) ?? null)
32
+
33
+ // A metric's kind tells the chart what it is — surfaced as a tone so the
34
+ // catalog reads at a glance.
35
+ const KIND_TONE: Record<MetricKind, 'ok' | 'info' | 'warn' | 'accent' | 'neutral'> = {
36
+ counter: 'info',
37
+ gauge: 'ok',
38
+ histogram: 'accent',
39
+ ratio: 'warn',
40
+ derived: 'neutral',
41
+ }
42
+
43
+ async function refresh() {
44
+ const client = connection.client
45
+ if (!client || secureStore.locked || !connected) {
46
+ metrics = []
47
+ selectedPath = null
48
+ series = []
49
+ return
50
+ }
51
+ loading = true
52
+ catalogError = null
53
+ try {
54
+ metrics = await activity.track('analytics · catalog', () => client.metricsCatalog())
55
+ if (metrics.length && !metrics.some((m) => m.path === selectedPath)) {
56
+ void selectMetric(metrics[0])
57
+ }
58
+ } catch (e) {
59
+ catalogError = (e as Error).message
60
+ metrics = []
61
+ }
62
+ loading = false
63
+ }
64
+
65
+ async function selectMetric(descriptor: MetricDescriptor) {
66
+ selectedPath = descriptor.path
67
+ const client = connection.client
68
+ if (!client) return
69
+ const plan = promQLForMetric(descriptor)
70
+ chartKind = plan.chart
71
+ windowLabel = plan.window
72
+ chartLoading = true
73
+ seriesError = null
74
+ series = []
75
+ // Last hour at 30s resolution — enough to read a trend without flooding.
76
+ const end = Math.floor(Date.now() / 1000)
77
+ const start = end - 3600
78
+ const step = 30
79
+ try {
80
+ const collected: NormalizedSeries[] = []
81
+ for (const q of plan.queries) {
82
+ const resp = await activity.track(`analytics · ${q.label}`, () =>
83
+ client.promQueryRange(q.expr, start, end, step),
84
+ )
85
+ const normalized = parsePromMatrix(resp)
86
+ for (const s of normalized) {
87
+ // For multi-query plans (percentiles) the query label IS the series
88
+ // identity; for single-query plans keep the metric's own labels.
89
+ collected.push(plan.queries.length > 1 ? { ...s, label: q.label } : s)
90
+ }
91
+ }
92
+ // Guard against a metric that exists in the catalog but was never
93
+ // written to a metrics collection (PromQL returns empty).
94
+ if (selectedPath === descriptor.path) series = collected
95
+ } catch (e) {
96
+ seriesError = (e as Error).message
97
+ series = []
98
+ } finally {
99
+ if (selectedPath === descriptor.path) chartLoading = false
100
+ }
101
+ }
102
+
103
+ $effect(() => {
104
+ if (secureStore.locked) return
105
+ void activeUrl
106
+ void connected
107
+ refresh()
108
+ })
109
+ </script>
110
+
111
+ <div class="flex h-full flex-col overflow-hidden bg-bg-0 text-fg-1">
112
+ <PageHeader
113
+ eyebrow="Observability"
114
+ title="Analytics"
115
+ subtitle="Metrics chart themselves by nature — a counter is a rate, a histogram is P50/P95/P99, a gauge is a level."
116
+ >
117
+ {#snippet actions()}
118
+ <button
119
+ type="button"
120
+ onclick={refresh}
121
+ disabled={loading || secureStore.locked || !connected}
122
+ class="inline-flex h-7 items-center gap-1.5 rounded-md border border-line-2 bg-bg-2 px-2.5 text-[11px] font-mono text-fg-1 hover:border-line-3 hover:text-fg-0 disabled:cursor-not-allowed disabled:opacity-40"
123
+ >
124
+ <RefreshCw class="size-3.5 {loading ? 'animate-spin' : ''}" />
125
+ refresh
126
+ </button>
127
+ {/snippet}
128
+ </PageHeader>
129
+
130
+ {#if secureStore.locked}
131
+ <div class="p-6"><EmptyState icon={Lock} title="Locked" message="Unlock the secure store to read the metric catalog." /></div>
132
+ {:else if !connected}
133
+ <div class="p-6"><EmptyState icon={LineChart} title="Not connected" message="Connect to a reddb instance to browse its metrics." /></div>
134
+ {:else if catalogError}
135
+ <div class="p-6">
136
+ <EmptyState
137
+ icon={LineChart}
138
+ title="No analytics surface"
139
+ message="This reddb didn't answer the metric catalog. Analytics needs a server with the metrics engine (red.metrics)."
140
+ hint={catalogError}
141
+ />
142
+ </div>
143
+ {:else if metrics.length === 0}
144
+ <div class="p-6">
145
+ <EmptyState
146
+ icon={LineChart}
147
+ title="No metrics registered"
148
+ message="Register one with CREATE METRIC, then feed it via Prometheus remote-write or a metrics collection."
149
+ hint="CREATE METRIC http.latency TYPE histogram ROLE sli;"
150
+ />
151
+ </div>
152
+ {:else}
153
+ <div class="grid min-h-0 flex-1 grid-cols-[260px_1fr] overflow-hidden">
154
+ <!-- Catalog -->
155
+ <div class="overflow-auto border-r border-line-1 p-2">
156
+ <div class="grid gap-0.5">
157
+ {#each metrics as m (m.path)}
158
+ <button
159
+ type="button"
160
+ onclick={() => selectMetric(m)}
161
+ class={[
162
+ 'flex flex-col gap-1 rounded px-2.5 py-2 text-left transition-colors',
163
+ selectedPath === m.path ? 'bg-bg-2' : 'hover:bg-bg-2',
164
+ ].join(' ')}
165
+ >
166
+ <span class="truncate font-mono text-[12px] text-fg-0">{m.path}</span>
167
+ <span class="flex items-center gap-1.5">
168
+ <Badge tone={KIND_TONE[m.kind] ?? 'neutral'}>{m.kind}</Badge>
169
+ <span class="font-mono text-[10px] text-fg-3">{m.role}</span>
170
+ </span>
171
+ </button>
172
+ {/each}
173
+ </div>
174
+ </div>
175
+
176
+ <!-- Chart -->
177
+ <div class="flex min-h-0 flex-col overflow-hidden p-4">
178
+ {#if selected}
179
+ <div class="mb-3 flex items-baseline justify-between">
180
+ <div>
181
+ <div class="font-mono text-[14px] text-fg-0">{selected.path}</div>
182
+ <div class="mt-0.5 text-[11px] text-fg-3">
183
+ {selected.kind} · {chartKind}{#if chartKind !== 'gauge'} · window {windowLabel}{/if}
184
+ </div>
185
+ </div>
186
+ {#if selected.unit}<span class="font-mono text-[11px] text-fg-3">{selected.unit}</span>{/if}
187
+ </div>
188
+ <div class="relative min-h-0 flex-1 rounded border border-line-1 bg-bg-1 p-3">
189
+ {#if chartLoading}
190
+ <div class="flex h-full items-center justify-center font-mono text-[12px] text-fg-3">querying…</div>
191
+ {:else if seriesError}
192
+ <div class="flex h-full items-center justify-center px-6 text-center font-mono text-[12px] text-fg-3">
193
+ {seriesError.includes('404') || seriesError.includes('route not found')
194
+ ? 'this reddb has no Prometheus query endpoint (/api/v1/query_range)'
195
+ : seriesError}
196
+ </div>
197
+ {:else}
198
+ <MetricChart chart={chartKind} {series} unit={selected.unit} />
199
+ {/if}
200
+ </div>
201
+ {:else}
202
+ <div class="flex h-full items-center justify-center font-mono text-[12px] text-fg-3">
203
+ select a metric
204
+ </div>
205
+ {/if}
206
+ </div>
207
+ </div>
208
+ {/if}
209
+ </div>
@@ -0,0 +1,255 @@
1
+ <script lang="ts">
2
+ // Eixo 4 — the ASK chat. Starts as a centred full-screen conversation;
3
+ // the moment an answer arrives grounded on sources, it splits into two
4
+ // columns: the conversation (squeezed left) and the visualization of what
5
+ // the answer was grounded on (right). Citation chips in the answer link a
6
+ // claim to the exact source that supports it — "the logic it took".
7
+ import { Badge } from '@reddb-io/ui-kit'
8
+ import EmptyState from '$lib/EmptyState.svelte'
9
+ import { activity } from '$lib/activity.svelte'
10
+ import { connection } from '$lib/connections.svelte'
11
+ import { secureStore } from '$lib/secureStore.svelte'
12
+ import { parseAnswerSegments, parseUrn, type AskResponse } from '#reddb'
13
+ import { Sparkles, Lock, CornerDownLeft, AlertTriangle } from 'lucide-svelte'
14
+
15
+ interface ChatMessage {
16
+ id: number
17
+ role: 'user' | 'assistant'
18
+ text: string
19
+ response?: AskResponse
20
+ error?: string
21
+ pending?: boolean
22
+ }
23
+
24
+ let messages = $state<ChatMessage[]>([])
25
+ let input = $state('')
26
+ let sending = $state(false)
27
+ let activeArtifact = $state<number | null>(null)
28
+ let highlightMarker = $state<number | null>(null)
29
+ let nextId = 0
30
+
31
+ const connected = $derived(connection.connected)
32
+ const locked = $derived(secureStore.locked)
33
+ const canSend = $derived(connected && !locked && !sending && input.trim().length > 0)
34
+
35
+ // Split as soon as any answer carries grounding sources.
36
+ const splitOpen = $derived(
37
+ messages.some((m) => m.role === 'assistant' && (m.response?.sources_flat?.length ?? 0) > 0),
38
+ )
39
+ const artifactMessage = $derived(
40
+ activeArtifact !== null
41
+ ? messages[activeArtifact]
42
+ : [...messages].reverse().find((m) => (m.response?.sources_flat?.length ?? 0) > 0),
43
+ )
44
+
45
+ const SUGGESTIONS = [
46
+ 'What changed in the last hour?',
47
+ 'Which collections hold the most rows?',
48
+ 'Summarize the most recent errors.',
49
+ ]
50
+
51
+ async function send() {
52
+ const question = input.trim()
53
+ const client = connection.client
54
+ if (!question || !client || sending) return
55
+ input = ''
56
+ const userMsg: ChatMessage = { id: nextId++, role: 'user', text: question }
57
+ const assistantMsg: ChatMessage = { id: nextId++, role: 'assistant', text: '', pending: true }
58
+ messages = [...messages, userMsg, assistantMsg]
59
+ sending = true
60
+ try {
61
+ const response = await activity.track('ask', () => client.ask(question))
62
+ const idx = messages.findIndex((m) => m.id === assistantMsg.id)
63
+ if (idx !== -1) {
64
+ messages[idx] = { ...assistantMsg, pending: false, text: response.answer, response }
65
+ if ((response.sources_flat?.length ?? 0) > 0) activeArtifact = idx
66
+ }
67
+ } catch (e) {
68
+ const idx = messages.findIndex((m) => m.id === assistantMsg.id)
69
+ if (idx !== -1) {
70
+ messages[idx] = { ...assistantMsg, pending: false, error: (e as Error).message }
71
+ }
72
+ } finally {
73
+ sending = false
74
+ }
75
+ }
76
+
77
+ function onKeydown(e: KeyboardEvent) {
78
+ if (e.key === 'Enter' && !e.shiftKey) {
79
+ e.preventDefault()
80
+ if (canSend) void send()
81
+ }
82
+ }
83
+
84
+ function focusSource(messageIndex: number, marker: number) {
85
+ activeArtifact = messageIndex
86
+ highlightMarker = marker
87
+ }
88
+
89
+ /** Pretty-print JSON content, else return the raw string. */
90
+ function formatContent(content: string): string {
91
+ try {
92
+ return JSON.stringify(JSON.parse(content), null, 2)
93
+ } catch {
94
+ return content
95
+ }
96
+ }
97
+
98
+ function fmtCost(usd: number | undefined): string {
99
+ if (usd === undefined) return ''
100
+ return usd < 0.01 ? `$${usd.toFixed(4)}` : `$${usd.toFixed(2)}`
101
+ }
102
+
103
+ const SOURCE_TONE: Record<string, 'info' | 'ok' | 'accent' | 'warn' | 'neutral'> = {
104
+ table: 'info',
105
+ document: 'ok',
106
+ vector: 'accent',
107
+ graph_node: 'warn',
108
+ graph_edge: 'warn',
109
+ kv: 'neutral',
110
+ }
111
+ </script>
112
+
113
+ {#snippet conversation(squeezed: boolean)}
114
+ <div class="flex h-full flex-col">
115
+ <div class="flex-1 overflow-auto">
116
+ <div class={['mx-auto flex flex-col gap-4 p-4', squeezed ? 'max-w-none' : 'max-w-2xl'].join(' ')}>
117
+ {#if messages.length === 0}
118
+ <div class="flex flex-col items-center gap-4 py-16 text-center">
119
+ <div class="flex size-12 items-center justify-center rounded-full border border-line-2 bg-bg-1">
120
+ <Sparkles class="size-5 text-accent" />
121
+ </div>
122
+ <div>
123
+ <div class="font-mono text-[15px] text-fg-0">Ask your database</div>
124
+ <div class="mt-1 text-[12px] text-fg-3">Grounded answers with citations to the rows, docs, and vectors behind them.</div>
125
+ </div>
126
+ <div class="flex flex-wrap justify-center gap-1.5">
127
+ {#each SUGGESTIONS as s}
128
+ <button
129
+ type="button"
130
+ onclick={() => { input = s; if (canSend) void send() }}
131
+ disabled={!connected || locked}
132
+ class="rounded-full border border-line-2 bg-bg-1 px-3 py-1.5 text-[11px] text-fg-2 hover:border-line-3 hover:text-fg-0 disabled:cursor-not-allowed disabled:opacity-40"
133
+ >{s}</button>
134
+ {/each}
135
+ </div>
136
+ </div>
137
+ {/if}
138
+
139
+ {#each messages as m, i (m.id)}
140
+ {#if m.role === 'user'}
141
+ <div class="flex justify-end">
142
+ <div class="max-w-[85%] rounded-lg rounded-br-sm border border-line-2 bg-bg-2 px-3 py-2 font-mono text-[12px] text-fg-0">{m.text}</div>
143
+ </div>
144
+ {:else}
145
+ <div class="flex flex-col gap-1.5">
146
+ {#if m.pending}
147
+ <div class="flex items-center gap-2 text-[12px] text-fg-3"><Sparkles class="size-3.5 animate-pulse text-accent" /> thinking…</div>
148
+ {:else if m.error}
149
+ <div class="flex items-start gap-2 rounded-lg border border-accent/30 bg-accent/5 px-3 py-2 text-[12px] text-fg-1">
150
+ <AlertTriangle class="mt-0.5 size-3.5 shrink-0 text-accent" />
151
+ <span>{m.error.includes('404') || m.error.includes('route not found') ? 'This reddb has no AI endpoint (/ai/ask). Configure a provider to enable Ask.' : m.error}</span>
152
+ </div>
153
+ {:else if m.response}
154
+ <div class="text-[13px] leading-relaxed text-fg-1">
155
+ {#each parseAnswerSegments(m.response.answer) as seg}
156
+ {#if seg.type === 'text'}{seg.value}{:else}<button
157
+ type="button"
158
+ onclick={() => focusSource(i, seg.marker)}
159
+ title="Jump to source {seg.marker}"
160
+ class="mx-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded bg-accent/15 px-1 align-super text-[9px] font-semibold text-accent hover:bg-accent/25"
161
+ >{seg.marker}</button>{/if}
162
+ {/each}
163
+ </div>
164
+ <div class="flex flex-wrap items-center gap-1.5 text-[10px] text-fg-3">
165
+ {#if m.response.validation && !m.response.validation.ok}
166
+ <Badge tone="warn">unverified citations</Badge>
167
+ {/if}
168
+ {#if m.response.sources_flat?.length}
169
+ <button type="button" class="rounded border border-line-1 px-1.5 py-0.5 hover:border-line-3 hover:text-fg-1" onclick={() => { activeArtifact = i; highlightMarker = null }}>
170
+ {m.response.sources_flat.length} source{m.response.sources_flat.length === 1 ? '' : 's'}
171
+ </button>
172
+ {/if}
173
+ {#if m.response.provider}<span class="font-mono">{m.response.provider}{m.response.model ? `·${m.response.model}` : ''}</span>{/if}
174
+ {#if m.response.cache_hit}<span>cached</span>{/if}
175
+ {#if m.response.cost_usd !== undefined}<span class="font-mono">{fmtCost(m.response.cost_usd)}</span>{/if}
176
+ </div>
177
+ {/if}
178
+ </div>
179
+ {/if}
180
+ {/each}
181
+ </div>
182
+ </div>
183
+
184
+ <!-- Composer -->
185
+ <div class="border-t border-line-1 bg-bg-0/80 p-3 backdrop-blur">
186
+ <div class={['mx-auto flex items-end gap-2', squeezed ? 'max-w-none' : 'max-w-2xl'].join(' ')}>
187
+ <textarea
188
+ bind:value={input}
189
+ onkeydown={onKeydown}
190
+ rows="1"
191
+ placeholder={connected ? 'Ask anything about this database…' : 'Connect to ask'}
192
+ disabled={!connected || locked}
193
+ class="max-h-32 min-h-9 flex-1 resize-none rounded-lg border border-line-2 bg-bg-1 px-3 py-2 font-mono text-[12px] text-fg-0 placeholder:text-fg-3 focus:border-line-3 focus:outline-none disabled:opacity-50"
194
+ ></textarea>
195
+ <button
196
+ type="button"
197
+ onclick={send}
198
+ disabled={!canSend}
199
+ class="inline-flex h-9 items-center gap-1.5 rounded-lg bg-accent px-3 text-[12px] font-medium text-white hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-40"
200
+ >
201
+ <CornerDownLeft class="size-3.5" />
202
+ Ask
203
+ </button>
204
+ </div>
205
+ </div>
206
+ </div>
207
+ {/snippet}
208
+
209
+ {#snippet visualization()}
210
+ <div class="flex h-full flex-col overflow-hidden bg-bg-1">
211
+ {#if artifactMessage?.response}
212
+ {@const r = artifactMessage.response}
213
+ <div class="flex items-center justify-between border-b border-line-1 px-4 py-2.5">
214
+ <span class="type-label">grounding · {r.sources_flat.length} source{r.sources_flat.length === 1 ? '' : 's'}</span>
215
+ <span class="font-mono text-[10px] text-fg-3">{r.provider ?? ''}{r.model ? ` · ${r.model}` : ''}</span>
216
+ </div>
217
+ <div class="flex-1 overflow-auto p-3">
218
+ <div class="grid gap-2">
219
+ {#each r.sources_flat as src, si (src.urn + si)}
220
+ {@const ref = parseUrn(src.urn)}
221
+ <div class={[
222
+ 'rounded border bg-bg-0 p-2.5 transition-colors',
223
+ highlightMarker === si + 1 ? 'border-accent' : 'border-line-1',
224
+ ].join(' ')}>
225
+ <div class="mb-1.5 flex items-center gap-2">
226
+ <span class="inline-flex size-4 items-center justify-center rounded bg-accent/15 text-[9px] font-semibold text-accent">{si + 1}</span>
227
+ <Badge tone={SOURCE_TONE[src.kind] ?? 'neutral'}>{src.kind}</Badge>
228
+ {#if ref}<span class="font-mono text-[10px] text-fg-2">{ref.collection}<span class="text-fg-3">/{ref.id}</span></span>{/if}
229
+ {#if src.score !== undefined}<span class="ml-auto font-mono text-[10px] text-fg-3">score {src.score.toFixed(2)}</span>{/if}
230
+ </div>
231
+ <pre class="max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-[10px] leading-relaxed text-fg-1">{formatContent(src.content)}</pre>
232
+ </div>
233
+ {/each}
234
+ </div>
235
+ </div>
236
+ {:else}
237
+ <div class="flex h-full items-center justify-center font-mono text-[12px] text-fg-3">no grounding to show</div>
238
+ {/if}
239
+ </div>
240
+ {/snippet}
241
+
242
+ <div class="h-full overflow-hidden bg-bg-0 text-fg-1">
243
+ {#if locked}
244
+ <div class="p-6"><EmptyState icon={Lock} title="Locked" message="Unlock the secure store to ask." /></div>
245
+ {:else if !connected}
246
+ <div class="p-6"><EmptyState icon={Sparkles} title="Not connected" message="Connect to a reddb instance to ask grounded questions." /></div>
247
+ {:else if splitOpen}
248
+ <div class="grid h-full grid-cols-[minmax(340px,42%)_1fr] overflow-hidden">
249
+ <div class="min-h-0 border-r border-line-1">{@render conversation(true)}</div>
250
+ <div class="min-h-0">{@render visualization()}</div>
251
+ </div>
252
+ {:else}
253
+ {@render conversation(false)}
254
+ {/if}
255
+ </div>