import { Injectable, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import Anthropic from '@anthropic-ai/sdk'; import { ImmigrationToolsService } from './tools/immigration-tools.service'; import { buildSystemPrompt, SystemPromptConfig } from './prompts/system-prompt'; import { KnowledgeClientService } from '../knowledge/knowledge-client.service'; export interface FileAttachment { id: string; originalName: string; mimeType: string; type: 'image' | 'document' | 'audio' | 'video' | 'other'; size: number; downloadUrl?: string; thumbnailUrl?: string; } export interface ConversationContext { userId: string; conversationId: string; userMemory?: string[]; previousMessages?: Array<{ role: 'user' | 'assistant'; content: string; attachments?: FileAttachment[]; }>; } export interface StreamChunk { type: 'text' | 'tool_use' | 'tool_result' | 'end'; content?: string; toolName?: string; toolInput?: Record; toolResult?: unknown; } @Injectable() export class ClaudeAgentService implements OnModuleInit { private client: Anthropic; private systemPromptConfig: SystemPromptConfig; constructor( private configService: ConfigService, private immigrationToolsService: ImmigrationToolsService, private knowledgeClient: KnowledgeClientService, ) {} onModuleInit() { const baseUrl = this.configService.get('ANTHROPIC_BASE_URL'); const isProxyUrl = baseUrl && (baseUrl.includes('67.223.119.33') || baseUrl.match(/^\d+\.\d+\.\d+\.\d+/)); // If using IP-based proxy, disable TLS certificate verification if (isProxyUrl) { console.log(`Using Anthropic proxy (TLS verification disabled): ${baseUrl}`); process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; } this.client = new Anthropic({ apiKey: this.configService.get('ANTHROPIC_API_KEY'), baseURL: baseUrl || undefined, }); if (baseUrl && !isProxyUrl) { console.log(`Using Anthropic API base URL: ${baseUrl}`); } // Initialize with default config this.systemPromptConfig = { identity: '专业、友善、耐心的香港移民顾问', conversationStyle: '专业但不生硬,用简洁明了的语言解答', }; } /** * Update system prompt configuration (for evolution) */ updateSystemPromptConfig(config: Partial) { this.systemPromptConfig = { ...this.systemPromptConfig, ...config, }; } /** * Fetch and format approved system experiences for injection */ private async getAccumulatedExperience(query: string): Promise { try { const experiences = await this.knowledgeClient.searchExperiences({ query, activeOnly: true, limit: 5, }); if (experiences.length === 0) { return '暂无'; } return experiences .map((exp, index) => `${index + 1}. [${exp.experienceType}] ${exp.content}`) .join('\n'); } catch (error) { console.error('[ClaudeAgent] Failed to fetch experiences:', error); return '暂无'; } } /** * Build multimodal content blocks for Claude Vision API */ private async buildMultimodalContent( text: string, attachments?: FileAttachment[], ): Promise { const content: Anthropic.ContentBlockParam[] = []; // Add image attachments first (Claude processes images before text) if (attachments && attachments.length > 0) { for (const attachment of attachments) { if (attachment.type === 'image' && attachment.downloadUrl) { try { // Fetch the image and convert to base64 const response = await fetch(attachment.downloadUrl); if (response.ok) { const buffer = await response.arrayBuffer(); const base64Data = Buffer.from(buffer).toString('base64'); // Determine media type const mediaType = attachment.mimeType as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'; content.push({ type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data, }, }); } } catch (error) { console.error(`Failed to fetch image ${attachment.originalName}:`, error); } } else if (attachment.type === 'document') { // For documents, add a text reference content.push({ type: 'text', text: `[Attached document: ${attachment.originalName}]`, }); } } } // Add the text message if (text) { content.push({ type: 'text', text, }); } return content; } /** * Send a message and get streaming response with tool loop support * Uses Prompt Caching to reduce costs (~90% savings on cached system prompt) * Supports multimodal messages with image attachments */ async *sendMessage( message: string, context: ConversationContext, attachments?: FileAttachment[], ): AsyncGenerator { const tools = this.immigrationToolsService.getTools(); // Fetch relevant system experiences and inject into prompt const accumulatedExperience = await this.getAccumulatedExperience(message); const dynamicConfig: SystemPromptConfig = { ...this.systemPromptConfig, accumulatedExperience, }; const systemPrompt = buildSystemPrompt(dynamicConfig); // Build messages array const messages: Anthropic.MessageParam[] = []; // Add previous messages if any (with multimodal support) if (context.previousMessages) { for (const msg of context.previousMessages) { if (msg.attachments && msg.attachments.length > 0 && msg.role === 'user') { // Build multimodal content for messages with attachments const multimodalContent = await this.buildMultimodalContent(msg.content, msg.attachments); messages.push({ role: msg.role, content: multimodalContent, }); } else { messages.push({ role: msg.role, content: msg.content, }); } } } // Add current message (with multimodal support) if (attachments && attachments.length > 0) { const multimodalContent = await this.buildMultimodalContent(message, attachments); messages.push({ role: 'user', content: multimodalContent, }); } else { messages.push({ role: 'user', content: message, }); } // Tool loop - continue until we get a final response (no tool use) const maxIterations = 10; // Safety limit let iterations = 0; // System prompt with cache_control for Prompt Caching // Cache TTL is 5 minutes, cache hits cost only 10% of normal input price const systemWithCache: Anthropic.TextBlockParam[] = [ { type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' }, }, ]; while (iterations < maxIterations) { iterations++; try { // Create streaming message with cached system prompt const stream = await this.client.messages.stream({ model: 'claude-sonnet-4-20250514', max_tokens: 4096, system: systemWithCache, messages, tools: tools as Anthropic.Tool[], }); let currentToolUse: { id: string; name: string; inputJson: string; input: Record; } | null = null; // Collect all tool uses and text blocks in this response const toolUses: Array<{ id: string; name: string; input: Record }> = []; const assistantContent: Anthropic.ContentBlockParam[] = []; let hasText = false; for await (const event of stream) { if (event.type === 'content_block_start') { if (event.content_block.type === 'tool_use') { currentToolUse = { id: event.content_block.id, name: event.content_block.name, inputJson: '', input: {}, }; } } else if (event.type === 'content_block_delta') { if (event.delta.type === 'text_delta') { hasText = true; yield { type: 'text', content: event.delta.text, }; } else if (event.delta.type === 'input_json_delta' && currentToolUse) { currentToolUse.inputJson += event.delta.partial_json || ''; } } else if (event.type === 'content_block_stop') { if (currentToolUse) { // Parse the complete accumulated JSON try { currentToolUse.input = JSON.parse(currentToolUse.inputJson || '{}'); } catch (e) { console.error('Failed to parse tool input JSON:', currentToolUse.inputJson, e); currentToolUse.input = {}; } toolUses.push({ id: currentToolUse.id, name: currentToolUse.name, input: currentToolUse.input, }); yield { type: 'tool_use', toolName: currentToolUse.name, toolInput: currentToolUse.input, }; currentToolUse = null; } } } // If no tool uses, we're done if (toolUses.length === 0) { yield { type: 'end' }; return; } // Build assistant message content with tool uses // First get the final message to extract text content const finalMessage = await stream.finalMessage(); for (const block of finalMessage.content) { if (block.type === 'text') { assistantContent.push({ type: 'text', text: block.text }); } else if (block.type === 'tool_use') { assistantContent.push({ type: 'tool_use', id: block.id, name: block.name, input: block.input as Record, }); } } // Add assistant message with tool uses messages.push({ role: 'assistant', content: assistantContent, }); // Execute all tools and collect results const toolResults: Anthropic.ToolResultBlockParam[] = []; for (const toolUse of toolUses) { const result = await this.immigrationToolsService.executeTool( toolUse.name, toolUse.input, context, ); yield { type: 'tool_result', toolName: toolUse.name, toolResult: result, }; toolResults.push({ type: 'tool_result', tool_use_id: toolUse.id, content: JSON.stringify(result), }); } // Add user message with tool results messages.push({ role: 'user', content: toolResults, }); // Continue the loop to get Claude's response after tool execution } catch (error) { console.error('Claude API error:', error); throw error; } } console.error('Tool loop exceeded maximum iterations'); yield { type: 'end' }; } /** * Non-streaming message for simple queries * Uses Prompt Caching for cost optimization */ async sendMessageSync( message: string, context: ConversationContext, ): Promise { const tools = this.immigrationToolsService.getTools(); // Fetch relevant system experiences and inject into prompt const accumulatedExperience = await this.getAccumulatedExperience(message); const dynamicConfig: SystemPromptConfig = { ...this.systemPromptConfig, accumulatedExperience, }; const systemPrompt = buildSystemPrompt(dynamicConfig); const messages: Anthropic.MessageParam[] = []; if (context.previousMessages) { for (const msg of context.previousMessages) { messages.push({ role: msg.role, content: msg.content, }); } } messages.push({ role: 'user', content: message, }); // System prompt with cache_control for Prompt Caching const systemWithCache: Anthropic.TextBlockParam[] = [ { type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' }, }, ]; const response = await this.client.messages.create({ model: 'claude-sonnet-4-20250514', max_tokens: 4096, system: systemWithCache, messages, tools: tools as Anthropic.Tool[], }); // Extract text response let result = ''; for (const block of response.content) { if (block.type === 'text') { result += block.text; } } return result; } /** * Analyze content (for evolution service) */ async analyze(prompt: string): Promise { const response = await this.client.messages.create({ model: 'claude-sonnet-4-20250514', max_tokens: 8192, messages: [ { role: 'user', content: prompt, }, ], }); let result = ''; for (const block of response.content) { if (block.type === 'text') { result += block.text; } } return result; } }