ng2-pdfjs-viewer
Version:
The most comprehensive Angular PDF viewer, powered by Mozilla PDF.js 6 — view, annotate, sign, fill forms, search, and read aloud from one component. 8.3M+ downloads, mobile-first, production-ready.
182 lines (179 loc) • 7.4 kB
JavaScript
// Secondary entry point: ng2-pdfjs-viewer/ai
//
// A minimal bring-your-own-endpoint AI client for chat-with-PDF and
// summarization flows. The library NEVER talks to any AI service on its own:
// this class only sends requests when the host application calls it, to the
// endpoint the host application configured, with the host application's key.
//
// Works with any OpenAI-compatible chat-completions endpoint: OpenAI, Azure
// OpenAI, Anthropic-compatible gateways, Ollama, vLLM, LM Studio, etc.
//
// This is a headless, framework-free entry point — it imports nothing from
// Angular, so it can be used outside an Angular context (a worker, a Node
// service, a plain script). The full component re-exports these symbols from
// the package root for backwards compatibility.
//
// SECURITY: this sends a fetch from wherever you construct it — in a browser
// app, that is the user's browser. Don't point `endpoint` directly at a hosted
// cloud LLM (OpenAI, Azure, …): the API key would be exposed to the client, and
// most providers block direct browser calls via CORS. Use a local model
// (Ollama, LM Studio) or your own backend proxy. See the AI Assistant guide.
//
// Usage:
// import { PdfAiAssistant } from "ng2-pdfjs-viewer/ai";
// const text = await viewer.getDocumentText();
// const ai = new PdfAiAssistant({
// endpoint: "http://localhost:11434/v1/chat/completions", // e.g. Ollama
// model: "llama3.2",
// });
// const answer = await ai.ask("What is this document about?", text);
class PdfAiAssistant {
config;
constructor(config) {
if (!config?.endpoint) {
throw new Error("PdfAiAssistant requires an endpoint");
}
this.config = config;
}
/**
* Ask a free-form question about the document. `documentText` is the output
* of PdfJsViewerComponent.getDocumentText(); `history` carries prior turns
* for multi-turn chat.
*/
async ask(question, documentText, history = [], signal, onToken) {
const context = this.buildContext(documentText);
const messages = [
{
role: "system",
content: "You answer questions about the provided PDF document. " +
"Cite page numbers like [p.3] when referencing content. " +
"If the answer is not in the document, say so.\n\n" +
`DOCUMENT:\n${context}`,
},
...history,
{ role: "user", content: question },
];
return this.complete(messages, signal, onToken);
}
/** One-shot document summary. */
async summarize(documentText) {
return this.ask("Summarize this document concisely. Lead with what it is, then the key points.", documentText);
}
/**
* Raw chat-completions call for custom prompting. Pass `onToken` to stream the
* answer token-by-token (the callback receives the running full text and the
* latest delta); the Promise still resolves to the complete text. Streaming is
* requested only when `onToken` is given and `config.stream !== false`, and it
* falls back to a single JSON response if the endpoint doesn't stream.
*/
async complete(messages, signal, onToken) {
const headers = {
"Content-Type": "application/json",
...(this.config.headers ?? {}),
};
if (this.config.apiKey) {
headers["Authorization"] = `Bearer ${this.config.apiKey}`;
}
const wantStream = !!onToken && this.config.stream !== false;
const response = await fetch(this.config.endpoint, {
method: "POST",
headers,
signal,
body: JSON.stringify({
model: this.config.model,
temperature: this.config.temperature ?? 0.2,
messages,
...(wantStream ? { stream: true } : {}),
}),
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(`AI endpoint returned ${response.status}: ${body.slice(0, 300)}`);
}
const contentType = response.headers?.get?.("Content-Type") ?? "";
if (wantStream && response.body && contentType.includes("text/event-stream")) {
return this.readStream(response, onToken);
}
// Non-streaming response, or the endpoint ignored `stream`: one JSON body.
const json = await response.json();
const content = json?.choices?.[0]?.message?.content;
if (typeof content !== "string") {
throw new Error("AI endpoint returned an unexpected response shape");
}
// Emit once so callers wired for streaming still receive their update.
if (onToken)
onToken(content, content);
return content;
}
/**
* Read an OpenAI-style Server-Sent Events stream, accumulating
* choices[0].delta.content and emitting each delta through onToken. Returns the
* full concatenated text. Tolerates chunk boundaries that split SSE lines, and
* skips frames it can't parse (keep-alive comments, non-`data:` lines) rather
* than failing the whole stream.
*/
async readStream(response, onToken) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let full = "";
const handleLine = (raw) => {
const line = raw.trim();
if (!line.startsWith("data:"))
return false;
const data = line.slice(5).trim();
if (data === "[DONE]")
return true;
try {
const delta = JSON.parse(data)?.choices?.[0]?.delta?.content;
if (typeof delta === "string" && delta) {
full += delta;
onToken(full, delta);
}
}
catch {
// Ignore an unparseable frame rather than aborting the stream.
}
return false;
};
try {
for (;;) {
const { done, value } = await reader.read();
if (done)
break;
buffer += decoder.decode(value, { stream: true });
let nl;
while ((nl = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nl);
buffer = buffer.slice(nl + 1);
if (handleLine(line))
return full; // [DONE]
}
}
if (buffer.trim())
handleLine(buffer); // trailing line without a newline
}
finally {
reader.releaseLock();
}
return full;
}
buildContext(documentText) {
const max = this.config.maxContextChars ?? 100_000;
let out = "";
for (const page of documentText ?? []) {
const chunk = `[page ${page.page}]\n${page.text}\n\n`;
if (out.length + chunk.length > max) {
out += `\n[truncated at ${max} characters]`;
break;
}
out += chunk;
}
return out;
}
}
/**
* Generated bundle index. Do not edit.
*/
export { PdfAiAssistant };
//# sourceMappingURL=ng2-pdfjs-viewer-ai.mjs.map