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"> <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"> <view class="pt-12 px-12 pb-24 border-box">
<!-- 内容支持markdown --> <template v-for="section in renderSections" :key="section.contentKey">
<ChatMarkdown :text="answerText" /> <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> <view id="bottom-anchor"></view>
@@ -21,9 +32,13 @@
<script setup> <script setup>
import TopNavBar from "@/components/TopNavBar/index.vue"; import TopNavBar from "@/components/TopNavBar/index.vue";
import ChatMarkdown from "../ChatMarkdown/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 { onLoad, onUnload } from "@dcloudio/uni-app";
import StreamManager from "@/utils/StreamManager.js"; import StreamManager from "@/utils/StreamManager.js";
import {
getLongTextSections,
getLongTextValue,
} from "@/utils/longTextCard";
const props = defineProps({ const props = defineProps({
answerText: { answerText: {
@@ -34,9 +49,36 @@ const props = defineProps({
const answerText = ref(props.answerText || ""); const answerText = ref(props.answerText || "");
const title = ref(""); const title = ref("");
const longTextData = ref(null);
let unsubscribe = 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 控制 */ /** ✅ scroll-into-view 控制 */
const scrollIntoViewId = ref(""); const scrollIntoViewId = ref("");
@@ -147,9 +189,12 @@ onLoad(({ message = "", streamId = "", finished = "0" }) => {
// ✅ 流式数据 // ✅ 流式数据
unsubscribe = StreamManager.subscribe( unsubscribe = StreamManager.subscribe(
streamId, streamId,
(text = "", finished = false) => { (text = "", finished = false, payload = null) => {
answerText.value = text || ""; answerText.value = text || "";
title.value = computeTitle(answerText.value); longTextData.value = payload || null;
title.value = computeTitle(
getLongTextValue(longTextData.value, "title") || answerText.value
);
nextTick(() => { nextTick(() => {
// 每次接收数据都重新测量高度content size 可能变化,比如加载图) // 每次接收数据都重新测量高度content size 可能变化,比如加载图)
@@ -169,6 +214,7 @@ onLoad(({ message = "", streamId = "", finished = "0" }) => {
} else { } else {
// ✅ 非流式 // ✅ 非流式
answerText.value = decodeURIComponent(message || ""); answerText.value = decodeURIComponent(message || "");
longTextData.value = null;
title.value = computeTitle(answerText.value); title.value = computeTitle(answerText.value);
nextTick(() => { nextTick(() => {
@@ -190,6 +236,27 @@ onUnload(() => {
}); });
</script> </script>
<style scoped> <style scoped lang="scss">
.long-answer-tag {
</style> 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 <AnswerComponent
v-if="item.componentName === CompName.longTextCard" v-if="item.componentName === CompName.longTextCard"
:text="item.componentMsg || item.msg" :longTextData="item.longTextData"
:title="item.title"
:finish="item.finish" :finish="item.finish"
/> />
<QuickBookingComponent <QuickBookingComponent
@@ -258,6 +257,10 @@ import {
} from "@/request/api/ConversationApi"; } from "@/request/api/ConversationApi";
import WebSocketManager from "@/utils/WebSocketManager"; import WebSocketManager from "@/utils/WebSocketManager";
import { IdUtils } from "@/utils"; import { IdUtils } from "@/utils";
import {
appendLongTextChunk,
createLongTextData,
} from "@/utils/longTextCard";
import { checkToken } from "@/hooks/useGoLogin"; import { checkToken } from "@/hooks/useGoLogin";
import { useAppStore } from "@/store"; import { useAppStore } from "@/store";
import { getAccessToken } from "@/constant/token"; import { getAccessToken } from "@/constant/token";
@@ -781,7 +784,7 @@ const handleWebSocketMessage = (data) => {
messageId: currentSessionMessageId, messageId: currentSessionMessageId,
replyMessageId: data.replyMessageId || "", replyMessageId: data.replyMessageId || "",
componentName: "", componentName: "",
title: "", longTextData: null,
finish: false, finish: false,
}; };
chatMsgList.value.push(aiMsg); chatMsgList.value.push(aiMsg);
@@ -803,7 +806,7 @@ const handleWebSocketMessage = (data) => {
messageId: currentSessionMessageId, messageId: currentSessionMessageId,
replyMessageId: data.replyMessageId || "", replyMessageId: data.replyMessageId || "",
componentName: "", componentName: "",
title: "", longTextData: null,
finish: false, finish: false,
}; };
chatMsgList.value.push(aiMsg); chatMsgList.value.push(aiMsg);
@@ -842,13 +845,30 @@ const handleWebSocketMessage = (data) => {
if (data.componentName) { if (data.componentName) {
aiItem.componentName = data.componentName; aiItem.componentName = data.componentName;
if (data.componentName === CompName.longTextCard) { if (data.componentName === CompName.longTextCard) {
aiItem.longTextData = aiItem.longTextData || createLongTextData();
if (aiItem.msg && aiItem.msg.length > 0) { if (aiItem.msg && aiItem.msg.length > 0) {
aiItem.componentMsg = (aiItem.componentMsg || "") + aiItem.msg; if (!aiItem.isLoading) {
aiItem.componentMsg = (aiItem.componentMsg || "") + aiItem.msg;
}
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") { if (data.content && typeof data.content !== "string") {
try { try {
@@ -861,9 +881,6 @@ const handleWebSocketMessage = (data) => {
// 直接拼接内容到对应 AI 消息 // 直接拼接内容到对应 AI 消息
if (data.content) { if (data.content) {
// 如果该条消息属于 longTextCard使用 componentMsg 存储内容并保持 ChatCardAI 的 text 为空 // 如果该条消息属于 longTextCard使用 componentMsg 存储内容并保持 ChatCardAI 的 text 为空
const isLongText =
aiItem.componentName === CompName.longTextCard ||
data.componentName === CompName.longTextCard;
if (isLongText) { if (isLongText) {
if (aiItem.isLoading) { if (aiItem.isLoading) {
aiItem.componentMsg = (aiItem.componentMsg || "") + data.content; aiItem.componentMsg = (aiItem.componentMsg || "") + data.content;
@@ -900,7 +917,6 @@ const handleWebSocketMessage = (data) => {
// 处理组件调用 // 处理组件调用
if (data.componentName) { if (data.componentName) {
chatMsgList.value[aiMsgIndex].title = data.content;
chatMsgList.value[aiMsgIndex].componentName = data.componentName; chatMsgList.value[aiMsgIndex].componentName = data.componentName;
} }
@@ -1173,7 +1189,7 @@ const sendChat = async (message, isInstruct = false) => {
messageId: currentSessionMessageId, messageId: currentSessionMessageId,
replyMessageId: "", replyMessageId: "",
componentName: "", componentName: "",
title: "", longTextData: null,
finish: false, finish: false,
}; };
chatMsgList.value.push(aiMsg); 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-full bg-white border-box border-ff overflow-hidden rounded-20 flex flex-col">
<!-- 占位撑开 --> <!-- 占位撑开 -->
<view class="w-vw"></view> <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"> <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" /> <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> <text class="font-size-16 font-500 text-color-900 ml-6"> {{ title }}</text>
</view> </view>
<!-- 文字内容最多显示3行 --> <!-- 文字内容最多显示3行 -->
<view class="answer-content font-size-12 font-color-600"> <view v-if="processedContent" class="answer-content font-size-12 font-color-600">
<ChatMarkdown :text="processedText" /> <ChatMarkdown :text="processedContent" />
</view> </view>
<!-- 超过3行时显示...提示 --> <!-- 超过3行时显示...提示 -->
<view v-if="!finish" class="flex flex-row flex-items-center mt-8"> <view v-if="!finish" class="flex flex-row flex-items-center mt-8">
<text class="font-size-12 font-400 font-color-600">正在生成</text> <text class="font-size-12 font-400 font-color-600">正在生成</text>
<ChatLoading /> <ChatLoading />
</view> </view>
<view v-if="isOverflow" class="flex flex-row flex-items-center mt-8" @click="lookDetailAction"> <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">查看详情</text> <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> <uni-icons class="icon-active" type="right" size="14" color="opacity"></uni-icons>
</view> </view>
@@ -32,16 +33,21 @@ import { defineProps, computed, watch, onBeforeUnmount } from "vue";
import ChatMarkdown from "../../ChatMain/ChatMarkdown/index.vue"; import ChatMarkdown from "../../ChatMain/ChatMarkdown/index.vue";
import ChatLoading from "../../ChatMain/ChatLoading/index.vue"; import ChatLoading from "../../ChatMain/ChatLoading/index.vue";
import StreamManager from '@/utils/StreamManager.js'; import StreamManager from '@/utils/StreamManager.js';
import {
getLongTextPreviewText,
getLongTextValue,
hasLongTextExtraSections,
} from "@/utils/longTextCard";
// 直接根据文字长度判断超过约100个字符认为会溢出约3行 // 直接根据文字长度判断超过约100个字符认为会溢出约3行
const props = defineProps({ const props = defineProps({
title: { content: {
type: String, type: String,
default: "", default: "",
}, },
text: { longTextData: {
type: String, type: Object,
default: "", default: null,
}, },
finish: { finish: {
type: Boolean, 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_LINES = 3;
const PREVIEW_CHAR_LIMIT = 100; // 作为备用,当没有换行但过长时也会截断 const PREVIEW_CHAR_LIMIT = 100; // 作为备用,当没有换行但过长时也会截断
const processedText = computed(() => { const processedContent = computed(() => {
const txt = props.text ? String(props.text) : ""; const content = previewContent.value ? String(previewContent.value) : "";
if (!txt) return ""; if (!content) return "";
// 按行分割(保留空行) // 按行分割(保留空行)
const lines = txt.split(/\r?\n/); const lines = content.split(/\r?\n/);
// 如果行数超过限制,截取前 PREVIEW_LINES 行并添加省略号 // 如果行数超过限制,截取前 PREVIEW_LINES 行并添加省略号
if (lines.length > PREVIEW_LINES) { if (lines.length > PREVIEW_LINES) {
@@ -66,21 +78,26 @@ const processedText = computed(() => {
} }
// 若虽然行数不超过,但总长度仍然很长,做字符级截断作为兜底 // 若虽然行数不超过,但总长度仍然很长,做字符级截断作为兜底
if (txt.length > PREVIEW_CHAR_LIMIT) { if (content.length > PREVIEW_CHAR_LIMIT) {
return txt.slice(0, PREVIEW_CHAR_LIMIT) + "..."; return content.slice(0, PREVIEW_CHAR_LIMIT) + "...";
} }
return txt; return content;
}); });
const isOverflow = computed(() => { const isOverflow = computed(() => {
const textStr = props.text ? String(props.text) : ""; const contentStr = previewContent.value ? String(previewContent.value) : "";
const lines = textStr.split(/\r?\n/); const lines = contentStr.split(/\r?\n/);
return lines.length > PREVIEW_LINES || textStr.length > PREVIEW_CHAR_LIMIT; return (
hasLongTextExtraSections(props.longTextData) ||
lines.length > PREVIEW_LINES ||
contentStr.length > PREVIEW_CHAR_LIMIT
);
}); });
let stopForwardWatcher = null; let stopForwardWatcher = null;
let stopFinishWatcher = null; let stopFinishWatcher = null;
let stopLongTextWatcher = null;
const cleanupStreamWatchers = () => { const cleanupStreamWatchers = () => {
if (stopForwardWatcher) { if (stopForwardWatcher) {
@@ -91,30 +108,51 @@ const cleanupStreamWatchers = () => {
stopFinishWatcher(); stopFinishWatcher();
stopFinishWatcher = null; stopFinishWatcher = null;
} }
if (stopLongTextWatcher) {
stopLongTextWatcher();
stopLongTextWatcher = null;
}
}; };
onBeforeUnmount(cleanupStreamWatchers); onBeforeUnmount(cleanupStreamWatchers);
const lookDetailAction = () => { const lookDetailAction = () => {
const message = props.text ? String(props.text) : ""; const message = previewContent.value ? String(previewContent.value) : "";
// 使用 StreamManager 以 streamId 转发当前及后续流式更新,详情页通过 streamId 订阅 // 使用 StreamManager 以 streamId 转发当前及后续流式更新,详情页通过 streamId 订阅
const streamId = `stream_${Date.now()}_${Math.random().toString(36).slice(2,8)}`; 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(); cleanupStreamWatchers();
if (!props.finish) { if (!props.finish) {
// 将当前组件后续 props.text/props.finish 的更新转发到 StreamManager // 将当前组件后续 props.content/props.finish 的更新转发到 StreamManager
stopForwardWatcher = watch( stopForwardWatcher = watch(
() => props.text, () => props.content,
(v) => { updateStream
StreamManager.updateStream(streamId, v ? String(v) : "", !!props.finish); );
} stopLongTextWatcher = watch(
() => props.longTextData,
updateStream,
{ deep: true }
); );
stopFinishWatcher = watch( stopFinishWatcher = watch(
() => props.finish, () => props.finish,
(f) => { (f) => {
StreamManager.updateStream(streamId, props.text ? String(props.text) : "", !!f); StreamManager.updateStream(
streamId,
previewContent.value ? String(previewContent.value) : "",
!!f,
props.longTextData || null,
);
if (f) { if (f) {
cleanupStreamWatchers(); cleanupStreamWatchers();
} }
@@ -142,7 +180,16 @@ const lookDetailAction = () => {
line-height: 16px; line-height: 16px;
max-height: 80px; max-height: 80px;
} }
.border-left-4 { .long-answer-tag {
border-left: 4px solid $theme-color-500; 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> </style>

View File

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

View File

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