1"use client";
2
3
17import { ApiError, apiFetch, buildQuery } from "./api";
18
19
20
21export interface AiConversationSummary {
22 id: string;
23 title: string | null;
24 context?: { pageId?: string; blockId?: string; databaseId?: string } | null;
25 createdAt: string;
26 updatedAt: string;
27 messageCount: number;
28}
29
30export interface AiMessage {
31 id: string;
32 role: "user" | "assistant";
33 content: string;
34 model?: string | null;
35 tokensIn?: number | null;
36 tokensOut?: number | null;
37 createdAt: string;
38 metadata?: { feature?: string; truncated?: boolean; error?: string } | null;
39}
40
41export interface AiContextRef {
42 pageId?: string;
43 blockId?: string;
44 databaseId?: string;
45
51 label?: string;
52}
53
54
55export function pickContextIds(c: AiContextRef): { pageId?: string; blockId?: string; databaseId?: string } {
56 return {
57 ...(c.pageId ? { pageId: c.pageId } : {}),
58 ...(c.blockId ? { blockId: c.blockId } : {}),
59 ...(c.databaseId ? { databaseId: c.databaseId } : {})
60 };
61}
62
63export function hasContextIds(c: AiContextRef | null | undefined): boolean {
64 return !!c && (!!c.pageId || !!c.blockId || !!c.databaseId);
65}
66
67
68export interface RagSource {
69
70 n: number;
71 docId: string;
72
73 id: string;
74
75 type: string;
76 title: string;
77
78 url: string;
79 snippet: string;
80 source: "bm25" | "vec" | "bm25+vec";
81
82 pageId?: string;
83 blockId?: string;
84 databaseId?: string;
85 recordId?: string;
86}
87
88
89export type AiSseFrame =
90 | { type: "sources"; sources: RagSource[] }
91 | { type: "delta"; text: string }
92 | { type: "tool_call"; id: string; name: string; arguments: Record<string, unknown> }
93 | { type: "usage"; tokensIn: number; tokensOut: number }
94 | { type: "error"; code: string; message: string; retryable?: boolean; retryAfterSeconds?: number }
95 | { type: "done"; assistantMessageId?: string; userMessageId?: string; reason?: "ok" | "aborted" | "error" };
96
97
98export class AiPreStreamError extends Error {
99 readonly code: string;
100 readonly status: number;
101 readonly retryAfterSeconds?: number;
102 constructor(err: ApiError) {
103 super(err.message);
104 this.name = "AiPreStreamError";
105 this.code = err.code;
106 this.status = err.status;
107 const d = (err.details as Array<Record<string, unknown>> | undefined)?.[0];
108 if (d && typeof d.retryAfterSeconds === "number") this.retryAfterSeconds = d.retryAfterSeconds;
109 }
110}
111
112
113
114
127export function parseSseFrames(buf: string): { frames: AiSseFrame[]; rest: string } {
128 const frames: AiSseFrame[] = [];
129 let rest = buf;
130 let idx: number;
131 while ((idx = rest.indexOf("\n\n")) !== -1) {
132 const frame = rest.slice(0, idx);
133 rest = rest.slice(idx + 2);
134 for (const line of frame.split("\n")) {
135 if (!line.startsWith("data:")) continue;
136 try {
137 frames.push(JSON.parse(line.slice(5).trim()) as AiSseFrame);
138 } catch {
139
140 }
141 }
142 }
143 return { frames, rest };
144}
145
146
147
148export const aiApi = {
149 listConversations(workspaceId: string, signal?: AbortSignal): Promise<AiConversationSummary[]> {
150 return apiFetch<{ data: AiConversationSummary[] }>(
151 `/workspaces/${workspaceId}/ai/conversations`,
152 { signal }
153 ).then((r) => r.data);
154 },
155 createConversation(
156 workspaceId: string,
157 body: { title?: string; context?: AiContextRef },
158 signal?: AbortSignal
159 ): Promise<AiConversationSummary> {
160 const ctx = body.context && hasContextIds(body.context) ? pickContextIds(body.context) : undefined;
161 return apiFetch<AiConversationSummary>(`/workspaces/${workspaceId}/ai/conversations`, {
162 method: "POST",
163 body: { ...(body.title ? { title: body.title } : {}), ...(ctx ? { context: ctx } : {}) },
164 signal
165 });
166 },
167 getConversation(id: string, signal?: AbortSignal): Promise<AiConversationSummary> {
168 return apiFetch<AiConversationSummary>(`/ai/conversations/${id}`, { signal });
169 },
170 renameConversation(id: string, title: string): Promise<AiConversationSummary> {
171 return apiFetch<AiConversationSummary>(`/ai/conversations/${id}`, {
172 method: "PATCH",
173 body: { title }
174 });
175 },
176 deleteConversation(id: string): Promise<void> {
177 return apiFetch<void>(`/ai/conversations/${id}`, { method: "DELETE" });
178 },
179 listMessages(conversationId: string, signal?: AbortSignal): Promise<AiMessage[]> {
180 return apiFetch<{ data: AiMessage[] }>(
181 `/ai/conversations/${conversationId}/messages${buildQuery({ limit: 50 })}`,
182 { signal }
183 ).then((r) => r.data);
184 }
185};
186
187
188
189export interface SendAiMessageParams {
190 workspaceId: string;
191 conversationId: string;
192 content: string;
193 context?: AiContextRef;
194
195 retryOf?: string;
196
197 signal?: AbortSignal;
198}
199
200
209export async function sendAiMessage(
210 params: SendAiMessageParams,
211 onFrame: (frame: AiSseFrame) => void
212): Promise<"ok" | "aborted" | "error"> {
213
214
215 const { authStore } = await import("./auth-store");
216 const { API_BASE } = await import("./constants");
217 const token = authStore.accessToken;
218
219 const res = await fetch(
220 `${API_BASE}/workspaces/${params.workspaceId}/ai/conversations/${params.conversationId}/messages`,
221 {
222 method: "POST",
223 headers: {
224 "Content-Type": "application/json",
225 Accept: "text/event-stream",
226 ...(token ? { Authorization: `Bearer ${token}` } : {})
227 },
228 body: JSON.stringify({
229 content: params.content,
230
231
232
233 ...(params.context && hasContextIds(params.context)
234 ? { context: pickContextIds(params.context) }
235 : {}),
236 ...(params.retryOf ? { retryOf: params.retryOf } : {})
237 }),
238 signal: params.signal,
239 credentials: "include"
240 }
241 );
242
243
244
245 if (!res.ok || !res.body) {
246 let err: ApiError;
247 try {
248 const json = (await res.json()) as { error?: { code: string; message: string; details?: unknown } };
249 err = new ApiError(json.error?.code ?? "UNKNOWN", res.status, json.error?.message ?? "Request failed", json.error?.details);
250 } catch {
251 err = new ApiError("UNKNOWN", res.status, "Request failed");
252 }
253 throw new AiPreStreamError(err);
254 }
255
256 const reader = res.body.getReader();
257 const decoder = new TextDecoder();
258 let buf = "";
259 let result: "ok" | "aborted" | "error" = "error";
260
261 try {
262 for (;;) {
263 const chunk = await reader.read();
264 if (chunk.done) break;
265 buf += decoder.decode(chunk.value, { stream: true });
266 const { frames, rest } = parseSseFrames(buf);
267 buf = rest;
268 for (const parsed of frames) {
269 onFrame(parsed);
270 if (parsed.type === "done") {
271 result = parsed.reason === "aborted" ? "aborted" : parsed.reason === "error" ? "error" : "ok";
272 } else if (parsed.type === "error") {
273 result = "error";
274 }
275 }
276 }
277
278
279 const { frames } = parseSseFrames(buf + "\n\n");
280 for (const parsed of frames) {
281 onFrame(parsed);
282 if (parsed.type === "done") {
283 result = parsed.reason === "aborted" ? "aborted" : parsed.reason === "error" ? "error" : "ok";
284 } else if (parsed.type === "error") {
285 result = "error";
286 }
287 }
288 } catch (err) {
289
290
291 if (err instanceof Error && err.name === "AbortError") {
292 result = "aborted";
293 } else {
294 throw err;
295 }
296 } finally {
297 try {
298 reader.releaseLock();
299 } catch {
300
301 }
302 }
303 return result;
304}
305
306
307
308
314export function blockPlainText(content: unknown): string {
315 if (typeof content === "string") return content;
316 if (content && typeof content === "object") {
317 const c = content as { text?: unknown; title?: unknown };
318 if (typeof c.text === "string") return c.text;
319 if (typeof c.title === "string") return c.title;
320 }
321 return "";
322}
323
324
325export const WRITING_ACTIONS = ["summarize", "rewrite", "improve", "translate"] as const;
326export type WritingAction = (typeof WRITING_ACTIONS)[number];
327
328
329export const WRITING_LANGUAGES = [
330 { id: "en", label: "English" },
331 { id: "es", label: "Spanish" },
332 { id: "de", label: "German" },
333 { id: "fr", label: "French" },
334 { id: "ja", label: "Japanese" },
335 { id: "pt", label: "Portuguese" },
336 { id: "zh", label: "Chinese" }
337] as const;
338
339
349export interface WritingSuggestionBlock {
350 blockId: string;
351 newText: string;
352
354 text?: string;
355
356 version?: number;
357}
358
359export interface WritingSuggestion {
360 action: WritingAction;
361 language?: string;
362 pageId: string;
363 blockIds: string[];
364 blocks: WritingSuggestionBlock[];
365
366 droppedIds: string[];
367}
368
369
370export interface WritingApplyResult {
371 blockId: string;
372 status: "applied" | "failed";
373 code?: string;
374 message?: string;
375 actualVersion?: number;
376}
377
378export const writingApi = {
379
385 apply(
386 workspaceId: string,
387 body: { suggestionId: string; blockIds?: string[] }
388 ): Promise<{ results: WritingApplyResult[] }> {
389 return apiFetch<{ results: WritingApplyResult[] }>(
390 `/workspaces/${workspaceId}/ai/writing/apply`,
391 { method: "POST", body }
392 );
393 }
394};
395
396export interface SendWritingParams {
397 workspaceId: string;
398 pageId: string;
399 blockIds: string[];
400 action: WritingAction;
401 language?: string;
402 signal?: AbortSignal;
403
404 onFrame?: (frame: AiSseFrame) => void;
405}
406
407
414export async function sendWriting(
415 params: SendWritingParams
416): Promise<{
417 reason: "ok" | "aborted" | "error";
418 suggestion?: WritingSuggestion;
419 suggestionId?: string;
420 usage?: { tokensIn: number; tokensOut: number };
421 notes?: string;
422}> {
423 const { authStore } = await import("./auth-store");
424 const { API_BASE } = await import("./constants");
425 const token = authStore.accessToken;
426
427 const res = await fetch(`${API_BASE}/workspaces/${params.workspaceId}/ai/writing`, {
428 method: "POST",
429 headers: {
430 "Content-Type": "application/json",
431 Accept: "text/event-stream",
432 ...(token ? { Authorization: `Bearer ${token}` } : {})
433 },
434 body: JSON.stringify({
435 action: params.action,
436 pageId: params.pageId,
437 blockIds: params.blockIds,
438 ...(params.language ? { language: params.language } : {})
439 }),
440 signal: params.signal,
441 credentials: "include"
442 });
443
444 if (!res.ok || !res.body) {
445 let err: ApiError;
446 try {
447 const json = (await res.json()) as { error?: { code: string; message: string; details?: unknown } };
448 err = new ApiError(json.error?.code ?? "UNKNOWN", res.status, json.error?.message ?? "Request failed", json.error?.details);
449 } catch {
450 err = new ApiError("UNKNOWN", res.status, "Request failed");
451 }
452 throw new AiPreStreamError(err);
453 }
454
455 const reader = res.body.getReader();
456 const decoder = new TextDecoder();
457 let buf = "";
458 let reason: "ok" | "aborted" | "error" = "error";
459 let suggestion: WritingSuggestion | undefined;
460 let suggestionId: string | undefined;
461 let usage: { tokensIn: number; tokensOut: number } | undefined;
462 const notes: string[] = [];
463
464 const handle = (frame: AiSseFrame): void => {
465 params.onFrame?.(frame);
466 if (frame.type === "tool_call" && frame.name === "generateBlocks") {
467 const args = frame.arguments as { blocks?: Array<{ blockId: string; newText: string }> };
468 const raw = Array.isArray(args?.blocks) ? args.blocks : [];
469
470
471
472 const requested = new Set(params.blockIds);
473 const emitted = raw.filter(
474 (b): b is { blockId: string; newText: string } => !!b && typeof b.blockId === "string"
475 );
476
477
478
479
480 suggestion = {
481 action: params.action,
482 ...(params.language ? { language: params.language } : {}),
483 pageId: params.pageId,
484 blockIds: params.blockIds,
485 blocks: emitted.map((b) => ({ blockId: b.blockId, newText: b.newText })),
486 droppedIds: emitted.filter((b) => !requested.has(b.blockId)).map((b) => b.blockId)
487 };
488 } else if (frame.type === "usage") {
489 usage = { tokensIn: frame.tokensIn, tokensOut: frame.tokensOut };
490 } else if (frame.type === "done") {
491 reason = frame.reason === "aborted" ? "aborted" : frame.reason === "error" ? "error" : "ok";
492 suggestionId = frame.assistantMessageId;
493 } else if (frame.type === "error") {
494 reason = "error";
495 } else if (frame.type === "delta" && frame.text) {
496
497
498 notes.push(frame.text);
499 }
500 };
501
502 try {
503 for (;;) {
504 const chunk = await reader.read();
505 if (chunk.done) break;
506 buf += decoder.decode(chunk.value, { stream: true });
507 const { frames, rest } = parseSseFrames(buf);
508 buf = rest;
509 for (const f of frames) handle(f);
510 }
511 const { frames } = parseSseFrames(buf + "\n\n");
512 for (const f of frames) handle(f);
513 } catch (err) {
514 if (err instanceof Error && err.name === "AbortError") reason = "aborted";
515 else throw err;
516 } finally {
517 try {
518 reader.releaseLock();
519 } catch {
520
521 }
522 }
523 return {
524 reason,
525 ...(suggestion ? { suggestion } : {}),
526 ...(suggestionId ? { suggestionId } : {}),
527 ...(usage ? { usage } : {}),
528 ...(notes.length > 0 ? { notes: notes.join(" ").trim() } : {})
529 };
530}
531
532
533
534export interface SendRagParams {
535 workspaceId: string;
536 query: string;
537
538 types?: string[];
539 signal?: AbortSignal;
540
541 onFrame?: (frame: AiSseFrame) => void;
542}
543
544
553export async function askRag(
554 params: SendRagParams
555): Promise<{
556 reason: "ok" | "aborted" | "error";
557 answer: string;
558 sources: RagSource[];
559 usage?: { tokensIn: number; tokensOut: number };
560 error?: { code: string; message: string };
561}> {
562 const { authStore } = await import("./auth-store");
563 const { API_BASE } = await import("./constants");
564 const token = authStore.accessToken;
565
566 const res = await fetch(`${API_BASE}/workspaces/${params.workspaceId}/ai/search`, {
567 method: "POST",
568 headers: {
569 "Content-Type": "application/json",
570 Accept: "text/event-stream",
571 ...(token ? { Authorization: `Bearer ${token}` } : {})
572 },
573 body: JSON.stringify({
574 query: params.query,
575 ...(params.types && params.types.length > 0 ? { context: { types: params.types } } : {})
576 }),
577 signal: params.signal,
578 credentials: "include"
579 });
580
581 if (!res.ok || !res.body) {
582 let err: ApiError;
583 try {
584 const json = (await res.json()) as { error?: { code: string; message: string; details?: unknown } };
585 err = new ApiError(json.error?.code ?? "UNKNOWN", res.status, json.error?.message ?? "Request failed", json.error?.details);
586 } catch {
587 err = new ApiError("UNKNOWN", res.status, "Request failed");
588 }
589 throw new AiPreStreamError(err);
590 }
591
592 const reader = res.body.getReader();
593 const decoder = new TextDecoder();
594 let buf = "";
595 let reason: "ok" | "aborted" | "error" = "error";
596 let answer = "";
597 let sources: RagSource[] = [];
598 let usage: { tokensIn: number; tokensOut: number } | undefined;
599 let error: { code: string; message: string } | undefined;
600
601 const handle = (frame: AiSseFrame): void => {
602 params.onFrame?.(frame);
603 if (frame.type === "sources") sources = frame.sources ?? [];
604 else if (frame.type === "delta" && frame.text) answer += frame.text;
605 else if (frame.type === "usage") usage = { tokensIn: frame.tokensIn, tokensOut: frame.tokensOut };
606 else if (frame.type === "error") {
607 reason = "error";
608 error = { code: frame.code, message: frame.message };
609 } else if (frame.type === "done") {
610 reason = frame.reason === "aborted" ? "aborted" : frame.reason === "error" ? "error" : "ok";
611 }
612 };
613
614 try {
615 for (;;) {
616 const chunk = await reader.read();
617 if (chunk.done) break;
618 buf += decoder.decode(chunk.value, { stream: true });
619 const { frames, rest } = parseSseFrames(buf);
620 buf = rest;
621 for (const f of frames) handle(f);
622 }
623 const { frames } = parseSseFrames(buf + "\n\n");
624 for (const f of frames) handle(f);
625 } catch (err) {
626 if (err instanceof Error && err.name === "AbortError") reason = "aborted";
627 else throw err;
628 } finally {
629 try {
630 reader.releaseLock();
631 } catch {
632
633 }
634 }
635 return {
636 reason,
637 answer,
638 sources,
639 ...(usage ? { usage } : {}),
640 ...(error ? { error } : {})
641 };
642}
643
644
645
646
652export type GenerateToolName = "createPage" | "insertBlocks" | "createDatabase" | "createRelation";
653
654export type GenerateFrame =
655 | { type: "tool"; name: GenerateToolName; status: "started" | "done"; ref?: string }
656 | {
657 type: "done";
658 pageId: string;
659 created: { pages: string[]; blocks: number; databases: string[]; relations: number };
660 tokensIn?: number;
661 tokensOut?: number;
662 }
663 | { type: "cancelled" }
664 | { type: "error"; code: string; message: string; retryable?: boolean };
665
666export interface GenerateAiPageParams {
667 workspaceId: string;
668 prompt: string;
669 parentId?: string | null;
670
672 idempotencyKey?: string;
673 signal?: AbortSignal;
674
675 onFrame?: (frame: GenerateFrame) => void;
676}
677
678export interface GenerateResult {
679 reason: "ok" | "aborted" | "error" | "cancelled";
680 pageId?: string;
681 created?: { pages: string[]; blocks: number; databases: string[]; relations: number };
682 usage?: { tokensIn: number; tokensOut: number };
683 error?: { code: string; message: string };
684}
685
686
694export async function generateAiPage(params: GenerateAiPageParams): Promise<GenerateResult> {
695 const { authStore } = await import("./auth-store");
696 const { API_BASE } = await import("./constants");
697 const token = authStore.accessToken;
698
699 const res = await fetch(`${API_BASE}/workspaces/${params.workspaceId}/ai/generate`, {
700 method: "POST",
701 headers: {
702 "Content-Type": "application/json",
703 Accept: "text/event-stream",
704 ...(token ? { Authorization: `Bearer ${token}` } : {}),
705 ...(params.idempotencyKey ? { "Idempotency-Key": params.idempotencyKey } : {})
706 },
707 body: JSON.stringify({
708 prompt: params.prompt,
709 ...(params.parentId ? { parentId: params.parentId } : {})
710 }),
711 signal: params.signal,
712 credentials: "include"
713 });
714
715 if (!res.ok || !res.body) {
716 let err: ApiError;
717 try {
718 const json = (await res.json()) as { error?: { code: string; message: string; details?: unknown } };
719 err = new ApiError(json.error?.code ?? "UNKNOWN", res.status, json.error?.message ?? "Request failed", json.error?.details);
720 } catch {
721 err = new ApiError("UNKNOWN", res.status, "Request failed");
722 }
723 throw new AiPreStreamError(err);
724 }
725
726 const reader = res.body.getReader();
727 const decoder = new TextDecoder();
728 let buf = "";
729 let result: GenerateResult = { reason: "error" };
730
731
732
733
734
735
736
737
738 const STREAM_IDLE_MS = 150_000;
739 let lastActivity = Date.now();
740 let stalled = false;
741 const watchdog = setInterval(() => {
742 if (Date.now() - lastActivity > STREAM_IDLE_MS) {
743 stalled = true;
744 clearInterval(watchdog);
745 void reader.cancel().catch(() => {
746
747 });
748 }
749 }, 5_000);
750
751 const handle = (frame: GenerateFrame): void => {
752 params.onFrame?.(frame);
753 if (frame.type === "done") {
754 result = {
755 reason: "ok",
756 pageId: frame.pageId,
757 created: frame.created,
758 ...(frame.tokensIn != null || frame.tokensOut != null
759 ? { usage: { tokensIn: frame.tokensIn ?? 0, tokensOut: frame.tokensOut ?? 0 } }
760 : {})
761 };
762 } else if (frame.type === "cancelled") {
763 result = { ...result, reason: "cancelled" };
764 } else if (frame.type === "error") {
765 result = { ...result, reason: "error", error: { code: frame.code, message: frame.message } };
766 }
767 };
768
769 try {
770 for (;;) {
771 const chunk = await reader.read();
772 if (chunk.done) break;
773 lastActivity = Date.now();
774 buf += decoder.decode(chunk.value, { stream: true });
775 const { frames, rest } = parseSseFrames(buf);
776 buf = rest;
777 for (const f of frames as GenerateFrame[]) handle(f);
778 }
779 if (!stalled) {
780 const { frames } = parseSseFrames(buf + "\n\n");
781 for (const f of frames as GenerateFrame[]) handle(f);
782 }
783 } catch (err) {
784 if (err instanceof Error && err.name === "AbortError") {
785 result = { ...result, reason: "aborted" };
786 } else if (!stalled) {
787 throw err;
788 }
789 } finally {
790 clearInterval(watchdog);
791 try {
792 reader.releaseLock();
793 } catch {
794
795 }
796 }
797 if (stalled) {
798 result = {
799 reason: "error",
800 error: {
801 code: "STREAM_TIMEOUT",
802 message: "Generation stalled — no progress from the server. Try again."
803 }
804 };
805 }
806 return result;
807}
808
809
810
818
819export interface AssistantExtractTask {
820 id: string;
821 title: string;
822 dueDate?: string | null;
823}
824
825export interface AssistantExtractResult {
826 tasks: AssistantExtractTask[];
827 tokensIn: number;
828 tokensOut: number;
829}
830
831export interface AssistantFaqResult {
832 page: { id: string; title: string };
833 faqCount: number;
834 tokensIn: number;
835 tokensOut: number;
836}
837
838export interface AssistantDuplicatePage {
839 pageId: string;
840 title: string;
841 updatedAt: string;
842}
843
844export interface AssistantDuplicateGroup {
845 similarity: number;
846 reason: string;
847 pages: AssistantDuplicatePage[];
848}
849
850export interface AssistantDuplicatesResult {
851 groups: AssistantDuplicateGroup[];
852 considered: number;
853 tokensIn: number;
854 tokensOut: number;
855}
856
857export interface AssistantStalePage {
858 pageId: string;
859 title: string;
860 updatedAt: string;
861 reason: string;
862}
863
864export interface AssistantStaleResult {
865 pages: AssistantStalePage[];
866 considered: number;
867 tokensIn: number;
868 tokensOut: number;
869}
870
871export interface AssistantSuggestion {
872 title?: string;
873 description?: string;
874}
875
876export interface AssistantSuggestResult {
877 suggestionId: string;
878 pageId: string;
879 current: { title: string; description: string | null };
880 suggested: AssistantSuggestion;
881 tokensIn: number;
882 tokensOut: number;
883}
884
885export interface AssistantApplyResult {
886 pageId: string;
887 applied: string[];
888}
889
890export const assistantApi = {
891
892 extractActions(
893 workspaceId: string,
894 text: string,
895 signal?: AbortSignal
896 ): Promise<AssistantExtractResult> {
897 return apiFetch<AssistantExtractResult>(`/workspaces/${workspaceId}/ai/assistant/extract-actions`, {
898 method: "POST",
899 body: { text },
900 signal
901 });
902 },
903
904
905 generateFaqs(
906 workspaceId: string,
907 pageIds: string[],
908 signal?: AbortSignal
909 ): Promise<AssistantFaqResult> {
910 return apiFetch<AssistantFaqResult>(`/workspaces/${workspaceId}/ai/assistant/generate-faqs`, {
911 method: "POST",
912 body: { pageIds },
913 signal
914 });
915 },
916
917
918 findDuplicates(
919 workspaceId: string,
920 params: { query?: string; limit?: number } = {},
921 signal?: AbortSignal
922 ): Promise<AssistantDuplicatesResult> {
923 return apiFetch<AssistantDuplicatesResult>(`/workspaces/${workspaceId}/ai/assistant/find-duplicates`, {
924 method: "POST",
925 body: {
926 ...(params.query ? { query: params.query } : {}),
927 ...(params.limit ? { limit: params.limit } : {})
928 },
929 signal
930 });
931 },
932
933
934 findStale(
935 workspaceId: string,
936 params: { days?: number; limit?: number } = {},
937 signal?: AbortSignal
938 ): Promise<AssistantStaleResult> {
939 return apiFetch<AssistantStaleResult>(`/workspaces/${workspaceId}/ai/assistant/find-stale`, {
940 method: "POST",
941 body: {
942 ...(params.days ? { days: params.days } : {}),
943 ...(params.limit ? { limit: params.limit } : {})
944 },
945 signal
946 });
947 },
948
949
950 suggestMetadata(
951 workspaceId: string,
952 pageId: string,
953 signal?: AbortSignal
954 ): Promise<AssistantSuggestResult> {
955 return apiFetch<AssistantSuggestResult>(`/workspaces/${workspaceId}/ai/assistant/suggest-metadata`, {
956 method: "POST",
957 body: { pageId },
958 signal
959 });
960 },
961
962
963 applyMetadata(
964 workspaceId: string,
965 suggestionId: string,
966 fields: AssistantSuggestion,
967 signal?: AbortSignal
968 ): Promise<AssistantApplyResult> {
969 return apiFetch<AssistantApplyResult>(`/workspaces/${workspaceId}/ai/assistant/apply-metadata`, {
970 method: "POST",
971 body: { suggestionId, fields },
972 signal
973 });
974 }
975};
976