mioku-service-ai 2.0.0
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/index.ts +1271 -0
- package/package.json +23 -0
- package/tsconfig.json +7 -0
- package/types.ts +303 -0
- package/usage/store.ts +751 -0
- package/usage/types.ts +183 -0
package/index.ts
ADDED
|
@@ -0,0 +1,1271 @@
|
|
|
1
|
+
import * as fs from "fs/promises";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { logger } from "mioki";
|
|
4
|
+
import OpenAI from "openai";
|
|
5
|
+
import type {
|
|
6
|
+
ChatCompletionMessageParam,
|
|
7
|
+
ChatCompletionTool,
|
|
8
|
+
} from "openai/resources/chat/completions";
|
|
9
|
+
import type { AITool, AISkill, MiokuService } from "mioku";
|
|
10
|
+
import { createAIUsageStore } from "./usage/store";
|
|
11
|
+
import {
|
|
12
|
+
AssistantMessageResult,
|
|
13
|
+
AIInstance,
|
|
14
|
+
AIService,
|
|
15
|
+
ChatRuntime,
|
|
16
|
+
CompleteOptions,
|
|
17
|
+
CompleteResponse,
|
|
18
|
+
MultimodalMessage,
|
|
19
|
+
SessionToolDefinition,
|
|
20
|
+
TextMessage,
|
|
21
|
+
ToolCallRecord,
|
|
22
|
+
TOOL_RESULT_FOLLOWUP_KEY,
|
|
23
|
+
type ToolResultFollowup,
|
|
24
|
+
} from "./types";
|
|
25
|
+
import type {
|
|
26
|
+
AIUsageCompletionMeta,
|
|
27
|
+
AIUsageContext,
|
|
28
|
+
AIUsageFinalization,
|
|
29
|
+
AIUsageMeasuredTokens,
|
|
30
|
+
AIUsageStore,
|
|
31
|
+
} from "./usage/types";
|
|
32
|
+
|
|
33
|
+
const DEFAULT_CHAT_MODEL = "gemini-3.0-flash-preview";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* AI 实例实现
|
|
37
|
+
*/
|
|
38
|
+
class AIInstanceImpl implements AIInstance {
|
|
39
|
+
private client: OpenAI;
|
|
40
|
+
private prompts: Map<string, string> = new Map();
|
|
41
|
+
private readonly globalSkills: Map<string, AISkill>;
|
|
42
|
+
private readonly usageStore: AIUsageStore;
|
|
43
|
+
private usageContext: AIUsageContext | undefined;
|
|
44
|
+
private readonly defaultModel: string | undefined;
|
|
45
|
+
|
|
46
|
+
constructor(
|
|
47
|
+
apiUrl: string,
|
|
48
|
+
apiKey: string,
|
|
49
|
+
_modelType: "text" | "multimodal",
|
|
50
|
+
defaultModel: string | undefined,
|
|
51
|
+
globalSkills: Map<string, AISkill>,
|
|
52
|
+
usageStore: AIUsageStore,
|
|
53
|
+
) {
|
|
54
|
+
this.client = new OpenAI({
|
|
55
|
+
baseURL: apiUrl,
|
|
56
|
+
apiKey: apiKey,
|
|
57
|
+
});
|
|
58
|
+
this.defaultModel = defaultModel;
|
|
59
|
+
this.globalSkills = globalSkills;
|
|
60
|
+
this.usageStore = usageStore;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async generateText(options: {
|
|
64
|
+
prompt?: string;
|
|
65
|
+
messages: TextMessage[];
|
|
66
|
+
model?: string;
|
|
67
|
+
temperature?: number;
|
|
68
|
+
max_tokens?: number;
|
|
69
|
+
}): Promise<string> {
|
|
70
|
+
const model = await this.resolveModel(options.model);
|
|
71
|
+
const messages: ChatCompletionMessageParam[] = options.prompt
|
|
72
|
+
? [{ role: "system", content: options.prompt }, ...options.messages]
|
|
73
|
+
: [...options.messages];
|
|
74
|
+
|
|
75
|
+
const response = await this.complete({
|
|
76
|
+
model,
|
|
77
|
+
messages,
|
|
78
|
+
temperature: options.temperature,
|
|
79
|
+
max_tokens: options.max_tokens,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return response.content || "";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async generateMultimodal(options: {
|
|
86
|
+
prompt?: string;
|
|
87
|
+
messages: MultimodalMessage[];
|
|
88
|
+
model?: string;
|
|
89
|
+
temperature?: number;
|
|
90
|
+
max_tokens?: number;
|
|
91
|
+
}): Promise<string> {
|
|
92
|
+
const model = await this.resolveModel(options.model);
|
|
93
|
+
const convertedMessages: ChatCompletionMessageParam[] =
|
|
94
|
+
options.messages.map((msg) => {
|
|
95
|
+
if (typeof msg.content === "string") {
|
|
96
|
+
return {
|
|
97
|
+
role: msg.role,
|
|
98
|
+
content: msg.content,
|
|
99
|
+
} as ChatCompletionMessageParam;
|
|
100
|
+
} else {
|
|
101
|
+
return {
|
|
102
|
+
role: msg.role,
|
|
103
|
+
content: msg.content.map((item) => {
|
|
104
|
+
if (item.type === "text") {
|
|
105
|
+
return { type: "text" as const, text: item.text || "" };
|
|
106
|
+
} else {
|
|
107
|
+
return {
|
|
108
|
+
type: "image_url" as const,
|
|
109
|
+
image_url: item.image_url!,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}),
|
|
113
|
+
} as ChatCompletionMessageParam;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const messages: ChatCompletionMessageParam[] = options.prompt
|
|
118
|
+
? [{ role: "system", content: options.prompt }, ...convertedMessages]
|
|
119
|
+
: convertedMessages;
|
|
120
|
+
|
|
121
|
+
const response = await this.complete({
|
|
122
|
+
model,
|
|
123
|
+
messages,
|
|
124
|
+
temperature: options.temperature,
|
|
125
|
+
max_tokens: options.max_tokens,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return response.content || "";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async complete(options: CompleteOptions): Promise<CompleteResponse> {
|
|
132
|
+
const model = await this.resolveModel(options.model);
|
|
133
|
+
const tracker = createUsageTracker({
|
|
134
|
+
model,
|
|
135
|
+
stream: Boolean(options.stream),
|
|
136
|
+
context: options.usageContext ?? this.usageContext,
|
|
137
|
+
startedAt: Date.now(),
|
|
138
|
+
initialMessages: options.messages,
|
|
139
|
+
initialTools: options.tools,
|
|
140
|
+
explicitContextTokens: options.usageContextTokens,
|
|
141
|
+
explicitBreakdown: options.usageBreakdown,
|
|
142
|
+
usageStore: this.usageStore,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const response =
|
|
147
|
+
(options.executableTools && options.executableTools.length > 0) ||
|
|
148
|
+
options.executableToolsProvider
|
|
149
|
+
? await this.completeWithExecutableTools(options, model, tracker)
|
|
150
|
+
: await this.completeOnce(options, model, tracker);
|
|
151
|
+
tracker.finish(true);
|
|
152
|
+
return response;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
tracker.finish(false, String(error));
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
setUsageContext(context: AIUsageContext | undefined): void {
|
|
160
|
+
this.usageContext = context;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async withUsageContext<T>(
|
|
164
|
+
context: AIUsageContext | undefined,
|
|
165
|
+
fn: () => Promise<T>,
|
|
166
|
+
): Promise<T> {
|
|
167
|
+
const previous = this.usageContext;
|
|
168
|
+
this.usageContext = context;
|
|
169
|
+
try {
|
|
170
|
+
return await fn();
|
|
171
|
+
} finally {
|
|
172
|
+
this.usageContext = previous;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async completeOnce(
|
|
177
|
+
options: CompleteOptions,
|
|
178
|
+
model: string,
|
|
179
|
+
tracker: UsageTracker,
|
|
180
|
+
): Promise<CompleteResponse> {
|
|
181
|
+
const assistant = await this.requestAssistantMessage({
|
|
182
|
+
model,
|
|
183
|
+
messages: options.messages,
|
|
184
|
+
tools: options.tools,
|
|
185
|
+
temperature: options.temperature ?? 0.7,
|
|
186
|
+
max_tokens: options.max_tokens,
|
|
187
|
+
stream: options.stream,
|
|
188
|
+
onTextDelta: options.onTextDelta,
|
|
189
|
+
});
|
|
190
|
+
tracker.recordAssistant(assistant);
|
|
191
|
+
if (assistant.usage) {
|
|
192
|
+
tracker.recordMeasuredTokens(assistant.usage);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
content: assistant.content || null,
|
|
197
|
+
reasoning: assistant.reasoning,
|
|
198
|
+
toolCalls: assistant.toolCalls,
|
|
199
|
+
raw: assistant.raw,
|
|
200
|
+
turnMessages: [assistant.raw],
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private async completeWithExecutableTools(
|
|
205
|
+
options: CompleteOptions,
|
|
206
|
+
model: string,
|
|
207
|
+
tracker: UsageTracker,
|
|
208
|
+
): Promise<CompleteResponse> {
|
|
209
|
+
const maxIterations = options.maxIterations ?? 40;
|
|
210
|
+
const allToolCalls: ToolCallRecord[] = [];
|
|
211
|
+
const failedToolCallKeys = new Set<string>();
|
|
212
|
+
const sessionMessages = [...options.messages];
|
|
213
|
+
const turnMessages: ChatCompletionMessageParam[] = [];
|
|
214
|
+
let iterations = 0;
|
|
215
|
+
let content = "";
|
|
216
|
+
let reasoning: string | null = null;
|
|
217
|
+
let raw: ChatCompletionMessageParam = { role: "assistant", content: "" };
|
|
218
|
+
|
|
219
|
+
while (iterations < maxIterations) {
|
|
220
|
+
iterations++;
|
|
221
|
+
const currentDefinitions = options.executableToolsProvider
|
|
222
|
+
? options.executableToolsProvider()
|
|
223
|
+
: (options.executableTools ?? []);
|
|
224
|
+
const toolMap = new Map<string, AITool>();
|
|
225
|
+
const tools: ChatCompletionTool[] = [];
|
|
226
|
+
const followupMessages: ChatCompletionMessageParam[] = [];
|
|
227
|
+
|
|
228
|
+
for (const definition of currentDefinitions) {
|
|
229
|
+
toolMap.set(definition.name, definition.tool);
|
|
230
|
+
tools.push({
|
|
231
|
+
type: "function",
|
|
232
|
+
function: {
|
|
233
|
+
name: definition.name,
|
|
234
|
+
description: definition.tool.description,
|
|
235
|
+
parameters: definition.tool.parameters,
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
tracker.recordToolDefinitions(tools);
|
|
240
|
+
|
|
241
|
+
const assistant = await this.requestAssistantMessage({
|
|
242
|
+
model,
|
|
243
|
+
messages: sessionMessages,
|
|
244
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
245
|
+
temperature: options.temperature ?? 0.7,
|
|
246
|
+
max_tokens: options.max_tokens,
|
|
247
|
+
stream: options.stream,
|
|
248
|
+
onTextDelta: options.onTextDelta,
|
|
249
|
+
});
|
|
250
|
+
tracker.recordAssistant(assistant);
|
|
251
|
+
if (assistant.usage) {
|
|
252
|
+
tracker.recordMeasuredTokens(assistant.usage);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
content = assistant.content;
|
|
256
|
+
reasoning = assistant.reasoning;
|
|
257
|
+
raw = assistant.raw;
|
|
258
|
+
sessionMessages.push(assistant.raw);
|
|
259
|
+
turnMessages.push(assistant.raw);
|
|
260
|
+
|
|
261
|
+
if (assistant.toolCalls.length === 0) {
|
|
262
|
+
return {
|
|
263
|
+
content,
|
|
264
|
+
reasoning,
|
|
265
|
+
toolCalls: [],
|
|
266
|
+
raw,
|
|
267
|
+
iterations,
|
|
268
|
+
allToolCalls,
|
|
269
|
+
turnMessages,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
for (const toolCall of assistant.toolCalls) {
|
|
274
|
+
const toolName = toolCall.name;
|
|
275
|
+
const tool = toolMap.get(toolName);
|
|
276
|
+
const args = parseToolArguments(toolCall.arguments);
|
|
277
|
+
const callKey = buildToolCallKey(toolName, args);
|
|
278
|
+
let result: any;
|
|
279
|
+
|
|
280
|
+
if (!tool) {
|
|
281
|
+
logger.warn(
|
|
282
|
+
`[ai] Tool ${toolName} not found (raw: "${toolName}"). Executable tools: ${[...toolMap.keys()].join(", ") || "(none)"}. Global skills: ${[...this.globalSkills.keys()].join(", ") || "(none)"}`,
|
|
283
|
+
);
|
|
284
|
+
result = { error: `Tool ${toolName} not found` };
|
|
285
|
+
} else if (failedToolCallKeys.has(callKey)) {
|
|
286
|
+
result = {
|
|
287
|
+
success: false,
|
|
288
|
+
error:
|
|
289
|
+
"Tool call skipped: the same tool call with identical arguments already failed in this turn.",
|
|
290
|
+
};
|
|
291
|
+
} else {
|
|
292
|
+
try {
|
|
293
|
+
result = await tool.handler(args);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
logger.error(`Tool ${toolName} execution failed: ${error}`);
|
|
296
|
+
result = { error: String(error) };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const normalizedResult = normalizeToolResult(result);
|
|
301
|
+
|
|
302
|
+
if (isToolErrorResult(normalizedResult.visibleResult)) {
|
|
303
|
+
failedToolCallKeys.add(callKey);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
allToolCalls.push({
|
|
307
|
+
name: toolName,
|
|
308
|
+
arguments: args,
|
|
309
|
+
result: normalizedResult.visibleResult,
|
|
310
|
+
});
|
|
311
|
+
tracker.recordToolCall(toolName);
|
|
312
|
+
|
|
313
|
+
const toolMessage = {
|
|
314
|
+
role: "tool",
|
|
315
|
+
content: JSON.stringify(normalizedResult.visibleResult),
|
|
316
|
+
tool_call_id: toolCall.id,
|
|
317
|
+
} as ChatCompletionMessageParam;
|
|
318
|
+
|
|
319
|
+
sessionMessages.push(toolMessage);
|
|
320
|
+
turnMessages.push(toolMessage);
|
|
321
|
+
tracker.recordMessage(toolMessage);
|
|
322
|
+
followupMessages.push(...normalizedResult.followupMessages);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (followupMessages.length > 0) {
|
|
326
|
+
sessionMessages.push(...followupMessages);
|
|
327
|
+
turnMessages.push(...followupMessages);
|
|
328
|
+
for (const followupMessage of followupMessages) {
|
|
329
|
+
tracker.recordMessage(followupMessage);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
logger.warn(
|
|
335
|
+
`Reached maximum iterations (${maxIterations}) for complete with executable tools`,
|
|
336
|
+
);
|
|
337
|
+
return {
|
|
338
|
+
content: "达到最大迭代次数限制",
|
|
339
|
+
reasoning,
|
|
340
|
+
toolCalls: [],
|
|
341
|
+
raw,
|
|
342
|
+
iterations,
|
|
343
|
+
allToolCalls,
|
|
344
|
+
turnMessages,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private async requestAssistantMessage(args: {
|
|
349
|
+
model: string;
|
|
350
|
+
messages: ChatCompletionMessageParam[];
|
|
351
|
+
tools?: ChatCompletionTool[];
|
|
352
|
+
temperature: number;
|
|
353
|
+
max_tokens?: number;
|
|
354
|
+
stream?: boolean;
|
|
355
|
+
onTextDelta?: (delta: string) => void | Promise<void>;
|
|
356
|
+
}): Promise<AssistantMessageResult> {
|
|
357
|
+
if (args.stream) {
|
|
358
|
+
return this.requestAssistantMessageStream(args);
|
|
359
|
+
}
|
|
360
|
+
return this.requestAssistantMessageNonStream(args);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private async requestAssistantMessageNonStream(args: {
|
|
364
|
+
model: string;
|
|
365
|
+
messages: ChatCompletionMessageParam[];
|
|
366
|
+
tools?: ChatCompletionTool[];
|
|
367
|
+
temperature: number;
|
|
368
|
+
max_tokens?: number;
|
|
369
|
+
}): Promise<AssistantMessageResult> {
|
|
370
|
+
const response = await this.client.chat.completions.create({
|
|
371
|
+
model: args.model,
|
|
372
|
+
messages: args.messages,
|
|
373
|
+
tools: args.tools,
|
|
374
|
+
temperature: args.temperature,
|
|
375
|
+
...(args.max_tokens != null && {
|
|
376
|
+
max_completion_tokens: args.max_tokens,
|
|
377
|
+
}),
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const message = response.choices[0]?.message;
|
|
381
|
+
if (!message) {
|
|
382
|
+
return {
|
|
383
|
+
content: "",
|
|
384
|
+
reasoning: null,
|
|
385
|
+
toolCalls: [],
|
|
386
|
+
raw: { role: "assistant", content: "" },
|
|
387
|
+
usage: extractUsageTokens(response),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const reasoning =
|
|
392
|
+
(message as any).reasoning_content || (message as any).reasoning || null;
|
|
393
|
+
const toolCalls = (message.tool_calls || [])
|
|
394
|
+
.filter((tc) => tc.type === "function")
|
|
395
|
+
.map((tc) => ({
|
|
396
|
+
id: tc.id,
|
|
397
|
+
name: tc.function.name,
|
|
398
|
+
arguments: tc.function.arguments,
|
|
399
|
+
}));
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
content: extractTextContent(message.content),
|
|
403
|
+
reasoning,
|
|
404
|
+
toolCalls,
|
|
405
|
+
raw: message as ChatCompletionMessageParam,
|
|
406
|
+
usage: extractUsageTokens(response),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private async requestAssistantMessageStream(args: {
|
|
411
|
+
model: string;
|
|
412
|
+
messages: ChatCompletionMessageParam[];
|
|
413
|
+
tools?: ChatCompletionTool[];
|
|
414
|
+
temperature: number;
|
|
415
|
+
max_tokens?: number;
|
|
416
|
+
onTextDelta?: (delta: string) => void | Promise<void>;
|
|
417
|
+
}): Promise<AssistantMessageResult> {
|
|
418
|
+
const stream = await this.client.chat.completions.create({
|
|
419
|
+
model: args.model,
|
|
420
|
+
messages: args.messages,
|
|
421
|
+
tools: args.tools,
|
|
422
|
+
temperature: args.temperature,
|
|
423
|
+
stream: true,
|
|
424
|
+
...(args.max_tokens != null && {
|
|
425
|
+
max_completion_tokens: args.max_tokens,
|
|
426
|
+
}),
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
let content = "";
|
|
430
|
+
let reasoning = "";
|
|
431
|
+
let streamUsage: AIUsageMeasuredTokens | undefined;
|
|
432
|
+
const toolCallsByIndex = new Map<
|
|
433
|
+
number,
|
|
434
|
+
{ id: string; name: string; arguments: string }
|
|
435
|
+
>();
|
|
436
|
+
|
|
437
|
+
for await (const chunk of stream as AsyncIterable<any>) {
|
|
438
|
+
const choice = chunk?.choices?.[0];
|
|
439
|
+
const chunkUsage = extractUsageTokens(chunk);
|
|
440
|
+
if (chunkUsage) {
|
|
441
|
+
streamUsage = mergeMeasuredTokens(streamUsage, chunkUsage);
|
|
442
|
+
}
|
|
443
|
+
const delta = choice?.delta;
|
|
444
|
+
if (!delta) continue;
|
|
445
|
+
|
|
446
|
+
const textDelta = extractTextDelta(delta.content);
|
|
447
|
+
if (textDelta) {
|
|
448
|
+
content += textDelta;
|
|
449
|
+
if (args.onTextDelta) {
|
|
450
|
+
await args.onTextDelta(textDelta);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (typeof delta.reasoning_content === "string") {
|
|
455
|
+
reasoning += delta.reasoning_content;
|
|
456
|
+
} else if (typeof delta.reasoning === "string") {
|
|
457
|
+
reasoning += delta.reasoning;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const deltaToolCalls = Array.isArray(delta.tool_calls)
|
|
461
|
+
? delta.tool_calls
|
|
462
|
+
: [];
|
|
463
|
+
for (const item of deltaToolCalls) {
|
|
464
|
+
const index =
|
|
465
|
+
typeof item?.index === "number" && item.index >= 0 ? item.index : 0;
|
|
466
|
+
const acc = toolCallsByIndex.get(index) || {
|
|
467
|
+
id: "",
|
|
468
|
+
name: "",
|
|
469
|
+
arguments: "",
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
if (typeof item?.id === "string" && item.id) {
|
|
473
|
+
acc.id = item.id;
|
|
474
|
+
}
|
|
475
|
+
if (typeof item?.function?.name === "string" && item.function.name) {
|
|
476
|
+
acc.name += item.function.name;
|
|
477
|
+
}
|
|
478
|
+
if (
|
|
479
|
+
typeof item?.function?.arguments === "string" &&
|
|
480
|
+
item.function.arguments
|
|
481
|
+
) {
|
|
482
|
+
acc.arguments += item.function.arguments;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
toolCallsByIndex.set(index, acc);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const toolCalls = Array.from(toolCallsByIndex.entries())
|
|
490
|
+
.sort(([a], [b]) => a - b)
|
|
491
|
+
.map(([index, item]) => ({
|
|
492
|
+
id: item.id || `tool_call_${index}_${Date.now()}`,
|
|
493
|
+
name: item.name,
|
|
494
|
+
arguments: item.arguments || "{}",
|
|
495
|
+
}))
|
|
496
|
+
.filter((item) => item.name);
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
content,
|
|
500
|
+
reasoning: reasoning || null,
|
|
501
|
+
toolCalls,
|
|
502
|
+
raw: buildAssistantRawMessage(content, toolCalls),
|
|
503
|
+
usage: streamUsage,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async generateWithTools(options: {
|
|
508
|
+
prompt?: string;
|
|
509
|
+
messages: TextMessage[] | MultimodalMessage[];
|
|
510
|
+
model?: string;
|
|
511
|
+
temperature?: number;
|
|
512
|
+
maxIterations?: number;
|
|
513
|
+
}): Promise<{
|
|
514
|
+
content: string;
|
|
515
|
+
iterations: number;
|
|
516
|
+
allToolCalls: ToolCallRecord[];
|
|
517
|
+
}> {
|
|
518
|
+
const executableTools: SessionToolDefinition[] = [];
|
|
519
|
+
|
|
520
|
+
for (const [skillName, skill] of this.globalSkills) {
|
|
521
|
+
for (const tool of skill.tools) {
|
|
522
|
+
executableTools.push({
|
|
523
|
+
name: `${skillName}.${tool.name}`,
|
|
524
|
+
tool: {
|
|
525
|
+
...tool,
|
|
526
|
+
description: `[${skillName}] ${tool.description}`,
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
let messages = this.convertMessages(options.messages);
|
|
533
|
+
if (options.prompt) {
|
|
534
|
+
messages = [{ role: "system", content: options.prompt }, ...messages];
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const response = await this.complete({
|
|
538
|
+
model: options.model,
|
|
539
|
+
messages,
|
|
540
|
+
executableTools,
|
|
541
|
+
temperature: options.temperature,
|
|
542
|
+
maxIterations: options.maxIterations,
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
return {
|
|
546
|
+
content: response.content || "",
|
|
547
|
+
iterations: response.iterations ?? 1,
|
|
548
|
+
allToolCalls: response.allToolCalls || [],
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private convertMessages(
|
|
553
|
+
messages: TextMessage[] | MultimodalMessage[],
|
|
554
|
+
): ChatCompletionMessageParam[] {
|
|
555
|
+
if (messages.length === 0) return [];
|
|
556
|
+
|
|
557
|
+
const firstMsg = messages[0];
|
|
558
|
+
if (typeof firstMsg.content === "string") {
|
|
559
|
+
return [...(messages as TextMessage[])];
|
|
560
|
+
} else {
|
|
561
|
+
return (messages as MultimodalMessage[]).map((msg) => {
|
|
562
|
+
if (typeof msg.content === "string") {
|
|
563
|
+
return {
|
|
564
|
+
role: msg.role,
|
|
565
|
+
content: msg.content,
|
|
566
|
+
} as ChatCompletionMessageParam;
|
|
567
|
+
} else {
|
|
568
|
+
return {
|
|
569
|
+
role: msg.role,
|
|
570
|
+
content: msg.content.map((item) => {
|
|
571
|
+
if (item.type === "text") {
|
|
572
|
+
return { type: "text" as const, text: item.text || "" };
|
|
573
|
+
} else {
|
|
574
|
+
return {
|
|
575
|
+
type: "image_url" as const,
|
|
576
|
+
image_url: item.image_url!,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
}),
|
|
580
|
+
} as ChatCompletionMessageParam;
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
registerPrompt(name: string, prompt: string): boolean {
|
|
587
|
+
if (this.prompts.has(name)) {
|
|
588
|
+
logger.warn(`Prompt ${name} already exists, overwriting`);
|
|
589
|
+
}
|
|
590
|
+
this.prompts.set(name, prompt);
|
|
591
|
+
logger.info(`Prompt ${name} registered successfully`);
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
getPrompt(name: string): string | undefined {
|
|
596
|
+
return this.prompts.get(name);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
getAllPrompts(): Record<string, string> {
|
|
600
|
+
const result: Record<string, string> = {};
|
|
601
|
+
for (const [name, prompt] of this.prompts.entries()) {
|
|
602
|
+
result[name] = prompt;
|
|
603
|
+
}
|
|
604
|
+
return result;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
removePrompt(name: string): boolean {
|
|
608
|
+
const deleted = this.prompts.delete(name);
|
|
609
|
+
if (deleted) {
|
|
610
|
+
logger.info(`Prompt ${name} removed`);
|
|
611
|
+
}
|
|
612
|
+
return deleted;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
private async resolveModel(model?: string): Promise<string> {
|
|
616
|
+
const explicitModel = String(model || "").trim();
|
|
617
|
+
if (explicitModel) {
|
|
618
|
+
return explicitModel;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const chatModel = await readChatPrimaryModel();
|
|
622
|
+
return chatModel || DEFAULT_CHAT_MODEL;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function readChatPrimaryModel(): Promise<string | undefined> {
|
|
627
|
+
const configPath = path.join(process.cwd(), "config", "chat", "base.json");
|
|
628
|
+
|
|
629
|
+
try {
|
|
630
|
+
const raw = await fs.readFile(configPath, "utf-8");
|
|
631
|
+
const parsed = JSON.parse(raw);
|
|
632
|
+
const model = String(parsed?.model || "").trim();
|
|
633
|
+
return model || undefined;
|
|
634
|
+
} catch {
|
|
635
|
+
return undefined;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* AI 服务实现
|
|
641
|
+
*/
|
|
642
|
+
class AIServiceImpl implements AIService {
|
|
643
|
+
private instances: Map<string, AIInstance> = new Map();
|
|
644
|
+
private globalSkills: Map<string, AISkill> = new Map();
|
|
645
|
+
private defaultInstanceName: string | null = null;
|
|
646
|
+
private chatRuntime: ChatRuntime | null = null;
|
|
647
|
+
private readonly usageStore: AIUsageStore;
|
|
648
|
+
|
|
649
|
+
constructor(usageStore: AIUsageStore) {
|
|
650
|
+
this.usageStore = usageStore;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async create(options: {
|
|
654
|
+
name: string;
|
|
655
|
+
apiUrl: string;
|
|
656
|
+
apiKey: string;
|
|
657
|
+
modelType: "text" | "multimodal";
|
|
658
|
+
model?: string;
|
|
659
|
+
}): Promise<AIInstance> {
|
|
660
|
+
if (this.instances.has(options.name)) {
|
|
661
|
+
logger.error(`AI instance ${options.name} already exists`);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const instance = new AIInstanceImpl(
|
|
665
|
+
options.apiUrl,
|
|
666
|
+
options.apiKey,
|
|
667
|
+
options.modelType,
|
|
668
|
+
options.model,
|
|
669
|
+
this.globalSkills,
|
|
670
|
+
this.usageStore,
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
this.instances.set(options.name, instance);
|
|
674
|
+
logger.info(`AI instance ${options.name} created successfully`);
|
|
675
|
+
return instance;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
get(name: string): AIInstance | undefined {
|
|
679
|
+
return this.instances.get(name);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
list(): string[] {
|
|
683
|
+
return Array.from(this.instances.keys());
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
remove(name: string): boolean {
|
|
687
|
+
const deleted = this.instances.delete(name);
|
|
688
|
+
if (deleted) {
|
|
689
|
+
if (this.defaultInstanceName === name) {
|
|
690
|
+
this.defaultInstanceName = null;
|
|
691
|
+
}
|
|
692
|
+
logger.info(`AI instance ${name} removed`);
|
|
693
|
+
}
|
|
694
|
+
return deleted;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
setDefault(name: string): boolean {
|
|
698
|
+
if (!this.instances.has(name)) {
|
|
699
|
+
logger.warn(`Cannot set default: AI instance ${name} not found`);
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
this.defaultInstanceName = name;
|
|
703
|
+
logger.info(`Default AI instance set to ${name}`);
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
getDefault(): AIInstance | undefined {
|
|
708
|
+
if (this.defaultInstanceName) {
|
|
709
|
+
return this.instances.get(this.defaultInstanceName);
|
|
710
|
+
}
|
|
711
|
+
return undefined;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
registerChatRuntime(runtime: ChatRuntime): boolean {
|
|
715
|
+
this.chatRuntime = runtime;
|
|
716
|
+
logger.info("Chat runtime registered successfully");
|
|
717
|
+
return true;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
getChatRuntime(): ChatRuntime | undefined {
|
|
721
|
+
return this.chatRuntime ?? undefined;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
removeChatRuntime(): boolean {
|
|
725
|
+
if (!this.chatRuntime) {
|
|
726
|
+
return false;
|
|
727
|
+
}
|
|
728
|
+
this.chatRuntime = null;
|
|
729
|
+
logger.info("Chat runtime removed");
|
|
730
|
+
return true;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
registerSkill(skill: AISkill): boolean {
|
|
734
|
+
if (this.globalSkills.has(skill.name)) {
|
|
735
|
+
logger.warn(`Skill ${skill.name} already exists, overwriting`);
|
|
736
|
+
}
|
|
737
|
+
this.globalSkills.set(skill.name, skill);
|
|
738
|
+
logger.info(
|
|
739
|
+
`Skill ${skill.name} registered with ${skill.tools.length} tools`,
|
|
740
|
+
);
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
getSkill(skillName: string): AISkill | undefined {
|
|
745
|
+
return this.globalSkills.get(skillName);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
getAllSkills(): Map<string, AISkill> {
|
|
749
|
+
return this.globalSkills;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
removeSkill(skillName: string): boolean {
|
|
753
|
+
const deleted = this.globalSkills.delete(skillName);
|
|
754
|
+
if (deleted) {
|
|
755
|
+
logger.info(`Skill ${skillName} removed`);
|
|
756
|
+
}
|
|
757
|
+
return deleted;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
getTool(toolName: string): AITool | undefined {
|
|
761
|
+
// 支持两种格式:skillName.toolName 或 toolName
|
|
762
|
+
const parts = toolName.split(".");
|
|
763
|
+
if (parts.length === 2) {
|
|
764
|
+
const [skillName, toolNameOnly] = parts;
|
|
765
|
+
const skill = this.globalSkills.get(skillName);
|
|
766
|
+
return skill?.tools.find((t) => t.name === toolNameOnly);
|
|
767
|
+
} else {
|
|
768
|
+
// 遍历所有 skills 查找工具
|
|
769
|
+
for (const skill of this.globalSkills.values()) {
|
|
770
|
+
const tool = skill.tools.find((t) => t.name === toolName);
|
|
771
|
+
if (tool) return tool;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return undefined;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
getAllTools(): Map<string, AITool> {
|
|
778
|
+
const allTools = new Map<string, AITool>();
|
|
779
|
+
for (const [skillName, skill] of this.globalSkills) {
|
|
780
|
+
for (const tool of skill.tools) {
|
|
781
|
+
const fullName = `${skillName}.${tool.name}`;
|
|
782
|
+
allTools.set(fullName, tool);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return allTools;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
getUsageSummary(options: Parameters<AIService["getUsageSummary"]>[0]) {
|
|
789
|
+
return this.usageStore.getSummary(options);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
cleanupUsageStats(retentionMs?: number): number {
|
|
793
|
+
return this.usageStore.cleanup(retentionMs);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
finalizeUsage(usageId: string, finalization: AIUsageFinalization): boolean {
|
|
797
|
+
return this.usageStore.updateFinalization(usageId, finalization);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
dispose(): void {
|
|
801
|
+
this.usageStore.close();
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function parseToolArguments(raw: string): any {
|
|
806
|
+
try {
|
|
807
|
+
return JSON.parse(raw || "{}");
|
|
808
|
+
} catch {
|
|
809
|
+
return {};
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
interface UsageTracker {
|
|
814
|
+
recordMessage(message: ChatCompletionMessageParam): void;
|
|
815
|
+
recordAssistant(assistant: AssistantMessageResult): void;
|
|
816
|
+
recordMeasuredTokens(tokens: AIUsageMeasuredTokens): void;
|
|
817
|
+
recordToolDefinitions(tools: ChatCompletionTool[]): void;
|
|
818
|
+
recordToolCall(name: string): void;
|
|
819
|
+
finish(success: boolean, errorMessage?: string): void;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function createUsageTracker(options: {
|
|
823
|
+
model: string;
|
|
824
|
+
stream: boolean;
|
|
825
|
+
context?: AIUsageContext;
|
|
826
|
+
startedAt: number;
|
|
827
|
+
initialMessages: ChatCompletionMessageParam[];
|
|
828
|
+
initialTools?: ChatCompletionTool[];
|
|
829
|
+
explicitContextTokens?: number;
|
|
830
|
+
explicitBreakdown?: AIUsageFinalization["breakdown"];
|
|
831
|
+
usageStore: AIUsageStore;
|
|
832
|
+
}): UsageTracker {
|
|
833
|
+
const messages: AIUsageCompletionMeta["messages"] = [];
|
|
834
|
+
const toolCalls: string[] = [];
|
|
835
|
+
let toolDefinitionTokens = 0;
|
|
836
|
+
let toolUseTokens = 0;
|
|
837
|
+
let measuredTokens: AIUsageMeasuredTokens | undefined;
|
|
838
|
+
let finished = false;
|
|
839
|
+
|
|
840
|
+
const recordMessage = (message: ChatCompletionMessageParam): void => {
|
|
841
|
+
const role = normalizeUsageRole(message.role);
|
|
842
|
+
const contentTokens = estimateMessageContentTokens(message);
|
|
843
|
+
messages.push({ role, contentTokens });
|
|
844
|
+
if (role === "tool") {
|
|
845
|
+
toolUseTokens += contentTokens;
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
for (const message of options.initialMessages) {
|
|
850
|
+
recordMessage(message);
|
|
851
|
+
}
|
|
852
|
+
if (options.initialTools) {
|
|
853
|
+
toolDefinitionTokens += estimateJsonTokens(options.initialTools);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
return {
|
|
857
|
+
recordMessage,
|
|
858
|
+
recordAssistant(assistant): void {
|
|
859
|
+
recordMessage(assistant.raw);
|
|
860
|
+
},
|
|
861
|
+
recordMeasuredTokens(tokens): void {
|
|
862
|
+
measuredTokens = mergeMeasuredTokens(measuredTokens, tokens);
|
|
863
|
+
},
|
|
864
|
+
recordToolDefinitions(tools): void {
|
|
865
|
+
toolDefinitionTokens += estimateJsonTokens(tools);
|
|
866
|
+
},
|
|
867
|
+
recordToolCall(name): void {
|
|
868
|
+
toolCalls.push(name);
|
|
869
|
+
},
|
|
870
|
+
finish(success, errorMessage): void {
|
|
871
|
+
if (finished) return;
|
|
872
|
+
finished = true;
|
|
873
|
+
|
|
874
|
+
const systemPromptTokens = messages
|
|
875
|
+
.filter((message) => message.role === "system")
|
|
876
|
+
.reduce((sum, message) => sum + message.contentTokens, 0);
|
|
877
|
+
const explicitContextTokens =
|
|
878
|
+
typeof options.explicitContextTokens === "number" &&
|
|
879
|
+
Number.isFinite(options.explicitContextTokens)
|
|
880
|
+
? Math.max(0, Math.floor(options.explicitContextTokens))
|
|
881
|
+
: 0;
|
|
882
|
+
const explicitBreakdown = options.explicitBreakdown;
|
|
883
|
+
const outputTokens = messages
|
|
884
|
+
.filter((message) => message.role === "assistant")
|
|
885
|
+
.reduce((sum, message) => sum + message.contentTokens, 0);
|
|
886
|
+
const inputTokens = messages
|
|
887
|
+
.filter((message) => message.role !== "assistant")
|
|
888
|
+
.reduce((sum, message) => sum + message.contentTokens, 0);
|
|
889
|
+
const finalInputTokens = measuredTokens?.inputTokens ?? inputTokens;
|
|
890
|
+
const finalOutputTokens = measuredTokens?.outputTokens ?? outputTokens;
|
|
891
|
+
const finalSystemPromptTokens =
|
|
892
|
+
normalizeUsageBreakdownValue(explicitBreakdown?.systemPromptTokens) ??
|
|
893
|
+
Math.max(0, systemPromptTokens - explicitContextTokens);
|
|
894
|
+
const finalChatHistoryTokens =
|
|
895
|
+
normalizeUsageBreakdownValue(explicitBreakdown?.chatHistoryTokens) ??
|
|
896
|
+
explicitContextTokens;
|
|
897
|
+
const finalToolDefinitionTokens =
|
|
898
|
+
normalizeUsageBreakdownValue(explicitBreakdown?.toolDefinitionTokens) ??
|
|
899
|
+
toolDefinitionTokens;
|
|
900
|
+
const finalToolUseTokens =
|
|
901
|
+
normalizeUsageBreakdownValue(explicitBreakdown?.toolUseTokens) ??
|
|
902
|
+
toolUseTokens;
|
|
903
|
+
const otherContextTokens =
|
|
904
|
+
normalizeUsageBreakdownValue(explicitBreakdown?.otherContextTokens) ??
|
|
905
|
+
Math.max(
|
|
906
|
+
0,
|
|
907
|
+
finalInputTokens -
|
|
908
|
+
finalSystemPromptTokens -
|
|
909
|
+
finalChatHistoryTokens -
|
|
910
|
+
finalToolDefinitionTokens -
|
|
911
|
+
finalToolUseTokens,
|
|
912
|
+
);
|
|
913
|
+
const adjustedMessages =
|
|
914
|
+
explicitContextTokens > 0
|
|
915
|
+
? splitExplicitContextTokens(messages, explicitContextTokens)
|
|
916
|
+
: messages;
|
|
917
|
+
|
|
918
|
+
options.usageStore.record({
|
|
919
|
+
model: options.model,
|
|
920
|
+
stream: options.stream,
|
|
921
|
+
success,
|
|
922
|
+
errorMessage,
|
|
923
|
+
startedAt: options.startedAt,
|
|
924
|
+
endedAt: Date.now(),
|
|
925
|
+
messages: adjustedMessages,
|
|
926
|
+
inputTokens: finalInputTokens,
|
|
927
|
+
outputTokens: finalOutputTokens,
|
|
928
|
+
cacheWriteTokens: measuredTokens?.cacheWriteTokens ?? 0,
|
|
929
|
+
cacheReadTokens: measuredTokens?.cacheReadTokens ?? 0,
|
|
930
|
+
sentUserMessages: 0,
|
|
931
|
+
sentAssistantMessages: 0,
|
|
932
|
+
systemPromptTokens: finalSystemPromptTokens,
|
|
933
|
+
toolDefinitionTokens: finalToolDefinitionTokens,
|
|
934
|
+
toolUseTokens: finalToolUseTokens,
|
|
935
|
+
chatHistoryTokens: finalChatHistoryTokens,
|
|
936
|
+
otherContextTokens,
|
|
937
|
+
toolCalls,
|
|
938
|
+
context: options.context,
|
|
939
|
+
});
|
|
940
|
+
},
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function mergeMeasuredTokens(
|
|
945
|
+
current: AIUsageMeasuredTokens | undefined,
|
|
946
|
+
next: AIUsageMeasuredTokens,
|
|
947
|
+
): AIUsageMeasuredTokens {
|
|
948
|
+
return {
|
|
949
|
+
inputTokens: sumOptional(current?.inputTokens, next.inputTokens),
|
|
950
|
+
outputTokens: sumOptional(current?.outputTokens, next.outputTokens),
|
|
951
|
+
totalTokens: sumOptional(current?.totalTokens, next.totalTokens),
|
|
952
|
+
cacheWriteTokens: sumOptional(
|
|
953
|
+
current?.cacheWriteTokens,
|
|
954
|
+
next.cacheWriteTokens,
|
|
955
|
+
),
|
|
956
|
+
cacheReadTokens: sumOptional(current?.cacheReadTokens, next.cacheReadTokens),
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function sumOptional(a: number | undefined, b: number | undefined): number | undefined {
|
|
961
|
+
if (a == null && b == null) return undefined;
|
|
962
|
+
return (a ?? 0) + (b ?? 0);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function normalizeUsageBreakdownValue(value: number | undefined): number | undefined {
|
|
966
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
967
|
+
return Math.max(0, Math.floor(value));
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function splitExplicitContextTokens(
|
|
971
|
+
messages: AIUsageCompletionMeta["messages"],
|
|
972
|
+
contextTokens: number,
|
|
973
|
+
): AIUsageCompletionMeta["messages"] {
|
|
974
|
+
let remaining = contextTokens;
|
|
975
|
+
return messages.map((message) => {
|
|
976
|
+
if (message.role !== "system" || remaining <= 0) {
|
|
977
|
+
return message;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const moved = Math.min(message.contentTokens, remaining);
|
|
981
|
+
remaining -= moved;
|
|
982
|
+
return {
|
|
983
|
+
...message,
|
|
984
|
+
contentTokens: Math.max(0, message.contentTokens - moved),
|
|
985
|
+
};
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function extractUsageTokens(payload: unknown): AIUsageMeasuredTokens | undefined {
|
|
990
|
+
if (!payload || typeof payload !== "object") return undefined;
|
|
991
|
+
const usage = (payload as Record<string, unknown>).usage;
|
|
992
|
+
if (!usage || typeof usage !== "object") return undefined;
|
|
993
|
+
const usageRecord = usage as Record<string, unknown>;
|
|
994
|
+
const promptDetails = firstObject(
|
|
995
|
+
usageRecord.prompt_tokens_details,
|
|
996
|
+
usageRecord.promptTokensDetails,
|
|
997
|
+
usageRecord.input_tokens_details,
|
|
998
|
+
usageRecord.inputTokensDetails,
|
|
999
|
+
);
|
|
1000
|
+
const completionDetails = firstObject(
|
|
1001
|
+
usageRecord.completion_tokens_details,
|
|
1002
|
+
usageRecord.completionTokensDetails,
|
|
1003
|
+
usageRecord.output_tokens_details,
|
|
1004
|
+
usageRecord.outputTokensDetails,
|
|
1005
|
+
);
|
|
1006
|
+
|
|
1007
|
+
const inputTokens = firstNumber(
|
|
1008
|
+
usageRecord.prompt_tokens,
|
|
1009
|
+
usageRecord.promptTokens,
|
|
1010
|
+
usageRecord.input_tokens,
|
|
1011
|
+
usageRecord.inputTokens,
|
|
1012
|
+
);
|
|
1013
|
+
const outputTokens = firstNumber(
|
|
1014
|
+
usageRecord.completion_tokens,
|
|
1015
|
+
usageRecord.completionTokens,
|
|
1016
|
+
usageRecord.output_tokens,
|
|
1017
|
+
usageRecord.outputTokens,
|
|
1018
|
+
);
|
|
1019
|
+
const cacheReadTokens = firstNumber(
|
|
1020
|
+
promptDetails?.cached_tokens,
|
|
1021
|
+
promptDetails?.cachedTokens,
|
|
1022
|
+
promptDetails?.cache_read_input_tokens,
|
|
1023
|
+
promptDetails?.cacheReadInputTokens,
|
|
1024
|
+
usageRecord.cache_read_input_tokens,
|
|
1025
|
+
usageRecord.cacheReadInputTokens,
|
|
1026
|
+
usageRecord.cached_tokens,
|
|
1027
|
+
usageRecord.cachedTokens,
|
|
1028
|
+
);
|
|
1029
|
+
const cacheWriteTokens = firstNumber(
|
|
1030
|
+
promptDetails?.cache_creation_input_tokens,
|
|
1031
|
+
promptDetails?.cacheCreationInputTokens,
|
|
1032
|
+
promptDetails?.cache_write_input_tokens,
|
|
1033
|
+
promptDetails?.cacheWriteInputTokens,
|
|
1034
|
+
usageRecord.cache_creation_input_tokens,
|
|
1035
|
+
usageRecord.cacheCreationInputTokens,
|
|
1036
|
+
usageRecord.cache_write_input_tokens,
|
|
1037
|
+
usageRecord.cacheWriteInputTokens,
|
|
1038
|
+
);
|
|
1039
|
+
|
|
1040
|
+
return {
|
|
1041
|
+
inputTokens,
|
|
1042
|
+
outputTokens,
|
|
1043
|
+
totalTokens: firstNumber(
|
|
1044
|
+
usageRecord.total_tokens,
|
|
1045
|
+
usageRecord.totalTokens,
|
|
1046
|
+
usageRecord.total,
|
|
1047
|
+
),
|
|
1048
|
+
cacheWriteTokens,
|
|
1049
|
+
cacheReadTokens,
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function firstObject(...values: unknown[]): Record<string, unknown> | undefined {
|
|
1054
|
+
return values.find(
|
|
1055
|
+
(value): value is Record<string, unknown> =>
|
|
1056
|
+
Boolean(value) && typeof value === "object" && !Array.isArray(value),
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function firstNumber(...values: unknown[]): number | undefined {
|
|
1061
|
+
for (const value of values) {
|
|
1062
|
+
const numberValue = typeof value === "number" ? value : Number(value);
|
|
1063
|
+
if (Number.isFinite(numberValue) && numberValue >= 0) {
|
|
1064
|
+
return Math.floor(numberValue);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
return undefined;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function normalizeUsageRole(role: string): "system" | "user" | "assistant" | "tool" {
|
|
1071
|
+
if (
|
|
1072
|
+
role === "system" ||
|
|
1073
|
+
role === "user" ||
|
|
1074
|
+
role === "assistant" ||
|
|
1075
|
+
role === "tool"
|
|
1076
|
+
) {
|
|
1077
|
+
return role;
|
|
1078
|
+
}
|
|
1079
|
+
return "user";
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function estimateMessageContentTokens(message: ChatCompletionMessageParam): number {
|
|
1083
|
+
return estimateContentTokens((message as { content?: unknown }).content);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function estimateContentTokens(content: unknown): number {
|
|
1087
|
+
if (typeof content === "string") {
|
|
1088
|
+
return estimateTextTokens(content);
|
|
1089
|
+
}
|
|
1090
|
+
if (!Array.isArray(content)) {
|
|
1091
|
+
return 0;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
return content.reduce((sum, item) => {
|
|
1095
|
+
if (!item || typeof item !== "object") return sum;
|
|
1096
|
+
const record = item as Record<string, unknown>;
|
|
1097
|
+
if (typeof record.text === "string") {
|
|
1098
|
+
return sum + estimateTextTokens(record.text);
|
|
1099
|
+
}
|
|
1100
|
+
if (record.type === "image_url") {
|
|
1101
|
+
return sum + 85;
|
|
1102
|
+
}
|
|
1103
|
+
return sum + estimateJsonTokens(record);
|
|
1104
|
+
}, 0);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function estimateJsonTokens(value: unknown): number {
|
|
1108
|
+
try {
|
|
1109
|
+
return estimateTextTokens(JSON.stringify(value));
|
|
1110
|
+
} catch {
|
|
1111
|
+
return 0;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function estimateTextTokens(text: string): number {
|
|
1116
|
+
const normalized = text.trim();
|
|
1117
|
+
if (!normalized) return 0;
|
|
1118
|
+
const cjkChars = normalized.match(/[\u3400-\u9fff\u3040-\u30ff]/g)?.length || 0;
|
|
1119
|
+
const latinWords = normalized.match(/[A-Za-z0-9_]+/g)?.length || 0;
|
|
1120
|
+
const symbols = Math.max(0, normalized.length - cjkChars);
|
|
1121
|
+
return Math.max(1, Math.ceil(cjkChars * 0.6 + latinWords * 1.3 + symbols / 6));
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function isToolErrorResult(result: any): boolean {
|
|
1125
|
+
if (!result || typeof result !== "object") return false;
|
|
1126
|
+
if (result.error) return true;
|
|
1127
|
+
return result.success === false;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function normalizeToolResult(result: any): {
|
|
1131
|
+
visibleResult: any;
|
|
1132
|
+
followupMessages: ChatCompletionMessageParam[];
|
|
1133
|
+
} {
|
|
1134
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
1135
|
+
return { visibleResult: result, followupMessages: [] };
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const followup = result[TOOL_RESULT_FOLLOWUP_KEY] as
|
|
1139
|
+
| ToolResultFollowup
|
|
1140
|
+
| undefined;
|
|
1141
|
+
if (
|
|
1142
|
+
!followup ||
|
|
1143
|
+
!Array.isArray(followup.images) ||
|
|
1144
|
+
followup.images.length === 0
|
|
1145
|
+
) {
|
|
1146
|
+
return { visibleResult: result, followupMessages: [] };
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const { [TOOL_RESULT_FOLLOWUP_KEY]: _followup, ...visibleResult } = result;
|
|
1150
|
+
const content = [
|
|
1151
|
+
{
|
|
1152
|
+
type: "text" as const,
|
|
1153
|
+
text: followup.text || "Use the attached image to answer the request.",
|
|
1154
|
+
},
|
|
1155
|
+
...followup.images.map((image) => ({
|
|
1156
|
+
type: "image_url" as const,
|
|
1157
|
+
image_url: {
|
|
1158
|
+
url: image.url,
|
|
1159
|
+
detail: image.detail ?? "auto",
|
|
1160
|
+
},
|
|
1161
|
+
})),
|
|
1162
|
+
];
|
|
1163
|
+
|
|
1164
|
+
return {
|
|
1165
|
+
visibleResult,
|
|
1166
|
+
followupMessages: [
|
|
1167
|
+
{
|
|
1168
|
+
role: "user",
|
|
1169
|
+
content,
|
|
1170
|
+
} as ChatCompletionMessageParam,
|
|
1171
|
+
],
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function buildToolCallKey(name: string, args: any): string {
|
|
1176
|
+
return `${name}:${stableStringify(args ?? {})}`;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function stableStringify(value: any): string {
|
|
1180
|
+
if (value === null || value === undefined) return String(value);
|
|
1181
|
+
if (typeof value !== "object") return JSON.stringify(value);
|
|
1182
|
+
if (Array.isArray(value)) {
|
|
1183
|
+
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const keys = Object.keys(value).sort();
|
|
1187
|
+
const pairs = keys.map(
|
|
1188
|
+
(key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`,
|
|
1189
|
+
);
|
|
1190
|
+
return `{${pairs.join(",")}}`;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function extractTextContent(
|
|
1194
|
+
content: ChatCompletionMessageParam["content"] | null | undefined,
|
|
1195
|
+
): string {
|
|
1196
|
+
if (typeof content === "string") {
|
|
1197
|
+
return content;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
if (!Array.isArray(content)) {
|
|
1201
|
+
return "";
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
return content
|
|
1205
|
+
.filter((part): part is { type: "text"; text: string } => {
|
|
1206
|
+
return Boolean(part && part.type === "text");
|
|
1207
|
+
})
|
|
1208
|
+
.map((part) => part.text)
|
|
1209
|
+
.join("\n")
|
|
1210
|
+
.trim();
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function extractTextDelta(content: any): string {
|
|
1214
|
+
if (typeof content === "string") {
|
|
1215
|
+
return content;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
if (!Array.isArray(content)) {
|
|
1219
|
+
return "";
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
return content
|
|
1223
|
+
.map((part) => {
|
|
1224
|
+
if (!part || part.type !== "text") return "";
|
|
1225
|
+
return typeof part.text === "string" ? part.text : "";
|
|
1226
|
+
})
|
|
1227
|
+
.join("");
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function buildAssistantRawMessage(
|
|
1231
|
+
content: string,
|
|
1232
|
+
toolCalls: Array<{ id: string; name: string; arguments: string }>,
|
|
1233
|
+
): ChatCompletionMessageParam {
|
|
1234
|
+
if (toolCalls.length === 0) {
|
|
1235
|
+
return { role: "assistant", content };
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
return {
|
|
1239
|
+
role: "assistant",
|
|
1240
|
+
content,
|
|
1241
|
+
tool_calls: toolCalls.map((toolCall) => ({
|
|
1242
|
+
id: toolCall.id,
|
|
1243
|
+
type: "function" as const,
|
|
1244
|
+
function: {
|
|
1245
|
+
name: toolCall.name,
|
|
1246
|
+
arguments: toolCall.arguments,
|
|
1247
|
+
},
|
|
1248
|
+
})),
|
|
1249
|
+
} as ChatCompletionMessageParam;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
const aiService: MiokuService = {
|
|
1253
|
+
name: "ai",
|
|
1254
|
+
version: "1.0.0",
|
|
1255
|
+
description:
|
|
1256
|
+
"为插件提供完整的ai服务支持,包括ai实例管理,提示词管理,skills管理等",
|
|
1257
|
+
api: {} as AIService,
|
|
1258
|
+
|
|
1259
|
+
async init() {
|
|
1260
|
+
this.api = new AIServiceImpl(createAIUsageStore());
|
|
1261
|
+
logger.info("ai-service 服务已就绪");
|
|
1262
|
+
},
|
|
1263
|
+
|
|
1264
|
+
async dispose() {
|
|
1265
|
+
const api = this.api as AIService & { dispose?: () => void };
|
|
1266
|
+
api.dispose?.();
|
|
1267
|
+
logger.info("ai-service 已卸载");
|
|
1268
|
+
},
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
export default aiService;
|