@kedataindo/docflow-core 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -31,12 +31,15 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  BlockAttributesExtension: () => BlockAttributesExtension,
34
+ CONTEXT_CHAR_CAP: () => CONTEXT_CHAR_CAP,
34
35
  EditorContextExtension: () => EditorContextExtension,
35
36
  FontSizeExtension: () => FontSizeExtension,
37
+ LOCAL_STORAGE_KEY: () => LOCAL_STORAGE_KEY,
36
38
  PAGE_SIZES: () => PAGE_SIZES,
37
39
  PaginationPlus: () => PaginationPlus,
38
40
  SearchAndReplaceExtension: () => SearchAndReplaceExtension,
39
41
  SubdocumentProvider: () => SubdocumentProvider,
42
+ buildAIPrompt: () => buildAIPrompt,
40
43
  clearSearch: () => clearSearch,
41
44
  collaborationExtensions: () => collaborationExtensions,
42
45
  collectExtensions: () => collectExtensions,
@@ -46,6 +49,10 @@ __export(index_exports, {
46
49
  definePlugin: () => definePlugin,
47
50
  findMatches: () => findMatches,
48
51
  getSearchState: () => getSearchState,
52
+ httpKeyStorage: () => httpKeyStorage,
53
+ localStorageKeyStorage: () => localStorageKeyStorage,
54
+ memoryKeyStorage: () => memoryKeyStorage,
55
+ openaiCompatibleProvider: () => openaiCompatibleProvider,
49
56
  replaceAll: () => replaceAll,
50
57
  replaceCurrent: () => replaceCurrent,
51
58
  resolveAction: () => resolveAction,
@@ -53,7 +60,10 @@ __export(index_exports, {
53
60
  searchAndReplaceKey: () => searchAndReplaceKey,
54
61
  searchNext: () => searchNext,
55
62
  searchPrev: () => searchPrev,
56
- setSearchQuery: () => setSearchQuery
63
+ setSearchQuery: () => setSearchQuery,
64
+ toAIStreamFn: () => toAIStreamFn,
65
+ trimContextAfter: () => trimContextAfter,
66
+ trimContextBefore: () => trimContextBefore
57
67
  });
58
68
  module.exports = __toCommonJS(index_exports);
59
69
 
@@ -1953,15 +1963,281 @@ var SubdocumentProvider = class {
1953
1963
  this.activeQueue.length = 0;
1954
1964
  }
1955
1965
  };
1966
+
1967
+ // src/ai/prompts.ts
1968
+ var CONTEXT_CHAR_CAP = 1500;
1969
+ var ONLY_RESULT = "Return ONLY the resulting text \u2014 no preamble, no explanation, no commentary, no markdown fences.";
1970
+ var SYSTEM_PROMPTS = {
1971
+ rewrite: `You are a writing assistant. Rewrite the selected text to improve clarity, flow, and style while preserving its meaning. ${ONLY_RESULT}`,
1972
+ summarize: `You are a writing assistant. Summarize the selected text concisely, keeping every key point. ${ONLY_RESULT}`,
1973
+ grammar: `You are a writing assistant. Fix grammar, spelling, and punctuation in the selected text without changing its meaning or voice. ${ONLY_RESULT}`,
1974
+ tone: `You are a writing assistant. Adjust the tone of the selected text as instructed while preserving its content. ${ONLY_RESULT}`,
1975
+ translate: `You are a writing assistant. Translate the selected text as instructed, preserving structure and formatting. ${ONLY_RESULT}`,
1976
+ expand: `You are a writing assistant. Expand the selected text with relevant detail, staying on topic and matching the existing style. ${ONLY_RESULT}`,
1977
+ shorten: `You are a writing assistant. Shorten the selected text, keeping its essential meaning. ${ONLY_RESULT}`,
1978
+ generate: `You are a writing assistant embedded in a document editor. Write the text the user asks for so it fits the surrounding context. ${ONLY_RESULT}`,
1979
+ chat: "You are a helpful writing assistant embedded in a document editor. Answer the user\u2019s question. Use the provided document context when it is relevant; say so when it is not enough to answer."
1980
+ };
1981
+ function trimContextBefore(text) {
1982
+ return text.length > CONTEXT_CHAR_CAP ? `\u2026${text.slice(-CONTEXT_CHAR_CAP)}` : text;
1983
+ }
1984
+ function trimContextAfter(text) {
1985
+ return text.length > CONTEXT_CHAR_CAP ? `${text.slice(0, CONTEXT_CHAR_CAP)}\u2026` : text;
1986
+ }
1987
+ function quote(label, text) {
1988
+ return `${label}:
1989
+ """${text}"""`;
1990
+ }
1991
+ function contextSection(req) {
1992
+ const parts = [];
1993
+ if (req.context) {
1994
+ const before = trimContextBefore(req.context.before ?? "");
1995
+ const after = trimContextAfter(req.context.after ?? "");
1996
+ if (before) parts.push(quote("Text before", before));
1997
+ if (after) parts.push(quote("Text after", after));
1998
+ }
1999
+ return parts;
2000
+ }
2001
+ function buildAIPrompt(req) {
2002
+ let system;
2003
+ const userParts = [];
2004
+ switch (req.action) {
2005
+ case "rewrite":
2006
+ case "summarize":
2007
+ case "grammar":
2008
+ case "expand":
2009
+ case "shorten":
2010
+ system = SYSTEM_PROMPTS[req.action];
2011
+ userParts.push(quote("Selected text", req.selection ?? ""));
2012
+ break;
2013
+ case "tone":
2014
+ system = SYSTEM_PROMPTS.tone;
2015
+ userParts.push(quote("Selected text", req.selection ?? ""));
2016
+ userParts.push(`Target tone: ${req.prompt ?? "professional"}`);
2017
+ break;
2018
+ case "translate":
2019
+ system = SYSTEM_PROMPTS.translate;
2020
+ userParts.push(quote("Selected text", req.selection ?? ""));
2021
+ userParts.push(`Target language: ${req.prompt ?? "English"}`);
2022
+ break;
2023
+ case "generate":
2024
+ system = SYSTEM_PROMPTS.generate;
2025
+ userParts.push(`Instruction: ${req.prompt ?? ""}`);
2026
+ break;
2027
+ case "chat":
2028
+ system = SYSTEM_PROMPTS.chat;
2029
+ if (req.selection) userParts.push(quote("Selected text", req.selection));
2030
+ userParts.push(req.prompt ?? "");
2031
+ break;
2032
+ case "draft":
2033
+ throw new Error(
2034
+ "action 'draft' is not built via buildAIPrompt \u2014 use the aiDraft port (Phase 7E)"
2035
+ );
2036
+ default: {
2037
+ const exhaustive = req.action;
2038
+ throw new Error(`Unknown AI action: ${String(exhaustive)}`);
2039
+ }
2040
+ }
2041
+ userParts.push(...contextSection(req));
2042
+ return { system, prompt: userParts.filter(Boolean).join("\n\n") };
2043
+ }
2044
+
2045
+ // src/ai/adapter.ts
2046
+ function toAIStreamFn(provider) {
2047
+ return async function* aiStream(req, signal) {
2048
+ const { system, prompt } = buildAIPrompt(req);
2049
+ for await (const event of provider.complete({ system, prompt, signal })) {
2050
+ if (event.type === "delta") {
2051
+ yield event.text;
2052
+ } else if (event.type === "done") {
2053
+ return;
2054
+ } else {
2055
+ throw event.error;
2056
+ }
2057
+ }
2058
+ };
2059
+ }
2060
+
2061
+ // src/ai/keyStorage.ts
2062
+ function memoryKeyStorage(initial = null) {
2063
+ let value = initial;
2064
+ return {
2065
+ async get() {
2066
+ return value;
2067
+ },
2068
+ async set(next) {
2069
+ value = next;
2070
+ },
2071
+ async clear() {
2072
+ value = null;
2073
+ }
2074
+ };
2075
+ }
2076
+ var LOCAL_STORAGE_KEY = "docflow:ai-config";
2077
+ function localStorageKeyStorage(key2 = LOCAL_STORAGE_KEY, storage) {
2078
+ const store = () => {
2079
+ const s = storage ?? globalThis.localStorage;
2080
+ if (!s) throw new Error("localStorageKeyStorage: no Storage available (SSR?)");
2081
+ return s;
2082
+ };
2083
+ return {
2084
+ async get() {
2085
+ const raw = store().getItem(key2);
2086
+ if (!raw) return null;
2087
+ try {
2088
+ return JSON.parse(raw);
2089
+ } catch {
2090
+ return null;
2091
+ }
2092
+ },
2093
+ async set(value) {
2094
+ store().setItem(key2, JSON.stringify(value));
2095
+ },
2096
+ async clear() {
2097
+ store().removeItem(key2);
2098
+ }
2099
+ };
2100
+ }
2101
+ function httpKeyStorage(urls) {
2102
+ const doFetch = urls.fetchImpl ?? ((...args) => fetch(...args));
2103
+ return {
2104
+ async get() {
2105
+ const res = await doFetch(urls.getUrl, { credentials: "include" });
2106
+ if (res.status === 404) return null;
2107
+ if (!res.ok) throw new Error(`AI config read failed: ${res.status}`);
2108
+ return await res.json();
2109
+ },
2110
+ async set(value) {
2111
+ const res = await doFetch(urls.setUrl, {
2112
+ method: "POST",
2113
+ headers: { "Content-Type": "application/json" },
2114
+ credentials: "include",
2115
+ body: JSON.stringify(value)
2116
+ });
2117
+ if (!res.ok) throw new Error(`AI config write failed: ${res.status}`);
2118
+ },
2119
+ async clear() {
2120
+ const res = await doFetch(urls.deleteUrl, { method: "DELETE", credentials: "include" });
2121
+ if (!res.ok && res.status !== 404) throw new Error(`AI config delete failed: ${res.status}`);
2122
+ }
2123
+ };
2124
+ }
2125
+
2126
+ // src/ai/openaiCompatibleProvider.ts
2127
+ function authHeaders(auth) {
2128
+ switch (auth.type) {
2129
+ case "bearer":
2130
+ return { Authorization: `Bearer ${auth.apiKey}` };
2131
+ case "header":
2132
+ return { [auth.name]: auth.value };
2133
+ case "none":
2134
+ return {};
2135
+ }
2136
+ }
2137
+ function toError(err) {
2138
+ return err instanceof Error ? err : new Error(String(err));
2139
+ }
2140
+ function openaiCompatibleProvider(config) {
2141
+ const baseUrl = config.baseUrl.replace(/\/+$/, "");
2142
+ return {
2143
+ async *complete(req) {
2144
+ const controller = new AbortController();
2145
+ const onAbort = () => controller.abort();
2146
+ const callerSignal = req.signal;
2147
+ if (callerSignal) {
2148
+ if (callerSignal.aborted) controller.abort();
2149
+ else callerSignal.addEventListener("abort", onAbort, { once: true });
2150
+ }
2151
+ try {
2152
+ const system = [config.systemPrompt, req.system].filter(Boolean).join("\n\n");
2153
+ const messages = [];
2154
+ if (system) messages.push({ role: "system", content: system });
2155
+ messages.push({ role: "user", content: req.prompt });
2156
+ let res;
2157
+ try {
2158
+ res = await fetch(`${baseUrl}/chat/completions`, {
2159
+ method: "POST",
2160
+ headers: { "Content-Type": "application/json", ...authHeaders(config.auth) },
2161
+ body: JSON.stringify({ model: config.model, messages, stream: true }),
2162
+ signal: controller.signal
2163
+ });
2164
+ } catch (err) {
2165
+ if (controller.signal.aborted) return;
2166
+ yield { type: "error", error: toError(err) };
2167
+ return;
2168
+ }
2169
+ if (!res.ok) {
2170
+ const detail = await res.text().catch(() => "");
2171
+ yield {
2172
+ type: "error",
2173
+ error: new Error(
2174
+ `AI provider responded ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`
2175
+ )
2176
+ };
2177
+ return;
2178
+ }
2179
+ if (!res.body) {
2180
+ yield { type: "error", error: new Error("AI provider response has no body") };
2181
+ return;
2182
+ }
2183
+ const reader = res.body.getReader();
2184
+ const decoder = new TextDecoder();
2185
+ let buffer = "";
2186
+ let sawDone = false;
2187
+ try {
2188
+ for (; ; ) {
2189
+ const { done, value } = await reader.read();
2190
+ if (done) break;
2191
+ buffer += decoder.decode(value, { stream: true });
2192
+ let sep;
2193
+ while ((sep = buffer.indexOf("\n\n")) >= 0) {
2194
+ const block = buffer.slice(0, sep);
2195
+ buffer = buffer.slice(sep + 2);
2196
+ for (const line of block.split("\n")) {
2197
+ if (!line.startsWith("data:")) continue;
2198
+ const data = line.slice("data:".length).trim();
2199
+ if (data === "[DONE]") {
2200
+ sawDone = true;
2201
+ yield { type: "done" };
2202
+ return;
2203
+ }
2204
+ try {
2205
+ const json = JSON.parse(data);
2206
+ const text = json.choices?.[0]?.delta?.content;
2207
+ if (text) yield { type: "delta", text };
2208
+ } catch {
2209
+ }
2210
+ }
2211
+ }
2212
+ }
2213
+ } catch (err) {
2214
+ if (!controller.signal.aborted) {
2215
+ yield { type: "error", error: toError(err) };
2216
+ return;
2217
+ }
2218
+ } finally {
2219
+ reader.releaseLock();
2220
+ }
2221
+ if (!sawDone && !controller.signal.aborted) yield { type: "done" };
2222
+ } finally {
2223
+ callerSignal?.removeEventListener("abort", onAbort);
2224
+ controller.abort();
2225
+ }
2226
+ }
2227
+ };
2228
+ }
1956
2229
  // Annotate the CommonJS export names for ESM import in node:
1957
2230
  0 && (module.exports = {
1958
2231
  BlockAttributesExtension,
2232
+ CONTEXT_CHAR_CAP,
1959
2233
  EditorContextExtension,
1960
2234
  FontSizeExtension,
2235
+ LOCAL_STORAGE_KEY,
1961
2236
  PAGE_SIZES,
1962
2237
  PaginationPlus,
1963
2238
  SearchAndReplaceExtension,
1964
2239
  SubdocumentProvider,
2240
+ buildAIPrompt,
1965
2241
  clearSearch,
1966
2242
  collaborationExtensions,
1967
2243
  collectExtensions,
@@ -1971,6 +2247,10 @@ var SubdocumentProvider = class {
1971
2247
  definePlugin,
1972
2248
  findMatches,
1973
2249
  getSearchState,
2250
+ httpKeyStorage,
2251
+ localStorageKeyStorage,
2252
+ memoryKeyStorage,
2253
+ openaiCompatibleProvider,
1974
2254
  replaceAll,
1975
2255
  replaceCurrent,
1976
2256
  resolveAction,
@@ -1978,5 +2258,8 @@ var SubdocumentProvider = class {
1978
2258
  searchAndReplaceKey,
1979
2259
  searchNext,
1980
2260
  searchPrev,
1981
- setSearchQuery
2261
+ setSearchQuery,
2262
+ toAIStreamFn,
2263
+ trimContextAfter,
2264
+ trimContextBefore
1982
2265
  });
package/dist/index.d.cts CHANGED
@@ -203,12 +203,51 @@ interface CitationPort {
203
203
  }
204
204
 
205
205
  /**
206
- * Shared AI action types (Phase 7).
206
+ * The library's only knowledge of LLMs (pluggable AI provider — issue #119).
207
+ *
208
+ * `AIProvider` is a thin transport: one prompt in, a stream of events out.
209
+ * The library imports only this type — never an implementation. Hosts plug in
210
+ * `openaiCompatibleProvider` (the default we ship) or their own class wrapping
211
+ * an agent SDK, a proxy, or an in-process model. Agent orchestration, tool
212
+ * calling, RAG, and multi-turn memory live behind the host's endpoint, not in
213
+ * the library. See docs/plans/PLUGGABLE_AI_PROVIDER.md §4.1.
214
+ */
215
+ type StreamEvent = {
216
+ type: 'delta';
217
+ text: string;
218
+ } | {
219
+ type: 'done';
220
+ stopReason?: string;
221
+ } | {
222
+ type: 'error';
223
+ error: Error;
224
+ };
225
+ interface AICompleteRequest {
226
+ system?: string;
227
+ prompt: string;
228
+ signal?: AbortSignal;
229
+ }
230
+ interface AIProvider {
231
+ complete(req: AICompleteRequest): AsyncIterable<StreamEvent>;
232
+ }
233
+
234
+ /**
235
+ * Shared AI action types (Phase 7; pluggable AI provider — issue #119).
207
236
  *
208
237
  * The library (aiPlugin) is provider-agnostic: it never names a URL or holds a
209
- * key. The app implements `AIStreamFn` (apps/web/src/ai/aiStream.ts) and injects
210
- * it; the server owns provider selection via env config.
238
+ * key. The host injects `aiStream` / `aiDraft` via `EditorOptions` — either a
239
+ * hand-written function or `toAIStreamFn(openaiCompatibleProvider({...}))`
240
+ * (see ./provider.ts, ./openaiCompatibleProvider.ts).
241
+ */
242
+
243
+ /**
244
+ * Factory shape for hosts that hand the library a provider object rather than
245
+ * a plain function (plan §4.2). `toAIStreamFn` adapts an `AIProvider` to the
246
+ * `AIStreamFn` port, so both shapes are accepted at the boundary.
211
247
  */
248
+ type AIProviderFactory = (config: {
249
+ signal?: AbortSignal;
250
+ }) => AIProvider;
212
251
  type AIAction = 'rewrite' | 'summarize' | 'grammar' | 'tone' | 'translate' | 'expand' | 'shorten' | 'generate' | 'chat' | 'draft';
213
252
  interface AIActionRequest {
214
253
  action: AIAction;
@@ -455,4 +494,109 @@ declare class SubdocumentProvider {
455
494
  destroy(): void;
456
495
  }
457
496
 
458
- export { type AIAction, type AIActionRequest, type AIDraftCitation, type AIDraftEvent, type AIDraftFn, type AIStreamFn, type AwarenessState, BlockAttributesExtension, type CitationPort, type CollaborationOptions, type CollaborationSetup, type CslDate, type CslItemData, type CslName, type DocsEditor, type DocsEditorPlugin, EditorContextExtension, type EditorContextOptions, type EditorOptions, FontSizeExtension, type ImageUploadHandler, type ImageUploadResult, SearchAndReplaceExtension, type SearchMatch, type SearchState, type SlashCommand, type SubdocState, SubdocumentProvider, type SubdocumentProviderOptions, type ToolbarItem, clearSearch, collaborationExtensions, collectExtensions, createActionMap, createCollaboration, createEditor, definePlugin, findMatches, getSearchState, replaceAll, replaceCurrent, resolveAction, sanitizePastedHTML, searchAndReplaceKey, searchNext, searchPrev, setSearchQuery };
497
+ declare function toAIStreamFn(provider: AIProvider): AIStreamFn;
498
+
499
+ /**
500
+ * Per-action prompt assembly (pluggable AI provider — issue #119).
501
+ *
502
+ * Ported from the Phase 7 server proxy (`apps/server/src/ai/context.ts`) so
503
+ * the browser can call the LLM directly: the selection + BOUNDED surrounding
504
+ * text is sent — never the whole document (cost + privacy). Untrusted document
505
+ * text stays in the user prompt; instructions live in the system prompt
506
+ * (prompt-injection hygiene).
507
+ */
508
+
509
+ /** Max characters of surrounding context sent on each side of the selection. */
510
+ declare const CONTEXT_CHAR_CAP = 1500;
511
+ /** Last `CONTEXT_CHAR_CAP` chars of the preceding text (nearest context wins). */
512
+ declare function trimContextBefore(text: string): string;
513
+ /** First `CONTEXT_CHAR_CAP` chars of the following text. */
514
+ declare function trimContextAfter(text: string): string;
515
+ /**
516
+ * Assemble the per-action system prompt + user prompt from a bounded request.
517
+ * `draft` is rejected: RAG-cited drafting goes through the `aiDraft` port,
518
+ * which owns its grounded-prompt assembly (Phase 7E).
519
+ */
520
+ declare function buildAIPrompt(req: AIActionRequest): {
521
+ system: string;
522
+ prompt: string;
523
+ };
524
+
525
+ /**
526
+ * Where the AI provider's config (base URL + credentials + model) lives —
527
+ * the host decides (pluggable AI provider — issue #119, plan §4.4).
528
+ *
529
+ * The URL and the key are a pair and travel together. The library ships three
530
+ * reference implementations; hosts may write their own (e.g. keyed per-tenant
531
+ * in their own backend). No URL is hardcoded anywhere — `httpKeyStorage`
532
+ * receives its endpoints from the host, keeping the library backend-agnostic
533
+ * (docs/LIBRARY_CONTRACT.md rule 3).
534
+ */
535
+ type Auth = {
536
+ type: 'bearer';
537
+ apiKey: string;
538
+ } | {
539
+ type: 'header';
540
+ name: string;
541
+ value: string;
542
+ } | {
543
+ type: 'none';
544
+ };
545
+ interface AIConfig {
546
+ baseUrl: string;
547
+ auth: Auth;
548
+ model: string;
549
+ }
550
+ interface KeyStorage {
551
+ get(): Promise<AIConfig | null>;
552
+ set(value: AIConfig): Promise<void>;
553
+ clear(): Promise<void>;
554
+ }
555
+ /** In-memory storage — for tests and SSR (no persistence). */
556
+ declare function memoryKeyStorage(initial?: AIConfig | null): KeyStorage;
557
+ declare const LOCAL_STORAGE_KEY = "docflow:ai-config";
558
+ /**
559
+ * Browser `localStorage` persistence — for embedded apps that want the config
560
+ * kept client-side. Note: this stores AI *credentials*, not document data, so
561
+ * it is the host's deliberate choice, not a library persistence leak.
562
+ */
563
+ declare function localStorageKeyStorage(key?: string, storage?: Storage): KeyStorage;
564
+ interface HttpKeyStorageUrls {
565
+ /** GET → 200 with the AIConfig JSON body, or 404 when unset. */
566
+ getUrl: string;
567
+ /** POST with the AIConfig JSON body. */
568
+ setUrl: string;
569
+ /** DELETE to clear. */
570
+ deleteUrl: string;
571
+ /** Optional fetch override (tests, custom credentials mode). Defaults to global fetch. */
572
+ fetchImpl?: typeof fetch;
573
+ }
574
+ /**
575
+ * Server-backed storage — for our SaaS web app (`/api/ai/config`) and any
576
+ * embedded app that wants credentials kept on its own backend. Endpoints are
577
+ * host-supplied; the library never names a path.
578
+ */
579
+ declare function httpKeyStorage(urls: HttpKeyStorageUrls): KeyStorage;
580
+
581
+ /**
582
+ * Default `AIProvider` implementation (pluggable AI provider — issue #119,
583
+ * plan §4.3): OpenAI-shaped `POST {baseUrl}/chat/completions` with SSE
584
+ * streaming. Covers OpenAI, Azure-style gateways, Ollama, vLLM, and most
585
+ * OpenAI-compatible proxies.
586
+ *
587
+ * Cancellation-safe: closing the async iterator early (e.g. the user accepts
588
+ * or cancels mid-stream) aborts the in-flight `fetch`; an aborted `req.signal`
589
+ * ends the stream silently rather than yielding an error event.
590
+ */
591
+
592
+ interface OpenAICompatibleConfig {
593
+ /** Base URL of the OpenAI-compatible endpoint, e.g. `https://api.openai.com/v1`. */
594
+ baseUrl: string;
595
+ auth: Auth;
596
+ model: string;
597
+ /** Prepended to every call's system prompt so hosts don't repeat it. */
598
+ systemPrompt?: string;
599
+ }
600
+ declare function openaiCompatibleProvider(config: OpenAICompatibleConfig): AIProvider;
601
+
602
+ export { type AIAction, type AIActionRequest, type AICompleteRequest, type AIConfig, type AIDraftCitation, type AIDraftEvent, type AIDraftFn, type AIProvider, type AIProviderFactory, type AIStreamFn, type Auth, type AwarenessState, BlockAttributesExtension, CONTEXT_CHAR_CAP, type CitationPort, type CollaborationOptions, type CollaborationSetup, type CslDate, type CslItemData, type CslName, type DocsEditor, type DocsEditorPlugin, EditorContextExtension, type EditorContextOptions, type EditorOptions, FontSizeExtension, type HttpKeyStorageUrls, type ImageUploadHandler, type ImageUploadResult, type KeyStorage, LOCAL_STORAGE_KEY, type OpenAICompatibleConfig, SearchAndReplaceExtension, type SearchMatch, type SearchState, type SlashCommand, type StreamEvent, type SubdocState, SubdocumentProvider, type SubdocumentProviderOptions, type ToolbarItem, buildAIPrompt, clearSearch, collaborationExtensions, collectExtensions, createActionMap, createCollaboration, createEditor, definePlugin, findMatches, getSearchState, httpKeyStorage, localStorageKeyStorage, memoryKeyStorage, openaiCompatibleProvider, replaceAll, replaceCurrent, resolveAction, sanitizePastedHTML, searchAndReplaceKey, searchNext, searchPrev, setSearchQuery, toAIStreamFn, trimContextAfter, trimContextBefore };
package/dist/index.d.ts CHANGED
@@ -203,12 +203,51 @@ interface CitationPort {
203
203
  }
204
204
 
205
205
  /**
206
- * Shared AI action types (Phase 7).
206
+ * The library's only knowledge of LLMs (pluggable AI provider — issue #119).
207
+ *
208
+ * `AIProvider` is a thin transport: one prompt in, a stream of events out.
209
+ * The library imports only this type — never an implementation. Hosts plug in
210
+ * `openaiCompatibleProvider` (the default we ship) or their own class wrapping
211
+ * an agent SDK, a proxy, or an in-process model. Agent orchestration, tool
212
+ * calling, RAG, and multi-turn memory live behind the host's endpoint, not in
213
+ * the library. See docs/plans/PLUGGABLE_AI_PROVIDER.md §4.1.
214
+ */
215
+ type StreamEvent = {
216
+ type: 'delta';
217
+ text: string;
218
+ } | {
219
+ type: 'done';
220
+ stopReason?: string;
221
+ } | {
222
+ type: 'error';
223
+ error: Error;
224
+ };
225
+ interface AICompleteRequest {
226
+ system?: string;
227
+ prompt: string;
228
+ signal?: AbortSignal;
229
+ }
230
+ interface AIProvider {
231
+ complete(req: AICompleteRequest): AsyncIterable<StreamEvent>;
232
+ }
233
+
234
+ /**
235
+ * Shared AI action types (Phase 7; pluggable AI provider — issue #119).
207
236
  *
208
237
  * The library (aiPlugin) is provider-agnostic: it never names a URL or holds a
209
- * key. The app implements `AIStreamFn` (apps/web/src/ai/aiStream.ts) and injects
210
- * it; the server owns provider selection via env config.
238
+ * key. The host injects `aiStream` / `aiDraft` via `EditorOptions` — either a
239
+ * hand-written function or `toAIStreamFn(openaiCompatibleProvider({...}))`
240
+ * (see ./provider.ts, ./openaiCompatibleProvider.ts).
241
+ */
242
+
243
+ /**
244
+ * Factory shape for hosts that hand the library a provider object rather than
245
+ * a plain function (plan §4.2). `toAIStreamFn` adapts an `AIProvider` to the
246
+ * `AIStreamFn` port, so both shapes are accepted at the boundary.
211
247
  */
248
+ type AIProviderFactory = (config: {
249
+ signal?: AbortSignal;
250
+ }) => AIProvider;
212
251
  type AIAction = 'rewrite' | 'summarize' | 'grammar' | 'tone' | 'translate' | 'expand' | 'shorten' | 'generate' | 'chat' | 'draft';
213
252
  interface AIActionRequest {
214
253
  action: AIAction;
@@ -455,4 +494,109 @@ declare class SubdocumentProvider {
455
494
  destroy(): void;
456
495
  }
457
496
 
458
- export { type AIAction, type AIActionRequest, type AIDraftCitation, type AIDraftEvent, type AIDraftFn, type AIStreamFn, type AwarenessState, BlockAttributesExtension, type CitationPort, type CollaborationOptions, type CollaborationSetup, type CslDate, type CslItemData, type CslName, type DocsEditor, type DocsEditorPlugin, EditorContextExtension, type EditorContextOptions, type EditorOptions, FontSizeExtension, type ImageUploadHandler, type ImageUploadResult, SearchAndReplaceExtension, type SearchMatch, type SearchState, type SlashCommand, type SubdocState, SubdocumentProvider, type SubdocumentProviderOptions, type ToolbarItem, clearSearch, collaborationExtensions, collectExtensions, createActionMap, createCollaboration, createEditor, definePlugin, findMatches, getSearchState, replaceAll, replaceCurrent, resolveAction, sanitizePastedHTML, searchAndReplaceKey, searchNext, searchPrev, setSearchQuery };
497
+ declare function toAIStreamFn(provider: AIProvider): AIStreamFn;
498
+
499
+ /**
500
+ * Per-action prompt assembly (pluggable AI provider — issue #119).
501
+ *
502
+ * Ported from the Phase 7 server proxy (`apps/server/src/ai/context.ts`) so
503
+ * the browser can call the LLM directly: the selection + BOUNDED surrounding
504
+ * text is sent — never the whole document (cost + privacy). Untrusted document
505
+ * text stays in the user prompt; instructions live in the system prompt
506
+ * (prompt-injection hygiene).
507
+ */
508
+
509
+ /** Max characters of surrounding context sent on each side of the selection. */
510
+ declare const CONTEXT_CHAR_CAP = 1500;
511
+ /** Last `CONTEXT_CHAR_CAP` chars of the preceding text (nearest context wins). */
512
+ declare function trimContextBefore(text: string): string;
513
+ /** First `CONTEXT_CHAR_CAP` chars of the following text. */
514
+ declare function trimContextAfter(text: string): string;
515
+ /**
516
+ * Assemble the per-action system prompt + user prompt from a bounded request.
517
+ * `draft` is rejected: RAG-cited drafting goes through the `aiDraft` port,
518
+ * which owns its grounded-prompt assembly (Phase 7E).
519
+ */
520
+ declare function buildAIPrompt(req: AIActionRequest): {
521
+ system: string;
522
+ prompt: string;
523
+ };
524
+
525
+ /**
526
+ * Where the AI provider's config (base URL + credentials + model) lives —
527
+ * the host decides (pluggable AI provider — issue #119, plan §4.4).
528
+ *
529
+ * The URL and the key are a pair and travel together. The library ships three
530
+ * reference implementations; hosts may write their own (e.g. keyed per-tenant
531
+ * in their own backend). No URL is hardcoded anywhere — `httpKeyStorage`
532
+ * receives its endpoints from the host, keeping the library backend-agnostic
533
+ * (docs/LIBRARY_CONTRACT.md rule 3).
534
+ */
535
+ type Auth = {
536
+ type: 'bearer';
537
+ apiKey: string;
538
+ } | {
539
+ type: 'header';
540
+ name: string;
541
+ value: string;
542
+ } | {
543
+ type: 'none';
544
+ };
545
+ interface AIConfig {
546
+ baseUrl: string;
547
+ auth: Auth;
548
+ model: string;
549
+ }
550
+ interface KeyStorage {
551
+ get(): Promise<AIConfig | null>;
552
+ set(value: AIConfig): Promise<void>;
553
+ clear(): Promise<void>;
554
+ }
555
+ /** In-memory storage — for tests and SSR (no persistence). */
556
+ declare function memoryKeyStorage(initial?: AIConfig | null): KeyStorage;
557
+ declare const LOCAL_STORAGE_KEY = "docflow:ai-config";
558
+ /**
559
+ * Browser `localStorage` persistence — for embedded apps that want the config
560
+ * kept client-side. Note: this stores AI *credentials*, not document data, so
561
+ * it is the host's deliberate choice, not a library persistence leak.
562
+ */
563
+ declare function localStorageKeyStorage(key?: string, storage?: Storage): KeyStorage;
564
+ interface HttpKeyStorageUrls {
565
+ /** GET → 200 with the AIConfig JSON body, or 404 when unset. */
566
+ getUrl: string;
567
+ /** POST with the AIConfig JSON body. */
568
+ setUrl: string;
569
+ /** DELETE to clear. */
570
+ deleteUrl: string;
571
+ /** Optional fetch override (tests, custom credentials mode). Defaults to global fetch. */
572
+ fetchImpl?: typeof fetch;
573
+ }
574
+ /**
575
+ * Server-backed storage — for our SaaS web app (`/api/ai/config`) and any
576
+ * embedded app that wants credentials kept on its own backend. Endpoints are
577
+ * host-supplied; the library never names a path.
578
+ */
579
+ declare function httpKeyStorage(urls: HttpKeyStorageUrls): KeyStorage;
580
+
581
+ /**
582
+ * Default `AIProvider` implementation (pluggable AI provider — issue #119,
583
+ * plan §4.3): OpenAI-shaped `POST {baseUrl}/chat/completions` with SSE
584
+ * streaming. Covers OpenAI, Azure-style gateways, Ollama, vLLM, and most
585
+ * OpenAI-compatible proxies.
586
+ *
587
+ * Cancellation-safe: closing the async iterator early (e.g. the user accepts
588
+ * or cancels mid-stream) aborts the in-flight `fetch`; an aborted `req.signal`
589
+ * ends the stream silently rather than yielding an error event.
590
+ */
591
+
592
+ interface OpenAICompatibleConfig {
593
+ /** Base URL of the OpenAI-compatible endpoint, e.g. `https://api.openai.com/v1`. */
594
+ baseUrl: string;
595
+ auth: Auth;
596
+ model: string;
597
+ /** Prepended to every call's system prompt so hosts don't repeat it. */
598
+ systemPrompt?: string;
599
+ }
600
+ declare function openaiCompatibleProvider(config: OpenAICompatibleConfig): AIProvider;
601
+
602
+ export { type AIAction, type AIActionRequest, type AICompleteRequest, type AIConfig, type AIDraftCitation, type AIDraftEvent, type AIDraftFn, type AIProvider, type AIProviderFactory, type AIStreamFn, type Auth, type AwarenessState, BlockAttributesExtension, CONTEXT_CHAR_CAP, type CitationPort, type CollaborationOptions, type CollaborationSetup, type CslDate, type CslItemData, type CslName, type DocsEditor, type DocsEditorPlugin, EditorContextExtension, type EditorContextOptions, type EditorOptions, FontSizeExtension, type HttpKeyStorageUrls, type ImageUploadHandler, type ImageUploadResult, type KeyStorage, LOCAL_STORAGE_KEY, type OpenAICompatibleConfig, SearchAndReplaceExtension, type SearchMatch, type SearchState, type SlashCommand, type StreamEvent, type SubdocState, SubdocumentProvider, type SubdocumentProviderOptions, type ToolbarItem, buildAIPrompt, clearSearch, collaborationExtensions, collectExtensions, createActionMap, createCollaboration, createEditor, definePlugin, findMatches, getSearchState, httpKeyStorage, localStorageKeyStorage, memoryKeyStorage, openaiCompatibleProvider, replaceAll, replaceCurrent, resolveAction, sanitizePastedHTML, searchAndReplaceKey, searchNext, searchPrev, setSearchQuery, toAIStreamFn, trimContextAfter, trimContextBefore };
package/dist/index.js CHANGED
@@ -1894,14 +1894,280 @@ var SubdocumentProvider = class {
1894
1894
  this.activeQueue.length = 0;
1895
1895
  }
1896
1896
  };
1897
+
1898
+ // src/ai/prompts.ts
1899
+ var CONTEXT_CHAR_CAP = 1500;
1900
+ var ONLY_RESULT = "Return ONLY the resulting text \u2014 no preamble, no explanation, no commentary, no markdown fences.";
1901
+ var SYSTEM_PROMPTS = {
1902
+ rewrite: `You are a writing assistant. Rewrite the selected text to improve clarity, flow, and style while preserving its meaning. ${ONLY_RESULT}`,
1903
+ summarize: `You are a writing assistant. Summarize the selected text concisely, keeping every key point. ${ONLY_RESULT}`,
1904
+ grammar: `You are a writing assistant. Fix grammar, spelling, and punctuation in the selected text without changing its meaning or voice. ${ONLY_RESULT}`,
1905
+ tone: `You are a writing assistant. Adjust the tone of the selected text as instructed while preserving its content. ${ONLY_RESULT}`,
1906
+ translate: `You are a writing assistant. Translate the selected text as instructed, preserving structure and formatting. ${ONLY_RESULT}`,
1907
+ expand: `You are a writing assistant. Expand the selected text with relevant detail, staying on topic and matching the existing style. ${ONLY_RESULT}`,
1908
+ shorten: `You are a writing assistant. Shorten the selected text, keeping its essential meaning. ${ONLY_RESULT}`,
1909
+ generate: `You are a writing assistant embedded in a document editor. Write the text the user asks for so it fits the surrounding context. ${ONLY_RESULT}`,
1910
+ chat: "You are a helpful writing assistant embedded in a document editor. Answer the user\u2019s question. Use the provided document context when it is relevant; say so when it is not enough to answer."
1911
+ };
1912
+ function trimContextBefore(text) {
1913
+ return text.length > CONTEXT_CHAR_CAP ? `\u2026${text.slice(-CONTEXT_CHAR_CAP)}` : text;
1914
+ }
1915
+ function trimContextAfter(text) {
1916
+ return text.length > CONTEXT_CHAR_CAP ? `${text.slice(0, CONTEXT_CHAR_CAP)}\u2026` : text;
1917
+ }
1918
+ function quote(label, text) {
1919
+ return `${label}:
1920
+ """${text}"""`;
1921
+ }
1922
+ function contextSection(req) {
1923
+ const parts = [];
1924
+ if (req.context) {
1925
+ const before = trimContextBefore(req.context.before ?? "");
1926
+ const after = trimContextAfter(req.context.after ?? "");
1927
+ if (before) parts.push(quote("Text before", before));
1928
+ if (after) parts.push(quote("Text after", after));
1929
+ }
1930
+ return parts;
1931
+ }
1932
+ function buildAIPrompt(req) {
1933
+ let system;
1934
+ const userParts = [];
1935
+ switch (req.action) {
1936
+ case "rewrite":
1937
+ case "summarize":
1938
+ case "grammar":
1939
+ case "expand":
1940
+ case "shorten":
1941
+ system = SYSTEM_PROMPTS[req.action];
1942
+ userParts.push(quote("Selected text", req.selection ?? ""));
1943
+ break;
1944
+ case "tone":
1945
+ system = SYSTEM_PROMPTS.tone;
1946
+ userParts.push(quote("Selected text", req.selection ?? ""));
1947
+ userParts.push(`Target tone: ${req.prompt ?? "professional"}`);
1948
+ break;
1949
+ case "translate":
1950
+ system = SYSTEM_PROMPTS.translate;
1951
+ userParts.push(quote("Selected text", req.selection ?? ""));
1952
+ userParts.push(`Target language: ${req.prompt ?? "English"}`);
1953
+ break;
1954
+ case "generate":
1955
+ system = SYSTEM_PROMPTS.generate;
1956
+ userParts.push(`Instruction: ${req.prompt ?? ""}`);
1957
+ break;
1958
+ case "chat":
1959
+ system = SYSTEM_PROMPTS.chat;
1960
+ if (req.selection) userParts.push(quote("Selected text", req.selection));
1961
+ userParts.push(req.prompt ?? "");
1962
+ break;
1963
+ case "draft":
1964
+ throw new Error(
1965
+ "action 'draft' is not built via buildAIPrompt \u2014 use the aiDraft port (Phase 7E)"
1966
+ );
1967
+ default: {
1968
+ const exhaustive = req.action;
1969
+ throw new Error(`Unknown AI action: ${String(exhaustive)}`);
1970
+ }
1971
+ }
1972
+ userParts.push(...contextSection(req));
1973
+ return { system, prompt: userParts.filter(Boolean).join("\n\n") };
1974
+ }
1975
+
1976
+ // src/ai/adapter.ts
1977
+ function toAIStreamFn(provider) {
1978
+ return async function* aiStream(req, signal) {
1979
+ const { system, prompt } = buildAIPrompt(req);
1980
+ for await (const event of provider.complete({ system, prompt, signal })) {
1981
+ if (event.type === "delta") {
1982
+ yield event.text;
1983
+ } else if (event.type === "done") {
1984
+ return;
1985
+ } else {
1986
+ throw event.error;
1987
+ }
1988
+ }
1989
+ };
1990
+ }
1991
+
1992
+ // src/ai/keyStorage.ts
1993
+ function memoryKeyStorage(initial = null) {
1994
+ let value = initial;
1995
+ return {
1996
+ async get() {
1997
+ return value;
1998
+ },
1999
+ async set(next) {
2000
+ value = next;
2001
+ },
2002
+ async clear() {
2003
+ value = null;
2004
+ }
2005
+ };
2006
+ }
2007
+ var LOCAL_STORAGE_KEY = "docflow:ai-config";
2008
+ function localStorageKeyStorage(key2 = LOCAL_STORAGE_KEY, storage) {
2009
+ const store = () => {
2010
+ const s = storage ?? globalThis.localStorage;
2011
+ if (!s) throw new Error("localStorageKeyStorage: no Storage available (SSR?)");
2012
+ return s;
2013
+ };
2014
+ return {
2015
+ async get() {
2016
+ const raw = store().getItem(key2);
2017
+ if (!raw) return null;
2018
+ try {
2019
+ return JSON.parse(raw);
2020
+ } catch {
2021
+ return null;
2022
+ }
2023
+ },
2024
+ async set(value) {
2025
+ store().setItem(key2, JSON.stringify(value));
2026
+ },
2027
+ async clear() {
2028
+ store().removeItem(key2);
2029
+ }
2030
+ };
2031
+ }
2032
+ function httpKeyStorage(urls) {
2033
+ const doFetch = urls.fetchImpl ?? ((...args) => fetch(...args));
2034
+ return {
2035
+ async get() {
2036
+ const res = await doFetch(urls.getUrl, { credentials: "include" });
2037
+ if (res.status === 404) return null;
2038
+ if (!res.ok) throw new Error(`AI config read failed: ${res.status}`);
2039
+ return await res.json();
2040
+ },
2041
+ async set(value) {
2042
+ const res = await doFetch(urls.setUrl, {
2043
+ method: "POST",
2044
+ headers: { "Content-Type": "application/json" },
2045
+ credentials: "include",
2046
+ body: JSON.stringify(value)
2047
+ });
2048
+ if (!res.ok) throw new Error(`AI config write failed: ${res.status}`);
2049
+ },
2050
+ async clear() {
2051
+ const res = await doFetch(urls.deleteUrl, { method: "DELETE", credentials: "include" });
2052
+ if (!res.ok && res.status !== 404) throw new Error(`AI config delete failed: ${res.status}`);
2053
+ }
2054
+ };
2055
+ }
2056
+
2057
+ // src/ai/openaiCompatibleProvider.ts
2058
+ function authHeaders(auth) {
2059
+ switch (auth.type) {
2060
+ case "bearer":
2061
+ return { Authorization: `Bearer ${auth.apiKey}` };
2062
+ case "header":
2063
+ return { [auth.name]: auth.value };
2064
+ case "none":
2065
+ return {};
2066
+ }
2067
+ }
2068
+ function toError(err) {
2069
+ return err instanceof Error ? err : new Error(String(err));
2070
+ }
2071
+ function openaiCompatibleProvider(config) {
2072
+ const baseUrl = config.baseUrl.replace(/\/+$/, "");
2073
+ return {
2074
+ async *complete(req) {
2075
+ const controller = new AbortController();
2076
+ const onAbort = () => controller.abort();
2077
+ const callerSignal = req.signal;
2078
+ if (callerSignal) {
2079
+ if (callerSignal.aborted) controller.abort();
2080
+ else callerSignal.addEventListener("abort", onAbort, { once: true });
2081
+ }
2082
+ try {
2083
+ const system = [config.systemPrompt, req.system].filter(Boolean).join("\n\n");
2084
+ const messages = [];
2085
+ if (system) messages.push({ role: "system", content: system });
2086
+ messages.push({ role: "user", content: req.prompt });
2087
+ let res;
2088
+ try {
2089
+ res = await fetch(`${baseUrl}/chat/completions`, {
2090
+ method: "POST",
2091
+ headers: { "Content-Type": "application/json", ...authHeaders(config.auth) },
2092
+ body: JSON.stringify({ model: config.model, messages, stream: true }),
2093
+ signal: controller.signal
2094
+ });
2095
+ } catch (err) {
2096
+ if (controller.signal.aborted) return;
2097
+ yield { type: "error", error: toError(err) };
2098
+ return;
2099
+ }
2100
+ if (!res.ok) {
2101
+ const detail = await res.text().catch(() => "");
2102
+ yield {
2103
+ type: "error",
2104
+ error: new Error(
2105
+ `AI provider responded ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`
2106
+ )
2107
+ };
2108
+ return;
2109
+ }
2110
+ if (!res.body) {
2111
+ yield { type: "error", error: new Error("AI provider response has no body") };
2112
+ return;
2113
+ }
2114
+ const reader = res.body.getReader();
2115
+ const decoder = new TextDecoder();
2116
+ let buffer = "";
2117
+ let sawDone = false;
2118
+ try {
2119
+ for (; ; ) {
2120
+ const { done, value } = await reader.read();
2121
+ if (done) break;
2122
+ buffer += decoder.decode(value, { stream: true });
2123
+ let sep;
2124
+ while ((sep = buffer.indexOf("\n\n")) >= 0) {
2125
+ const block = buffer.slice(0, sep);
2126
+ buffer = buffer.slice(sep + 2);
2127
+ for (const line of block.split("\n")) {
2128
+ if (!line.startsWith("data:")) continue;
2129
+ const data = line.slice("data:".length).trim();
2130
+ if (data === "[DONE]") {
2131
+ sawDone = true;
2132
+ yield { type: "done" };
2133
+ return;
2134
+ }
2135
+ try {
2136
+ const json = JSON.parse(data);
2137
+ const text = json.choices?.[0]?.delta?.content;
2138
+ if (text) yield { type: "delta", text };
2139
+ } catch {
2140
+ }
2141
+ }
2142
+ }
2143
+ }
2144
+ } catch (err) {
2145
+ if (!controller.signal.aborted) {
2146
+ yield { type: "error", error: toError(err) };
2147
+ return;
2148
+ }
2149
+ } finally {
2150
+ reader.releaseLock();
2151
+ }
2152
+ if (!sawDone && !controller.signal.aborted) yield { type: "done" };
2153
+ } finally {
2154
+ callerSignal?.removeEventListener("abort", onAbort);
2155
+ controller.abort();
2156
+ }
2157
+ }
2158
+ };
2159
+ }
1897
2160
  export {
1898
2161
  BlockAttributesExtension,
2162
+ CONTEXT_CHAR_CAP,
1899
2163
  EditorContextExtension,
1900
2164
  FontSizeExtension,
2165
+ LOCAL_STORAGE_KEY,
1901
2166
  PAGE_SIZES,
1902
2167
  PaginationPlus,
1903
2168
  SearchAndReplaceExtension,
1904
2169
  SubdocumentProvider,
2170
+ buildAIPrompt,
1905
2171
  clearSearch,
1906
2172
  collaborationExtensions,
1907
2173
  collectExtensions,
@@ -1911,6 +2177,10 @@ export {
1911
2177
  definePlugin,
1912
2178
  findMatches,
1913
2179
  getSearchState,
2180
+ httpKeyStorage,
2181
+ localStorageKeyStorage,
2182
+ memoryKeyStorage,
2183
+ openaiCompatibleProvider,
1914
2184
  replaceAll,
1915
2185
  replaceCurrent,
1916
2186
  resolveAction,
@@ -1918,5 +2188,8 @@ export {
1918
2188
  searchAndReplaceKey,
1919
2189
  searchNext,
1920
2190
  searchPrev,
1921
- setSearchQuery
2191
+ setSearchQuery,
2192
+ toAIStreamFn,
2193
+ trimContextAfter,
2194
+ trimContextBefore
1922
2195
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kedataindo/docflow-core",
3
3
  "license": "UNLICENSED",
4
- "version": "0.0.4",
4
+ "version": "0.0.6",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",