File size: 5,298 Bytes
a32aee9
 
 
 
 
 
 
f1fa34c
a32aee9
 
 
 
 
 
 
 
 
 
c2d8746
 
 
 
 
 
a32aee9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f1fa34c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a32aee9
f1fa34c
 
 
 
a32aee9
f1fa34c
a32aee9
f1fa34c
 
 
 
 
 
 
 
 
 
 
 
 
 
a32aee9
c2d8746
a32aee9
 
f1fa34c
c2d8746
f1fa34c
 
73db263
 
 
 
 
 
c2d8746
73db263
 
a32aee9
f1fa34c
 
 
 
 
 
c2d8746
a32aee9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c2d8746
 
a32aee9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
// SSE client for the streaming chat endpoint.
//
// EventSource only supports GET, but /api/chat/stream is a POST carrying the
// api_key + chat history in its body, so we read the stream manually from
// fetch()'s ReadableStream and parse SSE frames ("event:"/"data:" blocks).

import { API_BASE_URL } from "./api";
import { clearSession, getSession, isAccessTokenStale, refreshSession } from "./authStore";
import type { ChatMessage, ChatSource, Reference, WhereFilter } from "./types";

export interface ChatStreamMeta {
  references: Reference[];
  openalex_used: number;
  uploaded_used: number;
  reasoning: string;
  source: ChatSource;
}

/**
 * Completion is signalled by the returned promise settling, not by a callback.
 * A separate `onDone` was a second completion channel that every early return
 * had to remember to fire — and the one path that could not fire it (abort)
 * was exactly the path that left the caller's "streaming" flag stuck on.
 */
export interface ChatStreamHandlers {
  onToken: (text: string) => void;
  onMeta: (meta: ChatStreamMeta) => void;
  onError: (message: string) => void;
}

export interface ChatStreamParams {
  query: string;
  chatHistory: Pick<ChatMessage, "role" | "content">[];
  apiKey: string;
  model: string;
  where: WhereFilter;
  kbOnly: boolean;
  signal?: AbortSignal;
}

export async function streamChat(
  params: ChatStreamParams,
  handlers: ChatStreamHandlers,
): Promise<void> {
  const payload = JSON.stringify({
    query: params.query,
    chat_history: params.chatHistory,
    api_key: params.apiKey,
    model: params.model,
    where: params.where,
    kb_only: params.kbOnly,
  });

  // Refresh proactively — a stream that 401s mid-flight cannot be replayed
  // cleanly, so it is much better to renew before opening it.
  let session = getSession();
  if (session && isAccessTokenStale(session)) {
    session = await refreshSession(API_BASE_URL);
  }

  const open = (token?: string) =>
    fetch(`${API_BASE_URL}/api/chat/stream`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
      },
      signal: params.signal,
      body: payload,
    });

  let res: Response;
  try {
    res = await open(session?.accessToken);
    if (res.status === 401 && getSession()) {
      const refreshed = await refreshSession(API_BASE_URL);
      if (refreshed) {
        res = await open(refreshed.accessToken);
      } else {
        clearSession();
        handlers.onError("Your session has expired. Please sign in again.");
        return;
      }
    }
  } catch {
    handlers.onError(`Cannot reach the API at ${API_BASE_URL}. Is the backend running?`);    return;
  }

  if (res.status === 429) {
    handlers.onError("You're sending messages too quickly. Please wait a moment and retry.");    return;
  }

  // The backend sleeps when idle; while it reloads its models the proxy returns
  // 502/503/504. That is a wait, not a failure — say so rather than showing a
  // raw gateway error.
  if (res.status === 502 || res.status === 503 || res.status === 504) {
    handlers.onError(
      "The server is waking up and loading its models. Give it a minute, then send your question again.",
    );    return;
  }

  if (!res.ok || !res.body) {
    let detail = `Chat request failed (${res.status}).`;
    try {
      detail = (await res.json())?.message || detail;
    } catch {
      /* non-JSON body */
    }
    handlers.onError(detail);    return;
  }

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  const dispatch = (frame: string) => {
    // A frame is a block separated by a blank line, e.g.
    //   event: token\ndata: {"text":"..."}
    let event = "message";
    const dataLines: string[] = [];
    for (const line of frame.split("\n")) {
      if (line.startsWith("event:")) event = line.slice(6).trim();
      else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
    }
    if (dataLines.length === 0) return;
    let payload: unknown;
    try {
      payload = JSON.parse(dataLines.join("\n"));
    } catch {
      return;
    }

    switch (event) {
      case "token":
        handlers.onToken((payload as { text: string }).text ?? "");
        break;
      case "meta":
        handlers.onMeta(payload as ChatStreamMeta);
        break;
      case "error":
        handlers.onError((payload as { message: string }).message ?? "Unknown error.");
        break;
      // "done" needs no case: the frame simply ends the stream, and the caller
      // learns that from the promise settling.
    }
  };

  try {
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });

      let sep: number;
      // SSE frames are separated by a blank line ("\n\n").
      while ((sep = buffer.indexOf("\n\n")) !== -1) {
        const frame = buffer.slice(0, sep);
        buffer = buffer.slice(sep + 2);
        if (frame.trim()) dispatch(frame);
      }
    }
    if (buffer.trim()) dispatch(buffer);
  } catch (err) {
    if ((err as Error)?.name !== "AbortError") {
      handlers.onError("Connection interrupted while streaming the answer.");
    }
  }
}