feat(core): add ai tool call error type and ui (#12941)

<img width="775" alt="截屏2025-06-26 16 17 05"
src="https://github.com/user-attachments/assets/ed6bcae3-94af-4eb1-81e8-710f36ef5e46"
/>


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

## Summary by CodeRabbit

* **New Features**
  * Introduced a tool to crawl web pages and extract key information.
* Added a visual component to display tool call failures in the AI
interface.
* Enhanced error reporting for document and web search tools with
structured error messages.

* **Improvements**
* Updated error handling across AI tools and components for more
consistent and informative feedback.
* Default values added for tool card components to improve reliability
and display.

* **Bug Fixes**
* Improved handling of error and empty states in web crawl and web
search result displays.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Wu Yue 2025-06-26 17:38:19 +08:00 committed by GitHub
parent 2171d1bfe2
commit a7185e419c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 240 additions and 121 deletions

View File

@ -390,19 +390,19 @@ export interface CustomAITools extends ToolSet {
type ChunkType = TextStreamPart<CustomAITools>['type']; type ChunkType = TextStreamPart<CustomAITools>['type'];
export function parseUnknownError(error: unknown) { export function toError(error: unknown): Error {
if (typeof error === 'string') { if (typeof error === 'string') {
throw new Error(error); return new Error(error);
} else if (error instanceof Error) { } else if (error instanceof Error) {
throw error; return error;
} else if ( } else if (
typeof error === 'object' && typeof error === 'object' &&
error !== null && error !== null &&
'message' in error 'message' in error
) { ) {
throw new Error(String(error.message)); return new Error(String(error.message));
} else { } else {
throw new Error(JSON.stringify(error)); return new Error(JSON.stringify(error));
} }
} }
@ -483,8 +483,7 @@ export class TextStreamParser {
break; break;
} }
case 'error': { case 'error': {
parseUnknownError(chunk.error); throw toError(chunk.error);
break;
} }
} }
this.lastType = chunk.type; this.lastType = chunk.type;
@ -550,8 +549,7 @@ export class StreamObjectParser {
return chunk; return chunk;
} }
case 'error': { case 'error': {
parseUnknownError(chunk.error); throw toError(chunk.error);
return null;
} }
default: { default: {
return null; return null;

View File

@ -4,6 +4,7 @@ import { z } from 'zod';
import type { AccessController } from '../../../core/permission'; import type { AccessController } from '../../../core/permission';
import type { IndexerService, SearchDoc } from '../../indexer'; import type { IndexerService, SearchDoc } from '../../indexer';
import type { CopilotChatOptions } from '../providers'; import type { CopilotChatOptions } from '../providers';
import { toolError } from './error';
export const buildDocKeywordSearchGetter = ( export const buildDocKeywordSearchGetter = (
ac: AccessController, ac: AccessController,
@ -56,8 +57,8 @@ export const createDocKeywordSearchTool = (
createdByUser: doc.createdByUser, createdByUser: doc.createdByUser,
updatedByUser: doc.updatedByUser, updatedByUser: doc.updatedByUser,
})); }));
} catch { } catch (e: any) {
return 'Failed to search documents.'; return toolError('Doc Keyword Search Failed', e.message);
} }
}, },
}); });

View File

@ -5,6 +5,7 @@ import type { AccessController } from '../../../core/permission';
import type { ChunkSimilarity } from '../../../models'; import type { ChunkSimilarity } from '../../../models';
import type { CopilotContextService } from '../context'; import type { CopilotContextService } from '../context';
import type { CopilotChatOptions } from '../providers'; import type { CopilotChatOptions } from '../providers';
import { toolError } from './error';
export const buildDocSearchGetter = ( export const buildDocSearchGetter = (
ac: AccessController, ac: AccessController,
@ -46,8 +47,8 @@ export const createDocSemanticSearchTool = (
execute: async ({ query }) => { execute: async ({ query }) => {
try { try {
return await searchDocs(query); return await searchDocs(query);
} catch { } catch (e: any) {
return 'Failed to search documents.'; return toolError('Doc Semantic Search Failed', e.message);
} }
}, },
}); });

View File

@ -0,0 +1,11 @@
export interface ToolError {
type: 'error';
name: string;
message: string;
}
export const toolError = (name: string, message: string): ToolError => ({
type: 'error',
name,
message,
});

View File

