feat(chat): add guest chat mode for unauthenticated users
Some checks failed
ci/woodpecker/push/ci Pipeline failed

- Add POST /api/chat/guest endpoint (no auth required)
- Add proxyGuestChat() method using configurable LLM endpoint
- Add streamGuestChat() function to frontend chat API
- Modify useChat to fall back to guest mode on auth errors (403/401)
- Remove !user check from ChatInput disabled prop
- Configure guest LLM via env vars: GUEST_LLM_URL, GUEST_LLM_API_KEY, GUEST_LLM_MODEL
- Default guest LLM: http://10.1.1.42:11434/v1 (Ollama) with llama3.2 model
This commit is contained in:
2026-03-03 11:16:23 -06:00
parent b1baa70e00
commit c45cec3bba
5 changed files with 326 additions and 9 deletions

View File

@@ -6,7 +6,6 @@ import {
Post,
Req,
Res,
UnauthorizedException,
UseGuards,
} from "@nestjs/common";
import type { Response } from "express";
@@ -16,16 +15,72 @@ 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/guest
// Guest chat endpoint - no authentication required
// Uses a shared LLM configuration for unauthenticated users
@Post("guest")
async guestChat(
@Body() body: ChatStreamDto,
@Req() req: MaybeAuthenticatedRequest,
@Res() res: Response
): Promise<void> {
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.proxyGuestChat(
body.messages,
abortController.signal
);
const upstreamContentType = upstreamResponse.headers.get("content-type");
if (upstreamContentType) {
res.setHeader("Content-Type", upstreamContentType);
}
if (!upstreamResponse.body) {
throw new Error("LLM 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();
}
}
}
// POST /api/chat/stream
// Request: { messages: Array<{role, content}> }
// Response: SSE stream of chat completion events
// Requires authentication - uses user's personal OpenClaw container
@Post("stream")
@UseGuards(AuthGuard)
async streamChat(
@Body() body: ChatStreamDto,
@Req() req: MaybeAuthenticatedRequest,
@@ -33,7 +88,8 @@ export class ChatProxyController {
): Promise<void> {
const userId = req.user?.id;
if (!userId) {
throw new UnauthorizedException("No authenticated user found on request");
this.logger.warn("streamChat called without user ID after AuthGuard");
throw new HttpException("Authentication required", 401);
}
const abortController = new AbortController();