Skip to content

Commit f23bb9d

Browse files
committed
feat(ai): integrate AIServicePlugin and add dynamic route handling for AI services
1 parent ef363c7 commit f23bb9d

9 files changed

Lines changed: 298 additions & 0 deletions

File tree

apps/studio/server/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { SecurityPlugin } from '@objectstack/plugin-security';
3333
import { AuditPlugin } from '@objectstack/plugin-audit';
3434
import { FeedServicePlugin } from '@objectstack/service-feed';
3535
import { MetadataPlugin } from '@objectstack/metadata';
36+
import { AIServicePlugin } from '@objectstack/service-ai';
3637
import { handle } from '@hono/node-server/vercel';
3738
import { Hono } from 'hono';
3839
import { createBrokerShim } from '../src/lib/create-broker-shim.js';
@@ -106,6 +107,7 @@ async function ensureKernel(): Promise<ObjectKernel> {
106107
await kernel.use(new AuditPlugin());
107108
await kernel.use(new FeedServicePlugin());
108109
await kernel.use(new MetadataPlugin({ watch: false }));
110+
await kernel.use(new AIServicePlugin());
109111

110112
// Broker shim — bridges HttpDispatcher → ObjectQL engine
111113
(kernel as any).broker = createBrokerShim(kernel);

packages/adapters/hono/src/index.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,33 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
6666
if (res.type === 'redirect' && res.url) {
6767
return c.redirect(res.url);
6868
}
69+
if (res.type === 'stream' && res.events) {
70+
// SSE / Vercel Data Stream streaming response
71+
const headers: Record<string, string> = {
72+
'Content-Type': res.contentType || 'text/event-stream',
73+
'Cache-Control': 'no-cache',
74+
'Connection': 'keep-alive',
75+
...(res.headers || {}),
76+
};
77+
const stream = new ReadableStream({
78+
async start(controller) {
79+
try {
80+
const encoder = new TextEncoder();
81+
for await (const event of res.events) {
82+
const chunk = res.vercelDataStream
83+
? (typeof event === 'string' ? event : JSON.stringify(event) + '\n')
84+
: `data: ${JSON.stringify(event)}\n\n`;
85+
controller.enqueue(encoder.encode(chunk));
86+
}
87+
} catch (err) {
88+
// Stream error — close gracefully
89+
} finally {
90+
controller.close();
91+
}
92+
},
93+
});
94+
return new Response(stream, { status: 200, headers });
95+
}
6996
if (res.type === 'stream' && res.stream) {
7097
if (res.headers) {
7198
Object.entries(res.headers).forEach(([k, v]) => c.header(k, v as string));

packages/cli/src/commands/serve.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,22 @@ export default class Serve extends Command {
243243
// No translations and no explicit i18n plugin — this is fine, kernel fallback works
244244
}
245245

246+
// 3c. Auto-register AIServicePlugin if not already present
247+
const hasAiPlugin = plugins.some(
248+
(p: any) => p.name === 'com.objectstack.service-ai'
249+
|| p.constructor?.name === 'AIServicePlugin'
250+
);
251+
if (!hasAiPlugin) {
252+
try {
253+
const aiPkg = '@objectstack/service-ai';
254+
const { AIServicePlugin } = await import(/* webpackIgnore: true */ aiPkg);
255+
await kernel.use(new AIServicePlugin());
256+
trackPlugin('AIService');
257+
} catch {
258+
// @objectstack/service-ai not installed — skip
259+
}
260+
}
261+
246262
// Add HTTP server plugin BEFORE config plugins so that the
247263
// http-server service is available for any plugin that needs it
248264
// during init/start (e.g. AuthPlugin).

packages/plugins/plugin-audit/src/audit-plugin.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,22 @@ export class AuditPlugin implements Plugin {
2525
objects: [SysAuditLog],
2626
});
2727

28+
// Contribute navigation items to the Setup App (if SetupPlugin is loaded).
29+
try {
30+
const setupNav = ctx.getService<{ contribute(c: any): void }>('setupNav');
31+
if (setupNav) {
32+
setupNav.contribute({
33+
areaId: 'area_system',
34+
items: [
35+
{ id: 'nav_audit_logs', type: 'object', label: { key: 'setup.nav.audit_logs', defaultValue: 'Audit Logs' }, objectName: 'audit_log', icon: 'scroll-text', order: 10 },
36+
],
37+
});
38+
ctx.logger.info('Audit navigation items contributed to Setup App');
39+
}
40+
} catch {
41+
// SetupPlugin not loaded — skip silently
42+
}
43+
2844
ctx.logger.info('Audit Plugin initialized');
2945
}
3046
}