@ -0,0 +1,39 @@
import { tool } from 'ai';
import Exa from 'exa-js';
import { z } from 'zod';
import { Config } from '../../../base';
import { toolError } from './error';
export const createExaCrawlTool = (config: Config) => {
return tool({
description: 'Crawl the web url for information',
parameters: z.object({
url: z
.string()
.describe('The URL to crawl (including http:// or https://)'),
}),
execute: async ({ url }) => {
try {
const { key } = config.copilot.exa;
const exa = new Exa(key);
const result = await exa.getContents([url], {
livecrawl: 'always',
text: {
maxCharacters: 100000,
},
});
return result.results.map(data => ({
title: data.title,
url: data.url,
content: data.text,
favicon: data.favicon,
publishedDate: data.publishedDate,
author: data.author,
}));
} catch (e: any) {
return toolError('Exa Crawl Failed', e.message);
}
},
});
};

View File

@ -3,6 +3,7 @@ import Exa from 'exa-js';
import { z } from 'zod'; import { z } from 'zod';
import { Config } from '../../../base'; import { Config } from '../../../base';
import { toolError } from './error';
export const createExaSearchTool = (config: Config) => { export const createExaSearchTool = (config: Config) => {
return tool({ return tool({
@ -30,41 +31,8 @@ export const createExaSearchTool = (config: Config) => {
publishedDate: data.publishedDate, publishedDate: data.publishedDate,
author: data.author, author: data.author,
})); }));
} catch { } catch (e: any) {
return 'Failed to search the web'; return toolError('Exa Search Failed', e.message);
}
},
});
};
export const createExaCrawlTool = (config: Config) => {
return tool({
description: 'Crawl the web url for information',
parameters: z.object({
url: z
.string()
.describe('The URL to crawl (including http:// or https://)'),
}),
execute: async ({ url }) => {
try {
const { key } = config.copilot.exa;
const exa = new Exa(key);
const result = await exa.getContents([url], {
livecrawl: 'always',
text: {
maxCharacters: 100000,
},
});
return result.results.map(data => ({
title: data.title,
url: data.url,
content: data.text,
favicon: data.favicon,
publishedDate: data.publishedDate,
author: data.author,
}));
} catch {
return 'Failed to crawl the web url';
} }
}, },
}); });

View File

@ -1,3 +1,5 @@
export * from './doc-keyword-search'; export * from './doc-keyword-search';
export * from './doc-semantic-search'; export * from './doc-semantic-search';
export * from './web-search'; export * from './error';
export * from './exa-crawl';
export * from './exa-search';

View File

@ -1,6 +1,5 @@
import type { FeatureFlagService } from '@affine/core/modules/feature-flag'; import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WithDisposable } from '@blocksuite/affine/global/lit'; import { WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import type { EditorHost } from '@blocksuite/affine/std'; import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std'; import { ShadowlessElement } from '@blocksuite/affine/std';
import type { ExtensionType } from '@blocksuite/affine/store'; import type { ExtensionType } from '@blocksuite/affine/store';
@ -21,13 +20,6 @@ export class ChatContentStreamObjects extends WithDisposable(
border-radius: 8px; border-radius: 8px;
background-color: rgba(0, 0, 0, 0.05); background-color: rgba(0, 0, 0, 0.05);
} }
.tool-wrapper {
padding: 12px;
margin: 8px 0;
border-radius: 8px;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
}
`; `;
@property({ attribute: false }) @property({ attribute: false })
@ -70,12 +62,16 @@ export class ChatContentStreamObjects extends WithDisposable(
.width=${this.width} .width=${this.width}
></web-search-tool> ></web-search-tool>
`; `;
default: default: {
const name = streamObject.toolName + ' tool calling';
return html` return html`
<div class="tool-wrapper"> <tool-call-card
${streamObject.toolName} tool calling... .name=${name}
</div> .host=${this.host}
.width=${this.width}
></tool-call-card>
`; `;
}
} }
} }
@ -101,12 +97,16 @@ export class ChatContentStreamObjects extends WithDisposable(
.width=${this.width} .width=${this.width}
></web-search-tool> ></web-search-tool>
`; `;
default: default: {
const name = streamObject.toolName + ' tool result';
return html` return html`
<div class="tool-wrapper"> <tool-result-card
${streamObject.toolName} tool result... .name=${name}
</div> .host=${this.host}
.width=${this.width}
></tool-result-card>
`; `;
}
} }
} }

View File

