一个在Flask Web应用中安全渲染LLM输出的本地图片路径的方法

1. 问题背景:浏览器安全模型与本地文件访问限制

在构建基于大型语言模型(LLM)的 Web 应用时,一个常见的场景是 LLM 的输出中包含了指向服务器本地文件系统的图片路径。例如,在一个本地搭建的 RAG 系统中,LLM 可能会根据上下文生成一段包含标准 Markdown 图片语法的回答:

1
![图片](D:\images\image.png)

然而,如果将这段 Markdown 直接转换为 HTML(例如 <img src="D:\project\data\images\package_diagram.png">),并试图在用户浏览器中渲染,该请求将会失败出现报错:

1
Not allowed to load local resource: file:///D:/...

这是因为现代浏览器的安全模型,特别是同源策略(Same-Origin Policy)和安全沙箱机制,严格禁止网页中的JavaScript代码直接访问用户的本地或远程服务器的任意文件系统。这种限制是保障用户安全的基石。

因此,必须设计一个安全且有效的机制,作为浏览器和服务器文件系统之间的“中间人”,以实现图片的正确渲染。


2. 解决方案架构:建立一个“受信任的中间人”

既然前端浏览器不能直接去服务器硬盘上“拿”图片,我们就需要一个“中间人”来代劳。这个中间人必须是服务器自己,因为服务器有权限访问自己的文件系统。

这个思路引出了一种经典的架构模式:代理服务(Proxy Service)。

我们的方法论可以分解为以下几个步骤:

  • 约定一个暗号:前端和后端约定一个特殊的 URL 格式,作为请求图片的“暗号”。这个 URL 不会暴露真实的文件路径,而是指向我们即将创建的后端服务。

  • 创建后端代理端点:在 Flask 中创建一个 API 端点(例如 /image)。这个端点是唯一有权访问服务器上特定图片目录的“守门人”。

  • 前端发起代理请求:当前端需要显示一张本地图片时,它不直接使用文件路径,而是向这个 /image 端点发起一个请求,并将真实的文件路径作为参数传递。

  • 后端验证并服务:后端代理收到请求后,首先进行严格的安全检查,确保请求的路径是合法的。验证通过后,它才去硬盘上读取图片文件。

  • 安全的数据传输:后端不直接发送文件,而是将图片内容编码为 Base64 字符串。这是一种能将二进制数据转换为纯文本的编码方式,非常适合在 JSON 中传输。

  • 前端动态渲染:前端接收到包含Base64数据的JSON后,用这些数据动态地构建一个 Data URL ,并将其赋给 <img> 标签的 src 属性,最终将图片显示出来。

这个流程的核心在于,将一个不安全的直接文件访问,转变为一个安全的、受控的 API 调用。


3. 代码实现详解

3.1. 后端 (app.py): 构建安全的文件代理与Base64编码器

核心改动是在Flask应用中增加 /image 路由。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import os
import base64
from flask import Flask, jsonify, request, abort

# ... (其他Flask应用设置) ...

# 1. 定义一个安全的根目录,所有图片请求都必须在此目录之下
# 为安全起见,请将此路径设置为您的项目根目录或包含所有图片的特定父目录。
# os.getcwd() 会获取当前脚本运行的目录,这是一个安全的默认值。
IMAGE_BASE_DIR = os.path.abspath(os.getcwd())

@app.route('/image')
def serve_image():
# 2. 获取前端通过URL参数传来的路径
image_path = request.args.get('path')

# 3. 安全校验:确保请求的路径没有“越界”
safe_path = os.path.abspath(image_path)
if not safe_path.startswith(IMAGE_BASE_DIR):
abort(403) # 拒绝非法请求

