Spring AI Alibaba - DeepResearch 前端主体 UI 构建

Hello 大家好, 这里是 Anyin

前言

最近 AI Agent 如火如荼,自己也想着接触这块内容,所以选择了 Spring AI Alibaba 这个项目进行学习。

在下载了 Spring AI Alibaba 代码之后,本地编译通过,并且也能跑了几个相关的 Demo。为了再深入点学习,最终选择了 deepresearch 这个项目,在看了相关代码之后,发现还是一知半解。

所以为了能够更加直观的感受整个项目的流程,就把前端代码也跑起来,结果发现前端跑了之后,也是没办法完整体验到整个流程,因为前端功能还是缺失很多。 而且观察了几天,发现并没有新的前端 PR 提交。

刚好手上有 Trae PRO 的账号,而且前几天还拿到了 SOLO 的内测码(虽然这个过程中并没有用到 SOLO 的功能)。所以突然想着自己用 AI,上手把这个前端干了!

前端框架

在阅读了 Deep Research 现有的代码,发现其用到了 2 个前端 UI 框架: Ant Design VueAnt Design X Vue

其中 Ant Design X Vue 是一个 AI界面的一个组件框架,包含基本的对话气泡组件、管理对话组件、输入框组件、思维链组件、流式请求组件、模型调度、数据管理等。

基于以上的组件,能够快速、方便的构建一个 AI 聊天界面。如下:

为了让 Trae 能够理解这 2 个框架,不以至于给我瞎写代码,我把这 2 个框架的相关信息放在 Trae 的文档集中,使得其能够知道这 2 个框架的使用。

梳理需求

OK ,在了解了现有的代码、使用的框架、并且和现有的开发者沟通了下,就可以开始梳理需求。

Deep Research 的前后端流程大概如下:

js 复制代码
用户输入问题 ->  后端给出执行计划 -> 用户点击开始 -> 后端给出思考流程 -> 后端给出最终的报告

前端需要做的内容是:

js 复制代码
1. 根据后端给的消息列表,渲染对话气泡组件
2. 根据后端给的思考流程,渲染思维链组件
3. 根据后端给的最终报告结果,提供报告下载、在线预览功能

对话气泡组件

对话气泡组件就是整个聊天窗口的主体,展示 AI 和 人类的对话历史记录。

对话组件的数据是一个 消息列表,这个列表的数据应该交由 数据管理组件进行管理(useXChat)。

useXChat 组件会依赖 模型调度组件(useXAgent),useXAgent 提供了和后端交互,抽象数据流。

所以基本流程为:

  1. 使用 useXAgent 和后端交互,拿到数据
  2. 把 useXAgent 拿到的数据交给 useXChat进行数据管理
  3. 从 useXChat 拿到 对话气泡组件需要的消息列表
  4. 最终根据 消息列表渲染 对话气泡组件

对话气泡组件效果如下:

思维链组件

思维链组件的数据来源也应该是从 useXChat 中获取(其实也是消息列表)

思维链组件需要处理的数据有以下几个问题:

  1. 对于后端返回的普通节点数据,一个普通节点就会渲染一个思维链的节点
  2. 对于后端返回的流式节点数据,流式节点数据是多次返回,只有全部返回之后才能正常渲染思维链节点,并且流式节点可能是并行返回,即同时返回多个 流式节点数据
  3. 思维链的 loading 节点渲染,在普通节点返回之后下一个节点未返回之前,需要渲染一个 loading 的节点;在返回的流式节点过程中,需要一直渲染 loading 节点

思维链组件效果如下:

报告展示组件

对于一个用户问题最终后端会返回基于这个问题的一个报告,这个报告可以下载为 PDF,也可以在线预览。

下载 PDF 的接口后端提供即可。

在线预览就比较特殊,它是一个交由大模型写一个前端页面的代码,并且流式返回给前端,前端拿到这些数据拼接成一个完整的 html 页面最终渲染在页面上。

在线预览有以下注意点:

  1. 后端必须保证大模型最终返回的内容是一个标准的 html 代码,否则会导致页面渲染错误
  2. 前端页面渲染需要加载外部资源,例如外部的:JS 、CSS 文件;同样需要加载内部 CSS 和 JS 脚本
  3. 前端页面渲染不能影响到外层的主体页面,因为大模型返回的内容不能保证是否会污染外层主体页面的样式等,这里可以使用 iframe 解决

代码实现

对话气泡列表

js 复制代码
const bubbleList = computed(() => {
  const len = messages.value.length
  messageStore.history[convId] = messages.value
  //  当状态是loading的时候,是每个chunk,然后succes,把之前所有的chunk 全部返回
  return messages.value.map(({ id, message, status }, idx) => ({
    key: id,
    role: status === 'local' ? 'local' : 'ai',
    content: parseMessage(status, message, idx === len - 1),
    footer: parseFooter(status, idx === len - 1),
  }))
})

bubbleList 就是对话气泡列表需要的数据,它是由历史消息记录 messages 转换过来的

messages 的数据由是从 useXChat 中获取的

js 复制代码
const { onRequest, messages } = useXChat({
  agent: agent.value,
  requestPlaceholder: 'Waiting...',
  requestFallback: 'Failed return. Please try again later.',
})

useXChat 组件依赖 useXAgent 组件,它的数据来源都是useXAgent 提供的。

js 复制代码
const [agent] = useXAgent({
  request: async ({ message }, { onSuccess, onUpdate, onError }) => {
    let content = ''
    switch (current.aiType) {
      case 'normal':
      case 'startDS': {
        content = await sendChatStream(message, onUpdate, onError)
        break
      }
      case 'onDS': {
        current.deepResearchDetail = true
        content = await (configStore.chatConfig.auto_accepted_plan ? sendChatStream(message, onUpdate, onError) : sendResumeStream(message, onUpdate, onError))
        break
      }
    }
    // 最后会返回本次stream的所有内容
    onSuccess(content)
  },
})

以上为请求后端的核心代码。

message 就是用户发送的消息内容

onUpdate 在后端使用流式返回的时候,每一个 chunk 都可以通过 onUpdate方法传递给 messages

举例:

js 复制代码
用户消息:草莓蛋糕怎么做?
后端返回第一个 chunk:  这是第一个 chunk
后端返回第二个 chunk:  这是第二个 chunk

这个时候调用了 onUpdate 方法的时候,messages 的值变更如下:

json 复制代码
第一个 chunk 返回的时候:{ status: 'loading', messages: [ { role: '用户', msg: '草莓蛋糕怎么做?'}, { role: 'AI': msg: '这是第一个 chunk' } ]}
第二个 chunk 返回的时候:{ status: 'loading', messages: [ { role: '用户', msg: '草莓蛋糕怎么做?'}, { role: 'AI': msg: '这是第二个 chunk' } ]}

onSuccess 在后端使用流式返回的时候,可以通过 onSuccess 方法把所有 chunk 拼接好的内容一次性变更到 messages 的值

举例:

js 复制代码
用户消息:草莓蛋糕怎么做?
后端返回第一个 chunk:  这是第一个 chunk
后端返回第二个 chunk:  这是第二个 chunk

在所有 chunk 返回之后,这个时候调用了 onSuccess方法,messges 的值变更如下:

js 复制代码
{ status: 'success', messages: [ { role: '用户', msg: '草莓蛋糕怎么做?'}, { role: 'AI': msg: '这是第二个 chunk 这是第二个 chunk' } ]}

可以看到,在调用 onUpdate的时候返回的状态是 loading,而调用 success 返回的状态是 success

思维链组件

思维链组件的处理就会比较复杂:

  1. 已经完成对话的历史消息列表的思维链渲染
  2. 当前正在对话中的消息列表的思维链渲染
    2.1 普通节点的渲染
    2.2 流式节点的渲染
    2.3 Pending 状态节点渲染
js 复制代码
    messages.forEach(msg => {
      // 单个chunk
      // xchat组件的第一个chunk是 Waiting... 所以需要跳过
      if(msg.status === 'loading' && msg.message != 'Waiting...') {
           isLoading = true
           const node = JSON.parse(msg.message)
           if(node.nodeName) {
              processJsonNodeLogic(node)
           }else{
             processLlmStreamNodeLogic(node)
           }
      }
      //  完整的text, 历史记录的渲染
      //  当stream完成,xchat还会返回一次success,为避免思维链重复渲染,如果是loading状态,则不在重复增加节点
      if(msg.status === 'success' && !isLoading) {
          isLoading = false
          const jsonArray = parseJsonTextStrict(msg.message)
          jsonArray.forEach(node => {
            if(node.nodeName) {
              processJsonNodeLogic(node)
            }else{
              processLlmStreamNodeLogic(node)
            }
          })
      }
    })

对于已经完成对话的历史消息的渲染,需要使用 parseJsonTextStrict 方法对消息进行格式化下,转为消息数组。因为后端对于每个 chunk 返回其实都是一个标准 JSON 字符串,一个流式完整返回的时候,所有的 chunk 拼接就会变成类似以下状态:

js 复制代码
{ nodeName: 'node1', content: '这是第一个节点的内容'} { nodeName: 'node2', content: '这是第二个节点的内容'}

对于普通节点,既有 nodeName 属性使用 processJsonNodeLogic 方法进行渲染

对于流式节点,没有 nodeName 属性使用 processLlmStreamNodeLogic 方法进行渲染

processJsonNodeLogic 方法代码如下:

js 复制代码
const processJsonNodeLogic = (node: any) => {
    // 普通节点:完成之前的流式节点,然后处理当前节点
    finalizePreviousStreamNodes('')
    // 渲染普通节点
    processJsonNode(node)
    // 普通节点处理完后,添加pending节点
    appendPendingNode()
    // information 或者 end 节点 说明等待用户反馈 或者 结束
    if(node.nodeName === 'planner' || node.nodeName === '__END__'){
      removeLastPendingNode()
    }
}

processLlmStreamNodeLogic 方法代码如下:

js 复制代码
// 处理llm_stream节点
const processLlmStreamNodeLogic = (node: any) => {
  if(!node.visible) {
    return
  }
  let item: ThoughtChainItem | undefined
  // llm_stream 形式的节点,需要流式渲染
  // 动态遍历node对象的key,只要包含'llm_stream'就执行相应处理
  const llmStreamKeys = Object.keys(node).filter(key => key.includes('llm_stream'))

  for (const key of llmStreamKeys) {
    // 流式节点:移除pending节点,完成之前的流式节点
    removeLastPendingNode()
    finalizePreviousStreamNodes(key)
    item = processLlmStreamNode(node, key)
  }
  if(item) {
    // 检查是否已经存在相同的item(针对llm_stream节点)
    const existingIndex = arrayTemp.findIndex(existingItem => existingItem === item)
    if(existingIndex === -1) {
      arrayTemp.push(item)
    }
  }
}

对于流式节点返回的处理代码还不够完善,因为当流式节点是多个一起返回的时候,就会造成返回的流式节点都是 success 状态,但是期望是 loading 状态。

这是是因为目前流式节点返回的内容并没有一个 stop 标示这个流式节点完全返回,所以这里临时做法是 移除 loading 状态的节点 和 success之前的流式节点,这样子就会造成并行情况下,所有的流式节点都会变成 success 状态。

目前后端已经增加 stop 标识,等着我补充代码

报告展示组件

报告展示组件主要是渲染后端返回的 Html 页面。 这里的逻辑就比较简单了,使用 iframe 标签包裹,然后把后端返回的 html chunk 拼接即可。

JS 代码

js 复制代码
// iframe内容(用于预览视图)
const iframeContent = computed(() => {
  if (!accumulatedHtml.value) return ''
  
  // 确保HTML内容包含完整的文档结构
  let htmlContent = accumulatedHtml.value.trim()  
  return htmlContent
})

页面代码

html 复制代码
    <iframe 
      ref="htmlIframe"
      class="html-content"
      :srcdoc="iframeContent"
      sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
    ></iframe>

整个报告展示组件,都是使用 Trae 代码生成的,一行代码没有写。 AI 写前端代码,是真的香啊

相关截图

总结

首先,不得再说下 使用 AI 写代码是真的香,很多重复性工作、确定性的内容、CSS 样式等都是可以使用 AI 写的,例如: parseJsonTextStrict 方法、报告展示组件的样式、各种类型定义等

其次,在这个过程通过多次Debug、梳理相关逻辑,明白前后端交互逻辑,也熟悉后端代码的工作流程,后面应该还会再写一个后端代码相关总结

最后,以上如果有哪里不对欢迎指正 和 讨论。

本次前端主体 UI 构建完整的代码地址: github.com/alibaba/spr...

相关推荐
加班是不可能的,除非双倍日工资1 小时前
css预编译器实现星空背景图
前端·css·vue3
wyiyiyi2 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip2 小时前
vite和webpack打包结构控制
前端·javascript
一只爱撸猫的程序猿2 小时前
使用Spring AI配合MCP(Model Context Protocol)构建一个"智能代码审查助手"
spring boot·aigc·ai编程
excel2 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国3 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼3 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin
Jimmy3 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
前端卧龙人3 小时前
Trae助力,可视化面板登录页面接口联调与提测一气呵成
trae
ZXT3 小时前
promise & async await总结
前端