feat: AI 채팅 패널 (Claude API)

- 백엔드 modules/ai: POST /ai/chat — Anthropic Messages API 프록시(키 서버 env 한정, 30s 타임아웃, throttle 20/분, 미설정 503/외부오류 502)
- ChatDto 검증(role enum, content 8000자, 메시지 50개 상한), 모델 CLAUDE_MODEL(기본 claude-sonnet-4-6)
- 프론트 AgentChatPanel: 우측 슬라이드 채팅(말풍선/타이핑/에러/새 대화), plain text 렌더로 XSS 안전
- AppShell 헤더에 AI 버튼 상시 노출 — 모든 화면에서 열림
- 대화 이력은 stores/ai.store(싱글톤)에 보관 → 화면 이동에도 유지, '새 대화'로만 초기화
- TaskDetailPage 의 기존 목업 에이전트 패널 전부 제거(보안 L1 해소)
- env.example 에 CLAUDE_MODEL 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 00:04:48 +09:00
parent db6a80a6f4
commit 5da3ec8818
15 changed files with 814 additions and 1199 deletions
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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
+2
View File
@@ -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: [
+32
View File
@@ -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 };
}
}
+10
View File
@@ -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 {}
+100
View File
@@ -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<string>('CLAUDE_API_KEY') ?? '').trim();
this.model = (
config.get<string>('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<string> {
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 || '(빈 응답을 받았습니다.)';
}
}
+36
View File
@@ -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[];
}