普通视图

发现新文章,点击刷新页面。
昨天以前首页

从SSE到打字机——AI场景下前端的实现逻辑与实践

2025年7月1日 19:34

随着Deepseek的横空出世,让每个人都有了构建自己AI知识库的机会,作为一个前端开发者,完全可以通过大模型构建自己的日常学习知识库,然后自己写一个AI的交互页面构建自己的 ChatGPT ,当然说到这,肯定有人说现在有一键构建的开源项目为什么不用呢,说白了技术还是要自己实现才能更加深入地理解,并且更加灵活地运用到日常学习或者实际业务场景中去。

本篇文章只从前端的角度出发,分析实现一个AI的交互页面能用到哪些技术,最后再去实现一个AI场景页面。

当然,你也可以点击这里直接查看本篇文章实现的页面。

如果打不开,这里还有贴心国内服务器的备用链接

PS:上面两个演示链接都是用 vuepress 实现的个人博客,感觉用这套框架实现自定义组件里面的坑还挺多了,有机会可以再写一篇关于 vuepress 的开发避坑文章。

当然,关于IM的交互逻辑在我之前的文章 【从零开始实现一个腾讯IM即时通讯组件(无UI设计方案)~】中已经详细描述了实现过程,所以,这篇文章就从已经实现了IM交互的页面基础上开始实现AI场景下的IM。

技术选型

涉及到AI场景必然会联想到打字机效果的流式输出文本,那么前端实现这种效果有哪些方式呢?

协议对比

首先最简单的,通过轮询接口不断获取数据,其次通过websocket不断获取监听到的数据,最后通过服务端消息推送获取数据。这三种思路对应着三种通讯协议:HTTP、WebSocket、SSE。

先对比一下这三种协议:

基本概念与通信模式

特性 HTTP SSE (Server-Sent Events) WebSocket
协议类型 无状态的请求 - 响应协议 基于 HTTP 的单向事件流协议 基于 TCP 的全双工实时协议
通信方向 客户端→服务器(单向) 服务器→客户端(单向) 双向(全双工)
连接特性 短连接(每次请求新建连接) 长连接(单次请求,持续响应) 长连接(一次握手,持续通信)
发起方式 客户端主动请求 客户端主动请求,服务器持续推送 客户端发起握手,后续双向通信
典型场景 静态资源请求、API 调用 实时通知、股票行情、新闻推送 实时聊天、在线游戏、协作工具

技术细节对比

特性 HTTP SSE WebSocket
协议基础 HTTP/1.1 或 HTTP/2 HTTP/1.1 或 HTTP/2 WebSocket 协议 (RFC 6455)
端口 80 (HTTP) / 443 (HTTPS) 80/443 80 (ws) / 443 (wss)
数据格式 文本、JSON、二进制等 纯文本(text/event-stream) 文本或二进制(帧格式)
二进制支持 支持,但需额外处理 不支持(需编码为文本) 原生支持
自动重连 否(需客户端实现) 是(内置机制) 否(需手动实现)
心跳机制 否(需轮询) 否(需自定义) 是(Ping/Pong 帧)
浏览器兼容性 全兼容 现代浏览器(IE 不支持) 现代浏览器(IE 10+)

性能与效率

特性 HTTP SSE WebSocket
连接开销 高(每次请求需重新建立连接) 中(一次连接,长期保持) 低(一次握手,持续通信)
协议 overhead 高(HTTP 头信息冗余) 低(仅初始头) 中(帧头开销较小)
实时性 低(依赖客户端轮询) 高(服务器主动推送) 极高(双向实时)
带宽利用率 低(轮询导致无效请求) 中(单向持续传输) 高(按需双向传输)
延迟 高(请求响应周期) 中(推送延迟) 低(长连接直接通信)

API选择

再来回看一下我们的需求,AI场景说白了一问一答的方式,那么我们希望发送一次请求后,能够持续获取数据,本次请求后端也只需要知道我的问题即可,不需要和前端进行其他交互,所以 SSE 在这种场景下的优势就显而易见了。

前端要在浏览器中实现 SSE 的方式有两种:

  • EventSource API
  • fetch API

EventSourcefetch 都是现代 Web 开发中用于与服务器通信的 API。

特性 EventSource (SSE) Fetch API
通信模式 单向(服务器→客户端) 双向(请求→响应)
连接特性 长连接(持续接收服务器推送) 短连接(每次请求新建连接)
数据流类型 事件流(持续不断) 一次性响应(请求完成即结束)
数据格式 文本(事件流格式) 任意(JSON、Blob、文本等)
自动重连 内置支持(自动重连机制) 需手动实现

EventSource API实现了 SSE 。换句话说 EventSource API是 Web 内容与服务器发送事件通信的接口。一个EventSource 实例会对HTTP服务器开启一个持久化的连接,以 text/event-stream 格式发送事件,此连接会一直保持开启直到通过调用 EventSource.close() 关闭。

但是它有一些限制:

  • 无法传递请求体 request body ,必须将执行请求所需的所有信息编码到 URL 中,而大多数浏览器对 URL 的长度限制为 2000 个字符。
  • 无法传递自定义请求头。
  • 只能进行 GET 请求,无法指定其他方法。
  • 如果连接中断,无法控制重试策略,浏览器会自动进行几次尝试然后停止。

而AI场景常常会有一些其他需求,如上文记忆、接口 token 验证等等,于是 fetch 成了我们的最佳选择。

fetch API可以通过设置 headers 支持流式数据的接收,然后通过 ReadableStreamDefaultReader 对象,逐块读取响应的数据。

大模型选择

作为前端开发我们更注重于模型的定制化配置和页面的展示效果与交互,通过第三方模型可以快速满足我们的需求,这里我选用的是阿里云百炼

它直接提供了支持流式输出的接口,只需要在请求头加上 X-DashScope-SSE:true 。比较坑的是阿里云文档里面只提供了 node 的写法,实际浏览器中 axios 并不支持流式传输。

image-20250621145616487

API解析

AbortController

前面我们说到 SSE 的数据传输是单向的,有时候我们会想中断推送信息的接收,实际需求就是中断AI当前回答,所以我们需要一个控制器来更加精细地控制我们的请求。

AbortController 对象的作用是对一个或多个 Web 请求进行中止操作,像 fetch 请求、ReadableStream 以及第三方库的操作都可以取消。

核心机制:借助 AbortSignal 来实现操作的中止。AbortController 会生成一个信号对象,该对象可被传递给请求,当调用 abort() 方法时,就会触发请求的取消操作。

有了这个API我们就可以实现中断回答按钮的实现。

