iconsulting/packages/services/conversation-service/src/infrastructure/claude/claude-agent.service.ts

461 lines
13 KiB
TypeScript

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<string, unknown>;
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<string>('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<string>('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<SystemPromptConfig>) {
this.systemPromptConfig = {
...this.systemPromptConfig,
...config,
};
}
/**
* Fetch and format approved system experiences for injection
*/
private async getAccumulatedExperience(query: string): Promise<string> {
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<Anthropic.ContentBlockParam[]> {
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<StreamChunk> {
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<string, unknown>;
} | null = null;
// Collect all tool uses and text blocks in this response
const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = [];
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<string, unknown>,
});
}
}
// 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<string> {
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<string> {
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;
}
}