不调多模态,纯文本大模型如何给系统操作配上截图

当大模型只会吐文字,怎么让它"看懂"你的系统,还能边回答边截出带红框标注的操作图。

一句话讲清楚:不用多模态模型,不用 Playwright 连浏览器,纯前端 + 一个文本大模型,就能让助手"边说边画"------文字流式回答操作步骤,同时截出当前系统页面的图,还给关键按钮画上红框。


01 痛点:模型有嘴没眼

给业务系统做智能问答,最朴素的想法是接个多模态大模型------让它看页面、识按钮、截图回答。但现实里这条路有三道坎:

  • 贵且慢。 多模态模型一次调用要喂图片、要视觉理解,延迟和成本都翻几倍。用户问一句"在哪发起审批",等 5 秒才出字,体验崩塌。
  • 看不懂你的系统。 通用模型不知道你系统里"待审批列表"长什么样、菜单叫什么。它只会编一套通用术语------"点击审批管理 → 我的审批",可你系统里根本没这个菜单。
  • 截不了目标页。 用户此刻在首页,问的是"工单详情"页在哪。模型又不能替你点导航。就算用 html2canvas,也只能截当前已渲染的 DOM,截不到没打开的页面。

所以我们换了个思路:把"看"这件事,从模型身上拆出来。 模型只管用文字回答(快、便宜、准),截图和标注交给前端自己干。两者并行,最后拼到一起。


02 整体架构:文字和图片,两条腿走路

ASCII 版本(不支持 Mermaid 的环境看这个):

sql 复制代码
用户提问:"在哪发起审批"
   │
   ├──► 请求 1:LLM 流式回答
   │      fetch SSE → 逐字返回菜单路径 + 步骤
   │      (模型只管说话,不碰截图)
   │
   ├──► 请求 2:前端自己截图
   │      关键词匹配菜单路由 → /approval/create
   │      隐藏 iframe 加载该路由 → html2canvas 截图
   │      按关键词在 DOM 里找按钮 → 画红框
   │
   ▼
  两条腿汇合 → 渲染「文字步骤 + 红框截图」卡片

流程逐步解读:

  1. 用户提问:触发整个流程。问题既是给 LLM 的输入,也是前端匹配菜单路由的关键词来源------一个问题,两处用。
  2. 请求 1(LLM 流式回答):问题 + 历史对话发给大模型,走 SSE 流式返回。模型只管用文字组织答案(菜单路径 + 操作步骤),完全不碰截图。这一路的特点是"逐字吐",前端边收边渲染,用户看到打字效果。
  3. 请求 2(前端自己截图) :这一路和 LLM 没关系,纯前端干。先用关键词在菜单表里匹配出目标路由(如 /approval/create),再开隐藏 iframe 加载该路由,等表格渲染完用 html2canvas 截图,最后按关键词在 DOM 里找按钮画红框。
  4. 两条腿汇合:两路并行跑,谁先完成谁先就绪。文字流式到头 + 截图返回,拼成一张"文字步骤 + 红框截图"的卡片展示给用户。

关键点在"并行"。LLM 流式吐字的那一两秒里,截图请求同时在跑。等文字答完,图通常也截好了,一起展示。用户感觉是"瞬间"得到的,没有干等。

而模型之所以能答准菜单路径,不是靠它自己懂,是靠我们把系统的真实菜单结构喂给了它


03 让模型懂你的系统:菜单即知识库

模型编造通用术语,根因是它不知道你系统的真实结构。解法很直接------把当前用户可见的菜单树,序列化成文本,塞进 system prompt

typescript 复制代码
// 遍历菜单,拍平成「菜单路径 → 路由」的文本
function serializeMenu(menus, parent = '') {
  return menus
    .filter(m => !m.hideInMenu)
    .map(m => {
      const full = parent ? `${parent} > ${m.name}` : m.name;
      const kids = m.routes?.map(c => serializeMenu(m.routes, full)).join('\n');
      return `- ${full}(路径: ${m.path})\n${kids || ''}`;
    }).join('\n');
}

这样模型拿到的 system prompt 里就有一段真实结构:

javascript 复制代码
- 审批中心(路径: /approval)
  - 发起审批(路径: /approval/create)
  - 待我审批(路径: /approval/pending)
  - 审批历史(路径: /approval/history)

模型再被问"在哪发起审批",就能精确回答"菜单:审批中心 > 发起审批",而不是编一个"审批管理"。知识库不是硬约束,是辅助参考------问业务概念(两种审批流程的区别)时,模型照样可以用自身知识自由发挥;问菜单位置时,才以真实菜单为准。

踩坑:菜单数据从哪取 菜单在 Redux store 里(store.user.menu)。注意是 user.menu 不是 menu.menu------少一层就取空。顶层 import { store } from 'store' 即可,不会循环依赖。


04 SSE 流式:不用第三方库,原生 fetch 就行

