并非所有人工智能功能都需要依赖云模型来实现,因为使用云模型会产生按字符计费的费用、导致网络数据往返传输,并且还会使用户的私人数据离开自己的设备。如果你使用的是现代版的Mac电脑,那么一个功能强大的语言模型早已存在于你的设备硬盘中。

Foundation Models是苹果公司为处理大型语言模型而开发的Swift框架。它是苹果智能技术、苹果私有云计算服务,或是其他供应商提供的服务器模型背后所使用的离线模型。

本教程主要介绍这种离线模型:你向它发送指令,它就会完全在Mac自身的硬件上本地运行,每次调用都是免费的,并且支持离线使用。

如果再结合苹果的视觉识别技术,在设备上直接处理图像数据,那么就可以构建出诸如摘要生成、文本分类以及结构化信息提取等功能,而且这些过程完全不需要将数据传送到外部服务器。

目录

你将构建什么

你将构建一个名为Vision Bridge的Web应用,这个应用会将用户上传的图像发送到Mac设备上的配套应用中。该配套应用会使用苹果视觉识别技术来解析图像,然后利用Foundation Models进行进一步处理,并最终以结构化的JSON格式将结果返回给浏览器——这就是一种隐藏在普通Web界面背后的、私密的、离线运行的人工智能技术。

您可以在这个GitHub仓库中找到完整的源代码:github.com/03balogun/vision-bridge

我们的目标并不是开发出一个功能强大的产品,而是要弄清楚这一技术背后的工作原理及其架构结构。

Vision Bridge应用程序的截图,左侧为图片上传功能,右侧为JSON输出结果

Vision Bridge由两个部分组成:

  • 一个采用分屏界面的React应用程序。

  • 一个macOS配套应用程序,它提供了本地API接口。

React应用程序包含以下功能:

  • 图片上传区域

  • 图片预览功能

  • 上传后自动进行分析

  • JSON输出结果查看器

  • 健康状态指示功能

macOS配套应用程序具有以下功能:

  • GET /v1/health

  • POST /v1/analyze-image

  • Apple Vision OCR识别功能
  • 检查Foundation Models是否可用
  • 利用Foundation Models对视觉分析结果进行进一步处理

最终的响应格式如下:

{
  "support": {
    "visionAvailable": true,
    "foundationModelAvailable": true,
    "foundationModelStatus": "available"
  },
  "image": {
    "filename": "screenshot.png",
    "contentType": "image/png",
    "byteCount": 1048576,
    "width": 1440,
    "height": 900
  },
  "vision": {
    "detectedText": [
      {
        "text": "构建失败",
        "confidence": 0.96,
        "boundingBox": {
          "x": 0.12,
          "y": 0.31,
          "width": 0.45,
          "height": 0.08
        }
      }
    ]
  },
  "model": {
    "summary": "图片显示的是软件构建失败的情况。",
    "description": "开发工具窗口中出现了错误提示及相应的诊断信息。",
    "suggestedTags": ["screenshot", "developer-tool", "error"],
    "possibleUses": [
      "生成图片说明文字",
      "总结截图内容",
      "提取文档数据"
    ]
  }
}

先决条件

要跟随本教程进行操作,您需要满足以下要求:

  • macOS 26或更高版本

  • 装有macOS 26 SDK的Xcode开发工具

  • Node.js 20或更高版本

  • 具备基本的React开发知识

  • 具备基本的Swift编程知识

  • 使用支持Apple Intelligence功能的Mac电脑

Foundation Models的功能是否可用取决于所使用的Mac电脑、操作系统版本以及Apple Intelligence的设置。macOS配套应用程序会在运行时检查这些条件,我们将在后面详细介绍这一过程。

为什么需要macOS配套应用程序?

您无法在普通的React应用程序中使用以下代码:

import FoundationModels from "apple-frameworks";

因为浏览器并不提供这样的API接口。然而,原生macOS应用程序可以使用任何Apple框架,因此配套应用程序就起到了连接本地功能与Web平台的作用。对于那些Web平台没有提供的原生功能,同样的机制也同样适用。

