@objectstack/service-ai 4.0.4 → 4.0.5
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/dist/index.cjs +1176 -135
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1225 -430
- package/dist/index.d.ts +1225 -430
- package/dist/index.js +1160 -128
- package/dist/index.js.map +1 -1
- package/package.json +35 -8
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -61
- package/src/__tests__/ai-service.test.ts +0 -981
- package/src/__tests__/auth-and-toolcalling.test.ts +0 -677
- package/src/__tests__/chatbot-features.test.ts +0 -1116
- package/src/__tests__/metadata-tools.test.ts +0 -970
- package/src/__tests__/objectql-conversation-service.test.ts +0 -382
- package/src/__tests__/tool-routes.test.ts +0 -191
- package/src/__tests__/vercel-stream-encoder.test.ts +0 -310
- package/src/adapters/index.ts +0 -6
- package/src/adapters/memory-adapter.ts +0 -72
- package/src/adapters/types.ts +0 -3
- package/src/adapters/vercel-adapter.ts +0 -148
- package/src/agent-runtime.ts +0 -154
- package/src/agents/data-chat-agent.ts +0 -79
- package/src/agents/index.ts +0 -4
- package/src/agents/metadata-assistant-agent.ts +0 -87
- package/src/ai-service.ts +0 -364
- package/src/conversation/in-memory-conversation-service.ts +0 -103
- package/src/conversation/index.ts +0 -4
- package/src/conversation/objectql-conversation-service.ts +0 -301
- package/src/index.ts +0 -60
- package/src/objects/ai-conversation.object.ts +0 -86
- package/src/objects/ai-message.object.ts +0 -86
- package/src/objects/index.ts +0 -10
- package/src/plugin.ts +0 -391
- package/src/routes/agent-routes.ts +0 -190
- package/src/routes/ai-routes.ts +0 -439
- package/src/routes/index.ts +0 -5
- package/src/routes/message-utils.ts +0 -90
- package/src/routes/tool-routes.ts +0 -142
- package/src/stream/index.ts +0 -3
- package/src/stream/vercel-stream-encoder.ts +0 -153
- package/src/tools/add-field.tool.ts +0 -70
- package/src/tools/create-object.tool.ts +0 -66
- package/src/tools/data-tools.ts +0 -293
- package/src/tools/delete-field.tool.ts +0 -38
- package/src/tools/describe-object.tool.ts +0 -31
- package/src/tools/index.ts +0 -18
- package/src/tools/list-objects.tool.ts +0 -34
- package/src/tools/metadata-tools.ts +0 -430
- package/src/tools/modify-field.tool.ts +0 -44
- package/src/tools/tool-registry.ts +0 -132
- package/tsconfig.json +0 -17
- package/vitest.config.ts +0 -23
package/src/routes/ai-routes.ts
DELETED
|
@@ -1,439 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import type { IAIService, IAIConversationService, ModelMessage } from '@objectstack/spec/contracts';
|
|
4
|
-
import type { Logger } from '@objectstack/spec/contracts';
|
|
5
|
-
import { encodeVercelDataStream } from '../stream/vercel-stream-encoder.js';
|
|
6
|
-
import { normalizeMessage, validateMessageContent } from './message-utils.js';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Minimal HTTP handler abstraction so routes stay framework-agnostic.
|
|
10
|
-
*
|
|
11
|
-
* Consumers wire these handlers to their HTTP server of choice
|
|
12
|
-
* (Hono, Express, Fastify, etc.) via the kernel's HTTP server service.
|
|
13
|
-
*/
|
|
14
|
-
export interface RouteDefinition {
|
|
15
|
-
/** HTTP method */
|
|
16
|
-
method: 'GET' | 'POST' | 'DELETE';
|
|
17
|
-
/** Path pattern (e.g. '/api/v1/ai/chat') */
|
|
18
|
-
path: string;
|
|
19
|
-
/** Human-readable description */
|
|
20
|
-
description: string;
|
|
21
|
-
/** Whether this route requires authentication (default: true). */
|
|
22
|
-
auth?: boolean;
|
|
23
|
-
/** Required permissions for accessing this route. */
|
|
24
|
-
permissions?: string[];
|
|
25
|
-
/**
|
|
26
|
-
* Handler receives a plain request-like object and returns a response-like
|
|
27
|
-
* object. SSE responses set `stream: true` and provide an async iterable.
|
|
28
|
-
*/
|
|
29
|
-
handler: (req: RouteRequest) => Promise<RouteResponse>;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Authenticated user context attached to a route request.
|
|
34
|
-
*
|
|
35
|
-
* Populated by the auth middleware when `RouteDefinition.auth` is `true`.
|
|
36
|
-
*/
|
|
37
|
-
export interface RouteUserContext {
|
|
38
|
-
/** Unique user identifier. */
|
|
39
|
-
userId: string;
|
|
40
|
-
/** User display name (optional). */
|
|
41
|
-
displayName?: string;
|
|
42
|
-
/** Roles assigned to the user (e.g. `['admin', 'user']`). */
|
|
43
|
-
roles?: string[];
|
|
44
|
-
/** Fine-grained permissions (e.g. `['ai:chat', 'ai:admin']`). */
|
|
45
|
-
permissions?: string[];
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface RouteRequest {
|
|
49
|
-
/** Parsed JSON body (for POST requests) */
|
|
50
|
-
body?: unknown;
|
|
51
|
-
/** Route/query parameters */
|
|
52
|
-
params?: Record<string, string>;
|
|
53
|
-
/** Query string parameters */
|
|
54
|
-
query?: Record<string, string>;
|
|
55
|
-
/** Authenticated user context (populated by auth middleware). */
|
|
56
|
-
user?: RouteUserContext;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface RouteResponse {
|
|
60
|
-
/** HTTP status code */
|
|
61
|
-
status: number;
|
|
62
|
-
/** JSON-serializable body (for non-streaming responses) */
|
|
63
|
-
body?: unknown;
|
|
64
|
-
/** If true, `stream` provides SSE events */
|
|
65
|
-
stream?: boolean;
|
|
66
|
-
/** Async iterable of SSE events (when stream=true) */
|
|
67
|
-
events?: AsyncIterable<unknown>;
|
|
68
|
-
/**
|
|
69
|
-
* When `true`, the HTTP server layer should encode the `events` iterable
|
|
70
|
-
* using the Vercel AI Data Stream Protocol frame format (`0:`, `9:`, `d:`, …)
|
|
71
|
-
* instead of generic SSE `data:` lines.
|
|
72
|
-
*
|
|
73
|
-
* @see https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol
|
|
74
|
-
*/
|
|
75
|
-
vercelDataStream?: boolean;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Valid message roles accepted by the AI routes. */
|
|
79
|
-
const VALID_ROLES = new Set<string>(['system', 'user', 'assistant', 'tool']);
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Validate that `raw` is a well-formed message.
|
|
83
|
-
* Returns null on success, or an error string on failure.
|
|
84
|
-
*
|
|
85
|
-
* Accepts:
|
|
86
|
-
* - Simple string `content` (legacy)
|
|
87
|
-
* - Array `content` (e.g. `[{ type: 'text', text: '...' }]`)
|
|
88
|
-
* - Vercel AI SDK v6 `parts` format (content may be absent/null)
|
|
89
|
-
*/
|
|
90
|
-
function validateMessage(raw: unknown): string | null {
|
|
91
|
-
if (typeof raw !== 'object' || raw === null) {
|
|
92
|
-
return 'each message must be an object';
|
|
93
|
-
}
|
|
94
|
-
const msg = raw as Record<string, unknown>;
|
|
95
|
-
if (typeof msg.role !== 'string' || !VALID_ROLES.has(msg.role)) {
|
|
96
|
-
return `message.role must be one of ${[...VALID_ROLES].map(r => `"${r}"`).join(', ')}`;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Assistant / tool messages may legitimately have null or missing content
|
|
100
|
-
const allowEmpty = msg.role === 'assistant' || msg.role === 'tool';
|
|
101
|
-
return validateMessageContent(msg, { allowEmptyContent: allowEmpty });
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Build the standard AI REST/SSE routes.
|
|
106
|
-
*
|
|
107
|
-
* Depends on contracts ({@link IAIService} + {@link IAIConversationService})
|
|
108
|
-
* rather than concrete implementations, so any compliant service pair can
|
|
109
|
-
* be wired in.
|
|
110
|
-
*
|
|
111
|
-
* Routes:
|
|
112
|
-
* | Method | Path | Description |
|
|
113
|
-
* |:---|:---|:---|
|
|
114
|
-
* | POST | /api/v1/ai/chat | Synchronous chat completion |
|
|
115
|
-
* | POST | /api/v1/ai/chat/stream | SSE streaming chat completion |
|
|
116
|
-
* | POST | /api/v1/ai/complete | Text completion |
|
|
117
|
-
* | GET | /api/v1/ai/models | List available models |
|
|
118
|
-
* | POST | /api/v1/ai/conversations | Create a conversation |
|
|
119
|
-
* | GET | /api/v1/ai/conversations | List conversations |
|
|
120
|
-
* | POST | /api/v1/ai/conversations/:id/messages | Add message to conversation |
|
|
121
|
-
* | DELETE | /api/v1/ai/conversations/:id | Delete conversation |
|
|
122
|
-
*/
|
|
123
|
-
export function buildAIRoutes(
|
|
124
|
-
aiService: IAIService,
|
|
125
|
-
conversationService: IAIConversationService,
|
|
126
|
-
logger: Logger,
|
|
127
|
-
): RouteDefinition[] {
|
|
128
|
-
return [
|
|
129
|
-
// ── Chat ────────────────────────────────────────────────────
|
|
130
|
-
//
|
|
131
|
-
// Dual-mode endpoint compatible with both the legacy ObjectStack
|
|
132
|
-
// format (`{ messages, options }`) and the Vercel AI SDK useChat
|
|
133
|
-
// flat format (`{ messages, system, model, stream, … }`).
|
|
134
|
-
//
|
|
135
|
-
// Behaviour:
|
|
136
|
-
// • `stream !== false` → Vercel Data Stream Protocol (SSE)
|
|
137
|
-
// • `stream === false` → JSON response (legacy)
|
|
138
|
-
//
|
|
139
|
-
{
|
|
140
|
-
method: 'POST',
|
|
141
|
-
path: '/api/v1/ai/chat',
|
|
142
|
-
description: 'Chat completion (supports Vercel AI Data Stream Protocol)',
|
|
143
|
-
auth: true,
|
|
144
|
-
permissions: ['ai:chat'],
|
|
145
|
-
handler: async (req) => {
|
|
146
|
-
const body = (req.body ?? {}) as Record<string, unknown>;
|
|
147
|
-
|
|
148
|
-
// ── Parse messages ───────────────────────────────────
|
|
149
|
-
const messages = body.messages as unknown[] | undefined;
|
|
150
|
-
if (!Array.isArray(messages) || messages.length === 0) {
|
|
151
|
-
return { status: 400, body: { error: 'messages array is required' } };
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
for (const msg of messages) {
|
|
155
|
-
const err = validateMessage(msg);
|
|
156
|
-
if (err) return { status: 400, body: { error: err } };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// ── Resolve options ──────────────────────────────────
|
|
160
|
-
// Accept legacy nested `options` object **or** Vercel-style
|
|
161
|
-
// flat fields (`model`, `temperature`, `maxTokens`).
|
|
162
|
-
const nested = (body.options ?? {}) as Record<string, unknown>;
|
|
163
|
-
const resolvedOptions: Record<string, unknown> = {
|
|
164
|
-
...nested,
|
|
165
|
-
...(body.model != null && { model: body.model }),
|
|
166
|
-
...(body.temperature != null && { temperature: body.temperature }),
|
|
167
|
-
...(body.maxTokens != null && { maxTokens: body.maxTokens }),
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
// ── Prepend system prompt ────────────────────────────
|
|
171
|
-
// Vercel useChat sends `system` (or the deprecated `systemPrompt`)
|
|
172
|
-
// as a top-level field. We prepend it as a system message.
|
|
173
|
-
const rawSystemPrompt = body.system ?? body.systemPrompt;
|
|
174
|
-
if (rawSystemPrompt != null && typeof rawSystemPrompt !== 'string') {
|
|
175
|
-
return { status: 400, body: { error: 'system/systemPrompt must be a string' } };
|
|
176
|
-
}
|
|
177
|
-
const systemPrompt = rawSystemPrompt as string | undefined;
|
|
178
|
-
const finalMessages: ModelMessage[] = [
|
|
179
|
-
...(systemPrompt
|
|
180
|
-
? [{ role: 'system' as const, content: systemPrompt }]
|
|
181
|
-
: []),
|
|
182
|
-
...messages.map(m => normalizeMessage(m as Record<string, unknown>)),
|
|
183
|
-
];
|
|
184
|
-
|
|
185
|
-
// ── Choose response mode ─────────────────────────────
|
|
186
|
-
const wantStream = body.stream !== false;
|
|
187
|
-
|
|
188
|
-
if (wantStream) {
|
|
189
|
-
// UI Message Stream Protocol (SSE with JSON payloads)
|
|
190
|
-
try {
|
|
191
|
-
if (!(aiService as any).streamChatWithTools) {
|
|
192
|
-
return { status: 501, body: { error: 'Streaming is not supported by the configured AI service' } };
|
|
193
|
-
}
|
|
194
|
-
const events = (aiService as any).streamChatWithTools(finalMessages, resolvedOptions as any);
|
|
195
|
-
return {
|
|
196
|
-
status: 200,
|
|
197
|
-
stream: true,
|
|
198
|
-
vercelDataStream: true,
|
|
199
|
-
contentType: 'text/event-stream',
|
|
200
|
-
headers: {
|
|
201
|
-
'Content-Type': 'text/event-stream',
|
|
202
|
-
'Cache-Control': 'no-cache',
|
|
203
|
-
'Connection': 'keep-alive',
|
|
204
|
-
'x-vercel-ai-ui-message-stream': 'v1',
|
|
205
|
-
},
|
|
206
|
-
events: encodeVercelDataStream(events),
|
|
207
|
-
};
|
|
208
|
-
} catch (err) {
|
|
209
|
-
logger.error('[AI Route] /chat stream error', err instanceof Error ? err : undefined);
|
|
210
|
-
return { status: 500, body: { error: 'Internal AI service error' } };
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// JSON response (non-streaming)
|
|
215
|
-
try {
|
|
216
|
-
const result = await (aiService as any).chatWithTools(finalMessages, resolvedOptions as any);
|
|
217
|
-
return { status: 200, body: result };
|
|
218
|
-
} catch (err) {
|
|
219
|
-
logger.error('[AI Route] /chat error', err instanceof Error ? err : undefined);
|
|
220
|
-
return { status: 500, body: { error: 'Internal AI service error' } };
|
|
221
|
-
}
|
|
222
|
-
},
|
|
223
|
-
},
|
|
224
|
-
|
|
225
|
-
// ── Stream Chat (SSE) ──────────────────────────────────────
|
|
226
|
-
{
|
|
227
|
-
method: 'POST',
|
|
228
|
-
path: '/api/v1/ai/chat/stream',
|
|
229
|
-
description: 'SSE streaming chat completion',
|
|
230
|
-
auth: true,
|
|
231
|
-
permissions: ['ai:chat'],
|
|
232
|
-
handler: async (req) => {
|
|
233
|
-
const { messages, options } = (req.body ?? {}) as {
|
|
234
|
-
messages?: unknown[];
|
|
235
|
-
options?: Record<string, unknown>;
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
if (!Array.isArray(messages) || messages.length === 0) {
|
|
239
|
-
return { status: 400, body: { error: 'messages array is required' } };
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
for (const msg of messages) {
|
|
243
|
-
const err = validateMessage(msg);
|
|
244
|
-
if (err) return { status: 400, body: { error: err } };
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
try {
|
|
248
|
-
if (!aiService.streamChat) {
|
|
249
|
-
return { status: 501, body: { error: 'Streaming is not supported by the configured AI service' } };
|
|
250
|
-
}
|
|
251
|
-
const events = aiService.streamChat(messages.map(m => normalizeMessage(m as Record<string, unknown>)), options as any);
|
|
252
|
-
return { status: 200, stream: true, events };
|
|
253
|
-
} catch (err) {
|
|
254
|
-
logger.error('[AI Route] /chat/stream error', err instanceof Error ? err : undefined);
|
|
255
|
-
return { status: 500, body: { error: 'Internal AI service error' } };
|
|
256
|
-
}
|
|
257
|
-
},
|
|
258
|
-
},
|
|
259
|
-
|
|
260
|
-
// ── Complete ────────────────────────────────────────────────
|
|
261
|
-
{
|
|
262
|
-
method: 'POST',
|
|
263
|
-
path: '/api/v1/ai/complete',
|
|
264
|
-
description: 'Text completion',
|
|
265
|
-
auth: true,
|
|
266
|
-
permissions: ['ai:complete'],
|
|
267
|
-
handler: async (req) => {
|
|
268
|
-
const { prompt, options } = (req.body ?? {}) as {
|
|
269
|
-
prompt?: string;
|
|
270
|
-
options?: Record<string, unknown>;
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
if (!prompt || typeof prompt !== 'string') {
|
|
274
|
-
return { status: 400, body: { error: 'prompt string is required' } };
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
try {
|
|
278
|
-
const result = await aiService.complete(prompt, options as any);
|
|
279
|
-
return { status: 200, body: result };
|
|
280
|
-
} catch (err) {
|
|
281
|
-
logger.error('[AI Route] /complete error', err instanceof Error ? err : undefined);
|
|
282
|
-
return { status: 500, body: { error: 'Internal AI service error' } };
|
|
283
|
-
}
|
|
284
|
-
},
|
|
285
|
-
},
|
|
286
|
-
|
|
287
|
-
// ── Models ──────────────────────────────────────────────────
|
|
288
|
-
{
|
|
289
|
-
method: 'GET',
|
|
290
|
-
path: '/api/v1/ai/models',
|
|
291
|
-
description: 'List available models',
|
|
292
|
-
auth: true,
|
|
293
|
-
permissions: ['ai:read'],
|
|
294
|
-
handler: async () => {
|
|
295
|
-
try {
|
|
296
|
-
const models = aiService.listModels ? await aiService.listModels() : [];
|
|
297
|
-
return { status: 200, body: { models } };
|
|
298
|
-
} catch (err) {
|
|
299
|
-
logger.error('[AI Route] /models error', err instanceof Error ? err : undefined);
|
|
300
|
-
return { status: 500, body: { error: 'Internal AI service error' } };
|
|
301
|
-
}
|
|
302
|
-
},
|
|
303
|
-
},
|
|
304
|
-
|
|
305
|
-
// ── Conversations ──────────────────────────────────────────
|
|
306
|
-
{
|
|
307
|
-
method: 'POST',
|
|
308
|
-
path: '/api/v1/ai/conversations',
|
|
309
|
-
description: 'Create a conversation',
|
|
310
|
-
auth: true,
|
|
311
|
-
permissions: ['ai:conversations'],
|
|
312
|
-
handler: async (req) => {
|
|
313
|
-
try {
|
|
314
|
-
// Ensure the request body is a non-null object before mutating it
|
|
315
|
-
if (req.body !== undefined && req.body !== null && (typeof req.body !== 'object' || Array.isArray(req.body))) {
|
|
316
|
-
return { status: 400, body: { error: 'Invalid request payload' } };
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const options: Record<string, unknown> = { ...((req.body ?? {}) as Record<string, unknown>) };
|
|
320
|
-
// Bind the conversation to the authenticated user
|
|
321
|
-
if (req.user?.userId) {
|
|
322
|
-
options.userId = req.user.userId;
|
|
323
|
-
}
|
|
324
|
-
const conversation = await conversationService.create(options as any);
|
|
325
|
-
return { status: 201, body: conversation };
|
|
326
|
-
} catch (err) {
|
|
327
|
-
logger.error('[AI Route] POST /conversations error', err instanceof Error ? err : undefined);
|
|
328
|
-
return { status: 500, body: { error: 'Internal AI service error' } };
|
|
329
|
-
}
|
|
330
|
-
},
|
|
331
|
-
},
|
|
332
|
-
{
|
|
333
|
-
method: 'GET',
|
|
334
|
-
path: '/api/v1/ai/conversations',
|
|
335
|
-
description: 'List conversations',
|
|
336
|
-
auth: true,
|
|
337
|
-
permissions: ['ai:conversations'],
|
|
338
|
-
handler: async (req) => {
|
|
339
|
-
try {
|
|
340
|
-
const rawQuery = req.query ?? {};
|
|
341
|
-
const options: Record<string, unknown> = { ...rawQuery };
|
|
342
|
-
|
|
343
|
-
if (typeof rawQuery.limit === 'string') {
|
|
344
|
-
const parsedLimit = Number(rawQuery.limit);
|
|
345
|
-
if (!Number.isFinite(parsedLimit) || parsedLimit <= 0 || !Number.isInteger(parsedLimit)) {
|
|
346
|
-
return { status: 400, body: { error: 'Invalid limit parameter' } };
|
|
347
|
-
}
|
|
348
|
-
options.limit = parsedLimit;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// Scope to the authenticated user's conversations
|
|
352
|
-
if (req.user?.userId) {
|
|
353
|
-
options.userId = req.user.userId;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
const conversations = await conversationService.list(options as any);
|
|
357
|
-
return { status: 200, body: { conversations } };
|
|
358
|
-
} catch (err) {
|
|
359
|
-
logger.error('[AI Route] GET /conversations error', err instanceof Error ? err : undefined);
|
|
360
|
-
return { status: 500, body: { error: 'Internal AI service error' } };
|
|
361
|
-
}
|
|
362
|
-
},
|
|
363
|
-
},
|
|
364
|
-
{
|
|
365
|
-
method: 'POST',
|
|
366
|
-
path: '/api/v1/ai/conversations/:id/messages',
|
|
367
|
-
description: 'Add message to a conversation',
|
|
368
|
-
auth: true,
|
|
369
|
-
permissions: ['ai:conversations'],
|
|
370
|
-
handler: async (req) => {
|
|
371
|
-
const id = req.params?.id;
|
|
372
|
-
if (!id) {
|
|
373
|
-
return { status: 400, body: { error: 'conversation id is required' } };
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
const message = req.body;
|
|
377
|
-
const validationError = validateMessage(message);
|
|
378
|
-
if (validationError) {
|
|
379
|
-
return { status: 400, body: { error: validationError } };
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
try {
|
|
383
|
-
// Ownership check: verify the conversation belongs to the current user
|
|
384
|
-
if (req.user?.userId) {
|
|
385
|
-
const existing = await conversationService.get(id);
|
|
386
|
-
if (!existing) {
|
|
387
|
-
return { status: 404, body: { error: `Conversation "${id}" not found` } };
|
|
388
|
-
}
|
|
389
|
-
if (existing.userId && existing.userId !== req.user.userId) {
|
|
390
|
-
return { status: 403, body: { error: 'You do not have access to this conversation' } };
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
const conversation = await conversationService.addMessage(id, message as ModelMessage);
|
|
395
|
-
return { status: 200, body: conversation };
|
|
396
|
-
} catch (err) {
|
|
397
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
398
|
-
if (msg.includes('not found')) {
|
|
399
|
-
return { status: 404, body: { error: msg } };
|
|
400
|
-
}
|
|
401
|
-
logger.error('[AI Route] POST /conversations/:id/messages error', err instanceof Error ? err : undefined);
|
|
402
|
-
return { status: 500, body: { error: 'Internal AI service error' } };
|
|
403
|
-
}
|
|
404
|
-
},
|
|
405
|
-
},
|
|
406
|
-
{
|
|
407
|
-
method: 'DELETE',
|
|
408
|
-
path: '/api/v1/ai/conversations/:id',
|
|
409
|
-
description: 'Delete a conversation',
|
|
410
|
-
auth: true,
|
|
411
|
-
permissions: ['ai:conversations'],
|
|
412
|
-
handler: async (req) => {
|
|
413
|
-
const id = req.params?.id;
|
|
414
|
-
if (!id) {
|
|
415
|
-
return { status: 400, body: { error: 'conversation id is required' } };
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
try {
|
|
419
|
-
// Ownership check: verify the conversation belongs to the current user
|
|
420
|
-
if (req.user?.userId) {
|
|
421
|
-
const existing = await conversationService.get(id);
|
|
422
|
-
if (!existing) {
|
|
423
|
-
return { status: 404, body: { error: `Conversation "${id}" not found` } };
|
|
424
|
-
}
|
|
425
|
-
if (existing.userId && existing.userId !== req.user.userId) {
|
|
426
|
-
return { status: 403, body: { error: 'You do not have access to this conversation' } };
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
await conversationService.delete(id);
|
|
431
|
-
return { status: 204 };
|
|
432
|
-
} catch (err) {
|
|
433
|
-
logger.error('[AI Route] DELETE /conversations/:id error', err instanceof Error ? err : undefined);
|
|
434
|
-
return { status: 500, body: { error: 'Internal AI service error' } };
|
|
435
|
-
}
|
|
436
|
-
},
|
|
437
|
-
},
|
|
438
|
-
];
|
|
439
|
-
}
|
package/src/routes/index.ts
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
export { buildAIRoutes } from './ai-routes.js';
|
|
4
|
-
export type { RouteDefinition, RouteRequest, RouteResponse, RouteUserContext } from './ai-routes.js';
|
|
5
|
-
export { normalizeMessage, validateMessageContent } from './message-utils.js';
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import type { ModelMessage } from '@objectstack/spec/contracts';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Normalize a Vercel AI SDK v6 message (which may use `parts` instead of
|
|
7
|
-
* `content`) into a plain `{ role, content }` ModelMessage.
|
|
8
|
-
*
|
|
9
|
-
* Shared between the general chat routes and agent chat routes.
|
|
10
|
-
*/
|
|
11
|
-
export function normalizeMessage(raw: Record<string, unknown>): ModelMessage {
|
|
12
|
-
const role = raw.role as string;
|
|
13
|
-
|
|
14
|
-
// If content is already a string, use it directly
|
|
15
|
-
if (typeof raw.content === 'string') {
|
|
16
|
-
return { role, content: raw.content } as unknown as ModelMessage;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// If content is an array (multi-part), pass through
|
|
20
|
-
if (Array.isArray(raw.content)) {
|
|
21
|
-
return { role, content: raw.content } as unknown as ModelMessage;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// Vercel AI SDK v6: extract text from `parts` array
|
|
25
|
-
if (Array.isArray(raw.parts)) {
|
|
26
|
-
const textParts = (raw.parts as Array<Record<string, unknown>>)
|
|
27
|
-
.filter(p => p.type === 'text' && typeof p.text === 'string')
|
|
28
|
-
.map(p => p.text as string);
|
|
29
|
-
if (textParts.length > 0) {
|
|
30
|
-
return { role, content: textParts.join('') } as unknown as ModelMessage;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Fallback: empty content (e.g. tool-only assistant messages)
|
|
35
|
-
return { role, content: '' } as unknown as ModelMessage;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Validate message content/parts format (role-agnostic).
|
|
40
|
-
*
|
|
41
|
-
* Returns `null` when the content shape is valid, or an error string
|
|
42
|
-
* describing the first violation found.
|
|
43
|
-
*
|
|
44
|
-
* Accepts:
|
|
45
|
-
* - Simple string `content` (legacy)
|
|
46
|
-
* - Array `content` (e.g. `[{ type: 'text', text: '...' }]`)
|
|
47
|
-
* - Vercel AI SDK v6 `parts` format (content may be absent/null)
|
|
48
|
-
* - Null/undefined `content` for assistant messages (when `allowEmpty` is true)
|
|
49
|
-
*/
|
|
50
|
-
export function validateMessageContent(
|
|
51
|
-
msg: Record<string, unknown>,
|
|
52
|
-
opts?: { allowEmptyContent?: boolean },
|
|
53
|
-
): string | null {
|
|
54
|
-
const content = msg.content;
|
|
55
|
-
|
|
56
|
-
// Vercel AI SDK v6 sends `parts` instead of (or alongside) `content`.
|
|
57
|
-
// Accept any message that carries a `parts` array, even when `content` is absent.
|
|
58
|
-
if (Array.isArray(msg.parts)) {
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// content is a plain string — OK
|
|
63
|
-
if (typeof content === 'string') {
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// content is an array of typed parts (legacy multi-part format)
|
|
68
|
-
if (Array.isArray(content)) {
|
|
69
|
-
for (const part of content as unknown[]) {
|
|
70
|
-
if (typeof part !== 'object' || part === null) {
|
|
71
|
-
return 'message.content array elements must be non-null objects';
|
|
72
|
-
}
|
|
73
|
-
const partObj = part as Record<string, unknown>;
|
|
74
|
-
if (typeof partObj.type !== 'string') {
|
|
75
|
-
return 'each message.content array element must have a string "type" property';
|
|
76
|
-
}
|
|
77
|
-
if (partObj.type === 'text' && typeof partObj.text !== 'string') {
|
|
78
|
-
return 'message.content elements with type "text" must have a string "text" property';
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Allow empty content for certain roles (e.g. assistant tool-call messages)
|
|
85
|
-
if ((content === null || content === undefined) && opts?.allowEmptyContent) {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return 'message.content must be a string, an array, or include parts';
|
|
90
|
-
}
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import type { Logger } from '@objectstack/spec/contracts';
|
|
4
|
-
import type { AIService } from '../ai-service.js';
|
|
5
|
-
import type { RouteDefinition } from './ai-routes.js';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Extract string value from tool execution result output.
|
|
9
|
-
*/
|
|
10
|
-
function extractOutputValue(output: any): string {
|
|
11
|
-
if (!output) return '';
|
|
12
|
-
if (typeof output === 'string') return output;
|
|
13
|
-
if (typeof output === 'object' && 'value' in output) {
|
|
14
|
-
return String(output.value ?? '');
|
|
15
|
-
}
|
|
16
|
-
return JSON.stringify(output);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Build tool-specific REST routes.
|
|
21
|
-
*
|
|
22
|
-
* | Method | Path | Description |
|
|
23
|
-
* |:---|:---|:---|
|
|
24
|
-
* | GET | /api/v1/ai/tools | List all registered tools |
|
|
25
|
-
* | POST | /api/v1/ai/tools/:toolName/execute | Execute a tool with parameters |
|
|
26
|
-
*/
|
|
27
|
-
export function buildToolRoutes(
|
|
28
|
-
aiService: AIService,
|
|
29
|
-
logger: Logger,
|
|
30
|
-
): RouteDefinition[] {
|
|
31
|
-
return [
|
|
32
|
-
// ── List registered tools ──────────────────────────────────────
|
|
33
|
-
{
|
|
34
|
-
method: 'GET',
|
|
35
|
-
path: '/api/v1/ai/tools',
|
|
36
|
-
description: 'List all registered AI tools',
|
|
37
|
-
auth: true,
|
|
38
|
-
permissions: ['ai:tools'],
|
|
39
|
-
handler: async () => {
|
|
40
|
-
try {
|
|
41
|
-
const tools = aiService.toolRegistry.getAll();
|
|
42
|
-
return {
|
|
43
|
-
status: 200,
|
|
44
|
-
body: {
|
|
45
|
-
tools: tools.map(t => ({
|
|
46
|
-
name: t.name,
|
|
47
|
-
description: t.description,
|
|
48
|
-
category: (t as any).category,
|
|
49
|
-
}))
|
|
50
|
-
}
|
|
51
|
-
};
|
|
52
|
-
} catch (err) {
|
|
53
|
-
logger.error(
|
|
54
|
-
'[AI Route] /tools list error',
|
|
55
|
-
err instanceof Error ? err : undefined,
|
|
56
|
-
);
|
|
57
|
-
return { status: 500, body: { error: 'Internal AI service error' } };
|
|
58
|
-
}
|
|
59
|
-
},
|
|
60
|
-
},
|
|
61
|
-
|
|
62
|
-
// ── Execute a tool ──────────────────────────────────────────────
|
|
63
|
-
//
|
|
64
|
-
// Executes a tool with the provided parameters.
|
|
65
|
-
// This is intended for testing/playground use.
|
|
66
|
-
//
|
|
67
|
-
{
|
|
68
|
-
method: 'POST',
|
|
69
|
-
path: '/api/v1/ai/tools/:toolName/execute',
|
|
70
|
-
description: 'Execute a tool with parameters (playground/testing)',
|
|
71
|
-
auth: true,
|
|
72
|
-
permissions: ['ai:tools', 'ai:execute'],
|
|
73
|
-
handler: async (req) => {
|
|
74
|
-
const toolName = req.params?.toolName;
|
|
75
|
-
if (!toolName) {
|
|
76
|
-
return { status: 400, body: { error: 'toolName parameter is required' } };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Parse request body
|
|
80
|
-
const body = (req.body ?? {}) as Record<string, unknown>;
|
|
81
|
-
const { parameters } = body as {
|
|
82
|
-
parameters?: Record<string, unknown>;
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
if (!parameters || typeof parameters !== 'object') {
|
|
86
|
-
return { status: 400, body: { error: 'parameters object is required' } };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
// Check if tool exists
|
|
91
|
-
if (!aiService.toolRegistry.has(toolName)) {
|
|
92
|
-
return { status: 404, body: { error: `Tool "${toolName}" not found` } };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Execute the tool using ToolRegistry's execute method
|
|
96
|
-
const startTime = Date.now();
|
|
97
|
-
|
|
98
|
-
const toolCallPart = {
|
|
99
|
-
type: 'tool-call' as const,
|
|
100
|
-
toolCallId: `playground-${Date.now()}`,
|
|
101
|
-
toolName,
|
|
102
|
-
input: parameters,
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
const result = await aiService.toolRegistry.execute(toolCallPart);
|
|
106
|
-
const duration = Date.now() - startTime;
|
|
107
|
-
|
|
108
|
-
// Check if execution resulted in an error
|
|
109
|
-
if (result.isError) {
|
|
110
|
-
const errorMsg = extractOutputValue(result.output);
|
|
111
|
-
logger.error(
|
|
112
|
-
`[AI Route] Tool execution error: ${toolName}`,
|
|
113
|
-
new Error(errorMsg),
|
|
114
|
-
);
|
|
115
|
-
return {
|
|
116
|
-
status: 500,
|
|
117
|
-
body: {
|
|
118
|
-
error: errorMsg,
|
|
119
|
-
duration,
|
|
120
|
-
},
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return {
|
|
125
|
-
status: 200,
|
|
126
|
-
body: {
|
|
127
|
-
result: extractOutputValue(result.output),
|
|
128
|
-
duration,
|
|
129
|
-
toolName,
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
} catch (err) {
|
|
133
|
-
logger.error(
|
|
134
|
-
'[AI Route] /tools/:toolName/execute error',
|
|
135
|
-
err instanceof Error ? err : undefined,
|
|
136
|
-
);
|
|
137
|
-
return { status: 500, body: { error: 'Internal AI service error' } };
|
|
138
|
-
}
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
];
|
|
142
|
-
}
|
package/src/stream/index.ts
DELETED