你有没有这种体验:在用一些AI工具写文字的时候,文字是一个一个往外蹦的,而不是等半天突然冒出一大段。这种边生成边显示的效果,就是流式响应(Streaming Response)。

FileVibe的AI图片解读功能也是这样。用户在预览区打开一张图片,右边聊天区会自动感知到“已选择图片”,用户在输入框里打字提问,AI就会一边思考一边把答案逐字显示出来——而且图片和文字是同时发给AI的。

今天我们就来拆这个功能。我会带着你一步步思考:为什么要这么写?如果是我自己写,我会怎么想?同类情况应该怎么处理?

获取源代码:Gitee FileVibe


一、先想清楚:这个功能要解决什么问题?

1.1 用户场景:他到底想要什么?

想象你是FileVibe的用户。你电脑里有一张老照片,是你爷爷年轻时的。你想知道:

这张照片大概是什么年代的?衣服风格像哪个时期?

你希望直接选中图片,打个字,AI就能回答你

这就是用户要的——跟图片对话

在这里插入图片描述

1.2 技术挑战:我们要跨过哪些坎?

把这个需求翻译成技术语言,就是:

用户说 技术上意味着
“我选中一张图片” 要把图片数据传给AI
“我打个字” 要把文字和图片一起发给AI
“AI回答我” 要调用能看懂图片的AI模型
“快点显示” 不能用等全部生成完再显示的方式

再往深想一层:

  • 图片是加密的:FileVibe的文件都是AES加密的,不能直接把加密文件发给AI
  • AI生成要时间:一个完整回答可能5-10秒,让用户干等会疯掉
  • AI回答有格式:标题、列表、代码块,直接显示纯文本很难看
  • AI回答可能不安全:万一AI返回<script>alert('xss')</script>,直接插到页面里就完蛋了

所以我们要解决:

问题 解法
加密图片怎么给AI看? 后端解密,前端转成Data URL
图片和文字怎么一起发? 多模态API,一次请求同时传
等待时间太长怎么办? 流式响应,边生成边显示
回答有格式怎么办? 解析Markdown,转成HTML
回答不安全怎么办? XSS防护,双层保险

1.3 选型思考:为什么是模力方舟?

选AI服务时,我脑子里过了一遍选项:

  • 自己部署模型?不行,太贵太复杂,一个小工具不值得
  • OpenAI API?可以,但国内访问不稳定,还得考虑网络问题
  • 国产AI平台?有,但得选个靠谱的

最后选了模力方舟(Moark)。为什么?我列个表:

考虑因素 模力方舟 其他平台
是否支持图片理解 ✅ 多模态模型很多 不一定
调用方式 Serverless API,直接HTTP调 大多也是
免费额度 ✅ GLM-4.6V-Flash完全免费 有的要付费
国内访问 ✅ 国产,稳定 国外可能慢
API格式 ✅ 兼容OpenAI,学习成本低 各平台不同

思考过程:免费 + 国产 + 好用 = 模力方舟。就这么简单。

模力方舟官方网站

在这里插入图片描述


二、第一步:图片从哪来?(解密环节)

2.1 问题:文件是加密的,怎么给AI看?

FileVibe有个核心特性:所有文件都是AES-256加密的。硬盘上存的都是.encrypted文件,直接读是乱码。

但AI要的是图片数据,不是乱码。怎么办?

思考过程

  • 不能让前端解密,密钥在前端不安全
  • 不能把密钥传给AI,AI不需要知道
  • 所以解密必须在后端做,前端拿解密后的数据

2.2 解法:后端解密,前端转Data URL

这是server.js里的事,不是今天重点,但得知道:

// server.js - /api/read接口
let buf = await fs.readFile(file);
if (file.endsWith('.encrypted') && req.session.key) {
  buf = decryptBuffer(buf, req.session.key); // 后端解密
}

前端preview.js拿到的是解密后的二进制数据,转成Data URL:

// preview.js - 图片预览部分
const imgUrl = data.isBinary 
  ? `data:${mimeType};base64,${data.contentBase64}` 
  : `data:${mimeType};base64,${btoa(data.content)}`;

这个imgUrl长什么样?

data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...

这是一种Data URL格式:data:[MIME类型];base64,[数据]。浏览器可以直接当图片URL用,<img src="...">就能显示。

2.3 关键点:谁负责什么?

模块 职责
server.js 解密文件,返回二进制数据
preview.js 把二进制转成Data URL,渲染图片
preview.js 广播file-selected事件,把imgUrl传出去
chat.js 收到imgUrl,直接发给AI

chat.js根本不需要知道文件是加密的——它拿到的已经是处理好的图片URL。这就是职责分离

2.4 同类情况怎么处理?

如果你的应用也有类似场景(比如文件加密、图片处理、数据转换),记住这个原则:

每个模块只做自己擅长的事,做完交给下一个模块,不操心别人怎么做。

  • 后端擅长安全,就做解密
  • 预览模块擅长渲染,就做Data URL转换
  • 聊天模块擅长AI对话,就直接用现成的URL

这样代码才清晰,出了问题也好定位。


三、第二步:图片和文字怎么一起发?

3.1 问题:用户输入和图片怎么组合?

用户的操作可能是这样的:

  1. 在左边点了一张图片
  2. 在右边聊天框输入“这张图是什么年代?”
  3. 点击发送

这时候要发给AI的,是图片 + “这张图是什么年代?” 这两个东西。

思考过程

  • 不能先发图片再发文字,AI需要同时看到两者才能理解
  • 必须一次请求同时包含图片和文字
  • 找API文档,看怎么传多模态数据

在这里插入图片描述

3.2 解法:多模态API的content数组

模力方舟兼容OpenAI的API格式,多模态请求长这样:

{
  "messages": [
    {
      "role": "user",
      "content": [
        { "type": "image_url", "image_url": { "url": imageUrl } },
        { "type": "text", "text": userMessage }
      ]
    }
  ]
}

content字段不再是一个字符串,而是一个数组,可以同时放图片和文字。

3.3 代码实现:怎么把两者拼起来?

chat.js里的generateAIResponse

async function generateAIResponse(userMessage) {
  try {
    // 检查是否有选中的图片文件
    if (currentFile && currentFile.type && currentFile.type.startsWith('image/')) {
      // 调用图片解读API(同时传图片和文字)
      await analyzeImage(currentFile.url, userMessage);  // ← userMessage就是聊天框输入的文本
    } else {
      // 普通文本对话
      await generateTextResponse(userMessage);
    }
  } catch (error) {
    console.error('AI回复失败:', error);
    addChatMessage('ai', `抱歉,我无法处理你的请求。错误: ${error.message}`);
  }
}

关键点analyzeImage接收两个参数——imageUrluserMessage。前者来自之前file-selected事件存的currentFile,后者是用户刚输入的文字。

再看analyzeImage内部:

body: JSON.stringify({
  messages: [
    {
      "role": "system",
      "content": "You are a helpful and harmless assistant."
    },
    {
      "role": "user",
      "content": [
        {
          "type": "image_url",
          "image_url": {
            "url": imageUrl  // ← 图片URL
          }
        },
        {
          "type": "text",
          "text": userMessage  // ← 用户输入的文本
        }
      ]
    }
  ],
  // ...
})

思考过程

  • messages数组可以有多个角色:system设定AI人格,user是用户输入
  • 每个角色可以有多个content项,类型可以是image_urltext
  • AI看到这个请求就知道:用户发了一张图片,还问了一个问题

四、第三步:怎么处理AI的返回?(流式响应)

4.1 问题:AI生成要时间,让用户干等?

AI生成一段完整的回答,可能需要5-10秒。如果让用户盯着一个转圈圈等10秒,体验极差。

思考过程

  • 用户点发送后,最想要的是立即看到反馈
  • 哪怕只是“AI正在思考…”也比空白好
  • 如果能像打字一样一个字一个字显示,用户会觉得AI在“思考”,而不是卡住了

