wu-framework 1.1.15 → 1.1.17
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/README.md +52 -20
- package/dist/wu-framework.cjs.js +1 -1
- package/dist/wu-framework.cjs.js.map +1 -1
- package/dist/wu-framework.dev.js +15511 -15146
- package/dist/wu-framework.dev.js.map +1 -1
- package/dist/wu-framework.esm.js +1 -1
- package/dist/wu-framework.esm.js.map +1 -1
- package/dist/wu-framework.umd.js +1 -1
- package/dist/wu-framework.umd.js.map +1 -1
- package/package.json +166 -161
- package/src/adapters/angular/ai.js +30 -30
- package/src/adapters/angular/index.d.ts +154 -154
- package/src/adapters/angular/index.js +932 -932
- package/src/adapters/angular.d.ts +3 -3
- package/src/adapters/angular.js +3 -3
- package/src/adapters/index.js +168 -168
- package/src/adapters/lit/ai.js +20 -20
- package/src/adapters/lit/index.d.ts +120 -120
- package/src/adapters/lit/index.js +721 -721
- package/src/adapters/lit.d.ts +3 -3
- package/src/adapters/lit.js +3 -3
- package/src/adapters/preact/ai.js +33 -33
- package/src/adapters/preact/index.d.ts +108 -108
- package/src/adapters/preact/index.js +661 -661
- package/src/adapters/preact.d.ts +3 -3
- package/src/adapters/preact.js +3 -3
- package/src/adapters/react/index.js +48 -54
- package/src/adapters/react.d.ts +3 -3
- package/src/adapters/react.js +3 -3
- package/src/adapters/shared.js +64 -64
- package/src/adapters/solid/ai.js +32 -32
- package/src/adapters/solid/index.d.ts +101 -101
- package/src/adapters/solid/index.js +586 -586
- package/src/adapters/solid.d.ts +3 -3
- package/src/adapters/solid.js +3 -3
- package/src/adapters/svelte/ai.js +31 -31
- package/src/adapters/svelte/index.d.ts +166 -166
- package/src/adapters/svelte/index.js +798 -798
- package/src/adapters/svelte.d.ts +3 -3
- package/src/adapters/svelte.js +3 -3
- package/src/adapters/vanilla/ai.js +30 -30
- package/src/adapters/vanilla/index.d.ts +179 -179
- package/src/adapters/vanilla/index.js +785 -785
- package/src/adapters/vanilla.d.ts +3 -3
- package/src/adapters/vanilla.js +3 -3
- package/src/adapters/vue/ai.js +52 -52
- package/src/adapters/vue/index.d.ts +299 -299
- package/src/adapters/vue/index.js +610 -610
- package/src/adapters/vue.d.ts +3 -3
- package/src/adapters/vue.js +3 -3
- package/src/ai/wu-ai-actions.js +261 -261
- package/src/ai/wu-ai-agent.js +546 -546
- package/src/ai/wu-ai-browser-primitives.js +354 -354
- package/src/ai/wu-ai-browser.js +380 -380
- package/src/ai/wu-ai-context.js +332 -332
- package/src/ai/wu-ai-conversation.js +613 -613
- package/src/ai/wu-ai-orchestrate.js +1021 -1021
- package/src/ai/wu-ai-permissions.js +381 -381
- package/src/ai/wu-ai-provider.js +700 -700
- package/src/ai/wu-ai-schema.js +225 -225
- package/src/ai/wu-ai-triggers.js +396 -396
- package/src/ai/wu-ai.js +804 -804
- package/src/core/wu-app.js +236 -236
- package/src/core/wu-cache.js +498 -477
- package/src/core/wu-core.js +1412 -1398
- package/src/core/wu-error-boundary.js +396 -382
- package/src/core/wu-event-bus.js +390 -348
- package/src/core/wu-hooks.js +350 -350
- package/src/core/wu-html-parser.js +199 -190
- package/src/core/wu-iframe-sandbox.js +328 -328
- package/src/core/wu-loader.js +385 -273
- package/src/core/wu-logger.js +142 -134
- package/src/core/wu-manifest.js +532 -509
- package/src/core/wu-mcp-bridge.js +432 -432
- package/src/core/wu-overrides.js +510 -510
- package/src/core/wu-performance.js +228 -228
- package/src/core/wu-plugin.js +401 -348
- package/src/core/wu-prefetch.js +414 -414
- package/src/core/wu-proxy-sandbox.js +477 -476
- package/src/core/wu-sandbox.js +779 -779
- package/src/core/wu-script-executor.js +161 -113
- package/src/core/wu-snapshot-sandbox.js +227 -227
- package/src/core/wu-store.js +13 -3
- package/src/core/wu-strategies.js +256 -256
- package/src/core/wu-style-bridge.js +477 -477
- package/src/index.d.ts +317 -0
- package/src/index.js +234 -224
- package/src/utils/dependency-resolver.js +327 -327
|
@@ -1,613 +1,613 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* WU-AI-CONVERSATION: Multi-turn conversation manager
|
|
3
|
-
*
|
|
4
|
-
* Manages conversation state per namespace. Each namespace maintains
|
|
5
|
-
* its own message history, enabling multiple independent AI conversations
|
|
6
|
-
* (e.g., chat widget, background triggers, admin panel).
|
|
7
|
-
*
|
|
8
|
-
* Key features:
|
|
9
|
-
* - Multi-turn per namespace (isolated histories)
|
|
10
|
-
* - Streaming with async generator passthrough
|
|
11
|
-
* - Tool call loop (max rounds configurable, default 5)
|
|
12
|
-
* - Abort support via AbortController
|
|
13
|
-
* - Automatic context injection before each send
|
|
14
|
-
* - Token-aware history truncation
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { logger } from '../core/wu-logger.js';
|
|
18
|
-
import { sanitizeForPrompt, interpolate } from './wu-ai-schema.js';
|
|
19
|
-
|
|
20
|
-
// ─── Default Config ──────────────────────────────────────────────
|
|
21
|
-
|
|
22
|
-
const DEFAULT_CONFIG = {
|
|
23
|
-
maxHistoryMessages: 50, // per namespace
|
|
24
|
-
maxToolRounds: 5, // tool call loop limit
|
|
25
|
-
defaultNamespace: 'default',
|
|
26
|
-
systemPrompt: null, // string or function returning string
|
|
27
|
-
temperature: undefined,
|
|
28
|
-
maxTokens: undefined,
|
|
29
|
-
namespaceTTL: 30 * 60_000, // 30 min — auto-expire inactive namespaces (0 = disabled)
|
|
30
|
-
gcInterval: 5 * 60_000, // 5 min — how often to sweep for expired namespaces
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
// ─── Conversation Namespace ──────────────────────────────────────
|
|
34
|
-
|
|
35
|
-
class ConversationNamespace {
|
|
36
|
-
constructor(name) {
|
|
37
|
-
this.name = name;
|
|
38
|
-
this.messages = [];
|
|
39
|
-
this.createdAt = Date.now();
|
|
40
|
-
this.lastActivity = Date.now();
|
|
41
|
-
this._abortController = null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
addMessage(msg) {
|
|
45
|
-
this.messages.push({ ...msg, _ts: Date.now() });
|
|
46
|
-
this.lastActivity = Date.now();
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
getMessages() {
|
|
50
|
-
return this.messages.map(({ _ts, ...rest }) => rest);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
truncate(maxMessages) {
|
|
54
|
-
if (this.messages.length <= maxMessages) return;
|
|
55
|
-
|
|
56
|
-
// Keep system messages + last N messages
|
|
57
|
-
const system = this.messages.filter(m => m.role === 'system');
|
|
58
|
-
const nonSystem = this.messages.filter(m => m.role !== 'system');
|
|
59
|
-
const kept = nonSystem.slice(-maxMessages);
|
|
60
|
-
this.messages = [...system, ...kept];
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
clear() {
|
|
64
|
-
this.messages = [];
|
|
65
|
-
this.lastActivity = Date.now();
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
abort() {
|
|
69
|
-
if (this._abortController) {
|
|
70
|
-
this._abortController.abort();
|
|
71
|
-
this._abortController = null;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
createAbortController() {
|
|
76
|
-
this.abort(); // cancel previous if any
|
|
77
|
-
this._abortController = new AbortController();
|
|
78
|
-
return this._abortController;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// ─── Main Conversation Manager ───────────────────────────────────
|
|
83
|
-
|
|
84
|
-
export class WuAIConversation {
|
|
85
|
-
constructor({ provider, actions, context, permissions, eventBus }) {
|
|
86
|
-
this._provider = provider;
|
|
87
|
-
this._actions = actions;
|
|
88
|
-
this._context = context;
|
|
89
|
-
this._permissions = permissions;
|
|
90
|
-
this._eventBus = eventBus;
|
|
91
|
-
|
|
92
|
-
this._config = { ...DEFAULT_CONFIG };
|
|
93
|
-
this._namespaces = new Map();
|
|
94
|
-
this._activeRequests = new Map(); // namespace → promise
|
|
95
|
-
this._lastGcRun = Date.now();
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Configure conversation defaults.
|
|
100
|
-
*/
|
|
101
|
-
configure(config) {
|
|
102
|
-
Object.assign(this._config, config);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Send a message and get a complete response.
|
|
107
|
-
* Handles multi-turn tool call loops automatically.
|
|
108
|
-
*
|
|
109
|
-
* @param {string} message - User message
|
|
110
|
-
* @param {object} [options]
|
|
111
|
-
* @param {string} [options.namespace='default'] - Conversation namespace
|
|
112
|
-
* @param {string} [options.systemPrompt] - Override system prompt
|
|
113
|
-
* @param {object} [options.templateVars] - Variables for template interpolation
|
|
114
|
-
* @param {number} [options.temperature] - Override temperature
|
|
115
|
-
* @param {number} [options.maxTokens] - Override max tokens
|
|
116
|
-
* @param {string|object} [options.responseFormat] - Request JSON output ('json' or { type: 'json_schema', schema, name? })
|
|
117
|
-
* @param {AbortSignal} [options.signal] - External abort signal
|
|
118
|
-
* @returns {Promise<{ content: string, tool_results?: Array, usage?: object, namespace: string }>}
|
|
119
|
-
*/
|
|
120
|
-
async send(message, options = {}) {
|
|
121
|
-
const ns = this._getOrCreateNamespace(options.namespace);
|
|
122
|
-
const traceId = this._permissions.loopProtection.createTraceId();
|
|
123
|
-
const meta = { namespace: ns.name, depth: 0, traceId };
|
|
124
|
-
|
|
125
|
-
// Preflight checks
|
|
126
|
-
const preflight = this._permissions.preflight(meta);
|
|
127
|
-
if (!preflight.allowed) {
|
|
128
|
-
return { content: `[blocked] ${preflight.reason}`, namespace: ns.name };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Build system prompt
|
|
132
|
-
const systemPrompt = await this._buildSystemPrompt(options);
|
|
133
|
-
|
|
134
|
-
// Ensure system message is set/updated
|
|
135
|
-
this._setSystemMessage(ns, systemPrompt);
|
|
136
|
-
|
|
137
|
-
// Add user message
|
|
138
|
-
const processedMessage = this._processMessage(message, options.templateVars);
|
|
139
|
-
ns.addMessage({ role: 'user', content: processedMessage });
|
|
140
|
-
|
|
141
|
-
// Truncate history if needed
|
|
142
|
-
ns.truncate(this._config.maxHistoryMessages);
|
|
143
|
-
|
|
144
|
-
// Create abort controller (merges with external signal)
|
|
145
|
-
const controller = ns.createAbortController();
|
|
146
|
-
const signal = this._mergeSignals(controller.signal, options.signal);
|
|
147
|
-
|
|
148
|
-
// Rate limit tracking
|
|
149
|
-
this._permissions.rateLimiter.recordStart(ns.name);
|
|
150
|
-
|
|
151
|
-
try {
|
|
152
|
-
// Tool call loop
|
|
153
|
-
const toolResults = [];
|
|
154
|
-
let rounds = 0;
|
|
155
|
-
const maxRounds = this._config.maxToolRounds;
|
|
156
|
-
|
|
157
|
-
while (rounds <= maxRounds) {
|
|
158
|
-
// Get tools if actions are registered
|
|
159
|
-
const tools = this._actions.getToolSchemas();
|
|
160
|
-
|
|
161
|
-
const response = await this._provider.send(ns.getMessages(), {
|
|
162
|
-
tools: tools.length > 0 ? tools : undefined,
|
|
163
|
-
temperature: options.temperature ?? this._config.temperature,
|
|
164
|
-
maxTokens: options.maxTokens ?? this._config.maxTokens,
|
|
165
|
-
responseFormat: options.responseFormat,
|
|
166
|
-
provider: options.provider,
|
|
167
|
-
signal,
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
// Add assistant response to history
|
|
171
|
-
const assistantMsg = { role: 'assistant', content: response.content || '' };
|
|
172
|
-
if (response.tool_calls) assistantMsg.tool_calls = response.tool_calls;
|
|
173
|
-
ns.addMessage(assistantMsg);
|
|
174
|
-
|
|
175
|
-
// No tool calls → we're done
|
|
176
|
-
if (!response.tool_calls || response.tool_calls.length === 0) {
|
|
177
|
-
this._permissions.circuitBreaker.recordSuccess();
|
|
178
|
-
|
|
179
|
-
this._eventBus.emit('ai:response', {
|
|
180
|
-
namespace: ns.name,
|
|
181
|
-
content: response.content,
|
|
182
|
-
toolResults: toolResults.length > 0 ? toolResults : undefined,
|
|
183
|
-
usage: response.usage,
|
|
184
|
-
traceId,
|
|
185
|
-
}, { appName: 'wu-ai' });
|
|
186
|
-
|
|
187
|
-
return {
|
|
188
|
-
content: response.content || '',
|
|
189
|
-
tool_results: toolResults.length > 0 ? toolResults : undefined,
|
|
190
|
-
usage: response.usage,
|
|
191
|
-
namespace: ns.name,
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Execute tool calls
|
|
196
|
-
rounds++;
|
|
197
|
-
if (rounds > maxRounds) {
|
|
198
|
-
const msg = `[wu-ai] Tool call loop limit (${maxRounds}) reached in namespace '${ns.name}'`;
|
|
199
|
-
logger.wuWarn(msg);
|
|
200
|
-
ns.addMessage({ role: 'assistant', content: msg });
|
|
201
|
-
return { content: msg, tool_results: toolResults, namespace: ns.name };
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Loop protection
|
|
205
|
-
this._permissions.loopProtection.enter(traceId);
|
|
206
|
-
|
|
207
|
-
for (const toolCall of response.tool_calls) {
|
|
208
|
-
const result = await this._actions.execute(toolCall.name, toolCall.arguments, {
|
|
209
|
-
traceId,
|
|
210
|
-
depth: rounds,
|
|
211
|
-
callId: toolCall.id,
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
toolResults.push({
|
|
215
|
-
tool: toolCall.name,
|
|
216
|
-
params: toolCall.arguments,
|
|
217
|
-
result: result.result,
|
|
218
|
-
success: result.success,
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
// Add tool result to conversation
|
|
222
|
-
ns.addMessage({
|
|
223
|
-
role: 'tool',
|
|
224
|
-
content: JSON.stringify(result.success ? result.result : { error: result.reason }),
|
|
225
|
-
tool_call_id: toolCall.id,
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
this._permissions.loopProtection.exit(traceId);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Should not reach here, but safety net
|
|
233
|
-
return { content: '', tool_results: toolResults, namespace: ns.name };
|
|
234
|
-
|
|
235
|
-
} catch (err) {
|
|
236
|
-
this._permissions.circuitBreaker.recordFailure();
|
|
237
|
-
|
|
238
|
-
if (err.name === 'AbortError') {
|
|
239
|
-
return { content: '[aborted]', namespace: ns.name };
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
this._eventBus.emit('ai:error', {
|
|
243
|
-
namespace: ns.name,
|
|
244
|
-
error: err.message,
|
|
245
|
-
traceId,
|
|
246
|
-
}, { appName: 'wu-ai' });
|
|
247
|
-
|
|
248
|
-
throw err;
|
|
249
|
-
} finally {
|
|
250
|
-
this._permissions.rateLimiter.recordEnd();
|
|
251
|
-
ns._abortController = null;
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Send a message with streaming response.
|
|
257
|
-
* Returns an async generator that yields chunks.
|
|
258
|
-
*
|
|
259
|
-
* @param {string} message - User message
|
|
260
|
-
* @param {object} [options] - Same as send()
|
|
261
|
-
* @yields {{ type: 'text'|'tool_call'|'done'|'error', content?: string, tool_call?: object }}
|
|
262
|
-
*/
|
|
263
|
-
async *stream(message, options = {}) {
|
|
264
|
-
const ns = this._getOrCreateNamespace(options.namespace);
|
|
265
|
-
const traceId = this._permissions.loopProtection.createTraceId();
|
|
266
|
-
const meta = { namespace: ns.name, depth: 0, traceId };
|
|
267
|
-
|
|
268
|
-
// Preflight checks
|
|
269
|
-
const preflight = this._permissions.preflight(meta);
|
|
270
|
-
if (!preflight.allowed) {
|
|
271
|
-
yield { type: 'error', error: preflight.reason };
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Build system prompt and set it
|
|
276
|
-
const systemPrompt = await this._buildSystemPrompt(options);
|
|
277
|
-
this._setSystemMessage(ns, systemPrompt);
|
|
278
|
-
|
|
279
|
-
// Add user message
|
|
280
|
-
const processedMessage = this._processMessage(message, options.templateVars);
|
|
281
|
-
ns.addMessage({ role: 'user', content: processedMessage });
|
|
282
|
-
ns.truncate(this._config.maxHistoryMessages);
|
|
283
|
-
|
|
284
|
-
// Abort controller
|
|
285
|
-
const controller = ns.createAbortController();
|
|
286
|
-
const signal = this._mergeSignals(controller.signal, options.signal);
|
|
287
|
-
|
|
288
|
-
this._permissions.rateLimiter.recordStart(ns.name);
|
|
289
|
-
|
|
290
|
-
try {
|
|
291
|
-
// Tool call loop — mirrors send() behavior (up to maxToolRounds)
|
|
292
|
-
let rounds = 0;
|
|
293
|
-
const maxRounds = this._config.maxToolRounds;
|
|
294
|
-
|
|
295
|
-
while (rounds <= maxRounds) {
|
|
296
|
-
const tools = this._actions.getToolSchemas();
|
|
297
|
-
let fullContent = '';
|
|
298
|
-
const toolCallAccumulator = new Map(); // index → { id, name, args }
|
|
299
|
-
let streamEnded = false;
|
|
300
|
-
|
|
301
|
-
for await (const chunk of this._provider.stream(ns.getMessages(), {
|
|
302
|
-
tools: tools.length > 0 ? tools : undefined,
|
|
303
|
-
temperature: options.temperature ?? this._config.temperature,
|
|
304
|
-
maxTokens: options.maxTokens ?? this._config.maxTokens,
|
|
305
|
-
responseFormat: options.responseFormat,
|
|
306
|
-
provider: options.provider,
|
|
307
|
-
signal,
|
|
308
|
-
})) {
|
|
309
|
-
if (chunk.type === 'text') {
|
|
310
|
-
fullContent += chunk.content;
|
|
311
|
-
yield chunk;
|
|
312
|
-
} else if (chunk.type === 'tool_call_start') {
|
|
313
|
-
toolCallAccumulator.set(toolCallAccumulator.size, {
|
|
314
|
-
id: chunk.id,
|
|
315
|
-
name: chunk.name,
|
|
316
|
-
args: '',
|
|
317
|
-
});
|
|
318
|
-
} else if (chunk.type === 'tool_call_delta') {
|
|
319
|
-
const idx = chunk.index ?? (toolCallAccumulator.size - 1);
|
|
320
|
-
const acc = toolCallAccumulator.get(idx);
|
|
321
|
-
if (acc) {
|
|
322
|
-
if (chunk.id) acc.id = chunk.id;
|
|
323
|
-
if (chunk.name) acc.name = chunk.name;
|
|
324
|
-
acc.args += chunk.argumentsDelta || '';
|
|
325
|
-
} else {
|
|
326
|
-
toolCallAccumulator.set(idx, {
|
|
327
|
-
id: chunk.id || `tc_${idx}`,
|
|
328
|
-
name: chunk.name || '',
|
|
329
|
-
args: chunk.argumentsDelta || '',
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
} else if (chunk.type === 'done') {
|
|
333
|
-
streamEnded = true;
|
|
334
|
-
break; // exit inner loop, handle tool calls below
|
|
335
|
-
} else if (chunk.type === 'usage') {
|
|
336
|
-
yield chunk;
|
|
337
|
-
} else if (chunk.type === 'error') {
|
|
338
|
-
yield chunk;
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// No tool calls → final response, we're done
|
|
343
|
-
if (toolCallAccumulator.size === 0) {
|
|
344
|
-
if (fullContent) {
|
|
345
|
-
ns.addMessage({ role: 'assistant', content: fullContent });
|
|
346
|
-
}
|
|
347
|
-
this._permissions.circuitBreaker.recordSuccess();
|
|
348
|
-
yield { type: 'done' };
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// Tool calls detected — execute them
|
|
353
|
-
rounds++;
|
|
354
|
-
if (rounds > maxRounds) {
|
|
355
|
-
const msg = `[wu-ai] Tool call loop limit (${maxRounds}) reached in streaming namespace '${ns.name}'`;
|
|
356
|
-
logger.wuWarn(msg);
|
|
357
|
-
yield { type: 'error', error: msg };
|
|
358
|
-
yield { type: 'done' };
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Parse accumulated tool calls
|
|
363
|
-
const toolCalls = [];
|
|
364
|
-
for (const [, acc] of toolCallAccumulator) {
|
|
365
|
-
let parsedArgs = {};
|
|
366
|
-
try { parsedArgs = JSON.parse(acc.args); } catch { /* empty args */ }
|
|
367
|
-
toolCalls.push({ id: acc.id, name: acc.name, arguments: parsedArgs });
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Add assistant message with tool calls to history
|
|
371
|
-
ns.addMessage({ role: 'assistant', content: fullContent, tool_calls: toolCalls });
|
|
372
|
-
|
|
373
|
-
// Execute each tool call
|
|
374
|
-
this._permissions.loopProtection.enter(traceId);
|
|
375
|
-
for (const tc of toolCalls) {
|
|
376
|
-
const result = await this._actions.execute(tc.name, tc.arguments, {
|
|
377
|
-
traceId, depth: rounds, callId: tc.id,
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
yield {
|
|
381
|
-
type: 'tool_result',
|
|
382
|
-
tool: tc.name,
|
|
383
|
-
result: result.success ? result.result : { error: result.reason },
|
|
384
|
-
success: result.success,
|
|
385
|
-
};
|
|
386
|
-
|
|
387
|
-
ns.addMessage({
|
|
388
|
-
role: 'tool',
|
|
389
|
-
content: JSON.stringify(result.success ? result.result : { error: result.reason }),
|
|
390
|
-
tool_call_id: tc.id,
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
this._permissions.loopProtection.exit(traceId);
|
|
394
|
-
|
|
395
|
-
yield { type: 'tool_calls_done', count: toolCalls.length };
|
|
396
|
-
// Loop continues → re-stream with tool results in history
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
} catch (err) {
|
|
400
|
-
this._permissions.circuitBreaker.recordFailure();
|
|
401
|
-
|
|
402
|
-
if (err.name === 'AbortError') {
|
|
403
|
-
yield { type: 'error', error: 'aborted' };
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
yield { type: 'error', error: err.message };
|
|
408
|
-
|
|
409
|
-
this._eventBus.emit('ai:error', {
|
|
410
|
-
namespace: ns.name,
|
|
411
|
-
error: err.message,
|
|
412
|
-
traceId,
|
|
413
|
-
}, { appName: 'wu-ai' });
|
|
414
|
-
} finally {
|
|
415
|
-
this._permissions.rateLimiter.recordEnd();
|
|
416
|
-
ns._abortController = null;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/**
|
|
421
|
-
* Add a message to a namespace without sending to LLM.
|
|
422
|
-
* Useful for injecting context or tool results manually.
|
|
423
|
-
*/
|
|
424
|
-
inject(role, content, options = {}) {
|
|
425
|
-
const ns = this._getOrCreateNamespace(options.namespace);
|
|
426
|
-
ns.addMessage({ role, content });
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/**
|
|
430
|
-
* Get conversation history for a namespace.
|
|
431
|
-
*/
|
|
432
|
-
getHistory(namespace) {
|
|
433
|
-
const ns = this._namespaces.get(namespace || this._config.defaultNamespace);
|
|
434
|
-
return ns ? ns.getMessages() : [];
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/**
|
|
438
|
-
* Clear conversation history for a namespace.
|
|
439
|
-
*/
|
|
440
|
-
clear(namespace) {
|
|
441
|
-
const ns = this._namespaces.get(namespace || this._config.defaultNamespace);
|
|
442
|
-
if (ns) ns.clear();
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/**
|
|
446
|
-
* Clear all namespaces.
|
|
447
|
-
*/
|
|
448
|
-
clearAll() {
|
|
449
|
-
for (const ns of this._namespaces.values()) {
|
|
450
|
-
ns.clear();
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* Abort an active request in a namespace.
|
|
456
|
-
*/
|
|
457
|
-
abort(namespace) {
|
|
458
|
-
const ns = this._namespaces.get(namespace || this._config.defaultNamespace);
|
|
459
|
-
if (ns) ns.abort();
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* Abort all active requests.
|
|
464
|
-
*/
|
|
465
|
-
abortAll() {
|
|
466
|
-
for (const ns of this._namespaces.values()) {
|
|
467
|
-
ns.abort();
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* List active namespaces.
|
|
473
|
-
*/
|
|
474
|
-
getNamespaces() {
|
|
475
|
-
return [...this._namespaces.keys()];
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* Delete a namespace entirely.
|
|
480
|
-
*/
|
|
481
|
-
deleteNamespace(namespace) {
|
|
482
|
-
const ns = this._namespaces.get(namespace);
|
|
483
|
-
if (ns) {
|
|
484
|
-
ns.abort();
|
|
485
|
-
this._namespaces.delete(namespace);
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// ── Private ──
|
|
490
|
-
|
|
491
|
-
_getOrCreateNamespace(name) {
|
|
492
|
-
const nsName = name || this._config.defaultNamespace;
|
|
493
|
-
|
|
494
|
-
// Lazy GC sweep — only runs periodically, not on every access
|
|
495
|
-
this._maybeGcSweep();
|
|
496
|
-
|
|
497
|
-
if (!this._namespaces.has(nsName)) {
|
|
498
|
-
this._namespaces.set(nsName, new ConversationNamespace(nsName));
|
|
499
|
-
}
|
|
500
|
-
const ns = this._namespaces.get(nsName);
|
|
501
|
-
ns.lastActivity = Date.now();
|
|
502
|
-
return ns;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
/**
|
|
506
|
-
* Sweep expired namespaces. Called lazily on access, throttled by gcInterval.
|
|
507
|
-
* Never deletes the default namespace or namespaces with active requests.
|
|
508
|
-
*/
|
|
509
|
-
_maybeGcSweep() {
|
|
510
|
-
const ttl = this._config.namespaceTTL;
|
|
511
|
-
const interval = this._config.gcInterval;
|
|
512
|
-
if (!ttl || ttl <= 0) return; // GC disabled
|
|
513
|
-
|
|
514
|
-
const now = Date.now();
|
|
515
|
-
if (now - this._lastGcRun < interval) return; // Not time yet
|
|
516
|
-
this._lastGcRun = now;
|
|
517
|
-
|
|
518
|
-
const cutoff = now - ttl;
|
|
519
|
-
const toDelete = [];
|
|
520
|
-
|
|
521
|
-
for (const [name, ns] of this._namespaces) {
|
|
522
|
-
// Never GC the default namespace
|
|
523
|
-
if (name === this._config.defaultNamespace) continue;
|
|
524
|
-
// Don't GC namespaces with active abort controllers (in-flight request)
|
|
525
|
-
if (ns._abortController) continue;
|
|
526
|
-
// Expired?
|
|
527
|
-
if (ns.lastActivity < cutoff) {
|
|
528
|
-
toDelete.push(name);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
for (const name of toDelete) {
|
|
533
|
-
this._namespaces.delete(name);
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
if (toDelete.length > 0) {
|
|
537
|
-
logger.wuDebug(`[wu-ai] GC sweep: removed ${toDelete.length} expired namespace(s): ${toDelete.join(', ')}`);
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
async _buildSystemPrompt(options) {
|
|
542
|
-
// 1. Explicit override
|
|
543
|
-
if (options.systemPrompt) {
|
|
544
|
-
return typeof options.systemPrompt === 'function'
|
|
545
|
-
? await options.systemPrompt()
|
|
546
|
-
: options.systemPrompt;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// 2. Config-level system prompt
|
|
550
|
-
if (this._config.systemPrompt) {
|
|
551
|
-
return typeof this._config.systemPrompt === 'function'
|
|
552
|
-
? await this._config.systemPrompt()
|
|
553
|
-
: this._config.systemPrompt;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// 3. Auto-generate from context
|
|
557
|
-
if (this._context) {
|
|
558
|
-
await this._context.collect();
|
|
559
|
-
const tools = this._actions.getToolSchemas();
|
|
560
|
-
return this._context.toSystemPrompt({ tools });
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
return 'You are an AI assistant connected to a web application via Wu Framework.';
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
_setSystemMessage(ns, systemPrompt) {
|
|
567
|
-
// Replace or insert system message at the beginning
|
|
568
|
-
if (ns.messages.length > 0 && ns.messages[0].role === 'system') {
|
|
569
|
-
ns.messages[0].content = systemPrompt;
|
|
570
|
-
ns.messages[0]._ts = Date.now();
|
|
571
|
-
} else {
|
|
572
|
-
ns.messages.unshift({ role: 'system', content: systemPrompt, _ts: Date.now() });
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
_processMessage(message, templateVars) {
|
|
577
|
-
if (!templateVars) return message;
|
|
578
|
-
try {
|
|
579
|
-
const contextVars = this._context?.getInterpolationContext() || {};
|
|
580
|
-
return interpolate(message, { ...contextVars, ...templateVars });
|
|
581
|
-
} catch {
|
|
582
|
-
return message;
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
_mergeSignals(internalSignal, externalSignal) {
|
|
587
|
-
if (!externalSignal) return internalSignal;
|
|
588
|
-
|
|
589
|
-
// If AbortSignal.any is available (modern browsers), use it
|
|
590
|
-
if (typeof AbortSignal !== 'undefined' && AbortSignal.any) {
|
|
591
|
-
return AbortSignal.any([internalSignal, externalSignal]);
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// Fallback: create a new controller that listens to both
|
|
595
|
-
const merged = new AbortController();
|
|
596
|
-
const onAbort = () => merged.abort();
|
|
597
|
-
internalSignal.addEventListener('abort', onAbort, { once: true });
|
|
598
|
-
externalSignal.addEventListener('abort', onAbort, { once: true });
|
|
599
|
-
return merged.signal;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
getStats() {
|
|
603
|
-
const namespaces = {};
|
|
604
|
-
for (const [name, ns] of this._namespaces) {
|
|
605
|
-
namespaces[name] = {
|
|
606
|
-
messageCount: ns.messages.length,
|
|
607
|
-
lastActivity: ns.lastActivity,
|
|
608
|
-
hasActiveRequest: !!ns._abortController,
|
|
609
|
-
};
|
|
610
|
-
}
|
|
611
|
-
return { namespaces, config: { ...this._config } };
|
|
612
|
-
}
|
|
613
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* WU-AI-CONVERSATION: Multi-turn conversation manager
|
|
3
|
+
*
|
|
4
|
+
* Manages conversation state per namespace. Each namespace maintains
|
|
5
|
+
* its own message history, enabling multiple independent AI conversations
|
|
6
|
+
* (e.g., chat widget, background triggers, admin panel).
|
|
7
|
+
*
|
|
8
|
+
* Key features:
|
|
9
|
+
* - Multi-turn per namespace (isolated histories)
|
|
10
|
+
* - Streaming with async generator passthrough
|
|
11
|
+
* - Tool call loop (max rounds configurable, default 5)
|
|
12
|
+
* - Abort support via AbortController
|
|
13
|
+
* - Automatic context injection before each send
|
|
14
|
+
* - Token-aware history truncation
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { logger } from '../core/wu-logger.js';
|
|
18
|
+
import { sanitizeForPrompt, interpolate } from './wu-ai-schema.js';
|
|
19
|
+
|
|
20
|
+
// ─── Default Config ──────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const DEFAULT_CONFIG = {
|
|
23
|
+
maxHistoryMessages: 50, // per namespace
|
|
24
|
+
maxToolRounds: 5, // tool call loop limit
|
|
25
|
+
defaultNamespace: 'default',
|
|
26
|
+
systemPrompt: null, // string or function returning string
|
|
27
|
+
temperature: undefined,
|
|
28
|
+
maxTokens: undefined,
|
|
29
|
+
namespaceTTL: 30 * 60_000, // 30 min — auto-expire inactive namespaces (0 = disabled)
|
|
30
|
+
gcInterval: 5 * 60_000, // 5 min — how often to sweep for expired namespaces
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// ─── Conversation Namespace ──────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
class ConversationNamespace {
|
|
36
|
+
constructor(name) {
|
|
37
|
+
this.name = name;
|
|
38
|
+
this.messages = [];
|
|
39
|
+
this.createdAt = Date.now();
|
|
40
|
+
this.lastActivity = Date.now();
|
|
41
|
+
this._abortController = null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
addMessage(msg) {
|
|
45
|
+
this.messages.push({ ...msg, _ts: Date.now() });
|
|
46
|
+
this.lastActivity = Date.now();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
getMessages() {
|
|
50
|
+
return this.messages.map(({ _ts, ...rest }) => rest);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
truncate(maxMessages) {
|
|
54
|
+
if (this.messages.length <= maxMessages) return;
|
|
55
|
+
|
|
56
|
+
// Keep system messages + last N messages
|
|
57
|
+
const system = this.messages.filter(m => m.role === 'system');
|
|
58
|
+
const nonSystem = this.messages.filter(m => m.role !== 'system');
|
|
59
|
+
const kept = nonSystem.slice(-maxMessages);
|
|
60
|
+
this.messages = [...system, ...kept];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
clear() {
|
|
64
|
+
this.messages = [];
|
|
65
|
+
this.lastActivity = Date.now();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
abort() {
|
|
69
|
+
if (this._abortController) {
|
|
70
|
+
this._abortController.abort();
|
|
71
|
+
this._abortController = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
createAbortController() {
|
|
76
|
+
this.abort(); // cancel previous if any
|
|
77
|
+
this._abortController = new AbortController();
|
|
78
|
+
return this._abortController;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Main Conversation Manager ───────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export class WuAIConversation {
|
|
85
|
+
constructor({ provider, actions, context, permissions, eventBus }) {
|
|
86
|
+
this._provider = provider;
|
|
87
|
+
this._actions = actions;
|
|
88
|
+
this._context = context;
|
|
89
|
+
this._permissions = permissions;
|
|
90
|
+
this._eventBus = eventBus;
|
|
91
|
+
|
|
92
|
+
this._config = { ...DEFAULT_CONFIG };
|
|
93
|
+
this._namespaces = new Map();
|
|
94
|
+
this._activeRequests = new Map(); // namespace → promise
|
|
95
|
+
this._lastGcRun = Date.now();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Configure conversation defaults.
|
|
100
|
+
*/
|
|
101
|
+
configure(config) {
|
|
102
|
+
Object.assign(this._config, config);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Send a message and get a complete response.
|
|
107
|
+
* Handles multi-turn tool call loops automatically.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} message - User message
|
|
110
|
+
* @param {object} [options]
|
|
111
|
+
* @param {string} [options.namespace='default'] - Conversation namespace
|
|
112
|
+
* @param {string} [options.systemPrompt] - Override system prompt
|
|
113
|
+
* @param {object} [options.templateVars] - Variables for template interpolation
|
|
114
|
+
* @param {number} [options.temperature] - Override temperature
|
|
115
|
+
* @param {number} [options.maxTokens] - Override max tokens
|
|
116
|
+
* @param {string|object} [options.responseFormat] - Request JSON output ('json' or { type: 'json_schema', schema, name? })
|
|
117
|
+
* @param {AbortSignal} [options.signal] - External abort signal
|
|
118
|
+
* @returns {Promise<{ content: string, tool_results?: Array, usage?: object, namespace: string }>}
|
|
119
|
+
*/
|
|
120
|
+
async send(message, options = {}) {
|
|
121
|
+
const ns = this._getOrCreateNamespace(options.namespace);
|
|
122
|
+
const traceId = this._permissions.loopProtection.createTraceId();
|
|
123
|
+
const meta = { namespace: ns.name, depth: 0, traceId };
|
|
124
|
+
|
|
125
|
+
// Preflight checks
|
|
126
|
+
const preflight = this._permissions.preflight(meta);
|
|
127
|
+
if (!preflight.allowed) {
|
|
128
|
+
return { content: `[blocked] ${preflight.reason}`, namespace: ns.name };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Build system prompt
|
|
132
|
+
const systemPrompt = await this._buildSystemPrompt(options);
|
|
133
|
+
|
|
134
|
+
// Ensure system message is set/updated
|
|
135
|
+
this._setSystemMessage(ns, systemPrompt);
|
|
136
|
+
|
|
137
|
+
// Add user message
|
|
138
|
+
const processedMessage = this._processMessage(message, options.templateVars);
|
|
139
|
+
ns.addMessage({ role: 'user', content: processedMessage });
|
|
140
|
+
|
|
141
|
+
// Truncate history if needed
|
|
142
|
+
ns.truncate(this._config.maxHistoryMessages);
|
|
143
|
+
|
|
144
|
+
// Create abort controller (merges with external signal)
|
|
145
|
+
const controller = ns.createAbortController();
|
|
146
|
+
const signal = this._mergeSignals(controller.signal, options.signal);
|
|
147
|
+
|
|
148
|
+
// Rate limit tracking
|
|
149
|
+
this._permissions.rateLimiter.recordStart(ns.name);
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
// Tool call loop
|
|
153
|
+
const toolResults = [];
|
|
154
|
+
let rounds = 0;
|
|
155
|
+
const maxRounds = this._config.maxToolRounds;
|
|
156
|
+
|
|
157
|
+
while (rounds <= maxRounds) {
|
|
158
|
+
// Get tools if actions are registered
|
|
159
|
+
const tools = this._actions.getToolSchemas();
|
|
160
|
+
|
|
161
|
+
const response = await this._provider.send(ns.getMessages(), {
|
|
162
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
163
|
+
temperature: options.temperature ?? this._config.temperature,
|
|
164
|
+
maxTokens: options.maxTokens ?? this._config.maxTokens,
|
|
165
|
+
responseFormat: options.responseFormat,
|
|
166
|
+
provider: options.provider,
|
|
167
|
+
signal,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Add assistant response to history
|
|
171
|
+
const assistantMsg = { role: 'assistant', content: response.content || '' };
|
|
172
|
+
if (response.tool_calls) assistantMsg.tool_calls = response.tool_calls;
|
|
173
|
+
ns.addMessage(assistantMsg);
|
|
174
|
+
|
|
175
|
+
// No tool calls → we're done
|
|
176
|
+
if (!response.tool_calls || response.tool_calls.length === 0) {
|
|
177
|
+
this._permissions.circuitBreaker.recordSuccess();
|
|
178
|
+
|
|
179
|
+
this._eventBus.emit('ai:response', {
|
|
180
|
+
namespace: ns.name,
|
|
181
|
+
content: response.content,
|
|
182
|
+
toolResults: toolResults.length > 0 ? toolResults : undefined,
|
|
183
|
+
usage: response.usage,
|
|
184
|
+
traceId,
|
|
185
|
+
}, { appName: 'wu-ai' });
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
content: response.content || '',
|
|
189
|
+
tool_results: toolResults.length > 0 ? toolResults : undefined,
|
|
190
|
+
usage: response.usage,
|
|
191
|
+
namespace: ns.name,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Execute tool calls
|
|
196
|
+
rounds++;
|
|
197
|
+
if (rounds > maxRounds) {
|
|
198
|
+
const msg = `[wu-ai] Tool call loop limit (${maxRounds}) reached in namespace '${ns.name}'`;
|
|
199
|
+
logger.wuWarn(msg);
|
|
200
|
+
ns.addMessage({ role: 'assistant', content: msg });
|
|
201
|
+
return { content: msg, tool_results: toolResults, namespace: ns.name };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Loop protection
|
|
205
|
+
this._permissions.loopProtection.enter(traceId);
|
|
206
|
+
|
|
207
|
+
for (const toolCall of response.tool_calls) {
|
|
208
|
+
const result = await this._actions.execute(toolCall.name, toolCall.arguments, {
|
|
209
|
+
traceId,
|
|
210
|
+
depth: rounds,
|
|
211
|
+
callId: toolCall.id,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
toolResults.push({
|
|
215
|
+
tool: toolCall.name,
|
|
216
|
+
params: toolCall.arguments,
|
|
217
|
+
result: result.result,
|
|
218
|
+
success: result.success,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Add tool result to conversation
|
|
222
|
+
ns.addMessage({
|
|
223
|
+
role: 'tool',
|
|
224
|
+
content: JSON.stringify(result.success ? result.result : { error: result.reason }),
|
|
225
|
+
tool_call_id: toolCall.id,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
this._permissions.loopProtection.exit(traceId);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Should not reach here, but safety net
|
|
233
|
+
return { content: '', tool_results: toolResults, namespace: ns.name };
|
|
234
|
+
|
|
235
|
+
} catch (err) {
|
|
236
|
+
this._permissions.circuitBreaker.recordFailure();
|
|
237
|
+
|
|
238
|
+
if (err.name === 'AbortError') {
|
|
239
|
+
return { content: '[aborted]', namespace: ns.name };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
this._eventBus.emit('ai:error', {
|
|
243
|
+
namespace: ns.name,
|
|
244
|
+
error: err.message,
|
|
245
|
+
traceId,
|
|
246
|
+
}, { appName: 'wu-ai' });
|
|
247
|
+
|
|
248
|
+
throw err;
|
|
249
|
+
} finally {
|
|
250
|
+
this._permissions.rateLimiter.recordEnd();
|
|
251
|
+
ns._abortController = null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Send a message with streaming response.
|
|
257
|
+
* Returns an async generator that yields chunks.
|
|
258
|
+
*
|
|
259
|
+
* @param {string} message - User message
|
|
260
|
+
* @param {object} [options] - Same as send()
|
|
261
|
+
* @yields {{ type: 'text'|'tool_call'|'done'|'error', content?: string, tool_call?: object }}
|
|
262
|
+
*/
|
|
263
|
+
async *stream(message, options = {}) {
|
|
264
|
+
const ns = this._getOrCreateNamespace(options.namespace);
|
|
265
|
+
const traceId = this._permissions.loopProtection.createTraceId();
|
|
266
|
+
const meta = { namespace: ns.name, depth: 0, traceId };
|
|
267
|
+
|
|
268
|
+
// Preflight checks
|
|
269
|
+
const preflight = this._permissions.preflight(meta);
|
|
270
|
+
if (!preflight.allowed) {
|
|
271
|
+
yield { type: 'error', error: preflight.reason };
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Build system prompt and set it
|
|
276
|
+
const systemPrompt = await this._buildSystemPrompt(options);
|
|
277
|
+
this._setSystemMessage(ns, systemPrompt);
|
|
278
|
+
|
|
279
|
+
// Add user message
|
|
280
|
+
const processedMessage = this._processMessage(message, options.templateVars);
|
|
281
|
+
ns.addMessage({ role: 'user', content: processedMessage });
|
|
282
|
+
ns.truncate(this._config.maxHistoryMessages);
|
|
283
|
+
|
|
284
|
+
// Abort controller
|
|
285
|
+
const controller = ns.createAbortController();
|
|
286
|
+
const signal = this._mergeSignals(controller.signal, options.signal);
|
|
287
|
+
|
|
288
|
+
this._permissions.rateLimiter.recordStart(ns.name);
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
// Tool call loop — mirrors send() behavior (up to maxToolRounds)
|
|
292
|
+
let rounds = 0;
|
|
293
|
+
const maxRounds = this._config.maxToolRounds;
|
|
294
|
+
|
|
295
|
+
while (rounds <= maxRounds) {
|
|
296
|
+
const tools = this._actions.getToolSchemas();
|
|
297
|
+
let fullContent = '';
|
|
298
|
+
const toolCallAccumulator = new Map(); // index → { id, name, args }
|
|
299
|
+
let streamEnded = false;
|
|
300
|
+
|
|
301
|
+
for await (const chunk of this._provider.stream(ns.getMessages(), {
|
|
302
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
303
|
+
temperature: options.temperature ?? this._config.temperature,
|
|
304
|
+
maxTokens: options.maxTokens ?? this._config.maxTokens,
|
|
305
|
+
responseFormat: options.responseFormat,
|
|
306
|
+
provider: options.provider,
|
|
307
|
+
signal,
|
|
308
|
+
})) {
|
|
309
|
+
if (chunk.type === 'text') {
|
|
310
|
+
fullContent += chunk.content;
|
|
311
|
+
yield chunk;
|
|
312
|
+
} else if (chunk.type === 'tool_call_start') {
|
|
313
|
+
toolCallAccumulator.set(toolCallAccumulator.size, {
|
|
314
|
+
id: chunk.id,
|
|
315
|
+
name: chunk.name,
|
|
316
|
+
args: '',
|
|
317
|
+
});
|
|
318
|
+
} else if (chunk.type === 'tool_call_delta') {
|
|
319
|
+
const idx = chunk.index ?? (toolCallAccumulator.size - 1);
|
|
320
|
+
const acc = toolCallAccumulator.get(idx);
|
|
321
|
+
if (acc) {
|
|
322
|
+
if (chunk.id) acc.id = chunk.id;
|
|
323
|
+
if (chunk.name) acc.name = chunk.name;
|
|
324
|
+
acc.args += chunk.argumentsDelta || '';
|
|
325
|
+
} else {
|
|
326
|
+
toolCallAccumulator.set(idx, {
|
|
327
|
+
id: chunk.id || `tc_${idx}`,
|
|
328
|
+
name: chunk.name || '',
|
|
329
|
+
args: chunk.argumentsDelta || '',
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
} else if (chunk.type === 'done') {
|
|
333
|
+
streamEnded = true;
|
|
334
|
+
break; // exit inner loop, handle tool calls below
|
|
335
|
+
} else if (chunk.type === 'usage') {
|
|
336
|
+
yield chunk;
|
|
337
|
+
} else if (chunk.type === 'error') {
|
|
338
|
+
yield chunk;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// No tool calls → final response, we're done
|
|
343
|
+
if (toolCallAccumulator.size === 0) {
|
|
344
|
+
if (fullContent) {
|
|
345
|
+
ns.addMessage({ role: 'assistant', content: fullContent });
|
|
346
|
+
}
|
|
347
|
+
this._permissions.circuitBreaker.recordSuccess();
|
|
348
|
+
yield { type: 'done' };
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Tool calls detected — execute them
|
|
353
|
+
rounds++;
|
|
354
|
+
if (rounds > maxRounds) {
|
|
355
|
+
const msg = `[wu-ai] Tool call loop limit (${maxRounds}) reached in streaming namespace '${ns.name}'`;
|
|
356
|
+
logger.wuWarn(msg);
|
|
357
|
+
yield { type: 'error', error: msg };
|
|
358
|
+
yield { type: 'done' };
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Parse accumulated tool calls
|
|
363
|
+
const toolCalls = [];
|
|
364
|
+
for (const [, acc] of toolCallAccumulator) {
|
|
365
|
+
let parsedArgs = {};
|
|
366
|
+
try { parsedArgs = JSON.parse(acc.args); } catch { /* empty args */ }
|
|
367
|
+
toolCalls.push({ id: acc.id, name: acc.name, arguments: parsedArgs });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Add assistant message with tool calls to history
|
|
371
|
+
ns.addMessage({ role: 'assistant', content: fullContent, tool_calls: toolCalls });
|
|
372
|
+
|
|
373
|
+
// Execute each tool call
|
|
374
|
+
this._permissions.loopProtection.enter(traceId);
|
|
375
|
+
for (const tc of toolCalls) {
|
|
376
|
+
const result = await this._actions.execute(tc.name, tc.arguments, {
|
|
377
|
+
traceId, depth: rounds, callId: tc.id,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
yield {
|
|
381
|
+
type: 'tool_result',
|
|
382
|
+
tool: tc.name,
|
|
383
|
+
result: result.success ? result.result : { error: result.reason },
|
|
384
|
+
success: result.success,
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
ns.addMessage({
|
|
388
|
+
role: 'tool',
|
|
389
|
+
content: JSON.stringify(result.success ? result.result : { error: result.reason }),
|
|
390
|
+
tool_call_id: tc.id,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
this._permissions.loopProtection.exit(traceId);
|
|
394
|
+
|
|
395
|
+
yield { type: 'tool_calls_done', count: toolCalls.length };
|
|
396
|
+
// Loop continues → re-stream with tool results in history
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
} catch (err) {
|
|
400
|
+
this._permissions.circuitBreaker.recordFailure();
|
|
401
|
+
|
|
402
|
+
if (err.name === 'AbortError') {
|
|
403
|
+
yield { type: 'error', error: 'aborted' };
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
yield { type: 'error', error: err.message };
|
|
408
|
+
|
|
409
|
+
this._eventBus.emit('ai:error', {
|
|
410
|
+
namespace: ns.name,
|
|
411
|
+
error: err.message,
|
|
412
|
+
traceId,
|
|
413
|
+
}, { appName: 'wu-ai' });
|
|
414
|
+
} finally {
|
|
415
|
+
this._permissions.rateLimiter.recordEnd();
|
|
416
|
+
ns._abortController = null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Add a message to a namespace without sending to LLM.
|
|
422
|
+
* Useful for injecting context or tool results manually.
|
|
423
|
+
*/
|
|
424
|
+
inject(role, content, options = {}) {
|
|
425
|
+
const ns = this._getOrCreateNamespace(options.namespace);
|
|
426
|
+
ns.addMessage({ role, content });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Get conversation history for a namespace.
|
|
431
|
+
*/
|
|
432
|
+
getHistory(namespace) {
|
|
433
|
+
const ns = this._namespaces.get(namespace || this._config.defaultNamespace);
|
|
434
|
+
return ns ? ns.getMessages() : [];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Clear conversation history for a namespace.
|
|
439
|
+
*/
|
|
440
|
+
clear(namespace) {
|
|
441
|
+
const ns = this._namespaces.get(namespace || this._config.defaultNamespace);
|
|
442
|
+
if (ns) ns.clear();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Clear all namespaces.
|
|
447
|
+
*/
|
|
448
|
+
clearAll() {
|
|
449
|
+
for (const ns of this._namespaces.values()) {
|
|
450
|
+
ns.clear();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Abort an active request in a namespace.
|
|
456
|
+
*/
|
|
457
|
+
abort(namespace) {
|
|
458
|
+
const ns = this._namespaces.get(namespace || this._config.defaultNamespace);
|
|
459
|
+
if (ns) ns.abort();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Abort all active requests.
|
|
464
|
+
*/
|
|
465
|
+
abortAll() {
|
|
466
|
+
for (const ns of this._namespaces.values()) {
|
|
467
|
+
ns.abort();
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* List active namespaces.
|
|
473
|
+
*/
|
|
474
|
+
getNamespaces() {
|
|
475
|
+
return [...this._namespaces.keys()];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Delete a namespace entirely.
|
|
480
|
+
*/
|
|
481
|
+
deleteNamespace(namespace) {
|
|
482
|
+
const ns = this._namespaces.get(namespace);
|
|
483
|
+
if (ns) {
|
|
484
|
+
ns.abort();
|
|
485
|
+
this._namespaces.delete(namespace);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ── Private ──
|
|
490
|
+
|
|
491
|
+
_getOrCreateNamespace(name) {
|
|
492
|
+
const nsName = name || this._config.defaultNamespace;
|
|
493
|
+
|
|
494
|
+
// Lazy GC sweep — only runs periodically, not on every access
|
|
495
|
+
this._maybeGcSweep();
|
|
496
|
+
|
|
497
|
+
if (!this._namespaces.has(nsName)) {
|
|
498
|
+
this._namespaces.set(nsName, new ConversationNamespace(nsName));
|
|
499
|
+
}
|
|
500
|
+
const ns = this._namespaces.get(nsName);
|
|
501
|
+
ns.lastActivity = Date.now();
|
|
502
|
+
return ns;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Sweep expired namespaces. Called lazily on access, throttled by gcInterval.
|
|
507
|
+
* Never deletes the default namespace or namespaces with active requests.
|
|
508
|
+
*/
|
|
509
|
+
_maybeGcSweep() {
|
|
510
|
+
const ttl = this._config.namespaceTTL;
|
|
511
|
+
const interval = this._config.gcInterval;
|
|
512
|
+
if (!ttl || ttl <= 0) return; // GC disabled
|
|
513
|
+
|
|
514
|
+
const now = Date.now();
|
|
515
|
+
if (now - this._lastGcRun < interval) return; // Not time yet
|
|
516
|
+
this._lastGcRun = now;
|
|
517
|
+
|
|
518
|
+
const cutoff = now - ttl;
|
|
519
|
+
const toDelete = [];
|
|
520
|
+
|
|
521
|
+
for (const [name, ns] of this._namespaces) {
|
|
522
|
+
// Never GC the default namespace
|
|
523
|
+
if (name === this._config.defaultNamespace) continue;
|
|
524
|
+
// Don't GC namespaces with active abort controllers (in-flight request)
|
|
525
|
+
if (ns._abortController) continue;
|
|
526
|
+
// Expired?
|
|
527
|
+
if (ns.lastActivity < cutoff) {
|
|
528
|
+
toDelete.push(name);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
for (const name of toDelete) {
|
|
533
|
+
this._namespaces.delete(name);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (toDelete.length > 0) {
|
|
537
|
+
logger.wuDebug(`[wu-ai] GC sweep: removed ${toDelete.length} expired namespace(s): ${toDelete.join(', ')}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async _buildSystemPrompt(options) {
|
|
542
|
+
// 1. Explicit override
|
|
543
|
+
if (options.systemPrompt) {
|
|
544
|
+
return typeof options.systemPrompt === 'function'
|
|
545
|
+
? await options.systemPrompt()
|
|
546
|
+
: options.systemPrompt;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// 2. Config-level system prompt
|
|
550
|
+
if (this._config.systemPrompt) {
|
|
551
|
+
return typeof this._config.systemPrompt === 'function'
|
|
552
|
+
? await this._config.systemPrompt()
|
|
553
|
+
: this._config.systemPrompt;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// 3. Auto-generate from context
|
|
557
|
+
if (this._context) {
|
|
558
|
+
await this._context.collect();
|
|
559
|
+
const tools = this._actions.getToolSchemas();
|
|
560
|
+
return this._context.toSystemPrompt({ tools });
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return 'You are an AI assistant connected to a web application via Wu Framework.';
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
_setSystemMessage(ns, systemPrompt) {
|
|
567
|
+
// Replace or insert system message at the beginning
|
|
568
|
+
if (ns.messages.length > 0 && ns.messages[0].role === 'system') {
|
|
569
|
+
ns.messages[0].content = systemPrompt;
|
|
570
|
+
ns.messages[0]._ts = Date.now();
|
|
571
|
+
} else {
|
|
572
|
+
ns.messages.unshift({ role: 'system', content: systemPrompt, _ts: Date.now() });
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
_processMessage(message, templateVars) {
|
|
577
|
+
if (!templateVars) return message;
|
|
578
|
+
try {
|
|
579
|
+
const contextVars = this._context?.getInterpolationContext() || {};
|
|
580
|
+
return interpolate(message, { ...contextVars, ...templateVars });
|
|
581
|
+
} catch {
|
|
582
|
+
return message;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
_mergeSignals(internalSignal, externalSignal) {
|
|
587
|
+
if (!externalSignal) return internalSignal;
|
|
588
|
+
|
|
589
|
+
// If AbortSignal.any is available (modern browsers), use it
|
|
590
|
+
if (typeof AbortSignal !== 'undefined' && AbortSignal.any) {
|
|
591
|
+
return AbortSignal.any([internalSignal, externalSignal]);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Fallback: create a new controller that listens to both
|
|
595
|
+
const merged = new AbortController();
|
|
596
|
+
const onAbort = () => merged.abort();
|
|
597
|
+
internalSignal.addEventListener('abort', onAbort, { once: true });
|
|
598
|
+
externalSignal.addEventListener('abort', onAbort, { once: true });
|
|
599
|
+
return merged.signal;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
getStats() {
|
|
603
|
+
const namespaces = {};
|
|
604
|
+
for (const [name, ns] of this._namespaces) {
|
|
605
|
+
namespaces[name] = {
|
|
606
|
+
messageCount: ns.messages.length,
|
|
607
|
+
lastActivity: ns.lastActivity,
|
|
608
|
+
hasActiveRequest: !!ns._abortController,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
return { namespaces, config: { ...this._config } };
|
|
612
|
+
}
|
|
613
|
+
}
|