birdclaw 0.8.2 → 0.8.3
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 +16 -0
- package/package.json +2 -1
- package/src/cli/command-context.ts +17 -0
- package/src/cli/register-analysis.ts +500 -0
- package/src/cli/register-compose.ts +40 -0
- package/src/cli/register-graph.ts +132 -0
- package/src/cli/register-inbox.ts +41 -0
- package/src/cli/register-storage.ts +106 -0
- package/src/cli.ts +30 -750
- package/src/components/AccountSwitcher.tsx +7 -15
- package/src/components/AvatarChip.tsx +1 -1
- package/src/components/AvatarPreload.ts +149 -0
- package/src/components/MarkdownCitations.tsx +680 -0
- package/src/components/MarkdownViewer.tsx +8 -674
- package/src/components/ProfileAnalysisClient.ts +191 -0
- package/src/components/ProfileAnalysisStream.tsx +16 -185
- package/src/components/ProfilePreview.tsx +2 -0
- package/src/components/links-controller.ts +162 -0
- package/src/components/links-model.ts +198 -0
- package/src/components/network-map-controller.ts +84 -0
- package/src/components/network-map-model.ts +255 -0
- package/src/components/useTimelineRouteData.ts +105 -235
- package/src/lib/analysis-runtime.ts +238 -0
- package/src/lib/api-client.ts +16 -215
- package/src/lib/api-contracts.ts +328 -0
- package/src/lib/archive-import-plan.ts +102 -0
- package/src/lib/archive-import.ts +170 -239
- package/src/lib/authored-live.ts +75 -120
- package/src/lib/backup.ts +335 -424
- package/src/lib/blocks-write.ts +30 -26
- package/src/lib/blocks.ts +18 -20
- package/src/lib/database-metrics.ts +88 -0
- package/src/lib/database-migrations.ts +34 -0
- package/src/lib/database-schema.ts +312 -0
- package/src/lib/database-writer.ts +69 -0
- package/src/lib/db.ts +84 -330
- package/src/lib/dm-read-model.ts +533 -0
- package/src/lib/dms-live.ts +34 -97
- package/src/lib/follow-graph.ts +17 -27
- package/src/lib/import-repository.ts +138 -0
- package/src/lib/inbox.ts +2 -1
- package/src/lib/live-sync-engine.ts +209 -0
- package/src/lib/live-transport-gateway.ts +128 -0
- package/src/lib/mention-threads-live.ts +90 -177
- package/src/lib/mentions-export.ts +1 -1
- package/src/lib/mentions-live.ts +57 -181
- package/src/lib/moderation-target.ts +15 -4
- package/src/lib/moderation-write.ts +1 -1
- package/src/lib/mutes-write.ts +30 -26
- package/src/lib/openai-response-runtime.ts +251 -0
- package/src/lib/paginated-sync.ts +93 -0
- package/src/lib/period-digest.ts +116 -304
- package/src/lib/profile-analysis.ts +36 -110
- package/src/lib/queries.ts +6 -2381
- package/src/lib/query-actions.ts +437 -0
- package/src/lib/query-client.tsx +47 -0
- package/src/lib/query-read-model-shared.ts +52 -0
- package/src/lib/query-read-models.ts +5 -0
- package/src/lib/query-resource.ts +41 -0
- package/src/lib/query-status.ts +164 -0
- package/src/lib/research.ts +1 -1
- package/src/lib/runtime-services.ts +20 -0
- package/src/lib/search-discussion.ts +75 -279
- package/src/lib/server-runtime-services.ts +30 -0
- package/src/lib/sqlite.ts +48 -12
- package/src/lib/streaming-ingestion.ts +240 -0
- package/src/lib/sync-cache.ts +6 -1
- package/src/lib/sync-plan.ts +175 -0
- package/src/lib/timeline-collections-live.ts +83 -257
- package/src/lib/timeline-live.ts +86 -236
- package/src/lib/timeline-read-model.ts +1191 -0
- package/src/lib/tweet-repository.ts +156 -0
- package/src/lib/tweet-search-live.ts +63 -167
- package/src/lib/web-sync.ts +67 -50
- package/src/lib/whois.ts +2 -1
- package/src/routes/__root.tsx +11 -8
- package/src/routes/api/action.tsx +1 -1
- package/src/routes/api/conversation.tsx +1 -1
- package/src/routes/api/query.tsx +32 -26
- package/src/routes/api/status.tsx +6 -4
- package/src/routes/api/sync.tsx +5 -2
- package/src/routes/blocks.tsx +97 -131
- package/src/routes/data-sources.tsx +17 -25
- package/src/routes/dms.tsx +167 -184
- package/src/routes/inbox.tsx +63 -57
- package/src/routes/links.tsx +31 -394
- package/src/routes/network-map.tsx +41 -344
- package/src/routes/rate-limits.tsx +17 -21
- package/src/lib/client-cache.ts +0 -109
|
@@ -1,35 +1,22 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
import type {
|
|
3
|
-
QueryEnvelope,
|
|
4
|
-
ReplyFilter,
|
|
5
|
-
ResourceKind,
|
|
6
|
-
TimelineItem,
|
|
7
|
-
} from "#/lib/types";
|
|
8
1
|
import {
|
|
9
|
-
|
|
2
|
+
useInfiniteQuery,
|
|
3
|
+
useMutation,
|
|
4
|
+
useQuery,
|
|
5
|
+
useQueryClient,
|
|
6
|
+
} from "@tanstack/react-query";
|
|
7
|
+
import { useMemo } from "react";
|
|
8
|
+
import {
|
|
10
9
|
fetchQueryEnvelope,
|
|
11
|
-
|
|
12
|
-
invalidateCachedQueryResponses,
|
|
10
|
+
fetchQueryResponse,
|
|
13
11
|
postAction,
|
|
14
|
-
readCachedQueryEnvelope,
|
|
15
12
|
} from "#/lib/api-client";
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
deleteClientCacheByPrefix,
|
|
19
|
-
readClientCache,
|
|
20
|
-
writeClientCache,
|
|
21
|
-
} from "#/lib/client-cache";
|
|
13
|
+
import { queryKeys } from "#/lib/query-client";
|
|
14
|
+
import type { ReplyFilter, ResourceKind, TimelineItem } from "#/lib/types";
|
|
22
15
|
import { useSelectedAccountId } from "./account-selection";
|
|
23
16
|
import { useDebouncedValue } from "./useDebouncedValue";
|
|
24
17
|
|
|
25
18
|
const PAGE_SIZE = 50;
|
|
26
|
-
const
|
|
27
|
-
const TIMELINE_VIEW_CACHE_MAX_AGE_MS = 5 * 60_000;
|
|
28
|
-
|
|
29
|
-
interface TimelineViewSnapshot {
|
|
30
|
-
items: TimelineItem[];
|
|
31
|
-
hasMore: boolean;
|
|
32
|
-
}
|
|
19
|
+
const TIMELINE_STALE_TIME_MS = 5 * 60_000;
|
|
33
20
|
|
|
34
21
|
interface UseTimelineRouteDataOptions {
|
|
35
22
|
resource: Exclude<ResourceKind, "dms">;
|
|
@@ -40,6 +27,11 @@ interface UseTimelineRouteDataOptions {
|
|
|
40
27
|
bookmarkedOnly?: boolean;
|
|
41
28
|
}
|
|
42
29
|
|
|
30
|
+
interface TimelinePageParam {
|
|
31
|
+
until: string;
|
|
32
|
+
untilId: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
43
35
|
function buildTimelineQueryUrl({
|
|
44
36
|
resource,
|
|
45
37
|
search,
|
|
@@ -47,9 +39,7 @@ function buildTimelineQueryUrl({
|
|
|
47
39
|
likedOnly,
|
|
48
40
|
bookmarkedOnly,
|
|
49
41
|
selectedAccountId,
|
|
50
|
-
|
|
51
|
-
until,
|
|
52
|
-
untilId,
|
|
42
|
+
pageParam,
|
|
53
43
|
}: {
|
|
54
44
|
resource: Exclude<ResourceKind, "dms">;
|
|
55
45
|
search: string;
|
|
@@ -57,22 +47,21 @@ function buildTimelineQueryUrl({
|
|
|
57
47
|
likedOnly: boolean;
|
|
58
48
|
bookmarkedOnly: boolean;
|
|
59
49
|
selectedAccountId?: string;
|
|
60
|
-
|
|
61
|
-
until?: string;
|
|
62
|
-
untilId?: string;
|
|
50
|
+
pageParam?: TimelinePageParam;
|
|
63
51
|
}) {
|
|
64
52
|
const params = new URLSearchParams({
|
|
65
53
|
resource,
|
|
66
54
|
limit: String(PAGE_SIZE),
|
|
67
55
|
});
|
|
68
56
|
if (selectedAccountId) params.set("account", selectedAccountId);
|
|
69
|
-
params.set("refresh", String(refreshTick));
|
|
70
57
|
if (replyFilter) params.set("replyFilter", replyFilter);
|
|
71
58
|
if (likedOnly) params.set("liked", "true");
|
|
72
59
|
if (bookmarkedOnly) params.set("bookmarked", "true");
|
|
73
60
|
if (search.trim()) params.set("search", search.trim());
|
|
74
|
-
if (
|
|
75
|
-
|
|
61
|
+
if (pageParam) {
|
|
62
|
+
params.set("until", pageParam.until);
|
|
63
|
+
params.set("untilId", pageParam.untilId);
|
|
64
|
+
}
|
|
76
65
|
params.sort();
|
|
77
66
|
const base =
|
|
78
67
|
typeof window === "undefined"
|
|
@@ -81,12 +70,6 @@ function buildTimelineQueryUrl({
|
|
|
81
70
|
return new URL(`/api/query?${params.toString()}`, base).toString();
|
|
82
71
|
}
|
|
83
72
|
|
|
84
|
-
function timelineViewCacheKey(requestUrl: string) {
|
|
85
|
-
const url = new URL(requestUrl, "http://birdclaw.local");
|
|
86
|
-
url.searchParams.delete("refresh");
|
|
87
|
-
return `${TIMELINE_VIEW_CACHE_PREFIX}${url.pathname}?${url.searchParams.toString()}`;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
73
|
export function useTimelineRouteData({
|
|
91
74
|
resource,
|
|
92
75
|
search,
|
|
@@ -95,229 +78,116 @@ export function useTimelineRouteData({
|
|
|
95
78
|
likedOnly = false,
|
|
96
79
|
bookmarkedOnly = false,
|
|
97
80
|
}: UseTimelineRouteDataOptions) {
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
81
|
+
const queryClient = useQueryClient();
|
|
82
|
+
const statusQuery = useQuery({
|
|
83
|
+
queryKey: queryKeys.status,
|
|
84
|
+
queryFn: ({ signal }) => fetchQueryEnvelope({ signal }),
|
|
85
|
+
});
|
|
86
|
+
const meta = statusQuery.data ?? null;
|
|
101
87
|
const selectedAccountId = useSelectedAccountId(meta?.accounts);
|
|
102
88
|
const debouncedSearch = useDebouncedValue(search, 180);
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
replyFilter,
|
|
107
|
-
likedOnly,
|
|
108
|
-
bookmarkedOnly,
|
|
109
|
-
selectedAccountId,
|
|
110
|
-
refreshTick: 0,
|
|
111
|
-
});
|
|
112
|
-
const initialSnapshot = useRef(
|
|
113
|
-
readClientCache<TimelineViewSnapshot>(
|
|
114
|
-
timelineViewCacheKey(initialRequestUrl),
|
|
115
|
-
TIMELINE_VIEW_CACHE_MAX_AGE_MS,
|
|
116
|
-
),
|
|
117
|
-
);
|
|
118
|
-
const [items, setItems] = useState<TimelineItem[]>(
|
|
119
|
-
() => initialSnapshot.current?.items ?? [],
|
|
120
|
-
);
|
|
121
|
-
const [loading, setLoading] = useState(() => !initialSnapshot.current);
|
|
122
|
-
const [error, setError] = useState<string | null>(null);
|
|
123
|
-
const [replyError, setReplyError] = useState<string | null>(null);
|
|
124
|
-
const [refreshTick, setRefreshTick] = useState(0);
|
|
125
|
-
const [hasMore, setHasMore] = useState(
|
|
126
|
-
() => initialSnapshot.current?.hasMore ?? false,
|
|
127
|
-
);
|
|
128
|
-
const [loadingMore, setLoadingMore] = useState(false);
|
|
129
|
-
// Bumped whenever the active filters change. A `loadMore` request that
|
|
130
|
-
// resolves against a stale generation is discarded so its older page is
|
|
131
|
-
// never appended to a freshly loaded feed.
|
|
132
|
-
const generationRef = useRef(0);
|
|
133
|
-
const loadMoreControllerRef = useRef<AbortController | null>(null);
|
|
134
|
-
const activeRequestUrlRef = useRef(initialRequestUrl);
|
|
135
|
-
|
|
136
|
-
async function loadStatus(force = false) {
|
|
137
|
-
setMeta(await fetchQueryEnvelope(undefined, { force }));
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
useEffect(() => {
|
|
141
|
-
void loadStatus();
|
|
142
|
-
}, []);
|
|
143
|
-
|
|
144
|
-
useEffect(() => {
|
|
145
|
-
generationRef.current += 1;
|
|
146
|
-
loadMoreControllerRef.current?.abort();
|
|
147
|
-
const controller = new AbortController();
|
|
148
|
-
let active = true;
|
|
149
|
-
const requestUrl = buildTimelineQueryUrl({
|
|
89
|
+
const timelineQueryKey = [
|
|
90
|
+
...queryKeys.timelines,
|
|
91
|
+
{
|
|
150
92
|
resource,
|
|
151
93
|
search: debouncedSearch,
|
|
152
|
-
replyFilter,
|
|
94
|
+
replyFilter: replyFilter ?? "all",
|
|
153
95
|
likedOnly,
|
|
154
96
|
bookmarkedOnly,
|
|
155
|
-
selectedAccountId,
|
|
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
|
-
.catch((fetchError: unknown) => {
|
|
192
|
-
if (
|
|
193
|
-
fetchError instanceof DOMException &&
|
|
194
|
-
fetchError.name === "AbortError"
|
|
195
|
-
) {
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
if (!active) return;
|
|
199
|
-
setError(
|
|
200
|
-
fetchError instanceof Error ? fetchError.message : errorFallback,
|
|
201
|
-
);
|
|
202
|
-
setItems([]);
|
|
203
|
-
setHasMore(false);
|
|
204
|
-
})
|
|
205
|
-
.finally(() => {
|
|
206
|
-
if (active) {
|
|
207
|
-
setLoading(false);
|
|
208
|
-
}
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
return () => {
|
|
212
|
-
active = false;
|
|
213
|
-
controller.abort();
|
|
214
|
-
};
|
|
215
|
-
}, [
|
|
216
|
-
bookmarkedOnly,
|
|
217
|
-
debouncedSearch,
|
|
218
|
-
errorFallback,
|
|
219
|
-
likedOnly,
|
|
220
|
-
refreshTick,
|
|
221
|
-
replyFilter,
|
|
222
|
-
resource,
|
|
223
|
-
selectedAccountId,
|
|
224
|
-
]);
|
|
225
|
-
|
|
226
|
-
async function loadMore() {
|
|
227
|
-
if (loading || loadingMore || !hasMore || items.length === 0) return;
|
|
228
|
-
const lastItem = items[items.length - 1];
|
|
229
|
-
const until = lastItem?.createdAt;
|
|
230
|
-
const untilId = lastItem?.id;
|
|
231
|
-
if (!until || !untilId) return;
|
|
232
|
-
const generation = generationRef.current;
|
|
233
|
-
const controller = new AbortController();
|
|
234
|
-
loadMoreControllerRef.current = controller;
|
|
235
|
-
setLoadingMore(true);
|
|
236
|
-
try {
|
|
237
|
-
const nextPageUrl = new URL(
|
|
238
|
-
activeRequestUrlRef.current,
|
|
239
|
-
window.location.origin,
|
|
240
|
-
);
|
|
241
|
-
nextPageUrl.searchParams.set("until", until);
|
|
242
|
-
nextPageUrl.searchParams.set("untilId", untilId);
|
|
243
|
-
const data = await fetchCachedQueryResponse(nextPageUrl, {
|
|
244
|
-
signal: controller.signal,
|
|
245
|
-
});
|
|
246
|
-
// Discard if the filters changed (new generation) while in flight.
|
|
247
|
-
if (generation !== generationRef.current) return;
|
|
248
|
-
const page = data.items as TimelineItem[];
|
|
249
|
-
setItems((prev) => {
|
|
250
|
-
const seen = new Set(prev.map((item) => item.id));
|
|
251
|
-
const next = [...prev, ...page.filter((item) => !seen.has(item.id))];
|
|
252
|
-
writeClientCache(timelineViewCacheKey(activeRequestUrlRef.current), {
|
|
253
|
-
items: next,
|
|
254
|
-
hasMore: page.length >= PAGE_SIZE,
|
|
255
|
-
});
|
|
256
|
-
return next;
|
|
257
|
-
});
|
|
258
|
-
setHasMore(page.length >= PAGE_SIZE);
|
|
259
|
-
} catch (loadError) {
|
|
260
|
-
if (
|
|
261
|
-
loadError instanceof DOMException &&
|
|
262
|
-
loadError.name === "AbortError"
|
|
263
|
-
) {
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
if (generation !== generationRef.current) return;
|
|
267
|
-
setError(loadError instanceof Error ? loadError.message : errorFallback);
|
|
268
|
-
} finally {
|
|
269
|
-
if (generation === generationRef.current) {
|
|
270
|
-
setLoadingMore(false);
|
|
97
|
+
selectedAccountId: selectedAccountId ?? null,
|
|
98
|
+
},
|
|
99
|
+
] as const;
|
|
100
|
+
const timelineQuery = useInfiniteQuery({
|
|
101
|
+
queryKey: timelineQueryKey,
|
|
102
|
+
initialPageParam: undefined as TimelinePageParam | undefined,
|
|
103
|
+
queryFn: ({ pageParam, signal }) =>
|
|
104
|
+
fetchQueryResponse(
|
|
105
|
+
buildTimelineQueryUrl({
|
|
106
|
+
resource,
|
|
107
|
+
search: debouncedSearch,
|
|
108
|
+
replyFilter,
|
|
109
|
+
likedOnly,
|
|
110
|
+
bookmarkedOnly,
|
|
111
|
+
selectedAccountId,
|
|
112
|
+
pageParam,
|
|
113
|
+
}),
|
|
114
|
+
{ signal },
|
|
115
|
+
),
|
|
116
|
+
getNextPageParam: (lastPage) => {
|
|
117
|
+
const items = lastPage.items as TimelineItem[];
|
|
118
|
+
const lastItem = items.at(-1);
|
|
119
|
+
return items.length >= PAGE_SIZE && lastItem
|
|
120
|
+
? { until: lastItem.createdAt, untilId: lastItem.id }
|
|
121
|
+
: undefined;
|
|
122
|
+
},
|
|
123
|
+
staleTime: TIMELINE_STALE_TIME_MS,
|
|
124
|
+
});
|
|
125
|
+
const items = useMemo(() => {
|
|
126
|
+
const seen = new Set<string>();
|
|
127
|
+
const merged: TimelineItem[] = [];
|
|
128
|
+
for (const page of timelineQuery.data?.pages ?? []) {
|
|
129
|
+
for (const item of page.items as TimelineItem[]) {
|
|
130
|
+
if (seen.has(item.id)) continue;
|
|
131
|
+
seen.add(item.id);
|
|
132
|
+
merged.push(item);
|
|
271
133
|
}
|
|
272
134
|
}
|
|
273
|
-
|
|
135
|
+
return merged;
|
|
136
|
+
}, [timelineQuery.data]);
|
|
137
|
+
const replyMutation = useMutation({
|
|
138
|
+
mutationFn: ({ tweetId, text }: { tweetId: string; text: string }) =>
|
|
139
|
+
postAction({
|
|
140
|
+
kind: "replyTweet",
|
|
141
|
+
accountId: selectedAccountId ?? "acct_primary",
|
|
142
|
+
tweetId,
|
|
143
|
+
text,
|
|
144
|
+
}),
|
|
145
|
+
onSuccess: () =>
|
|
146
|
+
queryClient.invalidateQueries({ queryKey: timelineQueryKey }),
|
|
147
|
+
});
|
|
274
148
|
|
|
275
149
|
function retry() {
|
|
276
|
-
|
|
277
|
-
deleteClientCache(timelineViewCacheKey(activeRequestUrlRef.current));
|
|
278
|
-
setRefreshTick((value) => value + 1);
|
|
150
|
+
void timelineQuery.refetch();
|
|
279
151
|
}
|
|
280
152
|
|
|
281
153
|
function refreshLocalView() {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
154
|
+
void Promise.all([
|
|
155
|
+
queryClient.invalidateQueries({ queryKey: queryKeys.timelines }),
|
|
156
|
+
queryClient.invalidateQueries({ queryKey: queryKeys.status }),
|
|
157
|
+
]);
|
|
286
158
|
}
|
|
287
159
|
|
|
288
160
|
async function replyToTweet(tweetId: string) {
|
|
289
161
|
const text = window.prompt("Reply text");
|
|
290
162
|
if (!text?.trim()) return;
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
kind: "replyTweet",
|
|
296
|
-
accountId: selectedAccountId ?? "acct_primary",
|
|
297
|
-
tweetId,
|
|
298
|
-
text,
|
|
163
|
+
await replyMutation
|
|
164
|
+
.mutateAsync({ tweetId, text: text.trim() })
|
|
165
|
+
.catch(() => {
|
|
166
|
+
// The mutation error is exposed below for the route frame.
|
|
299
167
|
});
|
|
300
|
-
|
|
301
|
-
retry();
|
|
302
|
-
} catch (replyError) {
|
|
303
|
-
setReplyError(
|
|
304
|
-
replyError instanceof Error ? replyError.message : "Reply failed",
|
|
305
|
-
);
|
|
306
|
-
}
|
|
307
168
|
}
|
|
308
169
|
|
|
170
|
+
const queryError = timelineQuery.error;
|
|
309
171
|
return {
|
|
310
172
|
meta,
|
|
311
173
|
items,
|
|
312
|
-
loading,
|
|
313
|
-
error
|
|
314
|
-
|
|
174
|
+
loading: timelineQuery.isPending,
|
|
175
|
+
error: queryError
|
|
176
|
+
? queryError instanceof Error
|
|
177
|
+
? queryError.message
|
|
178
|
+
: errorFallback
|
|
179
|
+
: null,
|
|
180
|
+
replyError: replyMutation.error
|
|
181
|
+
? replyMutation.error instanceof Error
|
|
182
|
+
? replyMutation.error.message
|
|
183
|
+
: "Reply failed"
|
|
184
|
+
: null,
|
|
315
185
|
retry,
|
|
316
186
|
refreshLocalView,
|
|
317
187
|
replyToTweet,
|
|
318
188
|
selectedAccountId,
|
|
319
|
-
hasMore,
|
|
320
|
-
loadingMore,
|
|
321
|
-
loadMore,
|
|
189
|
+
hasMore: timelineQuery.hasNextPage,
|
|
190
|
+
loadingMore: timelineQuery.isFetchingNextPage,
|
|
191
|
+
loadMore: () => timelineQuery.fetchNextPage().then(() => undefined),
|
|
322
192
|
};
|
|
323
193
|
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { tryPromise } from "./effect-runtime";
|
|
3
|
+
import {
|
|
4
|
+
readOpenAIResponseStreamEffect,
|
|
5
|
+
requestOpenAIResponseEffect,
|
|
6
|
+
} from "./openai-response-runtime";
|
|
7
|
+
import {
|
|
8
|
+
defaultRuntimeServices,
|
|
9
|
+
type RuntimeServices,
|
|
10
|
+
} from "./runtime-services";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_MODEL = "gpt-5.5";
|
|
13
|
+
const DEFAULT_REASONING_EFFORT = "medium";
|
|
14
|
+
const DEFAULT_SERVICE_TIER = "priority";
|
|
15
|
+
const DEFAULT_DELIMITER_PATTERN = /\n---\s*\n/;
|
|
16
|
+
|
|
17
|
+
export type AnalysisReasoningEffort = "minimal" | "low" | "medium" | "high";
|
|
18
|
+
export type AnalysisServiceTier = "default" | "flex" | "priority";
|
|
19
|
+
|
|
20
|
+
export interface AnalysisModelOptions {
|
|
21
|
+
model?: string;
|
|
22
|
+
reasoningEffort?: AnalysisReasoningEffort;
|
|
23
|
+
serviceTier?: AnalysisServiceTier;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AnalysisModelSettings {
|
|
27
|
+
model: string;
|
|
28
|
+
reasoningEffort: AnalysisReasoningEffort;
|
|
29
|
+
serviceTier: AnalysisServiceTier;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface HybridAnalysisResult<T> {
|
|
33
|
+
value: T;
|
|
34
|
+
markdown: string;
|
|
35
|
+
rawText: string;
|
|
36
|
+
responseId?: string;
|
|
37
|
+
usage?: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toError(error: unknown) {
|
|
41
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveAnalysisModelSettings(
|
|
45
|
+
options: AnalysisModelOptions,
|
|
46
|
+
runtime: RuntimeServices = defaultRuntimeServices,
|
|
47
|
+
): AnalysisModelSettings {
|
|
48
|
+
return {
|
|
49
|
+
model: options.model ?? runtime.env("BIRDCLAW_AI_MODEL") ?? DEFAULT_MODEL,
|
|
50
|
+
reasoningEffort:
|
|
51
|
+
options.reasoningEffort ??
|
|
52
|
+
(runtime.env(
|
|
53
|
+
"BIRDCLAW_OPENAI_REASONING_EFFORT",
|
|
54
|
+
) as AnalysisReasoningEffort) ??
|
|
55
|
+
DEFAULT_REASONING_EFFORT,
|
|
56
|
+
serviceTier:
|
|
57
|
+
options.serviceTier ??
|
|
58
|
+
(runtime.env("BIRDCLAW_OPENAI_SERVICE_TIER") as AnalysisServiceTier) ??
|
|
59
|
+
DEFAULT_SERVICE_TIER,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createAnalysisRequestBody({
|
|
64
|
+
settings,
|
|
65
|
+
system,
|
|
66
|
+
prompt,
|
|
67
|
+
stream,
|
|
68
|
+
maxOutputTokens = 7000,
|
|
69
|
+
}: {
|
|
70
|
+
settings: AnalysisModelSettings;
|
|
71
|
+
system: string;
|
|
72
|
+
prompt: string;
|
|
73
|
+
stream: boolean;
|
|
74
|
+
maxOutputTokens?: number;
|
|
75
|
+
}) {
|
|
76
|
+
return {
|
|
77
|
+
model: settings.model,
|
|
78
|
+
reasoning: { effort: settings.reasoningEffort },
|
|
79
|
+
service_tier: settings.serviceTier,
|
|
80
|
+
store: false,
|
|
81
|
+
...(stream ? { stream: true } : {}),
|
|
82
|
+
max_output_tokens: maxOutputTokens,
|
|
83
|
+
input: [
|
|
84
|
+
{ role: "system", content: system },
|
|
85
|
+
{ role: "user", content: prompt },
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function parseHybridAnalysis<T>({
|
|
91
|
+
rawText,
|
|
92
|
+
parse,
|
|
93
|
+
fallback,
|
|
94
|
+
delimiterPattern = DEFAULT_DELIMITER_PATTERN,
|
|
95
|
+
}: {
|
|
96
|
+
rawText: string;
|
|
97
|
+
parse: (value: unknown) => T;
|
|
98
|
+
fallback: (markdown: string) => T;
|
|
99
|
+
delimiterPattern?: RegExp;
|
|
100
|
+
}) {
|
|
101
|
+
const [markdownPart, jsonPart] = rawText.split(delimiterPattern);
|
|
102
|
+
const markdown = (markdownPart ?? rawText).trim();
|
|
103
|
+
const candidate = jsonPart?.slice(
|
|
104
|
+
jsonPart.indexOf("{"),
|
|
105
|
+
jsonPart.lastIndexOf("}") + 1,
|
|
106
|
+
);
|
|
107
|
+
if (candidate?.startsWith("{")) {
|
|
108
|
+
try {
|
|
109
|
+
return { markdown, value: parse(JSON.parse(candidate)) };
|
|
110
|
+
} catch {
|
|
111
|
+
return { markdown, value: fallback(markdown) };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { markdown, value: fallback(markdown) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function extractOpenAIResponseText(payload: Record<string, unknown>) {
|
|
118
|
+
if (typeof payload.output_text === "string") {
|
|
119
|
+
return payload.output_text;
|
|
120
|
+
}
|
|
121
|
+
const output = Array.isArray(payload.output) ? payload.output : [];
|
|
122
|
+
const parts: string[] = [];
|
|
123
|
+
for (const item of output) {
|
|
124
|
+
if (!item || typeof item !== "object") continue;
|
|
125
|
+
const content = (item as { content?: unknown }).content;
|
|
126
|
+
if (!Array.isArray(content)) continue;
|
|
127
|
+
for (const block of content) {
|
|
128
|
+
if (!block || typeof block !== "object") continue;
|
|
129
|
+
const record = block as Record<string, unknown>;
|
|
130
|
+
if (typeof record.text === "string") parts.push(record.text);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return parts.join("");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function readHybridAnalysisStreamEffect<T>(
|
|
137
|
+
response: Response,
|
|
138
|
+
{
|
|
139
|
+
parse,
|
|
140
|
+
fallback,
|
|
141
|
+
onDelta,
|
|
142
|
+
delimiterPattern = DEFAULT_DELIMITER_PATTERN,
|
|
143
|
+
}: {
|
|
144
|
+
parse: (value: unknown) => T;
|
|
145
|
+
fallback: (markdown: string) => T;
|
|
146
|
+
onDelta?: (delta: string) => void;
|
|
147
|
+
delimiterPattern?: RegExp;
|
|
148
|
+
},
|
|
149
|
+
): Effect.Effect<HybridAnalysisResult<T>, Error> {
|
|
150
|
+
return Effect.gen(function* () {
|
|
151
|
+
const stream = yield* readOpenAIResponseStreamEffect(response, {
|
|
152
|
+
delimiterPattern,
|
|
153
|
+
onDelta,
|
|
154
|
+
});
|
|
155
|
+
const parsed = parseHybridAnalysis({
|
|
156
|
+
rawText: stream.rawText,
|
|
157
|
+
parse,
|
|
158
|
+
fallback,
|
|
159
|
+
delimiterPattern,
|
|
160
|
+
});
|
|
161
|
+
return {
|
|
162
|
+
...parsed,
|
|
163
|
+
rawText: stream.rawText,
|
|
164
|
+
...(stream.responseId ? { responseId: stream.responseId } : {}),
|
|
165
|
+
...(stream.usage === undefined ? {} : { usage: stream.usage }),
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function streamHybridAnalysisEffect<T>({
|
|
171
|
+
body,
|
|
172
|
+
signal,
|
|
173
|
+
runtime = defaultRuntimeServices,
|
|
174
|
+
parse,
|
|
175
|
+
fallback,
|
|
176
|
+
onDelta,
|
|
177
|
+
delimiterPattern = DEFAULT_DELIMITER_PATTERN,
|
|
178
|
+
}: {
|
|
179
|
+
body: unknown;
|
|
180
|
+
signal?: AbortSignal;
|
|
181
|
+
runtime?: RuntimeServices;
|
|
182
|
+
parse: (value: unknown) => T;
|
|
183
|
+
fallback: (markdown: string) => T;
|
|
184
|
+
onDelta?: (delta: string) => void;
|
|
185
|
+
delimiterPattern?: RegExp;
|
|
186
|
+
}): Effect.Effect<HybridAnalysisResult<T>, Error> {
|
|
187
|
+
return Effect.gen(function* () {
|
|
188
|
+
const response = yield* requestOpenAIResponseEffect({
|
|
189
|
+
body,
|
|
190
|
+
signal,
|
|
191
|
+
runtime,
|
|
192
|
+
});
|
|
193
|
+
return yield* readHybridAnalysisStreamEffect(response, {
|
|
194
|
+
parse,
|
|
195
|
+
fallback,
|
|
196
|
+
onDelta,
|
|
197
|
+
delimiterPattern,
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function requestHybridAnalysisEffect<T>({
|
|
203
|
+
body,
|
|
204
|
+
signal,
|
|
205
|
+
runtime = defaultRuntimeServices,
|
|
206
|
+
parse,
|
|
207
|
+
fallback,
|
|
208
|
+
delimiterPattern = DEFAULT_DELIMITER_PATTERN,
|
|
209
|
+
}: {
|
|
210
|
+
body: unknown;
|
|
211
|
+
signal?: AbortSignal;
|
|
212
|
+
runtime?: RuntimeServices;
|
|
213
|
+
parse: (value: unknown) => T;
|
|
214
|
+
fallback: (markdown: string) => T;
|
|
215
|
+
delimiterPattern?: RegExp;
|
|
216
|
+
}): Effect.Effect<HybridAnalysisResult<T>, Error> {
|
|
217
|
+
return Effect.gen(function* () {
|
|
218
|
+
const response = yield* requestOpenAIResponseEffect({
|
|
219
|
+
body,
|
|
220
|
+
signal,
|
|
221
|
+
runtime,
|
|
222
|
+
});
|
|
223
|
+
const payload = (yield* tryPromise(() => response.json()).pipe(
|
|
224
|
+
Effect.mapError(toError),
|
|
225
|
+
)) as Record<string, unknown>;
|
|
226
|
+
const rawText = extractOpenAIResponseText(payload);
|
|
227
|
+
if (!rawText) {
|
|
228
|
+
return yield* Effect.fail(new Error("OpenAI returned no output text"));
|
|
229
|
+
}
|
|
230
|
+
const parsed = parseHybridAnalysis({
|
|
231
|
+
rawText,
|
|
232
|
+
parse,
|
|
233
|
+
fallback,
|
|
234
|
+
delimiterPattern,
|
|
235
|
+
});
|
|
236
|
+
return { ...parsed, rawText };
|
|
237
|
+
});
|
|
238
|
+
}
|