这就是流式响应的由来。

4.2 解法:stream: true + ReadableStream

在请求里加一个参数:

stream: true

告诉API:你别等全部生成完再返回,生成一点发一点。

API返回的就不再是完整的JSON,而是一个流(Stream)。浏览器可以用response.body.getReader()逐块读取。

4.3 代码实现:怎么逐块读取?

handleStreamingResponse

async function handleStreamingResponse(response) {
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      
      // 解码这一块数据
      buffer += decoder.decode(value, { stream: true });
      
      // 按行分割(SSE格式是每行一个数据)
      const lines = buffer.split('\n');
      buffer = lines.pop(); // 保留不完整的行
      
      for (const line of lines) {
        if (!line.trim() || !line.startsWith('data: ')) continue;
        
        const dataStr = line.substring(6); // 去掉"data: "
        if (dataStr === '[DONE]') break;
        
        try {
          const data = JSON.parse(dataStr);
          const delta = data.choices?.[0]?.delta;
          if (delta?.content) {
            updateStreamingMessage(delta.content); // 逐字更新UI
          }
        } catch (e) {
          console.error('解析失败:', e); // 跳过不完整的JSON
        }
      }
    }
    
    completeStreamingMessage(); // 流结束,解析Markdown
  } finally {
    reader.releaseLock(); // 释放资源
  }
}

逐行解释

第1-2行getReader()从响应体拿到一个可读流,decoder用来把二进制数据转成字符串。

第4行buffer用来暂存不完整的数据。为什么需要?因为网络传输是分块的,可能一个完整的JSON被切成两半,比如收到{"cho,下一块才是ices": [...]}buffer就是用来拼凑的。

第8-10行reader.read()每次返回一块数据。donetrue表示流结束。

第13行:把新数据加到buffer后面。

第16行:按行分割。SSE(Server-Sent Events)格式是每行一个data: {...}

第17行lines.pop()把最后一行放回buffer——因为最后一行可能不完整。

第19-21行:跳过空行和非data行。

第22行:去掉开头的data: ,剩下JSON字符串。

第23行:如果是[DONE],表示流结束。

第26-30行:解析JSON,取出delta.content——这就是新生成的那一小段文字。

第28行updateStreamingMessage把这段文字追加到当前AI消息里。

第36行:流结束,调用completeStreamingMessage做收尾工作。

第38行finally里释放流锁,避免资源泄露。

4.4 关键点:为什么要有buffer?

思考:如果不用buffer,直接处理每一块数据,会有什么问题?

假设第一块数据是:data: {"choices":[{"delta":{"content":"你好"}}]}\ndata: {"choices":[{"delta":{
第二块数据是:"content":"世界"}}]}\ndata: [DONE]

第一块最后一行不完整,如果直接处理会解析失败。用buffer暂存,等第二块来了再拼起来,就能完整解析。

同类情况:凡是处理流式数据(网络请求、文件读取、WebSocket),都要考虑分块边界的问题。buffer是标准解法。

4.5 流式消息管理:怎么区分多条消息?

用户可能连续发多个请求,每条消息有自己的内容。怎么确保A消息的内容不会跑到B消息里?

let _streamingCounter = 0;
let _currentStreamingId = null;

function addStreamingMessage() {
  _streamingCounter += 1;
  const contentId = `streamingContent-${_streamingCounter}`;
  _currentStreamingId = _streamingCounter;

  // 创建空的AI消息容器
  const messageDiv = document.createElement('div');
  messageDiv.innerHTML = `...<p id="${contentId}"></p>...`;
  document.getElementById('chatMessages').appendChild(messageDiv);
}

function updateStreamingMessage(content) {
  if (!_currentStreamingId) return;
  const el = document.getElementById(`streamingContent-${_currentStreamingId}`);
  if (el) {
    el.textContent += content;
  }
}

