一起来动手实现一个ai聊天对话

本文,我们将根据前文来实现一个ai聊天对话项目,感受真实的业务。

项目技术栈

  • vite---一个前端工程构建工具。
  • antd --- 一个react ui组件库。
  • @ant-design/icons ---- 一个react图标库。
  • mockjs --- 模拟消息对话数据。
  • dayjs --- 一个日期处理库
  • react --- 一个javascript框架。
  • typescript --- javascript的超集。
  • ew-message --- 个人写的一个消息提示框插件。(ps: 为什么要用这个而不用antd自带的message,因为我想用一下看看我写的消息提示框好用不。)
  • animate.css----一个动画样式文件。

初始化项目

参考vite官网,我们来初始化一个react-ts工程。如下:

pnpm create vite ai-dialog --template react-ts

初始化项目完成之后,接着执行如下命令:

cd ai-dialog
pnpm install

接着添加相关依赖:

pnpm add antd @ant-design/icons mockjs @types/mockjs animate.css ew-message dayjs

项目初始化完成,我们需要将原本的代码逻辑给删掉,即App.tsx以及App.css,index.css等代码删掉,然后我们接着往下继续。

编码时刻

1. 定义消息的类型

在src目录下创建一个types目录,该目录下,新增一个messge.d.ts文件,然后定义消息类型接口,代码如下:

export interface Message {
  name: string; // 用户名还是机器人名字
  text: string; // 消息文本
  timestamp: number; // 日期时间戳
  type?: string; // 消息类型
  isEnd?: boolean; // 会话是否结束
}
为什么要有消息类型type字段?

答: 我们可以根据消息类型来决定会话的渲染,例如消息类型是一个markdown的字符串,我们就以markdown的方式来渲染,又比如想要消息类型是一个json-schema的字符串,也就是渲染成表单,那我们同样也可以根据Type来判断。

为什么要有isEnd字段?

每一条消息,我们应该都需要添加这个字段,然后我们需要轮询请求结束的接口,在真实的业务场景之下,会话是有时间的,当到达了这个时间之后,会话会变成已结束,然后如果用户再次询问问题,那就是新一轮的会话,我们也可以根据这个字段来进行分组,这也是数据分组工具函数的由来。

2. mock数据

根据mock.js的api文档,我们可以mock一些消息数据,方便我们来做渲染,如下所示:

在src目录下新建mock目录,并新建mock.ts文件,代码如下:

import Mock from "mockjs";
import { Message } from "../types/message";
import dayjs from "dayjs";

const generateMessage = () => {
  const messages = ["你好!", "你好吗?", "我能为你做什么?", "再见!"];
  const names = ["夕水", "机器人-毛毛"];

  return Mock.mock({
    "messages|5": [
      {
        "name|1": names,
        "text|1": messages,
        timestamp: "@datetime",
      },
    ],
  });
};

export const getMockMessages = () => {
  return generateMessage().messages.map((message: Message) => ({
    ...message,
    timestamp: dayjs(message.timestamp).unix() * 1000,
    isEnd: false,
  }));
};

主要是在没有对接ai服务的时候,我们可以先自己模拟数据来做渲染。

3. 工具函数

这里也涉及到了一些工具函数的定义,例如会话消息的分组,还有就是我们需要缓存数据,因此这里也会涉及到字符串解析成数组,以下是所有工具函数的代码:

import { Message } from "../types/message";

export const groupByInterval = (
  arr: Message[],
  filterFn = (item: Message) => item.isEnd
) => {
  if (arr.length === 0) {
    return [arr];
  }

  const result: Message[][] = [[arr[0]]];
  for (let i = 1; i < arr.length; i++) {
    const item = arr[i];
    if (filterFn(item)) {
      result.push([item]);
    } else {
      result[result.length - 1].push(item);
    }
  }

  return result;
};

