graphlit-client 1.0.20250531004 → 1.0.20250610001
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/CHANGELOG.md +64 -0
- package/README.md +441 -53
- package/dist/client.d.ts +128 -3
- package/dist/client.js +1616 -1567
- package/dist/generated/graphql-documents.d.ts +1 -0
- package/dist/generated/graphql-documents.js +376 -312
- package/dist/generated/graphql-types.d.ts +211 -23
- package/dist/generated/graphql-types.js +299 -246
- package/dist/model-mapping.d.ts +18 -0
- package/dist/model-mapping.js +95 -0
- package/dist/stream-helpers.d.ts +106 -0
- package/dist/stream-helpers.js +237 -0
- package/dist/streaming/chunk-buffer.d.ts +25 -0
- package/dist/streaming/chunk-buffer.js +170 -0
- package/dist/streaming/llm-formatters.d.ts +64 -0
- package/dist/streaming/llm-formatters.js +187 -0
- package/dist/streaming/providers.d.ts +18 -0
- package/dist/streaming/providers.js +353 -0
- package/dist/streaming/ui-event-adapter.d.ts +52 -0
- package/dist/streaming/ui-event-adapter.js +288 -0
- package/dist/types/agent.d.ts +39 -0
- package/dist/types/agent.js +1 -0
- package/dist/types/streaming.d.ts +58 -0
- package/dist/types/streaming.js +7 -0
- package/dist/types/ui-events.d.ts +38 -0
- package/dist/types/ui-events.js +1 -0
- package/package.json +32 -5
@@ -0,0 +1,18 @@
|
|
1
|
+
/**
|
2
|
+
* Get the actual model name for a given specification
|
3
|
+
* @param specification - The Graphlit specification object
|
4
|
+
* @returns The SDK-compatible model name
|
5
|
+
*/
|
6
|
+
export declare function getModelName(specification: any): string | undefined;
|
7
|
+
/**
|
8
|
+
* Check if a service type supports streaming
|
9
|
+
* @param serviceType - The model service type
|
10
|
+
* @returns True if streaming is supported
|
11
|
+
*/
|
12
|
+
export declare function isStreamingSupported(serviceType?: string): boolean;
|
13
|
+
/**
|
14
|
+
* Get the service type from specification
|
15
|
+
* @param specification - The specification object
|
16
|
+
* @returns The service type string
|
17
|
+
*/
|
18
|
+
export declare function getServiceType(specification: any): string | undefined;
|
@@ -0,0 +1,95 @@
|
|
1
|
+
import * as Types from "./generated/graphql-types.js";
|
2
|
+
/**
|
3
|
+
* Model mapping utilities to convert Graphlit specification enums
|
4
|
+
* to actual model names used by LLM SDKs
|
5
|
+
*/
|
6
|
+
// OpenAI model mappings
|
7
|
+
const OPENAI_MODEL_MAP = {
|
8
|
+
[Types.OpenAiModels.Gpt4]: "gpt-4",
|
9
|
+
[Types.OpenAiModels.Gpt4Turbo_128K]: "gpt-4-turbo",
|
10
|
+
[Types.OpenAiModels.Gpt4O_128K]: "gpt-4o",
|
11
|
+
[Types.OpenAiModels.Gpt4OMini_128K]: "gpt-4o-mini",
|
12
|
+
[Types.OpenAiModels.Gpt4OChat_128K]: "chatgpt-4o-latest",
|
13
|
+
[Types.OpenAiModels.Gpt35Turbo]: "gpt-3.5-turbo",
|
14
|
+
[Types.OpenAiModels.Gpt35Turbo_16K]: "gpt-3.5-turbo-16k",
|
15
|
+
[Types.OpenAiModels.Gpt41_1024K]: "gpt-4.1",
|
16
|
+
[Types.OpenAiModels.Gpt41Mini_1024K]: "gpt-4.1-mini",
|
17
|
+
[Types.OpenAiModels.Gpt41Nano_1024K]: "gpt-4.1-nano",
|
18
|
+
[Types.OpenAiModels.O3Mini_200K]: "o3-mini",
|
19
|
+
[Types.OpenAiModels.O3_200K]: "o3",
|
20
|
+
[Types.OpenAiModels.O4Mini_200K]: "o4-mini",
|
21
|
+
};
|
22
|
+
// Anthropic model mappings
|
23
|
+
const ANTHROPIC_MODEL_MAP = {
|
24
|
+
[Types.AnthropicModels.Claude_3Opus]: "claude-3-opus-20240229",
|
25
|
+
[Types.AnthropicModels.Claude_3Sonnet]: "claude-3-sonnet-20240229",
|
26
|
+
[Types.AnthropicModels.Claude_3Haiku]: "claude-3-haiku-20240307",
|
27
|
+
[Types.AnthropicModels.Claude_3_5Sonnet]: "claude-3-5-sonnet-20241022",
|
28
|
+
[Types.AnthropicModels.Claude_3_5Haiku]: "claude-3-5-haiku-20241022",
|
29
|
+
[Types.AnthropicModels.Claude_3_7Sonnet]: "claude-3-7-sonnet-20250219",
|
30
|
+
[Types.AnthropicModels.Claude_4Opus]: "claude-4-opus-20250514",
|
31
|
+
[Types.AnthropicModels.Claude_4Sonnet]: "claude-4-sonnet-20250514",
|
32
|
+
};
|
33
|
+
// Google model mappings
|
34
|
+
const GOOGLE_MODEL_MAP = {
|
35
|
+
[Types.GoogleModels.Gemini_1_5Pro]: "gemini-1.5-pro",
|
36
|
+
[Types.GoogleModels.Gemini_1_5Flash]: "gemini-1.5-flash",
|
37
|
+
[Types.GoogleModels.Gemini_1_5Flash_8B]: "gemini-1.5-flash-8b",
|
38
|
+
[Types.GoogleModels.Gemini_2_0Flash]: "gemini-2.0-flash-exp",
|
39
|
+
[Types.GoogleModels.Gemini_2_0FlashExperimental]: "gemini-2.0-flash-exp",
|
40
|
+
[Types.GoogleModels.Gemini_2_5FlashPreview]: "gemini-2.5-flash-preview-05-20",
|
41
|
+
[Types.GoogleModels.Gemini_2_5ProPreview]: "gemini-2.5-pro-preview-06-05",
|
42
|
+
};
|
43
|
+
/**
|
44
|
+
* Get the actual model name for a given specification
|
45
|
+
* @param specification - The Graphlit specification object
|
46
|
+
* @returns The SDK-compatible model name
|
47
|
+
*/
|
48
|
+
export function getModelName(specification) {
|
49
|
+
const serviceType = specification?.serviceType;
|
50
|
+
// Check for custom model names first
|
51
|
+
if (specification?.openAI?.modelName) {
|
52
|
+
return specification.openAI.modelName;
|
53
|
+
}
|
54
|
+
if (specification?.anthropic?.modelName) {
|
55
|
+
return specification.anthropic.modelName;
|
56
|
+
}
|
57
|
+
if (specification?.google?.modelName) {
|
58
|
+
return specification.google.modelName;
|
59
|
+
}
|
60
|
+
// Map based on service type and model enum
|
61
|
+
switch (serviceType) {
|
62
|
+
case Types.ModelServiceTypes.OpenAi:
|
63
|
+
const openAIModel = specification?.openAI?.model;
|
64
|
+
return openAIModel ? OPENAI_MODEL_MAP[openAIModel] : undefined;
|
65
|
+
case Types.ModelServiceTypes.Anthropic:
|
66
|
+
const anthropicModel = specification?.anthropic?.model;
|
67
|
+
return anthropicModel ? ANTHROPIC_MODEL_MAP[anthropicModel] : undefined;
|
68
|
+
case Types.ModelServiceTypes.Google:
|
69
|
+
const googleModel = specification?.google?.model;
|
70
|
+
return googleModel ? GOOGLE_MODEL_MAP[googleModel] : undefined;
|
71
|
+
default:
|
72
|
+
return undefined;
|
73
|
+
}
|
74
|
+
}
|
75
|
+
/**
|
76
|
+
* Check if a service type supports streaming
|
77
|
+
* @param serviceType - The model service type
|
78
|
+
* @returns True if streaming is supported
|
79
|
+
*/
|
80
|
+
export function isStreamingSupported(serviceType) {
|
81
|
+
const streamingServices = [
|
82
|
+
Types.ModelServiceTypes.OpenAi,
|
83
|
+
Types.ModelServiceTypes.Anthropic,
|
84
|
+
Types.ModelServiceTypes.Google,
|
85
|
+
];
|
86
|
+
return streamingServices.includes(serviceType);
|
87
|
+
}
|
88
|
+
/**
|
89
|
+
* Get the service type from specification
|
90
|
+
* @param specification - The specification object
|
91
|
+
* @returns The service type string
|
92
|
+
*/
|
93
|
+
export function getServiceType(specification) {
|
94
|
+
return specification?.serviceType;
|
95
|
+
}
|
@@ -0,0 +1,106 @@
|
|
1
|
+
import { StreamEvent } from "./client.js";
|
2
|
+
import { ConversationRoleTypes } from "./generated/graphql-types.js";
|
3
|
+
export declare class StreamEventAggregator {
|
4
|
+
private conversationId;
|
5
|
+
private messageBuffer;
|
6
|
+
private toolCallsBuffer;
|
7
|
+
private isFirstAssistantMessage;
|
8
|
+
private hasReceivedToolCalls;
|
9
|
+
private tokenBuffer;
|
10
|
+
/**
|
11
|
+
* Process a stream event and return any complete messages ready for the UI
|
12
|
+
*/
|
13
|
+
processEvent(event: StreamEvent): AggregatedEvent | null;
|
14
|
+
/**
|
15
|
+
* Reset the aggregator for a new conversation
|
16
|
+
*/
|
17
|
+
reset(): void;
|
18
|
+
/**
|
19
|
+
* Get current state (useful for debugging)
|
20
|
+
*/
|
21
|
+
getState(): {
|
22
|
+
conversationId: string;
|
23
|
+
messageBuffer: string;
|
24
|
+
toolCallsCount: number;
|
25
|
+
hasReceivedToolCalls: boolean;
|
26
|
+
isFirstAssistantMessage: boolean;
|
27
|
+
tokenCount: number;
|
28
|
+
};
|
29
|
+
}
|
30
|
+
/**
|
31
|
+
* Aggregated event types that are ready for UI consumption
|
32
|
+
*/
|
33
|
+
export type AggregatedEvent = {
|
34
|
+
type: "conversationStarted";
|
35
|
+
conversationId: string;
|
36
|
+
} | {
|
37
|
+
type: "token";
|
38
|
+
token: string;
|
39
|
+
accumulated: string;
|
40
|
+
} | {
|
41
|
+
type: "assistantMessage";
|
42
|
+
message: {
|
43
|
+
message?: string | null;
|
44
|
+
role?: ConversationRoleTypes | null;
|
45
|
+
toolCalls?: any[];
|
46
|
+
};
|
47
|
+
isFinal: boolean;
|
48
|
+
conversationId?: string;
|
49
|
+
} | {
|
50
|
+
type: "streamComplete";
|
51
|
+
conversationId?: string;
|
52
|
+
} | {
|
53
|
+
type: "error";
|
54
|
+
error: string;
|
55
|
+
};
|
56
|
+
/**
|
57
|
+
* Helper to create an SSE response with proper formatting
|
58
|
+
*/
|
59
|
+
export declare function formatSSEEvent(data: any, eventName?: string): string;
|
60
|
+
/**
|
61
|
+
* Helper to create a TransformStream for SSE with built-in ping support
|
62
|
+
*/
|
63
|
+
export declare function createSSEStream(options?: {
|
64
|
+
pingInterval?: number;
|
65
|
+
}): {
|
66
|
+
readable: ReadableStream<any>;
|
67
|
+
sendEvent: (data: any, eventName?: string) => Promise<void>;
|
68
|
+
close: () => Promise<void>;
|
69
|
+
writer: WritableStreamDefaultWriter<any>;
|
70
|
+
};
|
71
|
+
/**
|
72
|
+
* Helper to wrap tool handlers with result emission
|
73
|
+
*/
|
74
|
+
export interface ToolResultEmitter {
|
75
|
+
(toolCallId: string, result: any, status: "complete" | "error" | "blocked", duration: number): void;
|
76
|
+
}
|
77
|
+
export declare function wrapToolHandlers(handlers: Record<string, (args: any) => Promise<any>>, emitResult: ToolResultEmitter): Record<string, (args: any) => Promise<any>>;
|
78
|
+
/**
|
79
|
+
* Helper to enhance tool calls with server information
|
80
|
+
*/
|
81
|
+
export interface ServerMapping {
|
82
|
+
toolName: string;
|
83
|
+
serverName: string;
|
84
|
+
serverId: string;
|
85
|
+
}
|
86
|
+
export declare function enhanceToolCalls(toolCalls: any[], serverMappings: ServerMapping[]): any[];
|
87
|
+
/**
|
88
|
+
* Helper to track conversation metrics
|
89
|
+
*/
|
90
|
+
export declare class ConversationMetrics {
|
91
|
+
private startTime;
|
92
|
+
private tokenCount;
|
93
|
+
private toolCallCount;
|
94
|
+
private errorCount;
|
95
|
+
recordToken(): void;
|
96
|
+
recordToolCall(): void;
|
97
|
+
recordError(): void;
|
98
|
+
getMetrics(): {
|
99
|
+
duration: number;
|
100
|
+
tokenCount: number;
|
101
|
+
toolCallCount: number;
|
102
|
+
errorCount: number;
|
103
|
+
tokensPerSecond: number;
|
104
|
+
};
|
105
|
+
reset(): void;
|
106
|
+
}
|
@@ -0,0 +1,237 @@
|
|
1
|
+
import { ConversationRoleTypes, } from "./generated/graphql-types.js";
|
2
|
+
export class StreamEventAggregator {
|
3
|
+
conversationId = "";
|
4
|
+
messageBuffer = "";
|
5
|
+
toolCallsBuffer = new Map();
|
6
|
+
isFirstAssistantMessage = true;
|
7
|
+
hasReceivedToolCalls = false;
|
8
|
+
tokenBuffer = [];
|
9
|
+
/**
|
10
|
+
* Process a stream event and return any complete messages ready for the UI
|
11
|
+
*/
|
12
|
+
processEvent(event) {
|
13
|
+
switch (event.type) {
|
14
|
+
case "start":
|
15
|
+
this.conversationId = event.conversationId;
|
16
|
+
return {
|
17
|
+
type: "conversationStarted",
|
18
|
+
conversationId: event.conversationId,
|
19
|
+
};
|
20
|
+
case "token":
|
21
|
+
this.messageBuffer += event.token;
|
22
|
+
this.tokenBuffer.push(event.token);
|
23
|
+
return {
|
24
|
+
type: "token",
|
25
|
+
token: event.token,
|
26
|
+
accumulated: this.messageBuffer,
|
27
|
+
};
|
28
|
+
case "message":
|
29
|
+
// SDK provides accumulated message - we can use this instead of our buffer
|
30
|
+
this.messageBuffer = event.message;
|
31
|
+
return null; // Don't emit, wait for complete event
|
32
|
+
case "tool_call_start":
|
33
|
+
this.hasReceivedToolCalls = true;
|
34
|
+
this.toolCallsBuffer.set(event.toolCall.id, {
|
35
|
+
id: event.toolCall.id,
|
36
|
+
name: event.toolCall.name,
|
37
|
+
argumentsBuffer: "",
|
38
|
+
isComplete: false,
|
39
|
+
startTime: Date.now(),
|
40
|
+
});
|
41
|
+
return null; // Buffer until complete
|
42
|
+
case "tool_call_delta":
|
43
|
+
const toolCall = this.toolCallsBuffer.get(event.toolCallId);
|
44
|
+
if (toolCall) {
|
45
|
+
toolCall.argumentsBuffer += event.argumentDelta;
|
46
|
+
}
|
47
|
+
return null; // Buffer until complete
|
48
|
+
case "tool_call_complete":
|
49
|
+
const completeToolCall = this.toolCallsBuffer.get(event.toolCall.id);
|
50
|
+
if (completeToolCall) {
|
51
|
+
completeToolCall.argumentsBuffer = event.toolCall.arguments;
|
52
|
+
completeToolCall.isComplete = true;
|
53
|
+
}
|
54
|
+
// Check if all tool calls are complete
|
55
|
+
const allComplete = Array.from(this.toolCallsBuffer.values()).every((tc) => tc.isComplete);
|
56
|
+
if (allComplete &&
|
57
|
+
this.hasReceivedToolCalls &&
|
58
|
+
this.isFirstAssistantMessage) {
|
59
|
+
// Emit complete assistant message with all tool calls
|
60
|
+
const toolCalls = Array.from(this.toolCallsBuffer.values()).map((tc) => ({
|
61
|
+
id: tc.id,
|
62
|
+
name: tc.name,
|
63
|
+
arguments: tc.argumentsBuffer,
|
64
|
+
status: "pending",
|
65
|
+
}));
|
66
|
+
this.isFirstAssistantMessage = false;
|
67
|
+
return {
|
68
|
+
type: "assistantMessage",
|
69
|
+
message: {
|
70
|
+
message: this.messageBuffer,
|
71
|
+
role: ConversationRoleTypes.Assistant,
|
72
|
+
toolCalls,
|
73
|
+
},
|
74
|
+
isFinal: false,
|
75
|
+
};
|
76
|
+
}
|
77
|
+
return null;
|
78
|
+
case "complete":
|
79
|
+
// If we haven't sent a message yet (no tool calls), send it now
|
80
|
+
if (this.isFirstAssistantMessage && !this.hasReceivedToolCalls) {
|
81
|
+
return {
|
82
|
+
type: "assistantMessage",
|
83
|
+
message: {
|
84
|
+
message: this.messageBuffer,
|
85
|
+
role: ConversationRoleTypes.Assistant,
|
86
|
+
},
|
87
|
+
isFinal: true,
|
88
|
+
conversationId: event.conversationId,
|
89
|
+
};
|
90
|
+
}
|
91
|
+
return { type: "streamComplete", conversationId: event.conversationId };
|
92
|
+
case "error":
|
93
|
+
return { type: "error", error: event.error };
|
94
|
+
default:
|
95
|
+
return null;
|
96
|
+
}
|
97
|
+
}
|
98
|
+
/**
|
99
|
+
* Reset the aggregator for a new conversation
|
100
|
+
*/
|
101
|
+
reset() {
|
102
|
+
this.conversationId = "";
|
103
|
+
this.messageBuffer = "";
|
104
|
+
this.toolCallsBuffer.clear();
|
105
|
+
this.isFirstAssistantMessage = true;
|
106
|
+
this.hasReceivedToolCalls = false;
|
107
|
+
this.tokenBuffer = [];
|
108
|
+
}
|
109
|
+
/**
|
110
|
+
* Get current state (useful for debugging)
|
111
|
+
*/
|
112
|
+
getState() {
|
113
|
+
return {
|
114
|
+
conversationId: this.conversationId,
|
115
|
+
messageBuffer: this.messageBuffer,
|
116
|
+
toolCallsCount: this.toolCallsBuffer.size,
|
117
|
+
hasReceivedToolCalls: this.hasReceivedToolCalls,
|
118
|
+
isFirstAssistantMessage: this.isFirstAssistantMessage,
|
119
|
+
tokenCount: this.tokenBuffer.length,
|
120
|
+
};
|
121
|
+
}
|
122
|
+
}
|
123
|
+
/**
|
124
|
+
* Helper to create an SSE response with proper formatting
|
125
|
+
*/
|
126
|
+
export function formatSSEEvent(data, eventName = "message") {
|
127
|
+
if (typeof data === "string") {
|
128
|
+
return `event: ${eventName}\ndata: ${data}\n\n`;
|
129
|
+
}
|
130
|
+
return `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
|
131
|
+
}
|
132
|
+
/**
|
133
|
+
* Helper to create a TransformStream for SSE with built-in ping support
|
134
|
+
*/
|
135
|
+
export function createSSEStream(options) {
|
136
|
+
const encoder = new TextEncoder();
|
137
|
+
const { readable, writable } = new TransformStream();
|
138
|
+
const writer = writable.getWriter();
|
139
|
+
let pingInterval = null;
|
140
|
+
if (options?.pingInterval) {
|
141
|
+
pingInterval = globalThis.setInterval(() => {
|
142
|
+
writer.write(encoder.encode(":\n\n")).catch(() => {
|
143
|
+
// Ignore errors on ping
|
144
|
+
});
|
145
|
+
}, options.pingInterval);
|
146
|
+
}
|
147
|
+
const sendEvent = (data, eventName = "message") => {
|
148
|
+
const formatted = formatSSEEvent(data, eventName);
|
149
|
+
return writer.write(encoder.encode(formatted));
|
150
|
+
};
|
151
|
+
const close = async () => {
|
152
|
+
if (pingInterval) {
|
153
|
+
globalThis.clearInterval(pingInterval);
|
154
|
+
}
|
155
|
+
await writer.close();
|
156
|
+
};
|
157
|
+
return {
|
158
|
+
readable,
|
159
|
+
sendEvent,
|
160
|
+
close,
|
161
|
+
writer,
|
162
|
+
};
|
163
|
+
}
|
164
|
+
export function wrapToolHandlers(handlers, emitResult) {
|
165
|
+
const wrapped = {};
|
166
|
+
Object.entries(handlers).forEach(([name, handler]) => {
|
167
|
+
wrapped[name] = async (args) => {
|
168
|
+
const toolCallId = `tool_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
169
|
+
const startTime = Date.now();
|
170
|
+
try {
|
171
|
+
const result = await handler(args);
|
172
|
+
const duration = Date.now() - startTime;
|
173
|
+
// Emit success result
|
174
|
+
emitResult(toolCallId, { status: "success", result }, "complete", duration);
|
175
|
+
return result;
|
176
|
+
}
|
177
|
+
catch (error) {
|
178
|
+
const duration = Date.now() - startTime;
|
179
|
+
// Emit error result
|
180
|
+
emitResult(toolCallId, {
|
181
|
+
status: "error",
|
182
|
+
error: error instanceof Error ? error.message : String(error),
|
183
|
+
}, "error", duration);
|
184
|
+
throw error;
|
185
|
+
}
|
186
|
+
};
|
187
|
+
});
|
188
|
+
return wrapped;
|
189
|
+
}
|
190
|
+
export function enhanceToolCalls(toolCalls, serverMappings) {
|
191
|
+
const mappingDict = serverMappings.reduce((acc, mapping) => {
|
192
|
+
acc[mapping.toolName] = {
|
193
|
+
serverName: mapping.serverName,
|
194
|
+
serverId: mapping.serverId,
|
195
|
+
};
|
196
|
+
return acc;
|
197
|
+
}, {});
|
198
|
+
return toolCalls.map((toolCall) => ({
|
199
|
+
...toolCall,
|
200
|
+
serverName: mappingDict[toolCall.name]?.serverName,
|
201
|
+
serverId: mappingDict[toolCall.name]?.serverId,
|
202
|
+
}));
|
203
|
+
}
|
204
|
+
/**
|
205
|
+
* Helper to track conversation metrics
|
206
|
+
*/
|
207
|
+
export class ConversationMetrics {
|
208
|
+
startTime = Date.now();
|
209
|
+
tokenCount = 0;
|
210
|
+
toolCallCount = 0;
|
211
|
+
errorCount = 0;
|
212
|
+
recordToken() {
|
213
|
+
this.tokenCount++;
|
214
|
+
}
|
215
|
+
recordToolCall() {
|
216
|
+
this.toolCallCount++;
|
217
|
+
}
|
218
|
+
recordError() {
|
219
|
+
this.errorCount++;
|
220
|
+
}
|
221
|
+
getMetrics() {
|
222
|
+
const duration = Date.now() - this.startTime;
|
223
|
+
return {
|
224
|
+
duration,
|
225
|
+
tokenCount: this.tokenCount,
|
226
|
+
toolCallCount: this.toolCallCount,
|
227
|
+
errorCount: this.errorCount,
|
228
|
+
tokensPerSecond: this.tokenCount / (duration / 1000),
|
229
|
+
};
|
230
|
+
}
|
231
|
+
reset() {
|
232
|
+
this.startTime = Date.now();
|
233
|
+
this.tokenCount = 0;
|
234
|
+
this.toolCallCount = 0;
|
235
|
+
this.errorCount = 0;
|
236
|
+
}
|
237
|
+
}
|
@@ -0,0 +1,25 @@
|
|
1
|
+
export type ChunkingStrategy = "character" | "word" | "sentence" | ((text: string) => {
|
2
|
+
chunks: string[];
|
3
|
+
remainder: string;
|
4
|
+
});
|
5
|
+
export declare class ChunkBuffer {
|
6
|
+
private buffer;
|
7
|
+
private static readonly MAX_WORD_LEN;
|
8
|
+
private static readonly MAX_BUFFER_NO_BREAK;
|
9
|
+
private readonly graphemeSeg;
|
10
|
+
private readonly wordSeg;
|
11
|
+
private readonly sentenceSeg;
|
12
|
+
private readonly customChunker?;
|
13
|
+
private readonly strategy;
|
14
|
+
constructor(strategy: ChunkingStrategy);
|
15
|
+
/** Feed one LLM token, receive zero-or-more flushed chunks. */
|
16
|
+
addToken(token: string): string[];
|
17
|
+
/** Flush whatever is left in the buffer when the stream finishes. */
|
18
|
+
flush(): string[];
|
19
|
+
private flushGraphemes;
|
20
|
+
private flushWords;
|
21
|
+
private flushSentences;
|
22
|
+
/** Fallback guard to break up very long runs of text with no natural breaks. */
|
23
|
+
private flushLongRuns;
|
24
|
+
private flushCustom;
|
25
|
+
}
|
@@ -0,0 +1,170 @@
|
|
1
|
+
export class ChunkBuffer {
|
2
|
+
buffer = "";
|
3
|
+
// ----- Configurable Guards -----
|
4
|
+
static MAX_WORD_LEN = 50; // Breaks up extremely long "words" (e.g., URLs, code).
|
5
|
+
static MAX_BUFFER_NO_BREAK = 400; // Hard limit for any run without a natural break.
|
6
|
+
// --------------------------------
|
7
|
+
graphemeSeg;
|
8
|
+
wordSeg;
|
9
|
+
sentenceSeg;
|
10
|
+
customChunker;
|
11
|
+
strategy;
|
12
|
+
constructor(strategy) {
|
13
|
+
if (typeof strategy === "function") {
|
14
|
+
this.customChunker = strategy;
|
15
|
+
this.strategy = "custom";
|
16
|
+
}
|
17
|
+
else {
|
18
|
+
this.strategy = strategy;
|
19
|
+
}
|
20
|
+
this.graphemeSeg = new Intl.Segmenter(undefined, {
|
21
|
+
granularity: "grapheme",
|
22
|
+
});
|
23
|
+
this.wordSeg = new Intl.Segmenter(undefined, { granularity: "word" });
|
24
|
+
this.sentenceSeg = new Intl.Segmenter(undefined, {
|
25
|
+
granularity: "sentence",
|
26
|
+
});
|
27
|
+
}
|
28
|
+
/** Feed one LLM token, receive zero-or-more flushed chunks. */
|
29
|
+
addToken(token) {
|
30
|
+
this.buffer += token;
|
31
|
+
if (this.customChunker) {
|
32
|
+
return this.flushCustom();
|
33
|
+
}
|
34
|
+
// Pre-emptively flush any overly long runs of text that haven't found a natural break.
|
35
|
+
const longRunChunks = this.flushLongRuns();
|
36
|
+
let newChunks = [];
|
37
|
+
switch (this.strategy) {
|
38
|
+
case "character":
|
39
|
+
newChunks = this.flushGraphemes();
|
40
|
+
break;
|
41
|
+
case "word":
|
42
|
+
newChunks = this.flushWords();
|
43
|
+
break;
|
44
|
+
case "sentence":
|
45
|
+
newChunks = this.flushSentences();
|
46
|
+
break;
|
47
|
+
}
|
48
|
+
return [...longRunChunks, ...newChunks];
|
49
|
+
}
|
50
|
+
/** Flush whatever is left in the buffer when the stream finishes. */
|
51
|
+
flush() {
|
52
|
+
if (!this.buffer)
|
53
|
+
return [];
|
54
|
+
let finalChunks = [];
|
55
|
+
if (this.customChunker) {
|
56
|
+
// For custom chunkers, flush everything by treating the whole buffer as input.
|
57
|
+
const { chunks, remainder } = this.customChunker(this.buffer);
|
58
|
+
finalChunks.push(...chunks);
|
59
|
+
if (remainder) {
|
60
|
+
finalChunks.push(remainder);
|
61
|
+
}
|
62
|
+
}
|
63
|
+
else {
|
64
|
+
// For built-in strategies, the remaining buffer is the final chunk.
|
65
|
+
finalChunks.push(this.buffer);
|
66
|
+
}
|
67
|
+
this.buffer = "";
|
68
|
+
// Ensure no empty strings are returned.
|
69
|
+
return finalChunks.filter((c) => c.length > 0);
|
70
|
+
}
|
71
|
+
// ────────────────────────────────────────────────────────────────
|
72
|
+
// Internals
|
73
|
+
// ────────────────────────────────────────────────────────────────
|
74
|
+
flushGraphemes() {
|
75
|
+
const segments = Array.from(this.graphemeSeg.segment(this.buffer)).map((s) => s.segment);
|
76
|
+
// If there's only one segment, it might be incomplete. Wait for more.
|
77
|
+
if (segments.length <= 1) {
|
78
|
+
return [];
|
79
|
+
}
|
80
|
+
// Flush all but the last segment, which becomes the new buffer.
|
81
|
+
const chunksToFlush = segments.slice(0, -1);
|
82
|
+
this.buffer = segments[segments.length - 1];
|
83
|
+
return chunksToFlush;
|
84
|
+
}
|
85
|
+
flushWords() {
|
86
|
+
const chunks = [];
|
87
|
+
let currentWord = ""; // Accumulates the word part (e.g., "quick")
|
88
|
+
let currentNonWord = ""; // Accumulates trailing spaces/punctuation (e.g., " ")
|
89
|
+
// Iterate through all segments of the current buffer.
|
90
|
+
const segments = Array.from(this.wordSeg.segment(this.buffer));
|
91
|
+
// Process segments to form "word + non-word" chunks.
|
92
|
+
for (let i = 0; i < segments.length; i++) {
|
93
|
+
const part = segments[i];
|
94
|
+
if (part.isWordLike) {
|
95
|
+
// If we just finished a word and accumulated non-word characters,
|
96
|
+
// it means the previous "word + non-word" chunk is complete.
|
97
|
+
if (currentWord.length > 0 && currentNonWord.length > 0) {
|
98
|
+
chunks.push(currentWord + currentNonWord);
|
99
|
+
currentWord = "";
|
100
|
+
currentNonWord = "";
|
101
|
+
}
|
102
|
+
currentWord += part.segment;
|
103
|
+
}
|
104
|
+
else {
|
105
|
+
// This is a non-word segment (space, punctuation).
|
106
|
+
currentNonWord += part.segment;
|
107
|
+
}
|
108
|
+
// Guard against extremely long words (e.g., a URL) that don't have natural breaks.
|
109
|
+
// This flushes the accumulated word part even if it's not followed by a non-word yet.
|
110
|
+
if (currentWord.length > ChunkBuffer.MAX_WORD_LEN) {
|
111
|
+
chunks.push(currentWord + currentNonWord);
|
112
|
+
currentWord = "";
|
113
|
+
currentNonWord = "";
|
114
|
+
}
|
115
|
+
}
|
116
|
+
// After the loop, whatever remains in currentWord and currentNonWord
|
117
|
+
// is the incomplete part of the stream. This becomes the new buffer.
|
118
|
+
this.buffer = currentWord + currentNonWord;
|
119
|
+
// Filter out any empty strings that might result from edge cases.
|
120
|
+
return chunks.filter((c) => c.length > 0);
|
121
|
+
}
|
122
|
+
flushSentences() {
|
123
|
+
// This hybrid approach is more robust for sentence-ending punctuation.
|
124
|
+
// 1. Use a regex to find the last definitive sentence boundary.
|
125
|
+
// This is more reliable than Intl.Segmenter alone for partial streams.
|
126
|
+
const sentenceBoundaryRegex = /.*?[.?!](\s+|$)/g;
|
127
|
+
let lastMatchIndex = -1;
|
128
|
+
let match;
|
129
|
+
while ((match = sentenceBoundaryRegex.exec(this.buffer)) !== null) {
|
130
|
+
lastMatchIndex = match.index + match[0].length;
|
131
|
+
}
|
132
|
+
if (lastMatchIndex === -1) {
|
133
|
+
// No definitive sentence boundary found yet.
|
134
|
+
return [];
|
135
|
+
}
|
136
|
+
// 2. The text to be flushed is everything up to that boundary.
|
137
|
+
const textToFlush = this.buffer.substring(0, lastMatchIndex);
|
138
|
+
this.buffer = this.buffer.substring(lastMatchIndex);
|
139
|
+
// 3. Now, use Intl.Segmenter on the confirmed text to correctly split it.
|
140
|
+
// This handles cases where `textToFlush` contains multiple sentences.
|
141
|
+
return Array.from(this.sentenceSeg.segment(textToFlush))
|
142
|
+
.map((s) => s.segment)
|
143
|
+
.filter((c) => c.length > 0);
|
144
|
+
}
|
145
|
+
/** Fallback guard to break up very long runs of text with no natural breaks. */
|
146
|
+
flushLongRuns() {
|
147
|
+
const chunks = [];
|
148
|
+
// If the buffer is very long and contains no spaces (e.g., a single long word/URL),
|
149
|
+
// force a break to prevent excessive buffering.
|
150
|
+
if (this.buffer.length > ChunkBuffer.MAX_BUFFER_NO_BREAK &&
|
151
|
+
!/\s/.test(this.buffer)) {
|
152
|
+
chunks.push(this.buffer.slice(0, ChunkBuffer.MAX_BUFFER_NO_BREAK));
|
153
|
+
this.buffer = this.buffer.slice(ChunkBuffer.MAX_BUFFER_NO_BREAK);
|
154
|
+
}
|
155
|
+
return chunks;
|
156
|
+
}
|
157
|
+
flushCustom() {
|
158
|
+
try {
|
159
|
+
const { chunks, remainder } = this.customChunker(this.buffer);
|
160
|
+
this.buffer = remainder;
|
161
|
+
return chunks;
|
162
|
+
}
|
163
|
+
catch (err) {
|
164
|
+
console.error("Custom chunker failed. Flushing entire buffer to avoid data loss.", err);
|
165
|
+
const all = this.buffer;
|
166
|
+
this.buffer = "";
|
167
|
+
return [all];
|
168
|
+
}
|
169
|
+
}
|
170
|
+
}
|