@kognitivedev/adapter-chat-local 0.2.29
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/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-test.log +11 -0
- package/CHANGELOG.md +11 -0
- package/README.md +5 -0
- package/dist/__tests__/local-backend.test.d.ts +1 -0
- package/dist/__tests__/local-backend.test.js +54 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +266 -0
- package/package.json +38 -0
- package/src/__tests__/local-backend.test.ts +61 -0
- package/src/index.ts +332 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
$ vitest run
|
|
2
|
+
|
|
3
|
+
RUN v3.2.4 /Users/vserifsaglam/work/memory-experiment/packages/adapter-chat-local
|
|
4
|
+
|
|
5
|
+
✓ src/__tests__/local-backend.test.ts (3 tests) 6ms
|
|
6
|
+
|
|
7
|
+
Test Files 1 passed (1)
|
|
8
|
+
Tests 3 passed (3)
|
|
9
|
+
Start at 17:30:04
|
|
10
|
+
Duration 529ms (transform 58ms, setup 0ms, collect 64ms, tests 6ms, environment 0ms, prepare 152ms)
|
|
11
|
+
|
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const index_1 = require("../index");
|
|
5
|
+
(0, vitest_1.describe)("createLocalChatBackend", () => {
|
|
6
|
+
(0, vitest_1.it)("manages thread state in memory", async () => {
|
|
7
|
+
const backend = (0, index_1.createLocalChatBackend)();
|
|
8
|
+
const threadClient = backend.createThreadClient({ agentName: "assistant" });
|
|
9
|
+
const created = await threadClient.create({ title: "Prototype" });
|
|
10
|
+
const listed = await threadClient.list();
|
|
11
|
+
const detail = await threadClient.get(created.sessionId);
|
|
12
|
+
(0, vitest_1.expect)(created.title).toBe("Prototype");
|
|
13
|
+
(0, vitest_1.expect)(listed.total).toBe(1);
|
|
14
|
+
(0, vitest_1.expect)(detail.session.sessionId).toBe(created.sessionId);
|
|
15
|
+
});
|
|
16
|
+
(0, vitest_1.it)("persists assistant messages from the default responder", async () => {
|
|
17
|
+
const backend = (0, index_1.createLocalChatBackend)();
|
|
18
|
+
const executionClient = backend.createExecutionClient({ agentName: "assistant" });
|
|
19
|
+
const threadClient = backend.createThreadClient({ agentName: "assistant" });
|
|
20
|
+
await executionClient.stream({
|
|
21
|
+
sessionId: "s1",
|
|
22
|
+
messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "Hello" }] }],
|
|
23
|
+
onEvent: async () => { },
|
|
24
|
+
});
|
|
25
|
+
const detail = await threadClient.get("s1");
|
|
26
|
+
(0, vitest_1.expect)(detail.messages).toHaveLength(2);
|
|
27
|
+
(0, vitest_1.expect)(detail.messages[1]).toMatchObject({
|
|
28
|
+
role: "assistant",
|
|
29
|
+
content: [{ type: "text", text: "Local adapter reply: Hello" }],
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
(0, vitest_1.it)("supports custom streaming responders", async () => {
|
|
33
|
+
const onEvent = vitest_1.vi.fn(async () => { });
|
|
34
|
+
const backend = (0, index_1.createLocalChatBackend)({
|
|
35
|
+
stream: async ({ emit }) => {
|
|
36
|
+
await emit("debug", { step: "started" });
|
|
37
|
+
await emit("custom", {
|
|
38
|
+
role: "assistant",
|
|
39
|
+
content: [{ type: "text", text: "Custom reply" }],
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
await backend.createExecutionClient({ agentName: "assistant" }).stream({
|
|
44
|
+
sessionId: "s1",
|
|
45
|
+
messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "Hello" }] }],
|
|
46
|
+
onEvent,
|
|
47
|
+
});
|
|
48
|
+
(0, vitest_1.expect)(onEvent).toHaveBeenCalledWith("debug", { step: "started" });
|
|
49
|
+
(0, vitest_1.expect)(onEvent).toHaveBeenCalledWith("custom", {
|
|
50
|
+
role: "assistant",
|
|
51
|
+
content: [{ type: "text", text: "Custom reply" }],
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { KognitiveMessage, KognitiveUIMessage } from "@kognitivedev/shared";
|
|
2
|
+
import type { ChatBackendAdapter, ChatBackendContext, ChatBackendEventSink, ThreadSummary } from "@kognitivedev/ui";
|
|
3
|
+
export interface LocalChatBackendOptions {
|
|
4
|
+
initialThreads?: ThreadSummary[];
|
|
5
|
+
initialMessagesBySessionId?: Record<string, KognitiveMessage[]>;
|
|
6
|
+
idGenerator?: () => string;
|
|
7
|
+
now?: () => Date;
|
|
8
|
+
respond?: (input: {
|
|
9
|
+
messages: KognitiveUIMessage[];
|
|
10
|
+
sessionId?: string;
|
|
11
|
+
context: ChatBackendContext;
|
|
12
|
+
}) => Promise<KognitiveMessage> | KognitiveMessage;
|
|
13
|
+
stream?: (input: {
|
|
14
|
+
messages: KognitiveUIMessage[];
|
|
15
|
+
sessionId?: string;
|
|
16
|
+
context: ChatBackendContext;
|
|
17
|
+
emit: ChatBackendEventSink;
|
|
18
|
+
}) => Promise<KognitiveMessage | void> | KognitiveMessage | void;
|
|
19
|
+
}
|
|
20
|
+
export declare function createLocalChatBackend(options?: LocalChatBackendOptions): ChatBackendAdapter;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createLocalChatBackend = createLocalChatBackend;
|
|
4
|
+
function defaultId() {
|
|
5
|
+
return crypto.randomUUID();
|
|
6
|
+
}
|
|
7
|
+
function defaultNow() {
|
|
8
|
+
return new Date();
|
|
9
|
+
}
|
|
10
|
+
function getMessageText(message) {
|
|
11
|
+
return message.parts
|
|
12
|
+
.filter((part) => part.type === "text")
|
|
13
|
+
.map((part) => part.text)
|
|
14
|
+
.join(" ")
|
|
15
|
+
.trim();
|
|
16
|
+
}
|
|
17
|
+
function toRuntimeMessage(message) {
|
|
18
|
+
return {
|
|
19
|
+
role: message.role,
|
|
20
|
+
content: message.parts,
|
|
21
|
+
metadata: message.metadata,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function truncate(value, limit = 72) {
|
|
25
|
+
const normalized = value.trim();
|
|
26
|
+
if (normalized.length <= limit)
|
|
27
|
+
return normalized;
|
|
28
|
+
return `${normalized.slice(0, limit - 1)}…`;
|
|
29
|
+
}
|
|
30
|
+
function textParts(parts) {
|
|
31
|
+
return parts
|
|
32
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
33
|
+
.map((part) => part.text)
|
|
34
|
+
.join(" ");
|
|
35
|
+
}
|
|
36
|
+
function defaultAssistantReply(messages) {
|
|
37
|
+
const lastUserMessage = [...messages].reverse().find((message) => message.role === "user");
|
|
38
|
+
const text = lastUserMessage ? getMessageText(lastUserMessage) : "";
|
|
39
|
+
return {
|
|
40
|
+
role: "assistant",
|
|
41
|
+
content: [{
|
|
42
|
+
type: "text",
|
|
43
|
+
text: text
|
|
44
|
+
? `Local adapter reply: ${text}`
|
|
45
|
+
: "Local adapter reply ready.",
|
|
46
|
+
}],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function getDurableMessageId(message) {
|
|
50
|
+
const value = message.metadata?.kognitiveMessageId;
|
|
51
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
52
|
+
}
|
|
53
|
+
function withDurableMessageMetadata(message, fallbackId, feedback) {
|
|
54
|
+
const messageId = getDurableMessageId(message) ?? fallbackId;
|
|
55
|
+
const metadata = (message.metadata ?? {});
|
|
56
|
+
const { feedback: _ignoredFeedback, ...restMetadata } = metadata;
|
|
57
|
+
return {
|
|
58
|
+
...message,
|
|
59
|
+
metadata: {
|
|
60
|
+
...restMetadata,
|
|
61
|
+
kognitiveMessageId: messageId,
|
|
62
|
+
...(feedback ? { feedback } : {}),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function createSummary(now, id, title = "", metadata = null) {
|
|
67
|
+
return {
|
|
68
|
+
sessionDbId: `local-${id}`,
|
|
69
|
+
sessionId: id,
|
|
70
|
+
title,
|
|
71
|
+
status: "idle",
|
|
72
|
+
updatedAt: now.toISOString(),
|
|
73
|
+
messageCount: 0,
|
|
74
|
+
lastUserPreview: "",
|
|
75
|
+
lastAssistantPreview: "",
|
|
76
|
+
lastError: null,
|
|
77
|
+
lastTraceDbId: null,
|
|
78
|
+
metadata,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function createDetail(record) {
|
|
82
|
+
return {
|
|
83
|
+
session: {
|
|
84
|
+
id: record.summary.sessionDbId,
|
|
85
|
+
sessionId: record.summary.sessionId,
|
|
86
|
+
messageCount: record.summary.messageCount,
|
|
87
|
+
metadata: record.summary.metadata ?? null,
|
|
88
|
+
},
|
|
89
|
+
messages: record.messages.map((message) => withDurableMessageMetadata(message, crypto.randomUUID(), (() => {
|
|
90
|
+
const messageId = getDurableMessageId(message);
|
|
91
|
+
return messageId ? record.feedbackByMessageId.get(messageId) : undefined;
|
|
92
|
+
})())),
|
|
93
|
+
events: [],
|
|
94
|
+
traces: [],
|
|
95
|
+
runs: [],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function createLocalChatBackend(options = {}) {
|
|
99
|
+
const makeId = options.idGenerator ?? defaultId;
|
|
100
|
+
const now = options.now ?? defaultNow;
|
|
101
|
+
const sessions = new Map();
|
|
102
|
+
for (const thread of options.initialThreads ?? []) {
|
|
103
|
+
sessions.set(thread.sessionId, {
|
|
104
|
+
summary: thread,
|
|
105
|
+
messages: (options.initialMessagesBySessionId?.[thread.sessionId] ?? []).map((message) => withDurableMessageMetadata(message, makeId())),
|
|
106
|
+
feedbackByMessageId: new Map(),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const ensureSession = (sessionId, input) => {
|
|
110
|
+
const id = sessionId ?? input?.sessionId ?? makeId();
|
|
111
|
+
const existing = sessions.get(id);
|
|
112
|
+
if (existing)
|
|
113
|
+
return existing;
|
|
114
|
+
const record = {
|
|
115
|
+
summary: createSummary(now(), id, input?.title ?? "", input?.metadata ?? null),
|
|
116
|
+
messages: [],
|
|
117
|
+
feedbackByMessageId: new Map(),
|
|
118
|
+
};
|
|
119
|
+
sessions.set(id, record);
|
|
120
|
+
return record;
|
|
121
|
+
};
|
|
122
|
+
const updateSummaryFromMessages = (record) => {
|
|
123
|
+
const userMessages = record.messages.filter((message) => message.role === "user");
|
|
124
|
+
const assistantMessages = record.messages.filter((message) => message.role === "assistant");
|
|
125
|
+
const latestUser = userMessages.at(-1);
|
|
126
|
+
const latestAssistant = assistantMessages.at(-1);
|
|
127
|
+
const latestUserText = typeof latestUser?.content === "string"
|
|
128
|
+
? latestUser.content
|
|
129
|
+
: Array.isArray(latestUser?.content)
|
|
130
|
+
? textParts(latestUser.content)
|
|
131
|
+
: "";
|
|
132
|
+
const latestAssistantText = typeof latestAssistant?.content === "string"
|
|
133
|
+
? latestAssistant.content
|
|
134
|
+
: Array.isArray(latestAssistant?.content)
|
|
135
|
+
? textParts(latestAssistant.content)
|
|
136
|
+
: "";
|
|
137
|
+
record.summary = {
|
|
138
|
+
...record.summary,
|
|
139
|
+
title: record.summary.title || truncate(latestUserText, 48) || "New chat",
|
|
140
|
+
updatedAt: now().toISOString(),
|
|
141
|
+
messageCount: record.messages.length,
|
|
142
|
+
lastUserPreview: truncate(latestUserText),
|
|
143
|
+
lastAssistantPreview: truncate(latestAssistantText),
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
const threadManager = {
|
|
147
|
+
async list(query) {
|
|
148
|
+
const all = [...sessions.values()]
|
|
149
|
+
.map((record) => record.summary)
|
|
150
|
+
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime());
|
|
151
|
+
const offset = query?.offset ?? 0;
|
|
152
|
+
const limit = query?.limit ?? all.length;
|
|
153
|
+
return {
|
|
154
|
+
threads: all.slice(offset, offset + limit),
|
|
155
|
+
total: all.length,
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
async get(sessionId) {
|
|
159
|
+
const record = sessions.get(sessionId);
|
|
160
|
+
if (!record) {
|
|
161
|
+
throw new Error(`Thread "${sessionId}" not found.`);
|
|
162
|
+
}
|
|
163
|
+
return createDetail(record);
|
|
164
|
+
},
|
|
165
|
+
async create(input) {
|
|
166
|
+
const record = ensureSession(undefined, input);
|
|
167
|
+
updateSummaryFromMessages(record);
|
|
168
|
+
return record.summary;
|
|
169
|
+
},
|
|
170
|
+
async delete(sessionId) {
|
|
171
|
+
sessions.delete(sessionId);
|
|
172
|
+
},
|
|
173
|
+
async rename(sessionId, title) {
|
|
174
|
+
const record = ensureSession(sessionId);
|
|
175
|
+
record.summary = {
|
|
176
|
+
...record.summary,
|
|
177
|
+
title,
|
|
178
|
+
updatedAt: now().toISOString(),
|
|
179
|
+
};
|
|
180
|
+
return record.summary;
|
|
181
|
+
},
|
|
182
|
+
async archive(sessionId) {
|
|
183
|
+
const record = ensureSession(sessionId);
|
|
184
|
+
record.summary = {
|
|
185
|
+
...record.summary,
|
|
186
|
+
status: "archived",
|
|
187
|
+
updatedAt: now().toISOString(),
|
|
188
|
+
};
|
|
189
|
+
return record.summary;
|
|
190
|
+
},
|
|
191
|
+
async unarchive(sessionId) {
|
|
192
|
+
const record = ensureSession(sessionId);
|
|
193
|
+
record.summary = {
|
|
194
|
+
...record.summary,
|
|
195
|
+
status: "idle",
|
|
196
|
+
updatedAt: now().toISOString(),
|
|
197
|
+
};
|
|
198
|
+
return record.summary;
|
|
199
|
+
},
|
|
200
|
+
async setMessageFeedback(sessionId, messageId, value) {
|
|
201
|
+
const record = ensureSession(sessionId);
|
|
202
|
+
if (value) {
|
|
203
|
+
record.feedbackByMessageId.set(messageId, value);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
record.feedbackByMessageId.delete(messageId);
|
|
207
|
+
}
|
|
208
|
+
record.messages = record.messages.map((message) => {
|
|
209
|
+
if (getDurableMessageId(message) !== messageId)
|
|
210
|
+
return message;
|
|
211
|
+
return withDurableMessageMetadata(message, messageId, value ?? undefined);
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
return {
|
|
216
|
+
capabilities: {
|
|
217
|
+
threads: true,
|
|
218
|
+
threadDelete: true,
|
|
219
|
+
threadRename: true,
|
|
220
|
+
threadArchive: true,
|
|
221
|
+
messageFeedback: true,
|
|
222
|
+
},
|
|
223
|
+
createExecutionClient(context) {
|
|
224
|
+
return {
|
|
225
|
+
async stream(request) {
|
|
226
|
+
const record = ensureSession(request.sessionId);
|
|
227
|
+
record.messages = request.messages
|
|
228
|
+
.map(toRuntimeMessage)
|
|
229
|
+
.map((message) => withDurableMessageMetadata(message, makeId(), (() => {
|
|
230
|
+
const messageId = getDurableMessageId(message);
|
|
231
|
+
return messageId ? record.feedbackByMessageId.get(messageId) : undefined;
|
|
232
|
+
})()));
|
|
233
|
+
let assistantMessage;
|
|
234
|
+
if (options.stream) {
|
|
235
|
+
const streamedMessages = [];
|
|
236
|
+
const result = await options.stream({
|
|
237
|
+
messages: request.messages,
|
|
238
|
+
sessionId: request.sessionId,
|
|
239
|
+
context,
|
|
240
|
+
emit: async (eventName, data) => {
|
|
241
|
+
if (eventName === "custom" && data && typeof data === "object" && "role" in data) {
|
|
242
|
+
streamedMessages.push(withDurableMessageMetadata(data, makeId()));
|
|
243
|
+
}
|
|
244
|
+
await request.onEvent(eventName, data);
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
assistantMessage = result ? withDurableMessageMetadata(result, makeId()) : streamedMessages.at(-1);
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
assistantMessage = withDurableMessageMetadata(options.respond
|
|
251
|
+
? await options.respond({ messages: request.messages, sessionId: request.sessionId, context })
|
|
252
|
+
: defaultAssistantReply(request.messages), makeId());
|
|
253
|
+
await request.onEvent("custom", assistantMessage);
|
|
254
|
+
}
|
|
255
|
+
if (assistantMessage) {
|
|
256
|
+
record.messages = [...record.messages, assistantMessage];
|
|
257
|
+
updateSummaryFromMessages(record);
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
},
|
|
262
|
+
createThreadClient() {
|
|
263
|
+
return threadManager;
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kognitivedev/adapter-chat-local",
|
|
3
|
+
"version": "0.2.29",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"types": "dist/index.d.ts",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsc -w --noCheck",
|
|
12
|
+
"prepublishOnly": "npm run build",
|
|
13
|
+
"test": "vitest run"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@kognitivedev/shared": "^0.2.29",
|
|
17
|
+
"@kognitivedev/ui": "^0.2.29"
|
|
18
|
+
},
|
|
19
|
+
"description": "In-memory local chat backend adapter for @kognitivedev/ui",
|
|
20
|
+
"keywords": [
|
|
21
|
+
"kognitive",
|
|
22
|
+
"chat",
|
|
23
|
+
"local",
|
|
24
|
+
"adapter"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/kognitivedev/kognitive",
|
|
30
|
+
"directory": "packages/adapter-chat-local"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://kognitive.dev",
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^20.0.0",
|
|
35
|
+
"typescript": "^5.0.0",
|
|
36
|
+
"vitest": "^3.0.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createLocalChatBackend } from "../index";
|
|
3
|
+
|
|
4
|
+
describe("createLocalChatBackend", () => {
|
|
5
|
+
it("manages thread state in memory", async () => {
|
|
6
|
+
const backend = createLocalChatBackend();
|
|
7
|
+
const threadClient = backend.createThreadClient!({ agentName: "assistant" })!;
|
|
8
|
+
|
|
9
|
+
const created = await threadClient.create({ title: "Prototype" });
|
|
10
|
+
const listed = await threadClient.list();
|
|
11
|
+
const detail = await threadClient.get(created.sessionId);
|
|
12
|
+
|
|
13
|
+
expect(created.title).toBe("Prototype");
|
|
14
|
+
expect(listed.total).toBe(1);
|
|
15
|
+
expect(detail.session.sessionId).toBe(created.sessionId);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("persists assistant messages from the default responder", async () => {
|
|
19
|
+
const backend = createLocalChatBackend();
|
|
20
|
+
const executionClient = backend.createExecutionClient({ agentName: "assistant" });
|
|
21
|
+
const threadClient = backend.createThreadClient!({ agentName: "assistant" })!;
|
|
22
|
+
|
|
23
|
+
await executionClient.stream({
|
|
24
|
+
sessionId: "s1",
|
|
25
|
+
messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "Hello" }] }],
|
|
26
|
+
onEvent: async () => {},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const detail = await threadClient.get("s1");
|
|
30
|
+
expect(detail.messages).toHaveLength(2);
|
|
31
|
+
expect(detail.messages[1]).toMatchObject({
|
|
32
|
+
role: "assistant",
|
|
33
|
+
content: [{ type: "text", text: "Local adapter reply: Hello" }],
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("supports custom streaming responders", async () => {
|
|
38
|
+
const onEvent = vi.fn(async () => {});
|
|
39
|
+
const backend = createLocalChatBackend({
|
|
40
|
+
stream: async ({ emit }) => {
|
|
41
|
+
await emit("debug", { step: "started" });
|
|
42
|
+
await emit("custom", {
|
|
43
|
+
role: "assistant",
|
|
44
|
+
content: [{ type: "text", text: "Custom reply" }],
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await backend.createExecutionClient({ agentName: "assistant" }).stream({
|
|
50
|
+
sessionId: "s1",
|
|
51
|
+
messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "Hello" }] }],
|
|
52
|
+
onEvent,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(onEvent).toHaveBeenCalledWith("debug", { step: "started" });
|
|
56
|
+
expect(onEvent).toHaveBeenCalledWith("custom", {
|
|
57
|
+
role: "assistant",
|
|
58
|
+
content: [{ type: "text", text: "Custom reply" }],
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import type { KognitiveContentPart, KognitiveMessage, KognitiveUIMessage } from "@kognitivedev/shared";
|
|
2
|
+
import type {
|
|
3
|
+
ChatBackendAdapter,
|
|
4
|
+
ChatBackendContext,
|
|
5
|
+
ChatBackendEventSink,
|
|
6
|
+
ChatThreadClient,
|
|
7
|
+
ThreadCreateInput,
|
|
8
|
+
ThreadDetail,
|
|
9
|
+
ThreadMetadata,
|
|
10
|
+
ThreadSummary,
|
|
11
|
+
} from "@kognitivedev/ui";
|
|
12
|
+
|
|
13
|
+
export interface LocalChatBackendOptions {
|
|
14
|
+
initialThreads?: ThreadSummary[];
|
|
15
|
+
initialMessagesBySessionId?: Record<string, KognitiveMessage[]>;
|
|
16
|
+
idGenerator?: () => string;
|
|
17
|
+
now?: () => Date;
|
|
18
|
+
respond?: (input: {
|
|
19
|
+
messages: KognitiveUIMessage[];
|
|
20
|
+
sessionId?: string;
|
|
21
|
+
context: ChatBackendContext;
|
|
22
|
+
}) => Promise<KognitiveMessage> | KognitiveMessage;
|
|
23
|
+
stream?: (input: {
|
|
24
|
+
messages: KognitiveUIMessage[];
|
|
25
|
+
sessionId?: string;
|
|
26
|
+
context: ChatBackendContext;
|
|
27
|
+
emit: ChatBackendEventSink;
|
|
28
|
+
}) => Promise<KognitiveMessage | void> | KognitiveMessage | void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface LocalSessionRecord {
|
|
32
|
+
summary: ThreadSummary;
|
|
33
|
+
messages: KognitiveMessage[];
|
|
34
|
+
feedbackByMessageId: Map<string, "positive" | "negative">;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function defaultId() {
|
|
38
|
+
return crypto.randomUUID();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function defaultNow() {
|
|
42
|
+
return new Date();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getMessageText(message: KognitiveUIMessage): string {
|
|
46
|
+
return message.parts
|
|
47
|
+
.filter((part): part is Extract<KognitiveUIMessage["parts"][number], { type: "text" }> => part.type === "text")
|
|
48
|
+
.map((part) => part.text)
|
|
49
|
+
.join(" ")
|
|
50
|
+
.trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toRuntimeMessage(message: KognitiveUIMessage): KognitiveMessage {
|
|
54
|
+
return {
|
|
55
|
+
role: message.role,
|
|
56
|
+
content: message.parts,
|
|
57
|
+
metadata: message.metadata,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function truncate(value: string, limit = 72) {
|
|
62
|
+
const normalized = value.trim();
|
|
63
|
+
if (normalized.length <= limit) return normalized;
|
|
64
|
+
return `${normalized.slice(0, limit - 1)}…`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function textParts(parts: readonly KognitiveContentPart[]) {
|
|
68
|
+
return parts
|
|
69
|
+
.filter((part): part is Extract<KognitiveContentPart, { type: "text" }> => part.type === "text" && typeof part.text === "string")
|
|
70
|
+
.map((part) => part.text)
|
|
71
|
+
.join(" ");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function defaultAssistantReply(messages: KognitiveUIMessage[]): KognitiveMessage {
|
|
75
|
+
const lastUserMessage = [...messages].reverse().find((message) => message.role === "user");
|
|
76
|
+
const text = lastUserMessage ? getMessageText(lastUserMessage) : "";
|
|
77
|
+
return {
|
|
78
|
+
role: "assistant",
|
|
79
|
+
content: [{
|
|
80
|
+
type: "text",
|
|
81
|
+
text: text
|
|
82
|
+
? `Local adapter reply: ${text}`
|
|
83
|
+
: "Local adapter reply ready.",
|
|
84
|
+
}],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getDurableMessageId(message: Pick<KognitiveMessage, "metadata">): string | null {
|
|
89
|
+
const value = message.metadata?.kognitiveMessageId;
|
|
90
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function withDurableMessageMetadata(
|
|
94
|
+
message: KognitiveMessage,
|
|
95
|
+
fallbackId: string,
|
|
96
|
+
feedback?: "positive" | "negative",
|
|
97
|
+
): KognitiveMessage {
|
|
98
|
+
const messageId = getDurableMessageId(message) ?? fallbackId;
|
|
99
|
+
const metadata = (message.metadata ?? {}) as Record<string, unknown>;
|
|
100
|
+
const { feedback: _ignoredFeedback, ...restMetadata } = metadata;
|
|
101
|
+
return {
|
|
102
|
+
...message,
|
|
103
|
+
metadata: {
|
|
104
|
+
...restMetadata,
|
|
105
|
+
kognitiveMessageId: messageId,
|
|
106
|
+
...(feedback ? { feedback } : {}),
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function createSummary(now: Date, id: string, title = "", metadata: ThreadMetadata | null = null): ThreadSummary {
|
|
112
|
+
return {
|
|
113
|
+
sessionDbId: `local-${id}`,
|
|
114
|
+
sessionId: id,
|
|
115
|
+
title,
|
|
116
|
+
status: "idle",
|
|
117
|
+
updatedAt: now.toISOString(),
|
|
118
|
+
messageCount: 0,
|
|
119
|
+
lastUserPreview: "",
|
|
120
|
+
lastAssistantPreview: "",
|
|
121
|
+
lastError: null,
|
|
122
|
+
lastTraceDbId: null,
|
|
123
|
+
metadata,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function createDetail(record: LocalSessionRecord): ThreadDetail {
|
|
128
|
+
return {
|
|
129
|
+
session: {
|
|
130
|
+
id: record.summary.sessionDbId,
|
|
131
|
+
sessionId: record.summary.sessionId,
|
|
132
|
+
messageCount: record.summary.messageCount,
|
|
133
|
+
metadata: record.summary.metadata ?? null,
|
|
134
|
+
},
|
|
135
|
+
messages: record.messages.map((message) =>
|
|
136
|
+
withDurableMessageMetadata(
|
|
137
|
+
message,
|
|
138
|
+
crypto.randomUUID(),
|
|
139
|
+
(() => {
|
|
140
|
+
const messageId = getDurableMessageId(message);
|
|
141
|
+
return messageId ? record.feedbackByMessageId.get(messageId) : undefined;
|
|
142
|
+
})(),
|
|
143
|
+
),
|
|
144
|
+
),
|
|
145
|
+
events: [],
|
|
146
|
+
traces: [],
|
|
147
|
+
runs: [],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function createLocalChatBackend(options: LocalChatBackendOptions = {}): ChatBackendAdapter {
|
|
152
|
+
const makeId = options.idGenerator ?? defaultId;
|
|
153
|
+
const now = options.now ?? defaultNow;
|
|
154
|
+
const sessions = new Map<string, LocalSessionRecord>();
|
|
155
|
+
|
|
156
|
+
for (const thread of options.initialThreads ?? []) {
|
|
157
|
+
sessions.set(thread.sessionId, {
|
|
158
|
+
summary: thread,
|
|
159
|
+
messages: (options.initialMessagesBySessionId?.[thread.sessionId] ?? []).map((message) =>
|
|
160
|
+
withDurableMessageMetadata(message, makeId()),
|
|
161
|
+
),
|
|
162
|
+
feedbackByMessageId: new Map(),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const ensureSession = (sessionId?: string, input?: ThreadCreateInput) => {
|
|
167
|
+
const id = sessionId ?? input?.sessionId ?? makeId();
|
|
168
|
+
const existing = sessions.get(id);
|
|
169
|
+
if (existing) return existing;
|
|
170
|
+
const record: LocalSessionRecord = {
|
|
171
|
+
summary: createSummary(now(), id, input?.title ?? "", input?.metadata ?? null),
|
|
172
|
+
messages: [],
|
|
173
|
+
feedbackByMessageId: new Map(),
|
|
174
|
+
};
|
|
175
|
+
sessions.set(id, record);
|
|
176
|
+
return record;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const updateSummaryFromMessages = (record: LocalSessionRecord) => {
|
|
180
|
+
const userMessages = record.messages.filter((message) => message.role === "user");
|
|
181
|
+
const assistantMessages = record.messages.filter((message) => message.role === "assistant");
|
|
182
|
+
const latestUser = userMessages.at(-1);
|
|
183
|
+
const latestAssistant = assistantMessages.at(-1);
|
|
184
|
+
const latestUserText = typeof latestUser?.content === "string"
|
|
185
|
+
? latestUser.content
|
|
186
|
+
: Array.isArray(latestUser?.content)
|
|
187
|
+
? textParts(latestUser.content)
|
|
188
|
+
: "";
|
|
189
|
+
const latestAssistantText = typeof latestAssistant?.content === "string"
|
|
190
|
+
? latestAssistant.content
|
|
191
|
+
: Array.isArray(latestAssistant?.content)
|
|
192
|
+
? textParts(latestAssistant.content)
|
|
193
|
+
: "";
|
|
194
|
+
|
|
195
|
+
record.summary = {
|
|
196
|
+
...record.summary,
|
|
197
|
+
title: record.summary.title || truncate(latestUserText, 48) || "New chat",
|
|
198
|
+
updatedAt: now().toISOString(),
|
|
199
|
+
messageCount: record.messages.length,
|
|
200
|
+
lastUserPreview: truncate(latestUserText),
|
|
201
|
+
lastAssistantPreview: truncate(latestAssistantText),
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const threadManager: ChatThreadClient = {
|
|
206
|
+
async list(query) {
|
|
207
|
+
const all = [...sessions.values()]
|
|
208
|
+
.map((record) => record.summary)
|
|
209
|
+
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime());
|
|
210
|
+
const offset = query?.offset ?? 0;
|
|
211
|
+
const limit = query?.limit ?? all.length;
|
|
212
|
+
return {
|
|
213
|
+
threads: all.slice(offset, offset + limit),
|
|
214
|
+
total: all.length,
|
|
215
|
+
};
|
|
216
|
+
},
|
|
217
|
+
async get(sessionId) {
|
|
218
|
+
const record = sessions.get(sessionId);
|
|
219
|
+
if (!record) {
|
|
220
|
+
throw new Error(`Thread "${sessionId}" not found.`);
|
|
221
|
+
}
|
|
222
|
+
return createDetail(record);
|
|
223
|
+
},
|
|
224
|
+
async create(input) {
|
|
225
|
+
const record = ensureSession(undefined, input);
|
|
226
|
+
updateSummaryFromMessages(record);
|
|
227
|
+
return record.summary;
|
|
228
|
+
},
|
|
229
|
+
async delete(sessionId) {
|
|
230
|
+
sessions.delete(sessionId);
|
|
231
|
+
},
|
|
232
|
+
async rename(sessionId, title) {
|
|
233
|
+
const record = ensureSession(sessionId);
|
|
234
|
+
record.summary = {
|
|
235
|
+
...record.summary,
|
|
236
|
+
title,
|
|
237
|
+
updatedAt: now().toISOString(),
|
|
238
|
+
};
|
|
239
|
+
return record.summary;
|
|
240
|
+
},
|
|
241
|
+
async archive(sessionId) {
|
|
242
|
+
const record = ensureSession(sessionId);
|
|
243
|
+
record.summary = {
|
|
244
|
+
...record.summary,
|
|
245
|
+
status: "archived",
|
|
246
|
+
updatedAt: now().toISOString(),
|
|
247
|
+
};
|
|
248
|
+
return record.summary;
|
|
249
|
+
},
|
|
250
|
+
async unarchive(sessionId) {
|
|
251
|
+
const record = ensureSession(sessionId);
|
|
252
|
+
record.summary = {
|
|
253
|
+
...record.summary,
|
|
254
|
+
status: "idle",
|
|
255
|
+
updatedAt: now().toISOString(),
|
|
256
|
+
};
|
|
257
|
+
return record.summary;
|
|
258
|
+
},
|
|
259
|
+
async setMessageFeedback(sessionId, messageId, value) {
|
|
260
|
+
const record = ensureSession(sessionId);
|
|
261
|
+
if (value) {
|
|
262
|
+
record.feedbackByMessageId.set(messageId, value);
|
|
263
|
+
} else {
|
|
264
|
+
record.feedbackByMessageId.delete(messageId);
|
|
265
|
+
}
|
|
266
|
+
record.messages = record.messages.map((message) => {
|
|
267
|
+
if (getDurableMessageId(message) !== messageId) return message;
|
|
268
|
+
return withDurableMessageMetadata(message, messageId, value ?? undefined);
|
|
269
|
+
});
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
capabilities: {
|
|
275
|
+
threads: true,
|
|
276
|
+
threadDelete: true,
|
|
277
|
+
threadRename: true,
|
|
278
|
+
threadArchive: true,
|
|
279
|
+
messageFeedback: true,
|
|
280
|
+
},
|
|
281
|
+
createExecutionClient(context) {
|
|
282
|
+
return {
|
|
283
|
+
async stream(request) {
|
|
284
|
+
const record = ensureSession(request.sessionId);
|
|
285
|
+
record.messages = request.messages
|
|
286
|
+
.map(toRuntimeMessage)
|
|
287
|
+
.map((message) => withDurableMessageMetadata(
|
|
288
|
+
message,
|
|
289
|
+
makeId(),
|
|
290
|
+
(() => {
|
|
291
|
+
const messageId = getDurableMessageId(message);
|
|
292
|
+
return messageId ? record.feedbackByMessageId.get(messageId) : undefined;
|
|
293
|
+
})(),
|
|
294
|
+
));
|
|
295
|
+
|
|
296
|
+
let assistantMessage: KognitiveMessage | undefined;
|
|
297
|
+
if (options.stream) {
|
|
298
|
+
const streamedMessages: KognitiveMessage[] = [];
|
|
299
|
+
const result = await options.stream({
|
|
300
|
+
messages: request.messages,
|
|
301
|
+
sessionId: request.sessionId,
|
|
302
|
+
context,
|
|
303
|
+
emit: async (eventName, data) => {
|
|
304
|
+
if (eventName === "custom" && data && typeof data === "object" && "role" in (data as Record<string, unknown>)) {
|
|
305
|
+
streamedMessages.push(withDurableMessageMetadata(data as KognitiveMessage, makeId()));
|
|
306
|
+
}
|
|
307
|
+
await request.onEvent(eventName, data);
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
assistantMessage = result ? withDurableMessageMetadata(result, makeId()) : streamedMessages.at(-1);
|
|
311
|
+
} else {
|
|
312
|
+
assistantMessage = withDurableMessageMetadata(
|
|
313
|
+
options.respond
|
|
314
|
+
? await options.respond({ messages: request.messages, sessionId: request.sessionId, context })
|
|
315
|
+
: defaultAssistantReply(request.messages),
|
|
316
|
+
makeId(),
|
|
317
|
+
);
|
|
318
|
+
await request.onEvent("custom", assistantMessage);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (assistantMessage) {
|
|
322
|
+
record.messages = [...record.messages, assistantMessage];
|
|
323
|
+
updateSummaryFromMessages(record);
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
},
|
|
328
|
+
createThreadClient() {
|
|
329
|
+
return threadManager;
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"jsx": "react-jsx",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|