const controller = new AbortController()
const response = await fetch(
  url: 'url',
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer token`,
      'X-DashScope-SSE': 'enable', // 允许实时推送
    },
    signal: controller.signal, // 信号对象绑定请求
    body: "{...}",
  }
)

setTimeout(()=> controller.abort(), 1000) // 一秒钟后自动中断请求

Reader

在请求发出后,我们需要一个能持续获取推送信息的入口,fetchresponse.body.getReaderJavaScript 中用于处理 fetch 请求响应流的方法,它允许你以可控的方式逐块读取响应数据,而非一次性处理整个响应。这在处理大文件下载、实时数据流(如视频、SSE)或需要渐进式解析数据的场景中特别有用。

// 获取一个 ReadableStreamDefaultReader 对象,用于逐块读取响应的二进制数据(Uint8Array)。
const reader = response.body.getReader()

while (true) {
  // 读取数据块 流是一次性的,读取完成后无法再次读取
const {done, value} = await reader.read();
  if (done) {
    console.log('流读取完成');
    break;
  }
}

循环调用 read() 以达到获取完整数据的需求,根据 done 判断是否已经读取完毕。

TextDecoder

TextDecoderJavaScript 中用于将二进制数据(如 ArrayBufferUint8Array)解码为人类可读的文字字符串的内置对象。它支持多种字符编码(如 UTF-8ISO-8859-1GBK 等),是处理网络响应、文件读取等二进制数据转换的标准工具。

// 任意二进制数据
const value = ...

// 流式解码:支持分块处理二进制数据(通过多次调用 decode 方法)。
const decoder = new TextDecoder('UTF-8')
// 解码二进制数据为文本
const chunk = decoder.decode(value, { stream: true })

值得注意的是 decodestream 参数设置为 true ,这是为了防止乱码的情况,因为我们知道 UTF-8 是一种变长编码,ASCII 字符(0-127)用 1 个字节表示,而其他字符(如中文、 emoji)可能用 2-4 个字节表示。例如:

  • 的 UTF-8 编码是 [228, 184, 150](3 个字节)。
  • 😊 的 UTF-8 编码是 [240, 159, 152, 138](4 个字节)。

当数据分块传输时,一个字符可能被截断在不同的块中。例如:

块1: [228, 184]    // "中" 的前两个字节(不完整)
块2: [150]         // "中" 的最后一个字节

stream 选项决定了解码器如何处理可能不完整的多字节字符:

stream 行为描述
false 默认值。假设输入是完整的,直接解码所有字节。若遇到不完整字符,会用 替换。
true 假设输入是数据流的一部分,保留未完成的多字节字符,等待后续数据。

实际情况可以参考下段代码:

// 错误情况
const decoder = new TextDecoder();
const chunk1 = new Uint8Array([228, 184]); // "中" 的前两个字节
const chunk2 = new Uint8Array([150]);      // "中" 的最后一个字节

console.log(decoder.decode(chunk1)); // 输出: "�"(错误:截断的字符被替换为乱码)
console.log(decoder.decode(chunk2)); // 输出: "�"(错误:单独的第三个字节无法组成有效字符)

// 正确情况

const decoder = new TextDecoder();
const chunk1 = new Uint8Array([228, 184]); // "中" 的前两个字节
const chunk2 = new Uint8Array([150]);      // "中" 的最后一个字节

console.log(decoder.decode(chunk1, { stream: true })); // 输出: ""(无输出,保留未完成字符)
console.log(decoder.decode(chunk2));                   // 输出: "中"(合并后正确解码)

处理流式输出

结合上述API的分析,fetch 实现处理流式数据的代码如下:

const controller = new AbortController()

const response = await fetch(
  url,
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer sk-dd0e8892eb0445149fd21fd9b1d6176c`,
      'X-DashScope-SSE': 'enable',
    },
    signal: controller.signal,
    body: JSON.stringify({
      input: {
        prompt: text
      },
      parameters: {
        'incremental_output' : 'true' // 增量输出
      },
    }),
  }
)
if (!response.ok) {
  message.error('AI返回错误')
  loadingSend.value = false
  return
}

const decoder = new TextDecoder('UTF-8')
const reader = response.body.getReader()

while (true) {
  const {done, value} = await reader.read();

  if (done) {
    console.log('流读取完成');
    // 中断fetch请求
    controller.abort()
    // 资源释放:释放读取器锁
    reader.releaseLock()
    break;
  }

  // 解码二进制数据为文本
  const chunk = decoder.decode(value, { stream: true })
  console.log('chunk:===>', chunk)
}

处理流式数据

通过 reader 读取到的数据经过 decoder 处理后格式如下:

id:1
event:result
:HTTP_STATUS/200
data:{"output":{"session_id":"0837b503363c4525a6609f868f3f6afa","finish_reason":"null","text":"我是","reject_status":false},"usage":{"models":[{"input_tokens":370,"output_tokens":1,"model_id":"deepseek-v3"}]},"request_id":"ecea2ce7-3867-9074-aa67-92b39ba9253a"}

id:2
event:result
:HTTP_STATUS/200
data:{"output":{"session_id":"0837b503363c4525a6609f868f3f6afa","finish_reason":"null","text":"你的","reject_status":false},"usage":{"models":[{"input_tokens":370,"output_tokens":2,"model_id":"deepseek-v3"}]},"request_id":"ecea2ce7-3867-9074-aa67-92b39ba9253a"}

当然这个是阿里云的返回格式,但流式数据格式都大差不差,接下来我们来分析这段文本。

首先,reader 获取的数据可能会有多段,如上文中的就是 id:1id:2 两段数据。

其中关键字段为:data.output.text ,所以我们需要根据返回数据的结构特点通过正则把有效信息给过滤出来。

// 全局贪婪匹配 "text":" 到 ","reject_status": 之间的内容,确保多段数据也能准确提取所有的有效信息
const regex = /"text":"(.*?)","reject_status":/gs;

这里使用正则而不是 JSON 化的原因是流式数据的处理讲究高效与准确JSON 化更加地消耗性能,而且存在异常报错的可能,为了最大可能保证主流程的持续输出,用正则是更优的选择。当然具体业务场景具体处理,这里仅作个人见解。

根据上述正则,实现一个数据处理函数:

const extractText = (jsonString) => {
  try {
    const regex = /"text":"(.*?)","reject_status":/gs;
    let match;
    let result = '';
    // 利用regex.exec()在字符串里循环查找所有匹配结果,把每次匹配得到的捕获组内容(也就是text字段的值)添加到result字符串中。
    while ((match = regex.exec(jsonString)) !== null) {
      // 将字符串里的\n转义序列转换为真正的换行符,把\"转义序列还原为普通的双引号。
      result += match[1].replace(/\\n/g, '\n').replace(/\\"/g, '"');
    }
    return result
  } catch (error) {
    console.log('error', error)
    return ''
  }
}

最后把数据处理函数加到流式输出代码中,通过缓存持续获取有用的信息:

...
// 用于累计接收到的数据
let accumulatedText = ''

while (true) {
  const {done, value} = await reader.read();

  if (done) {
    ...
    break;
  }

  const chunk = decoder.decode(value, { stream: true })
  // 累加并渲染数据
  const newText = extractText(chunk)
  if (newText) {
  accumulatedText += newText
  }
}

转换MD文本

这里用到几个库来实现:

  • markdown-it 一个快速、功能丰富的 Markdown 解析器,基于 JavaScript 实现。它的主要作用是把 Markdown 文本转换成 HTML。
  • @vscode/markdown-it-katex VS Code 团队开发的插件,用于在 Markdown 中渲染 LaTeX 数学公式,它集成了 KaTeX 这个快速的数学公式渲染引擎。
  • markdown-it-link-attributes 为 Markdown 中的链接添加自定义属性,比如为外部链接添加target="_blank"rel="noopener noreferrer"属性。
  • mermaid-it-markdown 用于在 Markdown 中集成 Mermaid 图表,Mermaid 是一种用文本语法描述图表的工具。

三方库使用

结合上述各种库结合,处理接口返回的信息流:

import MarkdownIt from 'markdown-it'
import MdKatex from '@vscode/markdown-it-katex'
import MdLinkAttributes from 'markdown-it-link-attributes'
import MdMermaid from 'mermaid-it-markdown'
import hljs from 'highlight.js'

const mdi = new MarkdownIt({
  html: false,
  linkify: true,
  highlight(code, language) {
    const validLang = !!(language && hljs.getLanguage(language))
    if (validLang) {
      const lang = language ?? ''
      return highlightBlock(hljs.highlight(code, { language: lang }).value, lang)
    }
    return highlightBlock(hljs.highlightAuto(code).value, '')
  },
})
mdi.use(MdLinkAttributes, { attrs: { target: '_blank', rel: 'noopener' } }).use(MdKatex).use(MdMermaid)

// 实现代码块快速复制
function highlightBlock(str, lang) {
  return `<pre class="code-block-wrapper">
            <div class="code-block-header">
                <span class="code-block-header__lang">${lang}</span>
                <span class="code-block-header__copy">复制代码</span>
            </div>
            <code class="hljs code-block-body ${lang}"><br>${str}</code>
          </pre>`
}

const renderToAI = (text) => {
  // 对数学公式进行处理,自动添加 $$ 符号
  const escapedText = escapeBrackets(escapeDollarNumber(text))
  return mdi.render(escapedText)
}

const escapeBrackets = (text) => {
  const pattern = /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\]|\\\((.*?)\\\)/g
  return text.replace(pattern, (match, codeBlock, squareBracket, roundBracket) => {
    if (codeBlock)
      return codeBlock
    else if (squareBracket)
      return `$$${squareBracket}$$`
    else if (roundBracket)
      return `$${roundBracket}$`
    return match
  })
}

