头图

本文,我们将根据前文来实现一个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)更加简单和方便,同时保证了类型的安全性。

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 = <T extends Message>(arr: T[]) => {
  const stepMerge = (arr: T[], filterFn: (item: T) => boolean) => {
    const temp: Record<
      string,
      Omit<Message, "name" | "timestamp"> | 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的回答。

对话界面

对话界面如下图所示:

截屏2024-12-08 下午6.21.00.png

截屏2024-12-08 下午6.21.08.png

主要包含了3个部分:

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

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

对应组件代码如下:

标题:

<Typography.Title level={3} style={{ textAlign: "center" }}>
  一个模拟的聊天对话界面
</Typography.Title>

对话渲染:

<Row gutter={[16, 16]}>
   {groupedMessages.map((group, idx) => (
          <Col span={24} key={idx}>
            {group.map((msg, i) => (
              <Card key={i} style={{ marginBottom: "10px" }}>
                <Row style={{ marginBottom: 15 }}>
                  <Col span={24} style={{ marginBottom: 6 }}>
                    <Row align="middle">
                      {msg.name === "bot" ? (
                        <RobotOutlined
                          style={{ fontSize: 25, marginRight: 8 }}
                        />
                      ) : (
                        <UserOutlined
                          style={{ fontSize: 25, marginRight: 8 }}
                        />
                      )}
                      <Text strong>{msg.name}</Text>
                    </Row>
                  </Col>
                  <Col style={{ fontSize: "12px", color: "gray" }} span={24}>
                    {dayjs(msg.timestamp).format("YYYY-MM-DD HH:mm:ss")}
                  </Col>
                </Row>
                <Row
                  style={{
                    backgroundColor: "#98b7ef",
                    color: "#fff",
                    padding: 15,
                    borderRadius: 10,
                  }}
                >
                  <RenderContent {...msg} />
                </Row>
              </Card>
            ))}
          </Col>
   ))}
</Row>

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

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

 <Row style={{ marginTop: "20px" }} gutter={5} align="middle" wrap>
        <Col lg={{ span: 21 }} xs={{ span: 24 }}>
          <TextArea
            rows={2}
            value={inputMessage}
            onChange={(e) => setInputMessage(e.target.value)}
            placeholder="请输入你的问题"
          />
        </Col>
        <Col lg={{ span: 3 }} xs={{ span: 24 }}>
          <Flex justify="flex-end" align="center">
            <Space>
              <Popconfirm
                title="温馨提示"
                icon={null}
                description="确定要清空所有对话吗?"
                onConfirm={handleResetMessage}
                okText="确认"
                cancelText="取消"
              >
                <Button
                  type="primary"
                  danger
                  style={{ marginTop: "10px" }}
                  icon={<DeleteOutlined />}
                />
              </Popconfirm>
              <Button
                type="primary"
                style={{ marginTop: "10px" }}
                onClick={handleSendMessage}
                icon={<SendOutlined />}
              />
            </Space>
          </Flex>
     </Col>
</Row>

RenderContent组件

RenderContent组件就是渲染消息的组件,消息分成了2种,一种是用户的问题,就是一个字符串,还有一种就是我们根据type为message来合并的机器人的回复,这里的回复在真实场景当中渲染是十分复杂,比如可以涉及到markdown相关渲染,然后还可能涉及到基于json-schema来渲染表单等等。但在这里我们模拟的ai服务接口只是一个字符串,因此我们只需要考虑渲染字符串即可。如下:

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

export interface RenderContentProps {
  type?: string;
  text?: string;
  message?: Message;
}
export const RenderContent = ({ text, message }: RenderContentProps) => {
  if (
    typeof message === "object" &&
    message !== null &&
    message?.type === RenderType.MESSAGE
  ) {
    return <>{message?.text}</>;
  }
  return <>{text}</>;
};
注意: 真实业务场景,我们是需要扩展这个组件的。

比如说我们渲染的是markdown,而且markdown当中还有图片,我们需要添加图片预览,这又该如何实现呢?

实现思路如下:

  1. 我们通过dom api来获取markdown中的所有图片元素,然后还需要使用MutationObserver api来监听dom的变动。
  2. 然后我们可以基于antd的受控的preview示例封装一个preview组件,这个组件暴露出src和visible属性即可。
  3. 给获取到的dom图片元素添加点击事件,然后修改visible和src属性即可。

再比如我们需要基于json-schema来渲染表单呢?这就不得不用到react-jsonschema-form这个库呢。

如果返回的消息内容即message.text是一段jsonschema,我们就可以根据type字段值来判断,从而决定渲染的是这个表单。

滚动元素

由于我们的机器人回答,是一步一步返回数据的,因此我们的页面为了同步,就需要每次返回数据都需要页面滚动到底,这里我们通过在用户交互区前面添加一个div元素,然后监听我们的messages(用以管理消息数据的状态),只要触发了它的变动,就自己调用元素的scrollIntoView方法(这个方法兼容性还是不错的)来滚动到底。这里我们通过一个useRef来管理这个滚动元素。根据以上分析,我们可以写出如下代码:

{/* 滚动到底部的 div */}
<div ref={endOfMessagesRef} />
// ... 用户交互区组件
const endOfMessagesRef = useRef<HTMLDivElement>(null); // 用于滚动到底部
useEffect(() => {
    if (endOfMessagesRef.current) {
      endOfMessagesRef.current.scrollIntoView({ behavior: "smooth" });
    }
}, [messages]);

核心逻辑

核心逻辑主要有如下:

  1. 清空对话。
  2. 用户输入对话。
  3. 根据用户的问题,调用ai服务接口请求。
  4. 接收回来的消息数据只能算是当前会话,根据相关分组工具函数进行分组以及合并工具函数进行合并并渲染。

我们用一个storage状态来缓存会话消息和一个消息状态用来渲染当前交互,再用一个状态用来监听用户的输入,如下:

// 缓存消息数据
 const [storeMessages, setStoreMessages] = useStorage<Message[]>(
    STORE_MESSAGR_KEY,
    getMockMessages()
);
// 用来交互的消息数据
const [messages, setMessages] = useState<Message[]>([]);
// 用户输入信息
const [inputMessage, setInputMessage] = useState<string>("");

接下来,我们监听缓存数据,当然也在页面挂载的时候,添加我们的mock好的数据。如下所示:

useEffect(() => {
    if (storeMessages && storeMessages?.length > 0 && !messages.length) {
      setMessages(storeMessages!);
    }
}, [storeMessages]);

useEffect(() => {
   const msgs = getMockMessages();
   setStoreMessages(msgs);
}, []);

注意,我们的合并消息需要根据messageId来合并,并且还需要保持消息的渲染顺序不变,因此我们需要给每条消息添加一个messageId,值为它的索引值,修改我们的getMockMessages函数内部。如下:

export const getMockMessages = () => {
  return generateMessage().messages.map((message: Message, index: number) => ({
    ...message,
    timestamp: dayjs(message.timestamp).unix() * 1000,
    isEnd: false,
    messageId: index + 1, // 方便合并数据后保持顺序不变
  }));
};

接下来,我们处理一下清空会话的逻辑,这个很简单,就是清除数据即可,当然必要的时候,我们也需要调用后端的服务接口,用来清除对话。如下:

const handleResetMessage = () => {
    // 真实业务需要调用后端提供的结束会话服务接口
    setMessages([]);
    setStoreMessages([]);
};

然后就是我们的会话渲染数据了,当然还有最后一个重点,那就是用户输入问题的交互逻辑,这个容我放在最后,先来看我们的会话渲染数据groupedMessages,我们用useMemo缓存,监听messages是否变动,因为我们是通过修改messages来添加会话消息的。然后只要存在messages,就进行分组合并,代码如下:

 const groupedMessages = useMemo(() => {
    if (messages.length > 0) {
     // 先将会话分组,再进行合并,注意分组的条件
      const mergeData = groupByInterval(messages,(item) => item.isNext || item.isEnd).map((group) =>
        mergeMessagesByType(group)
      );
      return mergeData;
    }
    return [];
  }, [messages]);

注意这里我们的分组条件是(item) => item.isNext || item.isEnd,即2种场景,第一种就是用户输入下一个问题,这也属于下一个会话,我们的合并逻辑也就需要分开,第二种场景就是会话结束。

最后的逻辑---根据用户输入问题来回答

实际上我们就是监听用户输入的数据,然后把用户的提问也当成一个消息数据来渲染,然后发起请求,调用ai服务获取答案。如下所示:

const handleSendMessage = async () => {
    if (!inputMessage.trim())
      return ewMessage.warning({
        content: "请输入内容",
        removeClassName: ["animate__bounceOut", "animate__animated"],
        startClassName: ["animate__animated", "animate__bounce"],
        duration: 4000,
      });

    const userMessage: Message = {
      name: "夕水",
      text: inputMessage,
      timestamp: new Date().getTime(),
      isEnd: false,
      type: "text",
      isNext: true,
    };
    setMessages((p) => [...p, { ...userMessage, messageId: p.length + 1 }]);
    await getChatbotResponse<Message>(inputMessage, (data) => {
      setMessages((prevMessages) => [
        ...prevMessages,
        {
          ...data,
          name: "机器人-毛毛",
          timestamp: new Date().getTime(),
          isEnd: false,
          isNext: false,
          messageId: prevMessages.length + 1,
        },
      ]);
    });
    setInputMessage(""); // 清空输入框
  };

这里分成了3个逻辑,第一个逻辑,用户没有输入问题的时候,给出一个消息提示,这里我用到的是我写的消息提示框插件,用法参考官网

ewMessage.warning({
   content: "请输入内容",
   removeClassName: ["animate__bounceOut", "animate__animated"],
   startClassName: ["animate__animated", "animate__bounce"],
   duration: 4000,
})

这里用到了animated.css动画库。

第二个逻辑就是用户的输入当成一条消息添加进去,注意这里我们也要添加messageId。

第三个逻辑就是getChatbotResponse函数了,这个逻辑用来调用ai服务,它的核心逻辑就是调用fetchEventSource api,我们用的是@microsoft/fetch-event-source。然后我们提供一个回调函数,返回每一次请求的结果,代码如下所示:

import { Message } from "../types/message";
import { parseStr } from "../utils/utils";
import { fetchEventSource } from "@microsoft/fetch-event-source";
export const getChatbotResponse = async <T extends Message>(
  message: string,
  callback: (data: T) => void
) => {
  const ctrl = new AbortController();
  return new Promise<T[]>((resolve, reject) => {
    const messages: Message[] = [];
    // 真实业务场景,我们只需要替换接口路径,然后就是添加headers的api_key以及添加body参数的传入值即可
    fetchEventSource(`http://localhost:3000/events`, {
      method: "POST",
      body: JSON.stringify({ message }),
      headers: {
        "Content-Type": "application/json",
      },
      signal: ctrl.signal,
      onmessage(event) {
        const data = parseStr<T>(event.data);
        if (data) {
          messages.push(data);
          callback?.(data);
        }
      },
      onerror(error) {
        ctrl.abort();
        reject(error);
      },
      onclose() {
        ctrl.abort();
        resolve(messages as T[]);
      },
    });
  });
};

在真实业务场景,我们只需要替换接口路径,然后就是添加headers的api\_key以及添加body参数的传入值即可。

最后

将以上代码和前文所有代码综合起来,就得到了我们实现的一个模拟ai聊天对话的项目。最后我们输入一个问题,ai回复只会渲染最终的一个值。如下图所示:

截屏2024-12-08 下午7.10.21.png

这就是数据合并的意义所在。

总结

通过前文以及本文,我们也了解了前端实现ai聊天会话的三个原理。总结如下:

  1. 实现机器人的流式回复
  2. 会话消息的设计以及分组
  3. 会话消息的合并

也印证了我们这篇文章所描述的核心原理。这说明只要掌握核心原理,实现ai会话也不难。

特别说明: 这个模拟示例源码,我放到了这里,感兴趣可以下载下来,自行运行看看。

夕水
5.3k 声望5.8k 粉丝

问之以是非而观其志,穷之以辞辩而观其变,资之以计谋而观其识,告知以祸难而观其勇,醉之以酒而观其性,临之以利而观其廉,期之以事而观其信。