@objectstack/service-ai 4.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/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +202 -0
- package/dist/index.cjs +1418 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3406 -0
- package/dist/index.d.ts +3406 -0
- package/dist/index.js +1378 -0
- package/dist/index.js.map +1 -0
- package/package.json +29 -0
- package/src/__tests__/ai-service.test.ts +731 -0
- package/src/__tests__/chatbot-features.test.ts +821 -0
- package/src/__tests__/objectql-conversation-service.test.ts +364 -0
- package/src/adapters/index.ts +4 -0
- package/src/adapters/memory-adapter.ts +64 -0
- package/src/adapters/types.ts +3 -0
- package/src/agent-runtime.ts +130 -0
- package/src/agents/data-chat-agent.ts +79 -0
- package/src/agents/index.ts +3 -0
- package/src/ai-service.ts +205 -0
- package/src/conversation/in-memory-conversation-service.ts +103 -0
- package/src/conversation/index.ts +4 -0
- package/src/conversation/objectql-conversation-service.ts +252 -0
- package/src/index.ts +40 -0
- package/src/objects/ai-conversation.object.ts +86 -0
- package/src/objects/ai-message.object.ts +86 -0
- package/src/objects/index.ts +10 -0
- package/src/plugin.ts +184 -0
- package/src/routes/agent-routes.ts +132 -0
- package/src/routes/ai-routes.ts +286 -0
- package/src/routes/index.ts +4 -0
- package/src/tools/data-tools.ts +390 -0
- package/src/tools/index.ts +7 -0
- package/src/tools/tool-registry.ts +109 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AIMessage,
|
|
5
|
+
AIRequestOptions,
|
|
6
|
+
AIResult,
|
|
7
|
+
AIStreamEvent,
|
|
8
|
+
IAIService,
|
|
9
|
+
IAIConversationService,
|
|
10
|
+
ChatWithToolsOptions,
|
|
11
|
+
LLMAdapter,
|
|
12
|
+
} from '@objectstack/spec/contracts';
|
|
13
|
+
import type { Logger } from '@objectstack/spec/contracts';
|
|
14
|
+
import { createLogger } from '@objectstack/core';
|
|
15
|
+
import { MemoryLLMAdapter } from './adapters/memory-adapter.js';
|
|
16
|
+
import { ToolRegistry } from './tools/tool-registry.js';
|
|
17
|
+
import { InMemoryConversationService } from './conversation/in-memory-conversation-service.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Configuration for AIService.
|
|
21
|
+
*/
|
|
22
|
+
export interface AIServiceConfig {
|
|
23
|
+
/** LLM adapter to delegate calls to (defaults to MemoryLLMAdapter). */
|
|
24
|
+
adapter?: LLMAdapter;
|
|
25
|
+
/** Logger instance. */
|
|
26
|
+
logger?: Logger;
|
|
27
|
+
/** Pre-registered tools. */
|
|
28
|
+
toolRegistry?: ToolRegistry;
|
|
29
|
+
/** Conversation service (defaults to InMemoryConversationService). */
|
|
30
|
+
conversationService?: IAIConversationService;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* AIService — Unified AI capability service.
|
|
35
|
+
*
|
|
36
|
+
* Implements {@link IAIService} by delegating to a pluggable {@link LLMAdapter}
|
|
37
|
+
* and managing tools and conversations through dedicated sub-components:
|
|
38
|
+
*
|
|
39
|
+
* | Component | Responsibility |
|
|
40
|
+
* |:---|:---|
|
|
41
|
+
* | {@link LLMAdapter} | LLM provider abstraction (chat, complete, stream, embed) |
|
|
42
|
+
* | {@link ToolRegistry} | Tool definition storage & execution |
|
|
43
|
+
* | {@link IAIConversationService} | Conversation CRUD & message persistence |
|
|
44
|
+
*
|
|
45
|
+
* The service is registered as `'ai'` in the kernel service registry by
|
|
46
|
+
* the {@link AIServicePlugin}.
|
|
47
|
+
*/
|
|
48
|
+
export class AIService implements IAIService {
|
|
49
|
+
private readonly adapter: LLMAdapter;
|
|
50
|
+
private readonly logger: Logger;
|
|
51
|
+
readonly toolRegistry: ToolRegistry;
|
|
52
|
+
readonly conversationService: IAIConversationService;
|
|
53
|
+
|
|
54
|
+
constructor(config: AIServiceConfig = {}) {
|
|
55
|
+
this.adapter = config.adapter ?? new MemoryLLMAdapter();
|
|
56
|
+
this.logger = config.logger ?? createLogger({ level: 'info', format: 'pretty' });
|
|
57
|
+
this.toolRegistry = config.toolRegistry ?? new ToolRegistry();
|
|
58
|
+
this.conversationService = config.conversationService ?? new InMemoryConversationService();
|
|
59
|
+
|
|
60
|
+
this.logger.info(
|
|
61
|
+
`[AI] Service initialized with adapter="${this.adapter.name}", ` +
|
|
62
|
+
`tools=${this.toolRegistry.size}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The name of the active LLM adapter. */
|
|
67
|
+
get adapterName(): string {
|
|
68
|
+
return this.adapter.name;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── IAIService implementation ──────────────────────────────────
|
|
72
|
+
|
|
73
|
+
async chat(messages: AIMessage[], options?: AIRequestOptions): Promise<AIResult> {
|
|
74
|
+
this.logger.debug('[AI] chat', { messageCount: messages.length, model: options?.model });
|
|
75
|
+
return this.adapter.chat(messages, options);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async complete(prompt: string, options?: AIRequestOptions): Promise<AIResult> {
|
|
79
|
+
this.logger.debug('[AI] complete', { promptLength: prompt.length, model: options?.model });
|
|
80
|
+
return this.adapter.complete(prompt, options);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async *streamChat(
|
|
84
|
+
messages: AIMessage[],
|
|
85
|
+
options?: AIRequestOptions,
|
|
86
|
+
): AsyncIterable<AIStreamEvent> {
|
|
87
|
+
this.logger.debug('[AI] streamChat', { messageCount: messages.length, model: options?.model });
|
|
88
|
+
|
|
89
|
+
if (!this.adapter.streamChat) {
|
|
90
|
+
// Fallback: emit the entire response as a single text-delta + finish
|
|
91
|
+
const result = await this.adapter.chat(messages, options);
|
|
92
|
+
yield { type: 'text-delta', textDelta: result.content };
|
|
93
|
+
yield { type: 'finish', result };
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
yield* this.adapter.streamChat(messages, options);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async embed(input: string | string[], model?: string): Promise<number[][]> {
|
|
101
|
+
if (!this.adapter.embed) {
|
|
102
|
+
throw new Error(`[AI] Adapter "${this.adapter.name}" does not support embeddings`);
|
|
103
|
+
}
|
|
104
|
+
return this.adapter.embed(input, model);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async listModels(): Promise<string[]> {
|
|
108
|
+
if (!this.adapter.listModels) {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
return this.adapter.listModels();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Tool Call Loop ────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/** Default maximum iterations for the tool call loop. */
|
|
117
|
+
static readonly DEFAULT_MAX_ITERATIONS = 10;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Chat with automatic tool call resolution.
|
|
121
|
+
*
|
|
122
|
+
* 1. Merges registered tool definitions into `options.tools`.
|
|
123
|
+
* 2. Calls the LLM adapter.
|
|
124
|
+
* 3. If the response contains `toolCalls`, executes them via the
|
|
125
|
+
* {@link ToolRegistry}, appends tool results as `role: 'tool'`
|
|
126
|
+
* messages, and loops back to step 2.
|
|
127
|
+
* 4. Repeats until the model produces a final text response or the
|
|
128
|
+
* maximum number of iterations (`maxIterations`) is reached.
|
|
129
|
+
*/
|
|
130
|
+
async chatWithTools(
|
|
131
|
+
messages: AIMessage[],
|
|
132
|
+
options?: ChatWithToolsOptions,
|
|
133
|
+
): Promise<AIResult> {
|
|
134
|
+
// Destructure maxIterations out so it is never forwarded to the adapter
|
|
135
|
+
const { maxIterations: maxIter, ...restOptions } = options ?? {};
|
|
136
|
+
const maxIterations = maxIter ?? AIService.DEFAULT_MAX_ITERATIONS;
|
|
137
|
+
const registeredTools = this.toolRegistry.getAll();
|
|
138
|
+
|
|
139
|
+
// Merge registered tools with any explicitly provided tools
|
|
140
|
+
const mergedTools = [
|
|
141
|
+
...registeredTools,
|
|
142
|
+
...(restOptions.tools ?? []),
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
// Build the options that will be sent to every LLM call in the loop
|
|
146
|
+
const chatOptions: AIRequestOptions = {
|
|
147
|
+
...restOptions,
|
|
148
|
+
tools: mergedTools.length > 0 ? mergedTools : undefined,
|
|
149
|
+
toolChoice: mergedTools.length > 0 ? (restOptions.toolChoice ?? 'auto') : undefined,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Working copy of the conversation
|
|
153
|
+
const conversation = [...messages];
|
|
154
|
+
|
|
155
|
+
this.logger.debug('[AI] chatWithTools start', {
|
|
156
|
+
messageCount: conversation.length,
|
|
157
|
+
toolCount: mergedTools.length,
|
|
158
|
+
maxIterations,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
162
|
+
const result = await this.adapter.chat(conversation, chatOptions);
|
|
163
|
+
|
|
164
|
+
// If the model did not request any tool calls we're done
|
|
165
|
+
if (!result.toolCalls || result.toolCalls.length === 0) {
|
|
166
|
+
this.logger.debug('[AI] chatWithTools finished', { iteration, content: result.content.slice(0, 80) });
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this.logger.debug('[AI] chatWithTools tool calls', {
|
|
171
|
+
iteration,
|
|
172
|
+
calls: result.toolCalls.map(tc => tc.name),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Append the assistant's response (with tool call metadata) to the conversation
|
|
176
|
+
conversation.push({
|
|
177
|
+
role: 'assistant',
|
|
178
|
+
content: result.content ?? '',
|
|
179
|
+
toolCalls: result.toolCalls,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Execute all tool calls in parallel
|
|
183
|
+
const toolResults = await this.toolRegistry.executeAll(result.toolCalls);
|
|
184
|
+
|
|
185
|
+
// Append each tool result as a `role: 'tool'` message
|
|
186
|
+
for (const tr of toolResults) {
|
|
187
|
+
conversation.push({
|
|
188
|
+
role: 'tool',
|
|
189
|
+
content: tr.content,
|
|
190
|
+
toolCallId: tr.toolCallId,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// If we exhausted the loop without a final response, make one last
|
|
196
|
+
// call *without* tools so the model is forced to produce text.
|
|
197
|
+
this.logger.warn('[AI] chatWithTools max iterations reached, forcing final response');
|
|
198
|
+
const finalResult = await this.adapter.chat(conversation, {
|
|
199
|
+
...chatOptions,
|
|
200
|
+
tools: undefined,
|
|
201
|
+
toolChoice: undefined,
|
|
202
|
+
});
|
|
203
|
+
return finalResult;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AIConversation,
|
|
5
|
+
AIMessage,
|
|
6
|
+
IAIConversationService,
|
|
7
|
+
} from '@objectstack/spec/contracts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* InMemoryConversationService — Reference implementation of IAIConversationService.
|
|
11
|
+
*
|
|
12
|
+
* Stores conversations in a simple Map. Suitable for development, testing,
|
|
13
|
+
* and single-process deployments. Production environments should replace
|
|
14
|
+
* this with a persistent implementation (e.g., backed by ObjectQL/SQL).
|
|
15
|
+
*/
|
|
16
|
+
export class InMemoryConversationService implements IAIConversationService {
|
|
17
|
+
private readonly store = new Map<string, AIConversation>();
|
|
18
|
+
private counter = 0;
|
|
19
|
+
|
|
20
|
+
async create(options: {
|
|
21
|
+
title?: string;
|
|
22
|
+
agentId?: string;
|
|
23
|
+
userId?: string;
|
|
24
|
+
metadata?: Record<string, unknown>;
|
|
25
|
+
} = {}): Promise<AIConversation> {
|
|
26
|
+
const now = new Date().toISOString();
|
|
27
|
+
const id = `conv_${++this.counter}`;
|
|
28
|
+
|
|
29
|
+
const conversation: AIConversation = {
|
|
30
|
+
id,
|
|
31
|
+
title: options.title,
|
|
32
|
+
agentId: options.agentId,
|
|
33
|
+
userId: options.userId,
|
|
34
|
+
messages: [],
|
|
35
|
+
createdAt: now,
|
|
36
|
+
updatedAt: now,
|
|
37
|
+
metadata: options.metadata,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
this.store.set(id, conversation);
|
|
41
|
+
return conversation;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async get(conversationId: string): Promise<AIConversation | null> {
|
|
45
|
+
return this.store.get(conversationId) ?? null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async list(options: {
|
|
49
|
+
userId?: string;
|
|
50
|
+
agentId?: string;
|
|
51
|
+
limit?: number;
|
|
52
|
+
cursor?: string;
|
|
53
|
+
} = {}): Promise<AIConversation[]> {
|
|
54
|
+
let results = Array.from(this.store.values());
|
|
55
|
+
|
|
56
|
+
if (options.userId) {
|
|
57
|
+
results = results.filter(c => c.userId === options.userId);
|
|
58
|
+
}
|
|
59
|
+
if (options.agentId) {
|
|
60
|
+
results = results.filter(c => c.agentId === options.agentId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Simple cursor-based pagination: cursor = conversation ID
|
|
64
|
+
if (options.cursor) {
|
|
65
|
+
const idx = results.findIndex(c => c.id === options.cursor);
|
|
66
|
+
if (idx >= 0) {
|
|
67
|
+
results = results.slice(idx + 1);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (options.limit && options.limit > 0) {
|
|
72
|
+
results = results.slice(0, options.limit);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return results;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async addMessage(conversationId: string, message: AIMessage): Promise<AIConversation> {
|
|
79
|
+
const conversation = this.store.get(conversationId);
|
|
80
|
+
if (!conversation) {
|
|
81
|
+
throw new Error(`Conversation "${conversationId}" not found`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
conversation.messages.push(message);
|
|
85
|
+
conversation.updatedAt = new Date().toISOString();
|
|
86
|
+
return conversation;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async delete(conversationId: string): Promise<void> {
|
|
90
|
+
this.store.delete(conversationId);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Total number of stored conversations. */
|
|
94
|
+
get size(): number {
|
|
95
|
+
return this.store.size;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Clear all conversations. */
|
|
99
|
+
clear(): void {
|
|
100
|
+
this.store.clear();
|
|
101
|
+
this.counter = 0;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import type {
|
|
5
|
+
AIConversation,
|
|
6
|
+
AIMessage,
|
|
7
|
+
IAIConversationService,
|
|
8
|
+
IDataEngine,
|
|
9
|
+
} from '@objectstack/spec/contracts';
|
|
10
|
+
|
|
11
|
+
/** Object names used for persistence. */
|
|
12
|
+
const CONVERSATIONS_OBJECT = 'ai_conversations';
|
|
13
|
+
const MESSAGES_OBJECT = 'ai_messages';
|
|
14
|
+
|
|
15
|
+
/** Database row shape for ai_conversations. */
|
|
16
|
+
interface DbConversationRow {
|
|
17
|
+
id: string;
|
|
18
|
+
title: string | null;
|
|
19
|
+
agent_id: string | null;
|
|
20
|
+
user_id: string | null;
|
|
21
|
+
metadata: string | null;
|
|
22
|
+
created_at: string;
|
|
23
|
+
updated_at: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Database row shape for ai_messages. */
|
|
27
|
+
interface DbMessageRow {
|
|
28
|
+
id: string;
|
|
29
|
+
conversation_id: string;
|
|
30
|
+
role: 'system' | 'user' | 'assistant' | 'tool';
|
|
31
|
+
content: string;
|
|
32
|
+
tool_calls: string | null;
|
|
33
|
+
tool_call_id: string | null;
|
|
34
|
+
created_at: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Deterministic ordering for conversations (total order). */
|
|
38
|
+
const CONVERSATION_ORDER = [
|
|
39
|
+
{ field: 'created_at', order: 'asc' as const },
|
|
40
|
+
{ field: 'id', order: 'asc' as const },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Deterministic ordering for messages within a conversation. */
|
|
44
|
+
const MESSAGE_ORDER = [
|
|
45
|
+
{ field: 'created_at', order: 'asc' as const },
|
|
46
|
+
{ field: 'id', order: 'asc' as const },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* ObjectQLConversationService — Persistent implementation of IAIConversationService.
|
|
51
|
+
*
|
|
52
|
+
* Delegates all storage to an {@link IDataEngine} instance, using the
|
|
53
|
+
* `ai_conversations` and `ai_messages` objects. This decouples the service
|
|
54
|
+
* from any specific database driver (Turso, Postgres, SQLite, etc.).
|
|
55
|
+
*
|
|
56
|
+
* Production environments should use this implementation to ensure
|
|
57
|
+
* conversation history survives service restarts.
|
|
58
|
+
*/
|
|
59
|
+
export class ObjectQLConversationService implements IAIConversationService {
|
|
60
|
+
private readonly engine: IDataEngine;
|
|
61
|
+
|
|
62
|
+
constructor(engine: IDataEngine) {
|
|
63
|
+
this.engine = engine;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async create(options: {
|
|
67
|
+
title?: string;
|
|
68
|
+
agentId?: string;
|
|
69
|
+
userId?: string;
|
|
70
|
+
metadata?: Record<string, unknown>;
|
|
71
|
+
} = {}): Promise<AIConversation> {
|
|
72
|
+
const now = new Date().toISOString();
|
|
73
|
+
const id = `conv_${randomUUID()}`;
|
|
74
|
+
|
|
75
|
+
const record = {
|
|
76
|
+
id,
|
|
77
|
+
title: options.title ?? null,
|
|
78
|
+
agent_id: options.agentId ?? null,
|
|
79
|
+
user_id: options.userId ?? null,
|
|
80
|
+
metadata: options.metadata ? JSON.stringify(options.metadata) : null,
|
|
81
|
+
created_at: now,
|
|
82
|
+
updated_at: now,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
await this.engine.insert(CONVERSATIONS_OBJECT, record);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
id,
|
|
89
|
+
title: options.title,
|
|
90
|
+
agentId: options.agentId,
|
|
91
|
+
userId: options.userId,
|
|
92
|
+
messages: [],
|
|
93
|
+
createdAt: now,
|
|
94
|
+
updatedAt: now,
|
|
95
|
+
metadata: options.metadata,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async get(conversationId: string): Promise<AIConversation | null> {
|
|
100
|
+
const row: DbConversationRow | null = await this.engine.findOne(CONVERSATIONS_OBJECT, {
|
|
101
|
+
where: { id: conversationId },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!row) return null;
|
|
105
|
+
|
|
106
|
+
const messages: DbMessageRow[] = await this.engine.find(MESSAGES_OBJECT, {
|
|
107
|
+
where: { conversation_id: conversationId },
|
|
108
|
+
orderBy: MESSAGE_ORDER,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return this.toConversation(row, messages);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async list(options: {
|
|
115
|
+
userId?: string;
|
|
116
|
+
agentId?: string;
|
|
117
|
+
limit?: number;
|
|
118
|
+
cursor?: string;
|
|
119
|
+
} = {}): Promise<AIConversation[]> {
|
|
120
|
+
const where: Record<string, unknown> = {};
|
|
121
|
+
if (options.userId) where.user_id = options.userId;
|
|
122
|
+
if (options.agentId) where.agent_id = options.agentId;
|
|
123
|
+
|
|
124
|
+
// Stable cursor-based pagination using composite (created_at, id) order.
|
|
125
|
+
// This avoids skips/duplicates when multiple conversations share a timestamp.
|
|
126
|
+
if (options.cursor) {
|
|
127
|
+
const cursorRow = await this.engine.findOne(CONVERSATIONS_OBJECT, {
|
|
128
|
+
where: { id: options.cursor },
|
|
129
|
+
fields: ['created_at', 'id'],
|
|
130
|
+
});
|
|
131
|
+
if (cursorRow) {
|
|
132
|
+
where.$or = [
|
|
133
|
+
{ created_at: { $gt: cursorRow.created_at } },
|
|
134
|
+
{ created_at: cursorRow.created_at, id: { $gt: cursorRow.id } },
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const rows: DbConversationRow[] = await this.engine.find(CONVERSATIONS_OBJECT, {
|
|
140
|
+
where: Object.keys(where).length > 0 ? where : undefined,
|
|
141
|
+
orderBy: CONVERSATION_ORDER,
|
|
142
|
+
limit: options.limit && options.limit > 0 ? options.limit : undefined,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Load messages per conversation in parallel.
|
|
146
|
+
// N+1 is bounded by the pagination limit; driver-agnostic $in is not guaranteed.
|
|
147
|
+
const conversations: AIConversation[] = await Promise.all(
|
|
148
|
+
rows.map(async (row) => {
|
|
149
|
+
const messages: DbMessageRow[] = await this.engine.find(MESSAGES_OBJECT, {
|
|
150
|
+
where: { conversation_id: row.id },
|
|
151
|
+
orderBy: MESSAGE_ORDER,
|
|
152
|
+
});
|
|
153
|
+
return this.toConversation(row, messages);
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
return conversations;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async addMessage(conversationId: string, message: AIMessage): Promise<AIConversation> {
|
|
161
|
+
// Verify conversation exists
|
|
162
|
+
const row: DbConversationRow | null = await this.engine.findOne(CONVERSATIONS_OBJECT, {
|
|
163
|
+
where: { id: conversationId },
|
|
164
|
+
});
|
|
165
|
+
if (!row) {
|
|
166
|
+
throw new Error(`Conversation "${conversationId}" not found`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const now = new Date().toISOString();
|
|
170
|
+
const msgId = `msg_${randomUUID()}`;
|
|
171
|
+
|
|
172
|
+
// Insert the message
|
|
173
|
+
await this.engine.insert(MESSAGES_OBJECT, {
|
|
174
|
+
id: msgId,
|
|
175
|
+
conversation_id: conversationId,
|
|
176
|
+
role: message.role,
|
|
177
|
+
content: message.content,
|
|
178
|
+
tool_calls: message.toolCalls ? JSON.stringify(message.toolCalls) : null,
|
|
179
|
+
tool_call_id: message.toolCallId ?? null,
|
|
180
|
+
created_at: now,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Update conversation timestamp
|
|
184
|
+
await this.engine.update(CONVERSATIONS_OBJECT, { id: conversationId, updated_at: now }, {
|
|
185
|
+
where: { id: conversationId },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Return the full updated conversation
|
|
189
|
+
return (await this.get(conversationId))!;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async delete(conversationId: string): Promise<void> {
|
|
193
|
+
// Delete messages first (child records)
|
|
194
|
+
await this.engine.delete(MESSAGES_OBJECT, {
|
|
195
|
+
where: { conversation_id: conversationId },
|
|
196
|
+
multi: true,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Delete the conversation
|
|
200
|
+
await this.engine.delete(CONVERSATIONS_OBJECT, {
|
|
201
|
+
where: { id: conversationId },
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Private helpers ──────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Safely parse a JSON string, returning `undefined` on failure.
|
|
209
|
+
*/
|
|
210
|
+
private safeParse<T>(value: string | null, fallback?: T): T | undefined {
|
|
211
|
+
if (!value) return undefined;
|
|
212
|
+
try {
|
|
213
|
+
return JSON.parse(value) as T;
|
|
214
|
+
} catch {
|
|
215
|
+
return fallback;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Map a database row + message rows to an AIConversation.
|
|
221
|
+
*/
|
|
222
|
+
private toConversation(row: DbConversationRow, messageRows: DbMessageRow[]): AIConversation {
|
|
223
|
+
return {
|
|
224
|
+
id: row.id,
|
|
225
|
+
title: row.title ?? undefined,
|
|
226
|
+
agentId: row.agent_id ?? undefined,
|
|
227
|
+
userId: row.user_id ?? undefined,
|
|
228
|
+
messages: messageRows.map(m => this.toMessage(m)),
|
|
229
|
+
createdAt: row.created_at,
|
|
230
|
+
updatedAt: row.updated_at,
|
|
231
|
+
metadata: this.safeParse<Record<string, unknown>>(row.metadata),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Map a database row to an AIMessage.
|
|
237
|
+
*/
|
|
238
|
+
private toMessage(row: DbMessageRow): AIMessage {
|
|
239
|
+
const msg: AIMessage = {
|
|
240
|
+
role: row.role,
|
|
241
|
+
content: row.content,
|
|
242
|
+
};
|
|
243
|
+
const toolCalls = this.safeParse<any[]>(row.tool_calls);
|
|
244
|
+
if (toolCalls) {
|
|
245
|
+
msg.toolCalls = toolCalls;
|
|
246
|
+
}
|
|
247
|
+
if (row.tool_call_id) {
|
|
248
|
+
msg.toolCallId = row.tool_call_id;
|
|
249
|
+
}
|
|
250
|
+
return msg;
|
|
251
|
+
}
|
|
252
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
// Core service
|
|
4
|
+
export { AIService } from './ai-service.js';
|
|
5
|
+
export type { AIServiceConfig } from './ai-service.js';
|
|
6
|
+
|
|
7
|
+
// Kernel plugin
|
|
8
|
+
export { AIServicePlugin } from './plugin.js';
|
|
9
|
+
export type { AIServicePluginOptions } from './plugin.js';
|
|
10
|
+
|
|
11
|
+
// Adapters
|
|
12
|
+
export { MemoryLLMAdapter } from './adapters/memory-adapter.js';
|
|
13
|
+
export type { LLMAdapter } from '@objectstack/spec/contracts';
|
|
14
|
+
|
|
15
|
+
// Conversation
|
|
16
|
+
export { InMemoryConversationService } from './conversation/in-memory-conversation-service.js';
|
|
17
|
+
export { ObjectQLConversationService } from './conversation/objectql-conversation-service.js';
|
|
18
|
+
|
|
19
|
+
// Tool registry
|
|
20
|
+
export { ToolRegistry } from './tools/tool-registry.js';
|
|
21
|
+
export type { ToolHandler } from './tools/tool-registry.js';
|
|
22
|
+
|
|
23
|
+
// Data tools
|
|
24
|
+
export { registerDataTools, DATA_TOOL_DEFINITIONS } from './tools/data-tools.js';
|
|
25
|
+
export type { DataToolContext } from './tools/data-tools.js';
|
|
26
|
+
|
|
27
|
+
// Agent runtime
|
|
28
|
+
export { AgentRuntime } from './agent-runtime.js';
|
|
29
|
+
export type { AgentChatContext } from './agent-runtime.js';
|
|
30
|
+
|
|
31
|
+
// Built-in agents
|
|
32
|
+
export { DATA_CHAT_AGENT } from './agents/index.js';
|
|
33
|
+
|
|
34
|
+
// Object definitions
|
|
35
|
+
export { AiConversationObject, AiMessageObject } from './objects/index.js';
|
|
36
|
+
|
|
37
|
+
// Routes
|
|
38
|
+
export { buildAIRoutes } from './routes/ai-routes.js';
|
|
39
|
+
export { buildAgentRoutes } from './routes/agent-routes.js';
|
|
40
|
+
export type { RouteDefinition, RouteRequest, RouteResponse } from './routes/ai-routes.js';
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { ObjectSchema, Field } from '@objectstack/spec/data';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ai_conversations — AI Conversation Object
|
|
7
|
+
*
|
|
8
|
+
* Stores conversation metadata for persistent AI conversation management.
|
|
9
|
+
* Messages are stored separately in `ai_messages` to support efficient
|
|
10
|
+
* querying and pagination.
|
|
11
|
+
*
|
|
12
|
+
* @namespace ai
|
|
13
|
+
*/
|
|
14
|
+
export const AiConversationObject = ObjectSchema.create({
|
|
15
|
+
namespace: 'ai',
|
|
16
|
+
name: 'conversations',
|
|
17
|
+
label: 'AI Conversation',
|
|
18
|
+
pluralLabel: 'AI Conversations',
|
|
19
|
+
icon: 'message-square',
|
|
20
|
+
isSystem: true,
|
|
21
|
+
description: 'Persistent AI conversation metadata',
|
|
22
|
+
|
|
23
|
+
fields: {
|
|
24
|
+
id: Field.text({
|
|
25
|
+
label: 'Conversation ID',
|
|
26
|
+
required: true,
|
|
27
|
+
readonly: true,
|
|
28
|
+
}),
|
|
29
|
+
|
|
30
|
+
title: Field.text({
|
|
31
|
+
label: 'Title',
|
|
32
|
+
required: false,
|
|
33
|
+
maxLength: 500,
|
|
34
|
+
description: 'Conversation title or summary',
|
|
35
|
+
}),
|
|
36
|
+
|
|
37
|
+
agent_id: Field.text({
|
|
38
|
+
label: 'Agent ID',
|
|
39
|
+
required: false,
|
|
40
|
+
maxLength: 255,
|
|
41
|
+
description: 'Associated AI agent identifier',
|
|
42
|
+
}),
|
|
43
|
+
|
|
44
|
+
user_id: Field.text({
|
|
45
|
+
label: 'User ID',
|
|
46
|
+
required: false,
|
|
47
|
+
maxLength: 255,
|
|
48
|
+
description: 'User who owns the conversation',
|
|
49
|
+
}),
|
|
50
|
+
|
|
51
|
+
metadata: Field.textarea({
|
|
52
|
+
label: 'Metadata',
|
|
53
|
+
required: false,
|
|
54
|
+
description: 'JSON-serialized conversation metadata',
|
|
55
|
+
}),
|
|
56
|
+
|
|
57
|
+
created_at: Field.datetime({
|
|
58
|
+
label: 'Created At',
|
|
59
|
+
required: true,
|
|
60
|
+
defaultValue: 'NOW()',
|
|
61
|
+
readonly: true,
|
|
62
|
+
}),
|
|
63
|
+
|
|
64
|
+
updated_at: Field.datetime({
|
|
65
|
+
label: 'Updated At',
|
|
66
|
+
required: true,
|
|
67
|
+
defaultValue: 'NOW()',
|
|
68
|
+
readonly: true,
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
indexes: [
|
|
73
|
+
{ fields: ['user_id'] },
|
|
74
|
+
{ fields: ['agent_id'] },
|
|
75
|
+
{ fields: ['created_at'] },
|
|
76
|
+
],
|
|
77
|
+
|
|
78
|
+
enable: {
|
|
79
|
+
trackHistory: false,
|
|
80
|
+
searchable: false,
|
|
81
|
+
apiEnabled: true,
|
|
82
|
+
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
|
|
83
|
+
trash: false,
|
|
84
|
+
mru: false,
|
|
85
|
+
},
|
|
86
|
+
});
|