Skip to content
File

Blob: src/client/lib/ai/context.ts

typescript123 lines
1import type { EditorState } from "@tiptap/pm/state";
2import type { Node as PmNode, ResolvedPos } from "@tiptap/pm/model";
3 
4const BLOCK_TEXT_CAP = 2000;
5const SELECTION_CAP = 4000;
6const MAX_EMPTY_SIBLING_WALK = 12;
7 
8export interface RewriteContext {
9 selectedText: string;
10 parentBlock: string;
11 beforeBlock: string;
12 afterBlock: string;
13}
14 
15export interface GenerateContext {
16 beforeBlock: string;
17 afterBlock: string;
18}
19 
20export function extractRewriteContext(state: EditorState): RewriteContext {
21 const { from, to } = state.selection;
22 const selectedText = capText(state.doc.textBetween(from, to, "\n", " "), SELECTION_CAP);
23 
24 const boundary = topLevelBoundaryAt(state.doc, state.doc.resolve(from));
25 const parentNode = boundary ? state.doc.child(boundary.index) : null;
26 const parentBlock = parentNode ? capText(parentNode.textContent, BLOCK_TEXT_CAP) : "";
27 
28 return {
29 selectedText,
30 parentBlock,
31 beforeBlock: boundary ? nearestNonEmptySibling(state.doc, boundary.index - 1, -1) : "",
32 afterBlock: boundary ? nearestNonEmptySibling(state.doc, boundary.index + 1, 1) : "",
33 };
34}
35 
36export function extractDocumentTitle(doc: PmNode): string {
37 let title = "";
38 doc.descendants((node) => {
39 if (title) return false;
40 if (node.type.name === "heading" && (node.attrs as { level?: number }).level === 1) {
41 const text = node.textContent.trim();
42 if (text) {
43 title = text;
44 return false;
45 }
46 }
47 return true;
48 });
49 return title;
50}
51 
52export function extractGenerateContext(state: EditorState, cursorPos: number): GenerateContext {
53 const clamped = Math.max(0, Math.min(cursorPos, state.doc.content.size));
54 const $pos = state.doc.resolve(clamped);
55 const boundary = topLevelBoundaryAt(state.doc, $pos);
56 if (!boundary) {
57 return { beforeBlock: "", afterBlock: "" };
58 }
59 
60 const current = state.doc.child(boundary.index);
61 const currentText = current.textContent;
62 
63 const prevText = nearestNonEmptySibling(state.doc, boundary.index - 1, -1);
64 const nextText = nearestNonEmptySibling(state.doc, boundary.index + 1, 1);
65 
66 if (currentText.length === 0) {
67 return {
68 beforeBlock: prevText,
69 afterBlock: nextText,
70 };
71 }
72 
73 // Slice the current block at the cursor. textBetween over the top-level
74 // block range works uniformly for paragraphs, headings, list items,
75 // callouts, and details blocks (depth >= 2). When the cursor is at a
76 // top-level boundary ($pos.depth === 0), there is no inside-block position
77 // to slice at, so treat the cursor as sitting at the start of the block.
78 let beforeCursor = "";
79 let afterCursor = currentText;
80 if ($pos.depth > 0) {
81 const blockStart = $pos.before(1) + 1;
82 const blockEnd = $pos.after(1) - 1;
83 if (clamped >= blockStart && clamped <= blockEnd) {
84 beforeCursor = state.doc.textBetween(blockStart, clamped, "\n", " ");
85 afterCursor = state.doc.textBetween(clamped, blockEnd, "\n", " ");
86 }
87 }
88 
89 const before = prevText && beforeCursor ? `${prevText}\n\n${beforeCursor}` : prevText || beforeCursor;
90 const after = afterCursor && nextText ? `${afterCursor}\n\n${nextText}` : afterCursor || nextText;
91 return {
92 beforeBlock: capText(before, BLOCK_TEXT_CAP),
93 afterBlock: capText(after, BLOCK_TEXT_CAP),
94 };
95}
96 
97function topLevelBoundaryAt(doc: PmNode, pos: ResolvedPos): { index: number } | null {
98 if (doc.childCount === 0) return null;
99 if (pos.depth === 0) {
100 const index = Math.min(pos.index(0), doc.childCount - 1);
101 return { index };
102 }
103 return { index: pos.index(0) };
104}
105 
106function nearestNonEmptySibling(doc: PmNode, startIndex: number, step: 1 | -1): string {
107 let index = startIndex;
108 let walked = 0;
109 while (walked < MAX_EMPTY_SIBLING_WALK) {
110 if (index < 0 || index >= doc.childCount) return "";
111 const text = doc.child(index).textContent;
112 if (text.length > 0) return capText(text, BLOCK_TEXT_CAP);
113 index += step;
114 walked += 1;
115 }
116 return "";
117}
118 
119function capText(text: string, cap: number): string {
120 if (text.length <= cap) return text;
121 return text.slice(0, cap);
122}