mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 22:09:08 +08: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:
+30
@@ -101,6 +101,21 @@ export class ChatContentStreamObjects extends WithDisposable(
|
|||||||
.notificationService=${this.notificationService}
|
.notificationService=${this.notificationService}
|
||||||
></doc-edit-tool>
|
></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: {
|
default: {
|
||||||
const name = streamObject.toolName + ' tool calling';
|
const name = streamObject.toolName + ' tool calling';
|
||||||
return html`
|
return html`
|
||||||
@@ -159,6 +174,21 @@ export class ChatContentStreamObjects extends WithDisposable(
|
|||||||
.notificationService=${this.notificationService}
|
.notificationService=${this.notificationService}
|
||||||
></doc-edit-tool>
|
></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: {
|
default: {
|
||||||
const name = streamObject.toolName + ' tool result';
|
const name = streamObject.toolName + ' tool result';
|
||||||
return html`
|
return html`
|
||||||
|
|||||||
+70
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
@@ -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 { css, html, nothing, type TemplateResult } from 'lit';
|
||||||
import { property, state } from 'lit/decorators.js';
|
import { property, state } from 'lit/decorators.js';
|
||||||
|
|
||||||
interface ToolResult {
|
export interface ToolResult {
|
||||||
title: string;
|
title: string;
|
||||||
icon?: string | TemplateResult<1>;
|
icon?: string | TemplateResult<1>;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ import {
|
|||||||
} from './components/ai-tools/code-artifact';
|
} from './components/ai-tools/code-artifact';
|
||||||
import { DocComposeTool } from './components/ai-tools/doc-compose';
|
import { DocComposeTool } from './components/ai-tools/doc-compose';
|
||||||
import { DocEditTool } from './components/ai-tools/doc-edit';
|
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 { ToolCallCard } from './components/ai-tools/tool-call-card';
|
||||||
import { ToolFailedCard } from './components/ai-tools/tool-failed-card';
|
import { ToolFailedCard } from './components/ai-tools/tool-failed-card';
|
||||||
import { ToolResultCard } from './components/ai-tools/tool-result-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-call-card', ToolCallCard);
|
||||||
customElements.define('tool-result-card', ToolResultCard);
|
customElements.define('tool-result-card', ToolResultCard);
|
||||||
customElements.define('tool-call-failed', ToolFailedCard);
|
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-crawl-tool', WebCrawlTool);
|
||||||
customElements.define('web-search-tool', WebSearchTool);
|
customElements.define('web-search-tool', WebSearchTool);
|
||||||
customElements.define('doc-compose-tool', DocComposeTool);
|
customElements.define('doc-compose-tool', DocComposeTool);
|
||||||
|
|||||||
@@ -306,6 +306,38 @@ export const Component = () => {
|
|||||||
return () => sub.unsubscribe();
|
return () => sub.unsubscribe();
|
||||||
}, [framework, mockStd]);
|
}, [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) => {
|
const onChatContainerRef = useCallback((node: HTMLDivElement) => {
|
||||||
if (node) {
|
if (node) {
|
||||||
setIsBodyProvided(true);
|
setIsBodyProvided(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user