diff --git a/scripts/regression-long-answer-parsed-value-view.mjs b/scripts/regression-long-answer-parsed-value-view.mjs index 7833900..d7d0510 100644 --- a/scripts/regression-long-answer-parsed-value-view.mjs +++ b/scripts/regression-long-answer-parsed-value-view.mjs @@ -18,69 +18,148 @@ const longTextCardPath = resolve(scriptDir, "../src/utils/longTextCard.js"); const componentSource = readFileSync(componentPath, "utf8"); const detailPageSource = readFileSync(detailPagePath, "utf8"); const longTextCardSource = readFileSync(longTextCardPath, "utf8"); -const { getLongTextSections } = await import(pathToFileURL(longTextCardPath)); +const { + appendLongTextChunk, + createLongTextData, + getLongTextSections, +} = await import(pathToFileURL(longTextCardPath)); -assert.match( +assert.doesNotMatch( longTextCardSource, - /export\s+const\s+normalizeLongTextSpotLocate\s*=/, - "longTextCard should export normalizeLongTextSpotLocate for malformed spot_locate keys" + /normalizeLongText/, + "longTextCard should not contain field-specific normalize helpers" ); -assert.match( +assert.doesNotMatch( longTextCardSource, - /export\s+const\s+normalizeLongTextQuestionSuggest\s*=/, - "longTextCard should export normalizeLongTextQuestionSuggest for JSON-string question_suggest values" + /spot_longitude|spot_latitude|spot_tag/, + "longTextCard should not know spot_locate child field names" ); -assert.match( - longTextCardSource, - /export\s+const\s+normalizeLongTextContentImage\s*=/, - "longTextCard should export normalizeLongTextContentImage for string/object content_image values" +assert.doesNotMatch( + componentSource, + /isGenericObjectSectionField/, + "ParsedValueView should use renderFieldEntries instead of a separate generic object branch" ); assert.match( componentSource, - /entry\.type\s*===\s*["']content-image["']/, - "ParsedValueView should render content_image with the dedicated image style" + /getRenderEntriesForObject/, + "ParsedValueView should derive object rows through the existing entry renderer" +); + +assert.doesNotMatch( + componentSource, + /STRUCTURED_SECTION_CONFIG/, + "ParsedValueView should not require per-field structured section config" +); + +assert.doesNotMatch( + componentSource, + /LONG_TEXT_KEYS\.(preparationSection|sectionSuggestion|pitfallSection|viewSection|suggestionSection|lightReminder|photoSpotSection|phoneSection|decisionSection|routeWarning|tourRoutes|facilitiesAlongTheWay)/, + "ParsedValueView should not reference ordinary section keys individually" +); + +assert.doesNotMatch( + componentSource, + /content-body-section-dot|content-body-section-list/, + "generic object arrays should keep the original list-card rendering style" +); + +assert.doesNotMatch( + componentSource, + /entry\.type\s*===\s*'(question-suggest|photo-list|aigc-componet|commodity-list|spot-locate)'/, + "renderFieldEntries should only render generic text/image/list entries" +); + +assert.doesNotMatch( + componentSource, + /createSpecialFieldEntry/, + "dedicated long text components should be selected by top-level computed branches only" ); assert.match( componentSource, - /entry\.type\s*===\s*["']spot-locate["']/, + /shouldRenderQuestionSuggest/, + "top-level question_suggest fields should render through the dedicated chips" +); + +assert.match( + componentSource, + /shouldRenderCommodityList/, + "top-level commodity_list fields should render through the dedicated product list" +); + +assert.match( + componentSource, + /shouldRenderPhotoList/, + "top-level photo_list fields should render through the dedicated photo swiper" +); + +assert.match( + componentSource, + /shouldRenderAigcComponet/, + "top-level aigc_componet fields should render through the dedicated AIGC card" +); + +assert.match( + componentSource, + /shouldRenderSpotLocate/, + "top-level spot_locate fields should render through the dedicated POI card" +); + +assert.doesNotMatch( + componentSource, + /createRenderEntry\(key,\s*entryKey,\s*value\[key\]\)/, + "nested object keys should not trigger dedicated long text components" +); + +assert.match( + componentSource, + /openMap\(spotLocateValue\)/, "ParsedValueView should render spot_locate with the dedicated POI action card" ); assert.match( componentSource, - /entry\.type\s*===\s*["']question-suggest["']/, + /getSpotLocateValue/, + "ParsedValueView should own spot_locate value shaping" +); + +assert.doesNotMatch( + componentSource, + /spot_longitude:|spot_latitude:|spot_tag:/, + "ParsedValueView should not read malformed spot_locate keys" +); + +assert.match( + componentSource, + /shouldRenderQuestionSuggest/, "ParsedValueView should render question_suggest with dedicated FAQ chips" ); +assert.match( + componentSource, + /getQuestionSuggestItems/, + "ParsedValueView should own question_suggest value shaping" +); + assert.match( componentSource, /sendReply\(question\)/, "question_suggest chips should send the selected follow-up question" ); +assert.doesNotMatch( + detailPageSource, + /LONG_TEXT_KEYS\.content\b/, + "long answer detail page should not reference removed content key" +); + assert.match( componentSource, - /openMap\(entry\.value\)/, - "spot_locate action card should open the normalized map location" -); - -const hiddenDetailKeysMatch = detailPageSource.match( - /const\s+HIDDEN_DETAIL_SECTION_KEYS\s*=\s*\[([\s\S]*?)\];/ -); - -assert.ok( - hiddenDetailKeysMatch, - "long answer detail page should define HIDDEN_DETAIL_SECTION_KEYS" -); - -assert.doesNotMatch( - hiddenDetailKeysMatch[1], - /LONG_TEXT_KEYS\.contentSummary/, - "long answer detail page should not hide content_summary" + /LONG_TEXT_KEYS\.contentImage\b/, + "ParsedValueView should render content_image with the dedicated image style" ); const ignoredFieldKeysMatch = componentSource.match( @@ -102,28 +181,27 @@ const receivedKeys = [ "tag", "title", "content_summary", + "scene_image", "content_image", - "view_section_title", - "view_section_items", - "suggestion_section_title", - "suggestion_section_content", - "light_reminder_title", - "light_reminder_items", + "view_section", + "suggestion_section", + "light_reminder", "spot_locate", "question_suggest", ]; -const longTextData = { - values: {}, - parsedValues: {}, -}; +const longTextData = createLongTextData(); receivedKeys.forEach((key) => { - longTextData.values[key] = key; + appendLongTextChunk(longTextData, { contentKey: key, contentValue: key }); +}); +appendLongTextChunk(longTextData, { + contentKey: "view_section_items", + contentValue: "old flat field", }); assert.deepEqual( getLongTextSections(longTextData).map((section) => section.contentKey), - receivedKeys, - "long text detail sections should preserve server receive order, including configured fields after extra fields" + [...receivedKeys, "view_section_items"], + "long text detail sections should keep configured fields first and preserve unknown contentKey fields" ); diff --git a/scripts/test-ParsedValueView-strict.mjs b/scripts/test-ParsedValueView-strict.mjs index ed69a32..140c5f8 100644 --- a/scripts/test-ParsedValueView-strict.mjs +++ b/scripts/test-ParsedValueView-strict.mjs @@ -42,9 +42,16 @@ for (const snippet of forbiddenCompatibilitySnippets) { const requiredStrictRenderingSnippets = [ "LONG_TEXT_KEYS.sceneImage", + "LONG_TEXT_KEYS.contentImage", "LONG_TEXT_KEYS.commodityList", "LONG_TEXT_KEYS.photoList", "LONG_TEXT_KEYS.aigcComponet", + "shouldRenderSpotLocate", + "shouldRenderQuestionSuggest", + "shouldRenderCommodityList", + "shouldRenderPhotoList", + "shouldRenderAigcComponet", + "getRenderEntriesForObject", "commodity.commodity_id", "commodity.commodity_name", "commodity.commodity_price", @@ -62,9 +69,7 @@ const requiredStrictRenderingSnippets = [ "jumpAigcClick", "getAccessToken", "navigateTo", - "content-body-list-marker", - "entry.value.length > 1", - "formatListMarker(index)", + "content-body-list-card", ]; for (const snippet of requiredStrictRenderingSnippets) { @@ -75,4 +80,71 @@ for (const snippet of requiredStrictRenderingSnippets) { ); } +const removedInnerSpecialEntrySnippets = [ + "createSpecialFieldEntry", + "entry.type === 'question-suggest'", + "entry.type === 'photo-list'", + "entry.type === 'aigc-componet'", + "entry.type === 'commodity-list'", + "entry.type === 'spot-locate'", +]; + +for (const snippet of removedInnerSpecialEntrySnippets) { + assert.equal( + source.includes(snippet), + false, + `ParsedValueView should keep special fields at the top level only: ${snippet}` + ); +} + +const removedFlatFieldSnippets = [ + "preparationSectionTitle", + "preparationSectionItems", + "sectionSuggestionTitle", + "sectionSuggestionContent", + "pitfallSectionTitle", + "pitfallSectionItems", + "viewSectionTitle", + "viewSectionItems", + "suggestionSectionTitle", + "suggestionSectionContent", + "lightReminderTitle", + "lightReminderItems", +]; + +for (const snippet of removedFlatFieldSnippets) { + assert.equal( + source.includes(`LONG_TEXT_KEYS.${snippet}`), + false, + `ParsedValueView should not reference removed flat field: ${snippet}` + ); +} + +const removedStructuredConfigSnippets = [ + "STRUCTURED_SECTION_CONFIG", + "isGenericObjectSectionField", + "content-body-section-dot", + "content-body-section-list", + "LONG_TEXT_KEYS.preparationSection", + "LONG_TEXT_KEYS.sectionSuggestion", + "LONG_TEXT_KEYS.pitfallSection", + "LONG_TEXT_KEYS.viewSection", + "LONG_TEXT_KEYS.suggestionSection", + "LONG_TEXT_KEYS.lightReminder", + "LONG_TEXT_KEYS.photoSpotSection", + "LONG_TEXT_KEYS.phoneSection", + "LONG_TEXT_KEYS.decisionSection", + "LONG_TEXT_KEYS.routeWarning", + "LONG_TEXT_KEYS.tourRoutes", + "LONG_TEXT_KEYS.facilitiesAlongTheWay", +]; + +for (const snippet of removedStructuredConfigSnippets) { + assert.equal( + source.includes(snippet), + false, + `ParsedValueView should use generic section rendering instead of configured field: ${snippet}` + ); +} + console.log("ParsedValueView strict field checks passed"); diff --git a/scripts/test-longTextCard.mjs b/scripts/test-longTextCard.mjs index 7a10ea8..f1273da 100644 --- a/scripts/test-longTextCard.mjs +++ b/scripts/test-longTextCard.mjs @@ -6,15 +6,45 @@ const source = await readFile(resolve("src/utils/longTextCard.js"), "utf8"); const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`; const longTextCard = await import(moduleUrl); +assert.doesNotMatch( + source, + /normalizeLongText/, + "longTextCard should not contain field-specific normalize helpers" +); + +assert.doesNotMatch( + source, + /spot_longitude|spot_latitude|spot_tag/, + "longTextCard should not know spot_locate child field names" +); + const { LONG_TEXT_FIELD_CONFIG, LONG_TEXT_KEYS, + appendLongTextChunk, + createLongTextData, + getLongTextParsedValue, + getLongTextSections, + getLongTextValue, + hasLongTextExtraSections, parseLongTextDisplayValue, sanitizeLongTextDisplayValue, hasLongTextDisplayValue, formatLongTextDisplayValue, } = longTextCard; +assert.equal( + source.includes("REMOVED_LONG_TEXT_FIELD_KEYS"), + false, + "longTextCard should not carry a removed field blacklist" +); + +assert.equal( + source.includes("hasLongTextChunkPayload"), + false, + "ChatMainList should not ask longTextCard to inspect plain data.content payloads" +); + assert.deepEqual( parseLongTextDisplayValue('[ "see bridge", "hear water" ]'), ["see bridge", "hear water"], @@ -22,25 +52,29 @@ assert.deepEqual( ); assert.deepEqual( - parseLongTextDisplayValue('{"spot_name":"bridge","spot_longitude:":107.712345}'), - { spot_name: "bridge", "spot_longitude:": 107.712345 }, + parseLongTextDisplayValue('{"spot_name":"bridge","spot_longitude":107.712345}'), + { spot_name: "bridge", spot_longitude: 107.712345 }, "serialized objects should be parsed for display" ); assert.deepEqual( sanitizeLongTextDisplayValue( { - content: "hidden", - components: ["hidden"], - view_section_items: '[ "check piers", "count arches" ]', + preparation_section: { + preparation_section_title: "keep", + preparation_section_items: '[ "check piers", "count arches" ]', + }, nested: { title: "keep", }, }, - ["content", "components"] + [] ), { - view_section_items: ["check piers", "count arches"], + preparation_section: { + preparation_section_title: "keep", + preparation_section_items: ["check piers", "count arches"], + }, nested: { title: "keep", }, @@ -54,20 +88,28 @@ assert.equal(formatLongTextDisplayValue(true), "\u662f"); assert.equal(formatLongTextDisplayValue({ title: "bridge" }), '{"title":"bridge"}'); const expectedNewKeys = { - preparationSectionTitle: "preparation_section_title", - preparationSectionItems: "preparation_section_items", - sectionSuggestionTitle: "section_suggestion_title", - sectionSuggestionContent: "section_suggestion_content", - pitfallSectionTitle: "pitfall_section_title", - pitfallSectionItems: "pitfall_section_items", + tag: "tag", + title: "title", + contentSummary: "content_summary", + sceneImage: "scene_image", + preparationSection: "preparation_section", + sectionSuggestion: "section_suggestion", + pitfallSection: "pitfall_section", commodityList: "commodity_list", - photoSpotSectionTitle: "photo_spot_section_title", - photoSpotSectionItems: "photo_spot_section_items", - bestTimeSuggestion: "best_time_suggestion", - phoneSectionTitle: "phone_section_title", - phoneSectionItems: "phone_section_items", + contentImage: "content_image", + viewSection: "view_section", + suggestionSection: "suggestion_section", + lightReminder: "light_reminder", + spotLocate: "spot_locate", + photoSpotSection: "photo_spot_section", + phoneSection: "phone_section", photoList: "photo_list", aigcComponet: "aigc_componet", + decisionSection: "decision_section", + routeWarning: "route_warning", + tourRoutes: "tour_routes", + facilitiesAlongTheWay: "facilities_along_the_way", + questionSuggest: "question_suggest", }; for (const [keyName, keyValue] of Object.entries(expectedNewKeys)) { @@ -79,4 +121,153 @@ for (const [keyName, keyValue] of Object.entries(expectedNewKeys)) { ); } +const removedKeys = [ + "content", + "guideConclusion", + "keyFacts", + "preparationSectionTitle", + "preparationSectionItems", + "sectionSuggestionTitle", + "sectionSuggestionContent", + "pitfallSectionTitle", + "pitfallSectionItems", + "viewSectionTitle", + "viewSectionItems", + "suggestionSectionTitle", + "suggestionSectionContent", + "lightReminderTitle", + "lightReminderItems", + "components", + "actionZone", +]; + +for (const keyName of removedKeys) { + assert.equal( + Object.prototype.hasOwnProperty.call(LONG_TEXT_KEYS, keyName), + false, + `${keyName} should be removed from LONG_TEXT_KEYS` + ); +} + +const oldFlatFieldValues = [ + "preparation_section_title", + "preparation_section_items", + "view_section_title", + "view_section_items", +]; + +for (const keyValue of oldFlatFieldValues) { + assert.equal( + LONG_TEXT_FIELD_CONFIG.some((item) => item.key === keyValue), + false, + `${keyValue} should be removed from LONG_TEXT_FIELD_CONFIG` + ); +} + +const longTextData = createLongTextData(); +appendLongTextChunk(longTextData, { contentKey: "title", contentValue: "漂流攻略" }); +appendLongTextChunk(longTextData, { + contentKey: "preparation_section", + contentValue: { + preparation_section_title: "下水前", + preparation_section_items: ["带手机防水袋", "穿涉水鞋"], + }, +}); +appendLongTextChunk(longTextData, { + contentKey: "future_section", + contentValue: { + future_section_title: "未来新增字段", + future_section_items: ["不改 LONG_TEXT_KEYS 也展示"], + }, +}); +appendLongTextChunk(longTextData, { + contentKey: "content_image", + contentValue: "https://oss.nianxx.cn/XiaoQiKong/GQ00001.jpg", +}); +appendLongTextChunk(longTextData, { + contentKey: "spot_locate", + contentValue: '{"spot_name":"卧龙潭","spot_longitude":"107.712345","spot_latitude":"25.251234","spot_tag":"景点"}', +}); + +assert.equal(getLongTextValue(longTextData, "title"), "漂流攻略"); +assert.deepEqual( + getLongTextParsedValue(longTextData, "preparation_section"), + { + preparation_section_title: "下水前", + preparation_section_items: ["带手机防水袋", "穿涉水鞋"], + }, + "object contentValue should be serialized and parsed" +); +assert.deepEqual( + getLongTextSections(longTextData).map((section) => section.contentKey), + ["title", "preparation_section", "future_section", "content_image", "spot_locate"], + "sections should place unknown fields by receive order between configured fields" +); +assert.equal(hasLongTextExtraSections(longTextData), true); + +assert.deepEqual( + getLongTextParsedValue(longTextData, "future_section"), + { + future_section_title: "未来新增字段", + future_section_items: ["不改 LONG_TEXT_KEYS 也展示"], + }, + "future top-level fields should keep parsed values" +); + +const mergedPayload = { + tag: "攻略", + title: "合并结构标题", + content_summary: "合并结构摘要", + preparation_section: { + preparation_section_title: "下水前", + preparation_section_items: ["带手机防水袋", "穿涉水鞋"], + }, + spot_locate: { + spot_name: "卧龙潭", + spot_longitude: "107.712345", + spot_latitude: "25.251234", + spot_tag: "景点", + }, + photo_list: [ + { + photo_name: "桥边机位", + photo_description: "站在桥头拍", + photo_url: "https://example.com/photo.png", + }, + ], + photo_spot_section: "{\n\"photo_spot_section_title\":\"四个不会错的机位\",\n\"photo_spot_items\":\"- 涵碧潭半清半浊\\n- 断桥飞瀑错位玩\",\n\"best_time_suggestion\":\"下午 2 点后人少\"\n}", + phone_section: "{\n\"phone_section_title\":\"手机党看这里\",\n\"phone_section_items\":\"- 手机倒过来贴近水面\\n- 瀑布用长曝光\"\n}", + aigc_componet: { + background: "https://example.com/aigc.png", + title: "AIGC合影", + description: "生成合影", + jumpUrl: "https://example.com/aigc", + }, + question_suggest: ["还要准备什么?"], + future_section: { + future_section_title: "后续新增模块", + future_section_items: ["仍然展示"], + }, +}; + +const mergedLongTextData = createLongTextData(); +appendLongTextChunk(mergedLongTextData, { + contentKey: "photo_card_payload", + contentValue: mergedPayload, +}); + +assert.deepEqual( + getLongTextSections(mergedLongTextData).map((section) => section.contentKey), + [ + "photo_card_payload", + ], + "unknown contentKey fields should be kept as a single generic section" +); + +assert.deepEqual( + getLongTextParsedValue(mergedLongTextData, "photo_card_payload"), + mergedPayload, + "unknown contentKey object values should keep parsed values" +); + console.log("longTextCard display helpers passed"); diff --git a/src/pages/ChatMain/ChatLongAnswer/ParsedValueView.vue b/src/pages/ChatMain/ChatLongAnswer/ParsedValueView.vue index c8f65c0..97018e0 100644 --- a/src/pages/ChatMain/ChatLongAnswer/ParsedValueView.vue +++ b/src/pages/ChatMain/ChatLongAnswer/ParsedValueView.vue @@ -194,7 +194,7 @@ const contentBodyListCardStyle = computed(() => ({ })); const contentBodyMarkdownColor = computed(() => contentBodyListToneStyle.value.color); -const IGNORED_FIELD_KEYS = ["container_type", "content", "components"]; +const IGNORED_FIELD_KEYS = []; /// ======== Value Processing Helpers ======== const isArrayValue = (value) => Array.isArray(value); @@ -239,12 +239,11 @@ const createImageEntry = (key, value) => ({ }, }); - /// ======== Render Logic ======== const isIgnoredField = computed(() => IGNORED_FIELD_KEYS.includes(props.fieldKey)); const isContentImageField = computed(() => - props.fieldKey === LONG_TEXT_KEYS.contentImage || - props.fieldKey === LONG_TEXT_KEYS.sceneImage + props.fieldKey === LONG_TEXT_KEYS.sceneImage || + props.fieldKey === LONG_TEXT_KEYS.contentImage ); const isSpotLocateField = computed(() => props.fieldKey === LONG_TEXT_KEYS.spotLocate); const isQuestionSuggestField = computed(() => props.fieldKey === LONG_TEXT_KEYS.questionSuggest); @@ -260,28 +259,34 @@ const contentImageUrl = computed(() => { return typeof displayValue.value === "string" ? toTrimmedText(displayValue.value) : ""; }); -const spotLocateValue = computed(() => { - const value = isObjectValue(displayValue.value) ? displayValue.value : {}; +const getSpotLocateValue = (value) => { + const spot = isObjectValue(value) ? value : {}; return { - name: toTrimmedText(value.spot_name), - description: toTrimmedText(value.spot_description), - longitude: toFiniteNumber(value.spot_longitude), - latitude: toFiniteNumber(value.spot_latitude), - tag: toTrimmedText(value.spot_tag), + name: toTrimmedText(spot.spot_name), + description: toTrimmedText(spot.spot_description), + longitude: toFiniteNumber(spot.spot_longitude), + latitude: toFiniteNumber(spot.spot_latitude), + tag: toTrimmedText(spot.spot_tag), }; +}; + +const spotLocateValue = computed(() => { + return getSpotLocateValue(displayValue.value); }); -const questionSuggestItems = computed(() => { - return isArrayValue(displayValue.value) - ? displayValue.value.map((item) => toTrimmedText(item)).filter(Boolean) +const getQuestionSuggestItems = (value) => { + return isArrayValue(value) + ? value.map((item) => toTrimmedText(item)).filter(Boolean) : []; -}); +}; -const commodityItems = computed(() => { - if (!isArrayValue(displayValue.value)) return []; +const questionSuggestItems = computed(() => getQuestionSuggestItems(displayValue.value)); - return displayValue.value +const getCommodityItems = (value) => { + if (!isArrayValue(value)) return []; + + return value .filter((item) => isObjectValue(item)) .map((commodity) => ({ commodity_id: toTrimmedText(commodity.commodity_id), @@ -291,12 +296,14 @@ const commodityItems = computed(() => { commodity_photo: toTrimmedText(commodity.commodity_photo), })) .filter((commodity) => hasDisplayValue(commodity)); -}); +}; -const photoItems = computed(() => { - if (!isArrayValue(displayValue.value)) return []; +const commodityItems = computed(() => getCommodityItems(displayValue.value)); - return displayValue.value +const getPhotoItems = (value) => { + if (!isArrayValue(value)) return []; + + return value .filter((item) => isObjectValue(item)) .map((photo) => ({ photo_name: toTrimmedText(photo.photo_name), @@ -304,10 +311,12 @@ const photoItems = computed(() => { photo_url: toTrimmedText(photo.photo_url), })) .filter((photo) => !!photo.photo_url); -}); +}; -const aigcComponetValue = computed(() => { - const aigc = isObjectValue(displayValue.value) ? displayValue.value : {}; +const photoItems = computed(() => getPhotoItems(displayValue.value)); + +const getAigcComponetValue = (value) => { + const aigc = isObjectValue(value) ? value : {}; return { background: toTrimmedText(aigc.background), @@ -315,7 +324,44 @@ const aigcComponetValue = computed(() => { description: toTrimmedText(aigc.description), jumpUrl: toTrimmedText(aigc.jumpUrl), }; -}); +}; + +const aigcComponetValue = computed(() => getAigcComponetValue(displayValue.value)); + +const createRenderEntry = (key, value) => { + if (isImageValue(value)) { + return [createImageEntry(key, value)]; + } + + if (isArrayValue(value)) { + return [{ + key, + type: "list", + value: value.filter((item) => hasDisplayValue(item)), + }]; + } + + if (isObjectValue(value)) { + return getRenderEntriesForObject(value, key); + } + + return [{ + key, + type: "text", + value: formatLeafValue(value), + }]; +}; + +const getRenderEntriesForObject = (value = {}, parentKey = "") => { + if (!isObjectValue(value)) return []; + + return Object.keys(value) + .filter((key) => hasDisplayValue(value[key])) + .reduce((entries, key) => { + const entryKey = parentKey ? `${parentKey}-${key}` : key; + return entries.concat(createRenderEntry(entryKey, value[key])); + }, []); +}; const shouldRenderContentImage = computed(() => { return isContentImageField.value && !!contentImageUrl.value; @@ -345,6 +391,7 @@ const shouldRenderAigcComponet = computed(() => { const renderFieldEntries = computed(() => { if (isIgnoredField.value || !hasDisplayValue(props.value)) return []; const value = displayValue.value; + if (isImageValue(value)) { return [createImageEntry(props.fieldKey, value)]; } @@ -358,26 +405,7 @@ const renderFieldEntries = computed(() => { } if (isObjectValue(value)) { - return Object.keys(value) - .filter((key) => hasDisplayValue(value[key])) - .map((key) => { - const entryValue = value[key]; - if (isImageValue(entryValue)) { - return createImageEntry(key, entryValue); - } - if (isArrayValue(entryValue)) { - return { - key, - type: "list", - value: entryValue.filter((item) => hasDisplayValue(item)), - }; - } - return { - key, - type: "text", - value: formatLeafValue(entryValue), - }; - }); + return getRenderEntriesForObject(value); } return [{ diff --git a/src/pages/ChatMain/ChatLongAnswer/index.vue b/src/pages/ChatMain/ChatLongAnswer/index.vue index deab239..dc4783e 100644 --- a/src/pages/ChatMain/ChatLongAnswer/index.vue +++ b/src/pages/ChatMain/ChatLongAnswer/index.vue @@ -24,8 +24,6 @@ :value="section.parsedValue !== null ? section.parsedValue : section.contentValue" /> - - @@ -65,16 +63,13 @@ const longAnswerTagStyle = computed(() => buildTagToneStyle(longAnswerTagColor.v let unsubscribe = null; -const HIDDEN_DETAIL_SECTION_KEYS = [ - LONG_TEXT_KEYS.containerType, -]; +const HIDDEN_DETAIL_SECTION_KEYS = []; const shouldUseParsedValueView = (section) => { return ( section.fromLongTextData && section.contentKey !== LONG_TEXT_KEYS.tag && section.contentKey !== LONG_TEXT_KEYS.title && - section.contentKey !== LONG_TEXT_KEYS.content && !HIDDEN_DETAIL_SECTION_KEYS.includes(section.contentKey) ); }; @@ -227,7 +222,7 @@ onLoad(({ message = "", streamId = "", finished = "0", tagToneColor = "" }) => { answerText.value = text || ""; longTextData.value = payload || null; title.value = computeTitle( - getLongTextValue(longTextData.value, "title") || answerText.value + getLongTextValue(longTextData.value, LONG_TEXT_KEYS.title) || answerText.value ); nextTick(() => { diff --git a/src/pages/ChatModule/AnswerComponent/index.vue b/src/pages/ChatModule/AnswerComponent/index.vue index c775993..e49340f 100644 --- a/src/pages/ChatModule/AnswerComponent/index.vue +++ b/src/pages/ChatModule/AnswerComponent/index.vue @@ -33,6 +33,7 @@ import ChatMarkdown from "../../ChatMain/ChatMarkdown/index.vue"; import ChatLoading from "../../ChatMain/ChatLoading/index.vue"; import StreamManager from '@/utils/StreamManager.js'; import { + LONG_TEXT_KEYS, getLongTextPreviewText, getLongTextValue, hasLongTextExtraSections, @@ -55,12 +56,12 @@ const props = defineProps({ }, }); -const tag = computed(() => getLongTextValue(props.longTextData, "tag")); -const title = computed(() => getLongTextValue(props.longTextData, "title")); +const tag = computed(() => getLongTextValue(props.longTextData, LONG_TEXT_KEYS.tag)); +const title = computed(() => getLongTextValue(props.longTextData, LONG_TEXT_KEYS.title)); const longAnswerTagColor = ref(pickRandomTagToneColor()); const longAnswerTagStyle = computed(() => buildTagToneStyle(longAnswerTagColor.value)); const previewContent = computed(() => { - return getLongTextPreviewText(props.longTextData, ["content_summary"]); + return getLongTextPreviewText(props.longTextData, [LONG_TEXT_KEYS.contentSummary]); }); // 处理文本内容:按行截断以保证预览最多显示两行(更贴近视觉行数) diff --git a/src/utils/longTextCard.js b/src/utils/longTextCard.js index 2c846e7..a76f3f4 100644 --- a/src/utils/longTextCard.js +++ b/src/utils/longTextCard.js @@ -1,127 +1,56 @@ export const LONG_TEXT_KEYS = { - containerType: "container_type", tag: "tag", title: "title", - content: "content", contentSummary: "content_summary", - guideConclusion: "guide_conclusion", - keyFacts: "key_facts", sceneImage: "scene_image", - photoSpotSectionTitle: "photo_spot_section_title", - photoSpotSectionItems: "photo_spot_section_items", - bestTimeSuggestion: "best_time_suggestion", - phoneSectionTitle: "phone_section_title", - phoneSectionItems: "phone_section_items", - photoList: "photo_list", - aigcComponet: "aigc_componet", - preparationSectionTitle: "preparation_section_title", - preparationSectionItems: "preparation_section_items", - sectionSuggestionTitle: "section_suggestion_title", - sectionSuggestionContent: "section_suggestion_content", - pitfallSectionTitle: "pitfall_section_title", - pitfallSectionItems: "pitfall_section_items", + preparationSection: "preparation_section", + sectionSuggestion: "section_suggestion", + pitfallSection: "pitfall_section", commodityList: "commodity_list", contentImage: "content_image", - viewSectionTitle: "view_section_title", - viewSectionItems: "view_section_items", - suggestionSectionTitle: "suggestion_section_title", - suggestionSectionContent: "suggestion_section_content", - lightReminderTitle: "light_reminder_title", - lightReminderItems: "light_reminder_items", - checklistOrSteps: "checklist_or_steps", - bestTimeOrPeople: "best_time_or_people", - avoidPitfalls: "avoid_pitfalls", - nextSuggestion: "next_suggestion", - poiDefinition: "poi_definition", - keyHighlights: "key_highlights", - heroImage: "hero_image", - backgroundStory: "background_story", - bestTime: "best_time", - visitSuggestion: "visit_suggestion", - routeSummary: "route_summary", - routeMeta: "route_meta", - routeSteps: "route_steps", - onsiteClues: "onsite_clues", - realtimeNotice: "realtime_notice", - routeWarning: "route_warning", - arrivalNextStep: "arrival_next_step", - photoConclusion: "photo_conclusion", - photoSpots: "photo_spots", - compositionTips: "composition_tips", - phoneSettings: "phone_settings", + viewSection: "view_section", + suggestionSection: "suggestion_section", lightReminder: "light_reminder", - sampleImage: "sample_image", - components: "components", - checklist: "checklist", - suggest: "suggest", - commodity: "commodity", - actionZone: "action_zone", spotLocate: "spot_locate", + photoSpotSection: "photo_spot_section", + phoneSection: "phone_section", + photoList: "photo_list", + aigcComponet: "aigc_componet", + decisionSection: "decision_section", + routeWarning: "route_warning", + tourRoutes: "tour_routes", + facilitiesAlongTheWay: "facilities_along_the_way", questionSuggest: "question_suggest", }; export const LONG_TEXT_FIELD_CONFIG = [ - { key: LONG_TEXT_KEYS.containerType }, { key: LONG_TEXT_KEYS.tag }, { key: LONG_TEXT_KEYS.title }, - { key: LONG_TEXT_KEYS.content }, { key: LONG_TEXT_KEYS.contentSummary }, - { key: LONG_TEXT_KEYS.guideConclusion }, - { key: LONG_TEXT_KEYS.keyFacts }, { key: LONG_TEXT_KEYS.sceneImage }, - { key: LONG_TEXT_KEYS.photoSpotSectionTitle }, - { key: LONG_TEXT_KEYS.photoSpotSectionItems }, - { key: LONG_TEXT_KEYS.bestTimeSuggestion }, - { key: LONG_TEXT_KEYS.phoneSectionTitle }, - { key: LONG_TEXT_KEYS.phoneSectionItems }, - { key: LONG_TEXT_KEYS.photoList }, - { key: LONG_TEXT_KEYS.aigcComponet }, - { key: LONG_TEXT_KEYS.preparationSectionTitle }, - { key: LONG_TEXT_KEYS.preparationSectionItems }, - { key: LONG_TEXT_KEYS.sectionSuggestionTitle }, - { key: LONG_TEXT_KEYS.sectionSuggestionContent }, - { key: LONG_TEXT_KEYS.pitfallSectionTitle }, - { key: LONG_TEXT_KEYS.pitfallSectionItems }, + { key: LONG_TEXT_KEYS.preparationSection }, + { key: LONG_TEXT_KEYS.sectionSuggestion }, + { key: LONG_TEXT_KEYS.pitfallSection }, { key: LONG_TEXT_KEYS.commodityList }, { key: LONG_TEXT_KEYS.contentImage }, - { key: LONG_TEXT_KEYS.viewSectionTitle }, - { key: LONG_TEXT_KEYS.viewSectionItems }, - { key: LONG_TEXT_KEYS.suggestionSectionTitle }, - { key: LONG_TEXT_KEYS.suggestionSectionContent }, - { key: LONG_TEXT_KEYS.lightReminderTitle }, - { key: LONG_TEXT_KEYS.lightReminderItems }, - { key: LONG_TEXT_KEYS.checklistOrSteps }, - { key: LONG_TEXT_KEYS.bestTimeOrPeople }, - { key: LONG_TEXT_KEYS.avoidPitfalls }, - { key: LONG_TEXT_KEYS.nextSuggestion }, - { key: LONG_TEXT_KEYS.poiDefinition }, - { key: LONG_TEXT_KEYS.keyHighlights }, - { key: LONG_TEXT_KEYS.heroImage }, - { key: LONG_TEXT_KEYS.backgroundStory }, - { key: LONG_TEXT_KEYS.bestTime }, - { key: LONG_TEXT_KEYS.visitSuggestion }, - { key: LONG_TEXT_KEYS.routeSummary }, - { key: LONG_TEXT_KEYS.routeMeta }, - { key: LONG_TEXT_KEYS.routeSteps }, - { key: LONG_TEXT_KEYS.onsiteClues }, - { key: LONG_TEXT_KEYS.realtimeNotice }, - { key: LONG_TEXT_KEYS.routeWarning }, - { key: LONG_TEXT_KEYS.arrivalNextStep }, - { key: LONG_TEXT_KEYS.photoConclusion }, - { key: LONG_TEXT_KEYS.photoSpots }, - { key: LONG_TEXT_KEYS.compositionTips }, - { key: LONG_TEXT_KEYS.phoneSettings }, + { key: LONG_TEXT_KEYS.viewSection }, + { key: LONG_TEXT_KEYS.suggestionSection }, { key: LONG_TEXT_KEYS.lightReminder }, - { key: LONG_TEXT_KEYS.sampleImage }, - { key: LONG_TEXT_KEYS.components }, - { key: LONG_TEXT_KEYS.checklist }, - { key: LONG_TEXT_KEYS.suggest }, - { key: LONG_TEXT_KEYS.commodity }, - { key: LONG_TEXT_KEYS.actionZone }, { key: LONG_TEXT_KEYS.spotLocate }, + { key: LONG_TEXT_KEYS.photoSpotSection }, + { key: LONG_TEXT_KEYS.phoneSection }, + { key: LONG_TEXT_KEYS.photoList }, + { key: LONG_TEXT_KEYS.aigcComponet }, + { key: LONG_TEXT_KEYS.decisionSection }, + { key: LONG_TEXT_KEYS.routeWarning }, + { key: LONG_TEXT_KEYS.tourRoutes }, + { key: LONG_TEXT_KEYS.facilitiesAlongTheWay }, { key: LONG_TEXT_KEYS.questionSuggest }, ]; +const LONG_TEXT_FIELD_KEYS = LONG_TEXT_FIELD_CONFIG.map((item) => item.key); +const LONG_TEXT_FIELD_KEY_SET = new Set(LONG_TEXT_FIELD_KEYS); + export const LONG_TEXT_PREVIEW_KEYS = [ LONG_TEXT_KEYS.contentSummary, LONG_TEXT_KEYS.title, @@ -133,9 +62,21 @@ export const createLongTextData = () => ({ parsedValues: {}, }); +export const isKnownLongTextKey = (key) => { + return LONG_TEXT_FIELD_KEY_SET.has(String(key)); +}; + const toText = (value) => { if (value === undefined || value === null) return ""; - return typeof value === "string" ? value : String(value); + if (typeof value === "string") return value; + if (typeof value === "object") { + try { + return JSON.stringify(value); + } catch (e) { + return String(value); + } + } + return String(value); }; const shouldParseJSON = (raw) => { @@ -167,6 +108,10 @@ const isLongTextObjectValue = (value) => { return value !== null && typeof value === "object" && !Array.isArray(value); }; +const hasOwnField = (value, key) => { + return Object.prototype.hasOwnProperty.call(value, key); +}; + export const parseLongTextDisplayValue = (value) => { return parseMaybeJSON(value); }; @@ -218,15 +163,18 @@ export const formatLongTextDisplayValue = (value, ignoredKeys = []) => { return String(value); }; -export const appendLongTextChunk = (target, chunk = {}) => { - if (!target || !chunk.contentKey) return target; - - const key = String(chunk.contentKey); - const value = toText(chunk.contentValue); +const getChunkContentValue = (chunk = {}) => { + if (!isLongTextObjectValue(chunk)) return chunk; + if (hasOwnField(chunk, "contentValue")) return chunk.contentValue; + if (hasOwnField(chunk, "content")) return chunk.content; + return chunk; +}; +const appendLongTextField = (target, key, rawValue) => { if (!target.values) target.values = {}; if (!target.parsedValues) target.parsedValues = {}; + const value = toText(rawValue); target.values[key] = (target.values[key] || "") + value; const parsed = tryParseJSON(target.values[key]); @@ -235,7 +183,12 @@ export const appendLongTextChunk = (target, chunk = {}) => { } else { delete target.parsedValues[key]; } +}; +export const appendLongTextChunk = (target, chunk = {}) => { + if (!target || !chunk || !chunk.contentKey) return target; + + appendLongTextField(target, String(chunk.contentKey), getChunkContentValue(chunk)); return target; }; @@ -260,14 +213,40 @@ export const getLongTextPreviewText = (data, keys = LONG_TEXT_PREVIEW_KEYS) => { }; 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)); + return getLongTextSections(data).some((section) => !previewKeys.includes(section.contentKey)); }; export const getLongTextSections = (data) => { if (!data || !data.values) return []; - return Object.keys(data.values) + const receivedKeys = Object.keys(data.values); + const extraKeysByAnchor = receivedKeys + .filter((key) => !LONG_TEXT_FIELD_KEY_SET.has(key)) + .reduce((result, key) => { + const keyIndex = receivedKeys.indexOf(key); + const anchorKey = receivedKeys + .slice(0, keyIndex) + .reverse() + .find((item) => LONG_TEXT_FIELD_KEY_SET.has(item)) || ""; + + if (!result[anchorKey]) result[anchorKey] = []; + result[anchorKey].push(key); + return result; + }, {}); + + const orderedKeys = LONG_TEXT_FIELD_KEYS + .filter((key) => + Object.prototype.hasOwnProperty.call(data.values, key) + ) + .reduce((result, key) => { + result.push(key); + if (extraKeysByAnchor[key]) { + result.push(...extraKeysByAnchor[key]); + } + return result; + }, extraKeysByAnchor[""] || []); + + return orderedKeys .map((key) => ({ contentKey: key, contentValue: getLongTextValue(data, key),