import { BeforeApplicationShutdown, Controller, Get, Logger, Param, Query, Req, Res, Sse, } from '@nestjs/common'; import type { Request, Response } from 'express'; import { BehaviorSubject, catchError, filter, finalize, from, interval, lastValueFrom, map, merge, Observable, Subject, take, takeUntil, } from 'rxjs'; import { applyAttachHeaders, BlobNotFound, CallMetric, Config, mapSseError, metrics, UnsplashIsNotConfigured, } from '../../base'; import { CurrentUser, Public } from '../../core/auth'; import { ActionStreamHost, projectActionEventToChatEvent, } from './runtime/hosts/action-stream-host'; import { TurnOrchestrator } from './runtime/turn-orchestrator'; import { CopilotStorage } from './storage'; import { getSignal } from './utils'; export interface ChatEvent { type: 'event' | 'attachment' | 'message' | 'error' | 'ping'; id?: string; data: string | object; } const PING_INTERVAL = 5000; @Controller('/api/copilot') export class CopilotController implements BeforeApplicationShutdown { private readonly logger = new Logger(CopilotController.name); private readonly ongoingStreamCount$ = new BehaviorSubject(0); constructor( private readonly config: Config, private readonly orchestrator: TurnOrchestrator, private readonly actionStreams: ActionStreamHost, private readonly storage: CopilotStorage ) {} async beforeApplicationShutdown() { await lastValueFrom( this.ongoingStreamCount$.asObservable().pipe( filter(count => count === 0), take(1) ) ); this.ongoingStreamCount$.complete(); } private mergePingStream( messageId: string, source$: Observable ): Observable { const subject$ = new Subject(); const ping$ = interval(PING_INTERVAL).pipe( map(() => ({ type: 'ping' as const, id: messageId, data: '' })), takeUntil(subject$) ); return merge(source$.pipe(finalize(() => subject$.next(null))), ping$); } private toMessageEvent(messageId: string | undefined, data: string | object) { return { type: 'message' as const, id: messageId, data }; } private toAttachmentEvent(messageId: string | undefined, data: string) { return { type: 'attachment' as const, id: messageId, data }; } @Sse('/chat/:sessionId/stream') @CallMetric('ai', 'chat_stream', { timer: true }) async chatStream( @CurrentUser() user: CurrentUser, @Req() req: Request, @Param('sessionId') sessionId: string, @Query() query: Record ): Promise> { const info: any = { sessionId, params: query, throwInStream: false }; try { const { signal, onConnectionClosed } = getSignal(req); let endBeforePromiseResolve = false; onConnectionClosed(isAborted => { if (isAborted) { endBeforePromiseResolve = true; } }); const prepared = await this.orchestrator.streamText( user.id, sessionId, query, signal, () => endBeforePromiseResolve ); info.model = prepared.model; info.finalMessage = prepared.finalMessage.filter( m => m.role !== 'system' ); metrics.ai.counter('chat_stream_calls').add(1, { model: prepared.model }); this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1); const source$ = from(prepared.stream).pipe( map(data => this.toMessageEvent(prepared.messageId, data)), catchError(e => { metrics.ai.counter('chat_stream_errors').add(1); info.throwInStream = true; return mapSseError(e, info); }), finalize(() => { this.ongoingStreamCount$.next(this.ongoingStreamCount$.value - 1); }) ); return this.mergePingStream(prepared.messageId || '', source$); } catch (err) { metrics.ai.counter('chat_stream_errors').add(1, info); return mapSseError(err, info); } } @Sse('/chat/:sessionId/stream-object') @CallMetric('ai', 'chat_object_stream', { timer: true }) async chatStreamObject( @CurrentUser() user: CurrentUser, @Req() req: Request, @Param('sessionId') sessionId: string, @Query() query: Record ): Promise> { const info: any = { sessionId, params: query, throwInStream: false }; try { const { signal, onConnectionClosed } = getSignal(req); let endBeforePromiseResolve = false; onConnectionClosed(isAborted => { if (isAborted) { endBeforePromiseResolve = true; } }); const prepared = await this.orchestrator.streamObject( user.id, sessionId, query, signal, () => endBeforePromiseResolve ); info.model = prepared.model; info.finalMessage = prepared.finalMessage.filter( m => m.role !== 'system' ); metrics.ai.counter('chat_object_stream_calls').add(1, { model: prepared.model, }); this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1); const source$ = from(prepared.stream).pipe( map(data => this.toMessageEvent(prepared.messageId, data)), catchError(e => { metrics.ai.counter('chat_object_stream_errors').add(1); info.throwInStream = true; return mapSseError(e, info); }), finalize(() => { this.ongoingStreamCount$.next(this.ongoingStreamCount$.value - 1); }) ); return this.mergePingStream(prepared.messageId || '', source$); } catch (err) { metrics.ai.counter('chat_object_stream_errors').add(1, info); return mapSseError(err, info); } } @Sse('/actions/:sessionId/stream') @CallMetric('ai', 'action_stream', { timer: true }) async actionStream( @CurrentUser() user: CurrentUser, @Req() req: Request, @Param('sessionId') sessionId: string, @Query() query: Record ): Promise> { const info: any = { sessionId, params: query, throwInStream: false }; try { const { signal } = getSignal(req); const prepared = await this.actionStreams.stream( user.id, sessionId, query, signal ); info.actionId = prepared.actionId; info.actionVersion = prepared.actionVersion; metrics.ai.counter('action_stream_calls').add(1, { actionId: prepared.actionId, actionVersion: prepared.actionVersion, }); this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1); const source$ = from(prepared.stream).pipe( map(data => projectActionEventToChatEvent(prepared.messageId, data)), catchError(e => { metrics.ai.counter('action_stream_errors').add(1, info); info.throwInStream = true; return mapSseError(e, info); }), finalize(() => this.ongoingStreamCount$.next(this.ongoingStreamCount$.value - 1) ) ); return this.mergePingStream(prepared.messageId || '', source$); } catch (err) { metrics.ai.counter('action_stream_errors').add(1, info); return mapSseError(err, info); } } @Sse('/chat/:sessionId/images') @CallMetric('ai', 'chat_images', { timer: true }) async chatImagesStream( @CurrentUser() user: CurrentUser, @Req() req: Request, @Param('sessionId') sessionId: string, @Query() query: Record ): Promise> { const info: any = { sessionId, params: query, throwInStream: false }; try { const { signal, onConnectionClosed } = getSignal(req); let endBeforePromiseResolve = false; onConnectionClosed(isAborted => { if (isAborted) { endBeforePromiseResolve = true; } }); const prepared = await this.orchestrator.streamImages( user.id, sessionId, query, signal, () => endBeforePromiseResolve ); info.model = prepared.model; metrics.ai.counter('images_stream_calls').add(1, { model: prepared.model, }); this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1); const source$ = from(prepared.stream).pipe( map(attachment => this.toAttachmentEvent(prepared.messageId, attachment) ), catchError(e => { metrics.ai.counter('images_stream_errors').add(1, info); info.throwInStream = true; return mapSseError(e, info); }), finalize(() => this.ongoingStreamCount$.next(this.ongoingStreamCount$.value - 1) ) ); return this.mergePingStream(prepared.messageId || '', source$); } catch (err) { metrics.ai.counter('images_stream_errors').add(1, info); return mapSseError(err, info); } } @Get('/unsplash/photos') @CallMetric('ai', 'unsplash') async unsplashPhotos( @Req() req: Request, @Res() res: Response, @Query() params: Record ) { const { key } = this.config.copilot.unsplash; if (!key) { throw new UnsplashIsNotConfigured(); } const query = new URLSearchParams(params); const response = await fetch( `https://api.unsplash.com/search/photos?${query}`, { headers: { Authorization: `Client-ID ${key}` }, signal: getSignal(req).signal, } ); res.set({ 'Content-Type': response.headers.get('Content-Type'), 'Content-Length': response.headers.get('Content-Length'), 'X-Ratelimit-Limit': response.headers.get('X-Ratelimit-Limit'), 'X-Ratelimit-Remaining': response.headers.get('X-Ratelimit-Remaining'), }); res.status(response.status).send(await response.json()); } @Public() @Get('/blob/:userId/:workspaceId/:key') async getBlob( @Res() res: Response, @Param('userId') userId: string, @Param('workspaceId') workspaceId: string, @Param('key') key: string ) { const { body, metadata, redirectUrl } = await this.storage.get( userId, workspaceId, key, true ); if (redirectUrl) { // redirect to signed url return res.redirect(redirectUrl); } if (!body) { throw new BlobNotFound({ spaceId: workspaceId, blobId: key, }); } // metadata should always exists if body is not null if (metadata) { res.setHeader('content-type', metadata.contentType); res.setHeader('last-modified', metadata.lastModified.toUTCString()); res.setHeader('content-length', metadata.contentLength); } else { this.logger.warn(`Blob ${workspaceId}/${key} has no metadata`); } applyAttachHeaders(res, { contentType: metadata?.contentType, filename: key, }); res.setHeader('cache-control', 'public, max-age=2592000, immutable'); body.pipe(res); } }