feat: 长文本组件的对接调试
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
115
src/utils/longTextCard.js
Normal 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),
|
||||||
|
}));
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user