YingXingAI/utils/chat.js

323 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import dayjs from "dayjs";
/**
* 判断聊天列表中某条消息上方是否需要展示时间分隔。
*
* 典型用法:在 `v-for` 渲染消息时,根据相邻两条消息的时间差决定是否插入时间条。
*
* 规则:
* - 第一条消息始终展示时间
* - 其他消息:与上一条消息时间差大于 `gapMs` 时展示
*
* 边界与兼容:
* - `messageList` 不是数组 / 为空:返回 `false`
* - `sendDate` 为空或无法解析为有效时间戳:返回 `false`
*
* @param {Array<{ sendDate?: string }>} messageList - 消息数组(按时间正序)
* @param {number} index - 当前消息在数组中的下标
* @param {number} gapMs - 时间间隔阈值(毫秒),默认 30 分钟
* @returns {boolean} - 是否需要展示时间
*/
export function shouldShowTime(messageList, index, gapMs = 30 * 60 * 1000) {
if (!Array.isArray(messageList) || !messageList.length) return false;
if (index === 0) return true;
const current = messageList[index];
const prev = messageList[index - 1];
// `sendDate` 期望为 ISO 字符串或能被 Date 解析的字符串
const currentTime = new Date(current && current.sendDate).getTime();
const prevTime = new Date(prev && prev.sendDate).getTime();
if (Number.isNaN(currentTime) || Number.isNaN(prevTime)) return false;
return currentTime - prevTime > gapMs;
}
/**
* 将消息发送时间格式化为聊天页可展示的文本。
*
* 规则(与页面原逻辑一致):
* - 60 秒内:`刚刚`
* - 1 小时内:`x分钟前`
* - 今天:`今天 HH:mm`
* - 昨天:`昨天 HH:mm`
* - 更早但在今年:`MM月DD日 HH:mm`
* - 往年:`YYYY年MM月DD日 HH:mm`
*
* 说明:
* - 使用 `dayjs` 进行日期差计算与格式化,避免手写边界处理。
* - 若传入的 `sendDate` 不可解析dayjs 会返回 Invalid Date
* 此时 diff 结果可能不符合预期;上层建议确保 sendDate 为后端返回的时间字符串。
*
* @param {string} sendDate - 消息发送时间(后端返回的时间字符串)
* @returns {string} - 格式化后的展示文本
*/
export function formatChatShowTime(sendDate) {
const now = dayjs();
const msgTime = dayjs(sendDate);
if (now.diff(msgTime, "second") < 60) {
return "刚刚";
}
if (now.diff(msgTime, "hour") < 1) {
return `${now.diff(msgTime, "minute")}分钟前`;
}
// 使用 startOf('day') 抹平时分秒,方便按“天”比较
const today = dayjs().startOf("day");
const msgDay = dayjs(sendDate).startOf("day");
const dayDiff = today.diff(msgDay, "day");
if (dayDiff === 0) {
return `今天 ${msgTime.format("HH:mm")}`;
}
if (dayDiff === 1) {
return `昨天 ${msgTime.format("HH:mm")}`;
}
// 使用 startOf('year') 抹平年月日,方便按“年”比较
const thisYear = dayjs().startOf("year");
const msgYear = dayjs(sendDate).startOf("year");
const yearDiff = thisYear.diff(msgYear, "year");
if (yearDiff === 0) {
return `${msgTime.format("MM月DD日 HH:mm")}`;
}
return `${msgTime.format("YYYY年MM月DD日 HH:mm")}`;
}
/**
* 通过计算内容容器高度的方式,滚动到底部(与首页实现保持一致)。
*
* 为什么不用 `scroll-into-view`
* - 私聊场景经常需要“追加/插入消息”并保持当前视口稳定
* - `scroll-into-view` 在某些端会触发额外的自动滚动/动画,造成跳动体验
*
* 工作方式:
* - 使用 `uni.createSelectorQuery().in(vm)` 取到组件内 `selector` 对应节点高度
* - 以节点高度作为 `scrollTop` 的目标值,并加上 `extraOffset` 作为底部缓冲
*
* 使用要求:
* - `vm` 必须是 Vue 组件实例(需要 `vm.$nextTick` 与 `vm.scrollTop`
* - `selector` 对应的节点需要存在且能计算出 `boundingClientRect`
*
* @param {any} vm - Vue 组件实例(一般直接传 `this`
* @param {{ selector?: string; extraOffset?: number }} options - 选择器与额外偏移
*/
export function scrollToBottomByContentHeight(
vm,
{ selector = ".chat-content", extraOffset = 200 } = {},
) {
if (!vm) return;
vm.$nextTick(() => {
const query = uni.createSelectorQuery().in(vm);
query
.select(selector)
.boundingClientRect((data) => {
if (data) {
// 这里用高度作为 scrollTop 目标值即可“贴底”,额外加一点缓冲避免极端情况没到最底
vm.scrollTop = Number(data.height || 0) + Number(extraOffset || 0);
}
})
.exec();
});
}
/**
* 处理聊天消息内容:若内容为 JSON 字符串且符合预期结构,则提取并拼接为可读文本。
*
* 说明:
* - 首页对话存在部分消息内容为 JSON 字符串的情况(如包含 `answerTypeLabel/imageUrl/nearbyPaths`
* - 这里做“尽量解析、解析失败回退原文”的处理,避免误伤普通文本消息
*
* @param {string} message - 原始消息文本
* @returns {string} - 处理后的文本
*/
export function processChatMessageContent(message) {
try {
const parsed = JSON.parse(message);
if (typeof parsed === "object" && parsed !== null) {
let content = "";
const { answerTypeLabel, imageUrl, nearbyPaths } = parsed;
if (answerTypeLabel) content += `${answerTypeLabel}\n`;
if (imageUrl) content += `${imageUrl}\n`;
if (nearbyPaths) content += `${nearbyPaths}\n`;
return content ? content.trim() : message;
}
} catch (e) {
return message;
}
return message;
}
// 解析 sendDate 为微秒级时间戳(防止毫秒级精度丢失)
function getSendDateMicroValue(sendDate) {
const raw = String(sendDate || "").trim();
if (!raw) return Number.NaN;
const match = raw.match(/^(.+?)(?:\.(\d+))?(Z|[+-]\d{2}:?\d{2})?$/);
if (!match) {
const ms = new Date(raw).getTime();
return Number.isNaN(ms) ? Number.NaN : ms * 1000;
}
const base = match[1] || "";
const fraction = match[2] || "";
const zone = match[3] || "";
const baseMs = new Date(`${base}${zone}`).getTime();
if (Number.isNaN(baseMs)) return Number.NaN;
const microStr = `${fraction}000000`.slice(0, 6);
const microValue = Number(microStr);
return baseMs * 1000 + microValue;
}
/**
* 聊天消息排序:按 `sendDate` 升序;时间相同时,用户消息(`interactMode=0`)排在前面。
* 同时会对每条消息的 `message` 字段进行 `processChatMessageContent` 处理。
*
* @param {Array<any>} list - 会话详情接口返回的消息列表
* @returns {Array<any>} - 处理并排序后的新数组
*/
export function sortChatMessages(list = []) {
const processedList = (list || []).map((item) => ({
...item,
rawMessage: item && item.message,
message: processChatMessageContent(item && item.message),
}));
return processedList.sort((a, b) => {
const timeA = getSendDateMicroValue(a && a.sendDate);
const timeB = getSendDateMicroValue(b && b.sendDate);
if (Number.isNaN(timeA) && Number.isNaN(timeB)) {
return (a && a.interactMode) - (b && b.interactMode);
}
if (Number.isNaN(timeA)) return 1;
if (Number.isNaN(timeB)) return -1;
if (timeA === timeB) return (a && a.interactMode) - (b && b.interactMode);
return timeA - timeB;
});
}
/**
* 将 Date 格式化为 `HH:mm`。
*
* @param {Date} date - 时间对象
* @returns {string} - 格式化字符串
*/
export function formatHHmm(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return "";
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
}
/**
* 规范资源列表将字符串或数组转换为trimmed、非空字符串数组。
*
* @param {string|Array<string>} value - 原始资源列表
* @returns {Array<string>} - 规范后的资源数组
*/
function normalizeResourceList(value) {
if (!value) return [];
if (Array.isArray(value)) {
return value.map((item) => String(item || "").trim()).filter(Boolean);
}
return String(value)
.split(/[\n,|]/)
.map((item) => item.trim())
.filter(Boolean);
}
/**
* 格式化消息资源URL若URL已完整包含协议头或blob URI则直接返回否则拼接基础URL。
*
* @param {string} path - 原始资源路径
* @param {string} baseUrl - 基础URL可选
* @returns {string} - 格式化后的URL
*/
function formatMessageResourceUrl(path, baseUrl) {
if (!path) return "";
const currentPath = String(path).trim();
if (!currentPath) return "";
if (
currentPath.startsWith("http://") ||
currentPath.startsWith("https://") ||
currentPath.startsWith("blob:")
) {
return currentPath;
}
const cleanPath = currentPath.replace(/\\/g, "/");
const cleanBaseUrl = String(baseUrl || "")
.replace(/\\/g, "/")
.replace(/\/$/, "");
if (!cleanBaseUrl) return cleanPath;
if (cleanPath.startsWith("/")) return `${cleanBaseUrl}${cleanPath}`;
return `${cleanBaseUrl}/${cleanPath}`;
}
function getFileNameFromPath(path) {
const cleanPath = String(path || "")
.replace(/\\/g, "/")
.split("?")[0];
const segments = cleanPath.split("/");
const fileName = segments[segments.length - 1] || "下载文件";
try {
return decodeURIComponent(fileName);
} catch (error) {
return fileName;
}
}
/**
* 解析结构化消息块从JSON字符串中提取文本、图片、文件资源。
*
* @param {string} message - 原始消息字符串包含JSON格式
* @param {string} baseUrl - 基础URL可选用于格式化资源URL
* @returns {Array<{type: string, value?: string, url?: string, name?: string}>} - 解析后的消息块数组
*/
export function parseStructuredMessageBlocks(message, baseUrl) {
const rawMessage =
(message && (message.rawMessage || message.message)) || "";
if (!rawMessage || typeof rawMessage !== "string") return [];
let parsed = null;
try {
parsed = JSON.parse(rawMessage);
} catch (error) {
return [];
}
if (!parsed || typeof parsed !== "object") return [];
const answerTypeLabel = (parsed.answerTypeLabel || "").trim();
const imageUrl = parsed.imageUrl || "";
const nearbyPaths = parsed.nearbyPaths || "";
if (!answerTypeLabel && !imageUrl && !nearbyPaths) return [];
const blocks = [];
if (answerTypeLabel) {
blocks.push({ type: "text", value: answerTypeLabel });
}
normalizeResourceList(imageUrl).forEach((item) => {
const url = formatMessageResourceUrl(item, baseUrl);
if (url) blocks.push({ type: "image", url });
});
normalizeResourceList(nearbyPaths).forEach((item) => {
const url = formatMessageResourceUrl(item, baseUrl);
if (!url) return;
blocks.push({
type: "file",
url,
name: getFileNameFromPath(item),
});
});
return blocks;
}