mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
fix(records): improve messy chat content display
This commit is contained in:
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 '&'
|
||||
case '<':
|
||||
return '<'
|
||||
case '>':
|
||||
return '>'
|
||||
case '"':
|
||||
return '"'
|
||||
case "'":
|
||||
return '''
|
||||
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&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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/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: [],
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,9 @@
|
||||
},
|
||||
"messageItem": {
|
||||
"viewContext": "View Context",
|
||||
"replyTo": "Reply to"
|
||||
"replyTo": "Reply to",
|
||||
"expandMessyContent": "Show full content",
|
||||
"collapseMessyContent": "Collapse"
|
||||
},
|
||||
"sessionIndex": {
|
||||
"title": "Session Index",
|
||||
|
||||
@@ -66,7 +66,9 @@
|
||||
},
|
||||
"messageItem": {
|
||||
"viewContext": "コンテキストを表示",
|
||||
"replyTo": "返信"
|
||||
"replyTo": "返信",
|
||||
"expandMessyContent": "全文を表示",
|
||||
"collapseMessyContent": "折りたたむ"
|
||||
},
|
||||
"sessionIndex": {
|
||||
"title": "セッションインデックス",
|
||||
|
||||
@@ -66,7 +66,9 @@
|
||||
},
|
||||
"messageItem": {
|
||||
"viewContext": "查看上下文",
|
||||
"replyTo": "回复"
|
||||
"replyTo": "回复",
|
||||
"expandMessyContent": "展开完整内容",
|
||||
"collapseMessyContent": "收起"
|
||||
},
|
||||
"sessionIndex": {
|
||||
"title": "会话索引",
|
||||
|
||||
@@ -66,7 +66,9 @@
|
||||
},
|
||||
"messageItem": {
|
||||
"viewContext": "檢視上下文",
|
||||
"replyTo": "回覆"
|
||||
"replyTo": "回覆",
|
||||
"expandMessyContent": "展開完整內容",
|
||||
"collapseMessyContent": "收起"
|
||||
},
|
||||
"sessionIndex": {
|
||||
"title": "會話索引",
|
||||
|
||||
Reference in new issue
Block a user