@objectstack/service-ai 4.0.1 → 4.0.3
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 +17 -0
- package/dist/index.cjs +1632 -355
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +330 -87
- package/dist/index.d.ts +330 -87
- package/dist/index.js +1623 -352
- package/dist/index.js.map +1 -1
- package/package.json +27 -5
- package/src/__tests__/ai-service.test.ts +260 -27
- package/src/__tests__/auth-and-toolcalling.test.ts +81 -29
- package/src/__tests__/chatbot-features.test.ts +397 -102
- package/src/__tests__/metadata-tools.test.ts +970 -0
- package/src/__tests__/objectql-conversation-service.test.ts +34 -16
- package/src/__tests__/tool-routes.test.ts +191 -0
- package/src/__tests__/vercel-stream-encoder.test.ts +310 -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 +75 -36
- package/src/conversation/in-memory-conversation-service.ts +2 -2
- package/src/conversation/objectql-conversation-service.ts +67 -18
- package/src/index.ts +22 -2
- package/src/plugin.ts +237 -30
- package/src/routes/agent-routes.ts +68 -12
- package/src/routes/ai-routes.ts +93 -14
- package/src/routes/index.ts +1 -0
- package/src/routes/message-utils.ts +90 -0
- package/src/routes/tool-routes.ts +142 -0
- package/src/stream/index.ts +3 -0
- package/src/stream/vercel-stream-encoder.ts +153 -0
- package/src/tools/add-field.tool.ts +70 -0
- package/src/tools/create-object.tool.ts +66 -0
- package/src/tools/data-tools.ts +4 -101
- package/src/tools/delete-field.tool.ts +38 -0
- package/src/tools/describe-object.tool.ts +31 -0
- package/src/tools/index.ts +12 -1
- package/src/tools/list-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/routes/ai-routes.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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';
|
|
6
|
+
import { normalizeMessage, validateMessageContent } from './message-utils.js';
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Minimal HTTP handler abstraction so routes stay framework-agnostic.
|
|
@@ -63,14 +65,27 @@ export interface RouteResponse {
|
|
|
63
65
|
stream?: boolean;
|
|
64
66
|
/** Async iterable of SSE events (when stream=true) */
|
|
65
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;
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
/** Valid message roles accepted by the AI routes. */
|
|
69
79
|
const VALID_ROLES = new Set<string>(['system', 'user', 'assistant', 'tool']);
|
|
70
80
|
|
|
71
81
|
/**
|
|
72
|
-
* Validate that `raw` is a well-formed
|
|
82
|
+
* Validate that `raw` is a well-formed message.
|
|
73
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)
|
|
74
89
|
*/
|
|
75
90
|
function validateMessage(raw: unknown): string | null {
|
|
76
91
|
if (typeof raw !== 'object' || raw === null) {
|
|
@@ -80,10 +95,10 @@ function validateMessage(raw: unknown): string | null {
|
|
|
80
95
|
if (typeof msg.role !== 'string' || !VALID_ROLES.has(msg.role)) {
|
|
81
96
|
return `message.role must be one of ${[...VALID_ROLES].map(r => `"${r}"`).join(', ')}`;
|
|
82
97
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return
|
|
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 });
|
|
87
102
|
}
|
|
88
103
|
|
|
89
104
|
/**
|
|
@@ -112,18 +127,26 @@ export function buildAIRoutes(
|
|
|
112
127
|
): RouteDefinition[] {
|
|
113
128
|
return [
|
|
114
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
|
+
//
|
|
115
139
|
{
|
|
116
140
|
method: 'POST',
|
|
117
141
|
path: '/api/v1/ai/chat',
|
|
118
|
-
description: '
|
|
142
|
+
description: 'Chat completion (supports Vercel AI Data Stream Protocol)',
|
|
119
143
|
auth: true,
|
|
120
144
|
permissions: ['ai:chat'],
|
|
121
145
|
handler: async (req) => {
|
|
122
|
-
const
|
|
123
|
-
messages?: unknown[];
|
|
124
|
-
options?: Record<string, unknown>;
|
|
125
|
-
};
|
|
146
|
+
const body = (req.body ?? {}) as Record<string, unknown>;
|
|
126
147
|
|
|
148
|
+
// ── Parse messages ───────────────────────────────────
|
|
149
|
+
const messages = body.messages as unknown[] | undefined;
|
|
127
150
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
128
151
|
return { status: 400, body: { error: 'messages array is required' } };
|
|
129
152
|
}
|
|
@@ -133,8 +156,64 @@ export function buildAIRoutes(
|
|
|
133
156
|
if (err) return { status: 400, body: { error: err } };
|
|
134
157
|
}
|
|
135
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)
|
|
136
215
|
try {
|
|
137
|
-
const result = await aiService
|
|
216
|
+
const result = await (aiService as any).chatWithTools(finalMessages, resolvedOptions as any);
|
|
138
217
|
return { status: 200, body: result };
|
|
139
218
|
} catch (err) {
|
|
140
219
|
logger.error('[AI Route] /chat error', err instanceof Error ? err : undefined);
|
|
@@ -169,7 +248,7 @@ export function buildAIRoutes(
|
|
|
169
248
|
if (!aiService.streamChat) {
|
|
170
249
|
return { status: 501, body: { error: 'Streaming is not supported by the configured AI service' } };
|
|
171
250
|
}
|
|
172
|
-
const events = aiService.streamChat(messages as
|
|
251
|
+
const events = aiService.streamChat(messages.map(m => normalizeMessage(m as Record<string, unknown>)), options as any);
|
|
173
252
|
return { status: 200, stream: true, events };
|
|
174
253
|
} catch (err) {
|
|
175
254
|
logger.error('[AI Route] /chat/stream error', err instanceof Error ? err : undefined);
|
|
@@ -312,7 +391,7 @@ export function buildAIRoutes(
|
|
|
312
391
|
}
|
|
313
392
|
}
|
|
314
393
|
|
|
315
|
-
const conversation = await conversationService.addMessage(id, message as
|
|
394
|
+
const conversation = await conversationService.addMessage(id, message as ModelMessage);
|
|
316
395
|
return { status: 200, body: conversation };
|
|
317
396
|
} catch (err) {
|
|
318
397
|
const msg = err instanceof Error ? err.message : String(err);
|
package/src/routes/index.ts
CHANGED
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
/**
|
|
27
|
+
* Encode data using Vercel AI SDK Data Stream Protocol prefixes.
|
|
28
|
+
* @see https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol
|
|
29
|
+
*/
|
|
30
|
+
function dataStreamLine(prefix: string, data: object): string {
|
|
31
|
+
return `${prefix}:${JSON.stringify(data)}\n`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Public API ──────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Encode a single `TextStreamPart` event into SSE-formatted UI Message
|
|
38
|
+
* Stream chunk(s).
|
|
39
|
+
*
|
|
40
|
+
* Returns an empty string for event types that have no wire-format mapping.
|
|
41
|
+
*/
|
|
42
|
+
export function encodeStreamPart(part: TextStreamPart<ToolSet>): string {
|
|
43
|
+
switch (part.type) {
|
|
44
|
+
case 'text-delta':
|
|
45
|
+
return sse({ type: 'text-delta', id: '0', delta: part.text });
|
|
46
|
+
|
|
47
|
+
case 'tool-input-start':
|
|
48
|
+
return sse({
|
|
49
|
+
type: 'tool-input-start',
|
|
50
|
+
toolCallId: part.id,
|
|
51
|
+
toolName: part.toolName,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
case 'tool-input-delta':
|
|
55
|
+
return sse({
|
|
56
|
+
type: 'tool-input-delta',
|
|
57
|
+
toolCallId: part.id,
|
|
58
|
+
inputTextDelta: part.delta,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
case 'tool-call':
|
|
62
|
+
return sse({
|
|
63
|
+
type: 'tool-input-available',
|
|
64
|
+
toolCallId: part.toolCallId,
|
|
65
|
+
toolName: part.toolName,
|
|
66
|
+
input: part.input,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
case 'tool-result':
|
|
70
|
+
return sse({
|
|
71
|
+
type: 'tool-output-available',
|
|
72
|
+
toolCallId: part.toolCallId,
|
|
73
|
+
output: part.output,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
case 'error':
|
|
77
|
+
return sse({
|
|
78
|
+
type: 'error',
|
|
79
|
+
errorText: String(part.error),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Handle reasoning/thinking streams (DeepSeek R1, o1-style models)
|
|
83
|
+
// Use 'g:' prefix for reasoning content per Vercel AI SDK protocol
|
|
84
|
+
case 'reasoning-start':
|
|
85
|
+
return dataStreamLine('g', { text: '' });
|
|
86
|
+
|
|
87
|
+
case 'reasoning-delta':
|
|
88
|
+
return dataStreamLine('g', { text: part.text });
|
|
89
|
+
|
|
90
|
+
case 'reasoning-end':
|
|
91
|
+
return ''; // No specific end marker needed for reasoning
|
|
92
|
+
|
|
93
|
+
// finish-step and finish are handled by the generator, not here
|
|
94
|
+
default:
|
|
95
|
+
// Pass through any unknown event types that might be custom
|
|
96
|
+
// (e.g., step-start, step-finish from custom providers)
|
|
97
|
+
if ((part as any).type?.startsWith('step-')) {
|
|
98
|
+
return sse(part as any);
|
|
99
|
+
}
|
|
100
|
+
return '';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Transform an `AsyncIterable<TextStreamPart>` into an `AsyncIterable<string>`
|
|
106
|
+
* where each yielded string is an SSE-formatted UI Message Stream chunk.
|
|
107
|
+
*
|
|
108
|
+
* Lifecycle order required by the client:
|
|
109
|
+
* start → start-step → text-start → text-delta* → text-end → finish-step → finish → [DONE]
|
|
110
|
+
*/
|
|
111
|
+
export async function* encodeVercelDataStream(
|
|
112
|
+
events: AsyncIterable<TextStreamPart<ToolSet>>,
|
|
113
|
+
): AsyncIterable<string> {
|
|
114
|
+
// Preamble
|
|
115
|
+
yield sse({ type: 'start' });
|
|
116
|
+
yield sse({ type: 'start-step' });
|
|
117
|
+
yield sse({ type: 'text-start', id: '0' });
|
|
118
|
+
|
|
119
|
+
let textOpen = true;
|
|
120
|
+
let finishReason = 'stop';
|
|
121
|
+
|
|
122
|
+
for await (const part of events) {
|
|
123
|
+
// Capture finish reason
|
|
124
|
+
if (part.type === 'finish') {
|
|
125
|
+
finishReason = part.finishReason ?? 'stop';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Before finish-step/finish, close the text part first
|
|
129
|
+
if (part.type === 'finish-step' || part.type === 'finish') {
|
|
130
|
+
if (textOpen) {
|
|
131
|
+
yield sse({ type: 'text-end', id: '0' });
|
|
132
|
+
textOpen = false;
|
|
133
|
+
}
|
|
134
|
+
// Don't emit these via encodeStreamPart — we handle them in postamble
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const frame = encodeStreamPart(part);
|
|
139
|
+
if (frame) {
|
|
140
|
+
yield frame;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Close text if still open (safety)
|
|
145
|
+
if (textOpen) {
|
|
146
|
+
yield sse({ type: 'text-end', id: '0' });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Postamble
|
|
150
|
+
yield sse({ type: 'finish-step' });
|
|
151
|
+
yield sse({ type: 'finish', finishReason });
|
|
152
|
+
yield 'data: [DONE]\n\n';
|
|
153
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { defineTool } from '@objectstack/spec/ai';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* add_field — AI Tool Metadata
|
|
7
|
+
*
|
|
8
|
+
* Adds a new field (column) to an existing data object.
|
|
9
|
+
* Validates snake_case for objectName, field name, reference,
|
|
10
|
+
* and select option values before merging into the definition.
|
|
11
|
+
*/
|
|
12
|
+
export const addFieldTool = defineTool({
|
|
13
|
+
name: 'add_field',
|
|
14
|
+
label: 'Add Field',
|
|
15
|
+
description:
|
|
16
|
+
'Adds a new field (column) to an existing data object. ' +
|
|
17
|
+
'Use this when the user wants to add a property, column, or attribute to a table.',
|
|
18
|
+
category: 'data',
|
|
19
|
+
builtIn: true,
|
|
20
|
+
parameters: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
objectName: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
description: 'Target object machine name (snake_case)',
|
|
26
|
+
},
|
|
27
|
+
name: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
description: 'Field machine name (snake_case, e.g. due_date)',
|
|
30
|
+
},
|
|
31
|
+
label: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description: 'Human-readable field label (e.g. Due Date)',
|
|
34
|
+
},
|
|
35
|
+
type: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: 'Field data type',
|
|
38
|
+
enum: ['text', 'textarea', 'number', 'boolean', 'date', 'datetime', 'select', 'lookup', 'formula', 'autonumber'],
|
|
39
|
+
},
|
|
40
|
+
required: {
|
|
41
|
+
type: 'boolean',
|
|
42
|
+
description: 'Whether the field is required',
|
|
43
|
+
},
|
|
44
|
+
defaultValue: {
|
|
45
|
+
description: 'Default value for the field',
|
|
46
|
+
},
|
|
47
|
+
options: {
|
|
48
|
+
type: 'array',
|
|
49
|
+
description: 'Options for select/picklist fields',
|
|
50
|
+
items: {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
label: { type: 'string' },
|
|
54
|
+
value: {
|
|
55
|
+
type: 'string',
|
|
56
|
+
description: 'Option machine identifier (lowercase snake_case, e.g. high_priority)',
|
|
57
|
+
pattern: '^[a-z_][a-z0-9_]*$',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
reference: {
|
|
63
|
+
type: 'string',
|
|
64
|
+
description: 'Referenced object name for lookup fields (snake_case, e.g. account)',
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
required: ['objectName', 'name', 'type'],
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
},
|
|
70
|
+
});
|