import { Body, Controller, Delete, Get, Inject, Param, Patch, Post, Query, Req, Res } from "@nestjs/common"; import type { Request, Response } from "express"; import { AiChatService } from "./ai-chat.service.js"; import { AiService } from "./ai.service.js"; import { ZodValidate } from "../../common/pipes/zod-validation.pipe.js"; import { requireActor, requireUser } from "../../common/types.js"; import { ChatDto, type ChatInput, CreateConversationDto, type CreateConversationInput, ListMessagesQuery, type ListMessagesInput } from "./dto.js"; import { RenameConversationDto, type RenameConversationInput, SendMessageDto, type SendMessageInput } from "./dto.js"; @Controller() export class AiController { // Explicit `@Inject` (the codebase convention): the integration/ESM // runners do not emit `design:paramtypes`, so a bare class-typed // constructor param would silently fail to inject (caught by the // TASK-0121 capability suite: `this.ai` undefined on the first HTTP call). constructor( @Inject(AiService) private readonly ai: AiService, @Inject(AiChatService) private readonly aiChat: AiChatService ) {} /** * POST /workspaces/:workspaceId/ai/ping — the AI diagnostic (TASK-0121): * resolved provider/model + embedding dimension + budget state. No tokens * are spent. Errors: 403 (non-member / insufficient key scope), 503 (AI * misconfigured). TASK-0127: workspace-level — API keys accepted. */ @Post("workspaces/:workspaceId/ai/ping") ping(@Param("workspaceId") workspaceId: string, @Req() req: Request) { return this.ai.ping(workspaceId, requireActor(req)); } // TASK-0127: the conversation routes below are USER-IDENTITY-only // (requireUser) — AI conversations are PERSONAL (owned by the calling // user, listed/owned per `userId`); an API key cannot browse or mutate // the creator's conversations. The workspace-level AI operations (ping, // generate, RAG, database AI, writing) DO accept keys. @Get("workspaces/:workspaceId/ai/conversations") list( @Param("workspaceId") workspaceId: string, @Req() req: Request ) { return this.ai.listConversations(workspaceId, requireUser(req)); } @Post("workspaces/:workspaceId/ai/conversations") create( @Param("workspaceId") workspaceId: string, @Body(ZodValidate(CreateConversationDto)) body: CreateConversationInput, @Req() req: Request ) { return this.ai.createConversation(workspaceId, requireUser(req), body, req); } @Get("ai/conversations/:id") get(@Param("id") id: string, @Req() req: Request) { return this.ai.getConversation(id, requireUser(req)); } @Patch("ai/conversations/:id") rename( @Param("id") id: string, @Body(ZodValidate(RenameConversationDto)) body: RenameConversationInput, @Req() req: Request ) { return this.ai.renameConversation(id, requireUser(req), body.title); } @Delete("ai/conversations/:id") remove(@Param("id") id: string, @Req() req: Request) { return this.ai.deleteConversation(id, requireUser(req)); } @Get("ai/conversations/:id/messages") messages( @Param("id") id: string, @Query(ZodValidate(ListMessagesQuery)) query: ListMessagesInput, @Req() req: Request ) { return this.ai.listMessages(id, requireUser(req), query); } /** * Chat turn (TASK-0121): single turn through the `@factory/ai` gateway — * budget guard (402/429) → provider stream → both messages persisted with * token counts (the usage ledger). The SSE streaming contract arrives in * TASK-0122 (`POST /:conversationId/messages`). */ @Post("workspaces/:workspaceId/ai/chat") chat( @Param("workspaceId") workspaceId: string, @Body(ZodValidate(ChatDto)) body: ChatInput, @Req() req: Request ) { return this.ai.chat(workspaceId, requireUser(req), body, req); } /** * TASK-0122 — Ask AI streaming: SSE stream of ChatDelta frames * (delta / tool_call / usage / error / done — see `docs/api/ai.md`). * * `@Res()` (passthrough: false) — the service owns the socket: pre-stream * failures throw before any byte is written (global filter → JSON * envelope), mid-stream failures are in-band `error` deltas. */ @Post("workspaces/:workspaceId/ai/conversations/:conversationId/messages") sendMessage( @Param("workspaceId") workspaceId: string, @Param("conversationId") conversationId: string, @Body(ZodValidate(SendMessageDto)) body: SendMessageInput, @Req() req: Request, @Res() res: Response ) { return this.aiChat.sendMessage(workspaceId, requireUser(req), conversationId, body, req, res); } }