mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 20:16:26 +08:00
d64f368623
#### PR Dependency Tree * **PR #14892** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal)
378 lines
11 KiB
TypeScript
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);
|
|
}
|
|
}
|