const escapeDollarNumber = (text) => {
  let escapedText = ''

  for (let i = 0; i < text.length; i += 1) {
    let char = text[i]
    const nextChar = text[i + 1] || ' '

    if (char === '$' && nextChar >= '0' && nextChar <= '9')
      char = '\\$'

    escapedText += char
  }

  return escapedText
}

复制代码块

快速复制代码实现:

// 聊天列表主体元素
const textRef = ref()

// 构建textarea,将内容复制到剪切板
const copyToClip = (text) => {
  return new Promise((resolve, reject) => {
    try {
      const input = document.createElement('textarea')
      input.setAttribute('readonly', 'readonly')
      input.value = text
      document.body.appendChild(input)
      input.select()
      if (document.execCommand('copy'))
        document.execCommand('copy')
      document.body.removeChild(input)
      resolve(text)
    }
    catch (error) {
      reject(error)
    }
  })
}

// 为所有的复制代码按钮添加复制事件
const addCopyEvents = () => {
  if (textRef.value) {
    const copyBtn = textRef.value.querySelectorAll('.code-block-header__copy')
    copyBtn.forEach((btn) => {
      btn.addEventListener('click', () => {
        const code = btn.parentElement?.nextElementSibling?.textContent
        if (code) {
          copyToClip(code).then(() => {
            btn.textContent = '复制成功'
            setTimeout(() => {
              btn.textContent = '复制代码'
            }, 1000)
          })
        }
      })
    })
  }
}

// 移除页面中所有的复制事件
const removeCopyEvents = () => {
  if (textRef.value) {
    const copyBtn = textRef.value.querySelectorAll('.code-block-header__copy')
    copyBtn.forEach((btn) => {
      btn.removeEventListener('click', () => { })
    })
  }
}

// 在合适的生命周期里注册或卸载重新事件

// 可以在流式输出完成,页面渲染完成的时候手动调用,避免性能浪费,更加合理
onUpdated(() => {
  addCopyEvents()
})

onUnmounted(() => {
  removeCopyEvents()
})

自定义MD样式

MD样式:

.ai-message {
    background-color: transparent;
    font-size: 14px;
}
.ai-message p {
    white-space: pre-wrap;
}
.ai-message ol {
    list-style-type: decimal;
}
.ai-message ul {
    list-style-type: disc;
}
.ai-message pre code,
.ai-message pre tt {
    line-height: 1.65;
}
.ai-message .highlight pre,
.ai-message pre {
    background-color: #fff;
}
.ai-message code.hljs {
    padding: 0;
}
.ai-message .code-block-wrapper {
    position: relative;
    padding: 0 12px;
    border-radius: 8px;
}
.ai-message .code-block-header {
    position: absolute;
    top: 5px;
    right: 0;
    width: 100%;
    padding: 0 1rem;
    display: flex;
    justify-content: flex-end;
    align-items: center;
    color: #b3b3b3;
}
.ai-message .code-block-header__copy {
    cursor: pointer;
    margin-left: 0.5rem;
    user-select: none;
}
.ai-message .code-block-header__copy:hover {
    color: #65a665;
}
.ai-message div[id^='mermaid-container'] {
    padding: 4px;
    border-radius: 4px;
    overflow-x: auto !important;
    background-color: #fff;
    border: 1px solid #e5e5e5;
}
.ai-message li {
    margin-left: 16px;
    box-sizing: border-box;
}

最后,把处理函数追加到处理流式数据后面:

let mdHtml = ''

...
const chunk = decoder.decode(value, { stream: true })
const newText = extractText(chunk)
if (newText) {
  accumulatedText += newText
  mdHtml += renderToAI(accumulatedText)
}

打字机

到目前为止我们已经流式地拿到了接口返回的数据并且转换成了页面可以展示的MD风格HTML字符串。

打字机的基本思路就是按照一定频率把内容添加到页面上,并且在内容最前面加个打字的光标。

直接上代码:

<template>
  <div v-html="displayText + `${ showCursor || adding ? `<span class='cursors'>_</span>`:'' }`"></div>
</template>

<script setup>
import { ref, watch, onUnmounted } from 'vue';

const props = defineProps({
  // 要显示的完整文本
  text: {
    type: String,
    required: true
  },
  // 打字速度(毫秒/字符)
  speed: {
    type: Number,
    default: 10
  },
  showCursor: {
    type: Boolean,
    default: false
  },
  break: {
    type: Boolean,
    default: false
  },
});
const emits = defineEmits(['update', 'ok'])

const displayText = ref('');
const adding = ref(false);
let timer = null;

// 更新显示的文本
const updateDisplayText = () => {
  if (displayText.value.length < props.text.length) {
    adding.value = true;
    displayText.value = props.text.substring(0, displayText.value.length + 1);
    emits('update')
    timer = setTimeout(updateDisplayText, props.speed);
  } else {
    adding.value = false;
    setTimeout(() =>{
      emits('ok')
    } ,600)
  }
};

// 增量更新
watch(() => props.text, (newText) => {
  // 如果新文本比当前显示的文本长,则继续打字
  if (newText.length > displayText.value.length) {
    clearTimeout(timer);
    updateDisplayText();
  }
});

// 停止回答
watch(() => props.break, (val) => {
  if (val) {
    displayText.value = props.text + ''
    clearTimeout(timer);
    adding.value = false;
    setTimeout(() =>{
      emits('ok')
    } ,600)
  }
});

// 初始化
updateDisplayText();

// 组件卸载时清理定时器
onUnmounted(() => {
  clearTimeout(timer);
});
</script>

<style>

.cursors {
  font-weight: 700;
  vertical-align: baseline;
  animation: blink 1s infinite;
  color: #3a5ccc;
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}
</style>  

我们只需要把上述转换的MD文本传入这个组件就能实现打字机效果。

<temlate>
<div class="ai-message">
    <TypingEffect :text="text" :showCursor="!ready" :break="break" @update="updateAIText" @ok="textAllShow" />
  </div>
</temlate>

需要注意的是,打字机打印的速度是按照恒定速度执行的,流式数据是不规则时间返回的,有可能返回很快,也有可能返回很慢,所以两边就会有时间差。