思考过程

  • 每条消息需要一个唯一ID
  • 用计数器生成ID最简单:1、2、3…
  • _currentStreamingId记录当前正在接收的是哪条
  • 更新时根据ID找到对应的DOM元素

为什么不用时间戳做ID? 因为用户可能在1秒内连发两条,时间戳可能重复。

为什么不用UUID? 没必要,简单计数器就够了。


五、第四步:怎么把Markdown变好看?

5.1 问题:AI返回的是带格式的文本

AI返回的内容可能是这样的:

这张照片虽然是**现代数字绘画(动漫/插画风格**)
但人物的服饰风格明显参考了**中国宋代(960-1279年)**
特别是**北宋时期**的**官服制度**。

直接显示成纯文本很难看。用户期待的是:

  • 粗体真的变粗
  • 列表前面有点
  • 代码块有背景色、等宽字体

5.2 解法:解析Markdown,转成HTML

FileVibe自己写了个简单的Markdown解析器,不用第三方库。

function parseMarkdown(text) {
  if (!text) return '';
  
  let result = text
    // 标题
    .replace(/^### (.*$)/gim, '<h3 class="text-lg font-bold mt-4 mb-2">$1</h3>')
    .replace(/^## (.*$)/gim, '<h2 class="text-xl font-bold mt-5 mb-3">$1</h2>')
    .replace(/^# (.*$)/gim, '<h1 class="text-2xl font-bold mt-6 mb-4">$1</h1>')
    // 粗体和斜体
    .replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
    .replace(/\*(.*?)\*/gim, '<em>$1</em>')
    // 代码块
    .replace(/```(\w*?)\n([\s\S]*?)```/gim, 
      '<pre class="bg-gray-800 p-4 rounded-md overflow-x-auto"><code class="text-sm">$2</code></pre>')
    // 行内代码
    .replace(/`(.*?)`/gim, '<code class="bg-gray-800 px-1 py-0.5 rounded text-sm">$1</code>')
    // 列表
    .replace(/^\- (.*$)/gim, '<li class="list-disc ml-6">$1</li>');
  
  return result;
}

逐行解释

第5行/^### (.*$)/gim 匹配以### 开头的行,$1是后面的内容。gim标志:g全局匹配、i不区分大小写、m多行模式。

第8行/\*\*(.*?)\*\*/gim 匹配**内容**这样的粗体。.*?是非贪婪匹配,确保只匹配最近的一对**

第11行/```(\w*?)\n([\s\S]*?)```/gim 匹配代码块:

  • (\w*?) 匹配语言名称(如python),非贪婪
  • \n 匹配换行
  • ([\s\S]*?) 匹配代码内容,包括换行,非贪婪

第14行/(.*?)/gim 匹配行内代码,比如print()

第16行/^\- (.*$)/gim 匹配以- 开头的列表项。

5.3 为什么要自己写,不用现成库?

思考

  • 引入一个Markdown库,可能增加几十KB体积
  • 库的功能可能过剩,FileVibe只需要基本的标题、列表、代码块
  • 自己写可以精确控制生成的HTML类名,和Tailwind CSS完美配合

同类情况:如果你的项目只需要某个功能的一小部分,自己实现比引入库更轻量。当然,如果功能复杂(比如表格、脚注),还是用库更稳妥。


六、第五步:怎么防止XSS攻击?

6.1 问题:AI返回的内容可能不安全

AI是第三方服务,你无法控制它返回什么。万一它返回:

<script>alert('XSS')</script>
<img src=x onerror=alert('XSS')>

直接插到页面里,用户的浏览器就会执行这些恶意代码。

思考:绝对不能信任AI返回的内容,必须做防护。

6.2 解法:双层防护

FileVibe用了两层防护

第一层:流式接收时用textContent

function updateStreamingMessage(content) {
  const el = document.getElementById(`streamingContent-${_currentStreamingId}`);
  if (el) {
    el.textContent += content; // 注意:用textContent,不是innerHTML
  }
}

textContent只当纯文本处理,不会解析HTML。所以就算收到<script>,也只会显示成&lt;script&gt;,不会执行。

第二层:最后解析时先转义

function completeStreamingMessage() {
  const streamingContent = document.getElementById(contentId);
  
  // 第一层防护:从textContent取纯文本(确保没有可执行代码)
  const markdownText = streamingContent.textContent;
  
  // 第二层防护:先HTML转义
  const escapedText = escapeHtml(markdownText);
  
  // 再解析Markdown
  const htmlText = parseMarkdown(escapedText);
  
  // 最后插入格式化后的HTML
  streamingContent.innerHTML = htmlText;
}

function escapeHtml(text) {
  const map = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#039;'
  };
  return text.replace(/[&<>'"]/g, m => map[m]);
}

转义函数做了什么?

把特殊字符替换成HTML实体:

  • <&lt;
  • >&gt;
  • &&amp;
  • "&quot;
  • '&#039;

这样浏览器看到&lt;script&gt;,只会显示成<script>文本,不会当作标签执行。

6.3 为什么不在接收过程中直接转义?

因为Markdown解析需要原样的文本。比如:

<你好>

如果先转义变成&lt;你好&gt;,再解析Markdown,就找不到<开头的标签了。所以顺序是:

  1. 流式接收时用textContent存纯文本(安全)
  2. 接收完后从textContent取纯文本(安全)
  3. 先转义(防XSS)
  4. 再解析Markdown(转成好看的HTML)
  5. 最后用innerHTML插入(此时已经安全)

6.4 同类情况怎么处理?

任何时候,只要你要把外部输入插入到DOM里,都要做XSS防护:

输入来源 防护方式
用户输入 显示时用textContent,或转义后innerHTML
API返回 同上
URL参数 同上
localStorage数据 同上

原则:永远不要信任外部数据,永远先转义后插入。


七、总结:整个流程是怎么串起来的?

把上面所有环节串起来,就是完整的AI图片解读流程:

[用户操作]
    ↓
点击图片 → list.js触发'open-file'事件
    ↓
preview.js监听到 → 调用/api/read获取解密后的图片数据
    ↓
preview.js把二进制转成Data URL → 渲染图片 → 触发'file-selected'事件
    ↓
main.js监听到 → 调用chat.js的updateCurrentFile()
    ↓
chat.js记录currentFile = { url: 'data:...', name: 'xxx.jpg' }
    ↓
[用户输入文字,点击发送]
    ↓
chat.js的generateAIResponse被调用 → 检测到有currentFile
    ↓
调用analyzeImage(currentFile.url, userMessage)
    ↓
addStreamingMessage()创建空的消息容器
    ↓
fetch模力方舟API,设置stream: true
    ↓
服务器开始流式返回数据
    ↓
handleStreamingResponse逐块读取 → 每收到一段内容就updateStreamingMessage
    ↓
updateStreamingMessage用textContent追加内容(安全第一)
    ↓
流结束 → completeStreamingMessage被调用
    ↓
从textContent取纯文本 → escapeHtml转义 → parseMarkdown解析
    ↓
用innerHTML插入格式化后的内容
    ↓
用户看到格式漂亮的AI回答

八、写在最后

回顾整个实现,你会发现每个决策背后都有为什么

决策 为什么
后端解密 密钥不能放前端
Data URL传图片 浏览器原生支持,简单
图片和文字一起发 AI需要同时理解两者
流式响应 改善等待体验
buffer暂存 处理网络分块
计数器做ID 区分多条消息
自己写Markdown解析 轻量,可控
双层XSS防护 安全第一

写代码不只是写代码,是在做决策。每个决策背后都要想清楚:为什么要这么选?有没有更好的?如果出了问题会怎样?

FileVibe的AI图片解读功能,就是这些决策的集合。希望你看完这篇文章后,不只是会抄这几段代码,而是能理解为什么要这么写,以后遇到类似场景,也能自己做出好的决策。
在这里插入图片描述

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