大模型流式输出走 SSE(Server-Sent Events)。很多人第一反应是引 @microsoft/fetch-event-source,其实没必要------原生 fetch + ReadableStream 手动解析,200 行搞定,还少一个依赖。

OpenAI 兼容接口的 SSE 格式很简单:每个事件是一段 data: {json}\n\n,最后以 data: [DONE] 结尾。按 \n\n 切块、提取 data: 行、JSON 解析、取 delta.content 就完事。

typescript 复制代码
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';

for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });

  // SSE 事件以 \n\n 分隔,切出完整事件
  let idx;
  while ((idx = buffer.indexOf('\n\n')) !== -1) {
    const raw = buffer.slice(0, idx);
    buffer = buffer.slice(idx + 2);
    const content = parseSSEEvent(raw);  // 提取 delta.content
    if (content === null) onDone();      // [DONE]
    else if (content) onChunk({ content });
  }
}

踩坑:chunk 边界会切断 data 行 一个 SSE 事件可能被网络拆成两段到达。如果直接按行解析,切断的那行会丢。所以用 buffer 累积,只在遇到完整的 \n\n(事件分隔符)时才处理一个事件,剩余的不完整部分留给下一轮。这是手写 SSE 解析最容易错的点。
踩坑:CORS 让浏览器直连 LLM 端点失败 内部 LLM 代理默认不返回 Access-Control-Allow-Origin,浏览器 fetch 直接报 "Failed to fetch"。解法:在 Vite dev server 配 proxy,前端请求同源的 /copilot-api/chat/completions,由 proxy 转发到真实端点并注入 Authorization 头------顺带把 key 也藏出前端 bundle。


05 截图方案演进:从 Playwright 到 html2canvas

截图这事我们试了三条路,前两条都栽了,最后一条最轻。选型决策过程:

方案 A:Playwright + CDP 连用户浏览器(最先被淘汰)

听起来最帅------用 Playwright 的 connectOverCDP 连上用户已登录的浏览器,直接操作真实会话截图。一开始以为这是最佳方案,结果踩了一串坑,最终放弃:

  • 浏览器兼容性是硬伤。 这是最大的坑。Playwright 的 connectOverCDP 连 Chrome 可以,但连 Edge 时直接报错 Browser context management is not supported------Edge 的 CDP 实现和 Playwright 的 context 管理机制不兼容,连接建立后第一步调用 setDownloadBehavior 就失败。用户用什么浏览器不可控,不可能要求"必须用 Chrome",这条路在生产环境走不通。
  • 要常驻 Node 服务。 Playwright 是 Node 库,浏览器端调不了,得起一个本地进程做 HTTP 转发。开发环境多一个要启动的进程,部署也复杂。
  • 登录态管理割裂。 首次要弹一个 Playwright 自带的 chromium 窗口手动登录、存 storageState,和用户日常浏览器是两套会话,体验断层。

一句话:CDP 方案对浏览器有要求,但用户用什么浏览器你管不了。 这条死路直接把方案 A 否了。

方案 B:html2canvas 截当前页

项目本来就有 html2canvas 依赖,纯前端,任何浏览器都支持。但它有个硬伤:只能截当前已渲染的 DOM。用户在首页问"工单详情页在哪",那页根本没渲染,截不了。

方案 C(最终采纳):html2canvas + 隐藏 iframe

把 B 的短板补上------开一个隐藏 iframe,加载目标路由,再截 iframe 里的 DOM 。同源带 cookie,登录态天然在;不依赖任何外部进程;任何浏览器通吃。最关键的是:和方案 A 相比,它对浏览器零要求,Chrome、Edge、Firefox 都一样跑,因为本质就是前端 DOM 操作。

typescript 复制代码
const ifr = document.createElement('iframe');
ifr.style.cssText = 'position:fixed;left:-99999px;width:1440px;height:900px;';
document.body.appendChild(ifr);

ifr.src = `${origin}${route}`;          // /approval/create
await new Promise(r => { ifr.onload = r; });
await sleep(2000);                        // 等表格渲染

const canvas = await html2canvas(ifr.contentDocument.body, {
  windowWidth: 1440, windowHeight: 900,
});

踩坑:红框坐标算错 iframe 藏在 left:-99999px,元素的 getBoundingClientRect() 返回的视口坐标全是负的偏移。直接拿这个坐标在 canvas 上画框,会画到画布外。要先减去 iframe body 自身的视口偏移,转成文档坐标,才对得上 html2canvas 的 canvas 坐标系。


06 红框标注:不靠模型画,靠关键词匹配

截图有了,怎么给"发起审批"这个按钮画红框?又不想引多模态让模型识别。办法是:从用户问题里提关键词,在 iframe DOM 里按文本匹配元素,拿坐标后在 canvas 上画框

