From 862a9d0bc4740771a721d7d00cd137a1bc81fc88 Mon Sep 17 00:00:00 2001 From: akumatus Date: Wed, 22 Jan 2025 10:54:00 +0000 Subject: [PATCH] feat: use footnote for perplexity search results (#9851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support issue [BS-2475](https://linear.app/affine-design/issue/BS-2475). ![截屏2025-01-22 16.49.25.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/sJGviKxfE3Ap685cl5bj/49febcdf-e403-4a2e-ba99-da36df34e08c.png) --- .../server/src/__tests__/copilot.spec.ts | 42 ++++++++++++++----- .../plugins/copilot/providers/perplexity.ts | 28 ++++++++++--- .../ai/chat-panel/chat-panel-messages.ts | 21 +--------- .../blocksuite/presets/ai/chat-panel/index.ts | 11 ++++- .../affine-cloud-copilot/e2e/copilot.spec.ts | 6 ++- 5 files changed, 69 insertions(+), 39 deletions(-) diff --git a/packages/backend/server/src/__tests__/copilot.spec.ts b/packages/backend/server/src/__tests__/copilot.spec.ts index b99df0c3ac..d3ea94c6e3 100644 --- a/packages/backend/server/src/__tests__/copilot.spec.ts +++ b/packages/backend/server/src/__tests__/copilot.spec.ts @@ -1096,10 +1096,14 @@ test('CitationParser should replace citation placeholders with URLs', t => { const citations = ['https://example1.com', 'https://example2.com']; const parser = new CitationParser(); - const result = parser.parse(content, citations); + const result = parser.parse(content, citations) + parser.end(); + + const expected = [ + 'This is [a] test sentence with [citations [^1]] and [^2] and [3].', + `[^1]: {"type":"url","url":"${encodeURIComponent(citations[0])}"}`, + `[^2]: {"type":"url","url":"${encodeURIComponent(citations[1])}"}`, + ].join('\n\n'); - const expected = - 'This is [a] test sentence with [citations [[1](https://example1.com)]] and [[2](https://example2.com)] and [3].'; t.is(result, expected); }); @@ -1130,10 +1134,18 @@ test('CitationParser should replace chunks of citation placeholders with URLs', let result = contents.reduce((acc, current) => { return acc + parser.parse(current, citations); }, ''); - result += parser.flush(); + result += parser.end(); - const expected = - '[[]]This is [a] test sentence with citations [[1](https://example1.com)] and [[2](https://example2.com)] and [[3](https://example3.com)] and [[4](https://example4.com)] and [[5](https://example5.com)] and [[6](https://example6.com)] and [7'; + const expected = [ + '[[]]This is [a] test sentence with citations [^1] and [^2] and [^3] and [^4] and [^5] and [^6] and [7', + `[^1]: {"type":"url","url":"${encodeURIComponent(citations[0])}"}`, + `[^2]: {"type":"url","url":"${encodeURIComponent(citations[1])}"}`, + `[^3]: {"type":"url","url":"${encodeURIComponent(citations[2])}"}`, + `[^4]: {"type":"url","url":"${encodeURIComponent(citations[3])}"}`, + `[^5]: {"type":"url","url":"${encodeURIComponent(citations[4])}"}`, + `[^6]: {"type":"url","url":"${encodeURIComponent(citations[5])}"}`, + `[^7]: {"type":"url","url":"${encodeURIComponent(citations[6])}"}`, + ].join('\n\n'); t.is(result, expected); }); @@ -1147,9 +1159,14 @@ test('CitationParser should not replace citation already with URLs', t => { ]; const parser = new CitationParser(); - const result = parser.parse(content, citations); + const result = parser.parse(content, citations) + parser.end(); - const expected = content; + const expected = [ + content, + `[^1]: {"type":"url","url":"${encodeURIComponent(citations[0])}"}`, + `[^2]: {"type":"url","url":"${encodeURIComponent(citations[1])}"}`, + `[^3]: {"type":"url","url":"${encodeURIComponent(citations[2])}"}`, + ].join('\n\n'); t.is(result, expected); }); @@ -1169,8 +1186,13 @@ test('CitationParser should not replace chunks of citation already with URLs', t let result = contents.reduce((acc, current) => { return acc + parser.parse(current, citations); }, ''); - result += parser.flush(); + result += parser.end(); - const expected = contents.join(''); + const expected = [ + contents.join(''), + `[^1]: {"type":"url","url":"${encodeURIComponent(citations[0])}"}`, + `[^2]: {"type":"url","url":"${encodeURIComponent(citations[1])}"}`, + `[^3]: {"type":"url","url":"${encodeURIComponent(citations[2])}"}`, + ].join('\n\n'); t.is(result, expected); }); diff --git a/packages/backend/server/src/plugins/copilot/providers/perplexity.ts b/packages/backend/server/src/plugins/copilot/providers/perplexity.ts index e9a1040074..2cda3ed20d 100644 --- a/packages/backend/server/src/plugins/copilot/providers/perplexity.ts +++ b/packages/backend/server/src/plugins/copilot/providers/perplexity.ts @@ -63,7 +63,10 @@ export class CitationParser { private numberToken: string[] = []; + private citations: string[] = []; + public parse(content: string, citations: string[]) { + this.citations = citations; let result = ''; const contentArray = content.split(''); for (const [index, char] of contentArray.entries()) { @@ -85,7 +88,7 @@ export class CitationParser { cIndex <= citations.length && contentArray[index + 1] !== this.PARENTHESES_OPEN ) { - const content = `[[${cIndex}](${citations[cIndex - 1]})]`; + const content = `[^${cIndex}]`; result += content; this.resetToken(); } else { @@ -116,13 +119,26 @@ export class CitationParser { return result; } - public flush() { - const content = this.getFullContent(); + public end() { + return this.flush() + this.getFootnotes(); + } + + private flush() { + const content = this.getTokenContent(); this.resetToken(); return content; } - private getFullContent() { + private getFootnotes() { + const footnotes = this.citations.map((citation, index) => { + return `[^${index + 1}]: {"type":"url","url":"${encodeURIComponent( + citation + )}"}`; + }); + return '\n\n' + footnotes.join('\n\n'); + } + + private getTokenContent() { return this.startToken.concat(this.numberToken, this.endToken).join(''); } @@ -208,7 +224,7 @@ export class PerplexityProvider implements CopilotTextToTextProvider { const { content } = data.choices[0].message; const { citations } = data; let result = parser.parse(content, citations); - result += parser.flush(); + result += parser.end(); return result; } } catch (e: any) { @@ -277,7 +293,7 @@ export class PerplexityProvider implements CopilotTextToTextProvider { } }, flush(controller) { - controller.enqueue(parser.flush()); + controller.enqueue(parser.end()); controller.enqueue(null); }, }) diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-messages.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-messages.ts index ee503dedc7..1bbef686f0 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-messages.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/chat-panel-messages.ts @@ -14,7 +14,7 @@ import { css, html, nothing } from 'lit'; import { property, query, state } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; import { styleMap } from 'lit/directives/style-map.js'; -import { debounce, throttle } from 'lodash-es'; +import { debounce } from 'lodash-es'; import { EdgelessEditorActions, @@ -132,9 +132,6 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) { @query('.chat-panel-messages') accessor messagesContainer: HTMLDivElement | null = null; - @query('.message:nth-last-child(2)') - accessor lastMessage: HTMLDivElement | null = null; - private _renderAIOnboarding() { return this.isLoading || !this.host?.doc.get(FeatureFlagService).getFlag('enable_ai_onboarding') @@ -192,16 +189,6 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) { 100 ); - private readonly _scrollIntoView = () => { - if (!this.lastMessage) return; - this.lastMessage.scrollIntoView({ behavior: 'smooth' }); - }; - - private readonly _throttledScrollIntoView = throttle( - this._scrollIntoView, - 500 - ); - protected override render() { const { items } = this.chatContextValue; const { isLoading } = this; @@ -300,12 +287,6 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) { ); } - protected override updated() { - if (this.chatContextValue.status === 'transmitting') { - this._throttledScrollIntoView(); - } - } - renderItem(item: ChatItem, isLast: boolean) { const { status, error } = this.chatContextValue; const { host } = this; diff --git a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/index.ts b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/index.ts index 7695a4b79b..48b1398094 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/index.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/chat-panel/index.ts @@ -9,6 +9,7 @@ import type { Store } from '@blocksuite/affine/store'; import { css, html, type PropertyValues } from 'lit'; import { property, state } from 'lit/decorators.js'; import { createRef, type Ref, ref } from 'lit/directives/ref.js'; +import { throttle } from 'lodash-es'; import { AIHelpIcon, SmallHintIcon } from '../_common/icons'; import { AIProvider } from '../provider'; @@ -191,6 +192,8 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) { this._chatMessages.value?.scrollToEnd(); }; + private readonly _throttledScrollToEnd = throttle(this._scrollToEnd, 1000); + private readonly _cleanupHistories = async () => { const notification = this.host.std.getOptional(NotificationProvider); if (!notification) return; @@ -229,7 +232,6 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) { } if ( - !this.isLoading && _changedProperties.has('chatContextValue') && (this.chatContextValue.status === 'loading' || this.chatContextValue.status === 'error' || @@ -237,6 +239,13 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) { ) { setTimeout(this._scrollToEnd, 500); } + + if ( + _changedProperties.has('chatContextValue') && + this.chatContextValue.status === 'transmitting' + ) { + this._throttledScrollToEnd(); + } } override connectedCallback() { diff --git a/tests/affine-cloud-copilot/e2e/copilot.spec.ts b/tests/affine-cloud-copilot/e2e/copilot.spec.ts index 3e60b23e88..1c6a024498 100644 --- a/tests/affine-cloud-copilot/e2e/copilot.spec.ts +++ b/tests/affine-cloud-copilot/e2e/copilot.spec.ts @@ -415,7 +415,7 @@ test.describe('chat panel', () => { }); expect(history[1].name).toBe('AFFiNE AI'); expect( - await page.locator('chat-panel affine-link').count() + await page.locator('chat-panel affine-footnote-node').count() ).toBeGreaterThan(0); await clearChat(page); @@ -429,7 +429,9 @@ test.describe('chat panel', () => { content: 'What is the weather in Shanghai today?', }); expect(history[1].name).toBe('AFFiNE AI'); - expect(await page.locator('chat-panel affine-link').count()).toBe(0); + expect(await page.locator('chat-panel affine-footnote-node').count()).toBe( + 0 + ); }); test('can trigger inline ai input and action panel by clicking Start with AI button', async ({