这就造成了一种现象,有时候我们点了停止回答的按钮,页面上还在不断输出内容,好像没有打断这次回答,这里我们只需要在点击停止回答的时候终止打字机的轮询,直接展示完整数据即可。

最后优化显示,需要自动滚动到底部:

const scrollToBottom = () => {
  try {
    const { height } = textRef.value.getBoundingClientRect()
    textRef.value.scrollTo({
      top: textRef.value.scrollHeight - height,
      behavior: 'smooth',
    })
  } catch (e) {}
}

总结

前端AI场景下总结来说就两个平时不常见的技术点:

  • 流式输出
  • 请求中断

当然本篇文章只是实现了基本的AI场景,像上下文记忆、多对话框以及更大模型的微调等等并未涉及到,这些更加深入地功能,可以后面慢慢研究,那么,这次就到这里~

Flutter AI 工具包:集成 AI 聊天功能到 Flutter App

作者 Bowen_Jin
2025年6月29日 21:00

image.png

主要特点

  1. 多轮聊天:自动维护聊天历史,保持多轮交互的语义连贯性
  2. 流式响应渲染:实时逐字显示 AI 回复,提升交互体验
  3. 富文本显示:支持 Markdown 解析、代码块高亮、链接识别等
  4. 语音输入:使用语音输入prompt。
  5. 多媒体输入:支持发送图片、文件等附件,AI 可识别处理
  6. 自定义样式:提供自定义样式,以匹配App设计。
  7. 聊天序列化/反序列化:存储和检索App会话之间的对话。
  8. 自定义响应Widget:引入专门的UI组件来呈现LLM响应。
  9. 可插拔LLM支持:实现一个简单的界面来插入自定义LLM。
  10. 跨平台支持:兼容Android、iOS、Web和macOS平台。

demo效果

image.png

源代码可在GitHub上找到

安装

依赖项添加到pubspec.yaml文件中

dependencies:
  flutter_ai_toolkit: ^latest_version
  google_generative_ai: ^latest_version # 使用Gemini
  firebase_core: ^latest_version        # 使用Firebase Vertex AI

Gemini AI配置

要使用Google Gemini AI,请从Google Gemini AI Studio获取API密钥

还需要选择一个Gemini model。

import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart';

class ChatPage extends StatelessWidget {
  const ChatPage({super.key});

  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(title: const Text(App.title)),
        body: LlmChatView(
          provider: GeminiProvider( // Gemini 服务提供商
            model: GenerativeModel( // Gemini model
              model: 'gemini-2.0-flash',
              apiKey: 'GEMINI-API-KEY', // Gemini API Key
            ),
          ),
        ),
      );
}

GenerativeModel类来自google_generative_ai软件包。GeminiProvider将Gemini AI插入到LlmChatView,LlmChatView是顶级Widget,与您的用户提供基于LLM的聊天对话。

Vertex AI configuration

另外一个AI服务是Firebase的Vertex AI。不需要API密钥,并用更安全的Firebase取代它。要在项目中使用Vertex AI,请按照 Get started with the Gemini API using the Vertex AI in Firebase SDKs 中描述的步骤进行操作。

完成后,使用flutterfire CLI工具将新的Firebase项目集成到您的Flutter App中,如Add Firebase to your Flutter app文档中所述。

按照这些说明操作后,您就可以在Flutter App中使用Firebase Vertex AI了。

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_vertexai/firebase_vertexai.dart';
import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart';

// ... other imports

import 'firebase_options.dart'; // from `flutterfire config`

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  runApp(const App());
}

在Flutter App中正确初始化Firebase后,可以创建Vertex provider的实例了:

class ChatPage extends StatelessWidget {
  const ChatPage({super.key});

  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(title: const Text(App.title)),
        // create the chat view, passing in the Vertex provider
        body: LlmChatView(
          provider: VertexProvider(
            chatModel: FirebaseVertexAI.instance.generativeModel(
              model: 'gemini-2.0-flash',
            ),
          ),
        ),
      );
}

FirebaseVertexAI类来自firebase_vertex ai软件包。构建VertexProvider类,将Vertex AI暴露给LlmChatView。不需要提供API密钥。这些都作为Firebase项目自动处理了。

LlmChatView

LlmChatView Widget 是AI Toolkit提供的互动聊天组件。支持如下功能

  • 多行文本输入:允许用户在输入prompt时粘贴长文本或插入新行。
  • 语音输入:允许用户使用语音输入prompt
  • 多媒体输入:允许用户拍照和发送图像和其他文件类型prompt。
  • 图像缩放:允许用户放大图像缩略图。
  • 复制到剪贴板:允许用户将消息或LLM响应的文本复制到剪贴板。
  • 消息编辑:允许用户编辑最新的消息以重新提交到LLM。
  • 支持Material 和 Cupertino两种设计样式

多行文本输入

语音输入

多媒体输入

图片缩放

点击能缩放图片

复制到剪贴板

文字编辑

长按文字, 弹出编辑菜单

支持Material and Cupertino两种设计样式

额外的功能

  • 欢迎信息:向用户显示初始问候。
  • prompt建议:向用户提供预定义的提建议prompt,以引导互动。
  • 系统指令:让 AI 系统明确 “做什么”“如何做” 以及 “在什么条件下执行”,类似于给 AI 系统下达的 “任务说明书” 或 “操作指南”。
  • 管理历史记录:每个LLM Provider都允许管理聊天记录,用于清除、动态更改和在会话之间存储聊天状态。
  • 聊天序列化/反序列化:存储和检索App会话之间的对话。
  • 自定义响应Widget:引入专门的UI组件来呈现LLM响应。
  • 自定义样式:定义独特的视觉样式,以将聊天外观与整个App相匹配。
  • 自定义LLM Provider:构建自定义LLM Provider,将聊天与您自己的模型后端集成。
  • 重新路由提示:调试、记录或重新路由消息,旨在让Provider动态跟踪问题或路由提示。

欢迎信息

自定义欢迎消息

class ChatPage extends StatelessWidget {
 const ChatPage({super.key});

 @override
 Widget build(BuildContext context) => Scaffold(
       appBar: AppBar(title: const Text(App.title)),
       body: LlmChatView(
         welcomeMessage: 'Hello and welcome to the Flutter AI Toolkit!', //初始化LlmChatView的欢迎消息:
         provider: GeminiProvider(
           model: GenerativeModel(
             model: 'gemini-2.0-flash',
             apiKey: geminiApiKey,
           ),
         ),
       ),
     );
}

prompt建议

没有聊天记录时,提供一组建议的prompt

class ChatPage extends StatelessWidget {
 const ChatPage({super.key});

 @override
 Widget build(BuildContext context) => Scaffold(
       appBar: AppBar(title: const Text(App.title)),
       body: LlmChatView(
         suggestions: [
           'I\'m a Star Wars fan. What should I wear for Halloween?',
           'I\'m allergic to peanuts. What candy should I avoid at Halloween?',
           'What\'s the difference between a pumpkin and a squash?',
         ], /// 建议列表
         provider: GeminiProvider(
           model: GenerativeModel(
             model: 'gemini-2.0-flash',
             apiKey: geminiApiKey,
           ),
         ),
       ),
     );
}

系统指令

让 AI 明确 “做什么”“如何做” 以及 “在什么条件下执行”,类似于给 AI 系统下达的 “任务说明书” 或 “操作指南”。

例如,食谱示例App使用systemInstructions参数来定制LLM,以专注于根据用户的说明提供食谱:

class _HomePageState extends State<HomePage> {
  ...
  // create a new provider with the given history and the current settings
  LlmProvider _createProvider([List<ChatMessage>? history]) => GeminiProvider(
      history: history,
        ...,
        model: GenerativeModel(
          model: 'gemini-2.0-flash',
          apiKey: geminiApiKey,
          ...,
          systemInstruction: Content.system('''
You are a helpful assistant that generates recipes based on the ingredients and 
instructions provided as well as my food preferences, which are as follows:
${Settings.foodPreferences.isEmpty ? 'I don\'t have any food preferences' : Settings.foodPreferences}

You should keep things casual and friendly. You may generate multiple recipes in a single response, but only if asked. ...
''', /// 系统指令
          ),
        ),
      );
  ...
}

历史记录管理

访问history属性查看或设置历史记录:

void _clearHistory() => _provider.history = [];

使用旧的历史来创建新的Provider:

class _HomePageState extends State<HomePage> {
  ...
  void _onSettingsSave() => setState(() {
        // 迁移旧的历史记录到新的供应商
        final history = _provider.history.toList();
        _provider = _createProvider(history);
      });
}

_createProvider方法创建了一个具有上一个Provider历史记录和新用户首选项的新Provider。这对用户来说是无缝的;他们可以继续聊天,但现在LLM会考虑他们的新食物偏好,给他们回复

class _HomePageState extends State<HomePage> {
  ...
  // 根据给定的历史记录和当前设置创建一个新的提供者
  LlmProvider _createProvider([List<ChatMessage>? history]) =>
    GeminiProvider(
      history: history,
      ...
    );
  ...
}

Chat序列化/反序列化

要在App会话之间保存和恢复聊天记录,需要能够对每个用户prompt(包括附件)和每个 LLM 响应进行序列化和反序列化。 两种消息(用户prompt和LLM响应)都暴露在ChatMessage类中。 序列化可以通过使用每个ChatMessage实例的toJson方法来完成。

Future<void> _saveHistory() async {
  // 获取最新的历史
  final history = _provider.history.toList();

  // 保存历史消息
  for (var i = 0; i != history.length; ++i) {
    // 文件存在旧忽略
    final file = await _messageFile(i);
    if (file.existsSync()) continue;

    // 新消息保存到磁盘
    final map = history[i].toJson();
    final json = JsonEncoder.withIndent('  ').convert(map);
    await file.writeAsString(json);
  }
}

同样,要反序列化,使用ChatMessage fromJson方法:

Future<void> _loadHistory() async {
  // 从磁盘读取历史记录
  final history = <ChatMessage>[];
  for (var i = 0;; ++i) {
    final file = await _messageFile(i);
    if (!file.existsSync()) break;

    final map = jsonDecode(await file.readAsString());
    history.add(ChatMessage.fromJson(map));
  }

  /// 设置历史记录
  _provider.history = history;
}

自定义响应Widget

默认聊天视图显示的 LLM 响应格式为 Markdown。可以创建一个自定义Widget来显示您的App风格的样式:

设置LlmChatView的responseBuilder参数:

LlmChatView(
  provider: _provider,
  welcomeMessage: _welcomeMessage,
  responseBuilder: (context, response) => RecipeResponseView(
    response,
  ),
),
class RecipeResponseView extends StatelessWidget {
  const RecipeResponseView(this.response, {super.key});
  final String response;

  @override
  Widget build(BuildContext context) {
    final children = <Widget>[];
    String? finalText;

    // 收到LLM的回复后即时生成内容,因此目前无法得到完整的回复,添加一个按钮以便将食谱添加到列表中
    try {
      final map = jsonDecode(response);
      final recipesWithText = map['recipes'] as List<dynamic>;
      finalText = map['text'] as String?;

      for (final recipeWithText in recipesWithText) {
        // extract the text before the recipe
        final text = recipeWithText['text'] as String?;
        if (text != null && text.isNotEmpty) {
          children.add(MarkdownBody(data: text));
        }

        // 提取食谱
        final json = recipeWithText['recipe'] as Map<String, dynamic>;
        final recipe = Recipe.fromJson(json);
        children.add(const Gap(16));
        children.add(Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(recipe.title, style: Theme.of(context).textTheme.titleLarge),
            Text(recipe.description),
            RecipeContentView(recipe: recipe),
          ],
        ));

        // 添加按钮将食谱添加到列表中。
        children.add(const Gap(16));
        children.add(OutlinedButton(
          onPressed: () => RecipeRepository.addNewRecipe(recipe),
          child: const Text('Add Recipe'),
        ));
        children.add(const Gap(16));
      }
    } catch (e) {
      debugPrint('Error parsing response: $e');
    }

    ...

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: children,
    );
  }
}

自定义样式

使用LlmChatView构造函数的style参数来设置自己的样式,包括背景、文本字段、按钮、图标、建议等默认样式:

LlmChatView(
  provider: GeminiProvider(...),
  style: LlmChatViewStyle(...),
),

万圣节主题演示App

没有UI的聊天

不使用聊天视图也能访问Provider接口。

class _EditRecipePageState extends State<EditRecipePage> {
  ...
  final _provider = GeminiProvider(...);
  ...
  Future<void> _onMagic() async {
    final stream = _provider.sendMessageStream(
      'Generate a modified version of this recipe based on my food preferences: '
      '${_ingredientsController.text}\n\n${_instructionsController.text}',
    ); // 发送用户偏好食谱设置给llm provider
    var response = await stream.join(); // 获取llm推荐的响应
    final json = jsonDecode(response);

    try {
      final modifications = json['modifications'];
      final recipe = Recipe.fromJson(json['recipe']);

      if (!context.mounted) return;
      final accept = await showDialog<bool>( // 只使用了llm服务,没有使用聊天界面
        context: context,
        builder: (context) => AlertDialog(
          title: Text(recipe.title), // 推荐食谱标题
          content: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              const Text('Modifications:'),
              const Gap(16),
              Text(_wrapText(modifications)), /// 修改的内容
            ],
          ),
          actions: [
            TextButton(
              onPressed: () => context.pop(true),
              child: const Text('Accept'),
            ),
            TextButton(
              onPressed: () => context.pop(false),
              child: const Text('Reject'),
            ),
          ],
        ),
      );
      ...
    } catch (ex) {
      ...
      }
    }
  }
}

重新路由Prompt

设置LlmChatView messageSender来调试、记录或操作聊天视图和底层Provider之间的连接

class ChatPage extends StatelessWidget {
  final _provider = GeminiProvider(...);

  @override
  Widget build(BuildContext context) => Scaffold(
      appBar: AppBar(title: const Text(App.title)),
      body: LlmChatView(
        provider: _provider,
        messageSender: _logMessage,
      ),
    );

  Stream<String> _logMessage(
    String prompt, {
    required Iterable<Attachment> attachments,
  }) async* {
    // log the message and attachments
    debugPrint('# Sending Message');
    debugPrint('## Prompt\n$prompt');
    debugPrint('## Attachments\n${attachments.map((a) => a.toString())}');

    // 发送消息到provider
    final response = _provider.sendMessageStream(
      prompt,
      attachments: attachments,
    );

    // log response信息
    final text = await response.join();
    debugPrint('## Response\n$text');

    yield text;
  }
}

用于一些高级操作,如动态路由到Provider或检索增强生成(RAG)。

定制LLM Provider

