Files
AFFiNE-Mirror/packages/backend/server/src/plugins/copilot/controller.ts
T
DarkSky d64f368623 feat(server): refactor copilot (#14892)
#### PR Dependency Tree


* **PR #14892** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
2026-05-04 00:36:47 +08:00

378 lines
11 KiB
TypeScript

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<ChatEvent>
): Observable<ChatEvent> {
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<string, string>
): Promise<Observable<ChatEvent>> {
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<string, string>
): Promise<Observable<ChatEvent>> {
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<string, string>
): Promise<Observable<ChatEvent>> {
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<string, string>
): Promise<Observable<ChatEvent>> {
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<string, string>
) {
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);
}
}