export enum parseStrType {
  EVAL = "eval",
  JSON = "json",
}
export const parseStr = (
  str: string,
  type: parseStrType = parseStrType.JSON
) => {
  const parseMethod = {
    [parseStrType.EVAL]: (v: string): T => new Function(`return ${v}`)(),
    [parseStrType.JSON]: JSON.parse,
  };
  let res: T | null = null;
  try {
    const method = parseMethod[type];
    if (method) {
      res = method(str);
    }
  } catch (error) {
    console.error(`[parse data error]:${error}`);
  }
  return res;
};

export const isValidJSON = (val: string) => {
  try {
    const res = JSON.parse(val);
    return res !== null;
  } catch (error) {
    console.log("isValidJSON:", error);
    return false;
  }
};

第一个工具函数,我们在前文已经讲到过,这里不做过多解释。后面2个工具函数也很好理解,我们先来看parseStr工具函数。

该工具函数用于根据指定的解析类型(evaljson)将传入的字符串 str 解析为相应的 JavaScript 数据类型。具体来说,它提供了两种解析方式:

  1. 使用 eval 解析字符串
  2. 使用 JSON.parse 解析字符串

详细解读:

1. parseStrType 枚举
export enum parseStrType {
  EVAL = "eval",
  JSON = "json",
}
  • parseStrType 是一个枚举,定义了两个解析类型:

    • EVAL:使用 eval 来解析字符串。
    • JSON:使用 JSON.parse 来解析字符串。

枚举的作用是让代码更加可读,避免硬编码字符串(如 "eval""json")出现在多个地方,使得代码的意图更清晰,并提高可维护性。

