fix(records): improve messy chat content display

This commit is contained in:
digua committed 2026-07-02 10:35:02 +08:00
1 parent 57d6450430
commit 3a4386b5f5
8 files changed
+360 -14

No files matched your search

@@ -182,9 +182,19 @@ watch(
</script>
<template>
<UDrawer v-model:open="layoutStore.showChatRecordDrawer" direction="right" :handle="false" :ui="{ content: 'z-50' }">
<UDrawer
v-model:open="layoutStore.showChatRecordDrawer"
direction="right"
:handle="false"
handle-only
:ui="{ content: 'z-50' }"
>
<template #content>
<div class="flex h-full w-[750px] flex-col bg-white dark:bg-page-dark" style="-webkit-app-region: no-drag">
<div
data-vaul-no-drag
class="chat-record-drawer-content flex h-full w-[750px] flex-col bg-white dark:bg-page-dark"
style="-webkit-app-region: no-drag"
>
<!-- 头部 -->
<div
class="flex items-center justify-between border-b border-gray-200 px-4 dark:border-gray-800"
@@ -238,3 +248,11 @@ watch(
</template>
</UDrawer>
</template>
<style scoped>
.chat-record-drawer-content :deep(.chat-record-message-content),
.chat-record-drawer-content :deep(.chat-record-message-content *) {
-webkit-user-select: text;
user-select: text;
}
</style>
@@ -3,9 +3,10 @@
* 单条消息展示组件 - 气泡样式
* 支持 Owner 消息显示在右侧(类似聊天界面)
*/
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { analyzeMessyContent } from './messy-content'
import type { ChatRecordMessage } from './types'
import { useSessionStore } from '@/stores/session'
@@ -72,6 +73,23 @@ const nameColor = computed(() => currentColor.value.name)
// 气泡颜色
const bubbleColor = 'bg-gray-100/80 dark:bg-gray-800/80'
const showFullMessyContent = ref(false)
const contentAnalysis = computed(() => analyzeMessyContent(props.message.content || ''))
const visibleMessageContent = computed(() => {
const analysis = contentAnalysis.value
if (!analysis.shouldCollapse || showFullMessyContent.value) {
return analysis.normalizedContent
}
return analysis.previewLines.join('\n')
})
watch(
() => [props.message.id, props.message.content],
() => {
showFullMessyContent.value = false
}
)
// 显示名称(包含别名)
const displayName = computed(() => {
@@ -117,13 +135,34 @@ const avatarLetter = computed(() => {
const HIGHLIGHT_MARK_CLASS =
'bg-transparent text-inherit underline decoration-primary-500/80 underline-offset-4 dark:decoration-primary-400/80'
function escapeHtml(content: string): string {
return content.replace(/[&<>"']/g, (char) => {
switch (char) {
case '&':
return '&amp;'
case '<':
return '&lt;'
case '>':
return '&gt;'
case '"':
return '&quot;'
case "'":
return '&#39;'
default:
return char
}
})
}
// 高亮关键词
function highlightContent(content: string): string {
if (!props.highlightKeywords?.length || !content) return content
const escapedContent = escapeHtml(content)
const escapedKeywords = props.highlightKeywords?.map((keyword) => escapeHtml(keyword)).filter(Boolean) ?? []
if (!escapedKeywords.length || !escapedContent) return escapedContent
const pattern = props.highlightKeywords.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
const pattern = escapedKeywords.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
const regex = new RegExp(`(${pattern})`, 'gi')
return content.replace(regex, `<mark class="${HIGHLIGHT_MARK_CLASS}">$1</mark>`)
return escapedContent.replace(regex, `<mark class="${HIGHLIGHT_MARK_CLASS}">$1</mark>`)
}
</script>
@@ -159,9 +198,9 @@ function highlightContent(content: string): string {
<!-- 气泡和上下文按钮 -->
<!-- max-w-[calc(100%-48px)] = 100% - 头像宽度(36px) - gap(12px) -->
<div class="flex items-start gap-1 max-w-[calc(100%-68px)]" :class="isOwner ? 'flex-row-reverse' : ''">
<div class="flex min-w-0 max-w-[calc(100%-68px)] items-start gap-1" :class="isOwner ? 'flex-row-reverse' : ''">
<div
class="relative inline-block rounded-2xl px-3.5 py-2.5 transition-all duration-300 border border-gray-100 dark:border-gray-800/40"
class="relative min-w-0 max-w-full rounded-2xl border border-gray-100 px-3.5 py-2.5 transition-all duration-300 dark:border-gray-800/40"
:class="bubbleColor"
>
<!-- 回复引用样式 -->
@@ -181,9 +220,43 @@ function highlightContent(content: string): string {
</p>
</div>
<p
class="whitespace-pre-wrap break-all text-sm text-gray-700 dark:text-gray-200"
v-html="highlightContent(message.content || '')"
class="chat-record-message-content whitespace-pre-wrap break-all text-sm text-gray-700 dark:text-gray-200"
v-html="highlightContent(visibleMessageContent)"
/>
<ul
v-if="contentAnalysis.linkUrls.length"
class="chat-record-message-content mt-2 min-w-0 max-w-full list-disc space-y-1 pl-4 text-xs text-primary-600 dark:text-primary-400"
>
<li v-for="linkUrl in contentAnalysis.linkUrls" :key="linkUrl" class="min-w-0">
<a
:href="linkUrl"
target="_blank"
rel="noopener noreferrer"
class="block min-w-0 max-w-full truncate transition-colors hover:text-primary-700 dark:hover:text-primary-300"
@click.stop
>
{{ linkUrl }}
</a>
</li>
</ul>
<button
v-if="contentAnalysis.shouldCollapse"
type="button"
class="mt-2 inline-flex items-center gap-1 text-xs font-medium text-gray-500 transition-colors select-none hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 cursor-pointer"
@click="showFullMessyContent = !showFullMessyContent"
>
<UIcon
:name="showFullMessyContent ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'"
class="h-3.5 w-3.5"
/>
<span>
{{
showFullMessyContent
? t('records.messageItem.collapseMessyContent')
: t('records.messageItem.expandMessyContent')
}}
</span>
</button>
</div>
<!-- 上下文查看按钮 -->
@@ -0,0 +1,90 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { analyzeMessyContent } from './messy-content'
describe('analyzeMessyContent', () => {
it('collapses noisy multiline exports without depending on URLs', () => {
const content = `
\t
\t\t共享内容标题
多人转发
\tview
\t5
\t\t305f020100044b30490201000204846c4a3802032f53a20204afb5a97402046a3b7c61042433333039336236362d323531622d343534622d396561622d383235343132626435633836020405180803020100040d004c51e6000000000000000000
\t\t7d9c1deea7ef68b29a2bd5fa8bc5ff72
\t\t147bea67c1720db31fef2ee211d8ee0e
\t2
\tGhQKEnd4ZDhhMjc1MGNlOWQ0Njk4MA==
\tfalse
\t\t-1
\t-1
member_gh9gt7d9p30422
\t0
\t\t46
\t\t来源标签
`
const result = analyzeMessyContent(content)
assert.equal(result.shouldCollapse, true)
assert.deepEqual(result.previewLines, ['共享内容标题', '多人转发', 'view'])
assert.equal(result.normalizedContent.startsWith('\t\t共享内容标题'), true)
assert.equal(result.normalizedContent.endsWith('来源标签'), true)
assert.ok(result.hiddenLineCount > 0)
})
it('does not collapse readable multiline chat content', () => {
const content = `
今天整理了一下六月的记录
发现其实很多事情已经推进完了
剩下的是把安装流程再打磨一下
这个可以明天继续看
`
const result = analyzeMessyContent(content)
assert.equal(result.shouldCollapse, false)
assert.deepEqual(result.previewLines, [
'今天整理了一下六月的记录',
'发现其实很多事情已经推进完了',
'剩下的是把安装流程再打磨一下',
])
assert.equal(result.normalizedContent, content.trim())
})
it('uses readable leaf text as the preview for generic XML content', () => {
const content = `<?xml version="1.0"?>
<message>
<payload>
<title>可读标题</title>
<description>第一行说明
多人转发</description>
<action>view</action>
<type>5</type>
<link>https://example.test/items/abc?source=chat&amp;token=preview</link>
<backupLink>https://docs.example.test/read/123</backupLink>
<attachment>
<checksum>305f020100044b30490201000204846c4a3802032f53a20204afb5a97402046a3b7c61042433333039336236362d323531622d343534622d396561622d383235343132626435633836</checksum>
<encoded>GhQKEnd4ZDhhMjc1MGNlOWQ0Njk4MA==</encoded>
</attachment>
<flag>false</flag>
</payload>
</message>`
const result = analyzeMessyContent(content)
assert.equal(result.shouldCollapse, true)
assert.deepEqual(result.previewLines, ['可读标题', '第一行说明', '多人转发'])
assert.deepEqual(result.linkUrls, [
'https://example.test/items/abc?source=chat&token=preview',
'https://docs.example.test/read/123',
])
assert.equal(result.normalizedContent.startsWith('<?xml version="1.0"?>'), true)
})
})
@@ -0,0 +1,157 @@
export interface MessyContentAnalysis {
shouldCollapse: boolean
normalizedContent: string
previewLines: string[]
hiddenLineCount: number
linkUrls: string[]
}
const PREVIEW_LINE_COUNT = 3
const MIN_LINE_COUNT = 8
const MIN_MACHINE_LINE_RATIO = 0.35
const MIN_STRUCTURAL_NOISE_RATIO = 0.25
const MIN_XML_TAG_COUNT = 6
const LONG_TOKEN_LENGTH = 32
function normalizeLineEndings(content: string): string {
return content.replace(/\r\n?/g, '\n')
}
function trimOuterBlankLines(lines: string[]): string[] {
let start = 0
let end = lines.length
while (start < end && lines[start].trim() === '') {
start += 1
}
while (end > start && lines[end - 1].trim() === '') {
end -= 1
}
return lines.slice(start, end)
}
function isMachineLikeLine(line: string): boolean {
const value = line.trim()
if (!value) return false
if (/^https?:\/\//i.test(value) && value.length >= LONG_TOKEN_LENGTH) return true
if (/^(?:true|false|null|undefined|-?\d+)$/i.test(value)) return true
if (/^[0-9a-f]{24,}$/i.test(value)) return true
if (/^[a-z0-9_-]+_[a-z0-9_-]{8,}$/i.test(value)) return true
if (/^[a-z0-9+/=_-]{24,}$/i.test(value) && /[a-z]/i.test(value) && /\d/.test(value)) return true
const hasWhitespace = /\s/.test(value)
if (!hasWhitespace && value.length >= LONG_TOKEN_LENGTH && /^[\w+/=:%.-]+$/.test(value)) return true
const readableMatches = value.match(/[\p{Script=Han}\p{Letter}]/gu) ?? []
const readableRatio = readableMatches.length / value.length
return value.length >= 16 && readableRatio < 0.25
}
function isStructuralNoiseLine(line: string): boolean {
if (line.trim() === '') return true
return /^\s+/.test(line) || line.includes('\t')
}
function countXmlTags(content: string): number {
return content.match(/<\/?[A-Za-z_][\w:.-]*(?:\s[^<>]*)?>/g)?.length ?? 0
}
function isXmlLikeContent(content: string): boolean {
const trimmedContent = content.trim()
if (!/^(?:<\?xml[\s\S]*?\?>\s*)?<[A-Za-z_][\w:.-]*(?:\s[^<>]*)?>/.test(trimmedContent)) {
return false
}
return countXmlTags(trimmedContent) >= MIN_XML_TAG_COUNT
}
function decodeXmlEntities(content: string): string {
return content
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&')
}
function extractXmlPreviewLines(content: string): string[] {
const textContent = content
.replace(/<\?xml[\s\S]*?\?>/gi, '\n')
.replace(/<!--[\s\S]*?-->/g, '\n')
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '\n$1\n')
.replace(/<[^>]+>/g, '\n')
// XML 只抽取叶子文本节点做预览;标签名、属性和机器字段都不参与摘要。
return textContent
.split('\n')
.map((line) => decodeXmlEntities(line).trim())
.filter((line) => line && !isMachineLikeLine(line))
.slice(0, PREVIEW_LINE_COUNT)
}
function extractHttpUrls(content: string): string[] {
const decodedContent = decodeXmlEntities(content)
const matches = decodedContent.match(/https?:\/\/[^\s<>"']+/gi) ?? []
const urls = new Set<string>()
for (const match of matches) {
try {
const url = new URL(match)
if (url.protocol === 'http:' || url.protocol === 'https:') {
urls.add(url.toString())
}
} catch {
// 忽略格式不完整的链接片段,展示层只接收浏览器可打开的 URL。
}
}
return [...urls]
}
export function analyzeMessyContent(content: string): MessyContentAnalysis {
const normalizedLines = trimOuterBlankLines(normalizeLineEndings(content).split('\n'))
const normalizedContent = normalizedLines.join('\n')
const nonEmptyLines = normalizedLines.filter((line) => line.trim() !== '')
const xmlPreviewLines = isXmlLikeContent(normalizedContent) ? extractXmlPreviewLines(normalizedContent) : []
const previewLines =
xmlPreviewLines.length > 0 ? xmlPreviewLines : nonEmptyLines.slice(0, PREVIEW_LINE_COUNT).map((line) => line.trim())
if (xmlPreviewLines.length > 0) {
return {
shouldCollapse: true,
normalizedContent,
previewLines,
hiddenLineCount: Math.max(nonEmptyLines.length - previewLines.length, 0),
linkUrls: extractHttpUrls(normalizedContent),
}
}
if (nonEmptyLines.length < MIN_LINE_COUNT) {
return {
shouldCollapse: false,
normalizedContent,
previewLines,
hiddenLineCount: Math.max(nonEmptyLines.length - previewLines.length, 0),
linkUrls: [],
}
}
const machineLineCount = nonEmptyLines.filter(isMachineLikeLine).length
const structuralNoiseLineCount = normalizedLines.filter(isStructuralNoiseLine).length
// 这里只看文本结构,不识别具体平台或字段名,避免把某个聊天软件的导出格式写死到展示层。
const machineLineRatio = machineLineCount / nonEmptyLines.length
const structuralNoiseRatio = structuralNoiseLineCount / normalizedLines.length
const shouldCollapse =
machineLineRatio >= MIN_MACHINE_LINE_RATIO && structuralNoiseRatio >= MIN_STRUCTURAL_NOISE_RATIO
return {
shouldCollapse,
normalizedContent,
previewLines,
hiddenLineCount: Math.max(nonEmptyLines.length - previewLines.length, 0),
linkUrls: [],
}
}
+3 -1
View File
@@ -66,7 +66,9 @@
},
"messageItem": {
"viewContext": "View Context",
"replyTo": "Reply to"
"replyTo": "Reply to",
"expandMessyContent": "Show full content",
"collapseMessyContent": "Collapse"
},
"sessionIndex": {
"title": "Session Index",
+3 -1
View File
@@ -66,7 +66,9 @@
},
"messageItem": {
"viewContext": "コンテキストを表示",
"replyTo": "返信"
"replyTo": "返信",
"expandMessyContent": "全文を表示",
"collapseMessyContent": "折りたたむ"
},
"sessionIndex": {
"title": "セッションインデックス",
+3 -1
View File
@@ -66,7 +66,9 @@
},
"messageItem": {
"viewContext": "查看上下文",
"replyTo": "回复"
"replyTo": "回复",
"expandMessyContent": "展开完整内容",
"collapseMessyContent": "收起"
},
"sessionIndex": {
"title": "会话索引",
+3 -1
View File
@@ -66,7 +66,9 @@
},
"messageItem": {
"viewContext": "檢視上下文",
"replyTo": "回覆"
"replyTo": "回覆",
"expandMessyContent": "展開完整內容",
"collapseMessyContent": "收起"
},
"sessionIndex": {
"title": "會話索引",