tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
297
fork
atom
a tool for shared writing and social publishing
297
fork
atom
overview
issues
31
pulls
pipelines
add super alpha ai api routes
awarm.space
4 days ago
626f48c1
24f90f36
+1451
5 changed files
expand all
collapse all
unified
split
app
api
ai
blocks
[blockId]
route.ts
route.ts
doc
route.ts
lib.tsx
search
route.ts
+375
app/api/ai/blocks/[blockId]/route.ts
reviewed
···
1
1
+
import { NextRequest } from "next/server";
2
2
+
import { drizzle } from "drizzle-orm/node-postgres";
3
3
+
import { sql, eq, and } from "drizzle-orm";
4
4
+
import { pool } from "supabase/pool";
5
5
+
import { facts, entities } from "drizzle/schema";
6
6
+
import {
7
7
+
authenticateToken,
8
8
+
broadcastPoke,
9
9
+
tokenHash,
10
10
+
hasWriteAccess,
11
11
+
editYjsText,
12
12
+
EditOperation,
13
13
+
} from "../../lib";
14
14
+
15
15
+
type Params = { params: Promise<{ blockId: string }> };
16
16
+
17
17
+
// --- DELETE ---
18
18
+
19
19
+
export async function DELETE(req: NextRequest, { params }: Params) {
20
20
+
let auth = await authenticateToken(req);
21
21
+
if (auth instanceof Response) return auth;
22
22
+
23
23
+
if (!hasWriteAccess(auth)) {
24
24
+
return Response.json({ error: "No write access" }, { status: 403 });
25
25
+
}
26
26
+
27
27
+
let { blockId } = await params;
28
28
+
29
29
+
let client = await pool.connect();
30
30
+
try {
31
31
+
let db = drizzle(client);
32
32
+
await db.transaction(async (tx) => {
33
33
+
await tx.execute(sql`SELECT pg_advisory_xact_lock(${tokenHash(auth.tokenId)})`);
34
34
+
35
35
+
// Verify the block entity exists
36
36
+
let [entity] = await tx
37
37
+
.select({ id: entities.id, set: entities.set })
38
38
+
.from(entities)
39
39
+
.where(eq(entities.id, blockId));
40
40
+
41
41
+
if (!entity) {
42
42
+
throw Response.json({ error: "Block not found" }, { status: 404 });
43
43
+
}
44
44
+
45
45
+
// Verify permission
46
46
+
let hasAccess = auth.tokenRights.some(
47
47
+
(r) => r.entity_set === entity.set && r.write,
48
48
+
);
49
49
+
if (!hasAccess) {
50
50
+
throw Response.json({ error: "Block not found" }, { status: 404 });
51
51
+
}
52
52
+
53
53
+
// Check for image to clean up
54
54
+
let [imageFact] = await tx
55
55
+
.select({ data: facts.data })
56
56
+
.from(facts)
57
57
+
.where(and(eq(facts.entity, blockId), eq(facts.attribute, "block/image")));
58
58
+
59
59
+
if (imageFact) {
60
60
+
let { createClient } = await import("@supabase/supabase-js");
61
61
+
let supabase = createClient(
62
62
+
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
63
63
+
process.env.SUPABASE_SERVICE_ROLE_KEY as string,
64
64
+
);
65
65
+
let src = (imageFact.data as any).src;
66
66
+
if (src) {
67
67
+
let paths = src.split("/");
68
68
+
await supabase.storage
69
69
+
.from("minilink-user-assets")
70
70
+
.remove([paths[paths.length - 1]]);
71
71
+
}
72
72
+
}
73
73
+
74
74
+
// Delete the entity (cascades to facts)
75
75
+
await tx.delete(entities).where(eq(entities.id, blockId));
76
76
+
77
77
+
// Also delete referencing facts (card/block pointing to this entity)
78
78
+
await tx.delete(facts).where(
79
79
+
and(
80
80
+
eq(facts.attribute, "card/block"),
81
81
+
sql`data->>'value' = ${blockId}`,
82
82
+
),
83
83
+
);
84
84
+
});
85
85
+
86
86
+
await broadcastPoke(auth.rootEntity);
87
87
+
return Response.json({ deleted: blockId });
88
88
+
} catch (e) {
89
89
+
if (e instanceof Response) return e;
90
90
+
console.error("AI API delete error:", e);
91
91
+
return Response.json({ error: "Internal error" }, { status: 500 });
92
92
+
} finally {
93
93
+
client.release();
94
94
+
}
95
95
+
}
96
96
+
97
97
+
// --- PATCH ---
98
98
+
99
99
+
export async function PATCH(req: NextRequest, { params }: Params) {
100
100
+
let auth = await authenticateToken(req);
101
101
+
if (auth instanceof Response) return auth;
102
102
+
103
103
+
if (!hasWriteAccess(auth)) {
104
104
+
return Response.json({ error: "No write access" }, { status: 403 });
105
105
+
}
106
106
+
107
107
+
let { blockId } = await params;
108
108
+
109
109
+
let body: {
110
110
+
action?: "replace" | "insert";
111
111
+
content?: string;
112
112
+
position?: "start" | "end" | { before: string } | { after: string };
113
113
+
language?: string | null;
114
114
+
};
115
115
+
try {
116
116
+
body = await req.json();
117
117
+
} catch {
118
118
+
return Response.json({ error: "Invalid JSON" }, { status: 400 });
119
119
+
}
120
120
+
121
121
+
let hasContentEdit = body.action !== undefined || body.content !== undefined;
122
122
+
if (hasContentEdit && (!body.action || body.content === undefined)) {
123
123
+
return Response.json(
124
124
+
{ error: "action and content required together" },
125
125
+
{ status: 400 },
126
126
+
);
127
127
+
}
128
128
+
if (!hasContentEdit && body.language === undefined) {
129
129
+
return Response.json(
130
130
+
{ error: "must provide action+content or language" },
131
131
+
{ status: 400 },
132
132
+
);
133
133
+
}
134
134
+
135
135
+
let client = await pool.connect();
136
136
+
try {
137
137
+
let db = drizzle(client);
138
138
+
let result: { blockId: string; newText: string } | null = null;
139
139
+
140
140
+
await db.transaction(async (tx) => {
141
141
+
await tx.execute(sql`SELECT pg_advisory_xact_lock(${tokenHash(auth.tokenId)})`);
142
142
+
143
143
+
// Verify the block entity exists
144
144
+
let [entity] = await tx
145
145
+
.select({ id: entities.id, set: entities.set })
146
146
+
.from(entities)
147
147
+
.where(eq(entities.id, blockId));
148
148
+
149
149
+
if (!entity) {
150
150
+
throw Response.json({ error: "Block not found" }, { status: 404 });
151
151
+
}
152
152
+
153
153
+
let hasAccess = auth.tokenRights.some(
154
154
+
(r) => r.entity_set === entity.set && r.write,
155
155
+
);
156
156
+
if (!hasAccess) {
157
157
+
throw Response.json({ error: "Block not found" }, { status: 404 });
158
158
+
}
159
159
+
160
160
+
// Get block type
161
161
+
let [typeFact] = await tx
162
162
+
.select({ id: facts.id, data: facts.data })
163
163
+
.from(facts)
164
164
+
.where(and(eq(facts.entity, blockId), eq(facts.attribute, "block/type")));
165
165
+
166
166
+
if (!typeFact) {
167
167
+
throw Response.json({ error: "Block has no type" }, { status: 400 });
168
168
+
}
169
169
+
170
170
+
let blockType = (typeFact.data as any).value;
171
171
+
172
172
+
if (
173
173
+
blockType === "text" ||
174
174
+
blockType === "heading" ||
175
175
+
blockType === "blockquote"
176
176
+
) {
177
177
+
if (body.language !== undefined) {
178
178
+
throw Response.json(
179
179
+
{ error: "language only applies to code blocks" },
180
180
+
{ status: 400 },
181
181
+
);
182
182
+
}
183
183
+
if (!hasContentEdit) {
184
184
+
throw Response.json(
185
185
+
{ error: "action and content required" },
186
186
+
{ status: 400 },
187
187
+
);
188
188
+
}
189
189
+
// YJS content
190
190
+
let [textFact] = await tx
191
191
+
.select({ id: facts.id, data: facts.data })
192
192
+
.from(facts)
193
193
+
.where(
194
194
+
and(eq(facts.entity, blockId), eq(facts.attribute, "block/text")),
195
195
+
);
196
196
+
197
197
+
let existingBase64 = textFact ? (textFact.data as any).value : null;
198
198
+
let content = body.content as string;
199
199
+
200
200
+
let operation: EditOperation;
201
201
+
if (body.action === "replace") {
202
202
+
operation = { type: "replace", content };
203
203
+
} else {
204
204
+
operation = {
205
205
+
type: "insert",
206
206
+
position: body.position || "end",
207
207
+
content,
208
208
+
} as EditOperation;
209
209
+
}
210
210
+
211
211
+
if (!existingBase64) {
212
212
+
// No existing text, create new
213
213
+
let { createYjsText } = await import("../../lib");
214
214
+
let newBase64 = createYjsText(content);
215
215
+
if (textFact) {
216
216
+
await tx
217
217
+
.update(facts)
218
218
+
.set({ data: sql`jsonb_set(data, '{value}', ${JSON.stringify(newBase64)}::jsonb)` })
219
219
+
.where(eq(facts.id, textFact.id));
220
220
+
} else {
221
221
+
let { v7 } = await import("uuid");
222
222
+
await tx.insert(facts).values({
223
223
+
id: v7(),
224
224
+
entity: blockId,
225
225
+
attribute: "block/text",
226
226
+
data: sql`${JSON.stringify({ type: "text", value: newBase64 })}::jsonb`,
227
227
+
});
228
228
+
}
229
229
+
result = { blockId, newText: content };
230
230
+
} else {
231
231
+
let editResult = editYjsText(existingBase64, operation);
232
232
+
233
233
+
if ("error" in editResult) {
234
234
+
throw Response.json(
235
235
+
{
236
236
+
error: "search_not_found",
237
237
+
blockText: editResult.fullText,
238
238
+
},
239
239
+
{ status: 400 },
240
240
+
);
241
241
+
}
242
242
+
243
243
+
await tx
244
244
+
.update(facts)
245
245
+
.set({
246
246
+
data: sql`jsonb_set(data, '{value}', ${JSON.stringify(editResult.result)}::jsonb)`,
247
247
+
})
248
248
+
.where(eq(facts.id, textFact.id));
249
249
+
250
250
+
result = { blockId, newText: editResult.plaintext };
251
251
+
}
252
252
+
} else if (blockType === "code") {
253
253
+
// Plain string content
254
254
+
let [codeFact] = await tx
255
255
+
.select({ id: facts.id, data: facts.data })
256
256
+
.from(facts)
257
257
+
.where(
258
258
+
and(eq(facts.entity, blockId), eq(facts.attribute, "block/code")),
259
259
+
);
260
260
+
261
261
+
let existingCode = codeFact ? ((codeFact.data as any).value as string) : "";
262
262
+
let newCode = existingCode;
263
263
+
264
264
+
if (hasContentEdit) {
265
265
+
let content = body.content as string;
266
266
+
if (body.action === "replace") {
267
267
+
newCode = content;
268
268
+
} else {
269
269
+
let pos = body.position || "end";
270
270
+
if (pos === "start") {
271
271
+
newCode = content + existingCode;
272
272
+
} else if (pos === "end") {
273
273
+
newCode = existingCode + content;
274
274
+
} else if (typeof pos === "object" && "before" in pos) {
275
275
+
let idx = existingCode.indexOf(pos.before);
276
276
+
if (idx === -1) {
277
277
+
throw Response.json(
278
278
+
{ error: "search_not_found", blockText: existingCode },
279
279
+
{ status: 400 },
280
280
+
);
281
281
+
}
282
282
+
newCode =
283
283
+
existingCode.slice(0, idx) +
284
284
+
content +
285
285
+
existingCode.slice(idx);
286
286
+
} else if (typeof pos === "object" && "after" in pos) {
287
287
+
let idx = existingCode.indexOf(pos.after);
288
288
+
if (idx === -1) {
289
289
+
throw Response.json(
290
290
+
{ error: "search_not_found", blockText: existingCode },
291
291
+
{ status: 400 },
292
292
+
);
293
293
+
}
294
294
+
newCode =
295
295
+
existingCode.slice(0, idx + pos.after.length) +
296
296
+
content +
297
297
+
existingCode.slice(idx + pos.after.length);
298
298
+
} else {
299
299
+
newCode = existingCode + content;
300
300
+
}
301
301
+
}
302
302
+
303
303
+
if (codeFact) {
304
304
+
await tx
305
305
+
.update(facts)
306
306
+
.set({
307
307
+
data: sql`jsonb_set(data, '{value}', ${JSON.stringify(newCode)}::jsonb)`,
308
308
+
})
309
309
+
.where(eq(facts.id, codeFact.id));
310
310
+
} else {
311
311
+
let { v7 } = await import("uuid");
312
312
+
await tx.insert(facts).values({
313
313
+
id: v7(),
314
314
+
entity: blockId,
315
315
+
attribute: "block/code",
316
316
+
data: sql`${JSON.stringify({ type: "string", value: newCode })}::jsonb`,
317
317
+
});
318
318
+
}
319
319
+
}
320
320
+
321
321
+
// Handle language update
322
322
+
if (body.language !== undefined) {
323
323
+
let [langFact] = await tx
324
324
+
.select({ id: facts.id })
325
325
+
.from(facts)
326
326
+
.where(
327
327
+
and(
328
328
+
eq(facts.entity, blockId),
329
329
+
eq(facts.attribute, "block/code-language"),
330
330
+
),
331
331
+
);
332
332
+
333
333
+
if (body.language === null || body.language === "") {
334
334
+
// Remove language
335
335
+
if (langFact) {
336
336
+
await tx.delete(facts).where(eq(facts.id, langFact.id));
337
337
+
}
338
338
+
} else {
339
339
+
let langData = { type: "string", value: body.language };
340
340
+
if (langFact) {
341
341
+
await tx
342
342
+
.update(facts)
343
343
+
.set({ data: sql`${JSON.stringify(langData)}::jsonb` })
344
344
+
.where(eq(facts.id, langFact.id));
345
345
+
} else {
346
346
+
let { v7 } = await import("uuid");
347
347
+
await tx.insert(facts).values({
348
348
+
id: v7(),
349
349
+
entity: blockId,
350
350
+
attribute: "block/code-language",
351
351
+
data: sql`${JSON.stringify(langData)}::jsonb`,
352
352
+
});
353
353
+
}
354
354
+
}
355
355
+
}
356
356
+
357
357
+
result = { blockId, newText: newCode };
358
358
+
} else {
359
359
+
throw Response.json(
360
360
+
{ error: `Cannot edit blocks of type '${blockType}'` },
361
361
+
{ status: 400 },
362
362
+
);
363
363
+
}
364
364
+
});
365
365
+
366
366
+
await broadcastPoke(auth.rootEntity);
367
367
+
return Response.json(result);
368
368
+
} catch (e) {
369
369
+
if (e instanceof Response) return e;
370
370
+
console.error("AI API patch error:", e);
371
371
+
return Response.json({ error: "Internal error" }, { status: 500 });
372
372
+
} finally {
373
373
+
client.release();
374
374
+
}
375
375
+
}
+242
app/api/ai/blocks/route.ts
reviewed
···
1
1
+
import { NextRequest } from "next/server";
2
2
+
import { drizzle } from "drizzle-orm/node-postgres";
3
3
+
import { sql, eq } from "drizzle-orm";
4
4
+
import { pool } from "supabase/pool";
5
5
+
import { permission_token_rights } from "drizzle/schema";
6
6
+
import { cachedServerMutationContext } from "src/replicache/cachedServerMutationContext";
7
7
+
import { generateKeyBetween } from "fractional-indexing";
8
8
+
import { v7 } from "uuid";
9
9
+
import {
10
10
+
authenticateToken,
11
11
+
resolvePageEntity,
12
12
+
getPageBlocks,
13
13
+
createYjsText,
14
14
+
broadcastPoke,
15
15
+
tokenHash,
16
16
+
hasWriteAccess,
17
17
+
} from "../lib";
18
18
+
19
19
+
type BlockInput =
20
20
+
| { type: "text"; content: string }
21
21
+
| { type: "heading"; content: string; level?: number }
22
22
+
| { type: "code"; content: string; language?: string }
23
23
+
| { type: "blockquote"; content: string }
24
24
+
| { type: "horizontal-rule" };
25
25
+
26
26
+
type PositionInput =
27
27
+
| "start"
28
28
+
| "end"
29
29
+
| { after: string }
30
30
+
| { before: string };
31
31
+
32
32
+
export async function POST(req: NextRequest) {
33
33
+
let auth = await authenticateToken(req);
34
34
+
if (auth instanceof Response) return auth;
35
35
+
36
36
+
if (!hasWriteAccess(auth)) {
37
37
+
return Response.json({ error: "No write access" }, { status: 403 });
38
38
+
}
39
39
+
40
40
+
let body: { page?: string; position: PositionInput; blocks: BlockInput[] };
41
41
+
try {
42
42
+
body = await req.json();
43
43
+
} catch {
44
44
+
return Response.json({ error: "Invalid JSON" }, { status: 400 });
45
45
+
}
46
46
+
47
47
+
if (!body.blocks || !Array.isArray(body.blocks) || body.blocks.length === 0) {
48
48
+
return Response.json({ error: "blocks array required" }, { status: 400 });
49
49
+
}
50
50
+
if (!body.position) {
51
51
+
return Response.json({ error: "position required" }, { status: 400 });
52
52
+
}
53
53
+
54
54
+
let client = await pool.connect();
55
55
+
try {
56
56
+
let db = drizzle(client);
57
57
+
let createdBlocks: { blockId: string; type: string }[] = [];
58
58
+
59
59
+
await db.transaction(async (tx) => {
60
60
+
await tx.execute(sql`SELECT pg_advisory_xact_lock(${tokenHash(auth.tokenId)})`);
61
61
+
62
62
+
let pageEntity = await resolvePageEntity(tx, auth.rootEntity, body.page);
63
63
+
if (pageEntity instanceof Response) throw pageEntity;
64
64
+
65
65
+
let token_rights = await tx
66
66
+
.select()
67
67
+
.from(permission_token_rights)
68
68
+
.where(eq(permission_token_rights.token, auth.tokenId));
69
69
+
70
70
+
let { getContext, flush } = cachedServerMutationContext(
71
71
+
tx,
72
72
+
auth.tokenId,
73
73
+
token_rights,
74
74
+
);
75
75
+
let ctx = getContext("ai-api", 0);
76
76
+
77
77
+
let existingBlocks = await getPageBlocks(tx, pageEntity as string);
78
78
+
let sorted = existingBlocks.sort((a, b) =>
79
79
+
a.position > b.position ? 1 : -1,
80
80
+
);
81
81
+
82
82
+
// Compute initial position based on body.position
83
83
+
let currentPosition: string;
84
84
+
let pos = body.position;
85
85
+
86
86
+
if (pos === "start") {
87
87
+
currentPosition = generateKeyBetween(
88
88
+
null,
89
89
+
sorted[0]?.position || null,
90
90
+
);
91
91
+
} else if (pos === "end") {
92
92
+
currentPosition = generateKeyBetween(
93
93
+
sorted[sorted.length - 1]?.position || null,
94
94
+
null,
95
95
+
);
96
96
+
} else if ("after" in pos) {
97
97
+
let targetIdx = sorted.findIndex((b) => b.value === pos.after);
98
98
+
if (targetIdx === -1) {
99
99
+
throw Response.json({ error: "Block not found for 'after'" }, { status: 404 });
100
100
+
}
101
101
+
currentPosition = generateKeyBetween(
102
102
+
sorted[targetIdx].position,
103
103
+
sorted[targetIdx + 1]?.position || null,
104
104
+
);
105
105
+
} else if ("before" in pos) {
106
106
+
let targetIdx = sorted.findIndex((b) => b.value === pos.before);
107
107
+
if (targetIdx === -1) {
108
108
+
throw Response.json({ error: "Block not found for 'before'" }, { status: 404 });
109
109
+
}
110
110
+
currentPosition = generateKeyBetween(
111
111
+
sorted[targetIdx - 1]?.position || null,
112
112
+
sorted[targetIdx].position,
113
113
+
);
114
114
+
} else {
115
115
+
throw Response.json({ error: "Invalid position" }, { status: 400 });
116
116
+
}
117
117
+
118
118
+
// Track the next position boundary for chaining
119
119
+
let nextBound: string | null = null;
120
120
+
if (pos === "start" && sorted.length > 0) {
121
121
+
nextBound = sorted[0].position;
122
122
+
} else if (typeof pos === "object" && "before" in pos) {
123
123
+
let targetIdx = sorted.findIndex((b) => b.value === pos.before);
124
124
+
nextBound = sorted[targetIdx].position;
125
125
+
}
126
126
+
127
127
+
for (let i = 0; i < body.blocks.length; i++) {
128
128
+
let block = body.blocks[i];
129
129
+
let newEntityID = v7();
130
130
+
let factID = v7();
131
131
+
132
132
+
// For subsequent blocks, chain after the previous position
133
133
+
if (i > 0) {
134
134
+
currentPosition = generateKeyBetween(currentPosition, nextBound);
135
135
+
}
136
136
+
137
137
+
await ctx.createEntity({
138
138
+
entityID: newEntityID,
139
139
+
permission_set: auth.permissionSet!,
140
140
+
});
141
141
+
142
142
+
await ctx.assertFact({
143
143
+
entity: pageEntity as string,
144
144
+
id: factID,
145
145
+
data: {
146
146
+
type: "ordered-reference" as const,
147
147
+
value: newEntityID,
148
148
+
position: currentPosition,
149
149
+
},
150
150
+
attribute: "card/block" as const,
151
151
+
});
152
152
+
153
153
+
let blockType: string;
154
154
+
155
155
+
if (block.type === "text") {
156
156
+
blockType = "text";
157
157
+
await ctx.assertFact({
158
158
+
entity: newEntityID,
159
159
+
data: { type: "block-type-union" as const, value: "text" },
160
160
+
attribute: "block/type" as const,
161
161
+
});
162
162
+
await ctx.assertFact({
163
163
+
entity: newEntityID,
164
164
+
data: { type: "text" as const, value: createYjsText(block.content) },
165
165
+
attribute: "block/text" as const,
166
166
+
});
167
167
+
} else if (block.type === "heading") {
168
168
+
blockType = "heading";
169
169
+
await ctx.assertFact({
170
170
+
entity: newEntityID,
171
171
+
data: { type: "block-type-union" as const, value: "heading" },
172
172
+
attribute: "block/type" as const,
173
173
+
});
174
174
+
await ctx.assertFact({
175
175
+
entity: newEntityID,
176
176
+
data: { type: "text" as const, value: createYjsText(block.content) },
177
177
+
attribute: "block/text" as const,
178
178
+
});
179
179
+
await ctx.assertFact({
180
180
+
entity: newEntityID,
181
181
+
data: { type: "number" as const, value: block.level || 1 },
182
182
+
attribute: "block/heading-level" as const,
183
183
+
});
184
184
+
} else if (block.type === "code") {
185
185
+
blockType = "code";
186
186
+
await ctx.assertFact({
187
187
+
entity: newEntityID,
188
188
+
data: { type: "block-type-union" as const, value: "code" },
189
189
+
attribute: "block/type" as const,
190
190
+
});
191
191
+
await ctx.assertFact({
192
192
+
entity: newEntityID,
193
193
+
data: { type: "string" as const, value: block.content },
194
194
+
attribute: "block/code" as const,
195
195
+
});
196
196
+
if (block.language) {
197
197
+
await ctx.assertFact({
198
198
+
entity: newEntityID,
199
199
+
data: { type: "string" as const, value: block.language },
200
200
+
attribute: "block/code-language" as const,
201
201
+
});
202
202
+
}
203
203
+
} else if (block.type === "blockquote") {
204
204
+
blockType = "blockquote";
205
205
+
await ctx.assertFact({
206
206
+
entity: newEntityID,
207
207
+
data: { type: "block-type-union" as const, value: "blockquote" },
208
208
+
attribute: "block/type" as const,
209
209
+
});
210
210
+
await ctx.assertFact({
211
211
+
entity: newEntityID,
212
212
+
data: { type: "text" as const, value: createYjsText(block.content) },
213
213
+
attribute: "block/text" as const,
214
214
+
});
215
215
+
} else if (block.type === "horizontal-rule") {
216
216
+
blockType = "horizontal-rule";
217
217
+
await ctx.assertFact({
218
218
+
entity: newEntityID,
219
219
+
data: { type: "block-type-union" as const, value: "horizontal-rule" },
220
220
+
attribute: "block/type" as const,
221
221
+
});
222
222
+
} else {
223
223
+
continue;
224
224
+
}
225
225
+
226
226
+
createdBlocks.push({ blockId: newEntityID, type: blockType });
227
227
+
}
228
228
+
229
229
+
await flush();
230
230
+
});
231
231
+
232
232
+
await broadcastPoke(auth.rootEntity);
233
233
+
234
234
+
return Response.json({ blocks: createdBlocks });
235
235
+
} catch (e) {
236
236
+
if (e instanceof Response) return e;
237
237
+
console.error("AI API blocks error:", e);
238
238
+
return Response.json({ error: "Internal error" }, { status: 500 });
239
239
+
} finally {
240
240
+
client.release();
241
241
+
}
242
242
+
}
+138
app/api/ai/doc/route.ts
reviewed
···
1
1
+
import { NextRequest } from "next/server";
2
2
+
import { drizzle } from "drizzle-orm/node-postgres";
3
3
+
import { pool } from "supabase/pool";
4
4
+
import {
5
5
+
authenticateToken,
6
6
+
resolvePageEntity,
7
7
+
getPageBlocks,
8
8
+
getAllFactsForEntities,
9
9
+
blocksToMarkdown,
10
10
+
extractPlaintext,
11
11
+
} from "../lib";
12
12
+
13
13
+
export async function GET(req: NextRequest) {
14
14
+
let auth = await authenticateToken(req);
15
15
+
if (auth instanceof Response) return auth;
16
16
+
17
17
+
let pageParam = req.nextUrl.searchParams.get("page");
18
18
+
19
19
+
let client = await pool.connect();
20
20
+
try {
21
21
+
let db = drizzle(client);
22
22
+
return await db.transaction(async (tx) => {
23
23
+
let pageEntity = await resolvePageEntity(tx, auth.rootEntity, pageParam);
24
24
+
if (pageEntity instanceof Response) return pageEntity;
25
25
+
26
26
+
let blocks = await getPageBlocks(tx, pageEntity);
27
27
+
28
28
+
// Collect all entity IDs we need facts for
29
29
+
let entityIds = new Set<string>();
30
30
+
for (let b of blocks) {
31
31
+
entityIds.add(b.value);
32
32
+
}
33
33
+
let allFacts = await getAllFactsForEntities(tx, [...entityIds]);
34
34
+
35
35
+
// For card blocks, also fetch subpage facts
36
36
+
let subpages: { id: string; title: string }[] = [];
37
37
+
for (let b of blocks) {
38
38
+
if (b.type === "card") {
39
39
+
let cardFacts = allFacts.filter(
40
40
+
(f) => f.entity === b.value && f.attribute === "block/card",
41
41
+
);
42
42
+
if (cardFacts[0]) {
43
43
+
let cardEntityId = (cardFacts[0].data as any).value;
44
44
+
entityIds.add(cardEntityId);
45
45
+
}
46
46
+
}
47
47
+
}
48
48
+
49
49
+
// Re-fetch with subpage entities included
50
50
+
allFacts = await getAllFactsForEntities(tx, [...entityIds]);
51
51
+
52
52
+
// Also fetch subpage block entities for titles
53
53
+
let subpageBlockEntityIds = new Set<string>();
54
54
+
for (let b of blocks) {
55
55
+
if (b.type === "card") {
56
56
+
let cardFacts = allFacts.filter(
57
57
+
(f) => f.entity === b.value && f.attribute === "block/card",
58
58
+
);
59
59
+
if (cardFacts[0]) {
60
60
+
let cardEntityId = (cardFacts[0].data as any).value;
61
61
+
let blockRefs = allFacts
62
62
+
.filter(
63
63
+
(f) =>
64
64
+
f.entity === cardEntityId && f.attribute === "card/block",
65
65
+
)
66
66
+
.sort(
67
67
+
(a, b) =>
68
68
+
(a.data as any).position > (b.data as any).position ? 1 : -1,
69
69
+
);
70
70
+
for (let ref of blockRefs) {
71
71
+
subpageBlockEntityIds.add((ref.data as any).value);
72
72
+
}
73
73
+
}
74
74
+
}
75
75
+
}
76
76
+
77
77
+
if (subpageBlockEntityIds.size > 0) {
78
78
+
let subpageBlockFacts = await getAllFactsForEntities(tx, [
79
79
+
...subpageBlockEntityIds,
80
80
+
]);
81
81
+
allFacts = [...allFacts, ...subpageBlockFacts];
82
82
+
}
83
83
+
84
84
+
// Build subpages list
85
85
+
for (let b of blocks) {
86
86
+
if (b.type === "card") {
87
87
+
let cardFacts = allFacts.filter(
88
88
+
(f) => f.entity === b.value && f.attribute === "block/card",
89
89
+
);
90
90
+
if (cardFacts[0]) {
91
91
+
let cardEntityId = (cardFacts[0].data as any).value;
92
92
+
let blockRefs = allFacts
93
93
+
.filter(
94
94
+
(f) =>
95
95
+
f.entity === cardEntityId && f.attribute === "card/block",
96
96
+
)
97
97
+
.sort(
98
98
+
(a, b) =>
99
99
+
(a.data as any).position > (b.data as any).position ? 1 : -1,
100
100
+
);
101
101
+
let title = "";
102
102
+
if (blockRefs[0]) {
103
103
+
let firstBlockId = (blockRefs[0].data as any).value;
104
104
+
let textFact = allFacts.find(
105
105
+
(f) =>
106
106
+
f.entity === firstBlockId && f.attribute === "block/text",
107
107
+
);
108
108
+
if (textFact) {
109
109
+
title = extractPlaintext((textFact.data as any).value);
110
110
+
}
111
111
+
}
112
112
+
subpages.push({ id: cardEntityId, title: title || "Untitled" });
113
113
+
}
114
114
+
}
115
115
+
}
116
116
+
117
117
+
let markdown = await blocksToMarkdown(blocks, allFacts);
118
118
+
119
119
+
// Extract document title from first heading
120
120
+
let titleBlock = blocks.find(
121
121
+
(b) => b.type === "heading" || b.type === "text",
122
122
+
);
123
123
+
let title = "";
124
124
+
if (titleBlock) {
125
125
+
let textFact = allFacts.find(
126
126
+
(f) => f.entity === titleBlock.value && f.attribute === "block/text",
127
127
+
);
128
128
+
if (textFact) {
129
129
+
title = extractPlaintext((textFact.data as any).value);
130
130
+
}
131
131
+
}
132
132
+
133
133
+
return Response.json({ title, markdown, subpages });
134
134
+
});
135
135
+
} finally {
136
136
+
client.release();
137
137
+
}
138
138
+
}
+609
app/api/ai/lib.tsx
reviewed
···
1
1
+
import { createClient } from "@supabase/supabase-js";
2
2
+
import type { Database } from "supabase/database.types";
3
3
+
import { permission_tokens, permission_token_rights } from "drizzle/schema";
4
4
+
import { entities, facts } from "drizzle/schema";
5
5
+
import * as driz from "drizzle-orm";
6
6
+
import { PgTransaction } from "drizzle-orm/pg-core";
7
7
+
import * as Y from "yjs";
8
8
+
import * as base64 from "base64-js";
9
9
+
import { YJSFragmentToString } from "src/utils/yjsFragmentToString";
10
10
+
import { Block } from "components/Blocks/Block";
11
11
+
import { parseBlocksToList, List } from "src/utils/parseBlocksToList";
12
12
+
import { htmlToMarkdown } from "src/htmlMarkdownParsers";
13
13
+
14
14
+
// --- Auth ---
15
15
+
16
16
+
export type AuthResult = {
17
17
+
tokenId: string;
18
18
+
rootEntity: string;
19
19
+
tokenRights: {
20
20
+
token: string;
21
21
+
entity_set: string;
22
22
+
read: boolean;
23
23
+
write: boolean;
24
24
+
create_token: boolean;
25
25
+
change_entity_set: boolean;
26
26
+
}[];
27
27
+
permissionSet: string | null;
28
28
+
};
29
29
+
30
30
+
export async function authenticateToken(
31
31
+
request: Request,
32
32
+
): Promise<AuthResult | Response> {
33
33
+
let auth = request.headers.get("Authorization");
34
34
+
if (!auth || !auth.startsWith("Bearer ")) {
35
35
+
return Response.json({ error: "Missing Authorization header" }, { status: 401 });
36
36
+
}
37
37
+
let tokenId = auth.slice("Bearer ".length).trim();
38
38
+
if (!tokenId) {
39
39
+
return Response.json({ error: "Invalid token" }, { status: 401 });
40
40
+
}
41
41
+
42
42
+
let supabase = createClient<Database>(
43
43
+
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
44
44
+
process.env.SUPABASE_SERVICE_ROLE_KEY as string,
45
45
+
);
46
46
+
47
47
+
let { data: token } = await supabase
48
48
+
.from("permission_tokens")
49
49
+
.select("id, root_entity, blocked_by_admin")
50
50
+
.eq("id", tokenId)
51
51
+
.single();
52
52
+
53
53
+
if (!token) {
54
54
+
return Response.json({ error: "Invalid token" }, { status: 401 });
55
55
+
}
56
56
+
if (token.blocked_by_admin) {
57
57
+
return Response.json({ error: "Token blocked" }, { status: 403 });
58
58
+
}
59
59
+
60
60
+
let { data: rights } = await supabase
61
61
+
.from("permission_token_rights")
62
62
+
.select("token, entity_set, read, write, create_token, change_entity_set")
63
63
+
.eq("token", tokenId);
64
64
+
65
65
+
let tokenRights = rights || [];
66
66
+
let permissionSet =
67
67
+
tokenRights.find((r) => r.write)?.entity_set ?? null;
68
68
+
69
69
+
return {
70
70
+
tokenId,
71
71
+
rootEntity: token.root_entity,
72
72
+
tokenRights,
73
73
+
permissionSet,
74
74
+
};
75
75
+
}
76
76
+
77
77
+
// --- Page resolution ---
78
78
+
79
79
+
export async function resolvePageEntity(
80
80
+
tx: PgTransaction<any, any, any>,
81
81
+
rootEntity: string,
82
82
+
pageParam?: string | null,
83
83
+
): Promise<string | Response> {
84
84
+
let rootPageFacts = await tx
85
85
+
.select({ data: facts.data })
86
86
+
.from(facts)
87
87
+
.where(
88
88
+
driz.and(
89
89
+
driz.eq(facts.entity, rootEntity),
90
90
+
driz.eq(facts.attribute, "root/page"),
91
91
+
),
92
92
+
);
93
93
+
94
94
+
let mainPage = (rootPageFacts[0]?.data as any)?.value as string | undefined;
95
95
+
if (!mainPage) {
96
96
+
return Response.json({ error: "No main page found" }, { status: 404 });
97
97
+
}
98
98
+
99
99
+
if (!pageParam) return mainPage;
100
100
+
101
101
+
// Verify the requested page exists as an entity in this document
102
102
+
let [pageEntity] = await tx
103
103
+
.select({ id: entities.id })
104
104
+
.from(entities)
105
105
+
.where(driz.eq(entities.id, pageParam));
106
106
+
107
107
+
if (!pageEntity) {
108
108
+
return Response.json({ error: "Page not found" }, { status: 404 });
109
109
+
}
110
110
+
111
111
+
return pageParam;
112
112
+
}
113
113
+
114
114
+
// --- Block fetching (server-side version of getBlocksWithTypeLocal) ---
115
115
+
116
116
+
type FactRow = {
117
117
+
id: string;
118
118
+
entity: string;
119
119
+
attribute: string;
120
120
+
data: any;
121
121
+
};
122
122
+
123
123
+
export async function getPageBlocks(
124
124
+
tx: PgTransaction<any, any, any>,
125
125
+
pageEntity: string,
126
126
+
): Promise<Block[]> {
127
127
+
// Get all facts for this page's blocks in bulk
128
128
+
let blockRefs = await tx
129
129
+
.select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data })
130
130
+
.from(facts)
131
131
+
.where(
132
132
+
driz.and(
133
133
+
driz.eq(facts.entity, pageEntity),
134
134
+
driz.eq(facts.attribute, "card/block"),
135
135
+
),
136
136
+
);
137
137
+
138
138
+
blockRefs.sort((a, b) => {
139
139
+
let posA = (a.data as any).position;
140
140
+
let posB = (b.data as any).position;
141
141
+
if (posA === posB) return a.id > b.id ? 1 : -1;
142
142
+
return posA > posB ? 1 : -1;
143
143
+
});
144
144
+
145
145
+
if (blockRefs.length === 0) return [];
146
146
+
147
147
+
// Collect all block entity IDs
148
148
+
let blockEntityIds = blockRefs.map((r) => (r.data as any).value as string);
149
149
+
150
150
+
// Fetch all facts for these block entities in one query
151
151
+
let allBlockFacts = await tx
152
152
+
.select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data })
153
153
+
.from(facts)
154
154
+
.where(driz.inArray(facts.entity, blockEntityIds));
155
155
+
156
156
+
let factsByEntity = new Map<string, FactRow[]>();
157
157
+
for (let f of allBlockFacts) {
158
158
+
let arr = factsByEntity.get(f.entity);
159
159
+
if (!arr) {
160
160
+
arr = [];
161
161
+
factsByEntity.set(f.entity, arr);
162
162
+
}
163
163
+
arr.push(f);
164
164
+
}
165
165
+
166
166
+
let result: Block[] = [];
167
167
+
168
168
+
for (let ref of blockRefs) {
169
169
+
let blockEntityId = (ref.data as any).value as string;
170
170
+
let blockFacts = factsByEntity.get(blockEntityId) || [];
171
171
+
let typeFact = blockFacts.find((f) => f.attribute === "block/type");
172
172
+
if (!typeFact) continue;
173
173
+
174
174
+
let isListFact = blockFacts.find((f) => f.attribute === "block/is-list");
175
175
+
if (isListFact && (isListFact.data as any).value) {
176
176
+
let children = await getListChildren(tx, ref, pageEntity, 1, []);
177
177
+
result.push(...children);
178
178
+
} else {
179
179
+
result.push({
180
180
+
value: blockEntityId,
181
181
+
position: (ref.data as any).position,
182
182
+
factID: ref.id,
183
183
+
type: (typeFact.data as any).value,
184
184
+
parent: pageEntity,
185
185
+
});
186
186
+
}
187
187
+
}
188
188
+
189
189
+
computeDisplayNumbers(result);
190
190
+
return result;
191
191
+
}
192
192
+
193
193
+
async function getListChildren(
194
194
+
tx: PgTransaction<any, any, any>,
195
195
+
root: FactRow,
196
196
+
pageParent: string,
197
197
+
depth: number,
198
198
+
path: { depth: number; entity: string }[],
199
199
+
): Promise<Block[]> {
200
200
+
let rootValue = (root.data as any).value as string;
201
201
+
202
202
+
let childRefs = await tx
203
203
+
.select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data })
204
204
+
.from(facts)
205
205
+
.where(
206
206
+
driz.and(
207
207
+
driz.eq(facts.entity, rootValue),
208
208
+
driz.eq(facts.attribute, "card/block"),
209
209
+
),
210
210
+
);
211
211
+
childRefs.sort((a, b) =>
212
212
+
(a.data as any).position > (b.data as any).position ? 1 : -1,
213
213
+
);
214
214
+
215
215
+
let rootFacts = await tx
216
216
+
.select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data })
217
217
+
.from(facts)
218
218
+
.where(driz.eq(facts.entity, rootValue));
219
219
+
220
220
+
let typeFact = rootFacts.find((f) => f.attribute === "block/type");
221
221
+
if (!typeFact) return [];
222
222
+
223
223
+
let listStyleFact = rootFacts.find((f) => f.attribute === "block/list-style");
224
224
+
let listNumberFact = rootFacts.find((f) => f.attribute === "block/list-number");
225
225
+
226
226
+
let newPath = [...path, { entity: rootValue, depth }];
227
227
+
228
228
+
let childBlocks: Block[] = [];
229
229
+
for (let c of childRefs) {
230
230
+
let children = await getListChildren(tx, c, rootValue, depth + 1, newPath);
231
231
+
childBlocks.push(...children);
232
232
+
}
233
233
+
234
234
+
return [
235
235
+
{
236
236
+
value: rootValue,
237
237
+
position: (root.data as any).position,
238
238
+
factID: root.id,
239
239
+
type: (typeFact.data as any).value,
240
240
+
parent: pageParent,
241
241
+
listData: {
242
242
+
depth,
243
243
+
parent: root.entity,
244
244
+
path: newPath,
245
245
+
listStyle: listStyleFact ? (listStyleFact.data as any).value : undefined,
246
246
+
listStart: listNumberFact ? (listNumberFact.data as any).value : undefined,
247
247
+
},
248
248
+
},
249
249
+
...childBlocks,
250
250
+
];
251
251
+
}
252
252
+
253
253
+
function computeDisplayNumbers(blocks: Block[]): void {
254
254
+
let counters = new Map<string, number>();
255
255
+
for (let block of blocks) {
256
256
+
if (!block.listData) {
257
257
+
counters.clear();
258
258
+
continue;
259
259
+
}
260
260
+
if (block.listData.listStyle !== "ordered") continue;
261
261
+
let parent = block.listData.parent;
262
262
+
if (block.listData.listStart !== undefined) {
263
263
+
counters.set(parent, block.listData.listStart);
264
264
+
} else if (!counters.has(parent)) {
265
265
+
counters.set(parent, 1);
266
266
+
}
267
267
+
block.listData.displayNumber = counters.get(parent)!;
268
268
+
counters.set(parent, counters.get(parent)! + 1);
269
269
+
}
270
270
+
}
271
271
+
272
272
+
// --- Server-side YJS to HTML rendering ---
273
273
+
274
274
+
function escapeHtml(s: string): string {
275
275
+
return s
276
276
+
.replace(/&/g, "&")
277
277
+
.replace(/</g, "<")
278
278
+
.replace(/>/g, ">")
279
279
+
.replace(/"/g, """);
280
280
+
}
281
281
+
282
282
+
function renderYjsToHTML(
283
283
+
base64Value: string,
284
284
+
wrapper: "p" | "h1" | "h2" | "h3" | "blockquote",
285
285
+
attrs?: Record<string, string>,
286
286
+
): string {
287
287
+
let attrStr = attrs
288
288
+
? Object.entries(attrs)
289
289
+
.filter(([, v]) => v !== undefined)
290
290
+
.map(([k, v]) => ` ${k}="${escapeHtml(v)}"`)
291
291
+
.join("")
292
292
+
: "";
293
293
+
294
294
+
if (!base64Value) return `<${wrapper}${attrStr}></${wrapper}>`;
295
295
+
296
296
+
let doc = new Y.Doc();
297
297
+
Y.applyUpdate(doc, base64.toByteArray(base64Value));
298
298
+
let [node] = doc.getXmlElement("prosemirror").toArray();
299
299
+
if (!node || node.constructor !== Y.XmlElement) return `<${wrapper}${attrStr}></${wrapper}>`;
300
300
+
301
301
+
let children = node.toArray();
302
302
+
if (children.length === 0) return `<${wrapper}${attrStr}><br/></${wrapper}>`;
303
303
+
304
304
+
let inner = children
305
305
+
.map((child) => {
306
306
+
if (child.constructor === Y.XmlText) {
307
307
+
let deltas = child.toDelta() as { insert: string; attributes?: any }[];
308
308
+
if (deltas.length === 0) return "<br/>";
309
309
+
return deltas
310
310
+
.map((d) => {
311
311
+
let text = escapeHtml(d.insert);
312
312
+
if (d.attributes?.link) return `<a href="${escapeHtml(d.attributes.link.href)}">${text}</a>`;
313
313
+
if (d.attributes?.strong) text = `<strong>${text}</strong>`;
314
314
+
if (d.attributes?.em) text = `<em>${text}</em>`;
315
315
+
if (d.attributes?.code) text = `<code>${text}</code>`;
316
316
+
return text;
317
317
+
})
318
318
+
.join("");
319
319
+
}
320
320
+
if (child.constructor === Y.XmlElement) {
321
321
+
if (child.nodeName === "hard_break") return "<br/>";
322
322
+
if (child.nodeName === "didMention" || child.nodeName === "atMention") {
323
323
+
let text = child.getAttribute("text") || "";
324
324
+
return escapeHtml(text);
325
325
+
}
326
326
+
}
327
327
+
return "";
328
328
+
})
329
329
+
.join("");
330
330
+
331
331
+
return `<${wrapper}${attrStr}>${inner}</${wrapper}>`;
332
332
+
}
333
333
+
334
334
+
// --- Block-to-HTML (server-side, reads from pre-fetched facts) ---
335
335
+
336
336
+
export async function getAllFactsForEntities(
337
337
+
tx: PgTransaction<any, any, any>,
338
338
+
entityIds: string[],
339
339
+
): Promise<FactRow[]> {
340
340
+
if (entityIds.length === 0) return [];
341
341
+
return tx
342
342
+
.select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data })
343
343
+
.from(facts)
344
344
+
.where(driz.inArray(facts.entity, entityIds));
345
345
+
}
346
346
+
347
347
+
function factsLookup(allFacts: FactRow[], entity: string, attribute: string): FactRow[] {
348
348
+
return allFacts.filter((f) => f.entity === entity && f.attribute === attribute);
349
349
+
}
350
350
+
351
351
+
async function renderBlockToHTML(
352
352
+
b: Block,
353
353
+
allFacts: FactRow[],
354
354
+
): Promise<string> {
355
355
+
let [alignment] = factsLookup(allFacts, b.value, "block/text-alignment");
356
356
+
let a = alignment ? (alignment.data as any).value : undefined;
357
357
+
358
358
+
switch (b.type) {
359
359
+
case "text": {
360
360
+
let [value] = factsLookup(allFacts, b.value, "block/text");
361
361
+
return renderYjsToHTML(value?.data.value, "p", a ? { "data-alignment": a } : undefined);
362
362
+
}
363
363
+
case "heading": {
364
364
+
let [value] = factsLookup(allFacts, b.value, "block/text");
365
365
+
let [headingLevel] = factsLookup(allFacts, b.value, "block/heading-level");
366
366
+
let wrapper = ("h" + ((headingLevel?.data as any)?.value || 1)) as "h1" | "h2" | "h3";
367
367
+
return renderYjsToHTML(value?.data.value, wrapper, a ? { "data-alignment": a } : undefined);
368
368
+
}
369
369
+
case "blockquote": {
370
370
+
let [value] = factsLookup(allFacts, b.value, "block/text");
371
371
+
return renderYjsToHTML(value?.data.value, "blockquote", a ? { "data-alignment": a } : undefined);
372
372
+
}
373
373
+
case "code": {
374
374
+
let [code] = factsLookup(allFacts, b.value, "block/code");
375
375
+
let [lang] = factsLookup(allFacts, b.value, "block/code-language");
376
376
+
let langValue = (lang?.data as any)?.value as string | undefined;
377
377
+
let codeAttr = langValue ? ` class="language-${escapeHtml(langValue)}"` : "";
378
378
+
return `<pre><code${codeAttr}>${escapeHtml((code?.data as any)?.value || "")}</code></pre>`;
379
379
+
}
380
380
+
case "image": {
381
381
+
let [src] = factsLookup(allFacts, b.value, "block/image");
382
382
+
if (!src) return "";
383
383
+
let alignAttr = a ? ` data-alignment="${escapeHtml(a)}"` : "";
384
384
+
return `<img src="${escapeHtml((src.data as any).src)}"${alignAttr}/>`;
385
385
+
}
386
386
+
case "horizontal-rule":
387
387
+
return "<hr/>";
388
388
+
case "card": {
389
389
+
let [card] = factsLookup(allFacts, b.value, "block/card");
390
390
+
if (!card) return "";
391
391
+
let cardEntityId = (card.data as any).value;
392
392
+
let title = await getSubpageTitle(allFacts, cardEntityId);
393
393
+
return `<a href="subpage:${cardEntityId}">${escapeHtml(title || "Untitled")}</a>`;
394
394
+
}
395
395
+
case "link": {
396
396
+
let [url] = factsLookup(allFacts, b.value, "link/url");
397
397
+
let [title] = factsLookup(allFacts, b.value, "link/title");
398
398
+
if (!url) return "";
399
399
+
return `<a href="${escapeHtml((url.data as any).value)}" target="_blank">${escapeHtml((title?.data as any)?.value || "")}</a>`;
400
400
+
}
401
401
+
case "button": {
402
402
+
let [text] = factsLookup(allFacts, b.value, "button/text");
403
403
+
let [url] = factsLookup(allFacts, b.value, "button/url");
404
404
+
if (!text || !url) return "";
405
405
+
return `<a href="${escapeHtml((url.data as any).value)}">${escapeHtml((text.data as any).value)}</a>`;
406
406
+
}
407
407
+
case "math": {
408
408
+
let [math] = factsLookup(allFacts, b.value, "block/math");
409
409
+
return `<code>${escapeHtml((math?.data as any)?.value || "")}</code>`;
410
410
+
}
411
411
+
default:
412
412
+
return "";
413
413
+
}
414
414
+
}
415
415
+
416
416
+
async function getSubpageTitle(
417
417
+
allFacts: FactRow[],
418
418
+
cardEntityId: string,
419
419
+
): Promise<string> {
420
420
+
// Look for card/block children of the subpage to find first heading
421
421
+
let blockRefs = allFacts
422
422
+
.filter((f) => f.entity === cardEntityId && f.attribute === "card/block")
423
423
+
.sort((a, b) => ((a.data as any).position > (b.data as any).position ? 1 : -1));
424
424
+
425
425
+
if (blockRefs.length === 0) return "";
426
426
+
427
427
+
let firstBlockId = (blockRefs[0].data as any).value;
428
428
+
let [textFact] = allFacts.filter(
429
429
+
(f) => f.entity === firstBlockId && f.attribute === "block/text",
430
430
+
);
431
431
+
432
432
+
if (textFact) {
433
433
+
return extractPlaintext((textFact.data as any).value);
434
434
+
}
435
435
+
return "";
436
436
+
}
437
437
+
438
438
+
async function renderListToHTML(l: List, allFacts: FactRow[]): Promise<string> {
439
439
+
let children = (
440
440
+
await Promise.all(l.children.map((c) => renderListToHTML(c, allFacts)))
441
441
+
).join("\n");
442
442
+
443
443
+
let checkedFacts = factsLookup(allFacts, l.block.value, "block/check-list");
444
444
+
let checked = checkedFacts[0];
445
445
+
446
446
+
let isOrdered = l.children[0]?.block.listData?.listStyle === "ordered";
447
447
+
let tag = isOrdered ? "ol" : "ul";
448
448
+
449
449
+
return `<li ${checked ? `data-checked=${(checked.data as any).value}` : ""}>${await renderBlockToHTML(l.block, allFacts)} ${
450
450
+
l.children.length > 0 ? `<${tag}>${children}</${tag}>` : ""
451
451
+
}</li>`;
452
452
+
}
453
453
+
454
454
+
export async function blocksToHTML(
455
455
+
blocks: Block[],
456
456
+
allFacts: FactRow[],
457
457
+
): Promise<string[]> {
458
458
+
let result: string[] = [];
459
459
+
let parsed = parseBlocksToList(blocks);
460
460
+
461
461
+
for (let pb of parsed) {
462
462
+
if (pb.type === "block") {
463
463
+
result.push(await renderBlockToHTML(pb.block, allFacts));
464
464
+
} else {
465
465
+
let isOrdered = pb.children[0]?.block.listData?.listStyle === "ordered";
466
466
+
let tag = isOrdered ? "ol" : "ul";
467
467
+
let listItems = (
468
468
+
await Promise.all(
469
469
+
pb.children.map((c) => renderListToHTML(c, allFacts)),
470
470
+
)
471
471
+
).join("\n");
472
472
+
result.push(`<${tag}>${listItems}</${tag}>`);
473
473
+
}
474
474
+
}
475
475
+
return result;
476
476
+
}
477
477
+
478
478
+
// --- Blocks-to-markdown ---
479
479
+
480
480
+
export async function blocksToMarkdown(
481
481
+
blocks: Block[],
482
482
+
allFacts: FactRow[],
483
483
+
): Promise<string> {
484
484
+
let htmlParts = await blocksToHTML(blocks, allFacts);
485
485
+
let html = htmlParts.join("\n");
486
486
+
return htmlToMarkdown(html);
487
487
+
}
488
488
+
489
489
+
// --- YJS plaintext extraction ---
490
490
+
491
491
+
export function extractPlaintext(base64Value: string): string {
492
492
+
if (!base64Value) return "";
493
493
+
let doc = new Y.Doc();
494
494
+
Y.applyUpdate(doc, base64.toByteArray(base64Value));
495
495
+
let nodes = doc.getXmlElement("prosemirror").toArray();
496
496
+
if (nodes.length === 0) return "";
497
497
+
return YJSFragmentToString(nodes[0]);
498
498
+
}
499
499
+
500
500
+
// --- YJS text creation ---
501
501
+
502
502
+
export function createYjsText(plaintext: string): string {
503
503
+
let doc = new Y.Doc();
504
504
+
let fragment = doc.getXmlFragment("prosemirror");
505
505
+
let paragraph = new Y.XmlElement("paragraph");
506
506
+
let textNode = new Y.XmlText();
507
507
+
textNode.insert(0, plaintext);
508
508
+
paragraph.insert(0, [textNode]);
509
509
+
fragment.insert(0, [paragraph]);
510
510
+
return base64.fromByteArray(Y.encodeStateAsUpdate(doc));
511
511
+
}
512
512
+
513
513
+
// --- YJS text editing ---
514
514
+
515
515
+
export type EditOperation =
516
516
+
| { type: "replace"; content: string }
517
517
+
| { type: "insert"; position: "start" | "end"; content: string }
518
518
+
| { type: "insert"; position: { before: string } | { after: string }; content: string };
519
519
+
520
520
+
export function editYjsText(
521
521
+
existingBase64: string,
522
522
+
operation: EditOperation,
523
523
+
): { result: string; plaintext: string } | { error: "search_not_found"; fullText: string } {
524
524
+
let doc = new Y.Doc();
525
525
+
Y.applyUpdate(doc, base64.toByteArray(existingBase64));
526
526
+
527
527
+
let element = doc.getXmlElement("prosemirror");
528
528
+
let paragraph = element.toArray()[0];
529
529
+
if (!paragraph || paragraph.constructor !== Y.XmlElement) {
530
530
+
return { error: "search_not_found", fullText: "" };
531
531
+
}
532
532
+
533
533
+
// Find the XmlText child
534
534
+
let textNodes = paragraph.toArray();
535
535
+
let xmlText: Y.XmlText | null = null;
536
536
+
for (let n of textNodes) {
537
537
+
if (n.constructor === Y.XmlText) {
538
538
+
xmlText = n;
539
539
+
break;
540
540
+
}
541
541
+
}
542
542
+
543
543
+
if (!xmlText) {
544
544
+
// No text node exists yet, create one for replace/insert
545
545
+
xmlText = new Y.XmlText();
546
546
+
paragraph.insert(0, [xmlText]);
547
547
+
}
548
548
+
549
549
+
let currentText = (xmlText.toDelta() as { insert: string }[])
550
550
+
.map((d) => d.insert)
551
551
+
.join("");
552
552
+
553
553
+
if (operation.type === "replace") {
554
554
+
xmlText.delete(0, currentText.length);
555
555
+
xmlText.insert(0, operation.content);
556
556
+
} else if (operation.type === "insert") {
557
557
+
let pos = operation.position;
558
558
+
if (pos === "start") {
559
559
+
xmlText.insert(0, operation.content);
560
560
+
} else if (pos === "end") {
561
561
+
xmlText.insert(currentText.length, operation.content);
562
562
+
} else if ("before" in pos) {
563
563
+
let idx = currentText.indexOf(pos.before);
564
564
+
if (idx === -1) return { error: "search_not_found", fullText: currentText };
565
565
+
xmlText.insert(idx, operation.content);
566
566
+
} else if ("after" in pos) {
567
567
+
let idx = currentText.indexOf(pos.after);
568
568
+
if (idx === -1) return { error: "search_not_found", fullText: currentText };
569
569
+
xmlText.insert(idx + pos.after.length, operation.content);
570
570
+
}
571
571
+
}
572
572
+
573
573
+
let newText = (xmlText.toDelta() as { insert: string }[])
574
574
+
.map((d) => d.insert)
575
575
+
.join("");
576
576
+
577
577
+
return {
578
578
+
result: base64.fromByteArray(Y.encodeStateAsUpdate(doc)),
579
579
+
plaintext: newText,
580
580
+
};
581
581
+
}
582
582
+
583
583
+
// --- Realtime poke ---
584
584
+
585
585
+
export async function broadcastPoke(rootEntity: string) {
586
586
+
let supabase = createClient<Database>(
587
587
+
process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
588
588
+
process.env.SUPABASE_SERVICE_ROLE_KEY as string,
589
589
+
);
590
590
+
let channel = supabase.channel(`rootEntity:${rootEntity}`);
591
591
+
await channel.send({
592
592
+
type: "broadcast",
593
593
+
event: "poke",
594
594
+
payload: { message: "poke" },
595
595
+
});
596
596
+
await supabase.removeChannel(channel);
597
597
+
}
598
598
+
599
599
+
// --- Helpers ---
600
600
+
601
601
+
export function tokenHash(tokenId: string): number {
602
602
+
return tokenId.split("").reduce((acc, char) => {
603
603
+
return ((acc << 5) - acc + char.charCodeAt(0)) | 0;
604
604
+
}, 0);
605
605
+
}
606
606
+
607
607
+
export function hasWriteAccess(auth: AuthResult): boolean {
608
608
+
return auth.tokenRights.some((r) => r.write);
609
609
+
}
+87
app/api/ai/search/route.ts
reviewed
···
1
1
+
import { NextRequest } from "next/server";
2
2
+
import { drizzle } from "drizzle-orm/node-postgres";
3
3
+
import { pool } from "supabase/pool";
4
4
+
import {
5
5
+
authenticateToken,
6
6
+
resolvePageEntity,
7
7
+
getPageBlocks,
8
8
+
getAllFactsForEntities,
9
9
+
extractPlaintext,
10
10
+
} from "../lib";
11
11
+
12
12
+
export async function GET(req: NextRequest) {
13
13
+
let auth = await authenticateToken(req);
14
14
+
if (auth instanceof Response) return auth;
15
15
+
16
16
+
let query = req.nextUrl.searchParams.get("q");
17
17
+
if (!query) {
18
18
+
return Response.json({ error: "Missing q parameter" }, { status: 400 });
19
19
+
}
20
20
+
21
21
+
let pageParam = req.nextUrl.searchParams.get("page");
22
22
+
let queryLower = query.toLowerCase();
23
23
+
24
24
+
let client = await pool.connect();
25
25
+
try {
26
26
+
let db = drizzle(client);
27
27
+
return await db.transaction(async (tx) => {
28
28
+
let pageEntity = await resolvePageEntity(tx, auth.rootEntity, pageParam);
29
29
+
if (pageEntity instanceof Response) return pageEntity;
30
30
+
31
31
+
let blocks = await getPageBlocks(tx, pageEntity);
32
32
+
let entityIds = blocks.map((b) => b.value);
33
33
+
let allFacts = await getAllFactsForEntities(tx, entityIds);
34
34
+
35
35
+
let results: {
36
36
+
blockId: string;
37
37
+
type: string;
38
38
+
text: string;
39
39
+
language?: string;
40
40
+
}[] = [];
41
41
+
42
42
+
for (let b of blocks) {
43
43
+
if (
44
44
+
b.type === "text" ||
45
45
+
b.type === "heading" ||
46
46
+
b.type === "blockquote"
47
47
+
) {
48
48
+
let textFact = allFacts.find(
49
49
+
(f) => f.entity === b.value && f.attribute === "block/text",
50
50
+
);
51
51
+
if (textFact) {
52
52
+
let plaintext = extractPlaintext((textFact.data as any).value);
53
53
+
if (plaintext.toLowerCase().includes(queryLower)) {
54
54
+
results.push({ blockId: b.value, type: b.type, text: plaintext });
55
55
+
}
56
56
+
}
57
57
+
} else if (b.type === "code") {
58
58
+
let codeFact = allFacts.find(
59
59
+
(f) => f.entity === b.value && f.attribute === "block/code",
60
60
+
);
61
61
+
if (codeFact) {
62
62
+
let code = (codeFact.data as any).value as string;
63
63
+
if (code.toLowerCase().includes(queryLower)) {
64
64
+
let langFact = allFacts.find(
65
65
+
(f) =>
66
66
+
f.entity === b.value && f.attribute === "block/code-language",
67
67
+
);
68
68
+
let language = langFact
69
69
+
? ((langFact.data as any).value as string)
70
70
+
: undefined;
71
71
+
results.push({
72
72
+
blockId: b.value,
73
73
+
type: b.type,
74
74
+
text: code,
75
75
+
...(language ? { language } : {}),
76
76
+
});
77
77
+
}
78
78
+
}
79
79
+
}
80
80
+
}
81
81
+
82
82
+
return Response.json({ results });
83
83
+
});
84
84
+
} finally {
85
85
+
client.release();
86
86
+
}
87
87
+
}