openclaw-server 0.1.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/package.json +29 -0
- package/packs/default/faq.yaml +8 -0
- package/packs/default/intents.yaml +19 -0
- package/packs/default/pack.yaml +12 -0
- package/packs/default/policies.yaml +1 -0
- package/packs/default/scenarios.yaml +1 -0
- package/packs/default/synonyms.yaml +1 -0
- package/packs/default/templates.yaml +16 -0
- package/packs/default/tools.yaml +1 -0
- package/readme.md +1219 -0
- package/src/auth.ts +24 -0
- package/src/better-sqlite3.d.ts +17 -0
- package/src/config.ts +63 -0
- package/src/core/matcher.ts +214 -0
- package/src/core/normalizer.test.ts +37 -0
- package/src/core/normalizer.ts +183 -0
- package/src/core/pack-loader.ts +97 -0
- package/src/core/reply-engine.test.ts +76 -0
- package/src/core/reply-engine.ts +256 -0
- package/src/core/request-adapter.ts +65 -0
- package/src/core/session-store.ts +48 -0
- package/src/core/stream-renderer.ts +237 -0
- package/src/core/tool-engine.ts +60 -0
- package/src/debug-log.ts +211 -0
- package/src/index.ts +23 -0
- package/src/openai.ts +79 -0
- package/src/response-api.ts +107 -0
- package/src/routes/admin.ts +32 -0
- package/src/routes/chat-completions.ts +173 -0
- package/src/routes/health.ts +7 -0
- package/src/routes/models.ts +21 -0
- package/src/routes/request-validation.ts +33 -0
- package/src/routes/responses.ts +182 -0
- package/src/routes/tasks.ts +138 -0
- package/src/runtime-stats.ts +80 -0
- package/src/server.test.ts +776 -0
- package/src/server.ts +108 -0
- package/src/tasks/chat-integration.ts +70 -0
- package/src/tasks/service.ts +320 -0
- package/src/tasks/store.test.ts +183 -0
- package/src/tasks/store.ts +602 -0
- package/src/tasks/time-parser.test.ts +94 -0
- package/src/tasks/time-parser.ts +610 -0
- package/src/tasks/timezone.ts +171 -0
- package/src/tasks/types.ts +128 -0
- package/src/types.ts +202 -0
- package/src/weather/chat-integration.ts +56 -0
- package/src/weather/location-catalog.ts +166 -0
- package/src/weather/open-meteo-provider.ts +221 -0
- package/src/weather/parser.test.ts +23 -0
- package/src/weather/parser.ts +102 -0
- package/src/weather/service.test.ts +54 -0
- package/src/weather/service.ts +188 -0
- package/src/weather/types.ts +56 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
3
|
+
import { normalizeFreeText } from "./normalizer.js";
|
|
4
|
+
import { loadPack } from "./pack-loader.js";
|
|
5
|
+
import { ReplyEngine } from "./reply-engine.js";
|
|
6
|
+
import { SessionStore } from "./session-store.js";
|
|
7
|
+
|
|
8
|
+
describe("ReplyEngine FAQ matching", () => {
|
|
9
|
+
let engine: ReplyEngine;
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
const packDir = fileURLToPath(new URL("../../packs/default", import.meta.url));
|
|
13
|
+
const pack = await loadPack(packDir);
|
|
14
|
+
engine = new ReplyEngine(pack, new SessionStore());
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("matches faq prompts across spacing and punctuation variants", () => {
|
|
18
|
+
for (const prompt of [
|
|
19
|
+
"\u4f60\u662f\u771f AI \u5417",
|
|
20
|
+
"\u4f60\u662f\u771f AI \u5417\uff1f",
|
|
21
|
+
"\u4f60\u662f\u771fAI\u5417",
|
|
22
|
+
"Are you a real AI?",
|
|
23
|
+
]) {
|
|
24
|
+
const result = engine.respond({
|
|
25
|
+
sessionId: `faq-test:${prompt}`,
|
|
26
|
+
locale: "mixed",
|
|
27
|
+
model: "default-assistant",
|
|
28
|
+
stream: false,
|
|
29
|
+
systemPrompts: [],
|
|
30
|
+
history: [{ role: "user", text: prompt }],
|
|
31
|
+
userText: prompt,
|
|
32
|
+
normalizedUserText: normalizeFreeText(prompt),
|
|
33
|
+
tools: [],
|
|
34
|
+
toolChoice: undefined,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(result.templateId).toBe("faq.real-ai");
|
|
38
|
+
expect(result.text).toContain("\u4e0d\u4f1a\u8c03\u7528\u771f\u5b9e AI API");
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("matches fuzzy intent wording without overmatching unrelated prompts", () => {
|
|
43
|
+
const capabilitiesResult = engine.respond({
|
|
44
|
+
sessionId: "intent-test:capabilities",
|
|
45
|
+
locale: "zh-CN",
|
|
46
|
+
model: "default-assistant",
|
|
47
|
+
stream: false,
|
|
48
|
+
systemPrompts: [],
|
|
49
|
+
history: [{ role: "user", text: "你会做什么" }],
|
|
50
|
+
userText: "你会做什么",
|
|
51
|
+
normalizedUserText: normalizeFreeText("你会做什么"),
|
|
52
|
+
tools: [],
|
|
53
|
+
toolChoice: undefined,
|
|
54
|
+
});
|
|
55
|
+
expect(capabilitiesResult.templateId).toBe("capabilities.help");
|
|
56
|
+
expect(capabilitiesResult.text).toContain("任务和提醒");
|
|
57
|
+
expect(capabilitiesResult.text).toContain("天气查询");
|
|
58
|
+
|
|
59
|
+
const fallbackResult = engine.respond({
|
|
60
|
+
sessionId: "intent-test:fallback",
|
|
61
|
+
locale: "zh-CN",
|
|
62
|
+
model: "default-assistant",
|
|
63
|
+
stream: false,
|
|
64
|
+
systemPrompts: [],
|
|
65
|
+
history: [{ role: "user", text: "随便说点啥" }],
|
|
66
|
+
userText: "随便说点啥",
|
|
67
|
+
normalizedUserText: normalizeFreeText("随便说点啥"),
|
|
68
|
+
tools: [],
|
|
69
|
+
toolChoice: undefined,
|
|
70
|
+
});
|
|
71
|
+
expect(fallbackResult.templateId).toBe("fallback.default");
|
|
72
|
+
expect(fallbackResult.text).toContain("我有哪些任务");
|
|
73
|
+
expect(fallbackResult.text).toContain("天津明天天气如何");
|
|
74
|
+
expect(fallbackResult.text).not.toContain("网关日志");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EngineResult,
|
|
3
|
+
LoadedPack,
|
|
4
|
+
NormalizedTurn,
|
|
5
|
+
ScenarioDefinition,
|
|
6
|
+
ScenarioNodeDefinition,
|
|
7
|
+
SessionState,
|
|
8
|
+
} from "../types.js";
|
|
9
|
+
import { compileFaqs, compileIntents, findBestFaq, findBestIntent } from "./matcher.js";
|
|
10
|
+
import { normalizeFreeText } from "./normalizer.js";
|
|
11
|
+
import { SessionStore } from "./session-store.js";
|
|
12
|
+
import { selectToolCall } from "./tool-engine.js";
|
|
13
|
+
|
|
14
|
+
function renderTemplate(text: string, context: Record<string, string>): string {
|
|
15
|
+
return text.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (_match, key: string) => context[key] ?? "");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function chooseTemplateVariant(variants: string[], seed: string): string {
|
|
19
|
+
if (variants.length === 1) {
|
|
20
|
+
return variants[0];
|
|
21
|
+
}
|
|
22
|
+
let sum = 0;
|
|
23
|
+
for (const char of seed) {
|
|
24
|
+
sum += char.charCodeAt(0);
|
|
25
|
+
}
|
|
26
|
+
return variants[sum % variants.length];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getTemplateText(
|
|
30
|
+
pack: LoadedPack,
|
|
31
|
+
templateId: string,
|
|
32
|
+
seed: string,
|
|
33
|
+
context: Record<string, string>,
|
|
34
|
+
): string {
|
|
35
|
+
const template = pack.templatesById.get(templateId);
|
|
36
|
+
if (!template) {
|
|
37
|
+
throw new Error(`Template not found: ${templateId}`);
|
|
38
|
+
}
|
|
39
|
+
return renderTemplate(chooseTemplateVariant(template.variants, seed), context).trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildContext(
|
|
43
|
+
turn: NormalizedTurn,
|
|
44
|
+
session: SessionState,
|
|
45
|
+
matchedIntentId?: string,
|
|
46
|
+
): Record<string, string> {
|
|
47
|
+
return {
|
|
48
|
+
locale: turn.locale,
|
|
49
|
+
modelId: turn.model,
|
|
50
|
+
sessionId: session.sessionId,
|
|
51
|
+
matchedIntent: matchedIntentId ?? "",
|
|
52
|
+
userText: turn.userText,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function nextNodeForScenario(
|
|
57
|
+
scenario: ScenarioDefinition,
|
|
58
|
+
currentNode: ScenarioNodeDefinition,
|
|
59
|
+
): ScenarioNodeDefinition | undefined {
|
|
60
|
+
if (currentNode.nextId) {
|
|
61
|
+
return scenario.nodes.find((node) => node.id === currentNode.nextId);
|
|
62
|
+
}
|
|
63
|
+
const index = scenario.nodes.findIndex((node) => node.id === currentNode.id);
|
|
64
|
+
if (index < 0) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
return scenario.nodes[index + 1];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class ReplyEngine {
|
|
71
|
+
private readonly compiledIntents: ReturnType<typeof compileIntents>;
|
|
72
|
+
private readonly compiledFaqs: ReturnType<typeof compileFaqs>;
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
private readonly pack: LoadedPack,
|
|
76
|
+
private readonly sessionStore: SessionStore,
|
|
77
|
+
) {
|
|
78
|
+
this.compiledIntents = compileIntents(pack.intents);
|
|
79
|
+
this.compiledFaqs = compileFaqs(pack.faqs);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
respond(turn: NormalizedTurn): EngineResult {
|
|
83
|
+
const session = this.sessionStore.get(turn.sessionId, turn.locale);
|
|
84
|
+
session.turnCount += 1;
|
|
85
|
+
|
|
86
|
+
const continued = this.tryContinueScenario(turn, session);
|
|
87
|
+
if (continued) {
|
|
88
|
+
return continued;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const matchedFaq = findBestFaq(this.compiledFaqs, turn.userText);
|
|
92
|
+
if (matchedFaq) {
|
|
93
|
+
return this.replyWithTemplate(turn, session, matchedFaq.responseTemplateId, matchedFaq.id);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const matchedIntent = findBestIntent(this.compiledIntents, turn.userText);
|
|
97
|
+
if (!matchedIntent) {
|
|
98
|
+
return this.replyWithTemplate(turn, session, this.pack.manifest.fallbackTemplateId);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
session.lastIntentId = matchedIntent.id;
|
|
102
|
+
|
|
103
|
+
const toolCall = selectToolCall({
|
|
104
|
+
turn,
|
|
105
|
+
intent: matchedIntent,
|
|
106
|
+
pack: this.pack,
|
|
107
|
+
});
|
|
108
|
+
if (toolCall) {
|
|
109
|
+
this.sessionStore.clearScenario(session);
|
|
110
|
+
this.sessionStore.save(session, {
|
|
111
|
+
kind: "tool_call",
|
|
112
|
+
matchedIntentId: matchedIntent.id,
|
|
113
|
+
toolName: toolCall.function.name,
|
|
114
|
+
});
|
|
115
|
+
return {
|
|
116
|
+
model: turn.model,
|
|
117
|
+
sessionId: turn.sessionId,
|
|
118
|
+
text: null,
|
|
119
|
+
finishReason: "tool_calls",
|
|
120
|
+
matchedIntentId: matchedIntent.id,
|
|
121
|
+
toolCalls: [toolCall],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (matchedIntent.scenarioId) {
|
|
126
|
+
const scenario = this.pack.scenariosById.get(matchedIntent.scenarioId);
|
|
127
|
+
if (scenario) {
|
|
128
|
+
return this.replyFromScenarioNode(
|
|
129
|
+
turn,
|
|
130
|
+
session,
|
|
131
|
+
scenario,
|
|
132
|
+
scenario.nodes[0],
|
|
133
|
+
matchedIntent.id,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const templateId = matchedIntent.responseTemplateId ?? this.pack.manifest.fallbackTemplateId;
|
|
139
|
+
return this.replyWithTemplate(turn, session, templateId, matchedIntent.id);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private tryContinueScenario(
|
|
143
|
+
turn: NormalizedTurn,
|
|
144
|
+
session: SessionState,
|
|
145
|
+
): EngineResult | undefined {
|
|
146
|
+
if (!session.activeScenarioId || !session.activeNodeId) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const scenario = this.pack.scenariosById.get(session.activeScenarioId);
|
|
151
|
+
if (!scenario) {
|
|
152
|
+
this.sessionStore.clearScenario(session);
|
|
153
|
+
this.sessionStore.save(session, { kind: "scenario_missing" });
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const currentNode = scenario.nodes.find((node) => node.id === session.activeNodeId);
|
|
158
|
+
if (!currentNode) {
|
|
159
|
+
this.sessionStore.clearScenario(session);
|
|
160
|
+
this.sessionStore.save(session, { kind: "scenario_node_missing", scenarioId: scenario.id });
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const continueKeywords = new Set(
|
|
165
|
+
[...this.pack.manifest.continueKeywords, ...currentNode.continueKeywords].map((item) =>
|
|
166
|
+
normalizeFreeText(item),
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
if (!continueKeywords.has(turn.normalizedUserText)) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const nextNode = nextNodeForScenario(scenario, currentNode);
|
|
174
|
+
if (!nextNode) {
|
|
175
|
+
this.sessionStore.clearScenario(session);
|
|
176
|
+
this.sessionStore.save(session, { kind: "scenario_completed", scenarioId: scenario.id });
|
|
177
|
+
return this.replyWithTemplate(
|
|
178
|
+
turn,
|
|
179
|
+
session,
|
|
180
|
+
this.pack.manifest.fallbackTemplateId,
|
|
181
|
+
session.lastIntentId,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return this.replyFromScenarioNode(turn, session, scenario, nextNode, session.lastIntentId);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private replyFromScenarioNode(
|
|
189
|
+
turn: NormalizedTurn,
|
|
190
|
+
session: SessionState,
|
|
191
|
+
scenario: ScenarioDefinition,
|
|
192
|
+
node: ScenarioNodeDefinition,
|
|
193
|
+
matchedIntentId?: string,
|
|
194
|
+
): EngineResult {
|
|
195
|
+
const text = getTemplateText(
|
|
196
|
+
this.pack,
|
|
197
|
+
node.responseTemplateId,
|
|
198
|
+
`${session.sessionId}:${node.responseTemplateId}:${session.turnCount}`,
|
|
199
|
+
buildContext(turn, session, matchedIntentId),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
session.activeScenarioId = scenario.id;
|
|
203
|
+
session.activeNodeId = node.id;
|
|
204
|
+
session.lastTemplateId = node.responseTemplateId;
|
|
205
|
+
|
|
206
|
+
if (node.end || !nextNodeForScenario(scenario, node)) {
|
|
207
|
+
this.sessionStore.clearScenario(session);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this.sessionStore.save(session, {
|
|
211
|
+
kind: "scenario_reply",
|
|
212
|
+
scenarioId: scenario.id,
|
|
213
|
+
nodeId: node.id,
|
|
214
|
+
matchedIntentId,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
model: turn.model,
|
|
219
|
+
sessionId: turn.sessionId,
|
|
220
|
+
text,
|
|
221
|
+
finishReason: "stop",
|
|
222
|
+
matchedIntentId,
|
|
223
|
+
templateId: node.responseTemplateId,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private replyWithTemplate(
|
|
228
|
+
turn: NormalizedTurn,
|
|
229
|
+
session: SessionState,
|
|
230
|
+
templateId: string,
|
|
231
|
+
matchedIntentId?: string,
|
|
232
|
+
): EngineResult {
|
|
233
|
+
const text = getTemplateText(
|
|
234
|
+
this.pack,
|
|
235
|
+
templateId,
|
|
236
|
+
`${session.sessionId}:${templateId}:${session.turnCount}`,
|
|
237
|
+
buildContext(turn, session, matchedIntentId),
|
|
238
|
+
);
|
|
239
|
+
session.lastTemplateId = templateId;
|
|
240
|
+
this.sessionStore.clearScenario(session);
|
|
241
|
+
this.sessionStore.save(session, {
|
|
242
|
+
kind: "text_reply",
|
|
243
|
+
matchedIntentId,
|
|
244
|
+
templateId,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
model: turn.model,
|
|
249
|
+
sessionId: turn.sessionId,
|
|
250
|
+
text,
|
|
251
|
+
finishReason: "stop",
|
|
252
|
+
matchedIntentId,
|
|
253
|
+
templateId,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChatCompletionsRequest,
|
|
3
|
+
ChatMessage,
|
|
4
|
+
ResponsesInputItem,
|
|
5
|
+
ResponsesRequest,
|
|
6
|
+
} from "../types.js";
|
|
7
|
+
|
|
8
|
+
function appendMessage(messages: ChatMessage[], role: string, content: unknown): void {
|
|
9
|
+
if (typeof content === "string") {
|
|
10
|
+
const text = content.trim();
|
|
11
|
+
if (!text) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
messages.push({ role, content: text });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(content) && content.length === 0) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (content === undefined || content === null) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
messages.push({ role, content });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function appendResponsesInput(messages: ChatMessage[], item: ResponsesInputItem): void {
|
|
27
|
+
if (typeof item === "string") {
|
|
28
|
+
appendMessage(messages, "user", item);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (item.type === "input_text") {
|
|
33
|
+
appendMessage(messages, "user", item.text);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const role = typeof item.role === "string" && item.role.trim() ? item.role.trim() : "user";
|
|
38
|
+
appendMessage(messages, role, item.content ?? item.text ?? "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeResponsesInput(input: ResponsesRequest["input"]): ResponsesInputItem[] {
|
|
42
|
+
return Array.isArray(input) ? input : [input];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function responseRequestToChatRequest(request: ResponsesRequest): ChatCompletionsRequest {
|
|
46
|
+
const messages: ChatMessage[] = [];
|
|
47
|
+
const instructions = request.instructions?.trim();
|
|
48
|
+
if (instructions) {
|
|
49
|
+
messages.push({ role: "system", content: instructions });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const item of normalizeResponsesInput(request.input)) {
|
|
53
|
+
appendResponsesInput(messages, item);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
model: request.model,
|
|
58
|
+
stream: request.stream,
|
|
59
|
+
user: request.user,
|
|
60
|
+
messages,
|
|
61
|
+
tools: request.tools,
|
|
62
|
+
tool_choice: request.tool_choice,
|
|
63
|
+
max_tokens: request.max_output_tokens,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { SessionState } from "../types.js";
|
|
4
|
+
|
|
5
|
+
export class SessionStore {
|
|
6
|
+
private readonly sessions = new Map<string, SessionState>();
|
|
7
|
+
private logWrite = Promise.resolve();
|
|
8
|
+
|
|
9
|
+
constructor(private readonly sessionLogPath?: string) {}
|
|
10
|
+
|
|
11
|
+
get(sessionId: string, locale: string): SessionState {
|
|
12
|
+
const existing = this.sessions.get(sessionId);
|
|
13
|
+
if (existing) {
|
|
14
|
+
return existing;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const created: SessionState = {
|
|
18
|
+
sessionId,
|
|
19
|
+
locale,
|
|
20
|
+
turnCount: 0,
|
|
21
|
+
};
|
|
22
|
+
this.sessions.set(sessionId, created);
|
|
23
|
+
return created;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
size(): number {
|
|
27
|
+
return this.sessions.size;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
save(state: SessionState, event?: Record<string, unknown>): void {
|
|
31
|
+
this.sessions.set(state.sessionId, state);
|
|
32
|
+
if (!this.sessionLogPath) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const payload = JSON.stringify({ ts: Date.now(), ...event, state }) + "\n";
|
|
37
|
+
this.logWrite = this.logWrite.then(async () => {
|
|
38
|
+
const dir = path.dirname(this.sessionLogPath as string);
|
|
39
|
+
await fs.mkdir(dir, { recursive: true });
|
|
40
|
+
await fs.appendFile(this.sessionLogPath as string, payload, "utf8");
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
clearScenario(state: SessionState): void {
|
|
45
|
+
delete state.activeScenarioId;
|
|
46
|
+
delete state.activeNodeId;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { Response } from "express";
|
|
2
|
+
import {
|
|
3
|
+
buildResponseFunctionCallItem,
|
|
4
|
+
buildResponsesInProgressResponse,
|
|
5
|
+
buildResponsesResponse,
|
|
6
|
+
} from "../response-api.js";
|
|
7
|
+
import type { ChatToolCall, EngineResult } from "../types.js";
|
|
8
|
+
|
|
9
|
+
function delay(ms: number): Promise<void> {
|
|
10
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function writeChunk(res: Response, payload: Record<string, unknown>): void {
|
|
14
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function writeEvent(res: Response, event: string, payload: Record<string, unknown>): void {
|
|
18
|
+
res.write(`event: ${event}\n`);
|
|
19
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeDone(res: Response): void {
|
|
23
|
+
res.write("data: [DONE]\n\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function splitIntoChunks(text: string, chunkChars: number): string[] {
|
|
27
|
+
const chunks: string[] = [];
|
|
28
|
+
let buffer = "";
|
|
29
|
+
|
|
30
|
+
for (const char of text) {
|
|
31
|
+
buffer += char;
|
|
32
|
+
if (buffer.length < chunkChars && !/[.!?,;:,。!?;:\n]/u.test(char)) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
chunks.push(buffer);
|
|
36
|
+
buffer = "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (buffer) {
|
|
40
|
+
chunks.push(buffer);
|
|
41
|
+
}
|
|
42
|
+
return chunks.length > 0 ? chunks : [text];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function setSseHeaders(res: Response): void {
|
|
46
|
+
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
47
|
+
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
48
|
+
res.setHeader("Connection", "keep-alive");
|
|
49
|
+
res.flushHeaders();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function streamTextCompletion(params: {
|
|
53
|
+
res: Response;
|
|
54
|
+
id: string;
|
|
55
|
+
model: string;
|
|
56
|
+
text: string;
|
|
57
|
+
initialDelayMs: number;
|
|
58
|
+
chunkChars: number;
|
|
59
|
+
}): Promise<void> {
|
|
60
|
+
setSseHeaders(params.res);
|
|
61
|
+
writeChunk(params.res, {
|
|
62
|
+
id: params.id,
|
|
63
|
+
object: "chat.completion.chunk",
|
|
64
|
+
created: Math.floor(Date.now() / 1000),
|
|
65
|
+
model: params.model,
|
|
66
|
+
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await delay(params.initialDelayMs);
|
|
70
|
+
|
|
71
|
+
for (const chunk of splitIntoChunks(params.text, params.chunkChars)) {
|
|
72
|
+
writeChunk(params.res, {
|
|
73
|
+
id: params.id,
|
|
74
|
+
object: "chat.completion.chunk",
|
|
75
|
+
created: Math.floor(Date.now() / 1000),
|
|
76
|
+
model: params.model,
|
|
77
|
+
choices: [{ index: 0, delta: { content: chunk }, finish_reason: null }],
|
|
78
|
+
});
|
|
79
|
+
await delay(30);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
writeChunk(params.res, {
|
|
83
|
+
id: params.id,
|
|
84
|
+
object: "chat.completion.chunk",
|
|
85
|
+
created: Math.floor(Date.now() / 1000),
|
|
86
|
+
model: params.model,
|
|
87
|
+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
88
|
+
});
|
|
89
|
+
writeDone(params.res);
|
|
90
|
+
params.res.end();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function streamToolCallCompletion(params: {
|
|
94
|
+
res: Response;
|
|
95
|
+
id: string;
|
|
96
|
+
model: string;
|
|
97
|
+
toolCall: ChatToolCall;
|
|
98
|
+
}): Promise<void> {
|
|
99
|
+
setSseHeaders(params.res);
|
|
100
|
+
writeChunk(params.res, {
|
|
101
|
+
id: params.id,
|
|
102
|
+
object: "chat.completion.chunk",
|
|
103
|
+
created: Math.floor(Date.now() / 1000),
|
|
104
|
+
model: params.model,
|
|
105
|
+
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
writeChunk(params.res, {
|
|
109
|
+
id: params.id,
|
|
110
|
+
object: "chat.completion.chunk",
|
|
111
|
+
created: Math.floor(Date.now() / 1000),
|
|
112
|
+
model: params.model,
|
|
113
|
+
choices: [
|
|
114
|
+
{
|
|
115
|
+
index: 0,
|
|
116
|
+
delta: {
|
|
117
|
+
tool_calls: [
|
|
118
|
+
{
|
|
119
|
+
index: 0,
|
|
120
|
+
id: params.toolCall.id,
|
|
121
|
+
type: params.toolCall.type,
|
|
122
|
+
function: params.toolCall.function,
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
finish_reason: null,
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
writeChunk(params.res, {
|
|
132
|
+
id: params.id,
|
|
133
|
+
object: "chat.completion.chunk",
|
|
134
|
+
created: Math.floor(Date.now() / 1000),
|
|
135
|
+
model: params.model,
|
|
136
|
+
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
|
|
137
|
+
});
|
|
138
|
+
writeDone(params.res);
|
|
139
|
+
params.res.end();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function streamResponsesText(params: {
|
|
143
|
+
res: Response;
|
|
144
|
+
id: string;
|
|
145
|
+
result: EngineResult;
|
|
146
|
+
outputItemId: string;
|
|
147
|
+
initialDelayMs: number;
|
|
148
|
+
chunkChars: number;
|
|
149
|
+
}): Promise<void> {
|
|
150
|
+
const createdAt = Math.floor(Date.now() / 1000);
|
|
151
|
+
const text = params.result.text ?? "No response available.";
|
|
152
|
+
const finalResponse = buildResponsesResponse({
|
|
153
|
+
id: params.id,
|
|
154
|
+
result: params.result,
|
|
155
|
+
createdAt,
|
|
156
|
+
outputItemId: params.outputItemId,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
setSseHeaders(params.res);
|
|
160
|
+
writeEvent(params.res, "response.created", {
|
|
161
|
+
type: "response.created",
|
|
162
|
+
response: buildResponsesInProgressResponse({
|
|
163
|
+
id: params.id,
|
|
164
|
+
model: params.result.model,
|
|
165
|
+
createdAt,
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await delay(params.initialDelayMs);
|
|
170
|
+
|
|
171
|
+
for (const chunk of splitIntoChunks(text, params.chunkChars)) {
|
|
172
|
+
writeEvent(params.res, "response.output_text.delta", {
|
|
173
|
+
type: "response.output_text.delta",
|
|
174
|
+
response_id: params.id,
|
|
175
|
+
item_id: params.outputItemId,
|
|
176
|
+
output_index: 0,
|
|
177
|
+
content_index: 0,
|
|
178
|
+
delta: chunk,
|
|
179
|
+
});
|
|
180
|
+
await delay(30);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
writeEvent(params.res, "response.output_text.done", {
|
|
184
|
+
type: "response.output_text.done",
|
|
185
|
+
response_id: params.id,
|
|
186
|
+
item_id: params.outputItemId,
|
|
187
|
+
output_index: 0,
|
|
188
|
+
content_index: 0,
|
|
189
|
+
text,
|
|
190
|
+
});
|
|
191
|
+
writeEvent(params.res, "response.completed", {
|
|
192
|
+
type: "response.completed",
|
|
193
|
+
response: finalResponse,
|
|
194
|
+
});
|
|
195
|
+
params.res.end();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function streamResponsesToolCall(params: {
|
|
199
|
+
res: Response;
|
|
200
|
+
id: string;
|
|
201
|
+
result: EngineResult;
|
|
202
|
+
outputItemId: string;
|
|
203
|
+
}): Promise<void> {
|
|
204
|
+
const toolCall = params.result.toolCalls?.[0];
|
|
205
|
+
if (!toolCall) {
|
|
206
|
+
throw new Error("Missing tool call for Responses API stream.");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const createdAt = Math.floor(Date.now() / 1000);
|
|
210
|
+
const finalResponse = buildResponsesResponse({
|
|
211
|
+
id: params.id,
|
|
212
|
+
result: params.result,
|
|
213
|
+
createdAt,
|
|
214
|
+
outputItemId: params.outputItemId,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
setSseHeaders(params.res);
|
|
218
|
+
writeEvent(params.res, "response.created", {
|
|
219
|
+
type: "response.created",
|
|
220
|
+
response: buildResponsesInProgressResponse({
|
|
221
|
+
id: params.id,
|
|
222
|
+
model: params.result.model,
|
|
223
|
+
createdAt,
|
|
224
|
+
}),
|
|
225
|
+
});
|
|
226
|
+
writeEvent(params.res, "response.output_item.added", {
|
|
227
|
+
type: "response.output_item.added",
|
|
228
|
+
response_id: params.id,
|
|
229
|
+
output_index: 0,
|
|
230
|
+
item: buildResponseFunctionCallItem(toolCall, params.outputItemId),
|
|
231
|
+
});
|
|
232
|
+
writeEvent(params.res, "response.completed", {
|
|
233
|
+
type: "response.completed",
|
|
234
|
+
response: finalResponse,
|
|
235
|
+
});
|
|
236
|
+
params.res.end();
|
|
237
|
+
}
|