diff --git a/backend/src/modules/ai/ai.controller.ts b/backend/src/modules/ai/ai.controller.ts index ca48010..45bbede 100644 --- a/backend/src/modules/ai/ai.controller.ts +++ b/backend/src/modules/ai/ai.controller.ts @@ -1,32 +1,97 @@ import { Body, Controller, + DefaultValuePipe, + Delete, + Get, HttpCode, HttpStatus, + Param, + ParseIntPipe, + ParseUUIDPipe, Post, + Query, UseGuards, } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { AiService } from './ai.service'; -import { ChatDto } from './dto/chat.dto'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { PublicUser } from '../user/user.service'; +import type { PaginatedResult } from '../../common/pagination/pagination'; +import { + AiService, + type ConversationDetail, + type ConversationSummary, + type SendResult, +} from './ai.service'; +import { SendMessageDto } from './dto/send-message.dto'; -// AI 채팅 컨트롤러 — 인증 필요. 외부 API 호출 비용이 있어 라우트 단위 throttle 적용. +// AI 채팅 컨트롤러 — 인증 필요. 대화는 본인 소유만 접근(서비스에서 스코프). @ApiTags('AI') -@Controller('ai') +@Controller('ai/conversations') @UseGuards(JwtAuthGuard) export class AiController { constructor(private readonly aiService: AiService) {} - @Post('chat') + @Get() + @ApiOperation({ summary: '내 대화 목록 (최근순, 페이지네이션)' }) + @ApiResponse({ status: 200, description: '목록 조회 성공' }) + list( + @CurrentUser() user: PublicUser, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number, + ): Promise> { + return this.aiService.listConversations(user.id, page, size); + } + + @Get(':id') + @ApiOperation({ summary: '대화 상세 (메시지 포함)' }) + @ApiResponse({ status: 200, description: '조회 성공' }) + @ApiResponse({ status: 404, description: '대화 없음 (RES_001)' }) + get( + @CurrentUser() user: PublicUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.aiService.getConversation(user.id, id); + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @Throttle({ default: { limit: 20, ttl: 60_000 } }) + @ApiOperation({ summary: '새 대화 시작 (첫 메시지)' }) + @ApiResponse({ status: 201, description: '생성 + AI 응답' }) + start( + @CurrentUser() user: PublicUser, + @Body() dto: SendMessageDto, + ): Promise { + return this.aiService.startConversation(user.id, dto.content); + } + + @Post(':id/messages') @HttpCode(HttpStatus.OK) @Throttle({ default: { limit: 20, ttl: 60_000 } }) - @ApiOperation({ summary: 'AI 채팅 (Claude)' }) + @ApiOperation({ summary: '대화에 메시지 추가' }) @ApiResponse({ status: 200, description: 'AI 응답' }) - @ApiResponse({ status: 502, description: 'AI 응답 실패 (BIZ_001)' }) - async chat(@Body() dto: ChatDto): Promise<{ reply: string }> { - const reply = await this.aiService.chat(dto.messages); - return { reply }; + @ApiResponse({ status: 404, description: '대화 없음 (RES_001)' }) + send( + @CurrentUser() user: PublicUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SendMessageDto, + ): Promise { + return this.aiService.addMessage(user.id, id, dto.content); + } + + @Delete(':id') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '대화 삭제' }) + @ApiResponse({ status: 200, description: '삭제 성공' }) + @ApiResponse({ status: 404, description: '대화 없음 (RES_001)' }) + async remove( + @CurrentUser() user: PublicUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + await this.aiService.deleteConversation(user.id, id); + return null; } } diff --git a/backend/src/modules/ai/ai.module.ts b/backend/src/modules/ai/ai.module.ts index 7992a57..fd20b2c 100644 --- a/backend/src/modules/ai/ai.module.ts +++ b/backend/src/modules/ai/ai.module.ts @@ -1,9 +1,13 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { AiController } from './ai.controller'; import { AiService } from './ai.service'; +import { AiConversation } from './entities/ai-conversation.entity'; +import { AiMessage } from './entities/ai-message.entity'; -// AI 모듈 — Claude(Anthropic) 채팅 프록시 +// AI 모듈 — Claude(Anthropic) 채팅 프록시 + 대화/메시지 영속 @Module({ + imports: [TypeOrmModule.forFeature([AiConversation, AiMessage])], controllers: [AiController], providers: [AiService], }) diff --git a/backend/src/modules/ai/ai.service.ts b/backend/src/modules/ai/ai.service.ts index 9cdd0f5..4e86b1c 100644 --- a/backend/src/modules/ai/ai.service.ts +++ b/backend/src/modules/ai/ai.service.ts @@ -1,22 +1,48 @@ import { HttpStatus, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { BusinessException } from '../../common/exceptions/business.exception'; -import type { ChatMessageDto } from './dto/chat.dto'; +import { + paginated, + resolvePage, + type PaginatedResult, +} from '../../common/pagination/pagination'; +import { AiConversation } from './entities/ai-conversation.entity'; +import { AiMessage } from './entities/ai-message.entity'; // Anthropic Messages API 응답(필요 필드만) interface AnthropicResponse { content?: { type: string; text?: string }[]; } +// 외부 노출 형태 +export interface ConversationSummary { + id: string; + title: string; + updatedAt: Date; +} +export interface ConversationDetail { + id: string; + title: string; + messages: { role: 'user' | 'assistant'; content: string }[]; +} +export interface SendResult { + conversationId: string; + title: string; + reply: string; +} + const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages'; const REQUEST_TIMEOUT_MS = 30_000; const MAX_TOKENS = 1024; +const TITLE_MAX = 60; const SYSTEM_PROMPT = '당신은 업무 협업 도구 "Relay" 의 AI 어시스턴트입니다. ' + '사용자의 질문에 한국어로 간결하고 정확하게 답하세요. ' + '모르는 내용은 추측하지 말고 모른다고 답하세요.'; -// AI(Claude) 채팅 서비스 — Anthropic Messages API 프록시. +// AI(Claude) 채팅 서비스 — 대화/메시지를 DB 에 보관하고 Anthropic Messages API 를 호출. // 키(CLAUDE_API_KEY)는 서버 env 에만 두고 클라이언트에 노출하지 않는다. @Injectable() export class AiService { @@ -24,7 +50,13 @@ export class AiService { private readonly apiKey: string; private readonly model: string; - constructor(config: ConfigService) { + constructor( + config: ConfigService, + @InjectRepository(AiConversation) + private readonly convRepo: Repository, + @InjectRepository(AiMessage) + private readonly msgRepo: Repository, + ) { this.apiKey = (config.get('CLAUDE_API_KEY') ?? '').trim(); this.model = ( config.get('CLAUDE_MODEL') ?? 'claude-sonnet-4-6' @@ -38,8 +70,152 @@ export class AiService { return this.apiKey.length > 0; } - // 대화 이력을 받아 Claude 응답 텍스트를 반환 - async chat(messages: ChatMessageDto[]): Promise { + // 내 대화 목록(최근순, 페이지네이션) + async listConversations( + userId: string, + page?: number, + size?: number, + ): Promise> { + const params = resolvePage(page, size); + const [rows, total] = await this.convRepo.findAndCount({ + where: { user: { id: userId } }, + order: { updatedAt: 'DESC' }, + skip: params.skip, + take: params.take, + }); + return paginated( + rows.map((c) => ({ id: c.id, title: c.title, updatedAt: c.updatedAt })), + total, + params, + ); + } + + // 대화 상세(본인 소유만) — 메시지 시간순 + async getConversation( + userId: string, + conversationId: string, + ): Promise { + const conv = await this.getOwnedConversation(userId, conversationId); + const messages = await this.msgRepo.find({ + where: { conversation: { id: conv.id } }, + order: { createdAt: 'ASC' }, + }); + return { + id: conv.id, + title: conv.title, + messages: messages.map((m) => ({ role: m.role, content: m.content })), + }; + } + + // 새 대화 시작 — 첫 메시지로 대화 생성 후 응답 + async startConversation( + userId: string, + content: string, + ): Promise { + this.ensureEnabled(); + const conv = await this.convRepo.save( + this.convRepo.create({ + user: { id: userId }, + title: this.makeTitle(content), + }), + ); + return this.appendAndReply(conv, content); + } + + // 기존 대화에 메시지 추가 후 응답(본인 소유만) + async addMessage( + userId: string, + conversationId: string, + content: string, + ): Promise { + this.ensureEnabled(); + const conv = await this.getOwnedConversation(userId, conversationId); + return this.appendAndReply(conv, content); + } + + // 대화 삭제(본인 소유만) — 메시지 CASCADE + async deleteConversation( + userId: string, + conversationId: string, + ): Promise { + const conv = await this.getOwnedConversation(userId, conversationId); + await this.convRepo.remove(conv); + } + + // 사용자 메시지 저장 → 전체 이력으로 Claude 호출 → 응답 저장 → 대화 updatedAt 갱신. + // Claude 실패 시 방금 저장한 사용자 메시지를 롤백(빈 대화면 대화도 삭제)해 고아 데이터 방지. + private async appendAndReply( + conv: AiConversation, + content: string, + ): Promise { + const userMsg = await this.msgRepo.save( + this.msgRepo.create({ + conversation: { id: conv.id }, + role: 'user', + content, + }), + ); + const history = await this.msgRepo.find({ + where: { conversation: { id: conv.id } }, + order: { createdAt: 'ASC' }, + }); + + let reply: string; + try { + reply = await this.callClaude( + history.map((m) => ({ role: m.role, content: m.content })), + ); + } catch (e) { + // 롤백 — 방금 사용자 메시지 제거, 남은 메시지가 없으면(첫 메시지였다면) 대화도 삭제 + await this.msgRepo.delete(userMsg.id); + const remaining = await this.msgRepo.count({ + where: { conversation: { id: conv.id } }, + }); + if (remaining === 0) { + await this.convRepo.delete(conv.id); + } + throw e; + } + + await this.msgRepo.save( + this.msgRepo.create({ + conversation: { id: conv.id }, + role: 'assistant', + content: reply, + }), + ); + // 최근 대화로 정렬되도록 updatedAt 갱신(@UpdateDateColumn) + await this.convRepo.save(conv); + return { conversationId: conv.id, title: conv.title, reply }; + } + + // 소유권 확인 — 본인 대화가 아니면 404(존재 비노출) + private async getOwnedConversation( + userId: string, + conversationId: string, + ): Promise { + const conv = await this.convRepo.findOne({ + where: { id: conversationId, user: { id: userId } }, + }); + if (!conv) { + throw new BusinessException( + 'RES_001', + '대화를 찾을 수 없습니다.', + HttpStatus.NOT_FOUND, + ); + } + return conv; + } + + private makeTitle(content: string): string { + const firstLine = content.trim().split('\n')[0].trim(); + if (!firstLine) return '새 대화'; + return firstLine.length > TITLE_MAX + ? `${firstLine.slice(0, TITLE_MAX)}…` + : firstLine; + } + + private ensureEnabled(): void { if (!this.enabled) { throw new BusinessException( 'BIZ_001', @@ -47,7 +223,12 @@ export class AiService { HttpStatus.SERVICE_UNAVAILABLE, ); } + } + // Anthropic Messages API 호출 — 실패 시 내부/키 노출 없이 일반 문구 + private async callClaude( + messages: { role: 'user' | 'assistant'; content: string }[], + ): Promise { let response: Response; try { response = await fetch(ANTHROPIC_URL, { @@ -61,12 +242,11 @@ export class AiService { model: this.model, max_tokens: MAX_TOKENS, system: SYSTEM_PROMPT, - messages: messages.map((m) => ({ role: m.role, content: m.content })), + messages, }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); } catch (e) { - // 네트워크 오류/타임아웃 — 키나 내부 정보는 노출하지 않는다 this.logger.error( `Anthropic 요청 실패: ${e instanceof Error ? e.message : String(e)}`, ); diff --git a/backend/src/modules/ai/dto/chat.dto.ts b/backend/src/modules/ai/dto/chat.dto.ts deleted file mode 100644 index c5c7210..0000000 --- a/backend/src/modules/ai/dto/chat.dto.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - ArrayMaxSize, - ArrayNotEmpty, - IsArray, - IsIn, - IsNotEmpty, - IsString, - MaxLength, - ValidateNested, -} from 'class-validator'; -import { Type } from 'class-transformer'; -import { ApiProperty } from '@nestjs/swagger'; - -// 대화 메시지 1건 — 역할(user/assistant)과 내용 -export class ChatMessageDto { - @ApiProperty({ description: '역할', enum: ['user', 'assistant'] }) - @IsIn(['user', 'assistant'], { message: '잘못된 메시지 역할입니다.' }) - role!: 'user' | 'assistant'; - - @ApiProperty({ description: '메시지 내용' }) - @IsString() - @IsNotEmpty({ message: '메시지 내용을 입력해 주세요.' }) - @MaxLength(8000, { message: '메시지가 너무 깁니다.' }) - content!: string; -} - -// AI 채팅 요청 — 대화 이력 전체를 전달(서버는 무상태) -export class ChatDto { - @ApiProperty({ description: '대화 이력', type: [ChatMessageDto] }) - @IsArray() - @ArrayNotEmpty({ message: '메시지를 입력해 주세요.' }) - @ArrayMaxSize(50, { message: '대화가 너무 깁니다. 새 대화를 시작해 주세요.' }) - @ValidateNested({ each: true }) - @Type(() => ChatMessageDto) - messages!: ChatMessageDto[]; -} diff --git a/backend/src/modules/ai/dto/send-message.dto.ts b/backend/src/modules/ai/dto/send-message.dto.ts new file mode 100644 index 0000000..11c863c --- /dev/null +++ b/backend/src/modules/ai/dto/send-message.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +// 메시지 전송 — 대화 생성/이어가기 공통. 이력은 서버가 DB 에서 불러온다. +export class SendMessageDto { + @ApiProperty({ description: '사용자 메시지 내용' }) + @IsString() + @IsNotEmpty({ message: '메시지 내용을 입력해 주세요.' }) + @MaxLength(8000, { message: '메시지가 너무 깁니다.' }) + content!: string; +} diff --git a/backend/src/modules/ai/entities/ai-conversation.entity.ts b/backend/src/modules/ai/entities/ai-conversation.entity.ts new file mode 100644 index 0000000..1db268e --- /dev/null +++ b/backend/src/modules/ai/entities/ai-conversation.entity.ts @@ -0,0 +1,33 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, + type Relation, +} from 'typeorm'; +import { User } from '../../user/entities/user.entity'; + +// AI 대화 — 사용자별 채팅 스레드. 메시지는 AiMessage 로 분리(1:N). +@Entity('ai_conversations') +export class AiConversation { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE', nullable: false }) + @JoinColumn({ name: 'user_id' }) + user!: Relation; + + // 첫 사용자 메시지로 자동 생성되는 제목 + @Column({ type: 'varchar', length: 120 }) + title!: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + // 새 메시지 추가 시 갱신(최근 대화 정렬용) + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/backend/src/modules/ai/entities/ai-message.entity.ts b/backend/src/modules/ai/entities/ai-message.entity.ts new file mode 100644 index 0000000..3a5f395 --- /dev/null +++ b/backend/src/modules/ai/entities/ai-message.entity.ts @@ -0,0 +1,30 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + type Relation, +} from 'typeorm'; +import { AiConversation } from './ai-conversation.entity'; + +// AI 대화 메시지 1건 — 대화에 종속(CASCADE). +@Entity('ai_messages') +export class AiMessage { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ManyToOne(() => AiConversation, { onDelete: 'CASCADE', nullable: false }) + @JoinColumn({ name: 'conversation_id' }) + conversation!: Relation; + + @Column({ type: 'varchar' }) + role!: 'user' | 'assistant'; + + @Column({ type: 'text' }) + content!: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/docs/api-contract.md b/docs/api-contract.md index 73b7b63..6686f6a 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -336,14 +336,19 @@ todo ─시작→ prog ─승인요청→ review ─승인→ done Google/Kakao OAuth(passport, 키 미설정 시 자동 비활성) + 이메일 가입 인증(미인증 로그인 차단, 그랜드페더링). 메일 발송은 `MailService` 플레이스홀더(백엔드 로그). 상세는 §1 참조. -### AI 채팅 (`/api/ai`) — **✅ 완료** +### AI 채팅 (`/api/ai/conversations`) — **✅ 완료 (DB 영속, 다중 대화)** | 메서드 | 경로 | 요청 | 응답 | 비고 | |---|---|---|---|---| -| POST | `/ai/chat` | `{ messages: {role:'user'\|'assistant', content}[] }` | `{ reply }` | Claude(Anthropic) 프록시. 인증 필요, IP당 20회/분 throttle | +| GET | `/ai/conversations` | `?page&size` | `{items:[{id,title,updatedAt}],total,...}` | 내 대화 목록(최근순) | +| GET | `/ai/conversations/:id` | — | `{id,title,messages:[{role,content}]}` | 대화 상세(본인 소유만) | +| POST | `/ai/conversations` | `{ content }` | `{ conversationId, title, reply }` | 새 대화 시작(첫 메시지). throttle 20/분 | +| POST | `/ai/conversations/:id/messages` | `{ content }` | `{ conversationId, title, reply }` | 메시지 추가. throttle 20/분 | +| DELETE | `/ai/conversations/:id` | — | `null` | 대화 삭제(메시지 CASCADE) | -- `AiService` 가 Anthropic Messages API(`x-api-key`) 호출 — **키(`CLAUDE_API_KEY`)는 서버 env 에만**, 클라이언트 비노출. 모델 `CLAUDE_MODEL`(기본 `claude-sonnet-4-6`). 미설정 시 503. 외부 오류는 502 + 일반 문구(내부/키 비노출). -- 프론트: `components/AgentChatPanel.vue`(우측 슬라이드 패널). 버튼은 **AppShell 헤더에 상시 노출(모든 화면에서 열림)**. 대화 이력 전체를 매 요청 전송(서버 무상태). 기존 목업 터미널 패널은 제거. +- **대화/메시지를 DB 보관**(`AiConversation`·`AiMessage`, user CASCADE). 모든 접근은 **본인 소유 스코프**(타 사용자 대화는 404=RES_001, IDOR 차단). 클라는 "이 대화에 새 메시지 1건"만 보내고 **서버가 DB 이력으로 Claude 호출**(이전의 클라 전체-이력 전송 방식 폐기). +- `AiService` 가 Anthropic Messages API(`x-api-key`) 호출 — **키(`CLAUDE_API_KEY`)는 서버 env 에만**. 모델 `CLAUDE_MODEL`(기본 `claude-sonnet-4-6`). 미설정 503 / 외부오류 502(내부·키 비노출). **Claude 실패 시 방금 사용자 메시지·빈 대화 롤백**(고아 데이터 방지). +- 프론트: `components/AgentChatPanel.vue`(우측 슬라이드, **채팅 ↔ 지난 대화 목록** 전환·열기·삭제), 버튼은 **AppShell 헤더 상시 노출(모든 화면)**. `stores/ai.store` 가 목록·현재 대화 관리 → **DB 영속이라 다른 기기·재로그인에도 기록 유지**. ### 그 외(미착수) diff --git a/docs/integration-progress.md b/docs/integration-progress.md index deebf16..22ea01f 100644 --- a/docs/integration-progress.md +++ b/docs/integration-progress.md @@ -315,7 +315,8 @@ - **백엔드** 신규 `modules/ai/`: `AiService`(Anthropic Messages API 프록시 — 글로벌 `fetch` + `AbortSignal.timeout(30s)`, 키는 서버 env 만, 미설정 503·외부오류 502·내부정보 비노출), `AiController` `POST /ai/chat`(JwtAuthGuard + `@Throttle` 20/분), `ChatDto`(role enum + content MaxLength 8000 + 메시지 ArrayMaxSize 50). 모델 `CLAUDE_MODEL`(기본 `claude-sonnet-4-6`). `AiModule` → app.module. - **프론트** 신규 `components/AgentChatPanel.vue`(우측 슬라이드 채팅 패널, 환영문구·말풍선·타이핑 인디케이터·에러·새대화, plain text + `white-space:pre-wrap`로 XSS 안전), `types/ai.ts`·`composables/useAi.ts`(대화 이력 전체 전송, 서버 무상태). **TaskDetailPage 의 기존 목업 에이전트 패널(스크립트·템플릿·전역 스타일 전부) 제거**. 이로써 보안 L1(에이전트 v-html)도 해소. -- **버튼은 AppShell 헤더로 이동 — 모든 화면에서 상시 노출/열림**(`agentOpen` 은 AppShell 가 보유, `` 도 AppShell 에서 렌더). **대화 이력은 `stores/ai.store.ts`(싱글톤)에 보관 → 화면 이동에도 유지, '새 대화' 버튼으로만 초기화**(입력 텍스트만 컴포넌트 로컬). 하드 리프레시는 초기화(인메모리). +- **버튼은 AppShell 헤더로 이동 — 모든 화면에서 상시 노출/열림**(`agentOpen` 은 AppShell 가 보유, `` 도 AppShell 에서 렌더). +- **DB 영속 + 다중 대화로 확장(2026-06-20)**: 사용자 요청 "어디서든 본인 기록 조회 → DB 필요". 무상태 `/ai/chat` → **대화 스코프 API**(`AiConversation`·`AiMessage` 엔티티, user CASCADE). 엔드포인트 `GET /ai/conversations`(목록)·`GET/:id`(상세)·`POST /ai/conversations`(새 대화)·`POST /:id/messages`(추가)·`DELETE /:id` — 전부 **본인 소유 스코프**(타인 대화 404=IDOR 차단). **클라는 새 메시지 1건만 보내고 서버가 DB 이력으로 Claude 호출**(보안·페이로드 개선). **Claude 실패 시 사용자 메시지·빈 대화 롤백**. 프론트 `AgentChatPanel` 에 **채팅↔지난 대화 목록 뷰**(열기/삭제), `ai.store` 가 목록·현재 대화 관리. **이제 다른 기기·재로그인·새로고침에도 기록 유지.** SendMessageDto(content 8000자). 검증 백엔드 build/lint 0·20 tests, 프론트 0. 런타임 미검증(키·DB 필요). - 검증: 백엔드 build/lint 0·20 tests, 프론트 type-check/lint/build 0. **런타임 미검증**(실제 API 키 호출 필요). 모델 ID는 콘솔 가용 모델로 `CLAUDE_MODEL` 교체 가능. #### 8-7. 그 외(미착수) diff --git a/frontend/src/components/AgentChatPanel.vue b/frontend/src/components/AgentChatPanel.vue index 0822e22..955f6ab 100644 --- a/frontend/src/components/AgentChatPanel.vue +++ b/frontend/src/components/AgentChatPanel.vue @@ -1,17 +1,15 @@