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:
parent
2171d1bfe2
commit
a7185e419c
@ -390,19 +390,19 @@ export interface CustomAITools extends ToolSet {
|
||||
|
||||
type ChunkType = TextStreamPart<CustomAITools>['type'];
|
||||
|
||||
export function parseUnknownError(error: unknown) {
|
||||
export function toError(error: unknown): Error {
|
||||
if (typeof error === 'string') {
|
||||
throw new Error(error);
|
||||
return new Error(error);
|
||||
} else if (error instanceof Error) {
|
||||
throw error;
|
||||
return error;
|
||||
} else if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'message' in error
|
||||
) {
|
||||
throw new Error(String(error.message));
|
||||
return new Error(String(error.message));
|
||||
} else {
|
||||
throw new Error(JSON.stringify(error));
|
||||
return new Error(JSON.stringify(error));
|
||||
}
|
||||
}
|
||||
|
||||
@ -483,8 +483,7 @@ export class TextStreamParser {
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
parseUnknownError(chunk.error);
|
||||
break;
|
||||
throw toError(chunk.error);
|
||||
}
|
||||
}
|
||||
this.lastType = chunk.type;
|
||||
@ -550,8 +549,7 @@ export class StreamObjectParser {
|
||||
return chunk;
|
||||
}
|
||||
case 'error': {
|
||||
parseUnknownError(chunk.error);
|
||||
return null;
|
||||
throw toError(chunk.error);
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
|
@ -4,6 +4,7 @@ import { z } from 'zod';
|
||||
import type { AccessController } from '../../../core/permission';
|
||||
import type { IndexerService, SearchDoc } from '../../indexer';
|
||||
import type { CopilotChatOptions } from '../providers';
|
||||
import { toolError } from './error';
|
||||
|
||||
export const buildDocKeywordSearchGetter = (
|
||||
ac: AccessController,
|
||||
@ -56,8 +57,8 @@ export const createDocKeywordSearchTool = (
|
||||
createdByUser: doc.createdByUser,
|
||||
updatedByUser: doc.updatedByUser,
|
||||
}));
|
||||
} catch {
|
||||
return 'Failed to search documents.';
|
||||
} catch (e: any) {
|
||||
return toolError('Doc Keyword Search Failed', e.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
@ -5,6 +5,7 @@ import type { AccessController } from '../../../core/permission';
|
||||
import type { ChunkSimilarity } from '../../../models';
|
||||
import type { CopilotContextService } from '../context';
|
||||
import type { CopilotChatOptions } from '../providers';
|
||||
import { toolError } from './error';
|
||||
|
||||
export const buildDocSearchGetter = (
|
||||
ac: AccessController,
|
||||
@ -46,8 +47,8 @@ export const createDocSemanticSearchTool = (
|
||||
execute: async ({ query }) => {
|
||||
try {
|
||||
return await searchDocs(query);
|
||||
} catch {
|
||||
return 'Failed to search documents.';
|
||||
} catch (e: any) {
|
||||
return toolError('Doc Semantic Search Failed', e.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
11
packages/backend/server/src/plugins/copilot/tools/error.ts
Normal file
11
packages/backend/server/src/plugins/copilot/tools/error.ts
Normal 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,
|
||||
});
|
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
@ -3,6 +3,7 @@ import Exa from 'exa-js';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Config } from '../../../base';
|
||||
import { toolError } from './error';
|
||||
|
||||
export const createExaSearchTool = (config: Config) => {
|
||||
return tool({
|
||||
@ -30,41 +31,8 @@ export const createExaSearchTool = (config: Config) => {
|
||||
publishedDate: data.publishedDate,
|
||||
author: data.author,
|
||||
}));
|
||||
} catch {
|
||||
return 'Failed to search the web';
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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';
|
||||
} catch (e: any) {
|
||||
return toolError('Exa Search Failed', e.message);
|
||||
}
|
||||
},
|
||||
});
|
@ -1,3 +1,5 @@
|
||||
export * from './doc-keyword-search';
|
||||
export * from './doc-semantic-search';
|
||||
export * from './web-search';
|
||||
export * from './error';
|
||||
export * from './exa-crawl';
|
||||
export * from './exa-search';
|
||||
|
@ -1,6 +1,5 @@
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import type { ExtensionType } from '@blocksuite/affine/store';
|
||||
@ -21,13 +20,6 @@ export class ChatContentStreamObjects extends WithDisposable(
|
||||
border-radius: 8px;
|
||||
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 })
|
||||
@ -70,12 +62,16 @@ export class ChatContentStreamObjects extends WithDisposable(
|
||||
.width=${this.width}
|
||||
></web-search-tool>
|
||||
`;
|
||||
default:
|
||||
default: {
|
||||
const name = streamObject.toolName + ' tool calling';
|
||||
return html`
|
||||
<div class="tool-wrapper">
|
||||
${streamObject.toolName} tool calling...
|
||||
</div>
|
||||
<tool-call-card
|
||||
.name=${name}
|
||||
.host=${this.host}
|
||||
.width=${this.width}
|
||||
></tool-call-card>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,12 +97,16 @@ export class ChatContentStreamObjects extends WithDisposable(
|
||||
.width=${this.width}
|
||||
></web-search-tool>
|
||||
`;
|
||||
default:
|
||||
default: {
|
||||
const name = streamObject.toolName + ' tool result';
|
||||
return html`
|
||||
<div class="tool-wrapper">
|
||||
${streamObject.toolName} tool result...
|
||||
</div>
|
||||
<tool-result-card
|
||||
.name=${name}
|
||||
.host=${this.host}
|
||||
.width=${this.width}
|
||||
></tool-result-card>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
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, state } from 'lit/decorators.js';
|
||||
|
||||
@ -51,10 +52,10 @@ export class ToolCallCard extends WithDisposable(ShadowlessElement) {
|
||||
`;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor name!: string;
|
||||
accessor name: string = 'Tool calling';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor icon!: TemplateResult<1>;
|
||||
accessor icon: TemplateResult<1> = ToolIcon();
|
||||
|
||||
@state()
|
||||
private accessor dotsText = '.';
|
||||
|
@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
@ -2,7 +2,7 @@ import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { ImageProxyService } from '@blocksuite/affine/shared/adapters';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
||||
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 { css, html, nothing, type TemplateResult } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
@ -17,7 +17,7 @@ export class ToolResultCard extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
.ai-tool-wrapper {
|
||||
.ai-tool-result-wrapper {
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
border-radius: 8px;
|
||||
@ -158,16 +158,16 @@ export class ToolResultCard extends SignalWatcher(
|
||||
accessor host!: EditorHost;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor name!: string;
|
||||
accessor name: string = 'Tool result';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor icon!: TemplateResult<1> | string;
|
||||
accessor icon: TemplateResult<1> | string = ToolIcon();
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor footerIcons: TemplateResult<1>[] | string[] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor results!: ToolResult[];
|
||||
accessor results: ToolResult[] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor width: Signal<number | undefined> | undefined;
|
||||
@ -177,7 +177,7 @@ export class ToolResultCard extends SignalWatcher(
|
||||
|
||||
protected override render() {
|
||||
return html`
|
||||
<div class="ai-tool-wrapper">
|
||||
<div class="ai-tool-result-wrapper">
|
||||
<div class="ai-tool-header" @click=${this.toggleCard}>
|
||||
<div class="ai-icon">${this.renderIcon(this.icon)}</div>
|
||||
<div class="ai-tool-name">${this.name}</div>
|
||||
|
@ -0,0 +1,5 @@
|
||||
export interface ToolError {
|
||||
type: 'error';
|
||||
name: string;
|
||||
message: string;
|
||||
}
|
@ -5,6 +5,8 @@ import type { Signal } from '@preact/signals-core';
|
||||
import { html, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import type { ToolError } from './type';
|
||||
|
||||
interface WebCrawlToolCall {
|
||||
type: 'tool-call';
|
||||
toolCallId: string;
|
||||
@ -17,14 +19,17 @@ interface WebCrawlToolResult {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: { url: string };
|
||||
result: Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
content: string;
|
||||
favicon: string;
|
||||
publishedDate: string;
|
||||
author: string;
|
||||
}>;
|
||||
result:
|
||||
| Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
content: string;
|
||||
favicon: string;
|
||||
publishedDate: string;
|
||||
author: string;
|
||||
}>
|
||||
| ToolError
|
||||
| null;
|
||||
}
|
||||
|
||||
export class WebCrawlTool extends WithDisposable(ShadowlessElement) {
|
||||
@ -51,23 +56,32 @@ export class WebCrawlTool extends WithDisposable(ShadowlessElement) {
|
||||
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`
|
||||
<tool-result-card
|
||||
.host=${this.host}
|
||||
.name=${'The reading is complete, and this webpage has been read'}
|
||||
<tool-call-failed
|
||||
.name=${'Web reading failed'}
|
||||
.icon=${WebIcon()}
|
||||
.footerIcons=${favicon ? [favicon] : []}
|
||||
.results=${[
|
||||
{
|
||||
title: title,
|
||||
icon: favicon,
|
||||
content: content,
|
||||
},
|
||||
]}
|
||||
.width=${this.width}
|
||||
></tool-result-card>
|
||||
></tool-call-failed>
|
||||
`;
|
||||
}
|
||||
|
||||
|
@ -5,6 +5,8 @@ import type { Signal } from '@preact/signals-core';
|
||||
import { html, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import type { ToolError } from './type';
|
||||
|
||||
interface WebSearchToolCall {
|
||||
type: 'tool-call';
|
||||
toolCallId: string;
|
||||
@ -17,14 +19,17 @@ interface WebSearchToolResult {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: { url: string };
|
||||
result: Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
content: string;
|
||||
favicon: string;
|
||||
publishedDate: string;
|
||||
author: string;
|
||||
}>;
|
||||
result:
|
||||
| Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
content: string;
|
||||
favicon: string;
|
||||
publishedDate: string;
|
||||
author: string;
|
||||
}>
|
||||
| ToolError
|
||||
| null;
|
||||
}
|
||||
|
||||
export class WebSearchTool extends WithDisposable(ShadowlessElement) {
|
||||
@ -50,27 +55,35 @@ export class WebSearchTool extends WithDisposable(ShadowlessElement) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const results = this.data.result.map(item => {
|
||||
const { favicon, title, content } = item;
|
||||
return {
|
||||
title: title,
|
||||
icon: favicon || WebIcon(),
|
||||
content: content,
|
||||
};
|
||||
});
|
||||
const footerIcons = this.data.result
|
||||
.map(item => item.favicon)
|
||||
.filter(Boolean);
|
||||
const result = this.data.result;
|
||||
if (result && Array.isArray(result)) {
|
||||
const results = result.map(item => {
|
||||
const { favicon, title, content } = item;
|
||||
return {
|
||||
title: title,
|
||||
icon: favicon || WebIcon(),
|
||||
content: content,
|
||||
};
|
||||
});
|
||||
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`
|
||||
<tool-result-card
|
||||
.host=${this.host}
|
||||
.name=${'The search is complete, and these webpages have been searched'}
|
||||
<tool-call-failed
|
||||
.name=${'Web search failed'}
|
||||
.icon=${WebIcon()}
|
||||
.footerIcons=${footerIcons}
|
||||
.results=${results}
|
||||
.width=${this.width}
|
||||
></tool-result-card>
|
||||
></tool-call-failed>
|
||||
`;
|
||||
}
|
||||
|
||||
|
@ -45,6 +45,7 @@ import { ChatContentRichText } from './components/ai-message-content/rich-text';
|
||||
import { ChatContentStreamObjects } from './components/ai-message-content/stream-objects';
|
||||
import { AIScrollableTextRenderer } from './components/ai-scrollable-text-renderer';
|
||||
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 { WebCrawlTool } from './components/ai-tools/web-crawl';
|
||||
import { WebSearchTool } from './components/ai-tools/web-search';
|
||||
@ -167,6 +168,7 @@ export function registerAIEffects() {
|
||||
|
||||
customElements.define('tool-call-card', ToolCallCard);
|
||||
customElements.define('tool-result-card', ToolResultCard);
|
||||
customElements.define('tool-call-failed', ToolFailedCard);
|
||||
customElements.define('web-crawl-tool', WebCrawlTool);
|
||||
customElements.define('web-search-tool', WebSearchTool);
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user