|
| 1 | +--- |
| 2 | +title: 在聊天中實現圖片上傳與 AI 識別:從設計到落地的完整方案 |
| 3 | +date: 2026-05-07 |
| 4 | +tags: [AI, 圖片上傳, 多模態, 系統設計, HagiCode] |
| 5 | +--- |
| 6 | + |
| 7 | +## 在聊天中實現圖片上傳與 AI 識別:從設計到落地的完整方案 |
| 8 | + |
| 9 | +> 在 AI 交互系統中,如何讓用戶上傳圖片並讓 AI 直接識別?其實這個問題我也糾結了很久,好在 HagiCode 的實踐中摸索出了一些門道。今天就聊聊這套圖片上傳與識別方案,從自定義協議設計到文件系統存儲,再到前後端分離預覽,也算是個完整的技術筆記了。 |
| 10 | +
|
| 11 | +## 背景 |
| 12 | + |
| 13 | +在這個 AI 聊天盛行的時代,視覺信息其實是用戶表達意圖的重要載體。只是呢,傳統聊天系統大多只支持純文本輸入,這就導致用戶沒法把視覺上下文直接傳遞給 AI 分析,多少有點遺憾。 |
| 14 | + |
| 15 | +HagiCode 在開發過程中也遇到了類似的困境:用戶無法在聊天或主意見創建時上傳圖片,AI 無法訪問用戶本地的視覺信息,缺少圖片從輸入、存儲、渲染到 AI 上下文傳遞的完整閉環。 |
| 16 | + |
| 17 | +其實這些問題也沒什麼大不了的,只是需要一點時間和耐心去解決罷了。我們設計並實現了一套完整的圖片上傳與識別流程,讓 Claude 等 AI 能夠直接識別和分析用戶上傳的截圖。接下來我會慢慢細說這個方案的實現細節。 |
| 18 | + |
| 19 | +## 關於 HagiCode |
| 20 | + |
| 21 | +本文分享的方案來自我們在 [HagiCode](https://hagicode.com) 項目中的實踐經驗。HagiCode 是一個開源的 AI 代碼助手項目,採用基於 OpenSpec 的工作流設計,致力於提供更智能的代碼編寫體驗。 |
| 22 | + |
| 23 | +## 分析 |
| 24 | + |
| 25 | +### 技術挑戰 |
| 26 | + |
| 27 | +在開始實現之前,我們還是得先理清楚面臨的主要挑戰,畢竟磨刀不誤砍柴工嘛。 |
| 28 | + |
| 29 | +**跨模塊協作**:圖片上傳涉及前端 UI、上傳服務、後端 API、文件存儲、消息持久化和 AI 執行映射等多個模塊。每個模塊都有自己的職責和接口,需要設計一個協調一致的整體方案。 |
| 30 | + |
| 31 | +**存儲策略選擇**:圖片應該存儲在數據庫還是文件系統?如果選擇文件系統,目錄結構如何設計?如何與現有的 OpenSpec 工作流集成?這些都需要仔細權衡。 |
| 32 | + |
| 33 | +**引用協議設計**:需要一種標準的圖片引用方式,既能被前端渲染顯示,又能被 AI 執行鏈路正確解析。直接使用文件路徑?HTTP URL?還是設計專門的協議? |
| 34 | + |
| 35 | +**AI 能力兼容**:不同 AI 執行器對多模態的支持程度不同。有些執行器原生支持圖片輸入,有些只能處理文本。如何設計統一的適配層,確保所有執行器都能正確處理圖片信息? |
| 36 | + |
| 37 | +### 設計決策 |
| 38 | + |
| 39 | +經過充分的討論和權衡,我們做出了以下關鍵設計決策。 |
| 40 | + |
| 41 | +**決策 1:文件系統存儲** |
| 42 | + |
| 43 | +我們選擇將圖片存儲在文件系統而非數據庫中。目錄結構設計如下: |
| 44 | + |
| 45 | +``` |
| 46 | +<系統根目錄>/images/<sessionId>/ |
| 47 | +├── <timestamp>-<uuid>.jpg |
| 48 | +└── <timestamp>-<uuid>.png |
| 49 | +``` |
| 50 | + |
| 51 | +理由其實也挺明確的:簡化實現,避免數據庫膨脹,文件可以直接被 AI 讀取。而且圖片文件本質上就不適合放在數據庫裡,文件系統才是更自然的選擇。這就像把書放在書架上,而不是塞進筆記本裡,一個道理罷了。 |
| 52 | + |
| 53 | +**決策 2:自定義協議 `hagiimag://`** |
| 54 | + |
| 55 | +為了避免與 HTTP URL 衝突,同時讓引用語義更清晰,我們設計了一個自定義圖片引用協議: |
| 56 | + |
| 57 | +``` |
| 58 | +hagiimag://session-abc123/20260301-143022-a1b2c3d4 |
| 59 | +``` |
| 60 | + |
| 61 | +這個協議的格式是 `hagiimag://<sessionId>/<imageId>`,語義清晰,便於解析和路由。看到這個格式,開發者立刻就能明白這是一個圖片引用,而不是普通的 URL。這種設計上的小心思,有時候還是挺有用的。 |
| 62 | + |
| 63 | +**決策 3:前端預覽與 AI 訪問分離** |
| 64 | + |
| 65 | +在實現過程中,我們發現前端和 AI 對圖片的訪問需求不同:前端需要通過 HTTP API 進行預覽,而 AI 需要直接讀取本地文件路徑。因此我們設計了分離的訪問方式: |
| 66 | + |
| 67 | +- 前端使用 `/api/Images/{sessionId}/{imageId}/content` 進行預覽 |
| 68 | +- AI 使用服務端解析的本地文件路徑 |
| 69 | + |
| 70 | +這樣既保證了安全性(不暴露服務器路徑),又兼顧了可用性(瀏覽器可直接訪問)。畢竟安全性和可用性這兩件事,總是需要平衡的。 |
| 71 | + |
| 72 | +**決策 4:立即上傳策略** |
| 73 | + |
| 74 | +另一個關鍵決策是上傳時機。我們選擇用戶選擇或粘貼圖片時立即觸發上傳,發送消息時只引用已經上傳成功的圖片。 |
| 75 | + |
| 76 | +這樣做的好處是錯誤處理前置,避免消息發送 API 變複雜,保持 JSON 契約的簡潔性。用戶在發送前就能知道圖片是否上傳成功,體驗也更好。這種"未雨綢繆"的設計思路,或許在很多時候都適用。 |
| 77 | + |
| 78 | +## 解決 |
| 79 | + |
| 80 | +### 架構設計 |
| 81 | + |
| 82 | +基於上述決策,我們設計了如下整體架構: |
| 83 | + |
| 84 | +``` |
| 85 | +前端層 |
| 86 | +├── ConversationInputArea ◄─────── useImageAttachmentManager |
| 87 | +│ │ │ |
| 88 | +│ ├── 文件選擇 ├── 附件狀態管理 |
| 89 | +│ ├── 剪貼板粘貼 ├── 上傳/重試/刪除 |
| 90 | +│ └── 附件預覽 └── 圖片引用生成 |
| 91 | +│ |
| 92 | +服務層 |
| 93 | +├── ImageUploadService |
| 94 | +│ ├── uploadImage() ◄─────── ImagesController |
| 95 | +│ ├── deleteImage() │ |
| 96 | +│ ├── parseHagiImageUrl() ◄─────── 解析協議鏈接 |
| 97 | +│ └── buildPreviewUrl() │ |
| 98 | +│ |
| 99 | +後端層 |
| 100 | +├── ImagesController ◄─────── ImagesDomainService |
| 101 | +│ │ │ |
| 102 | +│ ├── POST /upload ├── 文件驗證 |
| 103 | +│ ├── GET /{sessionId}/{imageId} ├── 圖片保存 |
| 104 | +│ ├── DELETE ├── 圖片壓縮 |
| 105 | +│ └── GET /content └── 引用解析 |
| 106 | +│ |
| 107 | +AI 執行層 |
| 108 | +├── ImageContentBlock ◄─────── StructuredMessageDomainService |
| 109 | +│ │ │ |
| 110 | +│ ├── 多模態執行器 ├── 圖片塊解析 |
| 111 | +│ └── 文本執行器降級 └── 路徑提示生成 |
| 112 | +``` |
| 113 | + |
| 114 | +這個架構清晰地展示了從前端到 AI 的完整數據流。每一層都有明確的職責,通過標準接口進行交互。其實好的架構就是這樣,各司其職,互不干擾,溝通順暢。 |
| 115 | + |
| 116 | +### 關鍵流程 |
| 117 | + |
| 118 | +**圖片上傳流程**: |
| 119 | + |
| 120 | +1. 用戶通過文件選擇或剪貼板粘貼選擇圖片 |
| 121 | +2. 前端驗證文件類型和大小(支持 JPEG/PNG/WEBP/GIF,單文件 10MB) |
| 122 | +3. 調用上傳 API,圖片保存到 `/images/{sessionId}/` 目錄 |
| 123 | +4. API 返回 `hagiimag://` 引用和預覽 URL |
| 124 | +5. 前端在附件條中顯示預覽縮略圖,用戶可以在發送前預覽 |
| 125 | + |
| 126 | +**AI 識別流程**: |
| 127 | + |
| 128 | +1. 用戶發送包含圖片引用的消息 |
| 129 | +2. 後端解析 `hagiimag://` 協議鏈接,提取 sessionId 和 imageId |
| 130 | +3. 將圖片引用映射為 `ImageContentBlock` |
| 131 | +4. 根據執行器能力選擇處理方式: |
| 132 | + - 多模態執行器:傳遞結構化圖片輸入 |
| 133 | + - 文本執行器:降級為圖片路徑提示 |
| 134 | + |
| 135 | +這樣就完成了一個完整的閉環:用戶上傳圖片 → AI 識別圖片 → AI 返回分析結果。這種流程上的順暢,往往能給用戶帶來更好的體驗。 |
| 136 | + |
| 137 | +## 實踐 |
| 138 | + |
| 139 | +### 前端實現 |
| 140 | + |
| 141 | +在前端,我們提供了一個專門的 Hook 來管理圖片附件狀態: |
| 142 | + |
| 143 | +```typescript |
| 144 | +import { useImageAttachmentManager } from '@/hooks/useImageAttachmentManager'; |
| 145 | + |
| 146 | +function ChatInput() { |
| 147 | + const { |
| 148 | + attachments, |
| 149 | + uploadedImages, |
| 150 | + hasBlockingAttachments, |
| 151 | + isUploading, |
| 152 | + selectFiles, |
| 153 | + removeAttachment, |
| 154 | + clearAttachments, |
| 155 | + } = useImageAttachmentManager({ |
| 156 | + ownerId: sessionId, |
| 157 | + mapUploadedImage: (response) => response, |
| 158 | + uploadOptions: { compress: false }, |
| 159 | + }); |
| 160 | + |
| 161 | + const handleFileSelect = (files: File[]) => { |
| 162 | + selectFiles(files); |
| 163 | + }; |
| 164 | + |
| 165 | + const handlePaste = (e: ClipboardEvent) => { |
| 166 | + const files = Array.from(e.clipboardData?.files || []) |
| 167 | + .filter(f => f.type.startsWith('image/')); |
| 168 | + if (files.length > 0) { |
| 169 | + handleFileSelect(files); |
| 170 | + } |
| 171 | + }; |
| 172 | + |
| 173 | + return ( |
| 174 | + <div> |
| 175 | + {/* 附件條 */} |
| 176 | + {attachments.map(att => ( |
| 177 | + <AttachmentItem |
| 178 | + key={att.localId} |
| 179 | + file={att.file} |
| 180 | + status={att.status} |
| 181 | + onRemove={() => removeAttachment(att.localId)} |
| 182 | + /> |
| 183 | + ))} |
| 184 | + |
| 185 | + {/* 輸入框 */} |
| 186 | + <textarea onPaste={handlePaste} /> |
| 187 | + |
| 188 | + {/* 上傳按鈕 */} |
| 189 | + <button onClick={() => fileInputRef.current?.click()}> |
| 190 | + 上傳圖片 |
| 191 | + </button> |
| 192 | + </div> |
| 193 | + ); |
| 194 | +} |
| 195 | +``` |
| 196 | + |
| 197 | +這個 Hook 封裝了所有附件管理的邏輯,包括上傳狀態跟蹤、失敗重試、附件刪除等。使用起來非常簡單,只需要調用幾個方法就能完成整個流程。其實好的 API 設計就是這樣,簡單易用,又不失靈活性。 |
| 198 | + |
| 199 | +**解析自定義協議**: |
| 200 | + |
| 201 | +```typescript |
| 202 | +// 從自定義協議中提取 sessionId 和 imageId |
| 203 | +const parsed = parseHagiImageUrl("hagiimag://session-abc123/20260301-143022-uuid"); |
| 204 | +// 返回: { sessionId: "session-abc123", imageId: "20260301-143022-uuid" } |
| 205 | + |
| 206 | +// 構建預覽 URL |
| 207 | +const previewUrl = buildPreviewUrl(parsed.sessionId, parsed.imageId); |
| 208 | +// 返回: "/api/Images/session-abc123/20260301-143022-uuid/content" |
| 209 | +``` |
| 210 | + |
| 211 | +通過這兩個工具函數,前端可以輕鬆地在 `hagiimag://` 協議和 HTTP URL 之間進行轉換。這種轉換邏輯封裝好了,使用起來就方便多了。 |
| 212 | + |
| 213 | +### 後端實現 |
| 214 | + |
| 215 | +後端使用 ASP.NET Core 實現,核心是 `ImagesController` 和 `ImagesDomainService`: |
| 216 | + |
| 217 | +```csharp |
| 218 | +[HttpPost("upload")] |
| 219 | +[RequestSizeLimit(50 * 1024 * 1024)] |
| 220 | +public async Task<ActionResult<ImageUploadResponseDto>> Upload( |
| 221 | + [FromForm] UploadImageFormRequest input) |
| 222 | +{ |
| 223 | + // 1. 驗證請求 |
| 224 | + if (file == null || file.Length == 0) |
| 225 | + throw new UserFriendlyException("No file provided"); |
| 226 | + |
| 227 | + // 2. 驗證文件類型和大小 |
| 228 | + var (isValid, errorMessage) = _imagesDomainService.ValidateImage( |
| 229 | + file.FileName, file.ContentType, file.Length); |
| 230 | + if (!isValid) |
| 231 | + throw new UserFriendlyException(errorMessage); |
| 232 | + |
| 233 | + // 3. 保存到文件系統 |
| 234 | + await using var stream = file.OpenReadStream(); |
| 235 | + var result = await _imagesDomainService.UploadImageAsync( |
| 236 | + stream, |
| 237 | + sessionId, |
| 238 | + file.FileName, |
| 239 | + file.ContentType, |
| 240 | + CurrentUserId, |
| 241 | + compress: input.Compress); |
| 242 | + |
| 243 | + // 4. 返回結果 |
| 244 | + return Ok(result); |
| 245 | +} |
| 246 | +``` |
| 247 | + |
| 248 | +這個實現遵循了典型的 Web API 開發模式:驗證、處理、返回。需要注意的是,我們設置了 50MB 的請求大小限制,防止惡意的大文件上傳。畢竟在網絡世界裡,小心一點總是沒錯的。 |
| 249 | + |
| 250 | +### 注意事項 |
| 251 | + |
| 252 | +在實現過程中,有一些細節需要特別注意: |
| 253 | + |
| 254 | +**權限校驗**:圖片訪問必須驗證用戶身份,確保只能訪問自己會話的圖片。這是一個基本的安全要求,不能省略。安全這東西,不怕一萬就怕萬一。 |
| 255 | + |
| 256 | +**路徑安全**:嚴格驗證 `sessionId` 和 `imageId`,防止路徑遍歷攻擊。比如要拒絕包含 `../` 的路徑,防止用戶訪問系統中的任意文件。這種邊界條件處理好了,系統才能更穩健。 |
| 257 | + |
| 258 | +**文件清理**:會話刪除時需同步清理關聯圖片,避免孤兒文件堆積。長期運行後,這些文件可能會佔用大量磁盤空間。及時清理,也是一種好的習慣。 |
| 259 | + |
| 260 | +**壓縮策略**:對於截圖類文件名(如 `screenshot.png`),自動啟用壓縮以節省空間。這個策略可以根據實際需求調整。存儲空間嘛,能省一點是一點。 |
| 261 | + |
| 262 | +**降級處理**:不支持多模態的執行器必須收到圖片路徑提示,不能靜默丟棄圖片信息。這一點很重要,否則用戶會以為 AI 忽略了他的圖片。用戶體驗這種事,細節決定成敗。 |
| 263 | + |
| 264 | +**狀態管理**:上傳中的附件會阻止消息發送,失敗附件允許重試或刪除。這個設計保證了用戶體驗的連貫性。狀態管理清晰了,用戶就不會感到困惑。 |
| 265 | + |
| 266 | +## 總結 |
| 267 | + |
| 268 | +通過這套完整的圖片上傳與識別方案,HagiCode 實現了從用戶輸入到 AI 識別的完整閉環。整個方案的核心亮點包括: |
| 269 | + |
| 270 | +- 自定義 `hagiimag://` 協議實現了圖片引用的標準化 |
| 271 | +- 文件系統存儲簡化了實現並提高了性能 |
| 272 | +- 前端預覽與 AI 訪問分離兼顧了安全性和可用性 |
| 273 | +- 立即上傳策略優化了用戶體驗 |
| 274 | +- 多模態與文本降級的兼容設計確保了靈活性 |
| 275 | + |
| 276 | +這個方案在 HagiCode 中運行穩定,用戶反饋良好。如果你也在實現類似的功能,希望這些經驗對你有幫助。 |
| 277 | + |
| 278 | +其實技術方案這東西,沒有絕對的對錯,只有適合不適合罷了。找到適合自己項目的路,才是最重要的。 |
| 279 | + |
| 280 | +## 參考資料 |
| 281 | + |
| 282 | +- HagiCode GitHub: [github.com/HagiCode-org/site](https://github.com/HagiCode-org/site) |
| 283 | +- HagiCode 官網: [hagicode.com](https://hagicode.com) |
| 284 | +- OpenSpec 工作流文檔: [docs.hagicode.com](https://docs.hagicode.com) |
0 commit comments