基础模型无法直接读取图像

公开的基础模型框架实际上是一个语言模型接口。目前,它并不像多模态云模型那样提供直接的图像输入功能,因此本教程中从未将图像直接传递给模型。相反,我们会将视觉识别生成的文本结果以及图像元数据作为输入数据提供给模型。模型处理的是结构化文本,而非原始的像素数据。

这种分工充分利用了各自框架的优势:视觉识别技术擅长从图像中提取机器可理解的信息,而基础模型则能将这些信息转化为摘要、标签、解释以及结构化的输出结果。

视觉识别桥梁架构:浏览器将图像通过本地主机发送给Swift辅助程序,该程序会运行Apple Vision OCR工具,生成文本分析结果,然后将其传递给基础模型,最终得到结构化的JSON数据

上图展示了本教程后续部分所实现的功能流程。浏览器会将上传的图像以base64 JSON格式通过本地主机发送给Swift辅助程序。在该程序内部,Apple Vision OCR工具会处理图像并生成文本分析结果,包括识别出的文字内容、它们的置信度以及对应的边界框信息。

这些分析结果而非原始图像本身会被格式化为输入数据传递给基础模型,模型会根据这些数据生成摘要、描述和标签。随后,辅助程序会将视觉识别产生的结果与模型输出合并成一份JSON响应,并将其返回给浏览器。

项目结构

请按照以下结构创建项目:

vision-bridge/
  apps/
    web/
      src/
        main.tsx
        styles.css
      package.json
      vite.config.ts
    macos-companion/
      Package.swift
      Sources/
        VisionBridgeCompanion/
          main.swift
  package.json
  README.md

根目录下的package.json文件提供了一些实用的命令:

{
  "scripts": {
    "dev": "npm --workspace apps/web run dev",
    "build": "npm --workspace apps/web run build",
    "companion": "swift run --package-path apps/macos-companion VisionBridgeCompanion"
  },
  "workspaces": ["apps/web"]
}

构建React应用程序

这个Web应用程序的设计非常简单,它的唯一功能就是让用户选择一张图片,然后显示辅助程序返回的JSON数据。

该应用程序使用了Vite、React、Lucide图标以及一个JSON查看器工具:

{
  "dependencies": {
    "@vitejs/plugin-react": "^6.0.3",
    "lucide-react": "^0.468.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-json-view-lite": "^2.5.0",
    "vite": "^8.1.3"
  }
}

在定义完所有依赖项后,就需要安装它们了:

npm install

API的基地址指向本地的辅助程序:

const API_BASE_URL = "http://127.0.0.1:43119";

检查辅助程序的状态

该Web应用程序会向辅助程序发送请求,以便用户界面能够显示原生桥接组件是否处于在线状态:

async function checkHealth() {
  setHealthError(null);

  try {
    const response = await fetch(`${API_BASE_URL}/v1/health`);
    if (!response.ok) {
      throw new Error(`健康检查失败,错误代码为 ${response.status}`);
    }

    const payload = await response.json();
    setHealth(payload);
  } catch (error) {
    setHealth(null);
    setHealthError(error instanceof Error ? error.message : "辅助程序不可用");
  }
}

辅助程序在线状态指示图的截图

将图片转换为Base64格式

当用户选择文件后,应用程序会将其转换为Base64格式,这样就可以以JSON的形式发送了:

function readFileAsBase64(file: File) {
  return new Promise<string>((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => {
      const result = String(reader.result);
      resolve(result.includes(",") ? result.split(",")[1] : result);
    };
    reader.onerror = () => rejectreader.error);
    reader.readAsDataURL(file);
  });
}

这并不是上传文件的唯一方法。你也可以使用`multipart/form-data`格式,但使用JSON格式的话,演示过程会更容易理解。

上传文件后立即进行分析

一旦图片被上传,应用程序就会立即开始分析操作:

