diff --git a/backend/.env.development.example b/backend/.env.development.example index e86ebbc..afd13b4 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -49,5 +49,7 @@ GITEA_TEMPLATE_REPO=project-base REPO_IMPORT_ADMIN_EMAIL=admin@example.com # --- AI 에이전트 (Claude) --- -# Claude(Anthropic) API 키 — AI 에이전트 패널 연동용(추후). 미설정 시 AI 기능 비활성. +# Claude(Anthropic) API 키 — AI 채팅 패널용. 미설정 시 AI 기능 비활성. CLAUDE_API_KEY=change-me-claude-api-key +# 사용할 모델 ID — 미설정 시 claude-sonnet-4-6. 콘솔에서 사용 가능한 모델로 교체 가능. +CLAUDE_MODEL=claude-sonnet-4-6 diff --git a/backend/.env.production.example b/backend/.env.production.example index d0a3bba..bdce873 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -49,5 +49,7 @@ GITEA_TEMPLATE_REPO=project-base REPO_IMPORT_ADMIN_EMAIL=admin@example.com # --- AI 에이전트 (Claude) --- -# Claude(Anthropic) API 키 — AI 에이전트 패널 연동용(추후). 미설정 시 AI 기능 비활성. +# Claude(Anthropic) API 키 — AI 채팅 패널용. 미설정 시 AI 기능 비활성. CLAUDE_API_KEY=change-me-claude-api-key +# 사용할 모델 ID — 미설정 시 claude-sonnet-4-6. 콘솔에서 사용 가능한 모델로 교체 가능. +CLAUDE_MODEL=claude-sonnet-4-6 diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2b0099f..8a6590c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -17,6 +17,7 @@ import { RepoModule } from './modules/repo/repo.module'; import { TaskModule } from './modules/task/task.module'; import { ActivityModule } from './modules/activity/activity.module'; import { NotificationModule } from './modules/notification/notification.module'; +import { AiModule } from './modules/ai/ai.module'; @Module({ imports: [ @@ -95,6 +96,7 @@ import { NotificationModule } from './modules/notification/notification.module'; TaskModule, ActivityModule, NotificationModule, + AiModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/modules/ai/ai.controller.ts b/backend/src/modules/ai/ai.controller.ts new file mode 100644 index 0000000..ca48010 --- /dev/null +++ b/backend/src/modules/ai/ai.controller.ts @@ -0,0 +1,32 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + 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'; + +// AI 채팅 컨트롤러 — 인증 필요. 외부 API 호출 비용이 있어 라우트 단위 throttle 적용. +@ApiTags('AI') +@Controller('ai') +@UseGuards(JwtAuthGuard) +export class AiController { + constructor(private readonly aiService: AiService) {} + + @Post('chat') + @HttpCode(HttpStatus.OK) + @Throttle({ default: { limit: 20, ttl: 60_000 } }) + @ApiOperation({ summary: 'AI 채팅 (Claude)' }) + @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 }; + } +} diff --git a/backend/src/modules/ai/ai.module.ts b/backend/src/modules/ai/ai.module.ts new file mode 100644 index 0000000..7992a57 --- /dev/null +++ b/backend/src/modules/ai/ai.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AiController } from './ai.controller'; +import { AiService } from './ai.service'; + +// AI 모듈 — Claude(Anthropic) 채팅 프록시 +@Module({ + controllers: [AiController], + providers: [AiService], +}) +export class AiModule {} diff --git a/backend/src/modules/ai/ai.service.ts b/backend/src/modules/ai/ai.service.ts new file mode 100644 index 0000000..9cdd0f5 --- /dev/null +++ b/backend/src/modules/ai/ai.service.ts @@ -0,0 +1,100 @@ +import { HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { BusinessException } from '../../common/exceptions/business.exception'; +import type { ChatMessageDto } from './dto/chat.dto'; + +// Anthropic Messages API 응답(필요 필드만) +interface AnthropicResponse { + content?: { type: string; text?: string }[]; +} + +const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages'; +const REQUEST_TIMEOUT_MS = 30_000; +const MAX_TOKENS = 1024; +const SYSTEM_PROMPT = + '당신은 업무 협업 도구 "Relay" 의 AI 어시스턴트입니다. ' + + '사용자의 질문에 한국어로 간결하고 정확하게 답하세요. ' + + '모르는 내용은 추측하지 말고 모른다고 답하세요.'; + +// AI(Claude) 채팅 서비스 — Anthropic Messages API 프록시. +// 키(CLAUDE_API_KEY)는 서버 env 에만 두고 클라이언트에 노출하지 않는다. +@Injectable() +export class AiService { + private readonly logger = new Logger(AiService.name); + private readonly apiKey: string; + private readonly model: string; + + constructor(config: ConfigService) { + this.apiKey = (config.get('CLAUDE_API_KEY') ?? '').trim(); + this.model = ( + config.get('CLAUDE_MODEL') ?? 'claude-sonnet-4-6' + ).trim(); + if (!this.apiKey) { + this.logger.warn('CLAUDE_API_KEY 미설정 — AI 채팅 비활성화'); + } + } + + get enabled(): boolean { + return this.apiKey.length > 0; + } + + // 대화 이력을 받아 Claude 응답 텍스트를 반환 + async chat(messages: ChatMessageDto[]): Promise { + if (!this.enabled) { + throw new BusinessException( + 'BIZ_001', + 'AI 기능이 설정되지 않았습니다. 관리자에게 문의해 주세요.', + HttpStatus.SERVICE_UNAVAILABLE, + ); + } + + let response: Response; + try { + response = await fetch(ANTHROPIC_URL, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-api-key': this.apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: this.model, + max_tokens: MAX_TOKENS, + system: SYSTEM_PROMPT, + messages: messages.map((m) => ({ role: m.role, content: m.content })), + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (e) { + // 네트워크 오류/타임아웃 — 키나 내부 정보는 노출하지 않는다 + this.logger.error( + `Anthropic 요청 실패: ${e instanceof Error ? e.message : String(e)}`, + ); + throw new BusinessException( + 'BIZ_001', + 'AI 응답을 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.', + HttpStatus.BAD_GATEWAY, + ); + } + + if (!response.ok) { + const detail = await response.text().catch(() => ''); + this.logger.error( + `Anthropic API 오류 ${response.status}: ${detail.slice(0, 500)}`, + ); + throw new BusinessException( + 'BIZ_001', + 'AI 응답을 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.', + HttpStatus.BAD_GATEWAY, + ); + } + + const data = (await response.json()) as AnthropicResponse; + const text = (data.content ?? []) + .filter((b) => b.type === 'text' && typeof b.text === 'string') + .map((b) => b.text) + .join('') + .trim(); + return text || '(빈 응답을 받았습니다.)'; + } +} diff --git a/backend/src/modules/ai/dto/chat.dto.ts b/backend/src/modules/ai/dto/chat.dto.ts new file mode 100644 index 0000000..c5c7210 --- /dev/null +++ b/backend/src/modules/ai/dto/chat.dto.ts @@ -0,0 +1,36 @@ +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/docs/api-contract.md b/docs/api-contract.md index 6e0b85b..73b7b63 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -336,9 +336,18 @@ todo ─시작→ prog ─승인요청→ review ─승인→ done Google/Kakao OAuth(passport, 키 미설정 시 자동 비활성) + 이메일 가입 인증(미인증 로그인 차단, 그랜드페더링). 메일 발송은 `MailService` 플레이스홀더(백엔드 로그). 상세는 §1 참조. +### AI 채팅 (`/api/ai`) — **✅ 완료** + +| 메서드 | 경로 | 요청 | 응답 | 비고 | +|---|---|---|---|---| +| POST | `/ai/chat` | `{ messages: {role:'user'\|'assistant', content}[] }` | `{ reply }` | Claude(Anthropic) 프록시. 인증 필요, IP당 20회/분 throttle | + +- `AiService` 가 Anthropic Messages API(`x-api-key`) 호출 — **키(`CLAUDE_API_KEY`)는 서버 env 에만**, 클라이언트 비노출. 모델 `CLAUDE_MODEL`(기본 `claude-sonnet-4-6`). 미설정 시 503. 외부 오류는 502 + 일반 문구(내부/키 비노출). +- 프론트: `components/AgentChatPanel.vue`(우측 슬라이드 패널). 버튼은 **AppShell 헤더에 상시 노출(모든 화면에서 열림)**. 대화 이력 전체를 매 요청 전송(서버 무상태). 기존 목업 터미널 패널은 제거. + ### 그 외(미착수) -AI 에이전트 패널. 메일 실제 발송(SMTP) — 현재 로그 대체. (전체 페이지네이션 통일·실시간 알림·Redis 캐싱은 완료.) +메일 실제 발송(SMTP) — 현재 로그 대체. 보안 후속(계정잠금/CAPTCHA 등). (페이지네이션·실시간 알림·Redis 캐싱·소셜로그인·이메일인증·refresh 회전·AI 채팅은 완료.) --- diff --git a/docs/integration-progress.md b/docs/integration-progress.md index cdb77c7..deebf16 100644 --- a/docs/integration-progress.md +++ b/docs/integration-progress.md @@ -309,8 +309,17 @@ - **L6** CORS `localhost:3000`·Swagger `/api-docs` 비운영 한정. 검증: 백엔드 build/lint 0·20 tests, 프론트 변경 없음. -#### 8-6. 그 외(미착수) -- AI 에이전트 패널 실연동(+ L1 v-html escape), 메일 실발송(SMTP), 보안 후속(토큰 완전회전·계정잠금·비번 재설정 플로우). +#### 8-6. AI 채팅 패널 ✅ (2026-06-19) + +**지시**: 기존 목업 UI(가짜 "relay order draft" 터미널)는 버리고, **Claude API로 채팅만** 되도록. 키는 백엔드 env(`CLAUDE_API_KEY`)에 등록됨. + +- **백엔드** 신규 `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`(싱글톤)에 보관 → 화면 이동에도 유지, '새 대화' 버튼으로만 초기화**(입력 텍스트만 컴포넌트 로컬). 하드 리프레시는 초기화(인메모리). +- 검증: 백엔드 build/lint 0·20 tests, 프론트 type-check/lint/build 0. **런타임 미검증**(실제 API 키 호출 필요). 모델 ID는 콘솔 가용 모델로 `CLAUDE_MODEL` 교체 가능. + +#### 8-7. 그 외(미착수) +- 메일 실발송(SMTP), 비번 재설정 플로우, 보안 후속(계정잠금/CAPTCHA). --- diff --git a/frontend/src/components/AgentChatPanel.vue b/frontend/src/components/AgentChatPanel.vue new file mode 100644 index 0000000..0822e22 --- /dev/null +++ b/frontend/src/components/AgentChatPanel.vue @@ -0,0 +1,500 @@ + + +