@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.
@@ -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_messages — AI Message Object
7
+ *
8
+ * Stores individual messages within an AI conversation.
9
+ * Each message belongs to a conversation via `conversation_id` foreign key.
10
+ *
11
+ * @namespace ai
12
+ */
13
+ export const AiMessageObject = ObjectSchema.create({
14
+ namespace: 'ai',
15
+ name: 'messages',
16
+ label: 'AI Message',
17
+ pluralLabel: 'AI Messages',
18
+ icon: 'message-circle',
19
+ isSystem: true,
20
+ description: 'Individual messages within AI conversations',
21
+
22
+ fields: {
23
+ id: Field.text({
24
+ label: 'Message ID',
25
+ required: true,
26
+ readonly: true,
27
+ }),
28
+
29
+ conversation_id: Field.text({
30
+ label: 'Conversation ID',
31
+ required: true,
32
+ description: 'Foreign key to ai_conversations',
33
+ }),
34
+
35
+ role: Field.select({
36
+ label: 'Role',
37
+ required: true,
38
+ options: [
39
+ { label: 'System', value: 'system' },
40
+ { label: 'User', value: 'user' },
41
+ { label: 'Assistant', value: 'assistant' },
42
+ { label: 'Tool', value: 'tool' },
43
+ ],
44
+ }),
45
+
46
+ content: Field.textarea({
47
+ label: 'Content',
48
+ required: true,
49
+ description: 'Message content',
50
+ }),
51
+
52
+ tool_calls: Field.textarea({
53
+ label: 'Tool Calls',
54
+ required: false,
55
+ description: 'JSON-serialized tool calls (when role=assistant)',
56
+ }),
57
+
58
+ tool_call_id: Field.text({
59
+ label: 'Tool Call ID',
60
+ required: false,
61
+ maxLength: 255,
62
+ description: 'ID of the tool call this message responds to (when role=tool)',
63
+ }),
64
+
65
+ created_at: Field.datetime({
66
+ label: 'Created At',
67
+ required: true,
68
+ defaultValue: 'NOW()',
69
+ readonly: true,
70
+ }),
71
+ },
72
+
73
+ indexes: [
74
+ { fields: ['conversation_id'] },
75
+ { fields: ['conversation_id', 'created_at'] },
76
+ ],
77
+
78
+ enable: {
79
+ trackHistory: false,
80
+ searchable: false,
81
+ apiEnabled: true,
82
+ apiMethods: ['get', 'list', 'create'],
83
+ trash: false,
84
+ mru: false,
85
+ },
86
+ });
@@ -0,0 +1,10 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * AI Service — System Object Definitions (ai namespace)
5
+ *
6
+ * Canonical ObjectSchema definitions for AI conversation persistence.
7
+ */
8
+
9
+ export { AiConversationObject } from './ai-conversation.object.js';
10
+ export { AiMessageObject } from './ai-message.object.js';
package/src/plugin.ts ADDED
@@ -0,0 +1,184 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { Plugin, PluginContext } from '@objectstack/core';
4
+ import type { IAIService, IAIConversationService, IDataEngine, IMetadataService, LLMAdapter } from '@objectstack/spec/contracts';
5
+ import { AIService } from './ai-service.js';
6
+ import type { AIServiceConfig } from './ai-service.js';
7
+ import { buildAIRoutes } from './routes/ai-routes.js';
8
+ import { buildAgentRoutes } from './routes/agent-routes.js';
9
+ import { ObjectQLConversationService } from './conversation/objectql-conversation-service.js';
10
+ import { AiConversationObject, AiMessageObject } from './objects/index.js';
11
+ import { registerDataTools } from './tools/data-tools.js';
12
+ import { AgentRuntime } from './agent-runtime.js';
13
+ import { DATA_CHAT_AGENT } from './agents/index.js';
14
+
15
+ /**
16
+ * Configuration options for the AIServicePlugin.
17
+ */
18
+ export interface AIServicePluginOptions {
19
+ /** LLM adapter to use (defaults to MemoryLLMAdapter). */
20
+ adapter?: LLMAdapter;
21
+ /** Enable debug logging. */
22
+ debug?: boolean;
23
+ /** Explicit conversation service override. When set, auto-detection is skipped. */
24
+ conversationService?: IAIConversationService;
25
+ }
26
+
27
+ /**
28
+ * AIServicePlugin — Kernel plugin for the unified AI capability service.
29
+ *
30
+ * Lifecycle:
31
+ * 1. **init** — Creates {@link AIService}, registers as `'ai'` service.
32
+ * If an existing AI service is already registered, it is replaced.
33
+ * 2. **start** — Triggers `'ai:ready'` hook so other plugins can register
34
+ * tools or extend the service. Registers REST/SSE routes.
35
+ * 3. **destroy** — Cleans up references.
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * import { LiteKernel } from '@objectstack/core';
40
+ * import { AIServicePlugin } from '@objectstack/service-ai';
41
+ *
42
+ * const kernel = new LiteKernel();
43
+ * kernel.use(new AIServicePlugin());
44
+ * await kernel.bootstrap();
45
+ *
46
+ * const ai = kernel.getService<IAIService>('ai');
47
+ * const result = await ai.chat([{ role: 'user', content: 'Hello' }]);
48
+ * ```
49
+ */
50
+ export class AIServicePlugin implements Plugin {
51
+ name = 'com.objectstack.service-ai';
52
+ version = '1.0.0';
53
+ type = 'standard' as const;
54
+ dependencies: string[] = [];
55
+
56
+ private service?: AIService;
57
+ private readonly options: AIServicePluginOptions;
58
+
59
+ constructor(options: AIServicePluginOptions = {}) {
60
+ this.options = options;
61
+ }
62
+
63
+ async init(ctx: PluginContext): Promise<void> {
64
+ // Check if there is an existing AI service (e.g. from dev-plugin)
65
+ let hasExisting = false;
66
+ try {
67
+ const existing = ctx.getService<IAIService>('ai');
68
+ if (existing && typeof existing.chat === 'function') {
69
+ hasExisting = true;
70
+ ctx.logger.debug('[AI] Found existing AI service, replacing');
71
+ }
72
+ } catch {
73
+ // No existing service — that's fine
74
+ }
75
+
76
+ // Determine conversation service: explicit > auto-detect IDataEngine > InMemory fallback
77
+ let conversationService: IAIConversationService | undefined = this.options.conversationService;
78
+ if (!conversationService) {
79
+ try {
80
+ const engine = ctx.getService<IDataEngine>('data');
81
+ if (engine && typeof engine.find === 'function') {
82
+ conversationService = new ObjectQLConversationService(engine);
83
+ ctx.logger.info('[AI] Using ObjectQLConversationService (IDataEngine detected)');
84
+ }
85
+ } catch {
86
+ // No data engine — fall back to InMemory
87
+ }
88
+ }
89
+
90
+ const config: AIServiceConfig = {
91
+ adapter: this.options.adapter,
92
+ logger: ctx.logger,
93
+ conversationService,
94
+ };
95
+
96
+ this.service = new AIService(config);
97
+
98
+ // Register or replace the AI service
99
+ if (hasExisting) {
100
+ ctx.replaceService('ai', this.service);
101
+ } else {
102
+ ctx.registerService('ai', this.service);
103
+ }
104
+
105
+ // Register AI system objects so ObjectQLPlugin auto-discovers them
106
+ ctx.registerService('app.com.objectstack.service-ai', {
107
+ id: 'com.objectstack.service-ai',
108
+ name: 'AI Service',
109
+ version: '1.0.0',
110
+ type: 'plugin',
111
+ namespace: 'ai',
112
+ objects: [AiConversationObject, AiMessageObject],
113
+ });
114
+
115
+ if (this.options.debug) {
116
+ ctx.hook('ai:beforeChat', async (messages: unknown) => {
117
+ ctx.logger.debug('[AI] Before chat', { messages });
118
+ });
119
+ }
120
+
121
+ ctx.logger.info('[AI] Service initialized');
122
+ }
123
+
124
+ async start(ctx: PluginContext): Promise<void> {
125
+ if (!this.service) return;
126
+
127
+ // ── Auto-register built-in data tools if data engine + metadata are available ──
128
+ try {
129
+ const dataEngine = ctx.getService<IDataEngine>('data');
130
+ const metadataService = ctx.getService<IMetadataService>('metadata');
131
+ if (dataEngine && metadataService) {
132
+ registerDataTools(this.service.toolRegistry, { dataEngine, metadataService });
133
+ ctx.logger.info('[AI] Built-in data tools registered');
134
+
135
+ // Register the built-in data_chat agent only if it does not already exist
136
+ const agentExists =
137
+ typeof metadataService.exists === 'function'
138
+ ? await metadataService.exists('agent', DATA_CHAT_AGENT.name)
139
+ : false;
140
+
141
+ if (!agentExists) {
142
+ await metadataService.register('agent', DATA_CHAT_AGENT.name, DATA_CHAT_AGENT);
143
+ ctx.logger.info('[AI] data_chat agent registered');
144
+ } else {
145
+ ctx.logger.debug('[AI] data_chat agent already exists, skipping auto-registration');
146
+ }
147
+ }
148
+ } catch {
149
+ // Data engine or metadata service not available — skip data tools
150
+ ctx.logger.debug('[AI] Data engine or metadata service not available, skipping data tools');
151
+ }
152
+
153
+ // Trigger hook to notify AI service is ready — other plugins can register tools
154
+ await ctx.trigger('ai:ready', this.service);
155
+
156
+ // Build and expose route definitions
157
+ const routes = buildAIRoutes(this.service, this.service.conversationService, ctx.logger);
158
+
159
+ // Build agent routes if metadata service is available
160
+ try {
161
+ const metadataService = ctx.getService<IMetadataService>('metadata');
162
+ if (metadataService) {
163
+ const agentRuntime = new AgentRuntime(metadataService);
164
+ const agentRoutes = buildAgentRoutes(this.service, agentRuntime, ctx.logger);
165
+ routes.push(...agentRoutes);
166
+ }
167
+ } catch {
168
+ ctx.logger.debug('[AI] Metadata service not available, skipping agent routes');
169
+ }
170
+
171
+ // Trigger hook so HTTP server plugins can mount these routes
172
+ await ctx.trigger('ai:routes', routes);
173
+
174
+ ctx.logger.info(
175
+ `[AI] Service started — adapter="${this.service.adapterName}", ` +
176
+ `tools=${this.service.toolRegistry.size}, ` +
177
+ `routes=${routes.length}`,
178
+ );
179
+ }
180
+
181
+ async destroy(): Promise<void> {
182
+ this.service = undefined;
183
+ }
184
+ }
@@ -0,0 +1,132 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { AIMessage } from '@objectstack/spec/contracts';
4
+ import type { Logger } from '@objectstack/spec/contracts';
5
+ import type { AIService } from '../ai-service.js';
6
+ import type { AgentRuntime, AgentChatContext } from '../agent-runtime.js';
7
+ import type { RouteDefinition } from './ai-routes.js';
8
+
9
+ /**
10
+ * Allowed message roles for the agent chat endpoint.
11
+ *
12
+ * Only `user` and `assistant` are accepted from clients.
13
+ * `system` messages are injected server-side from agent instructions,
14
+ * and `tool` messages are produced by the tool-call loop — accepting
15
+ * either from the client would allow callers to override agent
16
+ * guardrails or inject fabricated tool results.
17
+ */
18
+ const ALLOWED_AGENT_ROLES = new Set<string>(['user', 'assistant']);
19
+
20
+ function validateAgentMessage(raw: unknown): string | null {
21
+ if (typeof raw !== 'object' || raw === null) {
22
+ return 'each message must be an object';
23
+ }
24
+ const msg = raw as Record<string, unknown>;
25
+ if (typeof msg.role !== 'string' || !ALLOWED_AGENT_ROLES.has(msg.role)) {
26
+ return `message.role must be one of ${[...ALLOWED_AGENT_ROLES].map(r => `"${r}"`).join(', ')} for agent chat`;
27
+ }
28
+ if (typeof msg.content !== 'string') {
29
+ return 'message.content must be a string';
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Build agent-specific REST routes.
36
+ *
37
+ * | Method | Path | Description |
38
+ * |:---|:---|:---|
39
+ * | POST | /api/v1/ai/agents/:agentName/chat | Chat with a specific agent |
40
+ */
41
+ export function buildAgentRoutes(
42
+ aiService: AIService,
43
+ agentRuntime: AgentRuntime,
44
+ logger: Logger,
45
+ ): RouteDefinition[] {
46
+ return [
47
+ {
48
+ method: 'POST',
49
+ path: '/api/v1/ai/agents/:agentName/chat',
50
+ description: 'Chat with a specific AI agent',
51
+ handler: async (req) => {
52
+ const agentName = req.params?.agentName;
53
+ if (!agentName) {
54
+ return { status: 400, body: { error: 'agentName parameter is required' } };
55
+ }
56
+
57
+ // Parse request body
58
+ const {
59
+ messages: rawMessages,
60
+ context: chatContext,
61
+ options: extraOptions,
62
+ } = (req.body ?? {}) as {
63
+ messages?: unknown[];
64
+ context?: AgentChatContext;
65
+ options?: Record<string, unknown>;
66
+ };
67
+
68
+ if (!Array.isArray(rawMessages) || rawMessages.length === 0) {
69
+ return { status: 400, body: { error: 'messages array is required' } };
70
+ }
71
+
72
+ for (const msg of rawMessages) {
73
+ const err = validateAgentMessage(msg);
74
+ if (err) return { status: 400, body: { error: err } };
75
+ }
76
+
77
+ // Load agent definition
78
+ const agent = await agentRuntime.loadAgent(agentName);
79
+ if (!agent) {
80
+ return { status: 404, body: { error: `Agent "${agentName}" not found` } };
81
+ }
82
+ if (!agent.active) {
83
+ return { status: 403, body: { error: `Agent "${agentName}" is not active` } };
84
+ }
85
+
86
+ try {
87
+ // Build system messages from agent instructions + UI context
88
+ const systemMessages = agentRuntime.buildSystemMessages(agent, chatContext);
89
+
90
+ // Resolve agent model/tools → request options
91
+ const agentOptions = agentRuntime.buildRequestOptions(
92
+ agent,
93
+ aiService.toolRegistry.getAll(),
94
+ );
95
+
96
+ // Whitelist only safe caller overrides — block tools/toolChoice/model
97
+ // to prevent tool-definition injection or DoS via unregistered tools.
98
+ const safeOverrides: Record<string, unknown> = {};
99
+ if (extraOptions) {
100
+ const ALLOWED_KEYS = new Set(['temperature', 'maxTokens', 'stop']);
101
+ for (const key of Object.keys(extraOptions)) {
102
+ if (ALLOWED_KEYS.has(key)) {
103
+ safeOverrides[key] = extraOptions[key];
104
+ }
105
+ }
106
+ }
107
+ const mergedOptions = { ...agentOptions, ...safeOverrides };
108
+
109
+ // Prepend system messages then user conversation
110
+ const fullMessages: AIMessage[] = [
111
+ ...systemMessages,
112
+ ...(rawMessages as AIMessage[]),
113
+ ];
114
+
115
+ // Use chatWithTools for automatic tool resolution
116
+ const result = await aiService.chatWithTools(fullMessages, {
117
+ ...mergedOptions,
118
+ maxIterations: agent.planning?.maxIterations,
119
+ });
120
+
121
+ return { status: 200, body: result };
122
+ } catch (err) {
123
+ logger.error(
124
+ '[AI Route] /agents/:agentName/chat error',
125
+ err instanceof Error ? err : undefined,
126
+ );
127
+ return { status: 500, body: { error: 'Internal AI service error' } };
128
+ }
129
+ },
130
+ },
131
+ ];
132
+ }
@@ -0,0 +1,286 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { IAIService, IAIConversationService, AIMessage } from '@objectstack/spec/contracts';
4
+ import type { Logger } from '@objectstack/spec/contracts';
5
+
6
+ /**
7
+ * Minimal HTTP handler abstraction so routes stay framework-agnostic.
8
+ *
9
+ * Consumers wire these handlers to their HTTP server of choice
10
+ * (Hono, Express, Fastify, etc.) via the kernel's HTTP server service.
11
+ */
12
+ export interface RouteDefinition {
13
+ /** HTTP method */
14
+ method: 'GET' | 'POST' | 'DELETE';
15
+ /** Path pattern (e.g. '/api/v1/ai/chat') */
16
+ path: string;
17
+ /** Human-readable description */
18
+ description: string;
19
+ /**
20
+ * Handler receives a plain request-like object and returns a response-like
21
+ * object. SSE responses set `stream: true` and provide an async iterable.
22
+ */
23
+ handler: (req: RouteRequest) => Promise<RouteResponse>;
24
+ }
25
+
26
+ export interface RouteRequest {
27
+ /** Parsed JSON body (for POST requests) */
28
+ body?: unknown;
29
+ /** Route/query parameters */
30
+ params?: Record<string, string>;
31
+ /** Query string parameters */
32
+ query?: Record<string, string>;
33
+ }
34
+
35
+ export interface RouteResponse {
36
+ /** HTTP status code */
37
+ status: number;
38
+ /** JSON-serializable body (for non-streaming responses) */
39
+ body?: unknown;
40
+ /** If true, `stream` provides SSE events */
41
+ stream?: boolean;
42
+ /** Async iterable of SSE events (when stream=true) */
43
+ events?: AsyncIterable<unknown>;
44
+ }
45
+
46
+ /** Valid message roles accepted by the AI routes. */
47
+ const VALID_ROLES = new Set<string>(['system', 'user', 'assistant', 'tool']);
48
+
49
+ /**
50
+ * Validate that `raw` is a well-formed AIMessage.
51
+ * Returns null on success, or an error string on failure.
52
+ */
53
+ function validateMessage(raw: unknown): string | null {
54
+ if (typeof raw !== 'object' || raw === null) {
55
+ return 'each message must be an object';
56
+ }
57
+ const msg = raw as Record<string, unknown>;
58
+ if (typeof msg.role !== 'string' || !VALID_ROLES.has(msg.role)) {
59
+ return `message.role must be one of ${[...VALID_ROLES].map(r => `"${r}"`).join(', ')}`;
60
+ }
61
+ if (typeof msg.content !== 'string') {
62
+ return 'message.content must be a string';
63
+ }
64
+ return null;
65
+ }
66
+
67
+ /**
68
+ * Build the standard AI REST/SSE routes.
69
+ *
70
+ * Depends on contracts ({@link IAIService} + {@link IAIConversationService})
71
+ * rather than concrete implementations, so any compliant service pair can
72
+ * be wired in.
73
+ *
74
+ * Routes:
75
+ * | Method | Path | Description |
76
+ * |:---|:---|:---|
77
+ * | POST | /api/v1/ai/chat | Synchronous chat completion |
78
+ * | POST | /api/v1/ai/chat/stream | SSE streaming chat completion |
79
+ * | POST | /api/v1/ai/complete | Text completion |
80
+ * | GET | /api/v1/ai/models | List available models |
81
+ * | POST | /api/v1/ai/conversations | Create a conversation |
82
+ * | GET | /api/v1/ai/conversations | List conversations |
83
+ * | POST | /api/v1/ai/conversations/:id/messages | Add message to conversation |
84
+ * | DELETE | /api/v1/ai/conversations/:id | Delete conversation |
85
+ */
86
+ export function buildAIRoutes(
87
+ aiService: IAIService,
88
+ conversationService: IAIConversationService,
89
+ logger: Logger,
90
+ ): RouteDefinition[] {
91
+ return [
92
+ // ── Chat ────────────────────────────────────────────────────
93
+ {
94
+ method: 'POST',
95
+ path: '/api/v1/ai/chat',
96
+ description: 'Synchronous chat completion',
97
+ handler: async (req) => {
98
+ const { messages, options } = (req.body ?? {}) as {
99
+ messages?: unknown[];
100
+ options?: Record<string, unknown>;
101
+ };
102
+
103
+ if (!Array.isArray(messages) || messages.length === 0) {
104
+ return { status: 400, body: { error: 'messages array is required' } };
105
+ }
106
+
107
+ for (const msg of messages) {
108
+ const err = validateMessage(msg);
109
+ if (err) return { status: 400, body: { error: err } };
110
+ }
111
+
112
+ try {
113
+ const result = await aiService.chat(messages as AIMessage[], options as any);
114
+ return { status: 200, body: result };
115
+ } catch (err) {
116
+ logger.error('[AI Route] /chat error', err instanceof Error ? err : undefined);
117
+ return { status: 500, body: { error: 'Internal AI service error' } };
118
+ }
119
+ },
120
+ },
121
+
122
+ // ── Stream Chat (SSE) ──────────────────────────────────────
123
+ {
124
+ method: 'POST',
125
+ path: '/api/v1/ai/chat/stream',
126
+ description: 'SSE streaming chat completion',
127
+ handler: async (req) => {
128
+ const { messages, options } = (req.body ?? {}) as {
129
+ messages?: unknown[];
130
+ options?: Record<string, unknown>;
131
+ };
132
+
133
+ if (!Array.isArray(messages) || messages.length === 0) {
134
+ return { status: 400, body: { error: 'messages array is required' } };
135
+ }
136
+
137
+ for (const msg of messages) {
138
+ const err = validateMessage(msg);
139
+ if (err) return { status: 400, body: { error: err } };
140
+ }
141
+
142
+ try {
143
+ if (!aiService.streamChat) {
144
+ return { status: 501, body: { error: 'Streaming is not supported by the configured AI service' } };
145
+ }
146
+ const events = aiService.streamChat(messages as AIMessage[], options as any);
147
+ return { status: 200, stream: true, events };
148
+ } catch (err) {
149
+ logger.error('[AI Route] /chat/stream error', err instanceof Error ? err : undefined);
150
+ return { status: 500, body: { error: 'Internal AI service error' } };
151
+ }
152
+ },
153
+ },
154
+
155
+ // ── Complete ────────────────────────────────────────────────
156
+ {
157
+ method: 'POST',
158
+ path: '/api/v1/ai/complete',
159
+ description: 'Text completion',
160
+ handler: async (req) => {
161
+ const { prompt, options } = (req.body ?? {}) as {
162
+ prompt?: string;
163
+ options?: Record<string, unknown>;
164
+ };
165
+
166
+ if (!prompt || typeof prompt !== 'string') {
167
+ return { status: 400, body: { error: 'prompt string is required' } };
168
+ }
169
+
170
+ try {
171
+ const result = await aiService.complete(prompt, options as any);
172
+ return { status: 200, body: result };
173
+ } catch (err) {
174
+ logger.error('[AI Route] /complete error', err instanceof Error ? err : undefined);
175
+ return { status: 500, body: { error: 'Internal AI service error' } };
176
+ }
177
+ },
178
+ },
179
+
180
+ // ── Models ──────────────────────────────────────────────────
181
+ {
182
+ method: 'GET',
183
+ path: '/api/v1/ai/models',
184
+ description: 'List available models',
185
+ handler: async () => {
186
+ try {
187
+ const models = aiService.listModels ? await aiService.listModels() : [];
188
+ return { status: 200, body: { models } };
189
+ } catch (err) {
190
+ logger.error('[AI Route] /models error', err instanceof Error ? err : undefined);
191
+ return { status: 500, body: { error: 'Internal AI service error' } };
192
+ }
193
+ },
194
+ },
195
+
196
+ // ── Conversations ──────────────────────────────────────────
197
+ {
198
+ method: 'POST',
199
+ path: '/api/v1/ai/conversations',
200
+ description: 'Create a conversation',
201
+ handler: async (req) => {
202
+ try {
203
+ const options = (req.body ?? {}) as Record<string, unknown>;
204
+ const conversation = await conversationService.create(options as any);
205
+ return { status: 201, body: conversation };
206
+ } catch (err) {
207
+ logger.error('[AI Route] POST /conversations error', err instanceof Error ? err : undefined);
208
+ return { status: 500, body: { error: 'Internal AI service error' } };
209
+ }
210
+ },
211
+ },
212
+ {
213
+ method: 'GET',
214
+ path: '/api/v1/ai/conversations',
215
+ description: 'List conversations',
216
+ handler: async (req) => {
217
+ try {
218
+ const rawQuery = req.query ?? {};
219
+ const options: Record<string, unknown> = { ...rawQuery };
220
+
221
+ if (typeof rawQuery.limit === 'string') {
222
+ const parsedLimit = Number(rawQuery.limit);
223
+ if (!Number.isFinite(parsedLimit) || parsedLimit <= 0 || !Number.isInteger(parsedLimit)) {
224
+ return { status: 400, body: { error: 'Invalid limit parameter' } };
225
+ }
226
+ options.limit = parsedLimit;
227
+ }
228
+
229
+ const conversations = await conversationService.list(options as any);
230
+ return { status: 200, body: { conversations } };
231
+ } catch (err) {
232
+ logger.error('[AI Route] GET /conversations error', err instanceof Error ? err : undefined);
233
+ return { status: 500, body: { error: 'Internal AI service error' } };
234
+ }
235
+ },
236
+ },
237
+ {
238
+ method: 'POST',
239
+ path: '/api/v1/ai/conversations/:id/messages',
240
+ description: 'Add message to a conversation',
241
+ handler: async (req) => {
242
+ const id = req.params?.id;
243
+ if (!id) {
244
+ return { status: 400, body: { error: 'conversation id is required' } };
245
+ }
246
+
247
+ const message = req.body;
248
+ const validationError = validateMessage(message);
249
+ if (validationError) {
250
+ return { status: 400, body: { error: validationError } };
251
+ }
252
+
253
+ try {
254
+ const conversation = await conversationService.addMessage(id, message as AIMessage);
255
+ return { status: 200, body: conversation };
256
+ } catch (err) {
257
+ const msg = err instanceof Error ? err.message : String(err);
258
+ if (msg.includes('not found')) {
259
+ return { status: 404, body: { error: msg } };
260
+ }
261
+ logger.error('[AI Route] POST /conversations/:id/messages error', err instanceof Error ? err : undefined);
262
+ return { status: 500, body: { error: 'Internal AI service error' } };
263
+ }
264
+ },
265
+ },
266
+ {
267
+ method: 'DELETE',
268
+ path: '/api/v1/ai/conversations/:id',
269
+ description: 'Delete a conversation',
270
+ handler: async (req) => {
271
+ const id = req.params?.id;
272
+ if (!id) {
273
+ return { status: 400, body: { error: 'conversation id is required' } };
274
+ }
275
+
276
+ try {
277
+ await conversationService.delete(id);
278
+ return { status: 204 };
279
+ } catch (err) {
280
+ logger.error('[AI Route] DELETE /conversations/:id error', err instanceof Error ? err : undefined);
281
+ return { status: 500, body: { error: 'Internal AI service error' } };
282
+ }
283
+ },
284
+ },
285
+ ];
286
+ }