feat(chat): AI回复支持结构化消息渲染,包括文本、图片和文件

This commit is contained in:
yangzhe 2026-03-19 11:06:30 +08:00
parent 3c4aaa0f77
commit 8e9c57a6f9
2 changed files with 285 additions and 5 deletions

View File

@ -258,6 +258,8 @@
class="message-content" class="message-content"
:class="{ :class="{
'message-content-width': !message.isLoading, 'message-content-width': !message.isLoading,
'message-content-structured':
getStructuredMessageBlocks(message).length,
}" }"
> >
<!-- 加载动画 --> <!-- 加载动画 -->
@ -266,7 +268,47 @@
<view class="dot"></view> <view class="dot"></view>
<view class="dot"></view> <view class="dot"></view>
</view> </view>
<!-- 正常消息内容 --> <view
v-else-if="getStructuredMessageBlocks(message).length"
class="structured-message"
>
<view
v-for="(block, blockIndex) in getStructuredMessageBlocks(
message,
)"
:key="`${message.id || index}-block-${blockIndex}`"
class="structured-item"
:class="`structured-item-${block.type}`"
>
<text
v-if="block.type === 'text'"
class="structured-text"
>
{{ block.value }}
</text>
<image
v-else-if="block.type === 'image'"
class="structured-image"
:src="block.url"
mode="widthFix"
@click="previewMessageImage(block.url)"
></image>
<view
v-else-if="block.type === 'file'"
class="structured-file"
@click="downloadMessageFile(block.url)"
>
<u-icon
name="download"
size="28"
color="#4370fe"
></u-icon>
<text class="structured-file-name">{{
block.name
}}</text>
</view>
</view>
</view>
<markdown-viewer v-else :content="message.message" /> <markdown-viewer v-else :content="message.message" />
</view> </view>
</view> </view>
@ -322,6 +364,8 @@
class="message-content" class="message-content"
:class="{ :class="{
'message-content-width': !message.isLoading, 'message-content-width': !message.isLoading,
'message-content-structured':
getStructuredMessageBlocks(message).length,
}" }"
> >
<view v-if="message.isLoading" class="loading-dots"> <view v-if="message.isLoading" class="loading-dots">
@ -329,6 +373,47 @@
<view class="dot"></view> <view class="dot"></view>
<view class="dot"></view> <view class="dot"></view>
</view> </view>
<view
v-else-if="getStructuredMessageBlocks(message).length"
class="structured-message"
>
<view
v-for="(block, blockIndex) in getStructuredMessageBlocks(
message,
)"
:key="`${message.id || index}-teacher-block-${blockIndex}`"
class="structured-item"
:class="`structured-item-${block.type}`"
>
<text
v-if="block.type === 'text'"
class="structured-text"
>
{{ block.value }}
</text>
<image
v-else-if="block.type === 'image'"
class="structured-image"
:src="block.url"
mode="widthFix"
@click="previewMessageImage(block.url)"
></image>
<view
v-else-if="block.type === 'file'"
class="structured-file"
@click="downloadMessageFile(block.url)"
>
<u-icon
name="download"
size="28"
color="#4370fe"
></u-icon>
<text class="structured-file-name">{{
block.name
}}</text>
</view>
</view>
</view>
<markdown-viewer v-else :content="message.message" /> <markdown-viewer v-else :content="message.message" />
</view> </view>
</view> </view>
@ -1087,12 +1172,20 @@ export default {
(msg) => !msg.isLoading, (msg) => !msg.isLoading,
); );
const answerTypeLabel = data.detailedExplanation; //
const imageUrl = data.imageUrl; //
const nearbyPaths = data.nearbyPaths; //
const message = `{\r\n "answerTypeLabel": ${JSON.stringify(
answerTypeLabel || "",
)},\r\n "imageUrl": ${JSON.stringify(
imageUrl || "",
)},\r\n "nearbyPaths": ${JSON.stringify(nearbyPaths || "")}\r\n}`;
// AI // AI
const aiMessage = { const aiMessage = {
id: id:
data.conversationId || this.currentDMid || Math.random().toString(36).substring(2, 15),
Math.random().toString(36).substring(2, 15), message: message,
message: data.detailedExplanation,
sendDate: "", sendDate: "",
isSend: true, isSend: true,
isRead: false, isRead: false,
@ -1160,6 +1253,107 @@ export default {
this.handleGetConversationDetail(); this.handleGetConversationDetail();
}, },
//
getStructuredMessageBlocks(message) {
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 });
}
this.normalizeResourceList(imageUrl).forEach((item) => {
const url = this.formatMessageResourceUrl(item);
if (url) blocks.push({ type: "image", url });
});
this.normalizeResourceList(nearbyPaths).forEach((item) => {
const url = this.formatMessageResourceUrl(item);
if (!url) return;
blocks.push({
type: "file",
url,
name: this.getFileNameFromPath(item),
});
});
return blocks;
},
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);
},
formatMessageResourceUrl(path) {
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 = (this.baseUrl || "")
.replace(/\\/g, "/")
.replace(/\/$/, "");
if (!cleanBaseUrl) return cleanPath;
if (cleanPath.startsWith("/")) return `${cleanBaseUrl}${cleanPath}`;
return `${cleanBaseUrl}/${cleanPath}`;
},
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;
}
},
previewMessageImage(url) {
if (!url) return;
uni.previewImage({
current: url,
urls: [url],
});
},
downloadMessageFile(url) {
if (!url) return;
if (typeof window !== "undefined" && window.open) {
window.open(url, "_blank");
return;
}
uni.downloadFile({
url,
});
},
// JSON // JSON
processMessageContent(message) { processMessageContent(message) {
return processChatMessageContent(message); return processChatMessageContent(message);
@ -1307,7 +1501,14 @@ export default {
// /&退 // /&退
handleFeedback(message, isHelp) { handleFeedback(message, isHelp) {
this.$u.api.ModifyStatus({ id: message.id, isHelp }).then((res) => { this.$u.api.ModifyStatus({ id: message.id, isHelp }).then((res) => {
if (!res.succeed) return; if (!res.succeed) {
uni.showToast({
title: res.error || "操作失败",
icon: "none",
});
return;
}
this.$u.toast("操作成功"); this.$u.toast("操作成功");
// 退 // 退
this.refreshPageWithFallback(); this.refreshPageWithFallback();
@ -1720,6 +1921,12 @@ export default {
font-size: 28rpx; font-size: 28rpx;
line-height: 1.5; line-height: 1.5;
&.message-content-structured {
background-color: transparent;
padding: 0;
border-radius: 0;
}
/* 加载动画样式 */ /* 加载动画样式 */
.loading-dots { .loading-dots {
display: flex; display: flex;
@ -1792,6 +1999,78 @@ export default {
font-size: 24rpx; font-size: 24rpx;
} }
} }
.structured-message {
.structured-item + .structured-item {
margin-top: 16rpx;
}
.structured-item {
max-width: 100%;
}
.structured-item-text {
.structured-text {
display: inline-block;
background-color: #ffffff;
color: #333333;
font-size: 28rpx;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
padding: 20rpx 24rpx;
border-radius: 0 16rpx 16rpx 16rpx;
}
}
.structured-text {
display: inline-block;
color: #333333;
font-size: 28rpx;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.structured-item-image {
.structured-image {
display: block;
max-width: 240rpx;
border-radius: 12rpx;
}
}
.structured-image {
display: block;
max-width: 240rpx;
border-radius: 12rpx;
}
.structured-item-file {
.structured-file {
display: inline-flex;
align-items: center;
max-width: 100%;
padding: 16rpx 20rpx;
background-color: #ffffff;
border-radius: 12rpx;
}
}
.structured-file {
display: inline-flex;
align-items: center;
max-width: 100%;
padding: 8rpx 0;
}
.structured-file-name {
margin-left: 8rpx;
font-size: 26rpx;
color: #4370fe;
word-break: break-all;
}
}
} }
.message-content-width { .message-content-width {

View File

@ -164,6 +164,7 @@ export function processChatMessageContent(message) {
export function sortChatMessages(list = []) { export function sortChatMessages(list = []) {
const processedList = (list || []).map((item) => ({ const processedList = (list || []).map((item) => ({
...item, ...item,
rawMessage: item && item.message,
message: processChatMessageContent(item && item.message), message: processChatMessageContent(item && item.message),
})); }));