feat: 长文本组件的对接调试

This commit is contained in:
2026-05-20 15:26:52 +08:00
parent dd7b41d1ad
commit d087ee6b35
6 changed files with 316 additions and 63 deletions

View File

@@ -8,8 +8,19 @@
<!-- 滚动区域 -->
<scroll-view class="flex-full overflow-hidden chat-scroll" scroll-y :scroll-into-view="scrollIntoViewId" scroll-with-animation @scroll="onScroll" @touchstart="onTouchStart" @touchend="onTouchEnd" @touchcancel="onTouchEnd">
<view class="pt-12 px-12 pb-24 border-box">
<!-- 内容支持markdown -->
<ChatMarkdown :text="answerText" />
<template v-for="section in renderSections" :key="section.contentKey">
<view v-if="section.contentKey === 'tag'" class="long-answer-tag">
{{ section.contentValue }}
</view>
<view v-else-if="section.contentKey === 'title'" class="long-answer-title">
{{ section.contentValue }}
</view>
<ChatMarkdown v-else-if="section.contentKey === 'content'" :text="section.contentValue" />
<view v-else-if="section.parsedValue !== null" class="long-answer-block">
<ChatMarkdown :text="toMarkdownText(section.parsedValue)" />
</view>
<ChatMarkdown v-else :text="section.contentValue" />
</template>
<!-- 底部锚点必须存在 -->
<view id="bottom-anchor"></view>
@@ -21,9 +32,13 @@
<script setup>
import TopNavBar from "@/components/TopNavBar/index.vue";
import ChatMarkdown from "../ChatMarkdown/index.vue";
import { defineProps, ref, nextTick } from "vue";
import { defineProps, ref, nextTick, computed } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import StreamManager from "@/utils/StreamManager.js";
import {
getLongTextSections,
getLongTextValue,
} from "@/utils/longTextCard";
const props = defineProps({
answerText: {
@@ -34,9 +49,36 @@ const props = defineProps({
const answerText = ref(props.answerText || "");
const title = ref("");
const longTextData = ref(null);
let unsubscribe = null;
const toMarkdownText = (value) => {
if (value === undefined || value === null) return "";
if (Array.isArray(value)) {
return value.map((item) => toMarkdownText(item)).filter(Boolean).join("\n\n");
}
if (typeof value === "object") {
try {
return JSON.stringify(value, null, 2);
} catch (e) {
return String(value);
}
}
return String(value);
};
const renderSections = computed(() => {
const data = longTextData.value;
if (data && data.values) {
return getLongTextSections(data).filter((section) => section.contentValue);
}
return answerText.value
? [{ contentKey: "content", contentValue: answerText.value }]
: [];
});
/** ✅ scroll-into-view 控制 */
const scrollIntoViewId = ref("");
@@ -147,9 +189,12 @@ onLoad(({ message = "", streamId = "", finished = "0" }) => {
// ✅ 流式数据
unsubscribe = StreamManager.subscribe(
streamId,
(text = "", finished = false) => {
(text = "", finished = false, payload = null) => {
answerText.value = text || "";
title.value = computeTitle(answerText.value);
longTextData.value = payload || null;
title.value = computeTitle(
getLongTextValue(longTextData.value, "title") || answerText.value
);
nextTick(() => {
// 每次接收数据都重新测量高度content size 可能变化,比如加载图)
@@ -169,6 +214,7 @@ onLoad(({ message = "", streamId = "", finished = "0" }) => {
} else {
// ✅ 非流式
answerText.value = decodeURIComponent(message || "");
longTextData.value = null;
title.value = computeTitle(answerText.value);
nextTick(() => {
@@ -190,6 +236,27 @@ onUnload(() => {
});
</script>
<style scoped>
</style>
<style scoped lang="scss">
.long-answer-tag {
display: inline-flex;
width: fit-content;
margin-bottom: 8px;
padding: 3px 8px;
border-radius: 12px;
border: 1px solid rgba($theme-color-500, 0.2);
background: rgba($theme-color-500, 0.08);
color: $theme-color-500;
font-size: 12px;
line-height: 18px;
}
.long-answer-title {
margin-bottom: 12px;
color: #111827;
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.long-answer-block {
margin-bottom: 12px;
}
</style>

View File

@@ -76,8 +76,7 @@
>
<AnswerComponent
v-if="item.componentName === CompName.longTextCard"
:text="item.componentMsg || item.msg"
:title="item.title"
:longTextData="item.longTextData"
:finish="item.finish"
/>
<QuickBookingComponent
@@ -258,6 +257,10 @@ import {
} from "@/request/api/ConversationApi";
import WebSocketManager from "@/utils/WebSocketManager";
import { IdUtils } from "@/utils";
import {
appendLongTextChunk,
createLongTextData,
} from "@/utils/longTextCard";
import { checkToken } from "@/hooks/useGoLogin";
import { useAppStore } from "@/store";
import { getAccessToken } from "@/constant/token";
@@ -781,7 +784,7 @@ const handleWebSocketMessage = (data) => {
messageId: currentSessionMessageId,
replyMessageId: data.replyMessageId || "",
componentName: "",
title: "",
longTextData: null,
finish: false,
};
chatMsgList.value.push(aiMsg);
@@ -803,7 +806,7 @@ const handleWebSocketMessage = (data) => {
messageId: currentSessionMessageId,
replyMessageId: data.replyMessageId || "",
componentName: "",
title: "",
longTextData: null,
finish: false,
};
chatMsgList.value.push(aiMsg);
@@ -842,13 +845,30 @@ const handleWebSocketMessage = (data) => {
if (data.componentName) {
aiItem.componentName = data.componentName;
if (data.componentName === CompName.longTextCard) {
aiItem.longTextData = aiItem.longTextData || createLongTextData();
if (aiItem.msg && aiItem.msg.length > 0) {
aiItem.componentMsg = (aiItem.componentMsg || "") + aiItem.msg;
if (!aiItem.isLoading) {
aiItem.componentMsg = (aiItem.componentMsg || "") + aiItem.msg;
}
aiItem.msg = "";
}
}
}
const isLongText =
aiItem.componentName === CompName.longTextCard ||
data.componentName === CompName.longTextCard;
if (isLongText && data.contentKey) {
aiItem.longTextData = aiItem.longTextData || createLongTextData();
appendLongTextChunk(aiItem.longTextData, data);
if (aiItem.isLoading) {
aiItem.msg = "";
aiItem.isLoading = false;
}
nextTick(() => scrollToBottom());
}
// 确保消息内容是字符串类型
if (data.content && typeof data.content !== "string") {
try {
@@ -861,9 +881,6 @@ const handleWebSocketMessage = (data) => {
// 直接拼接内容到对应 AI 消息
if (data.content) {
// 如果该条消息属于 longTextCard使用 componentMsg 存储内容并保持 ChatCardAI 的 text 为空
const isLongText =
aiItem.componentName === CompName.longTextCard ||
data.componentName === CompName.longTextCard;
if (isLongText) {
if (aiItem.isLoading) {
aiItem.componentMsg = (aiItem.componentMsg || "") + data.content;
@@ -900,7 +917,6 @@ const handleWebSocketMessage = (data) => {
// 处理组件调用
if (data.componentName) {
chatMsgList.value[aiMsgIndex].title = data.content;
chatMsgList.value[aiMsgIndex].componentName = data.componentName;
}
@@ -1173,7 +1189,7 @@ const sendChat = async (message, isInstruct = false) => {
messageId: currentSessionMessageId,
replyMessageId: "",
componentName: "",
title: "",
longTextData: null,
finish: false,
};
chatMsgList.value.push(aiMsg);

View File

@@ -2,22 +2,23 @@
<view class="w-full bg-white border-box border-ff overflow-hidden rounded-20 flex flex-col">
<!-- 占位撑开 -->
<view class="w-vw"></view>
<view class="flex flex-col px-12 pb-12 pt-4 border-box border-left-4">
<view class="flex flex-col px-16 pt-16 pb-12 border-box">
<view v-if="tag" class="long-answer-tag">{{ tag }}</view>
<view v-if="title" class="flex flex-row flex-items-start flex-justify-start mb-8">
<uni-icons class="icon-active" type="fire-filled" size="18" color="opacity" />
<text class="font-size-16 font-500 text-color-900 ml-6"> {{ title }}</text>
</view>
<!-- 文字内容最多显示3行 -->
<view class="answer-content font-size-12 font-color-600">
<ChatMarkdown :text="processedText" />
<view v-if="processedContent" class="answer-content font-size-12 font-color-600">
<ChatMarkdown :text="processedContent" />
</view>
<!-- 超过3行时显示...提示 -->
<view v-if="!finish" class="flex flex-row flex-items-center mt-8">
<text class="font-size-12 font-400 font-color-600">正在生成</text>
<ChatLoading />
</view>
<view v-if="isOverflow" class="flex flex-row flex-items-center mt-8" @click="lookDetailAction">
<text class="font-size-12 font-400 theme-color-500 mr-4">查看详情</text>
<view v-if="isOverflow" class="flex flex-row flex-items-center flex-justify-between mt-8" @click="lookDetailAction">
<text class="font-size-12 font-400 theme-color-500 mr-4">查看完整{{ tag }}</text>
<uni-icons class="icon-active" type="right" size="14" color="opacity"></uni-icons>
</view>
@@ -32,16 +33,21 @@ import { defineProps, computed, watch, onBeforeUnmount } from "vue";
import ChatMarkdown from "../../ChatMain/ChatMarkdown/index.vue";
import ChatLoading from "../../ChatMain/ChatLoading/index.vue";
import StreamManager from '@/utils/StreamManager.js';
import {
getLongTextPreviewText,
getLongTextValue,
hasLongTextExtraSections,
} from "@/utils/longTextCard";
// 直接根据文字长度判断超过约100个字符认为会溢出约3行
const props = defineProps({
title: {
content: {
type: String,
default: "",
},
text: {
type: String,
default: "",
longTextData: {
type: Object,
default: null,
},
finish: {
type: Boolean,
@@ -49,16 +55,22 @@ const props = defineProps({
},
});
const tag = computed(() => getLongTextValue(props.longTextData, "tag"));
const title = computed(() => getLongTextValue(props.longTextData, "title"));
const previewContent = computed(() => {
return getLongTextPreviewText(props.longTextData) || (props.content ? String(props.content) : "");
});
// 处理文本内容:按行截断以保证预览最多显示三行(更贴近视觉行数)
// 点击“查看详情”会跳转到完整页面(不受预览截断影响)。
const PREVIEW_LINES = 3;
const PREVIEW_CHAR_LIMIT = 100; // 作为备用,当没有换行但过长时也会截断
const processedText = computed(() => {
const txt = props.text ? String(props.text) : "";
if (!txt) return "";
const processedContent = computed(() => {
const content = previewContent.value ? String(previewContent.value) : "";
if (!content) return "";
// 按行分割(保留空行)
const lines = txt.split(/\r?\n/);
const lines = content.split(/\r?\n/);
// 如果行数超过限制,截取前 PREVIEW_LINES 行并添加省略号
if (lines.length > PREVIEW_LINES) {
@@ -66,21 +78,26 @@ const processedText = computed(() => {
}
// 若虽然行数不超过,但总长度仍然很长,做字符级截断作为兜底
if (txt.length > PREVIEW_CHAR_LIMIT) {
return txt.slice(0, PREVIEW_CHAR_LIMIT) + "...";
if (content.length > PREVIEW_CHAR_LIMIT) {
return content.slice(0, PREVIEW_CHAR_LIMIT) + "...";
}
return txt;
return content;
});
const isOverflow = computed(() => {
const textStr = props.text ? String(props.text) : "";
const lines = textStr.split(/\r?\n/);
return lines.length > PREVIEW_LINES || textStr.length > PREVIEW_CHAR_LIMIT;
const contentStr = previewContent.value ? String(previewContent.value) : "";
const lines = contentStr.split(/\r?\n/);
return (
hasLongTextExtraSections(props.longTextData) ||
lines.length > PREVIEW_LINES ||
contentStr.length > PREVIEW_CHAR_LIMIT
);
});
let stopForwardWatcher = null;
let stopFinishWatcher = null;
let stopLongTextWatcher = null;
const cleanupStreamWatchers = () => {
if (stopForwardWatcher) {
@@ -91,30 +108,51 @@ const cleanupStreamWatchers = () => {
stopFinishWatcher();
stopFinishWatcher = null;
}
if (stopLongTextWatcher) {
stopLongTextWatcher();
stopLongTextWatcher = null;
}
};
onBeforeUnmount(cleanupStreamWatchers);
const lookDetailAction = () => {
const message = props.text ? String(props.text) : "";
const message = previewContent.value ? String(previewContent.value) : "";
// 使用 StreamManager 以 streamId 转发当前及后续流式更新,详情页通过 streamId 订阅
const streamId = `stream_${Date.now()}_${Math.random().toString(36).slice(2,8)}`;
StreamManager.openStream(streamId, message, !!props.finish);
const updateStream = () => {
StreamManager.updateStream(
streamId,
previewContent.value ? String(previewContent.value) : "",
!!props.finish,
props.longTextData || null,
);
};
StreamManager.openStream(streamId, message, !!props.finish, props.longTextData || null);
cleanupStreamWatchers();
if (!props.finish) {
// 将当前组件后续 props.text/props.finish 的更新转发到 StreamManager
// 将当前组件后续 props.content/props.finish 的更新转发到 StreamManager
stopForwardWatcher = watch(
() => props.text,
(v) => {
StreamManager.updateStream(streamId, v ? String(v) : "", !!props.finish);
}
() => props.content,
updateStream
);
stopLongTextWatcher = watch(
() => props.longTextData,
updateStream,
{ deep: true }
);
stopFinishWatcher = watch(
() => props.finish,
(f) => {
StreamManager.updateStream(streamId, props.text ? String(props.text) : "", !!f);
StreamManager.updateStream(
streamId,
previewContent.value ? String(previewContent.value) : "",
!!f,
props.longTextData || null,
);
if (f) {
cleanupStreamWatchers();
}
@@ -142,7 +180,16 @@ const lookDetailAction = () => {
line-height: 16px;
max-height: 80px;
}
.border-left-4 {
border-left: 4px solid $theme-color-500;
.long-answer-tag {
display: inline-flex;
width: fit-content;
margin-bottom: 8px;
padding: 3px 8px;
border-radius: 12px;
border: 1px solid rgba($theme-color-500, 0.2);
background: rgba($theme-color-500, 0.08);
color: $theme-color-500;
font-size: 12px;
line-height: 18px;
}
</style>

View File

@@ -15,8 +15,10 @@ const getEvnUrl = async () => {
if (developVersion) {
const appStore = useAppStore();
appStore.setServerConfig({
baseUrl: "https://abroadbiz.nianxx.com/ingress", // 服务器基础地址
wssUrl: "wss://abroadbiz.nianxx.com/ingress/agent/ws/chat", // 服务器wss地址
// baseUrl: "https://abroadbiz.nianxx.com/ingress", // 服务器基础地址
// wssUrl: "wss://abroadbiz.nianxx.com/ingress/agent/ws/chat", // 服务器wss地址
baseUrl: devUrl, // 服务器基础地址
wssUrl: wssDevUrl, // 服务器wss地址
});
return;
}

View File

@@ -1,28 +1,34 @@
// 简单的流式数据管理器:开启流、更新流、订阅流、关闭流
const streams = {};
function openStream(id, initial = '', finished = false) {
if (!id) return;
streams[id] = streams[id] || { text: '', finished: false, subs: new Set() };
streams[id].text = initial || '';
streams[id].finished = !!finished;
// notify existing subscribers
streams[id].subs.forEach((cb) => cb(streams[id].text, streams[id].finished));
function notify(stream) {
stream.subs.forEach((cb) => cb(stream.text, stream.finished, stream.payload));
}
function updateStream(id, text, finished = false) {
function openStream(id, initial = '', finished = false, payload = null) {
if (!id) return;
streams[id] = streams[id] || { text: '', finished: false, payload: null, subs: new Set() };
streams[id].text = initial || '';
streams[id].finished = !!finished;
streams[id].payload = payload || null;
// notify existing subscribers
notify(streams[id]);
}
function updateStream(id, text, finished = false, payload = null) {
if (!id || !streams[id]) return;
streams[id].text = text || '';
streams[id].finished = !!finished;
streams[id].subs.forEach((cb) => cb(streams[id].text, streams[id].finished));
streams[id].payload = payload || null;
notify(streams[id]);
}
function subscribe(id, cb) {
if (!id) return () => {};
streams[id] = streams[id] || { text: '', finished: false, subs: new Set() };
streams[id] = streams[id] || { text: '', finished: false, payload: null, subs: new Set() };
streams[id].subs.add(cb);
// send current snapshot immediately
cb(streams[id].text, streams[id].finished);
cb(streams[id].text, streams[id].finished, streams[id].payload);
return () => {
streams[id] && streams[id].subs.delete(cb);
// 移除空流
@@ -34,13 +40,13 @@ function subscribe(id, cb) {
function closeStream(id) {
if (!id || !streams[id]) return;
streams[id].subs.forEach((cb) => cb(streams[id].text, true));
streams[id].subs.forEach((cb) => cb(streams[id].text, true, streams[id].payload));
delete streams[id];
}
function getSnapshot(id) {
if (!id || !streams[id]) return { text: '', finished: false };
return { text: streams[id].text, finished: streams[id].finished };
if (!id || !streams[id]) return { text: '', finished: false, payload: null };
return { text: streams[id].text, finished: streams[id].finished, payload: streams[id].payload };
}
export default {

115
src/utils/longTextCard.js Normal file
View File

@@ -0,0 +1,115 @@
export const LONG_TEXT_KEYS = {
tag: "tag",
title: "title",
content: "content",
checklist: "checklist",
suggest: "suggest",
commodity: "commodity",
actionZone: "action_zone",
};
export const LONG_TEXT_FIELD_CONFIG = [
{ key: LONG_TEXT_KEYS.tag },
{ key: LONG_TEXT_KEYS.title },
{ key: LONG_TEXT_KEYS.content },
{ key: LONG_TEXT_KEYS.checklist },
{ key: LONG_TEXT_KEYS.suggest },
{ key: LONG_TEXT_KEYS.commodity },
{ key: LONG_TEXT_KEYS.actionZone },
];
export const LONG_TEXT_PREVIEW_KEYS = [
LONG_TEXT_KEYS.content,
LONG_TEXT_KEYS.title,
LONG_TEXT_KEYS.tag,
];
const CONFIGURED_KEYS = LONG_TEXT_FIELD_CONFIG.map((item) => item.key);
export const createLongTextData = () => ({
values: {},
parsedValues: {},
});
const toText = (value) => {
if (value === undefined || value === null) return "";
return typeof value === "string" ? value : String(value);
};
const shouldParseJSON = (raw) => {
if (!raw || typeof raw !== "string") return false;
return /^[\s]*[\[{]/.test(raw);
};
const tryParseJSON = (raw) => {
if (!shouldParseJSON(raw)) {
return { ok: false, value: null };
}
try {
return { ok: true, value: JSON.parse(raw) };
} catch (e) {
return { ok: false, value: null };
}
};
export const appendLongTextChunk = (target, chunk = {}) => {
if (!target || !chunk.contentKey) return target;
const key = String(chunk.contentKey);
const value = toText(chunk.contentValue);
if (!target.values) target.values = {};
if (!target.parsedValues) target.parsedValues = {};
target.values[key] = (target.values[key] || "") + value;
const parsed = tryParseJSON(target.values[key]);
if (parsed.ok) {
target.parsedValues[key] = parsed.value;
} else {
delete target.parsedValues[key];
}
return target;
};
export const getLongTextValue = (data, key) => {
if (!data || !data.values || !key) return "";
return data.values[key] || "";
};
export const getLongTextParsedValue = (data, key, fallback = undefined) => {
if (!data || !data.parsedValues || !key) return fallback;
return Object.prototype.hasOwnProperty.call(data.parsedValues, key)
? data.parsedValues[key]
: fallback;
};
export const getLongTextPreviewText = (data, keys = LONG_TEXT_PREVIEW_KEYS) => {
for (const key of keys) {
const value = getLongTextValue(data, key);
if (value) return value;
}
return "";
};
export const hasLongTextExtraSections = (data, previewKeys = LONG_TEXT_PREVIEW_KEYS) => {
if (!data || !data.values) return false;
return Object.keys(data.values).some((key) => !previewKeys.includes(key));
};
export const getLongTextSections = (data) => {
if (!data || !data.values) return [];
const extraKeys = Object.keys(data.values).filter(
(key) => !CONFIGURED_KEYS.includes(key),
);
return [...CONFIGURED_KEYS, ...extraKeys]
.filter((key) => Object.prototype.hasOwnProperty.call(data.values, key))
.map((key) => ({
contentKey: key,
contentValue: getLongTextValue(data, key),
parsedValue: getLongTextParsedValue(data, key, null),
}));
};