birdclaw 0.6.0 → 0.8.0
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.
- package/CHANGELOG.md +64 -0
- package/README.md +32 -2
- package/package.json +29 -30
- package/scripts/build-docs-site.mjs +39 -12
- package/src/cli.ts +457 -26
- package/src/components/AppNav.tsx +10 -0
- package/src/components/MarkdownViewer.tsx +438 -72
- package/src/components/ProfileAnalysisStream.tsx +428 -0
- package/src/components/ProfilePreview.tsx +120 -9
- package/src/components/SavedTimelineView.tsx +30 -8
- package/src/components/SyncNowButton.tsx +5 -2
- package/src/components/TimelineCard.tsx +20 -4
- package/src/components/TimelineRouteFrame.tsx +16 -0
- package/src/components/TweetRichText.tsx +36 -12
- package/src/components/useTimelineRouteData.ts +74 -6
- package/src/lib/account-sync-job.ts +15 -3
- package/src/lib/archive-finder.ts +1 -1
- package/src/lib/archive-import.ts +245 -7
- package/src/lib/authored-live.ts +1 -0
- package/src/lib/avatar-cache.ts +50 -0
- package/src/lib/backup.ts +4 -3
- package/src/lib/bird.ts +33 -0
- package/src/lib/config.ts +35 -2
- package/src/lib/data-sources.ts +219 -0
- package/src/lib/db.ts +62 -1
- package/src/lib/geocoding.ts +296 -0
- package/src/lib/link-insights.ts +2 -0
- package/src/lib/location.ts +137 -0
- package/src/lib/mention-threads-live.ts +94 -1
- package/src/lib/mentions-live.ts +187 -40
- package/src/lib/network-map.ts +382 -0
- package/src/lib/period-digest.ts +468 -29
- package/src/lib/profile-analysis.ts +1272 -0
- package/src/lib/profile-bio-entities.ts +1 -1
- package/src/lib/queries.ts +14 -4
- package/src/lib/search-discussion.ts +1016 -0
- package/src/lib/timeline-live.ts +272 -19
- package/src/lib/tweet-account-edges.ts +2 -0
- package/src/lib/tweet-render.ts +141 -1
- package/src/lib/tweet-search-live.ts +565 -0
- package/src/lib/types.ts +37 -2
- package/src/lib/ui.ts +1 -1
- package/src/lib/web-sync.ts +7 -2
- package/src/lib/xurl-rate-limits.ts +267 -0
- package/src/lib/xurl.ts +551 -41
- package/src/routeTree.gen.ts +231 -0
- package/src/routes/__root.tsx +5 -6
- package/src/routes/api/data-sources.tsx +24 -0
- package/src/routes/api/network-map.tsx +55 -0
- package/src/routes/api/period-digest.tsx +29 -3
- package/src/routes/api/profile-analysis.tsx +152 -0
- package/src/routes/api/query.tsx +1 -0
- package/src/routes/api/search-discussion.tsx +169 -0
- package/src/routes/api/xurl-rate-limits.tsx +24 -0
- package/src/routes/data-sources.tsx +257 -0
- package/src/routes/discuss.tsx +419 -0
- package/src/routes/network-map.tsx +1035 -0
- package/src/routes/profile-analyze.tsx +112 -0
- package/src/routes/profiles.$handle.tsx +228 -0
- package/src/routes/rate-limits.tsx +309 -0
- package/src/routes/today.tsx +22 -8
- package/src/styles.css +22 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import {
|
|
3
|
+
ChevronDown,
|
|
4
|
+
CheckCircle2,
|
|
5
|
+
Loader2,
|
|
6
|
+
RefreshCw,
|
|
7
|
+
Search,
|
|
8
|
+
Sparkles,
|
|
9
|
+
} from "lucide-react";
|
|
10
|
+
import {
|
|
11
|
+
type FormEvent,
|
|
12
|
+
useCallback,
|
|
13
|
+
useEffect,
|
|
14
|
+
useMemo,
|
|
15
|
+
useRef,
|
|
16
|
+
useState,
|
|
17
|
+
} from "react";
|
|
18
|
+
import { MarkdownViewer } from "#/components/MarkdownViewer";
|
|
19
|
+
import type {
|
|
20
|
+
SearchDiscussionContext,
|
|
21
|
+
SearchDiscussionRunResult,
|
|
22
|
+
SearchDiscussionSource,
|
|
23
|
+
SearchDiscussionStreamEvent,
|
|
24
|
+
} from "#/lib/search-discussion";
|
|
25
|
+
import type { TweetSearchMode } from "#/lib/tweet-search-live";
|
|
26
|
+
import {
|
|
27
|
+
cx,
|
|
28
|
+
errorCopyClass,
|
|
29
|
+
pageHeaderActionsClass,
|
|
30
|
+
pageHeaderClass,
|
|
31
|
+
pageHeaderRowClass,
|
|
32
|
+
pageSubtitleClass,
|
|
33
|
+
pageTitleClass,
|
|
34
|
+
primaryButtonClass,
|
|
35
|
+
searchFieldIconClass,
|
|
36
|
+
searchFieldInputClass,
|
|
37
|
+
searchFieldShellClass,
|
|
38
|
+
secondaryButtonClass,
|
|
39
|
+
selectFieldClass,
|
|
40
|
+
textFieldClass,
|
|
41
|
+
} from "#/lib/ui";
|
|
42
|
+
|
|
43
|
+
export const Route = createFileRoute("/discuss")({
|
|
44
|
+
component: DiscussRoute,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const sources: Array<{ value: SearchDiscussionSource; label: string }> = [
|
|
48
|
+
{ value: "search", label: "Live search" },
|
|
49
|
+
{ value: "all", label: "All local" },
|
|
50
|
+
{ value: "home", label: "Home" },
|
|
51
|
+
{ value: "mentions", label: "Mentions" },
|
|
52
|
+
{ value: "authored", label: "Authored" },
|
|
53
|
+
{ value: "likes", label: "Likes" },
|
|
54
|
+
{ value: "bookmarks", label: "Bookmarks" },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const modes: Array<{ value: TweetSearchMode; label: string }> = [
|
|
58
|
+
{ value: "auto", label: "Auto" },
|
|
59
|
+
{ value: "bird", label: "Bird" },
|
|
60
|
+
{ value: "xurl", label: "xurl" },
|
|
61
|
+
{ value: "local", label: "Local" },
|
|
62
|
+
];
|
|
63
|
+
const DISCUSS_SEARCH_LIMIT = 20_000;
|
|
64
|
+
const DISCUSS_MAX_PAGES = 200;
|
|
65
|
+
|
|
66
|
+
function discussionUrl(
|
|
67
|
+
query: string,
|
|
68
|
+
options: {
|
|
69
|
+
source: SearchDiscussionSource;
|
|
70
|
+
mode: TweetSearchMode;
|
|
71
|
+
includeDms: boolean;
|
|
72
|
+
question: string;
|
|
73
|
+
refresh: boolean;
|
|
74
|
+
},
|
|
75
|
+
) {
|
|
76
|
+
const url = new URL("/api/search-discussion", window.location.origin);
|
|
77
|
+
url.searchParams.set("query", query);
|
|
78
|
+
url.searchParams.set("source", options.source);
|
|
79
|
+
url.searchParams.set("mode", options.mode);
|
|
80
|
+
url.searchParams.set("includeDms", String(options.includeDms));
|
|
81
|
+
url.searchParams.set("limit", String(DISCUSS_SEARCH_LIMIT));
|
|
82
|
+
url.searchParams.set("maxPages", String(DISCUSS_MAX_PAGES));
|
|
83
|
+
if (options.question.trim()) {
|
|
84
|
+
url.searchParams.set("question", options.question.trim());
|
|
85
|
+
}
|
|
86
|
+
if (options.refresh) {
|
|
87
|
+
url.searchParams.set("refresh", "true");
|
|
88
|
+
}
|
|
89
|
+
return url;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function DropdownField<T extends string>({
|
|
93
|
+
label,
|
|
94
|
+
value,
|
|
95
|
+
options,
|
|
96
|
+
onChange,
|
|
97
|
+
}: {
|
|
98
|
+
label: string;
|
|
99
|
+
value: T;
|
|
100
|
+
options: Array<{ value: T; label: string }>;
|
|
101
|
+
onChange: (value: T) => void;
|
|
102
|
+
}) {
|
|
103
|
+
return (
|
|
104
|
+
<label className="relative min-w-0">
|
|
105
|
+
<span className="pointer-events-none absolute left-3 top-1.5 z-10 text-[11px] font-semibold uppercase tracking-[0.08em] text-[var(--ink-soft)]">
|
|
106
|
+
{label}
|
|
107
|
+
</span>
|
|
108
|
+
<select
|
|
109
|
+
aria-label={label}
|
|
110
|
+
className={cx(
|
|
111
|
+
selectFieldClass,
|
|
112
|
+
"h-[54px] rounded-2xl bg-[var(--bg)] pb-1 pl-3 pr-9 pt-5 font-semibold text-[var(--ink)]",
|
|
113
|
+
)}
|
|
114
|
+
value={value}
|
|
115
|
+
onChange={(event) => onChange(event.currentTarget.value as T)}
|
|
116
|
+
>
|
|
117
|
+
{options.map((item) => (
|
|
118
|
+
<option key={item.value} value={item.value}>
|
|
119
|
+
{item.label}
|
|
120
|
+
</option>
|
|
121
|
+
))}
|
|
122
|
+
</select>
|
|
123
|
+
<ChevronDown
|
|
124
|
+
aria-hidden="true"
|
|
125
|
+
className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[var(--ink-soft)]"
|
|
126
|
+
strokeWidth={2}
|
|
127
|
+
/>
|
|
128
|
+
</label>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function discussionRequestError(response: Response) {
|
|
133
|
+
const status = `${String(response.status)}${response.statusText ? ` ${response.statusText}` : ""}`;
|
|
134
|
+
let detail = "";
|
|
135
|
+
try {
|
|
136
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
137
|
+
if (contentType.includes("application/json")) {
|
|
138
|
+
const payload = (await response.json()) as {
|
|
139
|
+
error?: unknown;
|
|
140
|
+
message?: unknown;
|
|
141
|
+
};
|
|
142
|
+
if (typeof payload.message === "string") detail = payload.message;
|
|
143
|
+
else if (typeof payload.error === "string") detail = payload.error;
|
|
144
|
+
} else {
|
|
145
|
+
detail = (await response.text()).trim();
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
detail = "";
|
|
149
|
+
}
|
|
150
|
+
return new Error(
|
|
151
|
+
detail
|
|
152
|
+
? `Discussion request failed (${status}): ${detail}`
|
|
153
|
+
: `Discussion request failed (${status})`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function formatCounts(context: SearchDiscussionContext | null) {
|
|
158
|
+
if (!context) return "Live keyword search with local memory.";
|
|
159
|
+
const counts = context.counts;
|
|
160
|
+
const live = context.liveSearch
|
|
161
|
+
? context.liveSearch.ok
|
|
162
|
+
? `${context.liveSearch.source} ${String(context.liveSearch.count)} fetched`
|
|
163
|
+
: `${context.liveSearch.source} failed`
|
|
164
|
+
: "local";
|
|
165
|
+
return [
|
|
166
|
+
live,
|
|
167
|
+
`${String(counts.search)} search`,
|
|
168
|
+
`${String(counts.home + counts.mentions + counts.authored)} timeline`,
|
|
169
|
+
`${String(counts.likes + counts.bookmarks)} saved`,
|
|
170
|
+
context.includeDms ? `${String(counts.dms)} DMs` : null,
|
|
171
|
+
]
|
|
172
|
+
.filter(Boolean)
|
|
173
|
+
.join(" · ");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function useDiscussionStream(
|
|
177
|
+
query: string,
|
|
178
|
+
source: SearchDiscussionSource,
|
|
179
|
+
mode: TweetSearchMode,
|
|
180
|
+
includeDms: boolean,
|
|
181
|
+
question: string,
|
|
182
|
+
) {
|
|
183
|
+
const [markdown, setMarkdown] = useState("");
|
|
184
|
+
const [context, setContext] = useState<SearchDiscussionContext | null>(null);
|
|
185
|
+
const [result, setResult] = useState<SearchDiscussionRunResult | null>(null);
|
|
186
|
+
const [error, setError] = useState<string | null>(null);
|
|
187
|
+
const [loading, setLoading] = useState(false);
|
|
188
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
189
|
+
const requestIdRef = useRef(0);
|
|
190
|
+
|
|
191
|
+
const run = useCallback(
|
|
192
|
+
(refresh = false) => {
|
|
193
|
+
const trimmed = query.trim();
|
|
194
|
+
if (!trimmed) return;
|
|
195
|
+
abortRef.current?.abort();
|
|
196
|
+
const controller = new AbortController();
|
|
197
|
+
const requestId = requestIdRef.current + 1;
|
|
198
|
+
requestIdRef.current = requestId;
|
|
199
|
+
abortRef.current = controller;
|
|
200
|
+
const isActiveRequest = () =>
|
|
201
|
+
abortRef.current === controller &&
|
|
202
|
+
requestIdRef.current === requestId &&
|
|
203
|
+
!controller.signal.aborted;
|
|
204
|
+
setMarkdown("");
|
|
205
|
+
setContext(null);
|
|
206
|
+
setResult(null);
|
|
207
|
+
setError(null);
|
|
208
|
+
setLoading(true);
|
|
209
|
+
|
|
210
|
+
fetch(
|
|
211
|
+
discussionUrl(trimmed, {
|
|
212
|
+
source,
|
|
213
|
+
mode,
|
|
214
|
+
includeDms,
|
|
215
|
+
question,
|
|
216
|
+
refresh,
|
|
217
|
+
}),
|
|
218
|
+
{ signal: controller.signal },
|
|
219
|
+
)
|
|
220
|
+
.then(async (response) => {
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
throw await discussionRequestError(response);
|
|
223
|
+
}
|
|
224
|
+
if (!response.body) {
|
|
225
|
+
throw new Error("Discussion request failed: empty response body");
|
|
226
|
+
}
|
|
227
|
+
const reader = response.body.getReader();
|
|
228
|
+
const decoder = new TextDecoder();
|
|
229
|
+
let buffer = "";
|
|
230
|
+
const pump = (): Promise<void> =>
|
|
231
|
+
reader.read().then(({ done, value }) => {
|
|
232
|
+
if (!isActiveRequest()) return;
|
|
233
|
+
if (done) return;
|
|
234
|
+
buffer += decoder.decode(value, { stream: true });
|
|
235
|
+
let newline = buffer.indexOf("\n");
|
|
236
|
+
while (newline >= 0) {
|
|
237
|
+
const line = buffer.slice(0, newline).trim();
|
|
238
|
+
buffer = buffer.slice(newline + 1);
|
|
239
|
+
if (line) {
|
|
240
|
+
const event = JSON.parse(line) as SearchDiscussionStreamEvent;
|
|
241
|
+
if (!isActiveRequest()) return;
|
|
242
|
+
if (event.type === "start") {
|
|
243
|
+
setContext(event.context);
|
|
244
|
+
} else if (event.type === "delta") {
|
|
245
|
+
setMarkdown((current) => current + event.delta);
|
|
246
|
+
} else if (event.type === "done") {
|
|
247
|
+
setResult(event.result);
|
|
248
|
+
setContext(event.result.context);
|
|
249
|
+
setMarkdown(event.result.markdown);
|
|
250
|
+
} else if (event.type === "error") {
|
|
251
|
+
setError(event.error);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
newline = buffer.indexOf("\n");
|
|
255
|
+
}
|
|
256
|
+
return pump();
|
|
257
|
+
});
|
|
258
|
+
return pump();
|
|
259
|
+
})
|
|
260
|
+
.catch((cause: unknown) => {
|
|
261
|
+
if (!isActiveRequest()) return;
|
|
262
|
+
setError(
|
|
263
|
+
cause instanceof Error ? cause.message : "Discussion failed",
|
|
264
|
+
);
|
|
265
|
+
})
|
|
266
|
+
.finally(() => {
|
|
267
|
+
if (isActiveRequest()) {
|
|
268
|
+
setLoading(false);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
[includeDms, mode, query, question, source],
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
useEffect(() => () => abortRef.current?.abort(), []);
|
|
276
|
+
|
|
277
|
+
return { context, error, loading, markdown, result, run };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function DiscussRoute() {
|
|
281
|
+
const [query, setQuery] = useState("");
|
|
282
|
+
const [submittedQuery, setSubmittedQuery] = useState("");
|
|
283
|
+
const [question, setQuestion] = useState("");
|
|
284
|
+
const [source, setSource] = useState<SearchDiscussionSource>("search");
|
|
285
|
+
const [mode, setMode] = useState<TweetSearchMode>("xurl");
|
|
286
|
+
const [includeDms, setIncludeDms] = useState(false);
|
|
287
|
+
const pendingSubmitRef = useRef(false);
|
|
288
|
+
const { context, error, loading, markdown, result, run } =
|
|
289
|
+
useDiscussionStream(submittedQuery, source, mode, includeDms, question);
|
|
290
|
+
const sourceLabel = useMemo(
|
|
291
|
+
() => formatCounts(result?.context ?? context),
|
|
292
|
+
[context, result],
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
function submit(event: FormEvent) {
|
|
296
|
+
event.preventDefault();
|
|
297
|
+
const trimmed = query.trim();
|
|
298
|
+
if (!trimmed) return;
|
|
299
|
+
pendingSubmitRef.current = true;
|
|
300
|
+
setSubmittedQuery(trimmed);
|
|
301
|
+
if (trimmed === submittedQuery) {
|
|
302
|
+
pendingSubmitRef.current = false;
|
|
303
|
+
run(false);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
if (!submittedQuery || !pendingSubmitRef.current) return;
|
|
309
|
+
pendingSubmitRef.current = false;
|
|
310
|
+
run(false);
|
|
311
|
+
}, [run, submittedQuery]);
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<div className="flex min-h-screen flex-col">
|
|
315
|
+
<header className={pageHeaderClass}>
|
|
316
|
+
<div className={pageHeaderRowClass}>
|
|
317
|
+
<div className="min-w-0">
|
|
318
|
+
<h1 className={pageTitleClass}>Discuss</h1>
|
|
319
|
+
<p className={pageSubtitleClass}>{sourceLabel}</p>
|
|
320
|
+
</div>
|
|
321
|
+
<div className={pageHeaderActionsClass}>
|
|
322
|
+
<button
|
|
323
|
+
type="button"
|
|
324
|
+
className={secondaryButtonClass}
|
|
325
|
+
onClick={() => run(true)}
|
|
326
|
+
disabled={loading || !submittedQuery}
|
|
327
|
+
>
|
|
328
|
+
<RefreshCw
|
|
329
|
+
className={cx("size-4", loading && "animate-spin")}
|
|
330
|
+
aria-hidden="true"
|
|
331
|
+
/>
|
|
332
|
+
Refresh
|
|
333
|
+
</button>
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
<form
|
|
337
|
+
className="grid gap-2 px-4 pb-3 md:grid-cols-[minmax(220px,1fr)_minmax(180px,0.8fr)_auto]"
|
|
338
|
+
onSubmit={submit}
|
|
339
|
+
>
|
|
340
|
+
<label className={searchFieldShellClass}>
|
|
341
|
+
<Search className={searchFieldIconClass} strokeWidth={2} />
|
|
342
|
+
<input
|
|
343
|
+
className={searchFieldInputClass}
|
|
344
|
+
placeholder="Keywords"
|
|
345
|
+
value={query}
|
|
346
|
+
onChange={(event) => setQuery(event.currentTarget.value)}
|
|
347
|
+
/>
|
|
348
|
+
</label>
|
|
349
|
+
<input
|
|
350
|
+
className={textFieldClass}
|
|
351
|
+
placeholder="Optional question"
|
|
352
|
+
value={question}
|
|
353
|
+
onChange={(event) => setQuestion(event.currentTarget.value)}
|
|
354
|
+
/>
|
|
355
|
+
<button
|
|
356
|
+
type="submit"
|
|
357
|
+
className={primaryButtonClass}
|
|
358
|
+
disabled={loading || !query.trim()}
|
|
359
|
+
>
|
|
360
|
+
<Sparkles className="size-4" aria-hidden="true" />
|
|
361
|
+
Discuss
|
|
362
|
+
</button>
|
|
363
|
+
<div className="grid gap-2 md:col-span-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
|
|
364
|
+
<DropdownField
|
|
365
|
+
label="Source"
|
|
366
|
+
options={sources}
|
|
367
|
+
value={source}
|
|
368
|
+
onChange={setSource}
|
|
369
|
+
/>
|
|
370
|
+
<DropdownField
|
|
371
|
+
label="Mode"
|
|
372
|
+
options={modes}
|
|
373
|
+
value={mode}
|
|
374
|
+
onChange={setMode}
|
|
375
|
+
/>
|
|
376
|
+
<label className="inline-flex h-[54px] items-center gap-2 rounded-2xl border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-medium text-[var(--ink-soft)]">
|
|
377
|
+
<input
|
|
378
|
+
type="checkbox"
|
|
379
|
+
checked={includeDms}
|
|
380
|
+
onChange={(event) => setIncludeDms(event.currentTarget.checked)}
|
|
381
|
+
/>
|
|
382
|
+
DMs
|
|
383
|
+
</label>
|
|
384
|
+
</div>
|
|
385
|
+
</form>
|
|
386
|
+
</header>
|
|
387
|
+
|
|
388
|
+
{error ? <div className={errorCopyClass}>{error}</div> : null}
|
|
389
|
+
|
|
390
|
+
<div className="border-b border-[var(--line)] px-4 py-2 text-[13px] text-[var(--ink-soft)]">
|
|
391
|
+
<span className="inline-flex items-center gap-1">
|
|
392
|
+
{loading ? (
|
|
393
|
+
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
|
394
|
+
) : markdown ? (
|
|
395
|
+
<CheckCircle2 className="size-4" aria-hidden="true" />
|
|
396
|
+
) : (
|
|
397
|
+
<Sparkles className="size-4" aria-hidden="true" />
|
|
398
|
+
)}
|
|
399
|
+
{loading
|
|
400
|
+
? "Searching and streaming"
|
|
401
|
+
: result
|
|
402
|
+
? `${result.cached ? "Cached" : "Ready"} · ${result.context.query}`
|
|
403
|
+
: "Ready"}
|
|
404
|
+
</span>
|
|
405
|
+
</div>
|
|
406
|
+
|
|
407
|
+
{markdown ? (
|
|
408
|
+
<MarkdownViewer
|
|
409
|
+
context={(result?.context ?? context) as never}
|
|
410
|
+
markdown={markdown}
|
|
411
|
+
/>
|
|
412
|
+
) : (
|
|
413
|
+
<div className="px-4 py-5 text-[14px] text-[var(--ink-soft)]">
|
|
414
|
+
{loading ? "Waiting for the first tokens..." : "Search to begin."}
|
|
415
|
+
</div>
|
|
416
|
+
)}
|
|
417
|
+
</div>
|
|
418
|
+
);
|
|
419
|
+
}
|