@firtoz/chat-agent 1.0.0 → 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/README.md +99 -349
- package/package.json +13 -19
- package/src/chat-agent-base.ts +731 -303
- package/src/chat-messages.ts +81 -5
- package/src/index.ts +11 -22
- package/src/chat-agent-drizzle.ts +0 -227
- package/src/chat-agent-sql.ts +0 -199
- package/src/db/index.ts +0 -21
- package/src/db/schema.ts +0 -47
package/src/chat-messages.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
2
|
|
|
3
3
|
// ============================================================================
|
|
4
4
|
// Tool Definitions (for sending to OpenRouter)
|
|
@@ -37,9 +37,18 @@ export type ToolExecuteFunction = (args: any) => unknown | Promise<unknown>;
|
|
|
37
37
|
* - If `execute` is provided: server runs it automatically and continues
|
|
38
38
|
* - If `execute` is omitted: tool call is sent to client for execution
|
|
39
39
|
*/
|
|
40
|
+
export type ToolNeedsApprovalFn = (
|
|
41
|
+
args: Record<string, unknown>,
|
|
42
|
+
) => boolean | Promise<boolean>;
|
|
43
|
+
|
|
40
44
|
export type ToolDefinition = z.infer<typeof ToolDefinitionSchema> & {
|
|
41
45
|
/** Optional server-side execute function. If omitted, tool call goes to client. */
|
|
42
46
|
execute?: ToolExecuteFunction;
|
|
47
|
+
/**
|
|
48
|
+
* When `execute` is set, if this returns true the server waits for a client
|
|
49
|
+
* `toolApprovalResponse` before running `execute` (human-in-the-loop).
|
|
50
|
+
*/
|
|
51
|
+
needsApproval?: ToolNeedsApprovalFn;
|
|
43
52
|
};
|
|
44
53
|
|
|
45
54
|
// ============================================================================
|
|
@@ -56,6 +65,11 @@ export const ToolCallSchema = z.object({
|
|
|
56
65
|
name: z.string(),
|
|
57
66
|
arguments: z.string(), // JSON string
|
|
58
67
|
}),
|
|
68
|
+
/**
|
|
69
|
+
* Opaque provider-specific fields from the upstream stream (e.g. Gemini / Anthropic extras).
|
|
70
|
+
* Forwarded on the wire when calling the model again after tool results.
|
|
71
|
+
*/
|
|
72
|
+
providerMetadata: z.record(z.string(), z.unknown()).optional(),
|
|
59
73
|
});
|
|
60
74
|
|
|
61
75
|
export type ToolCall = z.infer<typeof ToolCallSchema>;
|
|
@@ -73,6 +87,7 @@ export const ToolCallDeltaSchema = z.object({
|
|
|
73
87
|
arguments: z.string().optional(),
|
|
74
88
|
})
|
|
75
89
|
.optional(),
|
|
90
|
+
providerMetadata: z.record(z.string(), z.unknown()).optional(),
|
|
76
91
|
});
|
|
77
92
|
|
|
78
93
|
export type ToolCallDelta = z.infer<typeof ToolCallDeltaSchema>;
|
|
@@ -160,11 +175,47 @@ export type TokenUsage = z.infer<typeof TokenUsageSchema>;
|
|
|
160
175
|
// Client → Server Messages (Discriminated Union)
|
|
161
176
|
// ============================================================================
|
|
162
177
|
|
|
178
|
+
export const SendMessageTriggerSchema = z.enum([
|
|
179
|
+
"submit-message",
|
|
180
|
+
"regenerate-message",
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
export type SendMessageTrigger = z.infer<typeof SendMessageTriggerSchema>;
|
|
184
|
+
|
|
163
185
|
export const ClientMessageSchema = z.discriminatedUnion("type", [
|
|
164
|
-
z
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
186
|
+
z
|
|
187
|
+
.object({
|
|
188
|
+
type: z.literal("sendMessage"),
|
|
189
|
+
content: z.string().optional(),
|
|
190
|
+
/** Full conversation snapshot; used with `regenerate-message` or to reconcile before a new user turn */
|
|
191
|
+
messages: z.array(ChatMessageSchema).optional(),
|
|
192
|
+
trigger: SendMessageTriggerSchema.optional(),
|
|
193
|
+
})
|
|
194
|
+
.superRefine((data, ctx) => {
|
|
195
|
+
const trigger = data.trigger ?? "submit-message";
|
|
196
|
+
if (trigger === "regenerate-message") {
|
|
197
|
+
if (!data.messages || data.messages.length === 0) {
|
|
198
|
+
ctx.addIssue({
|
|
199
|
+
code: z.ZodIssueCode.custom,
|
|
200
|
+
message:
|
|
201
|
+
"sendMessage with trigger regenerate-message requires non-empty messages",
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
const hasContent = data.content !== undefined && data.content !== "";
|
|
206
|
+
const messagesEndWithUser =
|
|
207
|
+
!!data.messages &&
|
|
208
|
+
data.messages.length > 0 &&
|
|
209
|
+
data.messages[data.messages.length - 1].role === "user";
|
|
210
|
+
if (!hasContent && !messagesEndWithUser) {
|
|
211
|
+
ctx.addIssue({
|
|
212
|
+
code: z.ZodIssueCode.custom,
|
|
213
|
+
message:
|
|
214
|
+
"sendMessage requires non-empty content, or messages ending with a user message",
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}),
|
|
168
219
|
z.object({
|
|
169
220
|
type: z.literal("clearHistory"),
|
|
170
221
|
}),
|
|
@@ -188,6 +239,11 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [
|
|
|
188
239
|
// If true, server should continue the conversation after tool result
|
|
189
240
|
autoContinue: z.boolean().optional(),
|
|
190
241
|
}),
|
|
242
|
+
z.object({
|
|
243
|
+
type: z.literal("toolApprovalResponse"),
|
|
244
|
+
approvalId: z.string(),
|
|
245
|
+
approved: z.boolean(),
|
|
246
|
+
}),
|
|
191
247
|
// Register client-defined tools at runtime
|
|
192
248
|
z.object({
|
|
193
249
|
type: z.literal("registerTools"),
|
|
@@ -222,6 +278,10 @@ export type CancelRequestPayload = Extract<
|
|
|
222
278
|
{ type: "cancelRequest" }
|
|
223
279
|
>;
|
|
224
280
|
export type ToolResultPayload = Extract<ClientMessage, { type: "toolResult" }>;
|
|
281
|
+
export type ToolApprovalResponsePayload = Extract<
|
|
282
|
+
ClientMessage,
|
|
283
|
+
{ type: "toolApprovalResponse" }
|
|
284
|
+
>;
|
|
225
285
|
export type RegisterToolsPayload = Extract<
|
|
226
286
|
ClientMessage,
|
|
227
287
|
{ type: "registerTools" }
|
|
@@ -300,6 +360,13 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [
|
|
|
300
360
|
toolName: z.string(),
|
|
301
361
|
message: z.string(),
|
|
302
362
|
}),
|
|
363
|
+
z.object({
|
|
364
|
+
type: z.literal("toolApprovalRequest"),
|
|
365
|
+
approvalId: z.string(),
|
|
366
|
+
toolCallId: z.string(),
|
|
367
|
+
toolName: z.string(),
|
|
368
|
+
arguments: z.string(),
|
|
369
|
+
}),
|
|
303
370
|
]);
|
|
304
371
|
|
|
305
372
|
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
|
@@ -334,6 +401,10 @@ export type MessageUpdatedMessage = Extract<
|
|
|
334
401
|
>;
|
|
335
402
|
export type ErrorMessage = Extract<ServerMessage, { type: "error" }>;
|
|
336
403
|
export type ToolErrorMessage = Extract<ServerMessage, { type: "toolError" }>;
|
|
404
|
+
export type ToolApprovalRequestMessage = Extract<
|
|
405
|
+
ServerMessage,
|
|
406
|
+
{ type: "toolApprovalRequest" }
|
|
407
|
+
>;
|
|
337
408
|
|
|
338
409
|
// ============================================================================
|
|
339
410
|
// Parsing Helpers
|
|
@@ -455,6 +526,8 @@ export function defineTool(config: {
|
|
|
455
526
|
strict?: boolean;
|
|
456
527
|
/** Server-side execute function. If omitted, tool call goes to client. */
|
|
457
528
|
execute?: ToolExecuteFunction;
|
|
529
|
+
/** If set with `execute`, client must approve before the server runs `execute`. */
|
|
530
|
+
needsApproval?: ToolNeedsApprovalFn;
|
|
458
531
|
}): ToolDefinition {
|
|
459
532
|
const tool: ToolDefinition = {
|
|
460
533
|
type: "function",
|
|
@@ -468,5 +541,8 @@ export function defineTool(config: {
|
|
|
468
541
|
if (config.execute) {
|
|
469
542
|
tool.execute = config.execute;
|
|
470
543
|
}
|
|
544
|
+
if (config.needsApproval) {
|
|
545
|
+
tool.needsApproval = config.needsApproval;
|
|
546
|
+
}
|
|
471
547
|
return tool;
|
|
472
548
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @firtoz/chat-agent
|
|
2
|
+
* @firtoz/chat-agent — wire protocol, tool helpers, and abstract `ChatAgentBase`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Install a persistence package separately:
|
|
5
|
+
* - `@firtoz/chat-agent-drizzle` — Drizzle ORM (recommended)
|
|
6
|
+
* - `@firtoz/chat-agent-sql` — raw `this.sql`
|
|
6
7
|
*
|
|
7
8
|
* @example
|
|
8
9
|
* ```typescript
|
|
9
|
-
* import {
|
|
10
|
+
* import { ChatAgentBase, defineTool, type ToolDefinition } from "@firtoz/chat-agent";
|
|
11
|
+
* import { DrizzleChatAgent } from "@firtoz/chat-agent-drizzle";
|
|
10
12
|
*
|
|
11
|
-
* class MyAgent extends
|
|
13
|
+
* class MyAgent extends DrizzleChatAgent<Env> {
|
|
12
14
|
* protected override getSystemPrompt(): string {
|
|
13
15
|
* return "You are a helpful assistant.";
|
|
14
16
|
* }
|
|
@@ -31,30 +33,18 @@
|
|
|
31
33
|
* ```
|
|
32
34
|
*/
|
|
33
35
|
|
|
34
|
-
// Export the abstract base class
|
|
35
36
|
export { ChatAgentBase } from "./chat-agent-base";
|
|
36
37
|
|
|
37
|
-
// Export concrete implementations
|
|
38
|
-
export { DrizzleChatAgent } from "./chat-agent-drizzle";
|
|
39
|
-
export { SqlChatAgent } from "./chat-agent-sql";
|
|
40
|
-
|
|
41
|
-
// Alias DrizzleChatAgent as ChatAgent for convenience (Drizzle is recommended)
|
|
42
|
-
export { DrizzleChatAgent as ChatAgent } from "./chat-agent-drizzle";
|
|
43
|
-
|
|
44
|
-
// Re-export all types and utilities from chat-messages
|
|
45
38
|
export type {
|
|
46
|
-
// Message types
|
|
47
39
|
AssistantMessage,
|
|
48
40
|
ChatMessage,
|
|
49
41
|
UserMessage,
|
|
50
42
|
ToolMessage,
|
|
51
|
-
// Tool types
|
|
52
43
|
ToolCall,
|
|
53
44
|
ToolCallDelta,
|
|
54
45
|
ToolDefinition,
|
|
55
46
|
ToolResult,
|
|
56
47
|
JSONSchema,
|
|
57
|
-
// Client/Server message types
|
|
58
48
|
ClientMessage,
|
|
59
49
|
ServerMessage,
|
|
60
50
|
SendMessagePayload,
|
|
@@ -64,6 +54,10 @@ export type {
|
|
|
64
54
|
CancelRequestPayload,
|
|
65
55
|
ToolResultPayload,
|
|
66
56
|
RegisterToolsPayload,
|
|
57
|
+
ToolApprovalResponsePayload,
|
|
58
|
+
ToolApprovalRequestMessage,
|
|
59
|
+
SendMessageTrigger,
|
|
60
|
+
ToolNeedsApprovalFn,
|
|
67
61
|
HistoryMessage,
|
|
68
62
|
MessageStartMessage,
|
|
69
63
|
MessageChunkMessage,
|
|
@@ -75,25 +69,20 @@ export type {
|
|
|
75
69
|
MessageUpdatedMessage,
|
|
76
70
|
ErrorMessage,
|
|
77
71
|
ToolErrorMessage,
|
|
78
|
-
// Usage types
|
|
79
72
|
TokenUsage,
|
|
80
73
|
} from "./chat-messages";
|
|
81
74
|
|
|
82
75
|
export {
|
|
83
|
-
// Tool definition helper
|
|
84
76
|
defineTool,
|
|
85
|
-
// Parsing helpers
|
|
86
77
|
parseClientMessage,
|
|
87
78
|
safeParseClientMessage,
|
|
88
79
|
parseServerMessage,
|
|
89
80
|
safeParseServerMessage,
|
|
90
|
-
// Type guards
|
|
91
81
|
isClientMessage,
|
|
92
82
|
isServerMessage,
|
|
93
83
|
isUserMessage,
|
|
94
84
|
isAssistantMessage,
|
|
95
85
|
isToolMessage,
|
|
96
86
|
hasToolCalls,
|
|
97
|
-
// Helper functions
|
|
98
87
|
parseToolArguments,
|
|
99
88
|
} from "./chat-messages";
|
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
import { and, asc, eq, lt } from "drizzle-orm";
|
|
2
|
-
import { migrate } from "drizzle-orm/durable-sqlite/migrator";
|
|
3
|
-
import { createDb, type Database } from "./db/index";
|
|
4
|
-
import {
|
|
5
|
-
messagesTable,
|
|
6
|
-
type NewMessage,
|
|
7
|
-
streamChunksTable,
|
|
8
|
-
streamMetadataTable,
|
|
9
|
-
} from "./db/schema";
|
|
10
|
-
import type { ChatMessage } from "./chat-messages";
|
|
11
|
-
import { ChatAgentBase } from "./chat-agent-base";
|
|
12
|
-
import migrations from "../drizzle/migrations.js";
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* ChatAgent implementation using Drizzle ORM
|
|
16
|
-
*
|
|
17
|
-
* Uses Drizzle's type-safe query builder for database operations.
|
|
18
|
-
*/
|
|
19
|
-
export class DrizzleChatAgent<
|
|
20
|
-
Env extends Cloudflare.Env & {
|
|
21
|
-
OPENROUTER_API_KEY: string;
|
|
22
|
-
} = Cloudflare.Env & { OPENROUTER_API_KEY: string },
|
|
23
|
-
> extends ChatAgentBase<Env> {
|
|
24
|
-
private db!: Database;
|
|
25
|
-
|
|
26
|
-
// ============================================================================
|
|
27
|
-
// Database Implementation - Drizzle ORM
|
|
28
|
-
// ============================================================================
|
|
29
|
-
|
|
30
|
-
protected dbInitialize(): void {
|
|
31
|
-
// Initialize Drizzle DB
|
|
32
|
-
this.db = createDb(this.ctx.storage);
|
|
33
|
-
|
|
34
|
-
// Run migrations
|
|
35
|
-
migrate(this.db, migrations);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
protected dbLoadMessages(): ChatMessage[] {
|
|
39
|
-
const rows = this.db
|
|
40
|
-
.select()
|
|
41
|
-
.from(messagesTable)
|
|
42
|
-
.orderBy(asc(messagesTable.createdAt))
|
|
43
|
-
.all();
|
|
44
|
-
|
|
45
|
-
return rows
|
|
46
|
-
.map((row) => {
|
|
47
|
-
try {
|
|
48
|
-
return JSON.parse(row.messageJson) as ChatMessage;
|
|
49
|
-
} catch (err) {
|
|
50
|
-
console.error(`Failed to parse message ${row.id}:`, err);
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
})
|
|
54
|
-
.filter((msg): msg is ChatMessage => msg !== null);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
protected dbSaveMessage(msg: ChatMessage): void {
|
|
58
|
-
const newMsg: NewMessage = {
|
|
59
|
-
id: msg.id,
|
|
60
|
-
role: msg.role,
|
|
61
|
-
messageJson: JSON.stringify(msg),
|
|
62
|
-
createdAt: new Date(msg.createdAt),
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
this.db
|
|
66
|
-
.insert(messagesTable)
|
|
67
|
-
.values(newMsg)
|
|
68
|
-
.onConflictDoUpdate({
|
|
69
|
-
target: messagesTable.id,
|
|
70
|
-
set: { messageJson: newMsg.messageJson },
|
|
71
|
-
})
|
|
72
|
-
.run();
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
protected dbClearAll(): void {
|
|
76
|
-
this.db.delete(messagesTable).run();
|
|
77
|
-
this.db.delete(streamChunksTable).run();
|
|
78
|
-
this.db.delete(streamMetadataTable).run();
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
protected dbFindActiveStream(): {
|
|
82
|
-
id: string;
|
|
83
|
-
messageId: string;
|
|
84
|
-
createdAt: Date;
|
|
85
|
-
} | null {
|
|
86
|
-
const activeStreams = this.db
|
|
87
|
-
.select()
|
|
88
|
-
.from(streamMetadataTable)
|
|
89
|
-
.where(eq(streamMetadataTable.status, "streaming"))
|
|
90
|
-
.orderBy(asc(streamMetadataTable.createdAt))
|
|
91
|
-
.limit(1)
|
|
92
|
-
.all();
|
|
93
|
-
|
|
94
|
-
if (activeStreams.length === 0) {
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const stream = activeStreams[0];
|
|
99
|
-
return {
|
|
100
|
-
id: stream.id,
|
|
101
|
-
messageId: stream.messageId,
|
|
102
|
-
createdAt: stream.createdAt,
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
protected dbDeleteStreamWithChunks(streamId: string): void {
|
|
107
|
-
this.db
|
|
108
|
-
.delete(streamChunksTable)
|
|
109
|
-
.where(eq(streamChunksTable.streamId, streamId))
|
|
110
|
-
.run();
|
|
111
|
-
this.db
|
|
112
|
-
.delete(streamMetadataTable)
|
|
113
|
-
.where(eq(streamMetadataTable.id, streamId))
|
|
114
|
-
.run();
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
protected dbInsertStreamMetadata(streamId: string, messageId: string): void {
|
|
118
|
-
this.db
|
|
119
|
-
.insert(streamMetadataTable)
|
|
120
|
-
.values({
|
|
121
|
-
id: streamId,
|
|
122
|
-
messageId,
|
|
123
|
-
status: "streaming",
|
|
124
|
-
createdAt: new Date(),
|
|
125
|
-
})
|
|
126
|
-
.run();
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
protected dbUpdateStreamStatus(
|
|
130
|
-
streamId: string,
|
|
131
|
-
status: "completed" | "error",
|
|
132
|
-
): void {
|
|
133
|
-
this.db
|
|
134
|
-
.update(streamMetadataTable)
|
|
135
|
-
.set({ status, completedAt: new Date() })
|
|
136
|
-
.where(eq(streamMetadataTable.id, streamId))
|
|
137
|
-
.run();
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
protected dbDeleteOldCompletedStreams(cutoffMs: number): void {
|
|
141
|
-
const cutoff = new Date(cutoffMs);
|
|
142
|
-
|
|
143
|
-
// Delete old stream chunks for completed streams
|
|
144
|
-
const oldStreams = this.db
|
|
145
|
-
.select({ id: streamMetadataTable.id })
|
|
146
|
-
.from(streamMetadataTable)
|
|
147
|
-
.where(
|
|
148
|
-
and(
|
|
149
|
-
eq(streamMetadataTable.status, "completed"),
|
|
150
|
-
lt(streamMetadataTable.completedAt, cutoff),
|
|
151
|
-
),
|
|
152
|
-
)
|
|
153
|
-
.all();
|
|
154
|
-
|
|
155
|
-
for (const stream of oldStreams) {
|
|
156
|
-
this.db
|
|
157
|
-
.delete(streamChunksTable)
|
|
158
|
-
.where(eq(streamChunksTable.streamId, stream.id))
|
|
159
|
-
.run();
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Delete old stream metadata
|
|
163
|
-
this.db
|
|
164
|
-
.delete(streamMetadataTable)
|
|
165
|
-
.where(
|
|
166
|
-
and(
|
|
167
|
-
eq(streamMetadataTable.status, "completed"),
|
|
168
|
-
lt(streamMetadataTable.completedAt, cutoff),
|
|
169
|
-
),
|
|
170
|
-
)
|
|
171
|
-
.run();
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
protected dbFindMaxChunkIndex(streamId: string): number | null {
|
|
175
|
-
const lastChunk = this.db
|
|
176
|
-
.select({ maxIndex: streamChunksTable.chunkIndex })
|
|
177
|
-
.from(streamChunksTable)
|
|
178
|
-
.where(eq(streamChunksTable.streamId, streamId))
|
|
179
|
-
.orderBy(asc(streamChunksTable.chunkIndex))
|
|
180
|
-
.limit(1)
|
|
181
|
-
.all();
|
|
182
|
-
|
|
183
|
-
const firstChunk = lastChunk[0];
|
|
184
|
-
return firstChunk?.maxIndex != null ? firstChunk.maxIndex : null;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
protected dbInsertChunks(
|
|
188
|
-
chunks: Array<{
|
|
189
|
-
id: string;
|
|
190
|
-
streamId: string;
|
|
191
|
-
content: string;
|
|
192
|
-
chunkIndex: number;
|
|
193
|
-
}>,
|
|
194
|
-
): void {
|
|
195
|
-
const now = new Date();
|
|
196
|
-
for (const chunk of chunks) {
|
|
197
|
-
this.db
|
|
198
|
-
.insert(streamChunksTable)
|
|
199
|
-
.values({
|
|
200
|
-
id: chunk.id,
|
|
201
|
-
streamId: chunk.streamId,
|
|
202
|
-
content: chunk.content,
|
|
203
|
-
chunkIndex: chunk.chunkIndex,
|
|
204
|
-
createdAt: now,
|
|
205
|
-
})
|
|
206
|
-
.run();
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
protected dbGetChunks(streamId: string): string[] {
|
|
211
|
-
const rows = this.db
|
|
212
|
-
.select()
|
|
213
|
-
.from(streamChunksTable)
|
|
214
|
-
.where(eq(streamChunksTable.streamId, streamId))
|
|
215
|
-
.orderBy(asc(streamChunksTable.chunkIndex))
|
|
216
|
-
.all();
|
|
217
|
-
|
|
218
|
-
return rows.map((r) => r.content);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
protected dbDeleteChunks(streamId: string): void {
|
|
222
|
-
this.db
|
|
223
|
-
.delete(streamChunksTable)
|
|
224
|
-
.where(eq(streamChunksTable.streamId, streamId))
|
|
225
|
-
.run();
|
|
226
|
-
}
|
|
227
|
-
}
|
package/src/chat-agent-sql.ts
DELETED
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
import type { ChatMessage } from "./chat-messages";
|
|
2
|
-
import { ChatAgentBase } from "./chat-agent-base";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* ChatAgent implementation using raw SQL (like @cloudflare/ai-chat)
|
|
6
|
-
*
|
|
7
|
-
* Uses Agent's built-in `this.sql` template tag for database operations.
|
|
8
|
-
*/
|
|
9
|
-
export class SqlChatAgent<
|
|
10
|
-
Env extends Cloudflare.Env & {
|
|
11
|
-
OPENROUTER_API_KEY: string;
|
|
12
|
-
} = Cloudflare.Env & { OPENROUTER_API_KEY: string },
|
|
13
|
-
> extends ChatAgentBase<Env> {
|
|
14
|
-
// ============================================================================
|
|
15
|
-
// Database Implementation - Raw SQL
|
|
16
|
-
// ============================================================================
|
|
17
|
-
|
|
18
|
-
protected dbInitialize(): void {
|
|
19
|
-
// Create tables for chat messages and resumable streaming
|
|
20
|
-
// Based on @cloudflare/ai-chat pattern from reference
|
|
21
|
-
this.sql`create table if not exists cf_ai_chat_agent_messages (
|
|
22
|
-
id text primary key,
|
|
23
|
-
message text not null,
|
|
24
|
-
created_at datetime default current_timestamp
|
|
25
|
-
)`;
|
|
26
|
-
|
|
27
|
-
this.sql`create table if not exists cf_ai_chat_stream_chunks (
|
|
28
|
-
id text primary key,
|
|
29
|
-
stream_id text not null,
|
|
30
|
-
body text not null,
|
|
31
|
-
chunk_index integer not null,
|
|
32
|
-
created_at integer not null
|
|
33
|
-
)`;
|
|
34
|
-
|
|
35
|
-
this.sql`create table if not exists cf_ai_chat_stream_metadata (
|
|
36
|
-
id text primary key,
|
|
37
|
-
request_id text not null,
|
|
38
|
-
status text not null,
|
|
39
|
-
created_at integer not null,
|
|
40
|
-
completed_at integer
|
|
41
|
-
)`;
|
|
42
|
-
|
|
43
|
-
this.sql`create index if not exists idx_stream_chunks_stream_id
|
|
44
|
-
on cf_ai_chat_stream_chunks(stream_id, chunk_index)`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
protected dbLoadMessages(): ChatMessage[] {
|
|
48
|
-
const rows =
|
|
49
|
-
(this
|
|
50
|
-
.sql`select * from cf_ai_chat_agent_messages order by created_at` as Array<{
|
|
51
|
-
id: string;
|
|
52
|
-
message: string;
|
|
53
|
-
}>) || [];
|
|
54
|
-
|
|
55
|
-
return rows
|
|
56
|
-
.map((row) => {
|
|
57
|
-
try {
|
|
58
|
-
return JSON.parse(row.message) as ChatMessage;
|
|
59
|
-
} catch (err) {
|
|
60
|
-
console.error(`Failed to parse message ${row.id}:`, err);
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
})
|
|
64
|
-
.filter((msg): msg is ChatMessage => msg !== null);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
protected dbSaveMessage(msg: ChatMessage): void {
|
|
68
|
-
const messageJson = JSON.stringify(msg);
|
|
69
|
-
this.sql`
|
|
70
|
-
insert into cf_ai_chat_agent_messages (id, message)
|
|
71
|
-
values (${msg.id}, ${messageJson})
|
|
72
|
-
on conflict(id) do update set message = excluded.message
|
|
73
|
-
`;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
protected dbClearAll(): void {
|
|
77
|
-
this.sql`delete from cf_ai_chat_agent_messages`;
|
|
78
|
-
this.sql`delete from cf_ai_chat_stream_chunks`;
|
|
79
|
-
this.sql`delete from cf_ai_chat_stream_metadata`;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
protected dbFindActiveStream(): {
|
|
83
|
-
id: string;
|
|
84
|
-
messageId: string;
|
|
85
|
-
createdAt: Date;
|
|
86
|
-
} | null {
|
|
87
|
-
const activeStreams = this.sql`
|
|
88
|
-
select * from cf_ai_chat_stream_metadata
|
|
89
|
-
where status = 'streaming'
|
|
90
|
-
order by created_at desc
|
|
91
|
-
limit 1
|
|
92
|
-
` as Array<{
|
|
93
|
-
id: string;
|
|
94
|
-
request_id: string;
|
|
95
|
-
status: string;
|
|
96
|
-
created_at: number;
|
|
97
|
-
completed_at: number | null;
|
|
98
|
-
}>;
|
|
99
|
-
|
|
100
|
-
if (!activeStreams || activeStreams.length === 0) {
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const stream = activeStreams[0];
|
|
105
|
-
return {
|
|
106
|
-
id: stream.id,
|
|
107
|
-
messageId: stream.request_id,
|
|
108
|
-
createdAt: new Date(stream.created_at),
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
protected dbDeleteStreamWithChunks(streamId: string): void {
|
|
113
|
-
this
|
|
114
|
-
.sql`delete from cf_ai_chat_stream_chunks where stream_id = ${streamId}`;
|
|
115
|
-
this.sql`delete from cf_ai_chat_stream_metadata where id = ${streamId}`;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
protected dbInsertStreamMetadata(streamId: string, messageId: string): void {
|
|
119
|
-
const now = Date.now();
|
|
120
|
-
this.sql`
|
|
121
|
-
insert into cf_ai_chat_stream_metadata (id, request_id, status, created_at)
|
|
122
|
-
values (${streamId}, ${messageId}, 'streaming', ${now})
|
|
123
|
-
`;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
protected dbUpdateStreamStatus(
|
|
127
|
-
streamId: string,
|
|
128
|
-
status: "completed" | "error",
|
|
129
|
-
): void {
|
|
130
|
-
const now = Date.now();
|
|
131
|
-
this.sql`
|
|
132
|
-
update cf_ai_chat_stream_metadata
|
|
133
|
-
set status = ${status}, completed_at = ${now}
|
|
134
|
-
where id = ${streamId}
|
|
135
|
-
`;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
protected dbDeleteOldCompletedStreams(cutoffMs: number): void {
|
|
139
|
-
// Delete old stream chunks first
|
|
140
|
-
this.sql`
|
|
141
|
-
delete from cf_ai_chat_stream_chunks
|
|
142
|
-
where stream_id in (
|
|
143
|
-
select id from cf_ai_chat_stream_metadata
|
|
144
|
-
where status = 'completed' and completed_at < ${cutoffMs}
|
|
145
|
-
)
|
|
146
|
-
`;
|
|
147
|
-
// Then delete the metadata
|
|
148
|
-
this.sql`
|
|
149
|
-
delete from cf_ai_chat_stream_metadata
|
|
150
|
-
where status = 'completed' and completed_at < ${cutoffMs}
|
|
151
|
-
`;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
protected dbFindMaxChunkIndex(streamId: string): number | null {
|
|
155
|
-
const result = this.sql`
|
|
156
|
-
select max(chunk_index) as max_index
|
|
157
|
-
from cf_ai_chat_stream_chunks
|
|
158
|
-
where stream_id = ${streamId}
|
|
159
|
-
` as Array<{ max_index: number | null }>;
|
|
160
|
-
|
|
161
|
-
if (!result || result.length === 0 || result[0].max_index == null) {
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return result[0].max_index;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
protected dbInsertChunks(
|
|
169
|
-
chunks: Array<{
|
|
170
|
-
id: string;
|
|
171
|
-
streamId: string;
|
|
172
|
-
content: string;
|
|
173
|
-
chunkIndex: number;
|
|
174
|
-
}>,
|
|
175
|
-
): void {
|
|
176
|
-
const now = Date.now();
|
|
177
|
-
for (const chunk of chunks) {
|
|
178
|
-
this.sql`
|
|
179
|
-
insert into cf_ai_chat_stream_chunks (id, stream_id, body, chunk_index, created_at)
|
|
180
|
-
values (${chunk.id}, ${chunk.streamId}, ${chunk.content}, ${chunk.chunkIndex}, ${now})
|
|
181
|
-
`;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
protected dbGetChunks(streamId: string): string[] {
|
|
186
|
-
const rows = this.sql`
|
|
187
|
-
select body from cf_ai_chat_stream_chunks
|
|
188
|
-
where stream_id = ${streamId}
|
|
189
|
-
order by chunk_index asc
|
|
190
|
-
` as Array<{ body: string }>;
|
|
191
|
-
|
|
192
|
-
return (rows || []).map((r) => r.body);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
protected dbDeleteChunks(streamId: string): void {
|
|
196
|
-
this
|
|
197
|
-
.sql`delete from cf_ai_chat_stream_chunks where stream_id = ${streamId}`;
|
|
198
|
-
}
|
|
199
|
-
}
|
package/src/db/index.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type DrizzleSqliteDODatabase,
|
|
3
|
-
drizzle,
|
|
4
|
-
} from "drizzle-orm/durable-sqlite";
|
|
5
|
-
import * as schema from "./schema";
|
|
6
|
-
|
|
7
|
-
export * from "./schema";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Creates a Drizzle instance for Durable Object SQLite storage
|
|
11
|
-
* Uses the native drizzle-orm/durable-sqlite driver
|
|
12
|
-
*
|
|
13
|
-
* @see https://orm.drizzle.team/docs/connect-cloudflare-do
|
|
14
|
-
*/
|
|
15
|
-
export function createDb(
|
|
16
|
-
storage: DurableObjectStorage,
|
|
17
|
-
): DrizzleSqliteDODatabase<typeof schema> {
|
|
18
|
-
return drizzle(storage, { schema, logger: false });
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export type Database = DrizzleSqliteDODatabase<typeof schema>;
|