fix: label citation links by their actual target

This commit is contained in:
2026-09-27 16:41:03 +08:00
parent e5bf1cf250
commit 92906c82bc
6 changed files with 50 additions and 13 deletions
+15 -2
View File
@@ -84,6 +84,17 @@ const safeUrl = (raw: unknown) => {
try { const url = new URL(raw, window.location.origin); return ['http:', 'https:'].includes(url.protocol) ? url.href : undefined; }
catch { return undefined; }
};
const isImageUrl = (href: string) => {
const path = new URL(href).pathname;
return /^\/api\/assets\/\d+\/\d+\/?$/.test(path) || /\.(?:png|jpe?g|gif|webp|avif|svg)$/i.test(path);
};
const sourceLabel = (href: string) => {
const url = new URL(href);
if (isImageUrl(href)) return '查看图像';
if (/^\/api\/knowledge\/documents\/\d+\/pages\/\d+\/?$/.test(url.pathname)) return '查看页面文字';
if (url.pathname === '/' && url.searchParams.get('page') === 'scores') return '查看评分';
return '打开来源';
};
function Answer({ text }: { text: string }) {
return <div className="chat-markdown"><ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={url => safeUrl(url) || ''}
components={{ a: ({ href, children }) => safeUrl(href) ? <a href={safeUrl(href)} target="_blank" rel="noopener noreferrer">{children}</a> : <span>{children}</span>,
@@ -94,10 +105,12 @@ function CitationCards({ items }: { items: any[] }) {
return <div className="citation-grid">{items.map((item: any, index) => { const name = String(item.title || item.document || '未命名资料');
const page = item.page ?? item.slide; const place = page == null ? '' : `第 ${page} ${/\.pptx?$/i.test(name) ? '张幻灯片' : '页'}`;
const quote = typeof item.quote === 'string' ? item.quote.trim() : '';
const sourceHref = safeUrl(item.url);
const imageHref = safeUrl(item.image_url);
return <div className="citation-card" key={`${name}-${page}-${index}`}><div className="citation-heading"><Text strong>{name}</Text>{place && <Tag>{place}</Tag>}</div>
{quote && (quote.length > 320 ? <details><summary>{quote.slice(0, 240)}… 展开原文</summary><p>{quote}</p></details> : <p>{quote}</p>)}
<Space wrap size="small">{safeUrl(item.url) && <a href={safeUrl(item.url)} target="_blank" rel="noopener noreferrer">打开页面</a>}
{safeUrl(item.image_url) && <a href={safeUrl(item.image_url)} target="_blank" rel="noopener noreferrer">查看图像</a>}</Space></div>;
<Space wrap size="small">{sourceHref && <a href={sourceHref} target="_blank" rel="noopener noreferrer">{sourceLabel(sourceHref)}</a>}
{imageHref && imageHref !== sourceHref && <a href={imageHref} target="_blank" rel="noopener noreferrer">查看图像</a>}</Space></div>;
})}</div>;
}
function Citations({ items }: { items: any[] }) {
+23 -8
View File
@@ -6,8 +6,11 @@ async function mockApi(page: Page) {
7: { id: 7, title: '历史会话', course_version_id: 1, batch_id: null, project_id: null,
scope_locked: true, messages: [{ id: 1, role: 'user', content: '旧问题' }, { id: 2, role: 'assistant',
content: '# 标题\n\n段落 [安全链接](https://example.com) [危险链接](javascript:alert(1)) <img src=x onerror=alert(1)>\n\n> 引用\n\n- 列表\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\n```js\nconst x = 1;\n```',
citations: [{ title: '资料.pdf', page: 2, quote: '原文摘录', url: '/api/pages/2' }, { title: '资料.pdf', page: 2, quote: '原文摘录', url: '/api/pages/2' },
{ chunk_id: 2977, title: '综合报告.pdf', page: 48, quote: '第48页原文片段', url: '/api/pages/48' }],
citations: [{ title: '资料.pdf', page: 2, quote: '原文摘录', url: '/api/knowledge/documents/10/pages/2', image_url: '/api/assets/5/2' },
{ title: '资料.pdf', page: 2, quote: '原文摘录', url: '/api/knowledge/documents/10/pages/2', image_url: '/api/assets/5/2' },
{ chunk_id: 2977, title: '综合报告.pdf', page: 48, quote: '第48页原文片段', url: '/api/assets/5/48' },
{ title: '评分报告.pdf', page: 49, quote: '评分原文片段', url: '/?page=scores&batch_id=3&project_id=5', image_url: '/api/assets/5/49' },
{ title: '课程资料.pdf', page: 5, quote: '课程说明', url: '/api/knowledge/documents/11/pages/5' }],
tool_calls: [{ id: 'call-one', name: 'search_project_material', status: 'completed', arguments: { project_id: 1, omitted_text_fields: 1 },
summary: '找到 1 条引用', result: { found: true, citations: [{ page: 48, quote: '第48页原文片段' }] } },
{ id: 'call-two', name: 'get_scores', status: 'completed', arguments: { batch_id: 3, omitted_text_fields: 1 },
@@ -93,8 +96,8 @@ test('markdown, citations, tool folding and narrow layout are safe', async ({ pa
await expect(page.locator('.chat-markdown pre')).toBeVisible();
await expect(page.locator('.chat-markdown img')).toHaveCount(0);
await expect(page.getByText('危险链接')).not.toHaveAttribute('href');
await expect(page.getByText('引用 · 2')).toBeVisible();
await expect(page.getByText('资料.pdf')).not.toBeVisible();
await expect(page.getByText('引用 · 4')).toBeVisible();
await expect(page.getByText('资料.pdf', { exact: true })).not.toBeVisible();
await expect(page.getByText('后台进度')).toHaveCount(0);
await expect(page.locator('.chat-tools')).toBeVisible();
await expect(page.locator('.chat-tool').first()).not.toBeVisible();
@@ -116,8 +119,20 @@ test('markdown, citations, tool folding and narrow layout are safe', async ({ pa
await expect(page.locator('.chat-tool').nth(3).locator('summary')).toContainText('已跳过');
await page.locator('.chat-citation-group > summary').click();
await expect(page.locator('.chat-citation-group')).toHaveAttribute('open', '');
await expect(page.getByText('资料.pdf')).toBeVisible();
await expect(page.getByText('资料.pdf', { exact: true })).toBeVisible();
await expect(page.getByText('第 48 页')).toBeVisible();
const searchCitation = page.locator('.citation-card').filter({ has: page.getByText('资料.pdf', { exact: true }) });
await expect(searchCitation.getByRole('link', { name: '查看页面文字' })).toHaveAttribute('href', /\/api\/knowledge\/documents\/10\/pages\/2$/);
await expect(searchCitation.getByRole('link', { name: '查看图像' })).toHaveAttribute('href', /\/api\/assets\/5\/2$/);
const legacyImage = page.locator('.citation-card').filter({ hasText: '综合报告.pdf' });
await expect(legacyImage.getByRole('link', { name: '查看图像' })).toHaveAttribute('href', /\/api\/assets\/5\/48$/);
await expect(legacyImage.getByRole('link')).toHaveCount(1);
const scoreCitation = page.locator('.citation-card').filter({ hasText: '评分报告.pdf' });
await expect(scoreCitation.getByRole('link', { name: '查看评分' })).toHaveAttribute('href', /page=scores&batch_id=3&project_id=5$/);
await expect(scoreCitation.getByRole('link', { name: '查看图像' })).toHaveAttribute('href', /\/api\/assets\/5\/49$/);
const textOnly = page.locator('.citation-card').filter({ hasText: '课程资料.pdf' });
await expect(textOnly.getByRole('link', { name: '查看页面文字' })).toBeVisible();
await expect(textOnly.getByRole('link')).toHaveCount(1);
const positions = await page.evaluate(() => {
const card = document.querySelector('.chat-assistant .ant-card-body');
return ['.chat-tools', '.chat-markdown', '.chat-citations'].map(selector =>
@@ -137,7 +152,7 @@ test('switching sessions leaves runs independent and updates each status', async
await page.goto('/?page=chat');
await page.getByPlaceholder(/例如/).fill('甲');
await page.getByRole('button', { name: /发\s*送/ }).click();
await expect(page.getByText('甲', { exact: true })).toBeVisible();
await expect(page.locator('.chat-user .chat-content')).toHaveText('甲');
await expect(page.getByText('生成中').first()).toBeVisible();
await page.getByRole('button', { name: /^删\s*除$/ }).last().click();
await page.getByRole('button', { name: /删\s*除\s*会\s*话/ }).click();
@@ -148,12 +163,12 @@ test('switching sessions leaves runs independent and updates each status', async
await page.getByRole('button', { name: /新\s*建/ }).click();
await page.getByPlaceholder(/例如/).fill('乙');
await page.getByRole('button', { name: /发\s*送/ }).click();
await expect(page.getByText('乙', { exact: true })).toBeVisible();
await expect(page.locator('.chat-user .chat-content')).toHaveText('乙');
await expect(page.getByText('生成中').first()).toBeVisible();
expect(state.history[8].runs[0].status).toBe('running');
state.history[8].runs[0].status = 'completed';
state.history[8].messages.push({ id: 82, role: 'assistant', content: '回答甲', citations: [], tool_calls: [] });
await page.getByText('甲', { exact: true }).first().click();
await page.locator('.chat-session').filter({ has: page.getByText('甲', { exact: true }) }).click();
await expect(page.getByText('回答甲')).toBeVisible();
expect(state.history[9].runs[0].status).toBe('running');
expect(state.calls.filter(c => c === 'POST /conversations/first-message')).toHaveLength(2);