@objectstack/service-ai 4.0.1 → 4.0.2
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 +11 -11
- package/CHANGELOG.md +9 -0
- package/dist/index.cjs +1120 -66
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +316 -78
- package/dist/index.d.ts +316 -78
- package/dist/index.js +1105 -63
- package/dist/index.js.map +1 -1
- package/package.json +26 -4
- package/src/__tests__/ai-service.test.ts +248 -27
- package/src/__tests__/auth-and-toolcalling.test.ts +30 -28
- package/src/__tests__/chatbot-features.test.ts +229 -82
- package/src/__tests__/metadata-tools.test.ts +964 -0
- package/src/__tests__/objectql-conversation-service.test.ts +34 -16
- package/src/__tests__/vercel-stream-encoder.test.ts +263 -0
- package/src/adapters/index.ts +2 -0
- package/src/adapters/memory-adapter.ts +17 -9
- package/src/adapters/vercel-adapter.ts +148 -0
- package/src/agent-runtime.ts +27 -3
- package/src/agents/index.ts +1 -0
- package/src/agents/metadata-assistant-agent.ts +87 -0
- package/src/ai-service.ts +68 -36
- package/src/conversation/in-memory-conversation-service.ts +2 -2
- package/src/conversation/objectql-conversation-service.ts +67 -18
- package/src/index.ts +21 -2
- package/src/plugin.ts +166 -9
- package/src/routes/agent-routes.ts +26 -3
- package/src/routes/ai-routes.ts +156 -13
- package/src/stream/index.ts +3 -0
- package/src/stream/vercel-stream-encoder.ts +129 -0
- package/src/tools/add-field.tool.ts +70 -0
- package/src/tools/create-object.tool.ts +66 -0
- package/src/tools/delete-field.tool.ts +38 -0
- package/src/tools/describe-metadata-object.tool.ts +32 -0
- package/src/tools/index.ts +12 -1
- package/src/tools/list-metadata-objects.tool.ts +34 -0
- package/src/tools/metadata-tools.ts +430 -0
- package/src/tools/modify-field.tool.ts +44 -0
- package/src/tools/tool-registry.ts +32 -9
package/src/plugin.ts
CHANGED
|
@@ -9,8 +9,11 @@ import { buildAgentRoutes } from './routes/agent-routes.js';
|
|
|
9
9
|
import { ObjectQLConversationService } from './conversation/objectql-conversation-service.js';
|
|
10
10
|
import { AiConversationObject, AiMessageObject } from './objects/index.js';
|
|
11
11
|
import { registerDataTools } from './tools/data-tools.js';
|
|
12
|
+
import { registerMetadataTools } from './tools/metadata-tools.js';
|
|
12
13
|
import { AgentRuntime } from './agent-runtime.js';
|
|
13
|
-
import { DATA_CHAT_AGENT } from './agents/index.js';
|
|
14
|
+
import { DATA_CHAT_AGENT, METADATA_ASSISTANT_AGENT } from './agents/index.js';
|
|
15
|
+
import { VercelLLMAdapter } from './adapters/vercel-adapter.js';
|
|
16
|
+
import { MemoryLLMAdapter } from './adapters/memory-adapter.js';
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* Configuration options for the AIServicePlugin.
|
|
@@ -51,7 +54,7 @@ export class AIServicePlugin implements Plugin {
|
|
|
51
54
|
name = 'com.objectstack.service-ai';
|
|
52
55
|
version = '1.0.0';
|
|
53
56
|
type = 'standard' as const;
|
|
54
|
-
dependencies: string[] = [];
|
|
57
|
+
dependencies: string[] = ['com.objectstack.engine.objectql']; // manifest service required
|
|
55
58
|
|
|
56
59
|
private service?: AIService;
|
|
57
60
|
private readonly options: AIServicePluginOptions;
|
|
@@ -60,6 +63,90 @@ export class AIServicePlugin implements Plugin {
|
|
|
60
63
|
this.options = options;
|
|
61
64
|
}
|
|
62
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Auto-detect LLM provider from environment variables.
|
|
68
|
+
*
|
|
69
|
+
* Priority order:
|
|
70
|
+
* 1. AI_GATEWAY_MODEL → Vercel AI Gateway
|
|
71
|
+
* 2. OPENAI_API_KEY → OpenAI
|
|
72
|
+
* 3. ANTHROPIC_API_KEY → Anthropic
|
|
73
|
+
* 4. GOOGLE_GENERATIVE_AI_API_KEY → Google
|
|
74
|
+
* 5. Fallback → MemoryLLMAdapter
|
|
75
|
+
*
|
|
76
|
+
* Returns the adapter and a description for logging.
|
|
77
|
+
*/
|
|
78
|
+
private async detectAdapter(ctx: PluginContext): Promise<{ adapter: LLMAdapter; description: string }> {
|
|
79
|
+
// 1. Vercel AI Gateway — works with any provider via gateway('provider/model')
|
|
80
|
+
const gatewayModel = process.env.AI_GATEWAY_MODEL;
|
|
81
|
+
if (gatewayModel) {
|
|
82
|
+
try {
|
|
83
|
+
const gatewayPkg = '@ai-sdk/gateway';
|
|
84
|
+
const { gateway } = await import(/* webpackIgnore: true */ gatewayPkg);
|
|
85
|
+
const adapter = new VercelLLMAdapter({ model: gateway(gatewayModel) });
|
|
86
|
+
return { adapter, description: `Vercel AI Gateway (model: ${gatewayModel})` };
|
|
87
|
+
} catch (err) {
|
|
88
|
+
ctx.logger.warn(
|
|
89
|
+
`[AI] Failed to load @ai-sdk/gateway for AI_GATEWAY_MODEL=${gatewayModel}, trying next provider`,
|
|
90
|
+
err instanceof Error ? { error: err.message } : undefined
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 2. Direct provider SDKs
|
|
96
|
+
const providerConfigs: Array<{
|
|
97
|
+
envKey: string;
|
|
98
|
+
pkg: string;
|
|
99
|
+
factory: string;
|
|
100
|
+
defaultModel: string;
|
|
101
|
+
displayName: string;
|
|
102
|
+
}> = [
|
|
103
|
+
{
|
|
104
|
+
envKey: 'OPENAI_API_KEY',
|
|
105
|
+
pkg: '@ai-sdk/openai',
|
|
106
|
+
factory: 'openai',
|
|
107
|
+
defaultModel: 'gpt-4o',
|
|
108
|
+
displayName: 'OpenAI'
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
envKey: 'ANTHROPIC_API_KEY',
|
|
112
|
+
pkg: '@ai-sdk/anthropic',
|
|
113
|
+
factory: 'anthropic',
|
|
114
|
+
defaultModel: 'claude-sonnet-4-20250514',
|
|
115
|
+
displayName: 'Anthropic'
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
envKey: 'GOOGLE_GENERATIVE_AI_API_KEY',
|
|
119
|
+
pkg: '@ai-sdk/google',
|
|
120
|
+
factory: 'google',
|
|
121
|
+
defaultModel: 'gemini-2.0-flash',
|
|
122
|
+
displayName: 'Google'
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
for (const { envKey, pkg, factory, defaultModel, displayName } of providerConfigs) {
|
|
127
|
+
if (process.env[envKey]) {
|
|
128
|
+
try {
|
|
129
|
+
const mod = await import(/* webpackIgnore: true */ pkg);
|
|
130
|
+
const createModel = mod[factory] ?? mod.default;
|
|
131
|
+
if (typeof createModel === 'function') {
|
|
132
|
+
const modelId = process.env.AI_MODEL ?? defaultModel;
|
|
133
|
+
const adapter = new VercelLLMAdapter({ model: createModel(modelId) });
|
|
134
|
+
return { adapter, description: `${displayName} (model: ${modelId})` };
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
ctx.logger.warn(
|
|
138
|
+
`[AI] Failed to load ${pkg} for ${envKey}, trying next provider`,
|
|
139
|
+
err instanceof Error ? { error: err.message } : undefined
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 3. Fallback to MemoryLLMAdapter
|
|
146
|
+
ctx.logger.warn('[AI] No LLM provider configured via environment variables. Falling back to MemoryLLMAdapter (echo mode). Set AI_GATEWAY_MODEL, OPENAI_API_KEY, ANTHROPIC_API_KEY, or GOOGLE_GENERATIVE_AI_API_KEY to use a real LLM.');
|
|
147
|
+
return { adapter: new MemoryLLMAdapter(), description: 'MemoryLLMAdapter (echo mode - for testing only)' };
|
|
148
|
+
}
|
|
149
|
+
|
|
63
150
|
async init(ctx: PluginContext): Promise<void> {
|
|
64
151
|
// Check if there is an existing AI service (e.g. from dev-plugin)
|
|
65
152
|
let hasExisting = false;
|
|
@@ -87,8 +174,26 @@ export class AIServicePlugin implements Plugin {
|
|
|
87
174
|
}
|
|
88
175
|
}
|
|
89
176
|
|
|
177
|
+
// Determine LLM adapter: explicit > auto-detect from env > MemoryLLMAdapter fallback
|
|
178
|
+
let adapter: LLMAdapter;
|
|
179
|
+
let adapterDescription: string;
|
|
180
|
+
|
|
181
|
+
if (this.options.adapter) {
|
|
182
|
+
// User provided an explicit adapter
|
|
183
|
+
adapter = this.options.adapter;
|
|
184
|
+
adapterDescription = `${adapter.name} (explicitly configured)`;
|
|
185
|
+
} else {
|
|
186
|
+
// Auto-detect from environment variables
|
|
187
|
+
const detected = await this.detectAdapter(ctx);
|
|
188
|
+
adapter = detected.adapter;
|
|
189
|
+
adapterDescription = detected.description;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Log the selected adapter
|
|
193
|
+
ctx.logger.info(`[AI] Using LLM adapter: ${adapterDescription}`);
|
|
194
|
+
|
|
90
195
|
const config: AIServiceConfig = {
|
|
91
|
-
adapter
|
|
196
|
+
adapter,
|
|
92
197
|
logger: ctx.logger,
|
|
93
198
|
conversationService,
|
|
94
199
|
};
|
|
@@ -102,8 +207,8 @@ export class AIServicePlugin implements Plugin {
|
|
|
102
207
|
ctx.registerService('ai', this.service);
|
|
103
208
|
}
|
|
104
209
|
|
|
105
|
-
// Register AI system objects
|
|
106
|
-
ctx.
|
|
210
|
+
// Register AI system objects via the manifest service.
|
|
211
|
+
ctx.getService<{ register(m: any): void }>('manifest').register({
|
|
107
212
|
id: 'com.objectstack.service-ai',
|
|
108
213
|
name: 'AI Service',
|
|
109
214
|
version: '1.0.0',
|
|
@@ -118,16 +223,40 @@ export class AIServicePlugin implements Plugin {
|
|
|
118
223
|
});
|
|
119
224
|
}
|
|
120
225
|
|
|
226
|
+
// Contribute navigation items to the Setup App (if SetupPlugin is loaded).
|
|
227
|
+
try {
|
|
228
|
+
const setupNav = ctx.getService<{ contribute(c: any): void }>('setupNav');
|
|
229
|
+
if (setupNav) {
|
|
230
|
+
setupNav.contribute({
|
|
231
|
+
areaId: 'area_ai',
|
|
232
|
+
items: [
|
|
233
|
+
{ id: 'nav_ai_conversations', type: 'object', label: { key: 'setup.nav.ai_conversations', defaultValue: 'Conversations' }, objectName: 'conversations', icon: 'message-square', order: 10 },
|
|
234
|
+
{ id: 'nav_ai_messages', type: 'object', label: { key: 'setup.nav.ai_messages', defaultValue: 'Messages' }, objectName: 'messages', icon: 'messages-square', order: 20 },
|
|
235
|
+
],
|
|
236
|
+
});
|
|
237
|
+
ctx.logger.info('[AI] Navigation items contributed to Setup App');
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
// SetupPlugin not loaded — skip silently
|
|
241
|
+
}
|
|
242
|
+
|
|
121
243
|
ctx.logger.info('[AI] Service initialized');
|
|
122
244
|
}
|
|
123
245
|
|
|
124
246
|
async start(ctx: PluginContext): Promise<void> {
|
|
125
247
|
if (!this.service) return;
|
|
126
248
|
|
|
127
|
-
// ── Auto-register built-in
|
|
249
|
+
// ── Auto-register built-in tools & agents when services are available ──
|
|
250
|
+
let metadataService: IMetadataService | undefined;
|
|
251
|
+
try {
|
|
252
|
+
metadataService = ctx.getService<IMetadataService>('metadata');
|
|
253
|
+
} catch {
|
|
254
|
+
ctx.logger.debug('[AI] Metadata service not available');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Data tools require both data engine and metadata service
|
|
128
258
|
try {
|
|
129
259
|
const dataEngine = ctx.getService<IDataEngine>('data');
|
|
130
|
-
const metadataService = ctx.getService<IMetadataService>('metadata');
|
|
131
260
|
if (dataEngine && metadataService) {
|
|
132
261
|
registerDataTools(this.service.toolRegistry, { dataEngine, metadataService });
|
|
133
262
|
ctx.logger.info('[AI] Built-in data tools registered');
|
|
@@ -146,8 +275,30 @@ export class AIServicePlugin implements Plugin {
|
|
|
146
275
|
}
|
|
147
276
|
}
|
|
148
277
|
} catch {
|
|
149
|
-
|
|
150
|
-
|
|
278
|
+
ctx.logger.debug('[AI] Data engine not available, skipping data tools');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Metadata tools require only the metadata service
|
|
282
|
+
if (metadataService) {
|
|
283
|
+
try {
|
|
284
|
+
registerMetadataTools(this.service.toolRegistry, { metadataService });
|
|
285
|
+
ctx.logger.info('[AI] Built-in metadata tools registered');
|
|
286
|
+
|
|
287
|
+
// Register the built-in metadata_assistant agent
|
|
288
|
+
const agentExists =
|
|
289
|
+
typeof metadataService.exists === 'function'
|
|
290
|
+
? await metadataService.exists('agent', METADATA_ASSISTANT_AGENT.name)
|
|
291
|
+
: false;
|
|
292
|
+
|
|
293
|
+
if (!agentExists) {
|
|
294
|
+
await metadataService.register('agent', METADATA_ASSISTANT_AGENT.name, METADATA_ASSISTANT_AGENT);
|
|
295
|
+
ctx.logger.info('[AI] metadata_assistant agent registered');
|
|
296
|
+
} else {
|
|
297
|
+
ctx.logger.debug('[AI] metadata_assistant agent already exists, skipping auto-registration');
|
|
298
|
+
}
|
|
299
|
+
} catch (err) {
|
|
300
|
+
ctx.logger.debug('[AI] Failed to register metadata tools', err instanceof Error ? err : undefined);
|
|
301
|
+
}
|
|
151
302
|
}
|
|
152
303
|
|
|
153
304
|
// Trigger hook to notify AI service is ready — other plugins can register tools
|
|
@@ -171,6 +322,12 @@ export class AIServicePlugin implements Plugin {
|
|
|
171
322
|
// Trigger hook so HTTP server plugins can mount these routes
|
|
172
323
|
await ctx.trigger('ai:routes', routes);
|
|
173
324
|
|
|
325
|
+
// Cache routes on the kernel so HttpDispatcher can find them
|
|
326
|
+
const kernel = ctx.getKernel();
|
|
327
|
+
if (kernel) {
|
|
328
|
+
(kernel as any).__aiRoutes = routes;
|
|
329
|
+
}
|
|
330
|
+
|
|
174
331
|
ctx.logger.info(
|
|
175
332
|
`[AI] Service started — adapter="${this.service.adapterName}", ` +
|
|
176
333
|
`tools=${this.service.toolRegistry.size}, ` +
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type { ModelMessage } from '@objectstack/spec/contracts';
|
|
4
4
|
import type { Logger } from '@objectstack/spec/contracts';
|
|
5
5
|
import type { AIService } from '../ai-service.js';
|
|
6
6
|
import type { AgentRuntime, AgentChatContext } from '../agent-runtime.js';
|
|
@@ -36,6 +36,7 @@ function validateAgentMessage(raw: unknown): string | null {
|
|
|
36
36
|
*
|
|
37
37
|
* | Method | Path | Description |
|
|
38
38
|
* |:---|:---|:---|
|
|
39
|
+
* | GET | /api/v1/ai/agents | List all active agents |
|
|
39
40
|
* | POST | /api/v1/ai/agents/:agentName/chat | Chat with a specific agent |
|
|
40
41
|
*/
|
|
41
42
|
export function buildAgentRoutes(
|
|
@@ -44,6 +45,28 @@ export function buildAgentRoutes(
|
|
|
44
45
|
logger: Logger,
|
|
45
46
|
): RouteDefinition[] {
|
|
46
47
|
return [
|
|
48
|
+
// ── List active agents ──────────────────────────────────────
|
|
49
|
+
{
|
|
50
|
+
method: 'GET',
|
|
51
|
+
path: '/api/v1/ai/agents',
|
|
52
|
+
description: 'List all active AI agents',
|
|
53
|
+
auth: true,
|
|
54
|
+
permissions: ['ai:chat'],
|
|
55
|
+
handler: async () => {
|
|
56
|
+
try {
|
|
57
|
+
const agents = await agentRuntime.listAgents();
|
|
58
|
+
return { status: 200, body: { agents } };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
logger.error(
|
|
61
|
+
'[AI Route] /agents list error',
|
|
62
|
+
err instanceof Error ? err : undefined,
|
|
63
|
+
);
|
|
64
|
+
return { status: 500, body: { error: 'Internal AI service error' } };
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
// ── Chat with a specific agent ──────────────────────────────
|
|
47
70
|
{
|
|
48
71
|
method: 'POST',
|
|
49
72
|
path: '/api/v1/ai/agents/:agentName/chat',
|
|
@@ -109,9 +132,9 @@ export function buildAgentRoutes(
|
|
|
109
132
|
const mergedOptions = { ...agentOptions, ...safeOverrides };
|
|
110
133
|
|
|
111
134
|
// Prepend system messages then user conversation
|
|
112
|
-
const fullMessages:
|
|
135
|
+
const fullMessages: ModelMessage[] = [
|
|
113
136
|
...systemMessages,
|
|
114
|
-
...(rawMessages as
|
|
137
|
+
...(rawMessages as ModelMessage[]),
|
|
115
138
|
];
|
|
116
139
|
|
|
117
140
|
// Use chatWithTools for automatic tool resolution
|
package/src/routes/ai-routes.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
2
|
|
|
3
|
-
import type { IAIService, IAIConversationService,
|
|
3
|
+
import type { IAIService, IAIConversationService, ModelMessage } from '@objectstack/spec/contracts';
|
|
4
4
|
import type { Logger } from '@objectstack/spec/contracts';
|
|
5
|
+
import { encodeVercelDataStream } from '../stream/vercel-stream-encoder.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Minimal HTTP handler abstraction so routes stay framework-agnostic.
|
|
@@ -63,14 +64,58 @@ export interface RouteResponse {
|
|
|
63
64
|
stream?: boolean;
|
|
64
65
|
/** Async iterable of SSE events (when stream=true) */
|
|
65
66
|
events?: AsyncIterable<unknown>;
|
|
67
|
+
/**
|
|
68
|
+
* When `true`, the HTTP server layer should encode the `events` iterable
|
|
69
|
+
* using the Vercel AI Data Stream Protocol frame format (`0:`, `9:`, `d:`, …)
|
|
70
|
+
* instead of generic SSE `data:` lines.
|
|
71
|
+
*
|
|
72
|
+
* @see https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol
|
|
73
|
+
*/
|
|
74
|
+
vercelDataStream?: boolean;
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
/** Valid message roles accepted by the AI routes. */
|
|
69
78
|
const VALID_ROLES = new Set<string>(['system', 'user', 'assistant', 'tool']);
|
|
70
79
|
|
|
71
80
|
/**
|
|
72
|
-
*
|
|
81
|
+
* Normalize a Vercel AI SDK v6 message (which may use `parts` instead of
|
|
82
|
+
* `content`) into a plain `{ role, content }` ModelMessage.
|
|
83
|
+
*/
|
|
84
|
+
function normalizeMessage(raw: Record<string, unknown>): ModelMessage {
|
|
85
|
+
const role = raw.role as string;
|
|
86
|
+
|
|
87
|
+
// If content is already a string, use it directly
|
|
88
|
+
if (typeof raw.content === 'string') {
|
|
89
|
+
return { role, content: raw.content } as unknown as ModelMessage;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// If content is an array (multi-part), pass through
|
|
93
|
+
if (Array.isArray(raw.content)) {
|
|
94
|
+
return { role, content: raw.content } as unknown as ModelMessage;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Vercel AI SDK v6: extract text from `parts` array
|
|
98
|
+
if (Array.isArray(raw.parts)) {
|
|
99
|
+
const textParts = (raw.parts as Array<Record<string, unknown>>)
|
|
100
|
+
.filter(p => p.type === 'text' && typeof p.text === 'string')
|
|
101
|
+
.map(p => p.text as string);
|
|
102
|
+
if (textParts.length > 0) {
|
|
103
|
+
return { role, content: textParts.join('') } as unknown as ModelMessage;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Fallback: empty content (e.g. tool-only assistant messages)
|
|
108
|
+
return { role, content: '' } as unknown as ModelMessage;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Validate that `raw` is a well-formed message.
|
|
73
113
|
* Returns null on success, or an error string on failure.
|
|
114
|
+
*
|
|
115
|
+
* Accepts:
|
|
116
|
+
* - Simple string `content` (legacy)
|
|
117
|
+
* - Array `content` (e.g. `[{ type: 'text', text: '...' }]`)
|
|
118
|
+
* - Vercel AI SDK v6 `parts` format (content may be absent/null)
|
|
74
119
|
*/
|
|
75
120
|
function validateMessage(raw: unknown): string | null {
|
|
76
121
|
if (typeof raw !== 'object' || raw === null) {
|
|
@@ -80,10 +125,44 @@ function validateMessage(raw: unknown): string | null {
|
|
|
80
125
|
if (typeof msg.role !== 'string' || !VALID_ROLES.has(msg.role)) {
|
|
81
126
|
return `message.role must be one of ${[...VALID_ROLES].map(r => `"${r}"`).join(', ')}`;
|
|
82
127
|
}
|
|
83
|
-
|
|
84
|
-
|
|
128
|
+
const content = msg.content;
|
|
129
|
+
|
|
130
|
+
// Vercel AI SDK v6 sends `parts` instead of (or alongside) `content`.
|
|
131
|
+
// Accept any message that carries a `parts` array, even when `content` is absent.
|
|
132
|
+
if (Array.isArray(msg.parts)) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// content is a plain string — OK
|
|
137
|
+
if (typeof content === 'string') {
|
|
138
|
+
return null;
|
|
85
139
|
}
|
|
86
|
-
|
|
140
|
+
|
|
141
|
+
// content is an array of typed parts (legacy multi-part format)
|
|
142
|
+
if (Array.isArray(content)) {
|
|
143
|
+
for (const part of content as unknown[]) {
|
|
144
|
+
if (typeof part !== 'object' || part === null) {
|
|
145
|
+
return 'message.content array elements must be non-null objects';
|
|
146
|
+
}
|
|
147
|
+
const partObj = part as Record<string, unknown>;
|
|
148
|
+
if (typeof partObj.type !== 'string') {
|
|
149
|
+
return 'each message.content array element must have a string "type" property';
|
|
150
|
+
}
|
|
151
|
+
if (partObj.type === 'text' && typeof partObj.text !== 'string') {
|
|
152
|
+
return 'message.content elements with type "text" must have a string "text" property';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Assistant / tool messages may legitimately have null or missing content
|
|
159
|
+
if (content === null || content === undefined) {
|
|
160
|
+
if (msg.role === 'assistant' || msg.role === 'tool') {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return 'message.content must be a string, an array, or include parts';
|
|
87
166
|
}
|
|
88
167
|
|
|
89
168
|
/**
|
|
@@ -112,18 +191,26 @@ export function buildAIRoutes(
|
|
|
112
191
|
): RouteDefinition[] {
|
|
113
192
|
return [
|
|
114
193
|
// ── Chat ────────────────────────────────────────────────────
|
|
194
|
+
//
|
|
195
|
+
// Dual-mode endpoint compatible with both the legacy ObjectStack
|
|
196
|
+
// format (`{ messages, options }`) and the Vercel AI SDK useChat
|
|
197
|
+
// flat format (`{ messages, system, model, stream, … }`).
|
|
198
|
+
//
|
|
199
|
+
// Behaviour:
|
|
200
|
+
// • `stream !== false` → Vercel Data Stream Protocol (SSE)
|
|
201
|
+
// • `stream === false` → JSON response (legacy)
|
|
202
|
+
//
|
|
115
203
|
{
|
|
116
204
|
method: 'POST',
|
|
117
205
|
path: '/api/v1/ai/chat',
|
|
118
|
-
description: '
|
|
206
|
+
description: 'Chat completion (supports Vercel AI Data Stream Protocol)',
|
|
119
207
|
auth: true,
|
|
120
208
|
permissions: ['ai:chat'],
|
|
121
209
|
handler: async (req) => {
|
|
122
|
-
const
|
|
123
|
-
messages?: unknown[];
|
|
124
|
-
options?: Record<string, unknown>;
|
|
125
|
-
};
|
|
210
|
+
const body = (req.body ?? {}) as Record<string, unknown>;
|
|
126
211
|
|
|
212
|
+
// ── Parse messages ───────────────────────────────────
|
|
213
|
+
const messages = body.messages as unknown[] | undefined;
|
|
127
214
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
128
215
|
return { status: 400, body: { error: 'messages array is required' } };
|
|
129
216
|
}
|
|
@@ -133,8 +220,64 @@ export function buildAIRoutes(
|
|
|
133
220
|
if (err) return { status: 400, body: { error: err } };
|
|
134
221
|
}
|
|
135
222
|
|
|
223
|
+
// ── Resolve options ──────────────────────────────────
|
|
224
|
+
// Accept legacy nested `options` object **or** Vercel-style
|
|
225
|
+
// flat fields (`model`, `temperature`, `maxTokens`).
|
|
226
|
+
const nested = (body.options ?? {}) as Record<string, unknown>;
|
|
227
|
+
const resolvedOptions: Record<string, unknown> = {
|
|
228
|
+
...nested,
|
|
229
|
+
...(body.model != null && { model: body.model }),
|
|
230
|
+
...(body.temperature != null && { temperature: body.temperature }),
|
|
231
|
+
...(body.maxTokens != null && { maxTokens: body.maxTokens }),
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// ── Prepend system prompt ────────────────────────────
|
|
235
|
+
// Vercel useChat sends `system` (or the deprecated `systemPrompt`)
|
|
236
|
+
// as a top-level field. We prepend it as a system message.
|
|
237
|
+
const rawSystemPrompt = body.system ?? body.systemPrompt;
|
|
238
|
+
if (rawSystemPrompt != null && typeof rawSystemPrompt !== 'string') {
|
|
239
|
+
return { status: 400, body: { error: 'system/systemPrompt must be a string' } };
|
|
240
|
+
}
|
|
241
|
+
const systemPrompt = rawSystemPrompt as string | undefined;
|
|
242
|
+
const finalMessages: ModelMessage[] = [
|
|
243
|
+
...(systemPrompt
|
|
244
|
+
? [{ role: 'system' as const, content: systemPrompt }]
|
|
245
|
+
: []),
|
|
246
|
+
...messages.map(m => normalizeMessage(m as Record<string, unknown>)),
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
// ── Choose response mode ─────────────────────────────
|
|
250
|
+
const wantStream = body.stream !== false;
|
|
251
|
+
|
|
252
|
+
if (wantStream) {
|
|
253
|
+
// UI Message Stream Protocol (SSE with JSON payloads)
|
|
254
|
+
try {
|
|
255
|
+
if (!(aiService as any).streamChatWithTools) {
|
|
256
|
+
return { status: 501, body: { error: 'Streaming is not supported by the configured AI service' } };
|
|
257
|
+
}
|
|
258
|
+
const events = (aiService as any).streamChatWithTools(finalMessages, resolvedOptions as any);
|
|
259
|
+
return {
|
|
260
|
+
status: 200,
|
|
261
|
+
stream: true,
|
|
262
|
+
vercelDataStream: true,
|
|
263
|
+
contentType: 'text/event-stream',
|
|
264
|
+
headers: {
|
|
265
|
+
'Content-Type': 'text/event-stream',
|
|
266
|
+
'Cache-Control': 'no-cache',
|
|
267
|
+
'Connection': 'keep-alive',
|
|
268
|
+
'x-vercel-ai-ui-message-stream': 'v1',
|
|
269
|
+
},
|
|
270
|
+
events: encodeVercelDataStream(events),
|
|
271
|
+
};
|
|
272
|
+
} catch (err) {
|
|
273
|
+
logger.error('[AI Route] /chat stream error', err instanceof Error ? err : undefined);
|
|
274
|
+
return { status: 500, body: { error: 'Internal AI service error' } };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// JSON response (non-streaming)
|
|
136
279
|
try {
|
|
137
|
-
const result = await aiService
|
|
280
|
+
const result = await (aiService as any).chatWithTools(finalMessages, resolvedOptions as any);
|
|
138
281
|
return { status: 200, body: result };
|
|
139
282
|
} catch (err) {
|
|
140
283
|
logger.error('[AI Route] /chat error', err instanceof Error ? err : undefined);
|
|
@@ -169,7 +312,7 @@ export function buildAIRoutes(
|
|
|
169
312
|
if (!aiService.streamChat) {
|
|
170
313
|
return { status: 501, body: { error: 'Streaming is not supported by the configured AI service' } };
|
|
171
314
|
}
|
|
172
|
-
const events = aiService.streamChat(messages as
|
|
315
|
+
const events = aiService.streamChat(messages.map(m => normalizeMessage(m as Record<string, unknown>)), options as any);
|
|
173
316
|
return { status: 200, stream: true, events };
|
|
174
317
|
} catch (err) {
|
|
175
318
|
logger.error('[AI Route] /chat/stream error', err instanceof Error ? err : undefined);
|
|
@@ -312,7 +455,7 @@ export function buildAIRoutes(
|
|
|
312
455
|
}
|
|
313
456
|
}
|
|
314
457
|
|
|
315
|
-
const conversation = await conversationService.addMessage(id, message as
|
|
458
|
+
const conversation = await conversationService.addMessage(id, message as ModelMessage);
|
|
316
459
|
return { status: 200, body: conversation };
|
|
317
460
|
} catch (err) {
|
|
318
461
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Vercel AI SDK v6 — UI Message Stream Encoder
|
|
5
|
+
*
|
|
6
|
+
* Converts `AsyncIterable<TextStreamPart<ToolSet>>` (the internal ObjectStack
|
|
7
|
+
* streaming format) into the Vercel AI SDK v6 **UI Message Stream Protocol**.
|
|
8
|
+
*
|
|
9
|
+
* Wire format: Server-Sent Events (SSE) with JSON payloads.
|
|
10
|
+
* `data: {"type":"text-delta","id":"0","delta":"Hello"}\n\n`
|
|
11
|
+
*
|
|
12
|
+
* The client-side `DefaultChatTransport` from `ai` v6 uses
|
|
13
|
+
* `parseJsonEventStream` to parse these SSE events.
|
|
14
|
+
*
|
|
15
|
+
* @see https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { TextStreamPart, ToolSet } from 'ai';
|
|
19
|
+
|
|
20
|
+
// ── SSE helpers ──────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function sse(data: object): string {
|
|
23
|
+
return `data: ${JSON.stringify(data)}\n\n`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Public API ──────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Encode a single `TextStreamPart` event into SSE-formatted UI Message
|
|
30
|
+
* Stream chunk(s).
|
|
31
|
+
*
|
|
32
|
+
* Returns an empty string for event types that have no wire-format mapping.
|
|
33
|
+
*/
|
|
34
|
+
export function encodeStreamPart(part: TextStreamPart<ToolSet>): string {
|
|
35
|
+
switch (part.type) {
|
|
36
|
+
case 'text-delta':
|
|
37
|
+
return sse({ type: 'text-delta', id: '0', delta: part.text });
|
|
38
|
+
|
|
39
|
+
case 'tool-input-start':
|
|
40
|
+
return sse({
|
|
41
|
+
type: 'tool-input-start',
|
|
42
|
+
toolCallId: part.id,
|
|
43
|
+
toolName: part.toolName,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
case 'tool-input-delta':
|
|
47
|
+
return sse({
|
|
48
|
+
type: 'tool-input-delta',
|
|
49
|
+
toolCallId: part.id,
|
|
50
|
+
inputTextDelta: part.delta,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
case 'tool-call':
|
|
54
|
+
return sse({
|
|
55
|
+
type: 'tool-input-available',
|
|
56
|
+
toolCallId: part.toolCallId,
|
|
57
|
+
toolName: part.toolName,
|
|
58
|
+
input: part.input,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
case 'tool-result':
|
|
62
|
+
return sse({
|
|
63
|
+
type: 'tool-output-available',
|
|
64
|
+
toolCallId: part.toolCallId,
|
|
65
|
+
output: part.output,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
case 'error':
|
|
69
|
+
return sse({
|
|
70
|
+
type: 'error',
|
|
71
|
+
errorText: String(part.error),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// finish-step and finish are handled by the generator, not here
|
|
75
|
+
default:
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Transform an `AsyncIterable<TextStreamPart>` into an `AsyncIterable<string>`
|
|
82
|
+
* where each yielded string is an SSE-formatted UI Message Stream chunk.
|
|
83
|
+
*
|
|
84
|
+
* Lifecycle order required by the client:
|
|
85
|
+
* start → start-step → text-start → text-delta* → text-end → finish-step → finish → [DONE]
|
|
86
|
+
*/
|
|
87
|
+
export async function* encodeVercelDataStream(
|
|
88
|
+
events: AsyncIterable<TextStreamPart<ToolSet>>,
|
|
89
|
+
): AsyncIterable<string> {
|
|
90
|
+
// Preamble
|
|
91
|
+
yield sse({ type: 'start' });
|
|
92
|
+
yield sse({ type: 'start-step' });
|
|
93
|
+
yield sse({ type: 'text-start', id: '0' });
|
|
94
|
+
|
|
95
|
+
let textOpen = true;
|
|
96
|
+
let finishReason = 'stop';
|
|
97
|
+
|
|
98
|
+
for await (const part of events) {
|
|
99
|
+
// Capture finish reason
|
|
100
|
+
if (part.type === 'finish') {
|
|
101
|
+
finishReason = part.finishReason ?? 'stop';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Before finish-step/finish, close the text part first
|
|
105
|
+
if (part.type === 'finish-step' || part.type === 'finish') {
|
|
106
|
+
if (textOpen) {
|
|
107
|
+
yield sse({ type: 'text-end', id: '0' });
|
|
108
|
+
textOpen = false;
|
|
109
|
+
}
|
|
110
|
+
// Don't emit these via encodeStreamPart — we handle them in postamble
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const frame = encodeStreamPart(part);
|
|
115
|
+
if (frame) {
|
|
116
|
+
yield frame;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Close text if still open (safety)
|
|
121
|
+
if (textOpen) {
|
|
122
|
+
yield sse({ type: 'text-end', id: '0' });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Postamble
|
|
126
|
+
yield sse({ type: 'finish-step' });
|
|
127
|
+
yield sse({ type: 'finish', finishReason });
|
|
128
|
+
yield 'data: [DONE]\n\n';
|
|
129
|
+
}
|