@reddb-io/ui 0.1.1

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 (169) hide show
  1. package/LICENSE +17 -0
  2. package/bin/red-ui.js +259 -0
  3. package/build/CNAME +1 -0
  4. package/build/_app/env.js +1 -0
  5. package/build/_app/immutable/assets/0.C63jY5jX.css +1 -0
  6. package/build/_app/immutable/assets/10.Dblyk0Kr.css +1 -0
  7. package/build/_app/immutable/assets/EmptyState.RXcH7ryE.css +1 -0
  8. package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +1 -0
  9. package/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
  10. package/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
  11. package/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
  12. package/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
  13. package/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
  14. package/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
  15. package/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
  16. package/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
  17. package/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
  18. package/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
  19. package/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
  20. package/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
  21. package/build/_app/immutable/chunks/3j4cZ58b.js +1 -0
  22. package/build/_app/immutable/chunks/69_IOA4Y.js +1 -0
  23. package/build/_app/immutable/chunks/ALqaDrNQ.js +2 -0
  24. package/build/_app/immutable/chunks/BGcIe2ep.js +2 -0
  25. package/build/_app/immutable/chunks/BMHKbvs8.js +1 -0
  26. package/build/_app/immutable/chunks/BQHbwf54.js +916 -0
  27. package/build/_app/immutable/chunks/Bgso8cDH.js +311 -0
  28. package/build/_app/immutable/chunks/Bmgx1mfv.js +310 -0
  29. package/build/_app/immutable/chunks/BrvvOVaJ.js +1 -0
  30. package/build/_app/immutable/chunks/Bzak7iHL.js +1 -0
  31. package/build/_app/immutable/chunks/D9CTkMjK.js +1 -0
  32. package/build/_app/immutable/chunks/DIeogL5L.js +1 -0
  33. package/build/_app/immutable/chunks/DXY_Rxa7.js +113 -0
  34. package/build/_app/immutable/chunks/DhEqZVGG.js +1 -0
  35. package/build/_app/immutable/chunks/Ov8N-vxt.js +1 -0
  36. package/build/_app/immutable/chunks/oiO4RLqp.js +1 -0
  37. package/build/_app/immutable/entry/app.D_IVtpdv.js +2 -0
  38. package/build/_app/immutable/entry/start.3oC0UQ_Q.js +1 -0
  39. package/build/_app/immutable/nodes/0.CHPiFxDo.js +1 -0
  40. package/build/_app/immutable/nodes/1.BC_4YZ0h.js +1 -0
  41. package/build/_app/immutable/nodes/10.B7DMKSNc.js +60 -0
  42. package/build/_app/immutable/nodes/2.CAG5_cGP.js +1 -0
  43. package/build/_app/immutable/nodes/3.CAG5_cGP.js +1 -0
  44. package/build/_app/immutable/nodes/4.CAG5_cGP.js +1 -0
  45. package/build/_app/immutable/nodes/5.CAG5_cGP.js +1 -0
  46. package/build/_app/immutable/nodes/6.CAG5_cGP.js +1 -0
  47. package/build/_app/immutable/nodes/7.BZX9Iird.js +134 -0
  48. package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
  49. package/build/_app/immutable/nodes/9.CAG5_cGP.js +1 -0
  50. package/build/_app/version.json +1 -0
  51. package/build/favicon.png +0 -0
  52. package/build/index.html +59 -0
  53. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js +26562 -0
  54. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +1 -0
  55. package/dist/embed/bundle-mjs-UuVRz3O4.js +3047 -0
  56. package/dist/embed/bundle-mjs-UuVRz3O4.js.map +1 -0
  57. package/dist/embed/core-QuRYvMt8.js +118 -0
  58. package/dist/embed/core-QuRYvMt8.js.map +1 -0
  59. package/dist/embed/index-B6ZQhCsu.js +577 -0
  60. package/dist/embed/index-B6ZQhCsu.js.map +1 -0
  61. package/dist/embed/index-client-ItCEU64C.js +3087 -0
  62. package/dist/embed/index-client-ItCEU64C.js.map +1 -0
  63. package/dist/embed/index-fad77a13.esm-BqPj0saH.js +1318 -0
  64. package/dist/embed/index-fad77a13.esm-BqPj0saH.js.map +1 -0
  65. package/dist/embed/index.js +11 -0
  66. package/dist/embed/index.js.map +1 -0
  67. package/dist/embed/sigma-node-border.esm-CGtlgqmo.js +432 -0
  68. package/dist/embed/sigma-node-border.esm-CGtlgqmo.js.map +1 -0
  69. package/dist/embed/sigma.esm-y0qgY-OC.js +2355 -0
  70. package/dist/embed/sigma.esm-y0qgY-OC.js.map +1 -0
  71. package/dist/embed/ui.css +1 -0
  72. package/package.json +101 -0
  73. package/src/app.css +238 -0
  74. package/src/lib/ActivityIndicator.svelte +100 -0
  75. package/src/lib/ClusterNode.svelte +119 -0
  76. package/src/lib/ClusterView.svelte +541 -0
  77. package/src/lib/CollectionHistory.svelte +266 -0
  78. package/src/lib/CollectionsView.svelte +14 -0
  79. package/src/lib/CommandPalette.svelte +237 -0
  80. package/src/lib/ConnectDropdown.svelte +222 -0
  81. package/src/lib/EmptyState.svelte +27 -0
  82. package/src/lib/LiveChanges.svelte +195 -0
  83. package/src/lib/MasterPasswordDialog.svelte +168 -0
  84. package/src/lib/PageHeader.svelte +32 -0
  85. package/src/lib/PendingChangesPanel.svelte +153 -0
  86. package/src/lib/QueryEditor.svelte +122 -0
  87. package/src/lib/ResultsPane.svelte +747 -0
  88. package/src/lib/SchemaTree.svelte +193 -0
  89. package/src/lib/SecurityView.svelte +255 -0
  90. package/src/lib/ShortcutOverlay.svelte +104 -0
  91. package/src/lib/Splash.svelte +46 -0
  92. package/src/lib/StatusBar.svelte +163 -0
  93. package/src/lib/SubNav.svelte +62 -0
  94. package/src/lib/Topbar.svelte +119 -0
  95. package/src/lib/Workspace.svelte +247 -0
  96. package/src/lib/activity.svelte.ts +91 -0
  97. package/src/lib/build-info.ts +41 -0
  98. package/src/lib/capability.ts +221 -0
  99. package/src/lib/cn.ts +6 -0
  100. package/src/lib/collection-catalog.ts +116 -0
  101. package/src/lib/collection-pages.ts +33 -0
  102. package/src/lib/connection-history.ts +301 -0
  103. package/src/lib/connections.svelte.ts +493 -0
  104. package/src/lib/embed/RedUiEmbedRoot.svelte +31 -0
  105. package/src/lib/embed/index.ts +234 -0
  106. package/src/lib/graph-loader/contract.ts +336 -0
  107. package/src/lib/graph-loader/drilldown.ts +334 -0
  108. package/src/lib/graph-loader/index.ts +13 -0
  109. package/src/lib/index.ts +114 -0
  110. package/src/lib/pending-changes.svelte.ts +149 -0
  111. package/src/lib/query-tabs.svelte.ts +130 -0
  112. package/src/lib/reddb/boot-params.ts +254 -0
  113. package/src/lib/reddb/cdc-stream-client.ts +234 -0
  114. package/src/lib/reddb/client.ts +698 -0
  115. package/src/lib/reddb/connection-bootstrap.ts +260 -0
  116. package/src/lib/reddb/connection-provider.contract.ts +72 -0
  117. package/src/lib/reddb/connection-provider.ts +72 -0
  118. package/src/lib/reddb/index.ts +14 -0
  119. package/src/lib/reddb/injected-client-provider.ts +76 -0
  120. package/src/lib/reddb/local-url-provider.ts +245 -0
  121. package/src/lib/reddb/secure-store.ts +41 -0
  122. package/src/lib/reddb/server-capabilities.ts +71 -0
  123. package/src/lib/reddb/tauri-encrypted-store.ts +64 -0
  124. package/src/lib/reddb/transports.ts +89 -0
  125. package/src/lib/reddb/types.ts +32 -0
  126. package/src/lib/reddb/uri.ts +54 -0
  127. package/src/lib/reddb/web-encrypted-store.ts +201 -0
  128. package/src/lib/renderers/DiffRenderer.svelte +184 -0
  129. package/src/lib/renderers/DocumentRenderer.svelte +134 -0
  130. package/src/lib/renderers/GraphRenderer.svelte +1328 -0
  131. package/src/lib/renderers/HypertableRenderer.svelte +253 -0
  132. package/src/lib/renderers/JsonRenderer.svelte +30 -0
  133. package/src/lib/renderers/KvRenderer.svelte +150 -0
  134. package/src/lib/renderers/QueueRenderer.svelte +286 -0
  135. package/src/lib/renderers/StatsRenderer.svelte +123 -0
  136. package/src/lib/renderers/TableRenderer.svelte +440 -0
  137. package/src/lib/renderers/VectorRenderer.svelte +388 -0
  138. package/src/lib/renderers/diff-render.ts +122 -0
  139. package/src/lib/renderers/diff.ts +9 -0
  140. package/src/lib/renderers/document-render.ts +64 -0
  141. package/src/lib/renderers/document.ts +9 -0
  142. package/src/lib/renderers/graph-render.ts +631 -0
  143. package/src/lib/renderers/graph-sigma.ts +395 -0
  144. package/src/lib/renderers/graph.ts +9 -0
  145. package/src/lib/renderers/hypertable-render.ts +320 -0
  146. package/src/lib/renderers/hypertable.ts +9 -0
  147. package/src/lib/renderers/index.ts +42 -0
  148. package/src/lib/renderers/json-render.ts +89 -0
  149. package/src/lib/renderers/json.ts +10 -0
  150. package/src/lib/renderers/kv-render.ts +193 -0
  151. package/src/lib/renderers/kv.ts +9 -0
  152. package/src/lib/renderers/queue-render.ts +197 -0
  153. package/src/lib/renderers/queue.ts +9 -0
  154. package/src/lib/renderers/registry.ts +68 -0
  155. package/src/lib/renderers/stats-render.ts +73 -0
  156. package/src/lib/renderers/stats.ts +9 -0
  157. package/src/lib/renderers/table-render.ts +156 -0
  158. package/src/lib/renderers/table.ts +11 -0
  159. package/src/lib/renderers/types.ts +33 -0
  160. package/src/lib/renderers/vector-render.ts +154 -0
  161. package/src/lib/renderers/vector.ts +9 -0
  162. package/src/lib/router.svelte.ts +163 -0
  163. package/src/lib/secureStore.svelte.ts +148 -0
  164. package/src/lib/shadow-dom-spike/ShadowDomSpikeCore.svelte +198 -0
  165. package/src/lib/shadow-dom-spike/mount-shadow-dom-spike.ts +94 -0
  166. package/src/lib/shortcuts.ts +22 -0
  167. package/src/lib/surface.ts +80 -0
  168. package/src/lib/tabs.svelte.ts +115 -0
  169. package/src/lib/theme.svelte.ts +63 -0
