mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 04:48:53 +00:00
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:
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user