packages/plugins/plugin-auth/src/auth-plugin.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,27 @@ export class AuthPlugin implements Plugin {
112112
],
113113
});
114114

115+
// Contribute navigation items to the Setup App (if SetupPlugin is loaded).
116+
// Uses try/catch so AuthPlugin works independently of SetupPlugin.
117+
try {
118+
const setupNav = ctx.getService<{ contribute(c: any): void }>('setupNav');
119+
if (setupNav) {
120+
setupNav.contribute({
121+
areaId: 'area_administration',
122+
items: [
123+
{ id: 'nav_users', type: 'object', label: { key: 'setup.nav.users', defaultValue: 'Users' }, objectName: 'user', icon: 'users', order: 10 },
124+
{ id: 'nav_organizations', type: 'object', label: { key: 'setup.nav.organizations', defaultValue: 'Organizations' }, objectName: 'organization', icon: 'building-2', order: 20 },
125+
{ id: 'nav_teams', type: 'object', label: { key: 'setup.nav.teams', defaultValue: 'Teams' }, objectName: 'team', icon: 'users-round', order: 30 },
126+
{ id: 'nav_api_keys', type: 'object', label: { key: 'setup.nav.api_keys', defaultValue: 'API Keys' }, objectName: 'api_key', icon: 'key', order: 40 },
127+
{ id: 'nav_sessions', type: 'object', label: { key: 'setup.nav.sessions', defaultValue: 'Sessions' }, objectName: 'session', icon: 'monitor', order: 50 },
128+
],
129+
});
130+
ctx.logger.info('Auth navigation items contributed to Setup App');
131+
}
132+
} catch {
133+
// SetupPlugin not loaded — skip silently
134+
}
135+
115136
ctx.logger.info('Auth Plugin initialized successfully');
116137
}
117138

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,23 @@ export class SecurityPlugin implements Plugin {
4848
objects: [SysRole, SysPermissionSet],
4949
});
5050

51+
// Contribute navigation items to the Setup App (if SetupPlugin is loaded).
52+
try {
53+
const setupNav = ctx.getService<{ contribute(c: any): void }>('setupNav');
54+
if (setupNav) {
55+
setupNav.contribute({
56+
areaId: 'area_administration',
57+
items: [
58+
{ id: 'nav_roles', type: 'object', label: { key: 'setup.nav.roles', defaultValue: 'Roles' }, objectName: 'role', icon: 'shield-check', order: 60 },
59+
{ id: 'nav_permission_sets', type: 'object', label: { key: 'setup.nav.permission_sets', defaultValue: 'Permission Sets' }, objectName: 'permission_set', icon: 'lock', order: 70 },
60+
],
61+
});
62+
ctx.logger.info('Security navigation items contributed to Setup App');
63+
}
64+
} catch {
65+
// SetupPlugin not loaded — skip silently
66+
}
67+
5168
ctx.logger.info('Security Plugin initialized');
5269
}
5370

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ export interface DispatcherPluginConfig {
1111
prefix?: string;
1212
}
1313