abstract class LlmProvider implements Listenable {
  Stream<String> generateStream(String prompt, {Iterable<Attachment> attachments});
  Stream<String> sendMessageStream(String prompt, {Iterable<Attachment> attachments});
  Iterable<ChatMessage> get history;
  set history(Iterable<ChatMessage> history);
}

任何实现LlmProvider接口的都可以插入聊天视图, 可以是云或本地的

  1. 提供配置支持
  2. 处理历史
  3. 将消息和附件翻译成底层LLM
  4. 调用底层LLM

配置支持

class GeminiProvider extends LlmProvider ... {
  @immutable
  GeminiProvider({
    required GenerativeModel model,
    ...
  })  : _model = model,
        ...

  final GenerativeModel _model;
  ...
}

处理历史

历史记录是Provider的重要组成部分

Provider不仅需要允许直接操作历史记录,而且必须在更改时通知Listener。 为了支持序列化和更改Provider参数,必须支持保存历史记录作为构建过程的一部分。

class GeminiProvider extends LlmProvider with ChangeNotifier {
  @immutable
  GeminiProvider({
    required GenerativeModel model,
    Iterable<ChatMessage>? history,
    ...
  })  : _model = model,
        _history = history?.toList() ?? [],
        ... { ... }

  final GenerativeModel _model;
  final List<ChatMessage> _history;
  ...

  /// 设置对话历史记录并重新初始化聊天会话
  @override
  Stream<String> sendMessageStream(
    String prompt, {
    Iterable<Attachment> attachments = const [],
  }) async* {
    final userMessage = ChatMessage.user(prompt, attachments);
    final llmMessage = ChatMessage.llm();
    _history.addAll([userMessage, llmMessage]); /// 添加到历史记录

    final response = _generateStream(
      prompt: prompt,
      attachments: attachments,
      contentStreamGenerator: _chat!.sendMessageStream,
    );

    yield* response.map((chunk) {
      llmMessage.append(chunk);
      return chunk;
    });

    notifyListeners();
  }

  /// 获取当前的对话历史记录
  @override
  Iterable<ChatMessage> get history => _history;

  /// 设置对话历史记录并重新初始化聊天会话
  @override
  set history(Iterable<ChatMessage> history) {
    _history.clear();
    _history.addAll(history);
    _chat = _startChat(history);
    notifyListeners();
  }

  ...
}
import 'package:google_generative_ai/google_generative_ai.dart';
...

class GeminiProvider extends LlmProvider with ChangeNotifier {
  ...
  static Part _partFrom(Attachment attachment) => switch (attachment) {
        (final FileAttachment a) => DataPart(a.mimeType, a.bytes),
        (final LinkAttachment a) => FilePart(a.url),
      };

  static Content _contentFrom(ChatMessage message) => Content(
        message.origin.isUser ? 'user' : 'model',
        [
          TextPart(message.text ?? ''),
          ...message.attachments.map(_partFrom),
        ],
      );
}

调用LLM

class GeminiProvider extends LlmProvider with ChangeNotifier {
  ...

  @override
  Stream<String> generateStream(
    String prompt, {
    Iterable<Attachment> attachments = const [],
  }) =>
      _generateStream(
        prompt: prompt,
        attachments: attachments,
        contentStreamGenerator: (c) => _model.generateContentStream([c]),
      );

  @override
  Stream<String> sendMessageStream(
    String prompt, {
    Iterable<Attachment> attachments = const [],
  }) async* {
    final userMessage = ChatMessage.user(prompt, attachments);
    final llmMessage = ChatMessage.llm();
    _history.addAll([userMessage, llmMessage]);

    final response = _generateStream(
      prompt: prompt,
      attachments: attachments,
      contentStreamGenerator: _chat!.sendMessageStream,
    );

    yield* response.map((chunk) {
      llmMessage.append(chunk);
      return chunk;
    });

    notifyListeners();
  }

  Stream<String> _generateStream({
    required String prompt,
    required Iterable<Attachment> attachments,
    required Stream<GenerateContentResponse> Function(Content)
        contentStreamGenerator,
  }) async* {
    final content = Content('user', [
      TextPart(prompt),
      ...attachments.map(_partFrom),
    ]);

    final response = contentStreamGenerator(content);
    yield* response
        .map((chunk) => chunk.text)
        .where((text) => text != null)
        .cast<String>();
  }

  @override
  Iterable<ChatMessage> get history => _history;

  @override
  set history(Iterable<ChatMessage> history) {
    _history.clear();
    _history.addAll(history);
    _chat = _startChat(history);
    notifyListeners();
  }
}

最终的AI 聊天效果

新手必看,AI编程路上不可避免的Node管理

作者 李想AI
2025年6月27日 17:11

大家好,我是李想。

AI编程真的很火,但经常有朋友在问我Node是什么,经常看见运行这个项目需要Node环境,运行这个MCP又需要Node环境,问我怎么下载,下载哪个版本。

就比如新出的Gemini-cli,前置条件就是必须安装Node.js18的版本。

wechat_2025-06-27_151047_685.png

为了让小白更好的上手AI编程,今天我就给大家写一篇Node基础文章,并推荐一个Node的版本管理工具。

1.Node简介

那Node到底是什么?

wechat_2025-06-27_165300_332.png

官方介绍:Node.js 是一个利用高性能 V8 引擎在服务器端运行 JavaScript 的平台,其独特的事件驱动和非阻塞 I/O 设计使它成为构建高性能、可扩展网络应用的理想选择,并拥有极其丰富的 npm 生态系统支持。

其实不重要,你不需要掌握Node.js这门语言,只是因为有Node.js有着庞大的开源库生态系统 npm-它是世界上最大的软件注册中心,提供数百万个可重用的代码包(模块)

所以我们需要通过Node.js的 npm 去下载相关的依赖包(工具),这样我们才能更好的去接触AI编程,接触Github众多的项目。

2.下载安装

网址:nodejs.org/zh-cn

来到我们的官网,点击Install下载

图片.pngwechat_2025-06-27_165344_284.png

在这里你可以选择Node版本,你的系统,然后点击.msi开始下载

图片.pngwechat_2025-06-27_153704_231.png

下载完毕后运行msi文件,一直next。

图片.pngwechat_2025-06-27_153907_065.png

这里可以把Node安装到C盘以外的地方,然后一直next

wechat_2025-06-27_153957_706.png

这里点击Install就可以把Node安装到本地了。

图片.png

wechat_2025-06-27_154040_153.png

安装完毕后Win+R开启命令列,输入cmd打开终端。

图片.png

wechat_2025-06-27_154339_921.png

然后输入node -v和npm -v。

图片.pngwechat_2025-06-27_154520_986.png

成功显示版本信息就说明我们的Node安装成功了!

3.Node版本管理工具

给大家介绍了Node,实在有必要给大家说说Node的版本管理工具-nvm

网址:github.com/coreybutler…

wechat_2025-06-27_161112_751.png

为什么需要对Node版本进行管理呢?大家通过Node去下载项目依赖后会经常遇见项目启动报错,启动不起来。

很多时候就是因为Node版本的问题,因为有些项目他需要的Node版本可能是18。

有点项目又可能是22或者其他的,但是我们系统下载Node的版本只有一个,总不能跑一个相对就重新下一个Node版本吧。

这时候,Node版本管理工具nvm就很重要了,它可以下载多种node版本,在不同的项目中切换不同的Node版本,这样在下载项目的依赖就不会出错了!

网址:github.com/coreybutler…

来到我们的下载页面

图片.pngwechat_2025-06-27_161212_789.png

选择红框中的exe版本下载。在安装之前大家记得把之前下载的Node给卸载了,没安装过就不用管。