@@ -0,0 +1,388 @@
1
+ <script lang="ts">
2
+ import type { QueryResult, RedClient } from '#reddb'
3
+ import { Braces, Gauge, ListFilter, LoaderCircle, Play, Rows3, Search, X } from 'lucide-svelte'
4
+ import {
5
+ buildVectorSearchQuery,
6
+ compactValue,
7
+ extractVectors,
8
+ isSyntheticVectorPreview,
9
+ isVectorSearchResult,
10
+ parseVectorInput,
11
+ scalarLabel,
12
+ vectorMagnitude,
13
+ type VectorRow,
14
+ } from './vector-render'
15
+
16
+ interface Props {
17
+ result: QueryResult
18
+ collection?: string
19
+ client?: RedClient
20
+ }
21
+
22
+ let { result, collection, client }: Props = $props()
23
+
24
+ let surface = $state<'browse' | 'search'>('browse')
25
+ let mode = $state<'metadata' | 'vector' | 'scalar'>('metadata')
26
+ let source = $state<'text' | 'vector'>('text')
27
+ let queryText = $state('')
28
+ let queryVector = $state('')
29
+ let limit = $state(20)
30
+ let filter = $state('')
31
+ let selected = $state<VectorRow | null>(null)
32
+ let searchResult = $state<QueryResult | null>(null)
33
+ let searching = $state(false)
34
+ let searchError = $state<string | null>(null)
35
+
36
+ const browseRows = $derived(extractVectors(result))
37
+ const declaredDimension = $derived(browseRows.find((row) => row.dimension > 0)?.dimension ?? null)
38
+ const incomingSearchResult = $derived(searchResult ?? (isVectorSearchResult(result) ? result : null))
39
+ const searchRows = $derived(incomingSearchResult ? extractVectors(incomingSearchResult) : [])
40
+ const rows = $derived(surface === 'search' ? searchRows : browseRows)
41
+ const syntheticPreview = $derived(incomingSearchResult ? isSyntheticVectorPreview(incomingSearchResult) : false)
42
+ const filtered = $derived.by(() => {
43
+ const q = filter.trim().toLowerCase()
44
+ if (!q) return rows
45
+ return rows.filter((row) =>
46
+ row.id.toLowerCase().includes(q) ||
47
+ Object.values(row.metadata).some((v) => compactValue(v).toLowerCase().includes(q)),
48
+ )
49
+ })
50
+ const active = $derived(selected ?? filtered[0] ?? null)
51
+ const maxMagnitude = $derived(Math.max(1, ...filtered.map((row) => vectorMagnitude(row.vector))))
52
+ const hasSearch = $derived(incomingSearchResult !== null)
53
+
54
+ function preview(row: VectorRow): string {
55
+ const entries = Object.entries(row.metadata).slice(0, 3)
56
+ if (entries.length === 0) return ''
57
+ return entries.map(([k, v]) => `${k}=${compactValue(v)}`).join(' · ')
58
+ }
59
+
60
+ function barWidth(value: number, vector: number[]): number {
61
+ const max = Math.max(1, ...vector.map((x) => Math.abs(x)))
62
+ return Math.max(2, Math.abs(value) / max * 100)
63
+ }
64
+
65
+ async function runSearch() {
66
+ if (!client || !collection) {
67
+ searchError = 'No active RedDB connection for vector search.'
68
+ return
69
+ }
70
+ searchError = null
71
+ searching = true
72
+ try {
73
+ const text = queryText.trim()
74
+ if (source === 'text' && text.length === 0) {
75
+ throw new Error('Enter text for RedDB to embed.')
76
+ }
77
+ const query = source === 'text'
78
+ ? buildVectorSearchQuery(collection, { kind: 'text', text }, limit)
79
+ : buildVectorSearchQuery(collection, { kind: 'vector', vector: parseVectorInput(queryVector) }, limit)
80
+ const next = await client.query(query)
81
+ searchResult = next
82
+ selected = null
83
+ surface = 'search'
84
+ mode = 'metadata'
85
+ } catch (e) {
86
+ searchError = (e as Error).message
87
+ } finally {
88
+ searching = false
89
+ }
90
+ }
91
+ </script>
92
+
93
+ <div class="flex h-full flex-col text-fg-1">
94
+ <div class="border-b border-line-1 bg-bg-1/40 px-3 py-2 font-mono text-[11px]">
95
+ <div class="flex flex-wrap items-center gap-2">
96
+ <div class="inline-flex shrink-0 overflow-hidden rounded border border-line-2">
97
+ <button
98
+ type="button"
99
+ class={['inline-flex h-7 items-center gap-1 px-2 transition-colors', surface === 'browse' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
100
+ onclick={() => { surface = 'browse'; selected = null }}
101
+ aria-pressed={surface === 'browse'}
102
+ >
103
+ <Rows3 class="size-3" />
104
+ browse
105
+ </button>
106
+ <button
107
+ type="button"
108
+ class={['inline-flex h-7 items-center gap-1 border-l border-line-2 px-2 transition-colors', surface === 'search' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
109
+ onclick={() => { surface = 'search'; selected = null }}
110
+ aria-pressed={surface === 'search'}
111
+ >
112
+ <Search class="size-3" />
113
+ search
114
+ </button>
115
+ </div>
116
+
117
+ {#if surface === 'search'}
118
+ <div class="inline-flex shrink-0 overflow-hidden rounded border border-line-2">
119
+ <button
120
+ type="button"
121
+ class={['inline-flex h-7 items-center gap-1 px-2 transition-colors', source === 'text' ? 'bg-bg-3 text-fg-0' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
122
+ onclick={() => (source = 'text')}
123
+ aria-pressed={source === 'text'}
124
+ >
125
+ text
126
+ </button>
127
+ <button
128
+ type="button"
129
+ class={['inline-flex h-7 items-center gap-1 border-l border-line-2 px-2 transition-colors', source === 'vector' ? 'bg-bg-3 text-fg-0' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
130
+ onclick={() => (source = 'vector')}
131
+ aria-pressed={source === 'vector'}
132
+ >
133
+ vector
134
+ </button>
135
+ </div>
136
+
137
+ <div class="min-w-[260px] flex-1">
138
+ {#if source === 'text'}
139
+ <input
140
+ type="text"
141
+ bind:value={queryText}
142
+ placeholder="text query…"
143
+ class="h-7 w-full rounded border border-line-1 bg-bg-0 px-2 text-fg-1 outline-none placeholder:text-fg-3 focus:border-line-3"
144
+ onkeydown={(e) => { if (e.key === 'Enter') void runSearch() }}
145
+ />
146
+ {:else}
147
+ <input
148
+ type="text"
149
+ bind:value={queryVector}
150
+ placeholder="[0.12, -0.04, 0.91, …]"
151
+ class="h-7 w-full rounded border border-line-1 bg-bg-0 px-2 text-fg-1 outline-none placeholder:text-fg-3 focus:border-line-3"
152
+ onkeydown={(e) => { if (e.key === 'Enter') void runSearch() }}
153
+ />
154
+ {/if}
155
+ </div>
156
+
157
+ <label class="inline-flex h-7 items-center gap-1 rounded border border-line-1 bg-bg-0 px-2 text-fg-3">
158
+ <span>limit</span>
159
+ <input
160
+ type="number"
161
+ min="1"
162
+ max="200"
163
+ bind:value={limit}
164
+ class="w-12 bg-transparent text-right text-fg-1 outline-none"
165
+ />
166
+ </label>
167
+
168
+ <button
169
+ type="button"
170
+ class={[
171
+ 'inline-flex h-7 items-center gap-1 rounded border px-2 transition-colors',
172
+ searching
173
+ ? 'border-line-1 bg-bg-2 text-fg-3'
174
+ : 'border-accent/40 bg-accent text-white hover:bg-accent/90',
175
+ ].join(' ')}
176
+ disabled={searching}
177
+ onclick={() => void runSearch()}
178
+ >
179
+ {#if searching}
180
+ <LoaderCircle class="size-3 animate-spin" />
181
+ {:else}
182
+ <Play class="size-3" />
183
+ {/if}
184
+ search
185
+ </button>
186
+ {:else}
187
+ <div class="inline-flex items-center gap-1.5 border border-line-1 rounded px-2 py-1 bg-bg-0">
188
+ <Search class="size-3 text-fg-3" />
189
+ <input
190
+ type="text"
191
+ bind:value={filter}
192
+ placeholder="filter rows…"
193
+ class="bg-transparent text-fg-1 outline-none w-[180px] placeholder:text-fg-3"
194
+ />
195
+ {#if filter}
196
+ <button type="button" onclick={() => (filter = '')} class="text-fg-3 hover:text-fg-1 cursor-pointer" aria-label="Clear vector row filter">
197
+ <X class="size-3" />
198
+ </button>
199
+ {/if}
200
+ </div>
201
+ {/if}
202
+
203
+ <div class="ml-auto flex items-center gap-2 text-fg-3">
204
+ <span>{declaredDimension ? `${declaredDimension}d` : 'dimension n/a'}</span>
205
+ {#if collection}<span>{collection}</span>{/if}
206
+ </div>
207
+ </div>
208
+
209
+ {#if searchError}
210
+ <div class="mt-1.5 text-warn">{searchError}</div>
211
+ {/if}
212
+ </div>
213
+
214
+ {#if surface === 'search' && syntheticPreview}
215
+ <div class="border-b border-line-1 bg-bg-1/30 px-3 py-1.5 text-[11px] font-mono text-fg-3">
216
+ Preview search: distances and scores are computed against the synthetic basis vector
217
+ <span class="text-fg-1">[1, 0, 0, …]</span>.
218
+ </div>
219
+ {/if}
220
+
221
+ {#if surface === 'search' && searchRows.length === 0}
222
+ <div class="flex-1 grid place-items-center p-6 text-center font-mono text-[12px] text-fg-3">
223
+ <div>
224
+ <Search class="mx-auto mb-3 size-7 text-fg-3" strokeWidth={1.4} />
225
+ <div class="text-fg-1">{hasSearch ? 'No search results.' : 'No vector search has been run.'}</div>
226
+ <div class="mt-1">Text queries are embedded by RedDB.</div>
227
+ </div>
228
+ </div>
229
+ {:else if rows.length === 0}
230
+ <div class="flex-1 grid place-items-center p-6 text-center font-mono text-[12px] text-fg-3">
231
+ <div>
232
+ <Rows3 class="mx-auto mb-3 size-7 text-fg-3" strokeWidth={1.4} />
233
+ <div class="text-fg-1">No vector rows returned.</div>
234
+ </div>
235
+ </div>
236
+ {:else}
237
+ <div class="border-b border-line-1 px-3 py-1.5 flex items-center gap-2 text-[11px] font-mono bg-bg-0">
238
+ <div class="inline-flex border border-line-2 rounded overflow-hidden">
239
+ <button
240
+ type="button"
241
+ class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors', mode === 'metadata' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
242
+ onclick={() => (mode = 'metadata')}
243
+ aria-pressed={mode === 'metadata'}
244
+ >
245
+ <Braces class="size-3" />
246
+ metadata
247
+ </button>
248
+ <button
249
+ type="button"
250
+ class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors border-l border-line-2', mode === 'vector' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
251
+ onclick={() => (mode = 'vector')}
252
+ aria-pressed={mode === 'vector'}
253
+ >
254
+ <ListFilter class="size-3" />
255
+ vector
256
+ </button>
257
+ <button
258
+ type="button"
259
+ class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors border-l border-line-2', mode === 'scalar' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
260
+ onclick={() => (mode = 'scalar')}
261
+ aria-pressed={mode === 'scalar'}
262
+ >
263
+ <Gauge class="size-3" />
264
+ scores
265
+ </button>
266
+ </div>
267
+
268
+ {#if surface === 'search'}
269
+ <div class="inline-flex items-center gap-1.5 border border-line-1 rounded px-2 py-1 bg-bg-0">
270
+ <Search class="size-3 text-fg-3" />
271
+ <input
272
+ type="text"
273
+ bind:value={filter}
274
+ placeholder="filter results…"
275
+ class="bg-transparent text-fg-1 outline-none w-[180px] placeholder:text-fg-3"
276
+ />
277
+ {#if filter}
278
+ <button type="button" onclick={() => (filter = '')} class="text-fg-3 hover:text-fg-1 cursor-pointer" aria-label="Clear vector result filter">
279
+ <X class="size-3" />
280
+ </button>
281
+ {/if}
282
+ </div>
283
+ {/if}
284
+
285
+ <div class="ml-auto text-fg-3">
286
+ {filtered.length.toLocaleString()}{#if filter} of {rows.length.toLocaleString()}{/if}
287
+ {surface === 'search' ? ' results' : ' rows'}
288
+ </div>
289
+ </div>
290
+
291
+ <div class="flex-1 min-h-0 grid grid-cols-[320px_1fr]">
292
+ <div class="overflow-auto border-r border-line-1 text-[12px] font-mono">
293
+ {#each filtered as row (row.id)}
294
+ <button
295
+ type="button"
296
+ onclick={() => (selected = row)}
297
+ class={[
298
+ 'w-full text-left px-3 py-2 border-b border-line-1/60 hover:bg-bg-1/50 cursor-pointer',
299
+ active === row ? 'bg-bg-1 text-fg-0' : 'text-fg-2',
300
+ ].join(' ')}
301
+ >
302
+ <div class="flex items-baseline gap-2">
303
+ <span class="text-accent">{row.id}</span>
304
+ <span class="text-fg-3">{row.dimension}d</span>
305
+ {#if surface === 'search' && row.scalar !== null}<span class="ml-auto text-fg-1" title={scalarLabel(row)}>{row.scalar.toFixed(4)}</span>{/if}
306
+ </div>
307
+ {#if preview(row)}
308
+ <div class="mt-0.5 truncate text-fg-3">{preview(row)}</div>
309
+ {/if}
310
+ </button>
311
+ {/each}
312
+ </div>
313
+
314
+ <div class="overflow-auto p-3 text-[12px] font-mono">
315
+ {#if !active}
316
+ <div class="text-fg-3">No vector selected.</div>
317
+ {:else if mode === 'metadata'}
318
+ <div class="grid grid-cols-[160px_1fr] gap-x-3 gap-y-1.5">
319
+ <div class="text-fg-3">id</div>
320
+ <div class="text-fg-0">{active.id}</div>
321
+ <div class="text-fg-3">dimension</div>
322
+ <div class="text-fg-0">{active.dimension}</div>
323
+ <div class="text-fg-3">magnitude</div>
324
+ <div class="text-fg-0">{active.vector.length > 0 ? vectorMagnitude(active.vector).toFixed(6) : 'n/a'}</div>
325
+ <div class="text-fg-3">coordinates</div>
326
+ <div class="text-fg-0">{active.vector.length > 0 ? 'returned' : 'not returned'}</div>
327
+ {#if active.vectorColumn}
328
+ <div class="text-fg-3">vector column</div>
329
+ <div class="text-fg-0">{active.vectorColumn}</div>
330
+ {/if}
331
+ {#if surface === 'search' && active.scalarColumn}
332
+ <div class="text-fg-3">ranking metric</div>
333
+ <div class="text-fg-0">{scalarLabel(active)}</div>
334
+ {/if}
335
+ {#each Object.entries(active.metadata) as [k, v]}
336
+ <div class="text-fg-3 truncate">{k}</div>
337
+ <div class="text-fg-1 break-words">{compactValue(v)}</div>
338
+ {/each}
339
+ </div>
340
+ {:else if mode === 'vector'}
341
+ {#if active.vector.length === 0}
342
+ <div class="border border-line-1 bg-bg-1/40 px-3 py-2 text-fg-3">
343
+ This result reports a {active.dimension}d vector, but the coordinate array was not returned by RedDB.
344
+ </div>
345
+ {:else}
346
+ <div class="mb-2 flex items-center justify-between text-fg-3">
347
+ <span>{active.vector.length} dimensions</span>
348
+ <span>|v| {vectorMagnitude(active.vector).toFixed(6)} / max visible {maxMagnitude.toFixed(2)}</span>
349
+ </div>
350
+ <div class="grid gap-1">
351
+ {#each active.vector.slice(0, 256) as value, i}
352
+ <div class="grid grid-cols-[52px_1fr_90px] items-center gap-2">
353
+ <span class="text-fg-3 text-right">{i}</span>
354
+ <div class="h-2 bg-bg-2 rounded overflow-hidden">
355
+ <div class="h-full bg-accent/80" style:width={`${barWidth(value, active.vector)}%`}></div>
356
+ </div>
357
+ <span class={value < 0 ? 'text-warn text-right' : 'text-fg-1 text-right'}>{value.toFixed(5)}</span>
358
+ </div>
359
+ {/each}
360
+ </div>
361
+ {#if active.vector.length > 256}
362
+ <div class="mt-2 text-fg-3">Showing first 256 dimensions.</div>
363
+ {/if}
364
+ {/if}
365
+ {:else}
366
+ <table class="w-full border-collapse">
367
+ <thead class="sticky top-0 bg-bg-0">
368
+ <tr class="border-b border-line-1 text-fg-3">
369
+ <th class="px-2 py-1.5 text-left font-normal">id</th>
370
+ <th class="px-2 py-1.5 text-right font-normal">ranking metric</th>
371
+ <th class="px-2 py-1.5 text-right font-normal">magnitude</th>
372
+ </tr>
373
+ </thead>
374
+ <tbody>
375
+ {#each filtered as row (row.id)}
376
+ <tr class="border-b border-line-1/60 hover:bg-bg-1/40 cursor-pointer" onclick={() => (selected = row)}>
377
+ <td class="px-2 py-1 text-accent">{row.id}</td>
378
+ <td class="px-2 py-1 text-right" title={scalarLabel(row)}>{row.scalar === null ? 'n/a' : row.scalar.toFixed(6)}</td>
379
+ <td class="px-2 py-1 text-right">{vectorMagnitude(row.vector).toFixed(6)}</td>
380
+ </tr>
381
+ {/each}
382
+ </tbody>
383
+ </table>
384
+ {/if}
385
+ </div>
386
+ </div>
387
+ {/if}
388
+ </div>
@@ -0,0 +1,122 @@
1
+ import type { QueryResult } from '#reddb'
2
+
3
+ export interface DiffEntry {
4
+ index: number
5
+ id: string
6
+ change: string
7
+ collection: string | null
8
+ entityId: string | null
9
+ before: unknown
10
+ after: unknown
11
+ note: string | null
12
+ values: Record<string, unknown>
13
+ }
14
+
15
+ export interface DiffFieldChange {
16
+ field: string
17
+ before: unknown
18
+ after: unknown
19
+ status: 'added' | 'removed' | 'modified' | 'unchanged'
20
+ }
21
+
22
+ const CHANGE_COLUMNS = ['change', 'change_type', 'operation', 'op']
23
+ const COLLECTION_COLUMNS = ['target_collection', 'collection_name', 'diff_collection']
24
+ const ENTITY_COLUMNS = ['entity_id', 'row_id', 'rid', 'id']
25
+ const BEFORE_COLUMNS = ['before_state', 'before_row', 'before']
26
+ const AFTER_COLUMNS = ['after_state', 'after_row', 'after']
27
+ const NOTE_COLUMNS = ['note', 'summary', 'message']
28
+
29
+ function pick(values: Record<string, unknown>, names: string[]): [string, unknown] | null {
30
+ for (const name of names) {
31
+ if (name in values) return [name, values[name]]
32
+ }
33
+ return Object.entries(values).find(([k]) => names.includes(k.toLowerCase())) ?? null
34
+ }
35
+
36
+ function parseMaybeJson(value: unknown): unknown {
37
+ if (typeof value !== 'string') return value
38
+ const trimmed = value.trim()
39
+ if (!trimmed || !/^[{[]/.test(trimmed)) return value
40
+ try {
41
+ return JSON.parse(trimmed)
42
+ } catch {
43
+ return value
44
+ }
45
+ }
46
+
47
+ export function extractDiffEntries(result: QueryResult): DiffEntry[] {
48
+ return result.result.records.map((record, index) => {
49
+ const values = record.values ?? {}
50
+ const change = pick(values, CHANGE_COLUMNS)
51
+ const collection = pick(values, COLLECTION_COLUMNS)
52
+ const entity = pick(values, ENTITY_COLUMNS)
53
+ const before = pick(values, BEFORE_COLUMNS)
54
+ const after = pick(values, AFTER_COLUMNS)
55
+ const note = pick(values, NOTE_COLUMNS)
56
+ return {
57
+ index,
58
+ id: String(values.id ?? values.diff_id ?? entity?.[1] ?? index + 1),
59
+ change: String(change?.[1] ?? 'modified'),
60
+ collection: collection?.[1] === undefined || collection?.[1] === null ? null : String(collection[1]),
61
+ entityId: entity?.[1] === undefined || entity?.[1] === null ? null : String(entity[1]),
62
+ before: parseMaybeJson(before?.[1] ?? null),
63
+ after: parseMaybeJson(after?.[1] ?? null),
64
+ note: note?.[1] === undefined || note?.[1] === null ? null : String(note[1]),
65
+ values,
66
+ }
67
+ })
68
+ }
69
+
70
+ export function hasDiffShape(result: QueryResult): boolean {
71
+ if (result.capability === 'diff') return true
72
+ return result.result.records.some((record) => {
73
+ const keys = Object.keys(record.values ?? {}).map((k) => k.toLowerCase())
74
+ return CHANGE_COLUMNS.some((c) => keys.includes(c)) &&
75
+ (BEFORE_COLUMNS.some((c) => keys.includes(c)) || AFTER_COLUMNS.some((c) => keys.includes(c)))
76
+ })
77
+ }
78
+
79
+ export function summarizeDiff(entries: DiffEntry[]) {
80
+ const counts = new Map<string, number>()
81
+ for (const entry of entries) counts.set(entry.change, (counts.get(entry.change) ?? 0) + 1)
82
+ return [...counts.entries()].sort(([a], [b]) => a.localeCompare(b))
83
+ }
84
+
85
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
86
+ return !!value && typeof value === 'object' && !Array.isArray(value)
87
+ }
88
+
89
+ function stableValue(value: unknown): string {
90
+ if (value === undefined) return '__red_ui_undefined__'
91
+ if (isPlainRecord(value)) {
92
+ return JSON.stringify(Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))))
93
+ }
94
+ return JSON.stringify(value)
95
+ }
96
+
97
+ export function diffEntryFields(entry: DiffEntry): DiffFieldChange[] {
98
+ const before = isPlainRecord(entry.before) ? entry.before : {}
99
+ const after = isPlainRecord(entry.after) ? entry.after : {}
100
+ const fields = new Set([...Object.keys(before), ...Object.keys(after)])
101
+
102
+ return [...fields].sort((a, b) => a.localeCompare(b)).map((field) => {
103
+ const beforeHas = Object.prototype.hasOwnProperty.call(before, field)
104
+ const afterHas = Object.prototype.hasOwnProperty.call(after, field)
105
+ const previous = before[field]
106
+ const next = after[field]
107
+ const status = !beforeHas && afterHas
108
+ ? 'added'
109
+ : beforeHas && !afterHas
110
+ ? 'removed'
111
+ : stableValue(previous) === stableValue(next)
112
+ ? 'unchanged'
113
+ : 'modified'
114
+ return { field, before: previous, after: next, status }
115
+ })
116
+ }
117
+
118
+ export function formatDiffValue(value: unknown): string {
119
+ if (value === null || value === undefined || value === '') return '-'
120
+ if (typeof value === 'object') return JSON.stringify(value, null, 2)
121
+ return String(value)
122
+ }
@@ -0,0 +1,9 @@
1
+ import DiffRenderer from './DiffRenderer.svelte'
2
+ import { hasDiffShape } from './diff-render'
3
+ import type { CapabilityRenderer } from './types'
4
+
5
+ export const diffRenderer: CapabilityRenderer = {
6
+ capability: 'diff',
7
+ renders: hasDiffShape,
8
+ component: DiffRenderer,
9
+ }
@@ -0,0 +1,64 @@
1
+ import type { QueryResult } from '#reddb'
2
+
3
+ const SYSTEM_FIELDS = new Set(['rid', 'collection', 'kind', 'tenant', 'created_at', 'updated_at', 'body'])
4
+
5
+ export interface DocumentItem {
6
+ rid: string
7
+ title: string
8
+ subtitle: string
9
+ body: unknown
10
+ fields: Record<string, unknown>
11
+ values: Record<string, unknown>
12
+ }
13
+
14
+ function titleFrom(values: Record<string, unknown>, body: unknown, index: number): string {
15
+ const bodyObject = body && typeof body === 'object' && !Array.isArray(body) ? body as Record<string, unknown> : {}
16
+ for (const key of ['title', 'name', 'slug', 'id', 'event_type', 'type']) {
17
+ const v = values[key] ?? bodyObject[key]
18
+ if (typeof v === 'string' && v.trim()) return v
19
+ if (typeof v === 'number') return String(v)
20
+ }
21
+ const rid = values.rid
22
+ if (typeof rid === 'string' || typeof rid === 'number') return `document ${rid}`
23
+ return `document ${index + 1}`
24
+ }
25
+
26
+ function subtitleFrom(values: Record<string, unknown>, body: unknown): string {
27
+ const bodyObject = body && typeof body === 'object' && !Array.isArray(body) ? body as Record<string, unknown> : {}
28
+ const parts: string[] = []
29
+ for (const key of ['category', 'source', 'status', 'collection']) {
30
+ const v = values[key] ?? bodyObject[key]
31
+ if (typeof v === 'string' && v.trim()) parts.push(v)
32
+ }
33
+ const updated = values.updated_at ?? values.created_at
34
+ if (typeof updated === 'string' || typeof updated === 'number') parts.push(String(updated))
35
+ return parts.join(' · ')
36
+ }
37
+
38
+ export function extractDocuments(result: QueryResult): DocumentItem[] {
39
+ const docs: DocumentItem[] = []
40
+ for (const [index, record] of result.result.records.entries()) {
41
+ const values = record.values ?? {}
42
+ const body = values.body
43
+ if (values.kind !== 'document' && (body === null || typeof body !== 'object')) continue
44
+ const fields: Record<string, unknown> = {}
45
+ for (const [key, value] of Object.entries(values)) {
46
+ if (SYSTEM_FIELDS.has(key.toLowerCase())) continue
47
+ fields[key] = value
48
+ }
49
+ docs.push({
50
+ rid: String(values.rid ?? index + 1),
51
+ title: titleFrom(values, body, index),
52
+ subtitle: subtitleFrom(values, body),
53
+ body,
54
+ fields,
55
+ values,
56
+ })
57
+ }
58
+ return docs
59
+ }
60
+
61
+ export function hasDocumentShape(result: QueryResult): boolean {
62
+ if (result.capability === 'document') return true
63
+ return extractDocuments(result).length > 0
64
+ }
@@ -0,0 +1,9 @@
1
+ import type { CapabilityRenderer } from './types'
2
+ import DocumentRenderer from './DocumentRenderer.svelte'
3
+ import { hasDocumentShape } from './document-render'
4
+
5
+ export const documentRenderer: CapabilityRenderer = {
6
+ capability: 'document',
7
+ renders: hasDocumentShape,
8
+ component: DocumentRenderer,
9
+ }