# 4. 读取文件并编码为Base64
try:
with open(safe_path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')

# 5. 构造包含MIME类型和编码数据的Data URL
ext = os.path.splitext(safe_path)[1].lower()
mime_type = {'.png': 'image/png', '.jpg': 'image/jpeg'}.get(ext, 'application/octet-stream')
data_url = f"data:{mime_type};base64,{encoded_string}"

# 6. 将Data URL打包成JSON返回
return jsonify({"imageData": data_url})
except FileNotFoundError:
abort(404)
except Exception as e:
abort(500)
  • 安全检查: IMAGE_BASE_DIR 的设置和 startswith() 的验证是此端点的安全核心,它将文件访问权限严格限制在预设的目录范围内。

  • Base64编码: 将图片转换为 Base64 格式的 Data URL,允许图片数据作为文本嵌入到 JSON 响应中,并能被浏览器直接渲染,避免了前端需要为获取图片而发起额外的 HTTP 请求。


3.2. 前端 (index.html): 自定义Markdown渲染与异步加载

前端的核心工作在 appendAiMessage 函数中,通过重写 marked.js 的图片渲染逻辑来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
function appendAiMessage(data, fullLog = '') {
// ... (其他创建消息元素的代码) ...
const messageDiv = document.createElement('div');

// --- 1. 自定义Markdown图片渲染规则 ---
const renderer = new marked.Renderer();
renderer.image = (href, title, text) => {
// 安全检查,确保href是字符串
if (typeof href !== 'string') {
console.error("marked.js renderer received a non-string href:", href);
return '';
}

const decodedHref = decodeURIComponent(href || '');

// 使用正则表达式检查是否是需要代理的本地绝对路径
if (/^[a-zA-Z]:[\\\/]/.test(decodedHref)) {
const encodedPath = encodeURIComponent(decodedHref);
// **步骤 A (生成占位符)**: 返回一个<img>标签,其src指向后端的/image代理API
return '<img src="/image?path=' + encodedPath + '" alt="' + (text || '相关图片') + '" style="opacity: 0.5;">';
}
// 对于标准的网络图片,维持原样
return '<img src="' + href + '" alt="' + (text || '') + '">';
};

// 使用自定义渲染器将Markdown文本转换为HTML
const htmlContent = marked.parse(answer || "抱歉,没有收到回答。", { renderer: renderer });
messageDiv.innerHTML = htmlContent;
// ... (将messageDiv添加到聊天窗口) ...

// --- 2. 异步获取并替换图片 ---
// 查找所有需要从后端加载的"占位"图片
const imagesToLoad = messageDiv.querySelectorAll('img[src^="/image?path="]');
imagesToLoad.forEach(async (img) => {
const placeholderSrc = img.getAttribute('src');
if (!placeholderSrc) return;

try {
// **步骤 B (发起代理请求)**: 异步向后端API请求真实的图片数据
const response = await fetch(placeholderSrc);
if (!response.ok) throw new Error('Failed to fetch image data: ' + response.status);
const imgData = await response.json(); // 解析后端返回的JSON

// **步骤 C (替换src)**: 将图片的src属性替换为后端返回的Base64 Data URL
if (imgData.imageData) {
img.src = imgData.imageData;
img.style.opacity = 1; // 使图片完全可见
}
} catch (error) {
console.error('加载图片失败:', error);
img.alt = "图片加载失败";
}
});

// ... (其他代码) ...
}
  • 渲染逻辑分离: HTML 的初始渲染和图片的最终加载是分离的。marked.js 首先创建了包含代理 URLHTML 结构。

  • 异步非阻塞: 图片的加载过程是异步的,不会阻塞用户界面的其他操作。用户可以立即看到文本内容,而图片会在后台加载完成后显示。


3.2.1. 正则表达式详解

在前端代码中,用于判断路径是否为本地绝对路径的正则表达式 /^(?:[a-zA-Z]:[\\\/]|\/)/ 是一个关键部分。让我们来详细解析它的工作原理:

  • ^: 这个符号代表“字符串的开始”。它确保我们只匹配路径的起始部分,而不是路径中间的某个位置。

  • (?: ... ): 这是一个非捕获组(non-capturing group)。它的作用是将括号内的多个模式组合成一个单元,但不会像普通括号那样“捕获”匹配到的内容。在这里,它将两种主要的路径格式组合在一起。

  • [a-zA-Z]:[\\\/]: 这是用来匹配Windows绝对路径的部分。

  • [a-zA-Z]: 匹配任意一个大写或小写字母(即盘符,如 C, D)。

  • :: 匹配紧跟在盘符后面的冒号。

  • [\\\/]: 匹配一个路径分隔符。\ 是对反斜杠 \ 的转义,/ 是正斜杠。这个部分意味着无论是 D:\... 还是 D:/... 格式的路径都能被识别。

  • |: 这是一个“或”操作符。它表示匹配左边的模式 或者 右边的模式。

  • /: 这是用来匹配Unix/Linux绝对路径的部分。所有Unix-like系统的绝对路径都以一个正斜杠 / 开始(例如 /home/user/image.png)。


4. 对LLM的输出引导

最后一步是确保LLM能够持续生成符合我们预期的、包含本地路径的 Markdown 图片语法。这通过在系统提示(System Prompt)中提供清晰的指令和示例来完成。

指令示例:

1
图片处理: 如果一个节点的值是文件路径(例如以 .png, .jpg 等结尾),你应该说“它的封装图如下所示:”或类似的话,然后另起一行,使用标准的Markdown语法 ![封装图](图片路径) 来格式化并展示图片。

成功在前端渲染 markdown 图片效果展示:
image


该项目代码参考 Not allowed to load local resource: 报错解决方法