一直下一步,这里的路径记得不用使用中文。

图片.pngwechat_2025-06-27_161326_808.png

安装完毕后启动win+r,cmd启动命令输入nvm -v。

图片.png

wechat_2025-06-27_161600_008.png

成功显示就说明成功。

之后我们可以通过nvm install 来下载指定的Node版本,比如nvm install 18.20.7。

wechat_2025-06-27_162212_991.png

通过nvm list查看我们安装了哪些版本,比如我这里就显示了我下载了两个版本,*代表现在使用的是18.20.7版本。

图片.png

wechat_2025-06-27_162212_991.png

切换的时候同nvm use 23.0.0 就可以切换成功了。

图片.png

wechat_2025-06-27_162328_542.png

最后再附上命令一览表

命令 说明
nvm install <version> 安装指定版本的 Node.js
nvm install lts 安装最新的 LTS(长期支持)版本
nvm use <version> 切换使用指定的 Node.js 版本
nvm list 查看所有已安装的 Node.js 版本
nvm ls-remote 列出所有远程可用的 Node.js 版本
nvm uninstall <version> 卸载指定的 Node.js 版本
nvm alias default <version> 设置默认使用的 Node.js 版本
nvm current 显示当前正在使用的 Node.js 版本
nvm on 启用 nvm 版本管理功能
nvm off 禁用 nvm 版本管理功能
nvm version 查看当前安装的 nvm 版本
nvm proxy [url] 设置或查看下载代理服务器
nvm node_mirror [url] 设置或查看 Node.js 镜像源
nvm npm_mirror [url] 设置或查看 npm 镜像源
nvm reinstall-packages <ver> 将当前 npm 包重新安装到另一个 Node 版本
nvm list available 显示可供安装的所有版本(Windows 专用)
nvm root [path] 设置或查看 nvm 的安装路径
nvm cache dir 显示 nvm 的缓存目录路径
nvm cache clear 清空 nvm 的缓存

4.结语

今天的文章就到这里了,恭喜我们又掌握了编程路上的一个小知识。

图片.png

搭建自动化 Web 页面性能检测系统 —— AI 篇

2025年6月27日 10:55

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。

本文作者:琉易

这一篇是系列文章:

搭建自动化 Web 页面性能检测系统 —— 设计篇

搭建自动化 Web 页面性能检测系统 —— 实现篇

搭建自动化 Web 页面性能检测系统 —— 部署篇

页面性能对于用户体验、用户留存有着重要影响,当页面加载时间过长时,往往会伴随着一部分用户的流失,也会带来一些用户差评。性能的优劣往往是同类产品中胜出的影响因素,也是一个网站口碑的重要评判标准。

系统架构图(简略)

本篇重点讲解 AI 分析模块的设计与实践。

画板

AI 分析模块的设计与实现

输入与输出

  • 输入:Lighthouse 检查产生的 JSON 数据
    • 由于每次检测产生的 JSON 数据较大,一般有 350KB ~ 500KB,而大模型往往是根据输入 Tokens 进行计费,且并不是单纯的按量计费,类似于生活中常见的阶梯计费;另外模型支持的输入也有限,一般为 32k Tokens 或 64k Tokens。所以我们将 JSON 数据传输给大模型前需要进行精简。
    • 保留检测结果中的关键数据,如:环境数据(environment)、每一项检测指标的详细结果(audits)、检测时的配置参数(configSettings)、汇总各类指标的最终得分(categories)。
  • 输出:自然语言优化建议列表
    • 如:建议将图片资源启用 lazy-load
    • 如:减小某个图片文件的大小以减少传输时间

核心组成

  • JSON 清洗与摘要
  • Prompt 定义
  • openai 接口集成
  • 流式处理

Prompt 设计要点

构建一个高质量的 Prompt 是成功的关键,以下是一个例子:

你是一个网页性能优化专家。我将提供一个通过 Google Lighthouse 生成的 JSON 报告,请你根据报告中的内容:
1. 每个关键指标给出两三条优化建议,需要结合 json 中的实际数据进行举例。
2. 回答的内容使用 markdown 格式。
3. 专业名词需要使用中文。

实际测试中 Kimi 的 moonshot-v1-auto 模型回答更快,百炼平台的模型输入输出 Tokens 限制更宽泛,但是输出速度略慢;百炼平台的免费额度更多,OpenAI 费用较高且部署后会有访问的问题。

关键技术点

Lighthouse 报告数据结构解析

JSON 数据清洗与摘要是大模型调用能否成功的关键,清洗后的结果是 Prompt 的数据来源,如果内容较多可能会超出模型输入 Tokens 的限制从而导致调用失败。

  • audits 中会包含各种指标近百种,我们可以删除一些内容较多但对分析用处不大的数据,如:offscreen-images、screenshot-thumbnails 等。
  • Lighthouse 生成的 JSON 数据会直接保存瀑布图等图片的 Base64 格式数据,这些图片数据占用 Tokens 明显。

经过清洗,尽量将输入的 Tokens 控制在 100k 以内。

流式输出

openai 是一个 npm 包,通过这个 npm 包可以快速的对接各种大模型的 API 调用服务。

// 流式输出
const stream = await client.chat.completions.create({
    model: 'moonshot-v1-auto',
    messages: [
        {
            role: 'system',
            content: `你是一个网页性能优化专家。我将提供一个通过 Google Lighthouse 生成的 JSON 报告,请你根据报告中的内容:
1. 每个关键指标给出两三条优化建议,需要结合 json 中的实际数据进行举例。
2. 回答的内容使用 markdown 格式。
3. 专业名词需要使用中文。`,
        },
        { role: 'user', content: jsonData },
    ],
    temperature: 0.3,
    stream: true,
});

// 当启用流式输出模式(stream=True),SDK 返回的内容也发生了变化,我们不再直接访问返回值中的 choice
// 而是通过 for 循环逐个访问返回值中每个单独的块(chunk)
for await (const chunk of stream) {
    if (abortSignal?.aborted) {
        console.log(`${taskIdLogStr}任务中止, kimi chat`);
        break;
    }

    // 在这里,每个 chunk 的结构都与之前的 completion 相似,但 message 字段被替换成了 delta 字段
    const delta = chunk.choices[0].delta;
    if (delta.content) {
        onData(delta.content);
    }
}
@ApiOperation({ summary: '分析检测结果' })
    @HttpCode(HttpStatus.OK)
    @Post('reportChat')
    @RawResponse()
    async reportChat(@Body() query: ReportChatReqDto, @Res() res: Response) {
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');
    res.flushHeaders();

    const abortController = new AbortController();

    // 👇 监听客户端断开连接(如 Abort)
    res.on('close', () => {
        abortController.abort();
    });

    try {
        await this.AIService.reportChat(
            query,
            (content: string) => {
                res.write(`data: ${JSON.stringify({ content })}\n\n`);
            },
            abortController.signal
        );
        res.write(`data: [DONE]\n\n`);
    } catch (error) {
        res.write(`data: [ERROR] ${error.message || 'stream error'}\n\n`);
        abortController.abort();
    } finally {
        res.end();
    }
}

非流式输出

// 非流式输出
const completion = await client.chat.completions.create({
    model: 'moonshot-v1-auto',
    messages: [
        {
            role: 'system',
            content: `你是一个网页性能评分分析专家。我将提供一个产品的性能评分数据,帮我分析得分趋势和较大的得分变化。回答的内容不要带格式符号,尤其是 **。`,
        },
        { role: 'user', content: jsonData },
    ],
    temperature: 0.3,
});