async function handleFile(file: File) {
  if (!file.type.startsWith("image/")) {
    setError("请选择PNG、JPEG、HEIC或其他浏览器能够识别的图片格式。");
    return;
  }

  const base64 = await readFileAsBase64(file);
  const nextImage = {
    file,
    previewUrl: URL.createObjectURL(file),
    base64,
  };

  setSelectedImage(nextImage);
  setAnalysis(null);
  setError(null);
  setCopied(false);

  analyzeImage(nextImage);
}

handleFile函数会为每一张新上传的图片完成相应的准备工作:它会拒绝那些无法被浏览器识别的文件格式,将文件转换为Base64格式,并生成一个包含所有所需信息的对象——这个对象包含了原始文件的名称和MIME类型、用于预览的Object URL,以及用于API调用的Base64编码数据。

之后,该函数会清除之前分析过程中产生的结果、任何错误信息,以及“文件已复制”的提示标志,这样用户界面就不会在新上传的图片旁边显示上一次分析的结果。最后,它会立即执行analyzeImage(nextImage)函数来继续进行分析操作。

请注意,该函数会直接传递最新的图像数据,而不会依赖selectedImage状态变量:React的状态变更直到下一次渲染才会生效,因此在此处读取状态变量仍然会得到之前的图像数据。

UI中仍然存在Analyze按钮,但它现在的作用只是用于手动重新执行分析操作。

将图像发送到配套应用

以下是核心的请求逻辑:

const analysisRequestId = useRef(0);

async function analyzeImage(image = selectedImage) {
  if (!image) {
    setError("请先选择一张图像。");
    return;
  }

  const requestId = analysisRequestId.current + 1;
  analysisRequestId.current = requestId;

  setRequestState("loading");
  setError(null);
  setCopied(false);

  try {
    const response = await fetch(`${API_BASE_URL}/v1/analyze-image`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        filename: image.file.name,
        mimeType: image.file.type || "application/octet-stream",
        base64: image.base64,
      }),
    });

    const payload = await response.json();

    if (requestId !== analysisRequestId.current) {
      return;
    }

    if (!response.ok) {
      throw new Error(payload.error?.message ?? `分析失败,状态码为 ${response.status}`);
    }

    setAnalysis(payload);
    setRequestState("success");
  } catch (error) {
    if (requestId !== analysisRequestId.current) {
      return;
    }

    setRequestState("error");
    setError(error instanceof Error ? error.message : "无法分析图像");
  }
}

这个函数构成了客户端的所有逻辑。它会将requestState设置为loading状态(这样就会显示旋转图标并禁用相关按钮),然后向/v1/analyze-image发送一个POST请求,请求体中包含三个字段:文件名、MIME类型以及Base64编码的图像数据。这些数据会与Swift配套应用后续解码时使用的AnalyzeImageRequest结构体一一对应。

需要注意的是,在检查response.ok之前,响应内容会被先解析成JSON格式。这样设计的目的是为了确保即使配套应用拒绝了请求(比如因为Base64编码错误或图像文件过大),它仍然会返回一个包含error.message字段的JSON响应,这样UI就可以显示配套应用给出的具体原因,而不仅仅是通用的状态码。如果分析成功,解析得到的数据会直接被存入状态变量中,然后JSON查看器会重新渲染界面以显示分析结果。

requestId这个计数器的存在有助于防止使用过时的响应数据。如果用户在第一张图像还在被分析时上传了第二张图像,那么最终只有完成分析的那次请求会被执行;由于OCR分析和模型生成需要一定的时间,因此响应数据的到达顺序可能会出现混乱。因此,每次请求都会使存储在ref中的计数器值加1,并且会记录下自己的请求ID。

在执行 `await` 之后,系统会检查当前请求是否仍然是最新的请求;如果在这段时间内有新的上传操作开始,那么旧的响应会被自动丢弃,而不会覆盖最新上传所得到的结果。同样的检查也会在 `catch` 块中执行,因此旧的错误也不会影响新的成功操作的结果。如果你还想取消正在进行的 HTTP 请求,而不仅仅是忽略它的结果,那么使用 `AbortController` 就是下一个合理的选择。

生成JSON输出结果

输出界面使用了react-json-view-lite组件:

<JsonView
  data={jsonData}
  shouldExpandNode={allExpanded}
  style={jsonViewTheme}
/>

构建macOS配套应用程序

这个配套程序是一个使用Swift编写的命令行应用,它提供了一个简单的本地HTTP接口。

如果你之前从事过Web开发,那么这种转换其实非常简单:Swift Package Manager就相当于Swift的npm,Package.swift文件则对应于package.json文件,而swift run命令则相当于npm start。由于这个程序是随Xcode一起提供的,因此无需额外安装任何组件。

Package.swift文件的格式如下:

// swift-tools-version: 6.0

import PackageDescription

let package = Package(
    name: "VisionBridgeCompanion",
    platforms: [
        .macOS("26.0")
    ],
    products: [
        .executable(
            name: "VisionBridgeCompanion",
            targets: ["VisionBridgeCompanion"]
        )
    ],
    targets: [
        .executableTarget(
            name: "VisionBridgeCompanion"
        )
    ]
)

这个配套程序会导入它所需要使用的Apple框架:

import Foundation
import FoundationModels
import ImageIO
import Network
import Vision

该程序会在127.0.0.1:43119这个端口上监听请求:

private let defaultPort: UInt16 = 43119

这个应用程序提供了两条路由路径:

switch (request.method, request.path) {
case ("GET", "/v1/health"):
    let health = HealthResponse(support: ModelSupport.current)
    return try json(health)

case ("POST", "/v1/analyze-image"):
    let payload = try JSONDecoder().decode(AnalyzeImageRequest.self, from: request.body)
    let response = try await service.analyze(payload)
    return try json(response)

default:
    return try json(
        ErrorResponse(error: APIErrorPayload(message: "路由路径未找到")),
        status: .notFound
    )
}

这个switch语句构成了整个配套程序的路由处理逻辑——完全没有使用任何Web框架,只是通过方法名和路径来匹配请求。

这两条路由路径分工明确:

  • GET /v1/health这条路径用于获取系统状态信息。该路径不会执行任何分析操作,只会通过ModelSupport.current判断当前Mac系统中是否支持Vision及Foundation Models框架(具体细节将在下一节中介绍)。React应用程序在加载时会调用这个接口,以便显示设备是处于在线状态还是离线状态,这样用户就可以在上传文件之前了解桥接工具是否可用。

  • POST /v1/analyze-image这条路径才是真正执行分析操作的地方。它会将请求体解码成AnalyzeImageRequest对象(其中包含浏览器发送的filenamemimeTypebase64字段),然后将其传递给分析服务。该服务会验证图像文件的有效性,运行Vision OCR识别功能,并调用Foundation Models进行分析,最终返回综合分析结果。try await语句在这里起到了关键作用:因为分析操作是异步进行的,所以程序会等待分析结果完成后才将响应数据序列化并返回给客户端。

其他所有情况都会被转换为JSON格式的404错误响应,因此即使是未知的路由,也会以浏览器已经能够解析的相同格式进行响应。

错误的处理方式也是如此:出现的错误会被集中捕获,并转换成带有相应状态码的JSON错误响应,而Web应用程序中的`payload.error?.message`这一代码正是用来读取这些错误信息的。

