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 分钟前
AI幻觉成WAIC首个关键词,Hinton敲响警钟,讯飞星火X1升级展示治理新突破
ai编程
Danny_FD11 分钟前
Vue + Element UI 实现模糊搜索自动补全
前端·javascript
gnip16 分钟前
闭包实现一个简单Vue3的状态管理
前端·javascript
斐济岛上有一只斐济22 分钟前
后端程序员的CSS复习
前端
Enddme25 分钟前
《面试必问!JavaScript 中this 全方位避坑指南 (含高频题解析)》
前端·javascript·面试
有梦想的程序员26 分钟前
微信小程序使用 Tailwind CSS version 3
前端
溟洵1 小时前
Qt 窗口 工具栏QToolBar、状态栏StatusBar
开发语言·前端·数据库·c++·后端·qt
用户2519162427111 小时前
Canvas之图像合成
前端·javascript·canvas
每天开心1 小时前
噜噜旅游App(4)——构建旅游智能客服模块,实现AI聊天
前端·微信小程序·前端框架
超凌1 小时前
el-input-number出现的点击+-按钮频现不生效
前端