@ -1,6 +1,7 @@
import { WithDisposable } from '@blocksuite/affine/global/lit'; import { WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { ShadowlessElement } from '@blocksuite/affine/std'; import { ShadowlessElement } from '@blocksuite/affine/std';
import { ToolIcon } from '@blocksuite/icons/lit';
import { css, html, type TemplateResult } from 'lit'; import { css, html, type TemplateResult } from 'lit';
import { property, state } from 'lit/decorators.js'; import { property, state } from 'lit/decorators.js';
@ -51,10 +52,10 @@ export class ToolCallCard extends WithDisposable(ShadowlessElement) {
`; `;
@property({ attribute: false }) @property({ attribute: false })
accessor name!: string; accessor name: string = 'Tool calling';
@property({ attribute: false }) @property({ attribute: false })
accessor icon!: TemplateResult<1>; accessor icon: TemplateResult<1> = ToolIcon();
@state() @state()
private accessor dotsText = '.'; private accessor dotsText = '.';

View File

@ -0,0 +1,64 @@
import { WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { ToolIcon } from '@blocksuite/icons/lit';
import { css, html, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
export class ToolFailedCard extends WithDisposable(ShadowlessElement) {
static override styles = css`
.ai-tool-failed-wrapper {
padding: 12px;
margin: 8px 0;
border-radius: 8px;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
.ai-tool-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.ai-icon {
width: 24px;
height: 24px;
svg {
width: 24px;
height: 24px;
color: ${unsafeCSSVarV2('button/error')};
}
}
.ai-error-name {
font-size: 14px;
font-weight: 500;
line-height: 24px;
margin-left: 0px;
margin-right: auto;
color: ${unsafeCSSVarV2('button/error')};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
`;
@property({ attribute: false })
accessor name: string = 'Tool calling failed';
@property({ attribute: false })
accessor icon: TemplateResult<1> = ToolIcon();
protected override render() {
return html`
<div class="ai-tool-failed-wrapper">
<div class="ai-tool-header">
<div class="ai-icon">${this.icon}</div>
<div class="ai-error-name">${this.name}</div>
</div>
</div>
`;
}
}

View File

@ -2,7 +2,7 @@ import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { ImageProxyService } from '@blocksuite/affine/shared/adapters'; import { ImageProxyService } from '@blocksuite/affine/shared/adapters';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { type EditorHost, ShadowlessElement } from '@blocksuite/affine/std'; import { type EditorHost, ShadowlessElement } from '@blocksuite/affine/std';
import { ToggleDownIcon } from '@blocksuite/icons/lit'; import { ToggleDownIcon, ToolIcon } from '@blocksuite/icons/lit';
import { type Signal } from '@preact/signals-core'; 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';
@ -17,7 +17,7 @@ export class ToolResultCard extends SignalWatcher(
WithDisposable(ShadowlessElement) WithDisposable(ShadowlessElement)
) { ) {
static override styles = css` static override styles = css`
.ai-tool-wrapper { .ai-tool-result-wrapper {
padding: 12px; padding: 12px;
margin: 8px 0; margin: 8px 0;
border-radius: 8px; border-radius: 8px;
@ -158,16 +158,16 @@ export class ToolResultCard extends SignalWatcher(
accessor host!: EditorHost; accessor host!: EditorHost;
@property({ attribute: false }) @property({ attribute: false })
accessor name!: string; accessor name: string = 'Tool result';
@property({ attribute: false }) @property({ attribute: false })
accessor icon!: TemplateResult<1> | string; accessor icon: TemplateResult<1> | string = ToolIcon();
@property({ attribute: false }) @property({ attribute: false })
accessor footerIcons: TemplateResult<1>[] | string[] = []; accessor footerIcons: TemplateResult<1>[] | string[] = [];
@property({ attribute: false }) @property({ attribute: false })
accessor results!: ToolResult[]; accessor results: ToolResult[] = [];
@property({ attribute: false }) @property({ attribute: false })
accessor width: Signal<number | undefined> | undefined; accessor width: Signal<number | undefined> | undefined;
@ -177,7 +177,7 @@ export class ToolResultCard extends SignalWatcher(
protected override render() { protected override render() {
return html` return html`
<div class="ai-tool-wrapper"> <div class="ai-tool-result-wrapper">
<div class="ai-tool-header" @click=${this.toggleCard}> <div class="ai-tool-header" @click=${this.toggleCard}>
<div class="ai-icon">${this.renderIcon(this.icon)}</div> <div class="ai-icon">${this.renderIcon(this.icon)}</div>
<div class="ai-tool-name">${this.name}</div> <div class="ai-tool-name">${this.name}</div>

View File

@ -0,0 +1,5 @@
export interface ToolError {
type: 'error';
name: string;
message: string;
}

View File

@ -5,6 +5,8 @@ import type { Signal } from '@preact/signals-core';
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
import type { ToolError } from './type';
interface WebCrawlToolCall { interface WebCrawlToolCall {
type: 'tool-call'; type: 'tool-call';
toolCallId: string; toolCallId: string;
@ -17,14 +19,17 @@ interface WebCrawlToolResult {
toolCallId: string; toolCallId: string;
toolName: string; toolName: string;
args: { url: string }; args: { url: string };
result: Array<{ result:
title: string; | Array<{
url: string; title: string;
content: string; url: string;
favicon: string; content: string;
publishedDate: string; favicon: string;
author: string; publishedDate: string;
}>; author: string;
}>
| ToolError
| null;
} }
export class WebCrawlTool extends WithDisposable(ShadowlessElement) { export class WebCrawlTool extends WithDisposable(ShadowlessElement) {
@ -51,23 +56,32 @@ export class WebCrawlTool extends WithDisposable(ShadowlessElement) {
return nothing; return nothing;
} }
const { favicon, title, content } = this.data.result[0]; const result = this.data.result;
if (result && Array.isArray(result)) {
const { favicon, title, content } = result[0];
return html`
<tool-result-card
.host=${this.host}
.name=${'The reading is complete, and this webpage has been read'}
.icon=${WebIcon()}
.footerIcons=${favicon ? [favicon] : []}
.results=${[
{
title: title,
icon: favicon,
content: content,
},
]}
.width=${this.width}
></tool-result-card>
`;
}
return html` return html`
<tool-result-card <tool-call-failed
.host=${this.host} .name=${'Web reading failed'}
.name=${'The reading is complete, and this webpage has been read'}
.icon=${WebIcon()} .icon=${WebIcon()}
.footerIcons=${favicon ? [favicon] : []} ></tool-call-failed>
.results=${[
{
title: title,
icon: favicon,
content: content,
},
]}
.width=${this.width}
></tool-result-card>
`; `;
} }

View File

@ -5,6 +5,8 @@ import type { Signal } from '@preact/signals-core';
import { html, nothing } from 'lit'; import { html, nothing } from 'lit';
import { property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
import type { ToolError } from './type';
interface WebSearchToolCall { interface WebSearchToolCall {
type: 'tool-call'; type: 'tool-call';
toolCallId: string; toolCallId: string;
@ -17,14 +19,17 @@ interface WebSearchToolResult {
toolCallId: string; toolCallId: string;
toolName: string; toolName: string;
args: { url: string }; args: { url: string };
result: Array<{ result:
title: string; | Array<{
url: string; title: string;
content: string; url: string;
favicon: string; content: string;
publishedDate: string; favicon: string;
author: string; publishedDate: string;
}>; author: string;
}>
| ToolError
| null;
} }
export class WebSearchTool extends WithDisposable(ShadowlessElement) { export class WebSearchTool extends WithDisposable(ShadowlessElement) {
@ -50,27 +55,35 @@ export class WebSearchTool extends WithDisposable(ShadowlessElement) {
return nothing; return nothing;
} }
const results = this.data.result.map(item => { const result = this.data.result;
const { favicon, title, content } = item; if (result && Array.isArray(result)) {
return { const results = result.map(item => {
title: title, const { favicon, title, content } = item;
icon: favicon || WebIcon(), return {
content: content, title: title,
}; icon: favicon || WebIcon(),
}); content: content,
const footerIcons = this.data.result };
.map(item => item.favicon) });
.filter(Boolean); const footerIcons = result.map(item => item.favicon).filter(Boolean);
return html`
<tool-result-card
.host=${this.host}
.name=${'The search is complete, and these webpages have been searched'}
.icon=${WebIcon()}
.footerIcons=${footerIcons}
.results=${results}
.width=${this.width}
></tool-result-card>
`;
}
return html` return html`
<tool-result-card <tool-call-failed
.host=${this.host} .name=${'Web search failed'}
.name=${'The search is complete, and these webpages have been searched'}
.icon=${WebIcon()} .icon=${WebIcon()}
.footerIcons=${footerIcons} ></tool-call-failed>
.results=${results}
.width=${this.width}
></tool-result-card>
`; `;
} }

View File

@ -45,6 +45,7 @@ import { ChatContentRichText } from './components/ai-message-content/rich-text';
import { ChatContentStreamObjects } from './components/ai-message-content/stream-objects'; import { ChatContentStreamObjects } from './components/ai-message-content/stream-objects';
import { AIScrollableTextRenderer } from './components/ai-scrollable-text-renderer'; import { AIScrollableTextRenderer } from './components/ai-scrollable-text-renderer';
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 { ToolResultCard } from './components/ai-tools/tool-result-card'; import { ToolResultCard } from './components/ai-tools/tool-result-card';
import { WebCrawlTool } from './components/ai-tools/web-crawl'; import { WebCrawlTool } from './components/ai-tools/web-crawl';
import { WebSearchTool } from './components/ai-tools/web-search'; import { WebSearchTool } from './components/ai-tools/web-search';
@ -167,6 +168,7 @@ 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('web-crawl-tool', WebCrawlTool); customElements.define('web-crawl-tool', WebCrawlTool);
customElements.define('web-search-tool', WebSearchTool); customElements.define('web-search-tool', WebSearchTool);