2. parseStr 函数
export const parseStr = (
  str: string,
  type: parseStrType = parseStrType.JSON
) => {
  • parseStr 是一个泛型函数,接收两个参数:

    • str: 要解析的字符串(string 类型)。
    • type: 指定解析类型的枚举,默认为 parseStrType.JSON,即使用 JSON.parse 解析。

泛型 T 使得返回值可以根据调用时的需要动态推断出类型,提供类型安全。

3. parseMethod 对象
const parseMethod = {
  [parseStrType.EVAL]: (v: string): T => new Function(`return ${v}`)(),
  [parseStrType.JSON]: JSON.parse,
};
  • parseMethod 是一个对象,存储了两种解析方法:

    • 对于 parseStrType.EVAL,使用 new Function('return ${v}')() 来动态解析字符串。Function 构造函数可以将一个字符串作为 JavaScript 代码执行,实际上类似于使用 eval,但是使用 Function 是一种更安全的方式,因为它不会访问当前的作用域,只能访问全局作用域。
    • 对于 parseStrType.JSON,直接使用 JSON.parse 方法来解析 JSON 字符串。
4. 解析逻辑
let res: T | null = null;
try {
  const method = parseMethod[type];
  if (method) {
    res = method(str);
  }
} catch (error) {
  console.error(`[parse data error]:${error}`);
}
  • 定义了一个变量 res 来存储解析结果,初始值为 null
  • try 块中,函数首先根据 type 获取相应的解析方法(parseMethod[type])。
  • 如果找到了对应的解析方法(即 method 不为 nullundefined),则调用该方法来解析传入的 str 字符串,并将结果赋值给 res
  • 如果解析过程中发生异常(例如,字符串格式不正确),则会进入 catch 块,打印错误信息。
5. 返回解析结果
return res;
  • 返回最终解析的结果。如果解析成功,返回解析后的值;如果出现异常或没有正确的解析结果,返回 null
代码示例:
const jsonString = '{"name": "John", "age": 30}';
const result1 = parseStr(jsonString, parseStrType.JSON);
console.log(result1); // { name: "John", age: 30 }

const evalString = '2 + 2';
const result2 = parseStr(evalString, parseStrType.EVAL);
console.log(result2); // 4

接下来,我们来看第二个工具函数。

该工具函数用于验证一个字符串是否是有效的 JSON 格式。以下是逐行解读:

函数签名:
export const isValidJSON = (val: string) => { 
  //... 
}
  • isValidJSON 是一个箭头函数,它接受一个参数 val,类型是 string,代表需要验证的字符串。
  • export 表明该函数可以被导入到其他文件中使用。
解析字符串并检查其有效性:
try {
  const res = JSON.parse(val);
  return res !== null;
} catch (error) {
  console.log("isValidJSON:", error);
  return false;
}
  1. try

    • try 块中,函数尝试通过 JSON.parse(val) 将传入的字符串 val 解析成一个 JavaScript 对象。

      • JSON.parse(val) 会尝试将 val 解析为一个 JSON 对象。如果字符串是有效的 JSON 格式,它将返回一个对应的 JavaScript 对象或数据结构。
  2. return res !== null;

    • 如果 JSON.parse 没有抛出错误(即字符串是有效的 JSON 格式),接下来会检查解析结果 res 是否为 null

      • JSON.parse 会成功解析有效的 JSON 字符串,返回对应的 JavaScript 对象或值。如果 resnull,则返回 false(这意味着 JSON 解析结果是 null,例如 {} 或其他有效的 JSON 对象,不能单纯地认定为有效 JSON)。
    • 如果 res 不是 null(比如一个合法的对象、数组、数字等),则返回 true,表示该字符串是有效的 JSON。
  3. catch

    • 如果 JSON.parse(val) 解析过程中抛出错误(例如,字符串格式不符合 JSON 规范),会进入 catch 块。

      • catch 捕获到的 error 会被打印出来,输出信息为 "isValidJSON:" 后跟错误内容。
      • catch 块中返回 false,表示传入的字符串不是有效的 JSON。

使用示例:

console.log(isValidJSON('{"name": "John", "age": 30}'));  // true
console.log(isValidJSON('{"name": "John", age: 30}'));    // false (invalid JSON format)
console.log(isValidJSON('null'));                         // false (valid JSON but is `null`)

4. 缓存数据

由于真实业务场景中,我们需要缓存数据,因此在这里,我封装了一个响应式监听会话存储的hooks。代码如下所示:

import { useState, useEffect } from "react";
import { parseStr } from "../utils/utils";

export enum StorageType {
  LOCAL = "local",
  SESSION = "session",
}

function useStorage(
  key: string,
  initialValue: T,
  storage: StorageType = StorageType.LOCAL
) {
  const currentStorage =
    storage === StorageType.LOCAL ? localStorage : sessionStorage;
  const getStoredValue = () => {
    const saved = currentStorage.getItem(key);
    if (saved !== null) {
      return parseStr(saved);
    } else {
      return initialValue;
    }
  };

  const [storedValue, setStoredValue] = useState(() => getStoredValue());

  useEffect(() => {
    const handleStorageChange = (event: StorageEvent) => {
      if (event.key === key) {
        setStoredValue(event.newValue ? parseStr(event.newValue) : null);
      }
    };

    window.addEventListener("storage", handleStorageChange);

    return () => {
      window.removeEventListener("storage", handleStorageChange);
    };
  }, [key]);

  const setValue = (value: T) => {
    setStoredValue(value);

    currentStorage.setItem(key, JSON.stringify(value));
  };

  return [storedValue, setValue] as const;
}

export default useStorage;

这段代码定义了一个名为 useStorage 的 React 自定义 Hook,用于在浏览器的 localStoragesessionStorage 中存储和读取数据。它支持类型安全,并提供了一些自动同步的功能。以下是对每一部分的详细解读:

1. StorageType 枚举

export enum StorageType {
  LOCAL = "local",
  SESSION = "session",
}
  • 定义了一个 StorageType 枚举,用于表示存储的类型。

    • LOCAL:表示使用 localStorage,即数据在浏览器关闭后依然存在。
    • SESSION:表示使用 sessionStorage,即数据只在当前会话中存在,浏览器关闭后数据会丢失。

2. useStorage 自定义 Hook

function useStorage(
  key: string,
  initialValue: T,
  storage: StorageType = StorageType.LOCAL
)
  • useStorage 是一个泛型函数,接受以下参数:

    • key:存储数据的键名。
    • initialValue:如果在存储中没有找到对应的值,使用的默认值。
    • storage:指定使用哪种存储类型(localStoragesessionStorage),默认使用 localStorage

3. currentStorage 选择存储类型

const currentStorage =
  storage === StorageType.LOCAL ? localStorage : sessionStorage;
  • 根据传入的 storage 参数,决定使用 localStorage 还是 sessionStorage

4. getStoredValue 函数

const getStoredValue = () => {
  const saved = currentStorage.getItem(key);
  if (saved !== null) {
    return parseStr(saved);
  } else {
    return initialValue;
  }
};
  • getStoredValue 函数从 currentStorage 中获取数据:

    • 如果存储中找到了对应的 key,则解析存储的字符串(通过 parseStr)并返回值。
    • 如果存储中没有数据(即 saved === null),则返回 initialValue 作为默认值。
    • parseStr 函数用来将存储的字符串反序列化为 JavaScript 对象,代码在前面有说明。

5. useState 用来管理存储值

const [storedValue, setStoredValue] = useState(() => getStoredValue());
  • useState 用来管理存储的值。初始值通过 getStoredValue 函数获取,storedValue 存储实际值,setStoredValue 是更新该值的函数。
  • useState 使用懒初始化,getStoredValue 只在组件首次渲染时执行一次。

6. useEffect 监听 Storage 事件

useEffect(() => {
  const handleStorageChange = (event: StorageEvent) => {
    if (event.key === key) {
      setStoredValue(event.newValue ? parseStr(event.newValue) : null);
    }
  };

  window.addEventListener("storage", handleStorageChange);

  return () => {
    window.removeEventListener("storage", handleStorageChange);
  };
}, [key]);
  • useEffect 用来监听 storage 事件,以便在其他窗口或标签页中更改了相同 key 对应的存储值时,自动同步更新当前窗口或标签页中的存储值。

    • handleStorageChange 函数处理存储变化,检查 event.key 是否与当前 key 匹配。如果匹配,就通过 setStoredValue 更新值。
    • useEffect 会在组件挂载时添加事件监听器,在组件卸载时移除事件监听器,避免内存泄漏。
    • key 作为依赖项,意味着只有当 key 发生变化时,useEffect 才会重新执行。

7. setValue 函数更新存储值

const setValue = (value: T) => {
  setStoredValue(value);
  currentStorage.setItem(key, JSON.stringify(value));
};
  • setValue 函数更新存储值:

    • 首先通过 setStoredValue 更新 React 状态(storedValue)。
    • 然后通过 currentStorage.setItem(key, JSON.stringify(value)) 将新值存储到 localStoragesessionStorage 中。这里使用 JSON.stringify 将值转化为 JSON 字符串存储。

8. 返回值

return [storedValue, setValue] as const;
  • 该 Hook 返回一个元组,包含当前存储的值和更新存储值的函数。
  • as const 用于确保返回的元组类型是固定的(即返回的是一个元组类型而不是普通数组),这样调用时可以保证类型安全。

9. 默认导出

export default useStorage;
  • 默认导出 useStorage 函数,允许在其他地方使用它。

使用示例:

const [user, setUser] = useStorage('user', { name: 'John', age: 30 });

// 获取当前存储的值
console.log(user);  // { name: 'John', age: 30 }

// 更新存储的值
setUser({ name: 'Jane', age: 25 });

这个 Hook 使得在 React 中使用浏览器的存储(localStoragesessionStorage)更加简单和方便,同时保证了类型的安全性。

5. 创建一个node项目,用来模拟ai接口

我们知道当前端通过调用ai服务接口,以此来获取事件流数据,从而渲染结果,由于通过我的调查,调用ai服务接口需要收费,暂时还没有找到好的ai服务(后续找到即可更新)。

因此这里我们先通过一个定时器用来模拟事件流数据。创建一个ai-node项目, 并初始化package.json,然后安装express依赖,然后创建一个index.js,并写上如下代码:

const express = require("express");
const app = express();
app.use((_, res, next) => {
  res.setHeader("Access-Control-Allow-Origin", "*");
  res.setHeader(
    "Access-Control-Allow-Methods",
    "GET, POST, PUT, DELETE, OPTIONS"
  );
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
  next();
});

app.use(express.json());

app.post("/events", (req, res) => {
  // 设置响应头以启动事件源流
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");

  let counter = 0;
  console.log("Received message:", req.body);

  // 模拟每秒发送数据的事件流
  const interval = setInterval(() => {
    counter++;
    const data = JSON.stringify({
      text: `你说的是: ${req.body?.message}.....${counter}`,
      counter,
      type: "message",
    });
    res.write(`data: ${data}\n\n`);
  }, 1000);

  // 监听连接关闭事件,清理资源
  req.on("close", () => {
    setTimeout(() => {
      clearInterval(interval);
      res.end();
    }, 10000);
  });
});

app.listen(3000, () => {
  console.log("Server is running on http://localhost:3000");
});

可以看到我们通过创建一个定时器,然后接收一个用户输入的message(代表用户输入的问题),然后返回。这里为了说明数据的合并,我添加了一个counter值。然后我监听客户端的close事件,延迟10s关闭定时器,用以终止传递数据,当然真实的业务场景应该是前端轮询一个后端服务接口用来判断会话是否已结束。为了方便在本地访问,我也添加了允许跨域的代码。

6. 前端添加合并工具函数

接下来,我们需要添加合并数据的工具函数,由于原理我们已经在前文所描述,这里不做过多解释,代码如下:

export const mergeMessagesByType = (arr: T[]) => {
  const stepMerge = (arr: T[], filterFn: (item: T) => boolean) => {
    const temp: Record<
      string,
      Omit | number | string | boolean
    > = {};
    let orderTypeId = -1;
    const result: T[] = [];

    arr.forEach((item, index) => {
      const {
        name,
        timestamp,
        isEnd,
        isNext,
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        messageId,
        type = "message",
        ...rest
      } = item;
      if (filterFn(item)) {
        temp[type] = { type, ...rest };
        temp.name = name;
        temp.timestamp = timestamp;
        temp.isEnd = isEnd || false;
        temp.isNext = isNext || false;
        temp.type = type;
        if (orderTypeId === -1) {
          orderTypeId = arr[Math.max(index - 1, 0)].messageId as number;
        }
      } else {
        result.push(item);
      }
    });

    if (Object.keys(temp).length > 0) {
      const spliceIndex = result.findIndex(
        (item) => item.messageId === orderTypeId
      );
      result.splice(spliceIndex + 1, 0, {
        ...temp,
        messageId: orderTypeId,
      } as T);
    }
    return result.map((item, index) => ({ ...item, messageId: index + 1 }));
  };

  return stepMerge(arr, (item) => item?.type === "message");
};

注意这里我是通过这个条件type === 'message'来进行合并,也就是说我们的消息分成2种,第一种就是我们用mock.ts模拟的数据,type我们规定为text,然后是用户问的问题,同样的我们也规定为text,第二种就是通过调用ai服务返回的数据,这里我们通过node来创建一个定时器模拟ai的回答。

对话界面

对话界面如下图所示:

主要包含了3个部分:

  1. 标题
  2. 对话渲染。
  3. 用户交互

其中对话渲染包含头像(机器人和用户),名字(机器人和用户),日期,以及问题/回答。用户交互区域包含一个用户输入问题的多行输入框,以及清空对话和点击发送按钮。

对应组件代码如下:

标题:


  一个模拟的聊天对话界面

对话渲染:


   {groupedMessages.map((group, idx) => (
          
            {group.map((msg, i) => (
              
                
                  
                    
                      {msg.name === "bot" ? (
                        
                      ) : (
                        
                      )}
                      {msg.name}
                    
                  
                  
                    {dayjs(msg.timestamp).format("YYYY-MM-DD HH:mm:ss")}
                  
                
                
                  
                
              
            ))}
          
   ))}

可以看到,我们通过Row组件分隔,将对话渲染区域分成了2个部分,并且通过Card组件包裹,然后第一个部分就是我们的头像,名字以及日期渲染区域。然后就是我们的回答渲染区域,即RenderContent组件。

再然后是我们的用户交互区: