| 1 | import type { ContentBlockParam } from '@anthropic-ai/sdk/resources' |
| 2 | import type { ContentBlock } from './agent.dto' |
| 3 | import { SDKMessage } from '@anthropic-ai/claude-agent-sdk' |
| 4 | |
| 5 | /** |
| 6 | * 标准化 prompt 为 content 数组 |
| 7 | * 将字符串转换为文本内容块数组,或直接返回已有的内容块数组 |
| 8 | */ |
| 9 | export function normalizePrompt(prompt: string | ContentBlock[]): ContentBlock[] { |
| 10 | if (typeof prompt === 'string') { |
| 11 | return [{ type: 'text', text: prompt }] |
| 12 | } |
| 13 | return prompt |
| 14 | } |
| 15 | |
| 16 | /** |
| 17 | * 增强 prompt,在文本中添加图片和视频URL的结构化说明 |
| 18 | * 用于发送给模型,不应保存到数据库 |
| 19 | * 注意:video 块会被过滤掉(转换为文本说明),因为 Anthropic SDK 原生不支持 video 类型 |
| 20 | */ |
| 21 | export function enhancePrompt(blocks: ContentBlock[]): ContentBlockParam[] { |
| 22 | const imageUrls: string[] = [] |
| 23 | const videoUrls: string[] = [] |
| 24 | |
| 25 | for (const block of blocks) { |
| 26 | if (block.type === 'image' && 'source' in block && block.source && typeof block.source === 'object' && 'url' in block.source) { |
| 27 | imageUrls.push(block.source.url as string) |
| 28 | } |
| 29 | if (block.type === 'video' && 'source' in block && block.source && typeof block.source === 'object' && 'url' in block.source) { |
| 30 | videoUrls.push(block.source.url as string) |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | // 过滤掉 video 块(原生不支持) |
| 35 | const filteredBlocks = blocks.filter(block => block.type !== 'video') |
| 36 | |
| 37 | if (imageUrls.length === 0 && videoUrls.length === 0) { |
| 38 | return filteredBlocks |
| 39 | } |
| 40 | |
| 41 | let mediaListText = '' |
| 42 | |
| 43 | if (imageUrls.length > 0) { |
| 44 | mediaListText += `\n\nReference Images:\n${imageUrls.map((url, index) => `- Image ${index + 1}: ${url}`).join('\n')}` |
| 45 | } |
| 46 | |
| 47 | if (videoUrls.length > 0) { |
| 48 | mediaListText += `\n\nReference Videos:\n${videoUrls.map((url, index) => `- Video ${index + 1}: ${url}`).join('\n')}` |
| 49 | } |
| 50 | |
| 51 | let firstTextBlockIndex = -1 |
| 52 | for (let i = 0; i < filteredBlocks.length; i++) { |
| 53 | if (filteredBlocks[i].type === 'text') { |
| 54 | firstTextBlockIndex = i |
| 55 | break |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | const result = [...filteredBlocks] |
| 60 | |
| 61 | if (firstTextBlockIndex !== -1) { |
| 62 | const textBlock = result[firstTextBlockIndex] as { type: 'text', text: string } |
| 63 | result[firstTextBlockIndex] = { |
| 64 | type: 'text', |
| 65 | text: `${textBlock.text}${mediaListText}`, |
| 66 | } |
| 67 | } |
| 68 | else { |
| 69 | result.unshift({ |
| 70 | type: 'text', |
| 71 | text: `Please use the following media:${mediaListText}`, |
| 72 | }) |
| 73 | } |
| 74 | |
| 75 | return result |
| 76 | } |
| 77 | |
| 78 | /** |
| 79 | * 过滤请求 headers,移除基础 HTTP headers,保留业务相关的 headers |
| 80 | * @param headers 原始请求 headers |
| 81 | * @returns 过滤后的 headers |
| 82 | */ |
| 83 | export function filterHeaders(headers: Record<string, unknown>): Record<string, string> { |
| 84 | const basicHeaders = new Set([ |
| 85 | 'host', |
| 86 | 'connection', |
| 87 | 'content-length', |
| 88 | 'content-type', |
| 89 | 'accept', |
| 90 | 'accept-encoding', |
| 91 | 'user-agent', |
| 92 | 'cache-control', |
| 93 | 'pragma', |
| 94 | 'upgrade-insecure-requests', |
| 95 | 'if-modified-since', |
| 96 | 'if-none-match', |
| 97 | ]) |
| 98 | |
| 99 | const filtered: Record<string, string> = {} |
| 100 | |
| 101 | for (const [key, value] of Object.entries(headers)) { |
| 102 | const lowerKey = key.toLowerCase() |
| 103 | if (!basicHeaders.has(lowerKey)) { |
| 104 | if (typeof value === 'string') { |
| 105 | filtered[key] = value |
| 106 | } |
| 107 | else if (Array.isArray(value)) { |
| 108 | filtered[key] = value.join(', ') |
| 109 | } |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | return filtered |
| 114 | } |
| 115 | |
| 116 | /** |
| 117 | * 判断消息是否应该被过滤 |
| 118 | * 过滤掉 type 为 user 且 isSynthetic 为 true 的消息 |
| 119 | * 这些消息不应该保存到数据库,也不应该返回到前端 |
| 120 | */ |
| 121 | export function shouldFilterSyntheticMessage(message: SDKMessage): boolean { |
| 122 | return ( |
| 123 | message.type === 'user' |
| 124 | && 'isSynthetic' in message |
| 125 | && message.isSynthetic === true |
| 126 | ) || message.type === 'system' |
| 127 | } |
| 128 | |
| 129 | export function sanitizeMessage(msg: SDKMessage): Omit<SDKMessage, 'session_id'> { |
| 130 | const { session_id, ...rest } = msg |
| 131 | if (rest.type === 'assistant') { |
| 132 | rest.message.content.forEach((block) => { |
| 133 | if (block.type === 'text' && block.text === '(no content)') { |
| 134 | block.text = '' |
| 135 | } |
| 136 | }) |
| 137 | } |
| 138 | if (rest.type === 'user' && typeof rest.message.content !== 'string') { |
| 139 | rest.message.content.forEach((block) => { |
| 140 | if (block.type === 'tool_result' && block.content && typeof block.content !== 'string') { |
| 141 | block.content.forEach((subBlock) => { |
| 142 | if (subBlock.type === 'image') { |
| 143 | if (subBlock.source.type === 'base64') { |
| 144 | subBlock.source.data = '' |
| 145 | } |
| 146 | } |
| 147 | }) |
| 148 | } |
| 149 | }) |
| 150 | if (rest.tool_use_result && typeof rest.tool_use_result !== 'string' && Array.isArray(rest.tool_use_result)) { |
| 151 | rest.tool_use_result.forEach((block) => { |
| 152 | if (block.type === 'image') { |
| 153 | if (block.source.type === 'base64') { |
| 154 | block.source.data = '' |
| 155 | } |
| 156 | } |
| 157 | }) |
| 158 | } |
| 159 | } |
| 160 | return rest |
| 161 | } |
| 162 |