Skip to content

Commit e796dd5

Browse files
committed
feat(ai): stream AI suggestions over HTTP
The suggestion reaches the client only once the model has finished writing it, so nothing appears until the whole generation is done. Serve it from GET /integration/ai/stream as a UI message stream, text/event-stream carrying typed parts. The alternative, pipeTextStreamToResponse, writes the model's text as bare bytes under text/plain; that body has no framing, so everything in it is content and the server cannot say that what follows is a failure rather than more of the answer. The route reuses the workspace membership check that guards the GraphQL field, which is why checkUserInWorkspaceByProjectId becomes exported. The Express request helper written for the GitHub route tests moves to test/helpers/expressRequest.ts so both suites share one copy. Event lookup fails the same way whether the factory throws or returns nothing: both become "Event not found", which the route answers with 404. The one-shot path shares that helper, so a lookup error no longer leaves it with its original message.
1 parent 3616297 commit e796dd5

13 files changed

Lines changed: 729 additions & 120 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hawk.api",
3-
"version": "1.5.13",
3+
"version": "1.5.14",
44
"main": "index.ts",
55
"license": "BUSL-1.1",
66
"scripts": {

‎src/directives/requireUserInWorkspace.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ async function checkUserInWorkspaceByWorkspaceId(context: ResolverContextBase, w
3737
* @param context - request context
3838
* @param projectId - project id
3939
*/
40-
async function checkUserInWorkspaceByProjectId(context: ResolverContextBase, projectId: string): Promise<void> {
40+
export async function checkUserInWorkspaceByProjectId(context: ResolverContextBase, projectId: string): Promise<void> {
4141
const userId = context.user.id;
4242

4343
if (userId) {

‎src/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import ReleasesFactory from './models/releasesFactory';
3232
import RedisHelper from './redisHelper';
3333
import { appendSsoRoutes } from './sso';
3434
import { appendGitHubRoutes } from './integrations/github';
35+
import { appendAiAssistantRoutes } from './services/askAi';
3536

3637
/**
3738
* Option to enable playground
@@ -272,6 +273,11 @@ class HawkAPI {
272273
*/
273274
appendGitHubRoutes(this.app, sharedFactories);
274275

276+
/**
277+
* Append AI assistant route to Express app
278+
*/
279+
appendAiAssistantRoutes(this.app);
280+
275281
await this.server.start();
276282
this.app.use(graphqlUploadExpress());
277283
this.server.applyMiddleware({ app: this.app });

‎src/integrations/vercel-ai/index.ts‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { generateText } from 'ai';
1+
import { generateText, streamText } from 'ai';
2+
import { ProviderOptions } from '@ai-sdk/provider-utils';
23

34
/**
45
* Params for a single completion call to the model
@@ -27,11 +28,24 @@ class VercelAIApi {
2728
*/
2829
private readonly modelId: string;
2930

31+
/**
32+
* Provider Gateway fallback order
33+
*/
34+
private readonly providerOptions: ProviderOptions;
35+
36+
/**
37+
* Set up model id and provider fallback order
38+
*/
3039
constructor() {
3140
/**
3241
* @todo make it dynamic, get from project settings
3342
*/
3443
this.modelId = 'deepseek/deepseek-v4-flash';
44+
this.providerOptions = {
45+
gateway: {
46+
order: ['novita', 'azure', 'deepseek'],
47+
},
48+
};
3549
}
3650

3751
/**
@@ -45,15 +59,26 @@ class VercelAIApi {
4559
model: this.modelId,
4660
system,
4761
prompt,
48-
providerOptions: {
49-
gateway: {
50-
order: ['novita', 'azure', 'deepseek'],
51-
},
52-
},
62+
providerOptions: this.providerOptions,
5363
});
5464

5565
return text;
5666
}
67+
68+
/**
69+
* Send a system/prompt pair to the model and return the generated text as a stream
70+
*
71+
* @param {CompletionParams} params - system instruction and prompt to complete
72+
* @returns {StreamTextResult} text generated by the model, as a stream
73+
*/
74+
public stream({ system, prompt }: CompletionParams): ReturnType<typeof streamText> {
75+
return streamText({
76+
model: this.modelId,
77+
system,
78+
prompt,
79+
providerOptions: this.providerOptions,
80+
});
81+
}
5782
}
5883

5984
export const vercelAIApi = new VercelAIApi();

‎src/services/askAi/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { AskAiService, askAiService } from './service';
2+
export { appendAiAssistantRoutes } from './routes';

‎src/services/askAi/routes.ts‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import '../../typeDefs/expressContext';
2+
import express from 'express';
3+
import { getEventsFactory } from '../../resolvers/helpers/eventsFactory';
4+
import { checkUserInWorkspaceByProjectId } from '../../directives/requireUserInWorkspace';
5+
import { askAiService } from './service';
6+
7+
/**
8+
* Verify the requesting user is a member of the project's workspace.
9+
*
10+
* @param req - Express request
11+
* @param res - Express response
12+
* @param projectId - project id from query parameters (may be string[] if repeated)
13+
* @returns user id and validated project id if authorized, {@code null} otherwise (response already sent)
14+
*/
15+
async function authorizeProjectAccess(
16+
req: express.Request,
17+
res: express.Response,
18+
projectId: unknown
19+
): Promise<{ userId: string; projectId: string } | null> {
20+
const userId = req.context?.user?.id;
21+
22+
if (!userId) {
23+
res.status(401).json({ error: 'Unauthorized. Please provide authorization token.' });
24+
25+
return null;
26+
}
27+
28+
if (!projectId || typeof projectId !== 'string') {
29+
res.status(400).json({ error: 'projectId query parameter is required' });
30+
31+
return null;
32+
}
33+
34+
try {
35+
await checkUserInWorkspaceByProjectId(req.context, projectId);
36+
} catch (error) {
37+
res.status(403).json({ error: error instanceof Error ? error.message : 'You have no access to this workspace' });
38+
39+
return null;
40+
}
41+
42+
return {
43+
userId,
44+
projectId,
45+
};
46+
}
47+
48+
/**
49+
* Create AI assistant router
50+
*
51+
* @returns Express router with AI assistant endpoints
52+
*/
53+
export function createAiStreamRouter(): express.Router {
54+
const router = express.Router();
55+
56+
/**
57+
* GET /integration/ai/stream?projectId=<projectId>&eventId=<eventId>&originalEventId=<originalEventId>
58+
* Stream an AI suggestion for the event
59+
*/
60+
router.get('/stream', async (req, res, next) => {
61+
try {
62+
const { projectId, eventId, originalEventId } = req.query;
63+
64+
const authResult = await authorizeProjectAccess(req, res, projectId);
65+
66+
if (!authResult) {
67+
return;
68+
}
69+
70+
if (!eventId || typeof eventId !== 'string') {
71+
res.status(400).json({ error: 'eventId query parameter is required' });
72+
73+
return;
74+
}
75+
76+
if (!originalEventId || typeof originalEventId !== 'string') {
77+
res.status(400).json({ error: 'originalEventId query parameter is required' });
78+
79+
return;
80+
}
81+
82+
const eventsFactory = getEventsFactory(req.context, authResult.projectId);
83+
84+
let result;
85+
86+
try {
87+
result = await askAiService.streamSuggestion(eventsFactory, eventId, originalEventId);
88+
} catch (error) {
89+
if (!(error instanceof Error) || error.message !== 'Event not found') {
90+
throw error;
91+
}
92+
93+
res.status(404).json({ error: error.message });
94+
95+
return;
96+
}
97+
98+
result.pipeUIMessageStreamToResponse(res);
99+
} catch (error) {
100+
next(error);
101+
}
102+
});
103+
104+
return router;
105+
}
106+
107+
/**
108+
* Append AI assistant routes to Express app
109+
*
110+
* @param app - Express application instance
111+
*/
112+
export function appendAiAssistantRoutes(app: express.Application): void {
113+
app.use('/integration/ai', createAiStreamRouter());
114+
}

‎src/services/askAi/service.ts‎

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { buildEventPrompt, spotlightInstruction } from './security/spotlighting'
44
import { echoesNonce, SUGGESTION_FALLBACK_MESSAGE } from './security/nonceEcho';
55
import { ctoInstruction } from './instructions/cto';
66
import { EventsFactoryInterface } from '../types';
7+
import type { Event } from '../types';
78

89
/**
910
* Report that the nonce check rejected an answer.
@@ -40,12 +41,12 @@ export class AskAiService {
4041
* @param originalEventId - original event id
4142
* @returns {Promise<string>} - suggestion
4243
*/
43-
public async generateSuggestion(eventsFactory: EventsFactoryInterface, eventId: string, originalEventId: string): Promise<string> {
44-
const event = await eventsFactory.getEventRepetition(eventId, originalEventId);
45-
46-
if (!event) {
47-
throw new Error('Event not found');
48-
}
44+
public async generateSuggestion(
45+
eventsFactory: EventsFactoryInterface,
46+
eventId: string,
47+
originalEventId: string
48+
): Promise<string> {
49+
const event = await this.getEventOrThrow(eventsFactory, eventId, originalEventId);
4950

5051
const { prompt, nonce } = buildEventPrompt(event.payload);
5152

@@ -62,6 +63,61 @@ export class AskAiService {
6263

6364
return text;
6465
}
66+
67+
/**
68+
* Generate streaming suggestion for the event
69+
*
70+
* The payload is spotlighted by {@link buildEventPrompt} exactly as in
71+
* {@link AskAiService.generateSuggestion}.
72+
*
73+
* @param eventsFactory - events factory
74+
* @param eventId - event id
75+
* @param originalEventId - original event id
76+
* @returns streaming suggestion
77+
*/
78+
public async streamSuggestion(
79+
eventsFactory: EventsFactoryInterface,
80+
eventId: string,
81+
originalEventId: string
82+
): Promise<ReturnType<typeof vercelAIApi.stream>> {
83+
const event = await this.getEventOrThrow(eventsFactory, eventId, originalEventId);
84+
85+
const { prompt, nonce } = buildEventPrompt(event.payload);
86+
87+
return vercelAIApi.stream({
88+
system: ctoInstruction + spotlightInstruction(nonce),
89+
prompt,
90+
});
91+
}
92+
93+
/**
94+
* Find the event repetition or throw if it doesn't exist. A thrown lookup
95+
* failure is normalized to the same message too, so it doesn't leak details.
96+
*
97+
* @param eventsFactory - events factory
98+
* @param eventId - event id
99+
* @param originalEventId - original event id
100+
* @returns {Promise<Event>} - event repetition
101+
*/
102+
private async getEventOrThrow(
103+
eventsFactory: EventsFactoryInterface,
104+
eventId: string,
105+
originalEventId: string
106+
): Promise<Event> {
107+
let event: Event | null;
108+
109+
try {
110+
event = await eventsFactory.getEventRepetition(eventId, originalEventId);
111+
} catch {
112+
throw new Error('Event not found');
113+
}
114+
115+
if (!event) {
116+
throw new Error('Event not found');
117+
}
118+
119+
return event;
120+
}
65121
}
66122

67123
export const askAiService = new AskAiService();

‎src/services/types.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { EventAddons, EventData } from '@hawk.so/types';
33
/**
44
* Event type which is returned by events factory
55
*/
6-
type Event = {
6+
export type Event = {
77
_id: string;
88
payload: EventData<EventAddons>;
99
};
@@ -20,4 +20,4 @@ export interface EventsFactoryInterface {
2020
* @returns {Promise<EventData<EventAddons>>} - event repetition
2121
*/
2222
getEventRepetition(repetitionId: string, originalEventId: string): Promise<Event>;
23-
}
23+
}

0 commit comments

Comments
 (0)