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

534 阅读10分钟

本文,我们将根据前文来实现一个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 = <T>(
  str: string,
  type: parseStrType = parseStrType.JSON
) => {
  const parseMethod = {
    [parseStrType.EVAL]: <T>(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 = <T>(
  str: string,
  type: parseStrType = parseStrType.JSON
) => {
  • parseStr 是一个泛型函数,接收两个参数:
    • str: 要解析的字符串(string 类型)。
    • type: 指定解析类型的枚举,默认为 parseStrType.JSON,即使用 JSON.parse 解析。

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

3. parseMethod 对象
const parseMethod = {
  [parseStrType.EVAL]: <T>(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<T>(
  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<T>(saved);
    } else {
      return initialValue;
    }
  };

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

  useEffect(() => {
    const handleStorageChange = (event: StorageEvent) => {
      if (event.key === key) {
        setStoredValue(event.newValue ? parseStr<T>(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<T>(
  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<T>(saved);
  } else {
    return initialValue;
  }
};
  • getStoredValue 函数从 currentStorage 中获取数据:
    • 如果存储中找到了对应的 key,则解析存储的字符串(通过 parseStr)并返回值。
    • 如果存储中没有数据(即 saved === null),则返回 initialValue 作为默认值。
    • parseStr<T> 函数用来将存储的字符串反序列化为 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<T>(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)更加简单和方便,同时保证了类型的安全性。

接下来,我们就需要实现一个聊天界面,由于篇幅比较长,所以分成了2篇,让我们继续在下一篇中相会,感谢阅读,如果觉得有用,望不吝啬点赞收藏。