跳转到内容

前端界面

刀刀
0字
0分钟
2026/8/17

基础界面

效果图

alt text

数据定义

  1. 输入框输入的问题:准备一个 inputValue 变量
  2. 对话区的内容:使用一个数组接管显示,变量名假定为 convertList,数组每个元素为提问或回答。同时需区分用户提问与 AI 回答,左侧显示 AI 回答,右侧显示用户提问。

保持 messageList 即可,role 区分来源,content 为内容

js
;[
  {
    role: 'user',
    content: '你好',
  },
  {
    role: 'assistant',
    content: '你好,我是AI',
  },
  {
    role: 'user',
    content: '你叫什么名字',
  },
]

界面搭建

发起接口时展示思考中,AI 思考完毕后隐藏思考中。

index.vue
html
<script setup>
  import { nextTick, ref } from 'vue'
  import MarkDown from './components/MarkDown.vue'
  import { requestLLM } from './api'

  const inputvalue = ref('')
  const convertList = ref([])
  const isThinking = ref(false)
  function sendToLLM() {
    isThinking.value = true
    requestLLM(inputvalue.value).then((res) => {
      isThinking.value = false
      convertList.value = res.data.data
    })
  }
</script>

<template>
  <div class="chat-wrapper">
    <div class="chat-content">
      <div v-for="chatItem in convertList" class="chat-item">
        <div v-if="chatItem.role === 'user'" class="user-content">
          <MarkDown :content="chatItem.content"> </MarkDown>
        </div>
        <div v-if="chatItem.role === 'assistant'" class="assistant-content">
          <MarkDown :content="chatItem.content"> </MarkDown>
        </div>
      </div>
      <div v-if="isThinking" class="chat-item">
        <div class="assistant-content">思考中...</div>
      </div>
    </div>
    <div class="input-content">
      <input type="text" v-model="inputvalue" />
      <button @click="sendToLLM">发送</button>
    </div>
  </div>
</template>

<style scoped>
  .chat-wrapper {
    width: 600px;
    margin: 0 auto;
  }

  .chat-content {
    padding-top: 20px;
    width: 100%;
    height: 500px;
    border: 1px solid black;
    overflow: scroll;
  }

  .input-content {
    width: 100%;
    display: flex;
  }

  .input-content input {
    flex: 1;
  }

  .chat-item {
    width: 100%;
    margin-bottom: 20px;
    display: flex;
    box-sizing: border-box;
    padding: 0 10px;
  }

  .user-content {
    border-radius: 12px;
    padding: 0 8px;
    background-color: rgb(29, 120, 188);
    margin-left: auto;
    max-width: 40%;
  }

  .assistant-content {
    border-radius: 12px;
    padding: 0 8px;
    border: 1px solid grey;
    margin-right: auto;
    max-width: 40%;
  }
</style>

逻辑

alt text

交互问题

  1. 当前版本每次都会重复返回内容

    历史会话会被接口反复返回。会话变长后严重影响接口速度。

    应做增量更新:前端自行储存每次提问,接口仅返回本次回答,前端将回答 push 到数组中。

  2. 每次提问不会立即显示

    前端界面依赖接口返回,用户提问需等接口响应后才能展示。

优化

index.vue
html
<script setup>
  import { nextTick, ref } from 'vue'
  import MarkDown from './components/MarkDown.vue'
  import { requestLLM } from './api'

  const inputvalue = ref('')
  const convertList = ref([])
  const isThinking = ref(false)
  function sendToLLM() {
    const _convertList = [...convertList.value] // 做一个浅拷贝
    // 添加用户本次询问
    _convertList.push({
      role: 'user', 
      content: inputvalue.value, 
    }) 
    convertList.value = _convertList // 赋值,快速展示用户提问
    // 等待用户提问展示出来,再发送请求展示思考中
    nextTick(() => {
      isThinking.value = true
    }) 
    requestLLM(inputvalue.value).then((res) => {
      isThinking.value = false
      const _convertList = [...convertList.value] 
      _convertList.push(res.data.data) // 增量替换
      convertList.value = _convertList 
    })
  }
</script>

<template>
  <div class="chat-wrapper">
    <div class="chat-content">
      <div v-for="chatItem in convertList" class="chat-item">
        <div v-if="chatItem.role === 'user'" class="user-content">
          <MarkDown :content="chatItem.content"> </MarkDown>
        </div>
        <div v-if="chatItem.role === 'assistant'" class="assistant-content">
          <MarkDown :content="chatItem.content"> </MarkDown>
        </div>
      </div>
      <div v-if="isThinking" class="chat-item">
        <div class="assistant-content">思考中...</div>
      </div>
    </div>
    <div class="input-content">
      <input type="text" v-model="inputvalue" />
      <button @click="sendToLLM">发送</button>
    </div>
  </div>
</template>

会话管理

对话记录问题

  1. 对话记录刷新即丢失
  2. 前后端各维护一份,数据可能不一致
  3. AI 应用需支持历史会话记录,如昨天提的问题可点进去查看记录
  4. 实际 AI 应用有多用户场景,每个用户有自己的提问历史记录,需区分不同用户
  5. 用户需能新建对话,也能回到历史对话继续对话

服务端绘画管理关系

alt text

  • json 储存数据设计

    json
    {
      "user1": {
        "昨天对话id": {
          "title": "今天天气怎么样",
          "list": []
        },
        "今天对话id": {
          "title": "今天天气怎么样",
          "list": []
        }
      },
      "user2": {}
    }
  • 数据库

    表1:用户对话记录id表,用户每创建一次对话插入一条记录

    useridconvertid
    0010011
    0010012
    0020021

    表2:对话id详情表,如果基于历史记录对话,更新该表

    convertidlisttitle
    0011用json字符串存储对话总结标题
    0012用json字符串存储对话总结标题
    0021用json字符串存储对话总结标题

接口增加和修改

  1. conversation/create:在某用户 id 下创建新对话到数据库,id 使用 userId + 时间戳
  2. conversation/get:根据会话 id 获取某对话的历史记录
  3. LLM 接口改造:不再使用单一数组储存会话上下文。前端调用接口时携带会话 id 和用户 id,用 id 查询数据库获取历史记录。接口返回后,将本次提问和结果新增到该记录中
  4. conversation/list:获取该用户 id 下所有历史会话,根据有无标题为历史记录添加标题
  5. conversation/delete:可选,传入会话 id 删除对应数据库记录
json
{
  "001": {
    "0011": {
      "title": "今天天气怎么样",
      "list": []
    },
    "0012": {
      "title": "今天天气怎么样",
      "list": []
    }
  },
  "002": {}
}
js
const fs = require('fs')

// 读取json文件
async function summaryMessage(openai, summaryList) {
  const llmres = await openai.chat.completions.create({
    model: 'qwen-plus',
    messages: [
      {
        role: 'system',
        content: '帮我总结下面的对话记录,做一个摘要',
      },
      ...summaryList,
    ],
  })

  return llmres.choices[0].message
}

// 根据对话id,获取对话详情
async function summaryTitle(openai, list) {
  const llmres = await openai.chat.completions.create({
    model: 'qwen-plus',
    messages: [
      {
        role: 'system',
        content: '帮我总结下面的对话记录,生成一个小于16个字的标题',
      },
      ...list,
    ],
  })

  return llmres.choices[0].message.content
}

// 读取对话记录
function readConversation() {
  const jsonStr = fs.readFileSync('./conversation.json')
  const jsonObj = JSON.parse(jsonStr)
  return jsonObj
}

// 写入对话记录
function writeConversation(obj) {
  const jsonstr = JSON.stringify(obj)
  fs.writeFileSync('./conversation.json', jsonstr)
}

module.exports = {
  summaryMessage,
  readConversation,
  writeConversation,
  summaryTitle,
}
js
const {
  summaryMessage, 
  readConversation, 
  writeConversation, 
  summaryTitle, 
} = require('./utils.js') 

// 改造对话接口,查询数据库,并保存提问和结果
app.get('/llm', async (req, res) => {
  const { keyword, userId, convertId } = req.query
  const conversationObj = readConversation()
  const singleConvertList = conversationObj[userId][convertId].list // 用户对话列表
  const queryObj = {
    role: 'user',
    content: keyword,
  }
  if (singleConvertList.length > 10) {
    //算出来要截取多少条
    //多截取一些,方便ai接口多给我们总结一下,所以设为6,每次大于10只保留6条。
    const removeNum = singleConvertList.length - 6
    const removeList = singleConvertList.splice(1, removeNum) 
    const summaryRes = await summaryMessage(openai, removeList)
    singleConvertList.splice(1, 0, summaryRes) 
  }
  //每次提问,存到singleConvertList,保存上下文
  singleConvertList.push(queryObj) 
  console.log(singleConvertList) 
  const llmres = await openai.chat.completions.create({
    model: 'qwen-plus',
    // system手动添加上去,避免丢失
    messages: [
      {
        role: 'system', 
        content: systemString, 
      }, 
      ...singleConvertList, 
    ],
  })
  //每次回答,存到singleConvertList,保存上下文
  singleConvertList.push(llmres.choices[0].message) 
  writeConversation(conversationObj) // 重新写入
  res.json({
    success: true,
    data: llmres.choices[0].message,
  })
})

// 创建会话
app.get('/conversation/create', async (req, res) => {
  const userId = req.query.userId 
  const conversationObj = readConversation() 
  // 如果当前用户没有会话,则创建一个
  if (!conversationObj[userId]) {
    conversationObj[userId] = {} 
  } 
  const userConversatioObj = conversationObj[userId] 
  const convertId = userId + Date.now() // 会话id:当前id 拼接 时间戳
  userConversatioObj[convertId] = {
    title: '', 
    list: [], 
  } 
  writeConversation(conversationObj) // 写入到json内
  res.json({
    success: true, 
    data: convertId, 
    message: '创建成功', 
  }) 
}) 

// 获取会话
app.get('/conversation/get', async (req, res) => {
  const userId = req.query.userId // user id
  const convertId = req.query.convertId // 对话id
  const conversationObj = readConversation() 
  const userAllConversation = conversationObj[userId] 
  const targetCOnversation = userAllConversation[convertId] // 拿到对话数据
  res.json({
    success: true, 
    data: targetCOnversation, 
    message: '查询成功', 
  }) 
}) 

// 查询对话列表,获取id和title列表
app.get('/conversation/list', async (req, res) => {
  const userId = req.query.userId 
  const conversationObj = readConversation() 
  const userAllConversation = conversationObj[userId] 
  //获取到所有的对话id
  const convertIdList = Object.keys(userAllConversation) 
  const returnList = [] 
  //[{id:'',title},{id:"",title}]
  for (let i = 0; i < convertIdList.length; i++) {
    const _id = convertIdList[i] 
    const idsConvert = userAllConversation[_id] // 拿到对话记录
    // 判断title是否存在
    if (idsConvert.title) {
      returnList.push({
        title: idsConvert.title, 
        convertId: _id, 
      }) 
    } else {
      //检测一下是否已经有对话了,有对话了,在让ai总结,没有就先设个空标题
      if (idsConvert.list.length === 0) {
        returnList.push({
          title: '', 
          convertId: _id, 
        }) 
      } else {
        const aititle = await summaryTitle(openai, idsConvert.list) 
        idsConvert.title = aititle 
        writeConversation(conversationObj) 
        returnList.push({
          convertId: _id, 
          title: aititle, 
        }) 
      } 
    } 
  } 
  res.json({
    success: true, 
    data: returnList, 
    message: '查询成功', 
  }) 
}) 

前端逻辑改造

alt text

用户已登录的前提下,进入页面后调用接口获取对话标题列表,根据返回数据渲染页面。

点击标题进入对应对话页面,展示对话内容并支持继续对话。

