feat(core): add basic ui for doc search related tool calling (#13176)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Cats Juice
2025-07-12 10:17:37 +08:00
committed by GitHub
parent 0d414d914a
commit 3dbdb99435
7 changed files with 309 additions and 1 deletions

View File

@@ -101,6 +101,21 @@ export class ChatContentStreamObjects extends WithDisposable(
.notificationService=${this.notificationService}
></doc-edit-tool>
`;
case 'doc_semantic_search':
return html`<doc-semantic-search-result
.data=${streamObject}
.width=${this.width}
></doc-semantic-search-result>`;
case 'doc_keyword_search':
return html`<doc-keyword-search-result
.data=${streamObject}
.width=${this.width}
></doc-keyword-search-result>`;
case 'doc_read':
return html`<doc-read-result
.data=${streamObject}
.width=${this.width}
></doc-read-result>`;
default: {
const name = streamObject.toolName + ' tool calling';
return html`
@@ -159,6 +174,21 @@ export class ChatContentStreamObjects extends WithDisposable(
.notificationService=${this.notificationService}
></doc-edit-tool>
`;
case 'doc_semantic_search':
return html`<doc-semantic-search-result
.data=${streamObject}
.width=${this.width}
></doc-semantic-search-result>`;
case 'doc_keyword_search':
return html`<doc-keyword-search-result
.data=${streamObject}
.width=${this.width}
></doc-keyword-search-result>`;
case 'doc_read':
return html`<doc-read-result
.data=${streamObject}
.width=${this.width}
></doc-read-result>`;
default: {
const name = streamObject.toolName + ' tool result';
return html`

View File

@@ -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<number | undefined> | undefined;
renderToolCall() {
return html`<tool-call-card
.name=${`Searching workspace documents for "${this.data.args.query}"`}
.icon=${SearchIcon()}
.width=${this.width}
></tool-call-card>`;
}
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`<tool-result-card
.name=${`Found ${this.data.result.length} pages for "${this.data.args.query}"`}
.icon=${SearchIcon()}
.width=${this.width}
.results=${results}
></tool-result-card>`;
}
protected override render() {
if (this.data.type === 'tool-call') {
return this.renderToolCall();
}
return this.renderToolResult();
}
}

View File

@@ -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<number | undefined> | undefined;
renderToolCall() {
// TODO: get document name by doc_id
return html`<tool-call-card
.name=${`Reading document`}
.icon=${ViewIcon()}
.width=${this.width}
></tool-call-card>`;
}
renderToolResult() {
if (this.data.type !== 'tool-result') {
return nothing;
}
// TODO: better markdown rendering
return html`<tool-result-card
.name=${`Read "${this.data.result.title}"`}
.icon=${ViewIcon()}
.width=${this.width}
.results=${[
{
title: this.data.result.title,
icon: PageIcon(),
content: this.data.result.markdown,
},
]}
></tool-result-card>`;
}
protected override render() {
if (this.data.type === 'tool-call') {
return this.renderToolCall();
}
if (this.data.type === 'tool-result') {
return this.renderToolResult();
}
return nothing;
}
}

View File

@@ -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<number | undefined> | undefined;
renderToolCall() {
return html`<tool-call-card
.name=${`Finding semantically related pages for "${this.data.args.query}"`}
.icon=${AiEmbeddingIcon()}
.width=${this.width}
></tool-call-card>`;
}
renderToolResult() {
if (this.data.type !== 'tool-result') {
return nothing;
}
return html`<tool-result-card
.name=${`Found semantically related pages for "${this.data.args.query}"`}
.icon=${AiEmbeddingIcon()}
.width=${this.width}
.results=${this.data.result
.map(result => parseResultContent(result.content))
.filter(Boolean)}
></tool-result-card>`;
}
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;
}
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);