还有一个实际需要注意的地方:由于浏览器是从不同的来源(即Vite开发服务器)发起请求的,因此每个响应都会包含CORS头部信息;同时,路由器也会对预先发送的`OPTIONS`请求返回一个空的`204响应。如果没有这些设置,浏览器会在请求到达这些路由之前就直接阻止请求的继续执行。

检查基础模型的可用性

辅助程序不应假设模型一定是可用的,因此应该先进行检测:

private struct ModelSupport: Encodable {
    let visionAvailable: Bool
    let foundationModelAvailable: Bool
    let foundationModelStatus: String

    static var current: ModelSupport {
        let model = SystemLanguageModel.default

        switch model.availability {
        case .available:
            return ModelSupport(
                visionAvailable: true,
                foundationModelAvailable: true,
                foundationModelStatus: "available"
            )

        case .unavailable(let reason):
            return ModelSupport(
                visionAvailable: true,
                foundationModelAvailable: false,
                foundationModelStatus: "unavailable.\(reason.description)"
            )

        @unknown default:
            return ModelSupport(
                visionAvailable: true,
                foundationModelAvailable: false,
                foundationModelStatus: "unavailable.unknown"
            )
        }
    }
}

用户可能使用的是不支持该模型的Mac设备,或者Apple Intelligence功能被禁用了,又或者该模型本身尚未准备好可供使用。响应信息会告诉浏览器当前面临的是哪种情况。

使用Apple Vision提取文本

辅助程序会先解码Base64编码的图像,检查其元数据,然后运行Vision OCR技术进行文本识别。

文本识别的具体流程如下:

private func recognizeText(in imageData: Data) async throws -> [DetectedText] {
    var request = RecognizeTextRequest()
    request.recognitionLevel = .accurate
    request.automaticallyDetectsLanguage = true
    request.usesLanguageCorrection = true

    let observations = try await request.perform(on: imageData)

    var detectedText: [DetectedText] = []

    for observation in observations {
        guard let candidate = observation.topCandidates(1).first else {
            continue
        }

        let bounds = NormalizedBox.from(points: [
            observation.topLeft,
            observation.topRight,
            observation.bottomRight,
            observation.bottomLeft
        ])

        detectedText.append(DetectedText(
            text: candidate.string,
            confidence: Double(candidate.confidence),
            boundingBox: bounds
        ))
    }

    return detectedText
}

视觉分析为我们提供了结构化的数据:

  • 被识别出的文本

  • 置信度分数

  • 标准化后的边界框信息

这些数据成为了模型进行分析的依据。

让基础模型来解释视觉分析的结果

接下来,辅助程序会根据图像的元数据以及OCR分析结果生成相应的提示信息。

请注意以下说明:

你无法查看原始图像。请仅使用下面的元数据和OCR分析结果。

这样的设计能够确保模型的客观性——它不会假装看到了自己实际上并未接收到的像素数据。

下面是提示信息的格式示例:

let textPreview = detectedText
    .prefix(30)
    .map { "- \($0.text) (confidence: \(String(format: "%.2f", $0.confidence)))" }
    .joined(separator: "\n")

let prompt = """
你正在为名为“Vision Bridge”的开发工具总结Apple Vision OCR的分析结果。
你无法查看原始图像。请仅使用下面的元数据和OCR分析结果。

图像信息:
- 文件名:\(image.filename)
- 内容类型:\(image.contentType)
- 大小:\(image.width ?? 0)x\(image.height ?? 0)

OCR分析结果:
\(textPreview.isEmpty ? "- 未检测到任何文本。" : textPreview)

请返回一个包含以下键值的JSON对象:
summary: 一句话总结
description: 一段简短的描述
suggestedTags: 3到6个合适的标签
possibleUses: 3到5种这种图像分析的实用场景
"""

然后调用模型进行处理:

let session = LanguageModelSession(
    model: .default,
    instructions: "仅返回有效的JSON格式数据。不要包含Markdown标记。"
)

let response = try await session.respond(to: prompt)
let raw = response.content.trimmingCharacters(in: .whitespacesAndNewlines)

即使你请求的是JSON格式的数据,也必须对输出内容进行验证——因为模型仍然有可能返回包含Markdown标记或格式错误的文本。示例应用程序会去除其中的简单Markdown代码,如果解析失败,则会直接使用原始响应数据。

将JSON数据返回给浏览器

辅助程序会将支持状态、图像元数据、视觉分析结果以及模型的输出信息整合在一起:

return AnalyzeImageResponse(
    support: support,
    image: metadata,
    vision: VisionPayload(detectedText: detectedText),
    model: modelInsight
)

浏览器并不需要了解视觉分析模型或基础模型的工作原理,它只需要接收JSON格式的数据即可。原生应用程序负责处理相关的底层功能,而Web应用程序则负责提供用户界面。

值得花时间仔细了解一下这四部分数据各自包含哪些信息,因为它们其实属于不同类型的数据。

  • support字段会告诉你这款Mac设备支持哪些功能。如果foundationModelAvailable的值为false,那么model块虽然仍然存在,但其中包含的只是替代性说明,而非实际的分析结果;而foundationModelStatus字符串(例如unavailable.appleIntelligenceNotEnabled)则会向用户界面说明“为什么会出现这种情况”,这样界面就能给出相应的解释,而不会直接出现故障。

  • image字段会返回文件的元数据以及其像素尺寸。这些信息有助于进行初步检查;而且要利用Vision工具得到的分析结果进行处理的话,就必须知道图像的宽度和高度。

  • vision字段包含的是真实的数据。在detectedText列表中的每一项,都是Vision工具实际检测到的内容,其中会附带一个置信度分数(范围为0到1),以及一个标准化的边界框——这些坐标是以图像尺寸的比例来表示的,因此x: 0.12, width: 0.45的意思是“从图像左侧开始,位置位于12%的位置处,宽度占整个图像宽度的45%”。由于这些边界框都是标准化的,因此无论在何种显示尺寸下,你都可以将它们乘以实际的渲染尺寸,从而在预览图中标出重点区域。对于那些置信度较低的检测结果,在正式使用之前,最好先进行过滤或标记处理。

  • model字段提供的是对检测结果的解读,而非客观观察所得的数据。summarydescriptionsuggestedTagspossibleUses这些字段都是语言模型根据OCR识别得到的文本生成的。这些信息可以用作替代文字、标题或标签建议,但需要注意的是,它们其实包含了OCR识别过程中可能遗漏的内容,因此应该将它们视为草稿而非最终结果。当模型的输出无法被解析为JSON格式时,rawResponse字段会保存未经过解析的原始文本,这样就不会有任何数据丢失。

对于那些构建失败的截图来说,模型块显示的内容可能会是这样的:

{
  "model": {
    "summary": "这张截图似乎显示了软件构建失败的情况。",
    "description": "一个开发者工具窗口显示出了错误状态,并附带了诊断信息。",
    "suggestedTags": ["screenshot", "developer-tool", "error"],
    "possibleUses": [
      "生成替代文本",
      "总结截图内容",
      "提取文档数据"
    ]
  }
}

这种组合方式——精确的文本信息以及模型提供的可读性解释——足以让我们基于一个由detectedTextsuggestedTags索引的可搜索截图库,开发出各种实用功能。例如,可以为上传的图片自动生成替代文本,或者利用边界框实现点击高亮显示的功能。

由于这些提示信息存储在辅助程序中,因此改变返回的内容(比如从收据中提取具体信息,而不是对截图进行标记)只需要修改辅助程序中的代码,而无需对整体架构进行任何调整。

运行应用程序

首先启动辅助程序:

npm run companion

然后在另一个终端中启动Web应用:

npm run dev

打开Vite提供的URL地址:

http://127.0.0.1:5173

如果该端口已被占用,Vite会自动选择另一个端口。

辅助程序的访问地址应该是:

http://127.0.0.1:43119

你可以直接测试它:

curl http://127.0.0.1:43119/v1/health

预期的响应结果如下:

{
  "app": "Vision Bridge Companion",
  "ok": true,
  "support": {
    "foundationModelAvailable": true,
    "foundationModelStatus": "available",
    "visionAvailable": true
  },
  "version": "0.1.0"
}

辅助程序运行时的终端截图

结论

现在你已经拥有了一个React界面,它可以用来上传图片;同时还有一个Swift编写的辅助程序,它可以使用苹果原生的框架来分析这些图片;而结构化的数据则通过JSON在两者之间进行传输。

Vision Bridge的设计初衷是保持简洁性,但它的核心功能是可以被重复使用的。一旦你拥有了一个可靠的本地辅助程序,Web应用就不只是能够向远程模型发送请求了——它还可以让Mac利用本地的资源进行处理,使用任何苹果提供的框架,并返回结构化的数据,这些数据可以被浏览器渲染、存储或同步。

资源链接

Comments are closed.