-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathApp.svelte
More file actions
207 lines (188 loc) · 7.19 KB
/
Copy pathApp.svelte
File metadata and controls
207 lines (188 loc) · 7.19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
<script lang="ts">
// search-and-add — the "Augment from DB" flow's provider-pluggable search
// surface. Launched from any 🔍 on an entity card (spec D2): the envelope
// arrives via localStorage + live CustomEvent, the seed term lands in the
// always-editable TermBar, results fire through search.fire (SearXNG free
// default, palette to swap), and every row's ➕ adds to the launching
// entity's list then broadcasts augment-it:entity-updated so the card
// refetches. Auto-fires once per fresh envelope — searches are reads; the
// gating thesis governs writes, and every write here is one operator click.
// See context-v/specs/Augment-From-DB-Flow.md §Phase 3.
import { onMount } from 'svelte';
import { workspace, resolveWsUrl } from '@augment-it/workspace';
import TermBar from './TermBar.svelte';
import ProviderPalette from './ProviderPalette.svelte';
import ResultsList from './ResultsList.svelte';
import { searchContext } from './lib/search-context.svelte';
import { fireSearch, fetchConnectors, addResult, verbFor, scanStream } from './lib/search-client';
import type { ConnectorInfo, ConnectorResult } from './lib/types';
const TOKEN_KEY = 'augment-it:session-token';
const WS_URL = resolveWsUrl();
let status = $state<'connecting' | 'open' | 'closed' | 'error' | 'auth_required'>('connecting');
let client = $state<string>('reach-edu');
let connectors = $state<ConnectorInfo[]>([]);
let selectedProvider = $state<string | null>(null); // null = auto → SearXNG
let term = $state('');
let firing = $state(false);
let fireError = $state<string | null>(null);
let results = $state<ConnectorResult[]>([]);
let firedVia = $state<string | null>(null);
let firedArrival = -1;
const req = $derived(searchContext.request);
// Phase 5 — scan mode: the envelope carries a stream. No term, no palette;
// the stream URL is the query and organization.stream.scan is the fire.
const scanMode = $derived(Boolean(req?.stream?.url && req?.entity.type === 'organization'));
// (The v1.2 crawl mode is retired — didi's crawls enqueue via search.submit
// and land in the search-results rail, per Search-Results-Queue-Remote.)
const entityLabel = $derived(
req
? req.entity.display_name ??
(req.entity.type === 'organization' ? req.entity.org_slug : req.entity.person_uuid)
: null,
);
const addVerb = $derived.by(() => {
if (!req) return null;
try {
return verbFor(req);
} catch {
return null;
}
});
async function fire() {
if (scanMode) return void scan();
if (!term.trim()) return;
firing = true;
fireError = null;
try {
const r = await fireSearch({
query: term.trim(),
intent: req?.intent,
provider: selectedProvider ?? undefined,
});
results = r.results;
firedVia = r.provider;
} catch (err) {
fireError = err instanceof Error ? err.message : String(err);
results = [];
firedVia = null;
} finally {
firing = false;
}
}
async function scan() {
if (!req?.stream?.url || req.entity.type !== 'organization') return;
firing = true;
fireError = null;
try {
const r = await scanStream({
org_slug: req.entity.org_slug,
stream_url: req.stream.url,
stream_kind: req.stream.kind,
client,
});
results = r.results;
firedVia = `stream scan (${r.already_known} already in corpus)`;
} catch (err) {
fireError = err instanceof Error ? err.message : String(err);
results = [];
firedVia = null;
} finally {
firing = false;
}
}
// A fresh envelope (mount-time localStorage read counts, via arrival 0 vs
// firedArrival -1) seeds the term and auto-fires exactly once. Later
// operator edits + re-fires never re-trigger this. When the envelope
// arrives at mount time the WS may still be connecting — invoke() would
// reject with "workspace not connected" — so the auto-fire waits for the
// socket via pendingAutoFire instead of racing it.
let pendingAutoFire = $state(false);
function autoFire() {
void (scanMode ? scan() : fire());
}
$effect(() => {
if (searchContext.arrival !== firedArrival || (firedArrival === -1 && req)) {
firedArrival = searchContext.arrival;
if (req) {
term = req.seed_term;
results = [];
firedVia = null;
fireError = null;
if (status === 'open') autoFire();
else pendingAutoFire = true;
}
}
});
$effect(() => {
if (status === 'open' && pendingAutoFire) {
pendingAutoFire = false;
autoFire();
}
});
async function onAdd(url: string) {
if (!req) throw new Error('no launch context — open a 🔍 from an entity card');
await addResult(req, url, client);
}
async function loadActiveClient() {
try {
const r = (await workspace.invoke('workspace.active', {})) as { active_client_id?: string };
if (r?.active_client_id) client = r.active_client_id;
} catch {
/* keep default */
}
}
function onWorkspaceChanged(e: Event) {
const detail = (e as CustomEvent).detail as { client_id?: string } | undefined;
if (detail?.client_id) client = detail.client_id;
else void loadActiveClient();
}
onMount(() => {
workspace.connect({
url: WS_URL,
getToken: () => localStorage.getItem(TOKEN_KEY),
saveToken: (t) => localStorage.setItem(TOKEN_KEY, t),
onStatus: (s) => (status = s),
});
void loadActiveClient();
void fetchConnectors().then((c) => (connectors = c)).catch(() => {});
const unlisten = searchContext.listen();
window.addEventListener('augment-it:workspace-changed', onWorkspaceChanged);
return () => {
unlisten();
window.removeEventListener('augment-it:workspace-changed', onWorkspaceChanged);
};
});
</script>
<div class="saa-app">
<header class="saa-header">
<div class="saa-title-row">
<h1 class="saa-title">Search & Add</h1>
{#if req}
<span class="saa-context">
adding to <strong>{entityLabel}</strong> · {req.target}
{#if !addVerb}<em class="saa-context-warn">(no add verb for this combination)</em>{/if}
</span>
{:else}
<span class="saa-context saa-context-none">no launch context — open a 🔍 from an entity card</span>
{/if}
<span class="saa-ws status-{status}">{status}</span>
</div>
{#if scanMode && req?.stream}
<div class="saa-scanbar">
<span class="saa-scan-label">scanning stream</span>
<a class="saa-scan-url" href={req.stream.url} target="_blank" rel="noreferrer">{req.stream.url}</a>
{#if req.stream.kind}<span class="saa-scan-kind">{req.stream.kind}</span>{/if}
<button type="button" class="saa-fire" disabled={firing} onclick={scan}>
{firing ? 'scanning…' : 'Re-scan'}
</button>
</div>
{:else}
<TermBar bind:term {firing} onfire={fire} />
<ProviderPalette {connectors} bind:selected={selectedProvider} />
{/if}
{#if fireError}<div class="saa-error saa-fire-error">{fireError}</div>{/if}
</header>
<main class="saa-body">
<ResultsList {results} provider={firedVia} onadd={onAdd} />
</main>
</div>