vue
<script setup>
import { RouterView, useRouter } from 'vue-router'
import { listConversation } from './api'
import { onMounted, ref } from 'vue'
//假设已经登录,你的id是001
const historyList = ref([])
const router = useRouter()
onMounted(() => {
  listConversation('001').then((res) => {
    historyList.value = res.data.data
  })
})
function gotoHistory(convertId) {
  router.push('/?convertId=' + convertId)
}
</script>

<template>
  <div class="layout">
    <div class="slidebar">
      <div
        v-for="item in historyList"
        :key="item.convertId"
        @click="gotoHistory(item.convertId)"
      >
        {{ item.title }}
      </div>
    </div>
    <RouterView></RouterView>
  </div>
</template>
vue
<script setup>
import { nextTick, onMounted, ref, watch } from 'vue'
import MarkDown from '../components/MarkDown.vue'
import { createConversation, getConversation, requestLLM } from '../api'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()

const inputvalue = ref('')
const convertList = ref([])
const isThinking = ref(false)
function sendToLLM() {
  const _convertList = [...convertList.value]
  _convertList.push({
    role: 'user',
    content: inputvalue.value,
  })
  convertList.value = _convertList
  nextTick(() => {
    isThinking.value = true
  })
  requestLLM(inputvalue.value, '001', route.query.convertId).then((res) => {
    isThinking.value = false
    const _convertList = [...convertList.value]
    _convertList.push(res.data.data)
    convertList.value = _convertList
  })
}
function getDetailById(convertId) {
  getConversation('001', convertId).then((res) => {
    convertList.value = res.data.data.list
  })
}
function createNew() {
  createConversation('001').then((res) => {
    router.push('/?convertId=' + res.data.data)
  })
}
onMounted(() => {
  const { convertId } = route.query
  // 路由有id,根据id获取对话详情数据
  if (convertId) {
    getDetailById(convertId)
  }
  // 没有id,则创建新的对话
  else {
    createConversation('001').then((res) => {
      router.push('/?convertId=' + res.data.data)
    })
  }
})
// 监听路由变化,如果路由变化,则重新获取对话详情数据
watch(route, () => {
  getDetailById(route.query.convertId)
})
</script>

<template>
  <div class="chat-wrapper">
    <div class="chat-content">
      <div v-for="chatItem in convertList" class="chat-item">
        <div v-if="chatItem.role === 'user'" class="user-content">
          <MarkDown :content="chatItem.content"> </MarkDown>
        </div>
        <div v-if="chatItem.role === 'assistant'" class="assistant-content">
          <MarkDown :content="chatItem.content"> </MarkDown>
        </div>
      </div>
      <div v-if="isThinking" class="chat-item">
        <div class="assistant-content">思考中...</div>
      </div>
    </div>
    <div class="input-content">
      <input type="text" v-model="inputvalue" />
      <button @click="sendToLLM">发送</button>
      <button @click="createNew">创建新对话</button>
    </div>
  </div>
</template>

<style scoped>
.chat-wrapper {
  width: 900px;
}

.chat-content {
  padding-top: 20px;
  width: 100%;
  height: 500px;
  border: 1px solid black;
  overflow: scroll;
}

.input-content {
  width: 100%;
  display: flex;
}

.input-content input {
  flex: 1;
}

.chat-item {
  width: 100%;
  margin-bottom: 20px;
  display: flex;
  box-sizing: border-box;
  padding: 0 10px;
}

.user-content {
  border-radius: 12px;
  padding: 0 8px;
  background-color: rgb(29, 120, 188);
  margin-left: auto;
  max-width: 40%;
}

.assistant-content {
  border-radius: 12px;
  padding: 0 8px;
  border: 1px solid grey;
  margin-right: auto;
  max-width: 40%;
}
</style>

贡献者

The avatar of contributor named as duyidao duyidao
The avatar of contributor named as 刀刀 刀刀
The avatar of contributor named as Copilot Copilot

页面历史

刀刀博客累计访客 人;文档累计访问量共