birdclaw 0.5.1 → 0.7.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 +92 -1
- package/README.md +75 -5
- package/package.json +8 -2
- package/scripts/browser-perf.mjs +1 -0
- package/scripts/start-test-server.mjs +16 -3
- package/src/cli.ts +812 -37
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +37 -7
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/DmWorkspace.tsx +18 -8
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +818 -0
- 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 +60 -25
- package/src/components/ThemeSlider.tsx +55 -50
- package/src/components/TimelineCard.tsx +240 -92
- package/src/components/TimelineRouteFrame.tsx +38 -8
- package/src/components/TweetMediaGrid.tsx +87 -38
- package/src/components/TweetRichText.tsx +45 -17
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +97 -13
- package/src/lib/account-sync-job.ts +666 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +128 -53
- package/src/lib/archive-finder.ts +78 -63
- package/src/lib/archive-import.ts +1593 -1291
- package/src/lib/authored-live.ts +262 -204
- package/src/lib/avatar-cache.ts +208 -43
- package/src/lib/backup.ts +1536 -954
- package/src/lib/bird-actions.ts +139 -57
- package/src/lib/bird-command.ts +101 -28
- package/src/lib/bird.ts +582 -194
- package/src/lib/blocklist.ts +40 -23
- package/src/lib/blocks-write.ts +129 -80
- package/src/lib/blocks.ts +165 -97
- package/src/lib/bookmark-sync-job.ts +250 -160
- package/src/lib/config.ts +35 -2
- package/src/lib/conversation-surface.ts +79 -48
- package/src/lib/data-sources.ts +219 -0
- package/src/lib/db.ts +95 -4
- package/src/lib/dms-live.ts +720 -66
- package/src/lib/effect-runtime.ts +45 -0
- package/src/lib/follow-graph.ts +224 -180
- package/src/lib/geocoding.ts +296 -0
- package/src/lib/http-effect.ts +222 -0
- package/src/lib/inbox.ts +74 -43
- package/src/lib/link-index.ts +88 -76
- package/src/lib/link-insights.ts +24 -0
- package/src/lib/link-preview-metadata.ts +472 -52
- package/src/lib/location.ts +137 -0
- package/src/lib/media-fetch.ts +286 -213
- package/src/lib/mention-threads-live.ts +445 -288
- package/src/lib/mentions-live.ts +549 -354
- package/src/lib/moderation-target.ts +102 -65
- package/src/lib/moderation-write.ts +77 -18
- package/src/lib/mutes-write.ts +129 -80
- package/src/lib/mutes.ts +8 -1
- package/src/lib/network-map.ts +382 -0
- package/src/lib/openai.ts +84 -53
- package/src/lib/period-digest.ts +1317 -0
- package/src/lib/profile-affiliation-hydration.ts +93 -54
- package/src/lib/profile-analysis.ts +1272 -0
- package/src/lib/profile-bio-entities.ts +1 -1
- package/src/lib/profile-hydration.ts +124 -72
- package/src/lib/profile-replies.ts +60 -43
- package/src/lib/profile-resolver.ts +402 -294
- package/src/lib/queries.ts +983 -203
- package/src/lib/research.ts +165 -120
- package/src/lib/search-discussion.ts +1016 -0
- package/src/lib/sqlite.ts +1 -0
- package/src/lib/timeline-collections-live.ts +204 -167
- package/src/lib/timeline-live.ts +325 -51
- package/src/lib/tweet-account-edges.ts +2 -0
- package/src/lib/tweet-lookup.ts +30 -19
- package/src/lib/tweet-render.ts +141 -1
- package/src/lib/tweet-search-live.ts +565 -0
- package/src/lib/types.ts +75 -3
- package/src/lib/ui.ts +31 -8
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +222 -149
- package/src/lib/whois.ts +166 -147
- package/src/lib/x-web.ts +102 -71
- package/src/lib/xurl-rate-limits.ts +267 -0
- package/src/lib/xurl.ts +1185 -405
- package/src/routeTree.gen.ts +273 -0
- package/src/routes/__root.tsx +24 -5
- package/src/routes/api/action.tsx +127 -78
- package/src/routes/api/avatar.tsx +39 -30
- package/src/routes/api/blocks.tsx +26 -23
- package/src/routes/api/conversation.tsx +25 -14
- package/src/routes/api/data-sources.tsx +24 -0
- package/src/routes/api/inbox.tsx +27 -21
- package/src/routes/api/link-insights.tsx +31 -25
- package/src/routes/api/link-preview.tsx +25 -21
- package/src/routes/api/network-map.tsx +55 -0
- package/src/routes/api/period-digest.tsx +133 -0
- package/src/routes/api/profile-analysis.tsx +152 -0
- package/src/routes/api/profile-hydrate.tsx +31 -28
- package/src/routes/api/query.tsx +80 -55
- package/src/routes/api/search-discussion.tsx +169 -0
- package/src/routes/api/status.tsx +18 -10
- package/src/routes/api/sync.tsx +75 -29
- package/src/routes/api/xurl-rate-limits.tsx +24 -0
- package/src/routes/data-sources.tsx +255 -0
- package/src/routes/discuss.tsx +419 -0
- package/src/routes/dms.tsx +95 -28
- package/src/routes/inbox.tsx +32 -19
- 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 +455 -0
- package/src/styles.css +22 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
|
|
4
|
+
import { runEffectBackground } from "#/lib/effect-runtime";
|
|
5
|
+
import {
|
|
6
|
+
parseBoundedInteger,
|
|
7
|
+
runRouteEffect,
|
|
8
|
+
sensitiveRequestErrorResponse,
|
|
9
|
+
} from "#/lib/http-effect";
|
|
10
|
+
import {
|
|
11
|
+
streamSearchDiscussionEffect,
|
|
12
|
+
type SearchDiscussionOptions,
|
|
13
|
+
type SearchDiscussionSource,
|
|
14
|
+
type SearchDiscussionStreamEvent,
|
|
15
|
+
} from "#/lib/search-discussion";
|
|
16
|
+
import type { TweetSearchMode } from "#/lib/tweet-search-live";
|
|
17
|
+
|
|
18
|
+
const encoder = new TextEncoder();
|
|
19
|
+
const MAX_DISCUSSION_SEARCH_LIMIT = 20_000;
|
|
20
|
+
const MAX_DISCUSSION_SEARCH_PAGES = 200;
|
|
21
|
+
|
|
22
|
+
function parseBoolean(value: string | null) {
|
|
23
|
+
return value === "true" || value === "1" || value === "yes";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseSource(value: string | null): SearchDiscussionSource {
|
|
27
|
+
if (
|
|
28
|
+
value === "all" ||
|
|
29
|
+
value === "home" ||
|
|
30
|
+
value === "mentions" ||
|
|
31
|
+
value === "authored" ||
|
|
32
|
+
value === "search" ||
|
|
33
|
+
value === "likes" ||
|
|
34
|
+
value === "bookmarks"
|
|
35
|
+
) {
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
return "search";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseMode(value: string | null): TweetSearchMode {
|
|
42
|
+
if (
|
|
43
|
+
value === "auto" ||
|
|
44
|
+
value === "bird" ||
|
|
45
|
+
value === "xurl" ||
|
|
46
|
+
value === "local"
|
|
47
|
+
) {
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
return "auto";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseOptions(url: URL): SearchDiscussionOptions {
|
|
54
|
+
return {
|
|
55
|
+
query: url.searchParams.get("query") ?? "",
|
|
56
|
+
account: url.searchParams.get("account") ?? undefined,
|
|
57
|
+
source: parseSource(url.searchParams.get("source")),
|
|
58
|
+
mode: parseMode(url.searchParams.get("mode")),
|
|
59
|
+
includeDms: parseBoolean(url.searchParams.get("includeDms")),
|
|
60
|
+
since: url.searchParams.get("since") ?? undefined,
|
|
61
|
+
until: url.searchParams.get("until") ?? undefined,
|
|
62
|
+
question: url.searchParams.get("question") ?? undefined,
|
|
63
|
+
originalsOnly: parseBoolean(url.searchParams.get("originalsOnly")),
|
|
64
|
+
hideLowQuality: parseBoolean(url.searchParams.get("hideLowQuality")),
|
|
65
|
+
refresh: parseBoolean(url.searchParams.get("refresh")),
|
|
66
|
+
model: url.searchParams.get("model") === "gpt-5.5" ? "gpt-5.5" : undefined,
|
|
67
|
+
limit: parseBoundedInteger(url.searchParams.get("limit"), {
|
|
68
|
+
max: MAX_DISCUSSION_SEARCH_LIMIT,
|
|
69
|
+
}),
|
|
70
|
+
maxPages: parseBoundedInteger(url.searchParams.get("maxPages"), {
|
|
71
|
+
max: MAX_DISCUSSION_SEARCH_PAGES,
|
|
72
|
+
}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function encodeEvent(event: SearchDiscussionStreamEvent) {
|
|
77
|
+
return encoder.encode(`${JSON.stringify(event)}\n`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const Route = createFileRoute("/api/search-discussion")({
|
|
81
|
+
server: {
|
|
82
|
+
handlers: {
|
|
83
|
+
GET: ({ request }) =>
|
|
84
|
+
runRouteEffect(
|
|
85
|
+
Effect.sync(() => {
|
|
86
|
+
const denied = sensitiveRequestErrorResponse(request);
|
|
87
|
+
if (denied) return denied;
|
|
88
|
+
|
|
89
|
+
const url = new URL(request.url);
|
|
90
|
+
const options = parseOptions(url);
|
|
91
|
+
let abortDiscussion: (() => void) | undefined;
|
|
92
|
+
|
|
93
|
+
return new Response(
|
|
94
|
+
new ReadableStream({
|
|
95
|
+
cancel() {
|
|
96
|
+
abortDiscussion?.();
|
|
97
|
+
},
|
|
98
|
+
start(controller) {
|
|
99
|
+
const abortController = new AbortController();
|
|
100
|
+
let closed = false;
|
|
101
|
+
const close = () => {
|
|
102
|
+
closed = true;
|
|
103
|
+
abortController.abort();
|
|
104
|
+
};
|
|
105
|
+
const closeController = () => {
|
|
106
|
+
request.signal.removeEventListener("abort", onAbort);
|
|
107
|
+
if (!closed) {
|
|
108
|
+
closed = true;
|
|
109
|
+
controller.close();
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const onAbort = () => close();
|
|
113
|
+
request.signal.addEventListener("abort", onAbort, {
|
|
114
|
+
once: true,
|
|
115
|
+
});
|
|
116
|
+
abortDiscussion = close;
|
|
117
|
+
const enqueue = (event: SearchDiscussionStreamEvent) => {
|
|
118
|
+
if (closed) return;
|
|
119
|
+
try {
|
|
120
|
+
controller.enqueue(encodeEvent(event));
|
|
121
|
+
} catch {
|
|
122
|
+
close();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
runEffectBackground(
|
|
127
|
+
maybeAutoUpdateBackupEffect().pipe(
|
|
128
|
+
Effect.flatMap(() => {
|
|
129
|
+
if (closed || abortController.signal.aborted) {
|
|
130
|
+
return Effect.succeed(undefined);
|
|
131
|
+
}
|
|
132
|
+
return streamSearchDiscussionEffect(
|
|
133
|
+
{
|
|
134
|
+
...options,
|
|
135
|
+
signal: abortController.signal,
|
|
136
|
+
prefetchAvatars: true,
|
|
137
|
+
},
|
|
138
|
+
{ onEvent: enqueue },
|
|
139
|
+
);
|
|
140
|
+
}),
|
|
141
|
+
),
|
|
142
|
+
{
|
|
143
|
+
onSuccess: closeController,
|
|
144
|
+
onFailure: (error) => {
|
|
145
|
+
enqueue({
|
|
146
|
+
type: "error",
|
|
147
|
+
error:
|
|
148
|
+
error instanceof Error
|
|
149
|
+
? error.message
|
|
150
|
+
: "Discussion failed",
|
|
151
|
+
});
|
|
152
|
+
closeController();
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
},
|
|
157
|
+
}),
|
|
158
|
+
{
|
|
159
|
+
headers: {
|
|
160
|
+
"cache-control": "no-store",
|
|
161
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
}),
|
|
166
|
+
),
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
});
|
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
|
|
4
|
+
import {
|
|
5
|
+
jsonResponse,
|
|
6
|
+
runRouteEffect,
|
|
7
|
+
sensitiveRequestErrorResponse,
|
|
8
|
+
} from "#/lib/http-effect";
|
|
9
|
+
import { getQueryEnvelopeEffect } from "#/lib/queries";
|
|
4
10
|
|
|
5
11
|
export const Route = createFileRoute("/api/status")({
|
|
6
12
|
server: {
|
|
7
13
|
handlers: {
|
|
8
|
-
GET:
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
GET: ({ request }) =>
|
|
15
|
+
runRouteEffect(
|
|
16
|
+
Effect.gen(function* () {
|
|
17
|
+
const denied = sensitiveRequestErrorResponse(request);
|
|
18
|
+
if (denied) return denied;
|
|
19
|
+
|
|
20
|
+
yield* maybeAutoUpdateBackupEffect();
|
|
21
|
+
return jsonResponse(yield* getQueryEnvelopeEffect());
|
|
22
|
+
}),
|
|
23
|
+
),
|
|
16
24
|
},
|
|
17
25
|
},
|
|
18
26
|
});
|
package/src/routes/api/sync.tsx
CHANGED
|
@@ -1,28 +1,64 @@
|
|
|
1
1
|
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import {
|
|
4
|
+
jsonResponse,
|
|
5
|
+
requestJsonEffect,
|
|
6
|
+
runRouteEffect,
|
|
7
|
+
sensitiveRequestErrorResponse,
|
|
8
|
+
} from "#/lib/http-effect";
|
|
9
|
+
import {
|
|
10
|
+
getWebSyncJob,
|
|
11
|
+
parseWebSyncKind,
|
|
12
|
+
startWebSync,
|
|
13
|
+
type WebSyncDmInbox,
|
|
14
|
+
type WebSyncOptions,
|
|
15
|
+
} from "#/lib/web-sync";
|
|
13
16
|
|
|
14
17
|
function parseAccountId(value: unknown) {
|
|
15
18
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
function parseDmInbox(value: unknown): WebSyncDmInbox | undefined {
|
|
22
|
+
return value === "all" || value === "accepted" || value === "requests"
|
|
23
|
+
? value
|
|
24
|
+
: undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parsePositiveInteger(value: unknown, max: number) {
|
|
28
|
+
const parsed =
|
|
29
|
+
typeof value === "number"
|
|
30
|
+
? value
|
|
31
|
+
: typeof value === "string"
|
|
32
|
+
? Number(value)
|
|
33
|
+
: Number.NaN;
|
|
34
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return undefined;
|
|
35
|
+
return Math.min(parsed, max);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseSyncOptions(kind: string, body: Record<string, unknown>) {
|
|
39
|
+
if (kind !== "dms") return {};
|
|
40
|
+
const options: WebSyncOptions = {};
|
|
41
|
+
const inbox = parseDmInbox(body.inbox);
|
|
42
|
+
const limit = parsePositiveInteger(body.limit, 1000);
|
|
43
|
+
const maxPages = parsePositiveInteger(body.maxPages, 250);
|
|
44
|
+
if (inbox) options.inbox = inbox;
|
|
45
|
+
if (limit) options.limit = limit;
|
|
46
|
+
if (maxPages) options.maxPages = maxPages;
|
|
47
|
+
if (typeof body.allPages === "boolean") options.allPages = body.allPages;
|
|
48
|
+
return options;
|
|
49
|
+
}
|
|
50
|
+
|
|
18
51
|
export const Route = createFileRoute("/api/sync")({
|
|
19
52
|
server: {
|
|
20
53
|
handlers: {
|
|
21
54
|
GET: ({ request }) => {
|
|
55
|
+
const denied = sensitiveRequestErrorResponse(request);
|
|
56
|
+
if (denied) return denied;
|
|
57
|
+
|
|
22
58
|
const url = new URL(request.url);
|
|
23
59
|
const id = url.searchParams.get("id");
|
|
24
60
|
if (!id) {
|
|
25
|
-
return
|
|
61
|
+
return jsonResponse(
|
|
26
62
|
{ ok: false, message: "Missing sync job id" },
|
|
27
63
|
{ status: 400 },
|
|
28
64
|
);
|
|
@@ -30,30 +66,40 @@ export const Route = createFileRoute("/api/sync")({
|
|
|
30
66
|
|
|
31
67
|
const job = getWebSyncJob(id);
|
|
32
68
|
if (!job) {
|
|
33
|
-
return
|
|
69
|
+
return jsonResponse(
|
|
34
70
|
{ ok: false, message: "Sync job not found" },
|
|
35
71
|
{ status: 404 },
|
|
36
72
|
);
|
|
37
73
|
}
|
|
38
74
|
|
|
39
|
-
return
|
|
75
|
+
return jsonResponse(job);
|
|
40
76
|
},
|
|
41
|
-
POST:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const kind = parseWebSyncKind(body.kind);
|
|
47
|
-
if (!kind) {
|
|
48
|
-
return json(
|
|
49
|
-
{ ok: false, message: "Unknown sync kind" },
|
|
50
|
-
{ status: 400 },
|
|
51
|
-
);
|
|
52
|
-
}
|
|
77
|
+
POST: ({ request }) =>
|
|
78
|
+
runRouteEffect(
|
|
79
|
+
Effect.gen(function* () {
|
|
80
|
+
const denied = sensitiveRequestErrorResponse(request);
|
|
81
|
+
if (denied) return denied;
|
|
53
82
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
83
|
+
const body = yield* requestJsonEffect<Record<string, unknown>>(
|
|
84
|
+
request,
|
|
85
|
+
{},
|
|
86
|
+
);
|
|
87
|
+
const kind = parseWebSyncKind(body.kind);
|
|
88
|
+
if (!kind) {
|
|
89
|
+
return jsonResponse(
|
|
90
|
+
{ ok: false, message: "Unknown sync kind" },
|
|
91
|
+
{ status: 400 },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const job = startWebSync(
|
|
96
|
+
kind,
|
|
97
|
+
parseAccountId(body.accountId),
|
|
98
|
+
parseSyncOptions(kind, body),
|
|
99
|
+
);
|
|
100
|
+
return jsonResponse(job, { status: job.inProgress ? 202 : 200 });
|
|
101
|
+
}),
|
|
102
|
+
),
|
|
57
103
|
},
|
|
58
104
|
},
|
|
59
105
|
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import {
|
|
4
|
+
jsonResponse,
|
|
5
|
+
runRouteEffect,
|
|
6
|
+
sensitiveRequestErrorResponse,
|
|
7
|
+
} from "#/lib/http-effect";
|
|
8
|
+
import { getXurlRateLimitSnapshot } from "#/lib/xurl-rate-limits";
|
|
9
|
+
|
|
10
|
+
export const Route = createFileRoute("/api/xurl-rate-limits")({
|
|
11
|
+
server: {
|
|
12
|
+
handlers: {
|
|
13
|
+
GET: ({ request }) =>
|
|
14
|
+
runRouteEffect(
|
|
15
|
+
Effect.sync(() => {
|
|
16
|
+
const denied = sensitiveRequestErrorResponse(request);
|
|
17
|
+
if (denied) return denied;
|
|
18
|
+
|
|
19
|
+
return jsonResponse(getXurlRateLimitSnapshot());
|
|
20
|
+
}),
|
|
21
|
+
),
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import {
|
|
3
|
+
AlertTriangle,
|
|
4
|
+
CheckCircle2,
|
|
5
|
+
Database,
|
|
6
|
+
RefreshCw,
|
|
7
|
+
Route as RouteIcon,
|
|
8
|
+
TerminalSquare,
|
|
9
|
+
XCircle,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
12
|
+
import type {
|
|
13
|
+
LiveDataSourceCapability,
|
|
14
|
+
LiveDataSourceKind,
|
|
15
|
+
LiveDataSourcesResponse,
|
|
16
|
+
LiveDataSourceStatus,
|
|
17
|
+
} from "#/lib/types";
|
|
18
|
+
import {
|
|
19
|
+
cx,
|
|
20
|
+
errorCopyClass,
|
|
21
|
+
pageHeaderActionsClass,
|
|
22
|
+
pageHeaderClass,
|
|
23
|
+
pageHeaderRowClass,
|
|
24
|
+
pageSubtitleClass,
|
|
25
|
+
pageTitleClass,
|
|
26
|
+
secondaryButtonClass,
|
|
27
|
+
statusCopyClass,
|
|
28
|
+
} from "#/lib/ui";
|
|
29
|
+
|
|
30
|
+
export const Route = createFileRoute("/data-sources")({
|
|
31
|
+
component: DataSourcesRoute,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
async function fetchDataSources() {
|
|
35
|
+
const response = await fetch("/api/data-sources");
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
throw new Error(`Data source status failed (${String(response.status)})`);
|
|
38
|
+
}
|
|
39
|
+
return (await response.json()) as LiveDataSourcesResponse;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sourceIcon(source: LiveDataSourceKind) {
|
|
43
|
+
if (source === "birdclaw") return Database;
|
|
44
|
+
if (source === "bird") return TerminalSquare;
|
|
45
|
+
return RouteIcon;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function statusTone(status: LiveDataSourceStatus["status"]) {
|
|
49
|
+
if (status === "ok") {
|
|
50
|
+
return "border-[color:color-mix(in_srgb,#22c55e_45%,var(--line))] bg-[color:color-mix(in_srgb,#22c55e_10%,var(--bg))] text-[color:color-mix(in_srgb,#22c55e_82%,var(--ink))]";
|
|
51
|
+
}
|
|
52
|
+
if (status === "warning") {
|
|
53
|
+
return "border-[color:color-mix(in_srgb,#f59e0b_50%,var(--line))] bg-[color:color-mix(in_srgb,#f59e0b_12%,var(--bg))] text-[color:color-mix(in_srgb,#f59e0b_82%,var(--ink))]";
|
|
54
|
+
}
|
|
55
|
+
return "border-[color:color-mix(in_srgb,var(--alert)_55%,var(--line))] bg-[var(--alert-soft)] text-[var(--alert)]";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function statusIcon(status: LiveDataSourceStatus["status"]) {
|
|
59
|
+
if (status === "ok") return CheckCircle2;
|
|
60
|
+
if (status === "warning") return AlertTriangle;
|
|
61
|
+
return XCircle;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sourceLabel(source: LiveDataSourceKind) {
|
|
65
|
+
if (source === "birdclaw") return "local";
|
|
66
|
+
return source;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function SourceCard({ source }: { source: LiveDataSourceStatus }) {
|
|
70
|
+
const Icon = sourceIcon(source.source);
|
|
71
|
+
const StatusIcon = statusIcon(source.status);
|
|
72
|
+
return (
|
|
73
|
+
<section className="min-w-0 border-b border-[var(--line)] px-4 py-4">
|
|
74
|
+
<div className="flex items-start justify-between gap-3">
|
|
75
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
76
|
+
<div className="grid size-10 shrink-0 place-items-center rounded-md border border-[var(--line)] bg-[var(--bg-active)] text-[var(--ink)]">
|
|
77
|
+
<Icon className="size-5" strokeWidth={1.9} />
|
|
78
|
+
</div>
|
|
79
|
+
<div className="min-w-0">
|
|
80
|
+
<h2 className="truncate text-[16px] font-bold text-[var(--ink)]">
|
|
81
|
+
{source.label}
|
|
82
|
+
</h2>
|
|
83
|
+
<p className="truncate text-[13px] text-[var(--ink-soft)]">
|
|
84
|
+
{source.installed === false ? "not installed" : source.detail}
|
|
85
|
+
</p>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
<span
|
|
89
|
+
className={cx(
|
|
90
|
+
"inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[12px] font-bold",
|
|
91
|
+
statusTone(source.status),
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
<StatusIcon className="size-3.5" strokeWidth={2} />
|
|
95
|
+
{source.works ? "works" : source.status}
|
|
96
|
+
</span>
|
|
97
|
+
</div>
|
|
98
|
+
<div className="mt-4 flex flex-wrap gap-2">
|
|
99
|
+
{source.accounts.length > 0 ? (
|
|
100
|
+
source.accounts.map((account, index) => (
|
|
101
|
+
<span
|
|
102
|
+
key={`${account.app ?? ""}:${account.username ?? account.handle ?? account.id ?? String(index)}`}
|
|
103
|
+
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-[var(--line)] bg-[var(--bg)] px-2.5 py-1 text-[12px] font-semibold text-[var(--ink-soft)]"
|
|
104
|
+
>
|
|
105
|
+
{account.app ? (
|
|
106
|
+
<span className="text-[var(--ink-faint)]">{account.app}</span>
|
|
107
|
+
) : null}
|
|
108
|
+
<span className="truncate">
|
|
109
|
+
{account.handle ??
|
|
110
|
+
(account.username ? `@${account.username}` : account.id)}
|
|
111
|
+
</span>
|
|
112
|
+
{account.isDefault ? (
|
|
113
|
+
<span className="text-[var(--accent)]">default</span>
|
|
114
|
+
) : null}
|
|
115
|
+
</span>
|
|
116
|
+
))
|
|
117
|
+
) : (
|
|
118
|
+
<span className="text-[13px] text-[var(--ink-soft)]">
|
|
119
|
+
no authenticated account detected
|
|
120
|
+
</span>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
</section>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function CapabilityRow({
|
|
128
|
+
capability,
|
|
129
|
+
sourcesByKind,
|
|
130
|
+
}: {
|
|
131
|
+
capability: LiveDataSourceCapability;
|
|
132
|
+
sourcesByKind: Map<LiveDataSourceKind, LiveDataSourceStatus>;
|
|
133
|
+
}) {
|
|
134
|
+
const chain = [capability.primary, ...capability.fallbacks];
|
|
135
|
+
return (
|
|
136
|
+
<div className="grid gap-3 border-b border-[var(--line)] px-4 py-4 min-[840px]:grid-cols-[220px_minmax(0,1fr)]">
|
|
137
|
+
<div className="min-w-0">
|
|
138
|
+
<div className="font-bold text-[var(--ink)]">{capability.label}</div>
|
|
139
|
+
{capability.notes ? (
|
|
140
|
+
<div className="mt-1 text-[12px] text-[var(--ink-soft)]">
|
|
141
|
+
{capability.notes}
|
|
142
|
+
</div>
|
|
143
|
+
) : null}
|
|
144
|
+
</div>
|
|
145
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
146
|
+
{chain.map((source, index) => {
|
|
147
|
+
const status = sourcesByKind.get(source);
|
|
148
|
+
const ok = Boolean(status?.works);
|
|
149
|
+
return (
|
|
150
|
+
<div
|
|
151
|
+
key={`${capability.key}:${source}:${String(index)}`}
|
|
152
|
+
className={cx(
|
|
153
|
+
"inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[13px] font-semibold",
|
|
154
|
+
ok
|
|
155
|
+
? "border-[color:color-mix(in_srgb,#22c55e_38%,var(--line))] text-[var(--ink)]"
|
|
156
|
+
: "border-[var(--line)] text-[var(--ink-soft)]",
|
|
157
|
+
)}
|
|
158
|
+
>
|
|
159
|
+
<span className="text-[12px] text-[var(--ink-faint)]">
|
|
160
|
+
{index === 0 ? "primary" : `fallback ${String(index)}`}
|
|
161
|
+
</span>
|
|
162
|
+
<span>{sourceLabel(source)}</span>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
})}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function DataSourcesRoute() {
|
|
172
|
+
const [snapshot, setSnapshot] = useState<LiveDataSourcesResponse | null>(null);
|
|
173
|
+
const [error, setError] = useState<string | null>(null);
|
|
174
|
+
const [loading, setLoading] = useState(true);
|
|
175
|
+
const sourcesByKind = useMemo(
|
|
176
|
+
() =>
|
|
177
|
+
new Map(
|
|
178
|
+
(snapshot?.sources ?? []).map((source) => [source.source, source]),
|
|
179
|
+
),
|
|
180
|
+
[snapshot],
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const load = useCallback(() => {
|
|
184
|
+
setLoading(true);
|
|
185
|
+
setError(null);
|
|
186
|
+
fetchDataSources()
|
|
187
|
+
.then(setSnapshot)
|
|
188
|
+
.catch((cause: unknown) => {
|
|
189
|
+
setError(
|
|
190
|
+
cause instanceof Error ? cause.message : "Data sources unavailable",
|
|
191
|
+
);
|
|
192
|
+
})
|
|
193
|
+
.finally(() => setLoading(false));
|
|
194
|
+
}, []);
|
|
195
|
+
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
load();
|
|
198
|
+
}, [load]);
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<section className="flex min-h-screen flex-col">
|
|
202
|
+
<header className={pageHeaderClass}>
|
|
203
|
+
<div className={pageHeaderRowClass}>
|
|
204
|
+
<div>
|
|
205
|
+
<h1 className={pageTitleClass}>Data Sources</h1>
|
|
206
|
+
<p className={pageSubtitleClass}>
|
|
207
|
+
Live auth, local archive, and automatic fallback order
|
|
208
|
+
</p>
|
|
209
|
+
</div>
|
|
210
|
+
<div className={pageHeaderActionsClass}>
|
|
211
|
+
<button
|
|
212
|
+
className={secondaryButtonClass}
|
|
213
|
+
type="button"
|
|
214
|
+
onClick={load}
|
|
215
|
+
disabled={loading}
|
|
216
|
+
>
|
|
217
|
+
<RefreshCw className={cx("size-4", loading && "animate-spin")} />
|
|
218
|
+
Refresh
|
|
219
|
+
</button>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</header>
|
|
223
|
+
{error ? <div className={errorCopyClass}>{error}</div> : null}
|
|
224
|
+
{snapshot ? (
|
|
225
|
+
<>
|
|
226
|
+
<div className="grid border-t border-[var(--line)]">
|
|
227
|
+
{snapshot.sources.map((source) => (
|
|
228
|
+
<SourceCard key={source.source} source={source} />
|
|
229
|
+
))}
|
|
230
|
+
</div>
|
|
231
|
+
<section className="min-h-0 flex-1">
|
|
232
|
+
<header className="border-b border-[var(--line)] px-4 py-3">
|
|
233
|
+
<h2 className="text-[16px] font-bold text-[var(--ink)]">
|
|
234
|
+
Fallbacks
|
|
235
|
+
</h2>
|
|
236
|
+
<p className="text-[13px] text-[var(--ink-soft)]">
|
|
237
|
+
Calls try the next source when the current source cannot serve
|
|
238
|
+
the request.
|
|
239
|
+
</p>
|
|
240
|
+
</header>
|
|
241
|
+
{snapshot.capabilities.map((capability) => (
|
|
242
|
+
<CapabilityRow
|
|
243
|
+
key={capability.key}
|
|
244
|
+
capability={capability}
|
|
245
|
+
sourcesByKind={sourcesByKind}
|
|
246
|
+
/>
|
|
247
|
+
))}
|
|
248
|
+
</section>
|
|
249
|
+
</>
|
|
250
|
+
) : loading ? (
|
|
251
|
+
<div className={statusCopyClass}>Checking data sources...</div>
|
|
252
|
+
) : null}
|
|
253
|
+
</section>
|
|
254
|
+
);
|
|
255
|
+
}
|