14+
/**
15+
* Route definition emitted by service plugins (e.g. AIServicePlugin) via hooks.
16+
* Minimal interface — matches the shape produced by `buildAIRoutes()`.
17+
*/
18+
interface RouteDefinition {
19+
method: 'GET' | 'POST' | 'DELETE';
20+
path: string;
21+
description: string;
22+
handler: (req: any) => Promise<any>;
23+
}
24+
1425
/**
1526
* Send an HttpDispatcherResult through IHttpResponse.
1627
* Differentiates between handled, unhandled (404), and special results.
@@ -379,6 +390,82 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
379390
});
380391

381392
ctx.logger.info('Dispatcher bridge routes registered', { prefix });
393+
394+
// ── Dynamic service routes (AI, etc.) ───────────────────
395+
// Listen for route definitions emitted by service plugins.
396+
// The AIServicePlugin emits 'ai:routes' with RouteDefinition[].
397+
ctx.hook('ai:routes', async (routes: RouteDefinition[]) => {
398+
if (!server) return;
399+
for (const route of routes) {
400+
// Strip the /api/v1 prefix if present (it's already in the path)
401+
// and register on the HTTP server with the configured prefix.
402+
const routePath = route.path.startsWith('/api/v1')
403+
? route.path
404+
: `${prefix}${route.path}`;
405+
406+
const handler = async (req: any, res: any) => {
407+
try {
408+
const result = await route.handler({
409+
body: req.body,
410+
params: req.params,
411+
query: req.query,
412+
});
413+
414+
if (result.stream && result.events) {
415+
// SSE streaming response
416+
res.status(result.status);
417+
res.header('Content-Type', result.vercelDataStream
418+
? 'text/plain; charset=utf-8'
419+
: 'text/event-stream');
420+
res.header('Cache-Control', 'no-cache');
421+
res.header('Connection', 'keep-alive');
422+
423+
// Write the stream — IHttpServer implementations
424+
// may or may not support raw write. Fall back to
425+
// collecting and sending JSON if write is unavailable.
426+
if (typeof res.write === 'function' && typeof res.end === 'function') {
427+
for await (const event of result.events) {
428+
if (result.vercelDataStream) {
429+
// Events are already TextStreamPart — need encoding
430+
// Import is dynamic to avoid hard dep on service-ai
431+
res.write(typeof event === 'string' ? event : JSON.stringify(event) + '\n');
432+
} else {
433+
res.write(`data: ${JSON.stringify(event)}\n\n`);
434+
}
435+
}
436+
res.end();
437+
} else {
438+
// Fallback: collect events into array
439+
const events = [];
440+
for await (const event of result.events) {
441+
events.push(event);
442+
}
443+
res.json({ events });
444+
}
445+
} else {
446+
res.status(result.status);
447+
if (result.body !== undefined) {
448+
res.json(result.body);
449+
} else {
450+
res.end();
451+
}
452+
}
453+
} catch (err: any) {
454+
errorResponse(err, res);
455+
}
456+
};
457+
458+
const m = route.method.toLowerCase();
459+
if (m === 'get' && typeof server.get === 'function') {
460+
server.get(routePath, handler);
461+
} else if (m === 'post' && typeof server.post === 'function') {
462+
server.post(routePath, handler);
463+
} else if (m === 'delete' && typeof server.delete === 'function') {
464+
server.delete(routePath, handler);
465+
}
466+
}
467+
ctx.logger.info(`[Dispatcher] Registered ${routes.length} AI routes`);
468+
});
382469
},
383470
};
384471
}

packages/runtime/src/http-dispatcher.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,6 +1210,107 @@ export class HttpDispatcher {
12101210
return s.charAt(0).toUpperCase() + s.slice(1);
12111211
}
12121212

1213+
/**
1214+
* Handle AI service routes (/ai/chat, /ai/models, /ai/conversations, etc.)
1215+
* Resolves the AI service and its built-in route handlers, then dispatches.
1216+
*/
1217+
async handleAI(subPath: string, method: string, body: any, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
1218+
let aiService: any;
1219+
try {
1220+
aiService = await this.resolveService('ai');
1221+
} catch {
1222+
// AI service not registered
1223+
}
1224+
1225+
if (!aiService) {
1226+
return {
1227+
handled: true,
1228+
response: {
1229+
status: 404,
1230+
body: { success: false, error: { message: 'AI service is not configured', code: 404 } },
1231+
},
1232+
};
1233+
}
1234+
1235+
// The AI service exposes route definitions via buildAIRoutes.
1236+
// We match the request path against known AI route patterns.
1237+
const fullPath = `/api/v1${subPath}`;
1238+
1239+
// Build a simple param-extracting matcher for route patterns like /api/v1/ai/conversations/:id
1240+
const matchRoute = (pattern: string, path: string): Record<string, string> | null => {
1241+
const patternParts = pattern.split('/');
1242+
const pathParts = path.split('/');
1243+
if (patternParts.length !== pathParts.length) return null;
1244+
const params: Record<string, string> = {};
1245+
for (let i = 0; i < patternParts.length; i++) {
1246+
if (patternParts[i].startsWith(':')) {
1247+
params[patternParts[i].substring(1)] = pathParts[i];
1248+
} else if (patternParts[i] !== pathParts[i]) {
1249+
return null;
1250+
}
1251+
}
1252+
return params;
1253+
};
1254+
1255+
// Try to get route definitions from the AI service's cached routes
1256+
const routes = (this.kernel as any).__aiRoutes as Array<{
1257+
method: string; path: string; handler: (req: any) => Promise<any>;
1258+
}> | undefined;
1259+
1260+
if (!routes) {
1261+
return {
1262+
handled: true,
1263+
response: {
1264+
status: 503,
1265+
body: { success: false, error: { message: 'AI service routes not yet initialized', code: 503 } },
1266+
},
1267+
};
1268+
}
1269+
1270+
for (const route of routes) {
1271+
if (route.method !== method) continue;
1272+
const params = matchRoute(route.path, fullPath);
1273+
if (params === null) continue;
1274+
1275+
const result = await route.handler({ body, params, query });
1276+
1277+
if (result.stream && result.events) {
1278+
// Return a streaming result for the adapter to handle
1279+
return {
1280+
handled: true,
1281+
result: {
1282+
type: 'stream',
1283+
contentType: result.vercelDataStream
1284+
? 'text/plain; charset=utf-8'
1285+
: 'text/event-stream',
1286+
events: result.events,
1287+
vercelDataStream: result.vercelDataStream,
1288+
headers: {
1289+
'Content-Type': result.vercelDataStream
1290+
? 'text/plain; charset=utf-8'
1291+
: 'text/event-stream',
1292+
'Cache-Control': 'no-cache',
1293+
'Connection': 'keep-alive',
1294+
},
1295+
},
1296+
};
1297+
}
1298+
1299+
return {
1300+
handled: true,
1301+
response: {
1302+
status: result.status,
1303+
body: result.body,
1304+
},
1305+
};
1306+
}
1307+
1308+
return {
1309+
handled: true,
1310+
response: this.routeNotFound(subPath),
1311+
};
1312+
}
1313+
12131314
/**
12141315
* Main Dispatcher Entry Point
12151316
* Routes the request to the appropriate handler based on path and precedence
@@ -1284,6 +1385,11 @@ export class HttpDispatcher {
12841385
return this.handleI18n(cleanPath.substring(5), method, query, context);
12851386
}
12861387

1388+
// AI Service — delegate to the registered AI route handlers
1389+
if (cleanPath.startsWith('/ai')) {
1390+
return this.handleAI(cleanPath, method, body, query, context);
1391+
}
1392+
12871393
// OpenAPI Specification
12881394
if (cleanPath === '/openapi.json' && method === 'GET') {
12891395
const broker = this.ensureBroker();

packages/services/service-ai/src/plugin.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@ export class AIServicePlugin implements Plugin {
171171
// Trigger hook so HTTP server plugins can mount these routes
172172
await ctx.trigger('ai:routes', routes);
173173

174+
// Cache routes on the kernel so HttpDispatcher can find them
175+
const kernel = ctx.getKernel();
176+
if (kernel) {
177+
(kernel as any).__aiRoutes = routes;
178+
}
179+
174180
ctx.logger.info(
175181
`[AI] Service started — adapter="${this.service.adapterName}", ` +
176182
`tools=${this.service.toolRegistry.size}, ` +

0 commit comments

Comments
 (0)