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:
@@ -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: [
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 || '(빈 응답을 받았습니다.)';
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user