return completion.choices[0].message.content;

实现的功能点

检测报告的智能分析与建议

由于保存的是 html 文件,我们可以通过正则将 html 文件中的 JSON 数据提取出来,用于后续的清洗与分析。

image.png

结合清洗后的 JSON 数据给出优化建议。

数据周报的趋势分析

将过去一周的分数给到大模型,由大模型分析解读得分的变化趋势。

image.png

后续规划

  • JSON 数据清洗更精确,确定好哪些是关键性能指标
  • 将 Lighthouse 的具体评分规则同步给大模型
  • 优化 Prompt,更换更合适的大模型
  • 结合埋点数据
    • 分析页面性能与用户停留时长的关系
    • 分析用户跳出率与页面性能的关系
    • 结合采集到的埋点数据分析页面性能对业务指标的影响

如何让异步序列(AsyncSequence)优雅的感知被取消(Cancel)

2025年6月26日 11:23

在这里插入图片描述

概览

自  从 Swift 5.5 推出新的 async/await 并发模型以来,异步队列(AsyncSequence)就成为其中不可或缺的重要一员。

不同于普通的序列,异步序列有着特殊的“惰性”和并发性,若序列中的元素还未准备好,系统在耐心等待的同时,还将宝贵的线程资源供其它任务去使用,极大的提高了系统整体性能。

在本篇博文中,您将学到以下知识:

  1. 什么是异步序列?
  2. 创建自定义异步序列
  3. 另一种创建异步序列的方式:AsyncStream
  4. 取消异步序列的处理

什么是异步序列?

异步序列(AsyncSequence)严格的说是一个协议,它为遵守者提供异步的、序列的、迭代的序列元素访问。

在这里插入图片描述

表面看起来它是序列,实际上它内部元素是异步产生的,这意味着当子元素暂不可用时使用者将会等待直到它们可用为止: 在这里插入图片描述

诸多系统框架都对异步序列做了相应扩展,比如 Foundation 的 URL、 Combine 的发布器等等:

// Foundation
let url = URL(string: "https://kinds.blog.csdn.net/article/details/132787577")!
Task {
    do {
        for try await line in url.lines {
            print(line)
        }
    }catch{
        print("ERR: \(error.localizedDescription)")
    }
}

// Combine
let p = PassthroughSubject<Int,Never>()
for await val in p.values {
    print(val)
}

如上代码所示,URL#lines 和 Publisher#values 属性都是异步序列。

除了系统已为我们考虑的以外,我们自己同样可以非常方便的创建自定义异步序列。

创建自定义异步序列

一般来说,要创建自定义异步序列我们只需遵守 AsyncSequence 和 AsyncIteratorProtocol 协议即可:

在这里插入图片描述

下面我们就来创建一个“超级英雄们(Super Heros)”的异步序列吧:

struct SuperHeros: AsyncSequence, AsyncIteratorProtocol {
    
    private let heros = ["超人", "钢铁侠", "孙悟空", "元始天尊", "菩提老祖"]
    
    typealias Element = String
    var index = 0
    
    mutating func next() async throws -> Element? {
        defer { index += 1}
        
        try? await Task.sleep(for: .seconds(1.0))
        
        if index >= heros.count {
            return nil
        }else{
            return heros[index]
        }
    }
    
    func makeAsyncIterator() -> SuperHeros {
        self
    }
}

Task {
    let heros = SuperHeros()
    for try await hero in heros {
        print("出场英雄:\(hero)")
    }
}

以上异步序列会每隔 1 秒“产出”一名超级英雄:

在这里插入图片描述

如上代码所示,如果下一个超级英雄还未就绪,系统会在等待同时去执行其它合适的任务,不会有任何资源上的浪费。

另一种创建异步序列的方式:AsyncStream

其实,除了直接遵守 AsyncSequence 协议以外,我们还有另外一种选择:AsyncStream!

不像 AsyncSequence 和 AsyncIteratorProtocol 协议 ,AsyncStream 是彻头彻尾的结构(实体): 在这里插入图片描述

它提供两种构造器,分别供正常和异步序列产出(Spawning)情境使用:

public init(_ elementType: Element.Type = Element.self, bufferingPolicy limit: AsyncStream<Element>.Continuation.BufferingPolicy = .unbounded, _ build: (AsyncStream<Element>.Continuation) -> Void)

    
public init(unfolding produce: @escaping () async -> Element?, onCancel: (@Sendable () -> Void)? = nil)

下面为此举两个  官方提供的代码示例:

let stream_0 = AsyncStream<Int>(Int.self,
                    bufferingPolicy: .bufferingNewest(5)) { continuation in
     Task.detached {
         for _ in 0..<100 {
             await Task.sleep(1 * 1_000_000_000)
             continuation.yield(Int.random(in: 1...10))
         }
         continuation.finish()
    }
}

let stream_1 = AsyncStream<Int> {
    await Task.sleep(1 * 1_000_000_000)
    return Int.random(in: 1...10)
}

更多关于异步序列的知识,请小伙伴们移步如下链接观赏:


取消异步序列的处理

我们知道  新的 async/await 并发模型主打一个“结构化”,之所以称为“结构化”一个重要原因就是并发中所有任务都共同组成一个层级继承体系,当父任务出错或被取消时,所有子任务都会收到取消通知,异步序列同样也不例外。

就拿下面倒计时异步序列来说吧,它能感应父任务取消事件的原因是由于其中调用了 Task.sleep() 方法( sleep() 方法内部会对取消做出响应):

let countdown = AsyncStream<String> { continuation in
    Task {
        for i in (0...3).reversed() {
            try await Task.sleep(until: .now + .seconds(1.0), clock: .suspending)
            
            guard i > 0 else {
                continuation.yield(with: .success("🎉 " + "see you!!!"))
                return
            }
            
            continuation.yield("\(i) ...")
        }
    }
}

Task {
    for await count in countdown {
        print("current is \(count)")
    }
}

正常情况下,我们应该在异步序列计算昂贵元素之前显式检查 Cancel 状态:

let stream_1 = AsyncStream<Int> {
    // 假设 spawn() 是一个“昂贵”方法
    func spawn() -> Int {
        Int.random(in: 1...10)
    }
    
    // 或者使用 Task.checkCancellation() 处理异常
    if Task.isCancelled {
        return nil
    }
    
    return spawn()
}

在某些情况下,我们希望用自己的模型(Model)去关联退出状态,这时我们可以利用 withTaskCancellationHandler() 方法为异步序列保驾护航:

public func next() async -> Order? {
    return await withTaskCancellationHandler {
        let result = await kitchen.generateOrder()
        // 使用自定义模型中的状态来判断是否取消
        guard state.isRunning else {
            return nil
        }
        return result
    } onCancel: {
    // 在父任务取消时设置取消状态!
        state.cancel()
    }
}

注意,当父任务被取消时上面 onCancel() 闭包中的代码会立即执行,很可能和 withTaskCancellationHandler() 方法主体代码同步进行。

现在,在一些 Task 内置取消状态不适合或不及时的场合下,我们可以在异步序列中使用 withTaskCancellationHandler() 的 onCancel() 子句来更有效率的完成退出操作,棒棒哒!💯。

总结

在本篇博文中,我们首先简单介绍了什么是异步序列,接着学习了几种创建自定义异步序列的方法,最后我们讨论了如何优雅的取消异步序列的迭代。

感谢观赏,再会!8-)

❌
❌