From 3dbdb994351fe3afbdd4fa6eaabffc93108fd5c3 Mon Sep 17 00:00:00 2001 From: Cats Juice Date: Sat, 12 Jul 2025 10:17:37 +0800 Subject: [PATCH] feat(core): add basic ui for doc search related tool calling (#13176) ## Summary by CodeRabbit * **New Features** * Introduced support for document semantic search, keyword search, and document reading tools in chat AI features. * Added new interactive cards to display results for document keyword search, semantic search, and reading operations within chat. * Automatically restores and displays pinned chat sessions when revisiting the workspace chat page. * **Improvements** * Enhanced the chat interface with new components for richer document-related AI responses. --- .../ai-message-content/stream-objects.ts | 30 ++++++ .../ai-tools/doc-keyword-search-result.ts | 70 ++++++++++++ .../ai/components/ai-tools/doc-read-result.ts | 70 ++++++++++++ .../ai-tools/doc-semantic-search-result.ts | 100 ++++++++++++++++++ .../components/ai-tools/tool-result-card.ts | 2 +- .../core/src/blocksuite/ai/effects.ts | 6 ++ .../desktop/pages/workspace/chat/index.tsx | 32 ++++++ 7 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-keyword-search-result.ts create mode 100644 packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-read-result.ts create mode 100644 packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-semantic-search-result.ts diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/stream-objects.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/stream-objects.ts index 3ee1a92bf..a873fa85c 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/stream-objects.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/stream-objects.ts @@ -101,6 +101,21 @@ export class ChatContentStreamObjects extends WithDisposable( .notificationService=${this.notificationService} > `; + case 'doc_semantic_search': + return html``; + case 'doc_keyword_search': + return html``; + case 'doc_read': + return html``; default: { const name = streamObject.toolName + ' tool calling'; return html` @@ -159,6 +174,21 @@ export class ChatContentStreamObjects extends WithDisposable( .notificationService=${this.notificationService} > `; + case 'doc_semantic_search': + return html``; + case 'doc_keyword_search': + return html``; + case 'doc_read': + return html``; default: { const name = streamObject.toolName + ' tool result'; return html` diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-keyword-search-result.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-keyword-search-result.ts new file mode 100644 index 000000000..834bfb2d5 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-keyword-search-result.ts @@ -0,0 +1,70 @@ +import { WithDisposable } from '@blocksuite/global/lit'; +import { PageIcon, SearchIcon } from '@blocksuite/icons/lit'; +import { ShadowlessElement } from '@blocksuite/std'; +import type { Signal } from '@preact/signals-core'; +import { html, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { ToolResult } from './tool-result-card'; + +interface DocKeywordSearchToolCall { + type: 'tool-call'; + toolCallId: string; + toolName: string; + args: { query: string }; +} + +interface DocKeywordSearchToolResult { + type: 'tool-result'; + toolCallId: string; + toolName: string; + args: { query: string }; + result: Array<{ + title: string; + docId: string; + }>; +} + +export class DocKeywordSearchResult extends WithDisposable(ShadowlessElement) { + @property({ attribute: false }) + accessor data!: DocKeywordSearchToolCall | DocKeywordSearchToolResult; + + @property({ attribute: false }) + accessor width: Signal | undefined; + + renderToolCall() { + return html``; + } + + renderToolResult() { + if (this.data.type !== 'tool-result') { + return nothing; + } + let results: ToolResult[] = []; + try { + results = this.data.result.map(item => ({ + title: item.title, + icon: PageIcon(), + })); + } catch (err) { + console.error('Failed to parse result', err); + } + return html``; + } + + protected override render() { + if (this.data.type === 'tool-call') { + return this.renderToolCall(); + } + return this.renderToolResult(); + } +} diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-read-result.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-read-result.ts new file mode 100644 index 000000000..09834d006 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-read-result.ts @@ -0,0 +1,70 @@ +import { WithDisposable } from '@blocksuite/global/lit'; +import { PageIcon, ViewIcon } from '@blocksuite/icons/lit'; +import { ShadowlessElement } from '@blocksuite/std'; +import type { Signal } from '@preact/signals-core'; +import { html, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; + +interface DocReadToolCall { + type: 'tool-call'; + toolCallId: string; + toolName: string; + args: { doc_id: string }; +} + +interface DocReadToolResult { + type: 'tool-result'; + toolCallId: string; + toolName: string; + args: { doc_id: string }; + result: { + title: string; + markdown: string; + }; +} + +export class DocReadResult extends WithDisposable(ShadowlessElement) { + @property({ attribute: false }) + accessor data!: DocReadToolCall | DocReadToolResult; + + @property({ attribute: false }) + accessor width: Signal | undefined; + + renderToolCall() { + // TODO: get document name by doc_id + return html``; + } + + renderToolResult() { + if (this.data.type !== 'tool-result') { + return nothing; + } + // TODO: better markdown rendering + return html``; + } + + protected override render() { + if (this.data.type === 'tool-call') { + return this.renderToolCall(); + } + if (this.data.type === 'tool-result') { + return this.renderToolResult(); + } + return nothing; + } +} diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-semantic-search-result.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-semantic-search-result.ts new file mode 100644 index 000000000..c19a8fbaf --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-semantic-search-result.ts @@ -0,0 +1,100 @@ +import { WithDisposable } from '@blocksuite/global/lit'; +import { AiEmbeddingIcon, PageIcon } from '@blocksuite/icons/lit'; +import { ShadowlessElement } from '@blocksuite/std'; +import type { Signal } from '@preact/signals-core'; +import { html, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; + +interface DocSemanticSearchToolCall { + type: 'tool-call'; + toolCallId: string; + toolName: string; + args: { query: string }; +} + +interface DocSemanticSearchToolResult { + type: 'tool-result'; + toolCallId: string; + toolName: string; + args: { query: string }; + result: Array<{ + content: string; + docId: string; + }>; +} + +function parseResultContent(content: string) { + const properties = [ + 'Title', + 'Created at', + 'Updated at', + 'Created by', + 'Updated by', + ]; + try { + // A row starts with "Title: ${title}\n" + const title = content.match(/^Title:\s+(.*)\n/)?.[1]; + // from first row that not starts with "${propertyName}:" to end of the content + const rows = content.split('\n'); + const startIndex = rows.findIndex( + line => !properties.some(property => line.startsWith(`${property}:`)) + ); + const text = rows.slice(startIndex).join('\n'); + return { + title, + content: text, + icon: PageIcon(), + }; + } catch (error) { + console.error('Failed to parse result content', error); + return null; + } +} + +export class DocSemanticSearchResult extends WithDisposable(ShadowlessElement) { + @property({ attribute: false }) + accessor data!: DocSemanticSearchToolCall | DocSemanticSearchToolResult; + + @property({ attribute: false }) + accessor width: Signal | undefined; + + renderToolCall() { + return html``; + } + + renderToolResult() { + if (this.data.type !== 'tool-result') { + return nothing; + } + return html` parseResultContent(result.content)) + .filter(Boolean)} + >`; + } + + protected override render() { + const { data } = this; + + if (data.type === 'tool-call') { + return this.renderToolCall(); + } + if (data.type === 'tool-result') { + return this.renderToolResult(); + } + return nothing; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'doc-semantic-search-result': DocSemanticSearchResult; + } +} diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/tool-result-card.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/tool-result-card.ts index ff71d4e77..5264b2cc6 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/tool-result-card.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/tool-result-card.ts @@ -7,7 +7,7 @@ import { type Signal } from '@preact/signals-core'; import { css, html, nothing, type TemplateResult } from 'lit'; import { property, state } from 'lit/decorators.js'; -interface ToolResult { +export interface ToolResult { title: string; icon?: string | TemplateResult<1>; content?: string; diff --git a/packages/frontend/core/src/blocksuite/ai/effects.ts b/packages/frontend/core/src/blocksuite/ai/effects.ts index 3728c19ec..60fcbaf7a 100644 --- a/packages/frontend/core/src/blocksuite/ai/effects.ts +++ b/packages/frontend/core/src/blocksuite/ai/effects.ts @@ -57,6 +57,9 @@ import { } from './components/ai-tools/code-artifact'; import { DocComposeTool } from './components/ai-tools/doc-compose'; import { DocEditTool } from './components/ai-tools/doc-edit'; +import { DocKeywordSearchResult } from './components/ai-tools/doc-keyword-search-result'; +import { DocReadResult } from './components/ai-tools/doc-read-result'; +import { DocSemanticSearchResult } from './components/ai-tools/doc-semantic-search-result'; import { ToolCallCard } from './components/ai-tools/tool-call-card'; import { ToolFailedCard } from './components/ai-tools/tool-failed-card'; import { ToolResultCard } from './components/ai-tools/tool-result-card'; @@ -208,6 +211,9 @@ export function registerAIEffects() { customElements.define('tool-call-card', ToolCallCard); customElements.define('tool-result-card', ToolResultCard); customElements.define('tool-call-failed', ToolFailedCard); + customElements.define('doc-semantic-search-result', DocSemanticSearchResult); + customElements.define('doc-keyword-search-result', DocKeywordSearchResult); + customElements.define('doc-read-result', DocReadResult); customElements.define('web-crawl-tool', WebCrawlTool); customElements.define('web-search-tool', WebSearchTool); customElements.define('doc-compose-tool', DocComposeTool); diff --git a/packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx index c8643ca44..fe2dd9233 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx @@ -306,6 +306,38 @@ export const Component = () => { return () => sub.unsubscribe(); }, [framework, mockStd]); + // restore pinned session + useEffect(() => { + if (!chatContent) return; + + const controller = new AbortController(); + const signal = controller.signal; + client + .getSessions( + workspaceId, + {}, + undefined, + { pinned: true, limit: 1 }, + signal + ) + .then(sessions => { + if (!Array.isArray(sessions)) return; + const session = sessions[0]; + if (!session) return; + setCurrentSession(session); + if (chatContent) { + chatContent.session = session; + chatContent.reloadSession(); + } + }) + .catch(console.error); + + // abort the request + return () => { + controller.abort(); + }; + }, [chatContent, client, workspaceId]); + const onChatContainerRef = useCallback((node: HTMLDivElement) => { if (node) { setIsBodyProvided(true);