All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
103 lines
2.8 KiB
TypeScript
103 lines
2.8 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
HttpException,
|
|
Logger,
|
|
Post,
|
|
Req,
|
|
Res,
|
|
UnauthorizedException,
|
|
UseGuards,
|
|
} from "@nestjs/common";
|
|
import type { Response } from "express";
|
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
|
import type { MaybeAuthenticatedRequest } from "../auth/types/better-auth-request.interface";
|
|
import { ChatStreamDto } from "./chat-proxy.dto";
|
|
import { ChatProxyService } from "./chat-proxy.service";
|
|
|
|
@Controller("chat")
|
|
@UseGuards(AuthGuard)
|
|
export class ChatProxyController {
|
|
private readonly logger = new Logger(ChatProxyController.name);
|
|
|
|
constructor(private readonly chatProxyService: ChatProxyService) {}
|
|
|
|
// POST /api/chat/stream
|
|
// Request: { messages: Array<{role, content}> }
|
|
// Response: SSE stream of chat completion events
|
|
@Post("stream")
|
|
async streamChat(
|
|
@Body() body: ChatStreamDto,
|
|
@Req() req: MaybeAuthenticatedRequest,
|
|
@Res() res: Response
|
|
): Promise<void> {
|
|
const userId = req.user?.id;
|
|
if (!userId) {
|
|
throw new UnauthorizedException("No authenticated user found on request");
|
|
}
|
|
|
|
const abortController = new AbortController();
|
|
req.once("close", () => {
|
|
abortController.abort();
|
|
});
|
|
|
|
res.setHeader("Content-Type", "text/event-stream");
|
|
res.setHeader("Cache-Control", "no-cache");
|
|
res.setHeader("Connection", "keep-alive");
|
|
res.setHeader("X-Accel-Buffering", "no");
|
|
|
|
try {
|
|
const upstreamResponse = await this.chatProxyService.proxyChat(
|
|
userId,
|
|
body.messages,
|
|
abortController.signal
|
|
);
|
|
|
|
const upstreamContentType = upstreamResponse.headers.get("content-type");
|
|
if (upstreamContentType) {
|
|
res.setHeader("Content-Type", upstreamContentType);
|
|
}
|
|
|
|
if (!upstreamResponse.body) {
|
|
throw new Error("OpenClaw response did not include a stream body");
|
|
}
|
|
|
|
for await (const chunk of upstreamResponse.body as unknown as AsyncIterable<Uint8Array>) {
|
|
if (res.writableEnded || res.destroyed) {
|
|
break;
|
|
}
|
|
|
|
res.write(Buffer.from(chunk));
|
|
}
|
|
} catch (error: unknown) {
|
|
this.logStreamError(error);
|
|
|
|
if (!res.writableEnded && !res.destroyed) {
|
|
res.write("event: error\n");
|
|
res.write(`data: ${JSON.stringify({ error: this.toSafeClientMessage(error) })}\n\n`);
|
|
}
|
|
} finally {
|
|
if (!res.writableEnded && !res.destroyed) {
|
|
res.end();
|
|
}
|
|
}
|
|
}
|
|
|
|
private toSafeClientMessage(error: unknown): string {
|
|
if (error instanceof HttpException && error.getStatus() < 500) {
|
|
return "Chat request was rejected";
|
|
}
|
|
|
|
return "Chat stream failed";
|
|
}
|
|
|
|
private logStreamError(error: unknown): void {
|
|
if (error instanceof Error) {
|
|
this.logger.warn(`Chat stream failed: ${error.message}`);
|
|
return;
|
|
}
|
|
|
|
this.logger.warn(`Chat stream failed: ${String(error)}`);
|
|
}
|
|
}
|