File size: 7,808 Bytes
48f6911
ae14296
 
48f6911
ae14296
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
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
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
import { supabase, SUPABASE_URL } from "@/integrations/supabase/client";

const FN_URL = (name: string) =>
  `${SUPABASE_URL}/functions/v1/${name}`;

/** Trigger ingest pipeline (PDF/DOCX/YouTube/text). */
export async function triggerIngest(documentId: string): Promise<void> {
  const { data: { session } } = await supabase.auth.getSession();
  const token = session?.access_token;
  if (!token) throw new Error("Not authenticated");

  const resp = await fetch(FN_URL("ingest"), {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({ document_id: documentId }),
  });
  if (!resp.ok) {
    const t = await resp.text();
    throw new Error(t || `Ingest failed (${resp.status})`);
  }
}

/** Trigger embedding + chunking for RAG chat. */
export async function embedChunks(documentId: string): Promise<{ ok: boolean; chunks?: number; cached?: boolean }> {
  const { data: { session } } = await supabase.auth.getSession();
  const token = session?.access_token;
  if (!token) throw new Error("Not authenticated");

  const resp = await fetch(FN_URL("embed_chunks"), {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
    body: JSON.stringify({ document_id: documentId }),
  });
  if (!resp.ok) {
    if (resp.status === 429) throw new Error("Rate limit reached β€” please wait a moment and try again.");
    if (resp.status === 402) throw new Error("Hit the free-tier rate limit β€” please wait a moment and retry.");
    const t = await resp.text();
    throw new Error(t || `Embedding failed (${resp.status})`);
  }
  return await resp.json();
}

export type Citation = { n: number; order_index: number; similarity: number; text: string };

/** Stream a RAG chat reply. Calls onCitations once with sources, then onDelta for each token. */
export async function streamChat({
  documentId, message, onCitations, onDelta, signal,
}: {
  documentId: string;
  message: string;
  onCitations: (cites: Citation[]) => void;
  onDelta: (chunk: string) => void;
  signal?: AbortSignal;
}): Promise<string> {
  const { data: { session } } = await supabase.auth.getSession();
  const token = session?.access_token;
  if (!token) throw new Error("Not authenticated");

  const resp = await fetch(FN_URL("chat"), {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
    body: JSON.stringify({ document_id: documentId, message }),
    signal,
  });

  if (!resp.ok || !resp.body) {
    if (resp.status === 429) throw new Error("Rate limit reached β€” please wait a moment and try again.");
    if (resp.status === 402) throw new Error("Hit the free-tier rate limit β€” please wait a moment and retry.");
    const t = await resp.text();
    throw new Error(t || `Chat failed (${resp.status})`);
  }

  const reader = resp.body.getReader();
  const decoder = new TextDecoder();
  let buf = "";
  let full = "";
  let currentEvent: string | null = null;
  let done = false;

  while (!done) {
    const { done: rd, value } = await reader.read();
    if (rd) break;
    buf += decoder.decode(value, { stream: true });

    let idx: number;
    while ((idx = buf.indexOf("\n")) !== -1) {
      let line = buf.slice(0, idx);
      buf = buf.slice(idx + 1);
      if (line.endsWith("\r")) line = line.slice(0, -1);
      if (line === "") { currentEvent = null; continue; }
      if (line.startsWith(":")) continue;
      if (line.startsWith("event: ")) { currentEvent = line.slice(7).trim(); continue; }
      if (!line.startsWith("data: ")) continue;
      const json = line.slice(6).trim();
      if (json === "[DONE]") { done = true; break; }
      try {
        const parsed = JSON.parse(json);
        if (currentEvent === "citations" && Array.isArray(parsed.citations)) {
          onCitations(parsed.citations as Citation[]);
        } else {
          const content = parsed.choices?.[0]?.delta?.content as string | undefined;
          if (content) { full += content; onDelta(content); }
        }
      } catch {
        buf = line + "\n" + buf;
        break;
      }
    }
  }
  return full;
}

/** Trigger flashcards + quiz generation. Returns counts. */
export async function generateDerivatives(documentId: string): Promise<{
  flashcards_count: number; questions_count: number; quiz_id: string | null;
}> {
  const { data: { session } } = await supabase.auth.getSession();
  const token = session?.access_token;
  if (!token) throw new Error("Not authenticated");

  const resp = await fetch(FN_URL("generate_derivatives"), {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
    body: JSON.stringify({ document_id: documentId }),
  });
  if (!resp.ok) {
    if (resp.status === 429) throw new Error("Rate limit reached β€” please wait a moment and try again.");
    if (resp.status === 402) throw new Error("Hit the free-tier rate limit β€” please wait a moment and retry.");
    const t = await resp.text();
    throw new Error(t || `Generation failed (${resp.status})`);
  }
  return await resp.json();
}

/**
 * Stream notes for a document. Calls onDelta for each token chunk.
 * Returns the full markdown when done.
 */
export async function streamNotes({
  documentId,
  onDelta,
  signal,
}: {
  documentId: string;
  onDelta: (chunk: string) => void;
  signal?: AbortSignal;
}): Promise<string> {
  const { data: { session } } = await supabase.auth.getSession();
  const token = session?.access_token;
  if (!token) throw new Error("Not authenticated");

  const resp = await fetch(FN_URL("generate_notes"), {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({ document_id: documentId }),
    signal,
  });

  if (!resp.ok || !resp.body) {
    if (resp.status === 429) throw new Error("Rate limit reached β€” please wait a moment and try again.");
    if (resp.status === 402) throw new Error("Hit the free-tier rate limit β€” please wait a moment and retry.");
    const t = await resp.text();
    throw new Error(t || `Notes generation failed (${resp.status})`);
  }

  const reader = resp.body.getReader();
  const decoder = new TextDecoder();
  let textBuffer = "";
  let full = "";
  let done = false;

  while (!done) {
    const { done: rd, value } = await reader.read();
    if (rd) break;
    textBuffer += decoder.decode(value, { stream: true });

    let idx: number;
    while ((idx = textBuffer.indexOf("\n")) !== -1) {
      let line = textBuffer.slice(0, idx);
      textBuffer = textBuffer.slice(idx + 1);
      if (line.endsWith("\r")) line = line.slice(0, -1);
      if (line.startsWith(":") || line.trim() === "") continue;
      if (!line.startsWith("data: ")) continue;
      const json = line.slice(6).trim();
      if (json === "[DONE]") { done = true; break; }
      try {
        const parsed = JSON.parse(json);
        const content = parsed.choices?.[0]?.delta?.content as string | undefined;
        if (content) {
          full += content;
          onDelta(content);
        }
      } catch {
        textBuffer = line + "\n" + textBuffer;
        break;
      }
    }
  }

  // Flush leftover
  if (textBuffer.trim()) {
    for (let raw of textBuffer.split("\n")) {
      if (raw.endsWith("\r")) raw = raw.slice(0, -1);
      if (!raw.startsWith("data: ")) continue;
      const json = raw.slice(6).trim();
      if (json === "[DONE]") continue;
      try {
        const parsed = JSON.parse(json);
        const content = parsed.choices?.[0]?.delta?.content as string | undefined;
        if (content) { full += content; onDelta(content); }
      } catch { /* ignore */ }
    }
  }

  return full;
}