typescript 复制代码
// 用户问"在哪发起审批" → 关键词["发起审批", "起审批", ...]
for (const el of doc.querySelectorAll('a, button, .ant-menu-item, span')) {
  if (el.textContent.includes(kw)) {
    const r = el.getBoundingClientRect();
    rects.push({ x: r.left - baseX, y: r.top - baseY, w: r.width, h: r.height });
  }
}
// canvas 上画框:只 stroke,不 fill,避免底色遮挡
ctx.strokeStyle = '#ff4d4f';
ctx.lineWidth = 3;
rects.forEach(r => ctx.strokeRect(r.x, r.y, r.w, r.h));

两个细节决定成败:

  • 去重防叠框。 菜单项 <li> 和里面 <span> 文本都含"发起审批",会画两个叠框。按面积排序、坐标相近的视为重叠只留一个。
  • 只画线不填色。 早期版本加了半透明红填充,看起来像高亮笔涂的,脏。改成只 strokeRect 画红框线,干净利落。

07 空气泡 bug:懒创建 assistant 消息

一个小但烦人的 bug:用户发问后,回答还没出来,界面上先冒出一个空气泡。根因是发送时 提前 push 了一条空的 assistant 消息 准备接收流式------可流式还没到,空气泡就先显示了。

解法是懒创建appendChunk 时检查最后一条是不是 assistant,不是的话才 push 新气泡。首个文字到了才建气泡,没收到就不建。"思考中..."的提示则只在 loading 且最后一条还是 user 消息时显示。

typescript 复制代码
appendChunk: (content) => set((state) => {
  const msgs = [...state.messages];
  const last = msgs[msgs.length - 1];
  if (last?.role === 'assistant') {
    // 已有 assistant 气泡,追加
    msgs[msgs.length - 1] = { ...last, content: last.content + content };
  } else {
    // 首个 chunk 才创建,不提前建空气泡
    msgs.push({ role: 'assistant', content });
  }
  return { messages: msgs };
}),

08 别让格式约束反吃模型

最后说一个 prompt 层面的坑。我们希望模型输出纯文本(不用 Markdown 表格,因为前端不渲染 Markdown),在 system prompt 里写了"禁止使用表格、加粗"。但有些模型对纯指令服从性一般,仍会偷偷用表格。

两个加固手段:

  • 给正确格式示例。 不只说"禁止表格",还要给"对比类该长什么样"的范例------编号分点 1. 方式 A:... 2. 方式 B:...。模型学示例比学规则靠谱。
  • 缩 history 到最近 2 条。 历史对话里如果有过带表格的回答,模型会模仿旧风格。砍掉远的历史,减少风格污染。

踩坑:模板字符串里的反引号 system prompt 是模板字符串,里面如果写 代码块 的反引号示例,反引号会提前结束字符串,swc 直接报语法错误 Expected ';', got 'ident'。prompt 里描述格式时别用反引号,用纯文字说"反引号"。


09 总结:拆开"看"和"说"

这套方案的核心思想就一句:把多模态模型干的事,拆成两件便宜的活

  • 交给文本模型------快、便宜、准。给它喂真实菜单当知识库,它就能答准路径,不编造。
  • 交给前端自己------html2canvas 截 iframe、关键词匹配画红框,不依赖任何外部进程,任何浏览器通吃。
  • 两者并行,用户感觉是瞬间得到的。

没有多模态,没有 Playwright,没有 CDP,没有常驻 Node 服务。一个文本大模型 + 几百行前端代码,就让纯文字助手"长出了眼睛"。

本文基于一个业务系统的智能助手实现,业务细节已脱敏替换为通用示例。 核心技术:SSE 流式解析 · html2canvas + iframe 截图 · 关键词匹配红框标注 · 菜单知识库注入

相关推荐
DigitalOcean3 小时前
AI Agent如何降本?从五层技术栈到三种推理服务
llm·agent
hunterandroid4 小时前
HarmonyOS WebSocket 实战:断线重连、心跳保活与连接状态机设计
前端
hunterandroid4 小时前
StateFlow 与 SharedFlow 的边界:状态与事件的正确建模
android·前端
心念科技4 小时前
1、搜索表单 xnSearch(基于:心念后台,后端 Java 21 + Spring Boot 4 + Spring Cloud,前端提供 ReactVue3+TS、Vue3+JS、Vue2+JS
前端
计算机魔术师5 小时前
Dwarkesh Patel 对 OpenAI/Hugging Face 事件的爆款解读被指危险误导
前端
百工蜂Agent5 小时前
Claude Code 整体架构与设计
llm·agent
自进化Agent智能体6 小时前
Hermes GitHub PR 审查 —— 自动化代码评审
前端
leeyi7 小时前
把软件装进不能上网的机房——一套建好了、还没上过战场的交付工程(第101篇)
docker·aigc·agent
涛涛ing7 小时前
OpenAI Astra 泄露:零样本生成 3D 网页,前端开发者慌了吗?
前端