fix(chat): polish references drawer layout and web reference titles

Improve reference list readability by resolving URL-like titles to domains,
stripping markdown noise from snippets, and aligning suggestion chip hover
styles across chat and embed surfaces.
This commit is contained in:
wizardchen
2026-07-17 11:03:43 +08:00
committed by lyingbug
parent eedd7d4641
commit 3edd9c2c84
6 changed files with 288 additions and 202 deletions
+172 -184
View File
@@ -10,8 +10,9 @@
>
<header class="chat-references-panel__header">
<div class="chat-references-panel__heading">
<h3 class="chat-references-panel__title">{{ panelTitle }}</h3>
<span v-if="totalCount" class="chat-references-panel__count">{{ totalCount }}</span>
<h3 class="chat-references-panel__title">
{{ panelTitle }}<span v-if="totalCount" class="chat-references-panel__count"> · {{ totalCount }}</span>
</h3>
</div>
<button
type="button"
@@ -19,7 +20,7 @@
:aria-label="t('common.close')"
@click="close"
>
<t-icon name="close" />
<t-icon name="close" size="20px" />
</button>
</header>
@@ -41,17 +42,17 @@
v-for="item in section.items"
:key="item.key"
:ref="(el) => setItemRef(item.key, el as HTMLElement | null)"
class="reference-card"
class="reference-item"
:class="{
'reference-card--web': item.kind === 'web',
'reference-card--document': item.kind === 'document',
'reference-card--tool': item.kind === 'tool',
'reference-item--web': item.kind === 'web',
'reference-item--document': item.kind === 'document',
'reference-item--tool': item.kind === 'tool',
'is-highlighted': item.key === activeHighlightKey,
}"
>
<component
:is="item.kind === 'web' ? 'a' : 'div'"
class="reference-card__inner"
class="reference-item__body"
:class="{ 'is-expandable': item.kind === 'document' && hasMoreContent(item) }"
:href="item.kind === 'web' ? item.url : undefined"
:target="item.kind === 'web' ? '_blank' : undefined"
@@ -63,60 +64,60 @@
@keydown.enter="item.kind === 'document' && hasMoreContent(item) ? toggleDocumentSnippet(item) : undefined"
@keydown.space.prevent="item.kind === 'document' && hasMoreContent(item) ? toggleDocumentSnippet(item) : undefined"
>
<div class="reference-card__meta">
<span class="reference-card__index">{{ item.index }}</span>
<img
v-if="item.kind === 'web' && item.faviconUrl"
class="reference-card__favicon"
:src="item.faviconUrl"
alt=""
loading="lazy"
@error="onFaviconError"
/>
<t-icon
v-else-if="item.kind === 'document'"
name="file"
class="reference-card__doc-icon"
/>
<t-icon
v-else-if="item.kind === 'tool'"
name="tools"
class="reference-card__doc-icon"
/>
<span v-if="item.domain" class="reference-card__domain">{{ item.domain }}</span>
<span v-else-if="item.kind === 'document'" class="reference-card__domain">
{{ t('chat.referencesDrawerDocument') }}
</span>
<span v-else-if="item.kind === 'tool'" class="reference-card__domain">
{{ t('chat.referencesDrawerTool') }}
</span>
</div>
<template v-if="item.kind === 'document'">
<div class="reference-item__document">
<t-icon name="file" class="reference-item__doc-icon" />
<div class="reference-item__document-main">
<div class="reference-item__title-row">
<h5 class="reference-item__title">{{ item.title }}</h5>
<a
v-if="item.knowledgeBaseId && !embeddedMode"
class="reference-item__open"
:href="getDocumentHref(item)"
target="_blank"
rel="noopener noreferrer"
:aria-label="t('chat.navigateToDocument')"
@click.stop
>
<t-icon name="jump" size="14px" />
</a>
</div>
<p v-if="item.snippet && !expandedKeys.has(item.key)" class="reference-item__snippet">
{{ formatReferenceSnippet(item.snippet) }}
</p>
<div v-if="expandedKeys.has(item.key)" class="reference-item__content">
{{ formatReferenceSnippet(item.content) }}
</div>
</div>
</div>
</template>
<template v-else>
<div v-if="item.kind === 'web' && item.domain" class="reference-item__source">
<img
v-if="item.faviconUrl"
class="reference-item__source-mark"
:src="item.faviconUrl"
alt=""
loading="lazy"
@error="onFaviconError"
/>
<span class="reference-item__domain">{{ item.domain }}</span>
</div>
<div v-else-if="item.kind === 'tool' && item.domain" class="reference-item__source">
<t-icon name="tools" class="reference-item__source-mark" />
<span class="reference-item__domain">{{ item.domain }}</span>
</div>
<h5 class="reference-card__title">{{ item.title }}</h5>
<p v-if="item.snippet && !expandedKeys.has(item.key)" class="reference-card__snippet">
{{ item.snippet }}
</p>
<div v-if="item.kind === 'document' && expandedKeys.has(item.key)" class="reference-card__content">
{{ item.content }}
</div>
<div v-else-if="item.kind === 'tool' && item.content" class="reference-card__content">
{{ item.content }}
</div>
<h5 v-if="shouldShowItemTitle(item)" class="reference-item__title">{{ item.title }}</h5>
<p v-if="item.snippet && !expandedKeys.has(item.key)" class="reference-item__snippet">
{{ formatReferenceSnippet(item.snippet) }}
</p>
<div v-if="item.kind === 'tool' && item.content" class="reference-item__content">
{{ formatReferenceSnippet(item.content) }}
</div>
</template>
</component>
<div v-if="item.kind === 'document'" class="reference-card__actions">
<a
v-if="item.knowledgeBaseId && !embeddedMode"
class="reference-card__action"
:href="getDocumentHref(item)"
target="_blank"
rel="noopener noreferrer"
>
<t-icon name="jump" size="14px" />
<span>{{ t('chat.navigateToDocument') }}</span>
</a>
</div>
</article>
</section>
</div>
@@ -140,6 +141,7 @@ import { useRouter } from 'vue-router'
import { useChatReferencesDrawer } from '@/composables/useChatReferencesDrawer'
import {
buildReferenceSections,
formatReferenceSnippet,
resolveReferenceHighlightKey,
type ReferenceListItem,
} from '@/utils/referenceSources'
@@ -267,6 +269,13 @@ function getDocumentHref(item: ReferenceListItem) {
}).href
}
function shouldShowItemTitle(item: ReferenceListItem) {
if (item.kind !== 'web') return true
const title = item.title?.trim()
const domain = item.domain?.trim()
return Boolean(title && title !== domain)
}
async function scrollToHighlight() {
if (!panelEntered.value) return
const key = activeHighlightKey.value
@@ -361,49 +370,45 @@ watch(visible, (open) => {
.chat-references-panel__title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--td-text-color-primary);
font-size: 14px;
font-weight: 500;
color: var(--td-text-color-secondary);
line-height: 1.4;
}
.chat-references-panel__count {
flex-shrink: 0;
min-width: 22px;
height: 22px;
padding: 0 8px;
border-radius: 999px;
background: var(--td-bg-color-secondarycontainer);
color: var(--td-text-color-secondary);
font-size: 12px;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--td-text-color-placeholder);
font-weight: 500;
}
.chat-references-panel__close {
border: 0;
background: transparent;
color: var(--td-text-color-placeholder);
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--td-bg-color-secondarycontainer);
color: var(--td-text-color-secondary);
width: 36px;
height: 36px;
border-radius: 10px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.15s ease, color 0.15s ease;
:deep(.t-icon) {
font-size: 20px;
}
&:hover {
background: var(--td-bg-color-component-hover);
color: var(--td-text-color-secondary);
background: color-mix(in srgb, var(--td-text-color-primary) 8%, var(--td-bg-color-secondarycontainer));
color: var(--td-text-color-primary);
}
}
.chat-references-panel__body {
flex: 1;
overflow-y: auto;
padding: 12px 12px 20px;
padding: 4px 12px 24px;
}
.chat-references-panel__empty {
@@ -413,6 +418,12 @@ watch(visible, (open) => {
font-size: 13px;
}
.chat-references-panel__section {
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-references-panel__section + .chat-references-panel__section {
margin-top: 16px;
}
@@ -427,162 +438,139 @@ watch(visible, (open) => {
letter-spacing: 0.04em;
}
.reference-card {
border: 1px solid var(--td-component-stroke);
.reference-item {
border-radius: 12px;
background: var(--td-bg-color-container);
overflow: hidden;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
transition: background-color 0.15s ease;
& + & {
margin-top: 10px;
}
&:hover {
border-color: color-mix(in srgb, var(--td-text-color-placeholder) 42%, var(--td-component-stroke));
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.04);
&:hover:not(.is-highlighted) {
background: color-mix(in srgb, var(--td-text-color-primary) 4%, transparent);
}
&.is-highlighted {
border-color: var(--td-text-color-placeholder);
background: var(--td-bg-color-secondarycontainer);
box-shadow: none;
animation: reference-card-highlight 1s ease;
}
}
@keyframes reference-card-highlight {
0% {
background: var(--td-bg-color-component-hover);
}
100% {
background: var(--td-bg-color-secondarycontainer);
}
}
.reference-card__inner {
.reference-item__body {
display: block;
padding: 12px 14px;
padding: 10px 12px;
color: inherit;
text-decoration: none;
transition: background-color 0.16s ease;
&.is-expandable {
cursor: pointer;
}
}
.reference-card--web .reference-card__inner:hover {
background: color-mix(in srgb, var(--td-text-color-primary) 3%, var(--td-bg-color-container));
}
.reference-card--document .reference-card__inner:hover,
.reference-card--tool .reference-card__inner:hover {
background: color-mix(in srgb, var(--td-text-color-primary) 3%, var(--td-bg-color-container));
}
.reference-card__meta {
.reference-item__document {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
align-items: flex-start;
gap: 10px;
min-width: 0;
}
.reference-card__index {
.reference-item__doc-icon {
flex-shrink: 0;
width: 20px;
height: 20px;
border-radius: 999px;
background: var(--td-bg-color-secondarycontainer);
color: var(--td-text-color-secondary);
font-size: 11px;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
margin-top: 3px;
font-size: 16px;
color: var(--td-text-color-primary);
}
.reference-card__favicon {
.reference-item__document-main {
flex: 1;
min-width: 0;
}
.reference-item__source {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
margin-bottom: 6px;
}
.reference-item__source-mark {
flex-shrink: 0;
width: 16px;
height: 16px;
border-radius: 4px;
flex-shrink: 0;
}
.reference-card__doc-icon {
border-radius: 999px;
object-fit: cover;
font-size: 14px;
color: var(--td-text-color-placeholder);
flex-shrink: 0;
}
.reference-card__domain {
font-size: 12px;
.reference-item__domain {
font-size: 13px;
line-height: 1.35;
color: var(--td-text-color-placeholder);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reference-card__title {
.reference-item__title-row {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
}
.reference-item__title {
flex: 1;
min-width: 0;
margin: 0;
font-size: 14px;
font-size: 15px;
font-weight: 600;
line-height: 1.45;
line-height: 1.4;
color: var(--td-text-color-primary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
.reference-item__open {
flex-shrink: 0;
margin-top: 3px;
color: var(--td-text-color-placeholder);
line-height: 1;
opacity: 0;
transition: opacity 0.15s ease, color 0.15s ease;
}
.reference-item:hover .reference-item__open,
.reference-item.is-highlighted .reference-item__open {
opacity: 1;
}
.reference-item__open:hover {
color: var(--td-text-color-primary);
}
.reference-item__snippet {
margin: 4px 0 0;
font-size: 13px;
line-height: 1.5;
color: var(--td-text-color-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.reference-card__snippet {
margin: 8px 0 0;
.reference-item__content {
margin: 4px 0 0;
font-size: 13px;
line-height: 1.55;
color: var(--td-text-color-secondary);
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.reference-card__content {
margin-top: 8px;
font-size: 13px;
line-height: 1.6;
color: var(--td-text-color-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 360px;
overflow-y: auto;
}
.reference-card__actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 0 14px 12px;
}
.reference-card__action {
border: 0;
background: transparent;
color: var(--td-text-color-secondary);
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 0;
text-decoration: none;
&:hover {
color: var(--td-text-color-primary);
text-decoration: underline;
}
}
.references-panel-enter-active {
transition:
transform 0.24s cubic-bezier(0.22, 0.61, 0.36, 1),
@@ -63,6 +63,8 @@ const dismiss = () => {
</script>
<style scoped lang="less">
@import (reference) '../css/suggested-questions.less';
.follow-ups {
max-width: 760px;
margin: -4px 0 28px 46px;
@@ -130,11 +132,9 @@ const dismiss = () => {
transition: border-color .2s ease, box-shadow .2s ease, background .2s ease;
}
.follow-ups__item:hover {
border-color: var(--td-brand-color);
background: var(--td-bg-color-container-hover);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
.suggestion-chip-hover();
}
.follow-ups__item:hover .t-icon { color: var(--td-brand-color); }
.follow-ups__item:hover .t-icon { color: var(--td-text-color-secondary); }
.is-spinning { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@@ -1,5 +1,17 @@
@suggested-ease: cubic-bezier(0.16, 1, 0.3, 1);
// Shared hover for starter / follow-up suggestion chips.
.suggestion-chip-hover() {
border-color: color-mix(in srgb, var(--td-text-color-primary) 10%, var(--td-component-stroke));
background: color-mix(in srgb, var(--td-text-color-primary) 4%, var(--td-bg-color-container));
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
}
.suggestion-chip-active() {
background: color-mix(in srgb, var(--td-text-color-primary) 6%, var(--td-bg-color-container));
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.suggested-questions-container {
display: flex;
flex-direction: column;
@@ -118,13 +130,11 @@
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
&:not(.sq-card-skeleton):hover {
border-color: var(--td-brand-color);
background: var(--td-bg-color-container-hover);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
.suggestion-chip-hover();
}
&:not(.sq-card-skeleton):active {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
.suggestion-chip-active();
}
&.sq-card-skeleton {
@@ -128,6 +128,53 @@ test('normalizeReferenceUrl trims trailing slash', () => {
)
})
test('buildReferenceList uses domain instead of raw url title', () => {
const items = buildReferenceList([
{
id: 'http://bj.bendibao.com/xiuxian/202671/384250.shtm',
chunk_type: 'web_search',
knowledge_title: 'http://bj.bendibao.com/xiuxian/202671/384250.shtm',
metadata: {
url: 'http://bj.bendibao.com/xiuxian/202671/384250.shtm',
snippet: '根据提供的网页内容...',
},
content: '根据提供的网页内容...',
},
])
assert.equal(items[0].title, 'bj.bendibao.com')
assert.equal(items[0].domain, 'bj.bendibao.com')
})
test('buildReferenceList prefers metadata title for web references', () => {
const items = buildReferenceList([
{
id: 'https://example.com/post',
chunk_type: 'web_search',
knowledge_title: 'https://example.com/post',
metadata: {
url: 'https://example.com/post',
title: 'Example headline',
snippet: 'snippet text',
},
},
])
assert.equal(items[0].title, 'Example headline')
})
test('formatReferenceSnippet strips markdown noise from preview text', async () => {
const { formatReferenceSnippet } = await import('./referenceSources.ts')
assert.equal(
formatReferenceSnippet('... [Free Online Lectures](https://example.com) **Everything I Know**'),
'Free Online Lectures Everything I Know',
)
assert.equal(
formatReferenceSnippet('![Logo](local://image.jpg) Summary text'),
'Summary text',
)
})
test('getDomainFromUrl strips www prefix', () => {
assert.equal(getDomainFromUrl('https://www.example.com/x'), 'example.com')
})
+48 -7
View File
@@ -77,11 +77,25 @@ export function getFaviconUrl(urlOrDomain: string): string {
}
function truncateText(text: string, maxLen: number): string {
const normalized = String(text || '').replace(/\s+/g, ' ').trim()
const normalized = formatReferenceSnippet(text)
if (normalized.length <= maxLen) return normalized
return `${normalized.slice(0, maxLen)}…`
}
export function formatReferenceSnippet(text: string | undefined): string {
let value = String(text || '').replace(/\s+/g, ' ').trim()
if (!value) return ''
value = value.replace(/^(\.{3}|…+)\s*/, '')
value = value.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
value = value.replace(/\[([^\]]+)]\([^)]*\)/g, '$1')
value = value.replace(/`([^`]+)`/g, '$1')
value = value.replace(/\*\*([^*]+)\*\*/g, '$1')
value = value.replace(/\*([^*]+)\*/g, '$1')
value = value.replace(/(^|\s)#+\s*/g, '$1')
value = value.replace(/\s+/g, ' ').trim()
return value
}
function isWebReference(item: KnowledgeReferenceLike): boolean {
return item.chunk_type === 'web_search'
}
@@ -90,18 +104,45 @@ function isToolReference(item: KnowledgeReferenceLike): boolean {
return item.chunk_type === 'tool_result'
}
function isLikelyUrl(text: string): boolean {
const trimmed = String(text || '').trim()
if (!trimmed) return false
return /^https?:\/\//i.test(trimmed) || /^www\./i.test(trimmed)
}
function isSameReferenceUrl(a: string, b: string): boolean {
const left = String(a || '').trim()
const right = String(b || '').trim()
if (!left || !right) return false
if (left === right) return true
return normalizeReferenceUrl(left) === normalizeReferenceUrl(right)
}
function resolveWebTitle(item: KnowledgeReferenceLike, url: string, domain: string): string {
const metaTitle = item.metadata?.title?.trim()
if (metaTitle && !isLikelyUrl(metaTitle)) return metaTitle
const knowledgeTitle = item.knowledge_title?.trim()
if (
knowledgeTitle &&
!isLikelyUrl(knowledgeTitle) &&
!isSameReferenceUrl(knowledgeTitle, url) &&
knowledgeTitle !== domain
) {
return knowledgeTitle
}
return domain || 'Web page'
}
function buildWebItem(item: KnowledgeReferenceLike, index: number): ReferenceListItem | null {
const url = getWebSearchUrl(item)
if (!url) return null
const title =
item.knowledge_title ||
item.metadata?.title ||
getDomainFromUrl(url) ||
url
const domain = getDomainFromUrl(url)
const title = resolveWebTitle(item, url, domain)
const snippet =
item.metadata?.snippet ||
truncateText(item.content || '', 220)
const domain = getDomainFromUrl(url)
const normalizedUrl = normalizeReferenceUrl(url)
return {
key: `web:${normalizedUrl}`,
+3 -3
View File
@@ -447,9 +447,9 @@ watch(
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
&:hover {
border-color: var(--td-brand-color);
background: var(--td-bg-color-container-hover);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border-color: color-mix(in srgb, var(--td-text-color-primary) 10%, var(--td-component-stroke));
background: color-mix(in srgb, var(--td-text-color-primary) 4%, var(--td-bg-color-container));
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
}
&--skeleton {