feat(server): improve doc tools error handle (#14662)

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

* **New Features**
* Centralized sync/status messages for cloud document sync and explicit
user-facing error types.
* Frontend helpers to detect and display tool errors with friendly
names.

* **Bug Fixes**
* Consistent, actionable error reporting for document and attachment
reads instead of silent failures.
* Search and semantic tools now validate workspace sync and permissions
and return clear responses.

* **Tests**
* Added comprehensive tests covering document/blob reads, search tools,
and sync/error paths.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-03-16 02:20:35 +08:00
committed by GitHub
parent 8f03090780
commit 121c0d172d
11 changed files with 419 additions and 70 deletions

View File

@@ -1,12 +1,35 @@
import test from 'ava'; import test from 'ava';
import { z } from 'zod'; import { z } from 'zod';
import type { DocReader } from '../../core/doc';
import type { AccessController } from '../../core/permission';
import type { Models } from '../../models';
import { NativeLlmRequest, NativeLlmStreamEvent } from '../../native'; import { NativeLlmRequest, NativeLlmStreamEvent } from '../../native';
import { import {
ToolCallAccumulator, ToolCallAccumulator,
ToolCallLoop, ToolCallLoop,
ToolSchemaExtractor, ToolSchemaExtractor,
} from '../../plugins/copilot/providers/loop'; } from '../../plugins/copilot/providers/loop';
import {
buildBlobContentGetter,
createBlobReadTool,
} from '../../plugins/copilot/tools/blob-read';
import {
buildDocKeywordSearchGetter,
createDocKeywordSearchTool,
} from '../../plugins/copilot/tools/doc-keyword-search';
import {
buildDocContentGetter,
createDocReadTool,
} from '../../plugins/copilot/tools/doc-read';
import {
buildDocSearchGetter,
createDocSemanticSearchTool,
} from '../../plugins/copilot/tools/doc-semantic-search';
import {
DOCUMENT_SYNC_PENDING_MESSAGE,
LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE,
} from '../../plugins/copilot/tools/doc-sync';
test('ToolCallAccumulator should merge deltas and complete tool call', t => { test('ToolCallAccumulator should merge deltas and complete tool call', t => {
const accumulator = new ToolCallAccumulator(); const accumulator = new ToolCallAccumulator();
@@ -286,3 +309,210 @@ test('ToolCallLoop should surface invalid JSON as tool error without executing',
is_error: true, is_error: true,
}); });
}); });
test('doc_read should return specific sync errors for unavailable docs', async t => {
const cases = [
{
name: 'local workspace without cloud sync',
workspace: null,
authors: null,
markdown: null,
expected: {
type: 'error',
name: 'Workspace Sync Required',
message: LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE,
},
docReaderCalled: false,
},
{
name: 'cloud workspace document not synced to server yet',
workspace: { id: 'ws-1' },
authors: null,
markdown: null,
expected: {
type: 'error',
name: 'Document Sync Pending',
message: DOCUMENT_SYNC_PENDING_MESSAGE('doc-1'),
},
docReaderCalled: false,
},
{
name: 'cloud workspace document markdown not ready yet',
workspace: { id: 'ws-1' },
authors: {
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
createdByUser: null,
updatedByUser: null,
},
markdown: null,
expected: {
type: 'error',
name: 'Document Sync Pending',
message: DOCUMENT_SYNC_PENDING_MESSAGE('doc-1'),
},
docReaderCalled: true,
},
] as const;
const ac = {
user: () => ({
workspace: () => ({ doc: () => ({ can: async () => true }) }),
}),
} as unknown as AccessController;
for (const testCase of cases) {
let docReaderCalled = false;
const docReader = {
getDocMarkdown: async () => {
docReaderCalled = true;
return testCase.markdown;
},
} as unknown as DocReader;
const models = {
workspace: {
get: async () => testCase.workspace,
},
doc: {
getAuthors: async () => testCase.authors,
},
} as unknown as Models;
const getDoc = buildDocContentGetter(ac, docReader, models);
const tool = createDocReadTool(
getDoc.bind(null, {
user: 'user-1',
workspace: 'workspace-1',
})
);
const result = await tool.execute?.({ doc_id: 'doc-1' }, {});
t.is(docReaderCalled, testCase.docReaderCalled, testCase.name);
t.deepEqual(result, testCase.expected, testCase.name);
}
});
test('document search tools should return sync error for local workspace', async t => {
const ac = {
user: () => ({
workspace: () => ({
can: async () => true,
docs: async () => [],
}),
}),
} as unknown as AccessController;
const models = {
workspace: {
get: async () => null,
},
} as unknown as Models;
let keywordSearchCalled = false;
const indexerService = {
searchDocsByKeyword: async () => {
keywordSearchCalled = true;
return [];
},
} as unknown as Parameters<typeof buildDocKeywordSearchGetter>[1];
let semanticSearchCalled = false;
const contextService = {
matchWorkspaceAll: async () => {
semanticSearchCalled = true;
return [];
},
} as unknown as Parameters<typeof buildDocSearchGetter>[1];
const keywordTool = createDocKeywordSearchTool(
buildDocKeywordSearchGetter(ac, indexerService, models).bind(null, {
user: 'user-1',
workspace: 'workspace-1',
})
);
const semanticTool = createDocSemanticSearchTool(
buildDocSearchGetter(ac, contextService, null, models).bind(null, {
user: 'user-1',
workspace: 'workspace-1',
})
);
const keywordResult = await keywordTool.execute?.({ query: 'hello' }, {});
const semanticResult = await semanticTool.execute?.({ query: 'hello' }, {});
t.false(keywordSearchCalled);
t.false(semanticSearchCalled);
t.deepEqual(keywordResult, {
type: 'error',
name: 'Workspace Sync Required',
message: LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE,
});
t.deepEqual(semanticResult, {
type: 'error',
name: 'Workspace Sync Required',
message: LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE,
});
});
test('doc_semantic_search should return empty array when nothing matches', async t => {
const ac = {
user: () => ({
workspace: () => ({
can: async () => true,
docs: async () => [],
}),
}),
} as unknown as AccessController;
const models = {
workspace: {
get: async () => ({ id: 'workspace-1' }),
},
} as unknown as Models;
const contextService = {
matchWorkspaceAll: async () => [],
} as unknown as Parameters<typeof buildDocSearchGetter>[1];
const semanticTool = createDocSemanticSearchTool(
buildDocSearchGetter(ac, contextService, null, models).bind(null, {
user: 'user-1',
workspace: 'workspace-1',
})
);
const result = await semanticTool.execute?.({ query: 'hello' }, {});
t.deepEqual(result, []);
});
test('blob_read should return explicit error when attachment context is missing', async t => {
const ac = {
user: () => ({
workspace: () => ({
allowLocal: () => ({
can: async () => true,
}),
}),
}),
} as unknown as AccessController;
const blobTool = createBlobReadTool(
buildBlobContentGetter(ac, null).bind(null, {
user: 'user-1',
workspace: 'workspace-1',
})
);
const result = await blobTool.execute?.({ blob_id: 'blob-1' }, {});
t.deepEqual(result, {
type: 'error',
name: 'Blob Read Failed',
message:
'Missing workspace, user, blob id, or copilot context for blob_read.',
});
});

View File

@@ -470,7 +470,8 @@ export abstract class CopilotProvider<C = any> {
}); });
const searchDocs = buildDocKeywordSearchGetter( const searchDocs = buildDocKeywordSearchGetter(
ac, ac,
indexerService indexerService,
models
); );
tools.doc_keyword_search = createDocKeywordSearchTool( tools.doc_keyword_search = createDocKeywordSearchTool(
searchDocs.bind(null, options) searchDocs.bind(null, options)

View File

@@ -18,7 +18,10 @@ export const buildBlobContentGetter = (
chunk?: number chunk?: number
) => { ) => {
if (!options?.user || !options?.workspace || !blobId || !context) { if (!options?.user || !options?.workspace || !blobId || !context) {
return; return toolError(
'Blob Read Failed',
'Missing workspace, user, blob id, or copilot context for blob_read.'
);
} }
const canAccess = await ac const canAccess = await ac
.user(options.user) .user(options.user)
@@ -29,7 +32,10 @@ export const buildBlobContentGetter = (
logger.warn( logger.warn(
`User ${options.user} does not have access workspace ${options.workspace}` `User ${options.user} does not have access workspace ${options.workspace}`
); );
return; return toolError(
'Blob Read Failed',
'You do not have permission to access this workspace attachment.'
);
} }
const contextFile = context.files.find( const contextFile = context.files.find(
@@ -42,7 +48,12 @@ export const buildBlobContentGetter = (
context.getBlobContent(canonicalBlobId, chunk), context.getBlobContent(canonicalBlobId, chunk),
]); ]);
const content = file?.trim() || blob?.trim(); const content = file?.trim() || blob?.trim();
if (!content) return; if (!content) {
return toolError(
'Blob Read Failed',
`Attachment ${canonicalBlobId} is not available for reading in the current copilot context.`
);
}
const info = contextFile const info = contextFile
? { fileName: contextFile.name, fileType: contextFile.mimeType } ? { fileName: contextFile.name, fileType: contextFile.mimeType }
: {}; : {};
@@ -53,10 +64,7 @@ export const buildBlobContentGetter = (
}; };
export const createBlobReadTool = ( export const createBlobReadTool = (
getBlobContent: ( getBlobContent: (targetId?: string, chunk?: number) => Promise<object>
targetId?: string,
chunk?: number
) => Promise<object | undefined>
) => { ) => {
return defineTool({ return defineTool({
description: description:
@@ -73,13 +81,10 @@ export const createBlobReadTool = (
execute: async ({ blob_id, chunk }) => { execute: async ({ blob_id, chunk }) => {
try { try {
const blob = await getBlobContent(blob_id, chunk); const blob = await getBlobContent(blob_id, chunk);
if (!blob) {
return;
}
return { ...blob }; return { ...blob };
} catch (err: any) { } catch (err: any) {
logger.error(`Failed to read the blob ${blob_id} in context`, err); logger.error(`Failed to read the blob ${blob_id} in context`, err);
return toolError('Blob Read Failed', err.message); return toolError('Blob Read Failed', err.message ?? String(err));
} }
}, },
}); });

View File

@@ -1,27 +1,43 @@
import { z } from 'zod'; import { z } from 'zod';
import type { AccessController } from '../../../core/permission'; import type { AccessController } from '../../../core/permission';
import type { Models } from '../../../models';
import type { IndexerService, SearchDoc } from '../../indexer'; import type { IndexerService, SearchDoc } from '../../indexer';
import { workspaceSyncRequiredError } from './doc-sync';
import { toolError } from './error'; import { toolError } from './error';
import { defineTool } from './tool'; import { defineTool } from './tool';
import type { CopilotChatOptions } from './types'; import type { CopilotChatOptions } from './types';
export const buildDocKeywordSearchGetter = ( export const buildDocKeywordSearchGetter = (
ac: AccessController, ac: AccessController,
indexerService: IndexerService indexerService: IndexerService,
models: Models
) => { ) => {
const searchDocs = async (options: CopilotChatOptions, query?: string) => { const searchDocs = async (options: CopilotChatOptions, query?: string) => {
if (!options || !query?.trim() || !options.user || !options.workspace) { const queryTrimmed = query?.trim();
return undefined; if (!options || !queryTrimmed || !options.user || !options.workspace) {
return toolError(
'Doc Keyword Search Failed',
'Missing workspace, user, or query for doc_keyword_search.'
);
}
const workspace = await models.workspace.get(options.workspace);
if (!workspace) {
return workspaceSyncRequiredError();
} }
const canAccess = await ac const canAccess = await ac
.user(options.user) .user(options.user)
.workspace(options.workspace) .workspace(options.workspace)
.can('Workspace.Read'); .can('Workspace.Read');
if (!canAccess) return undefined; if (!canAccess) {
return toolError(
'Doc Keyword Search Failed',
'You do not have permission to access this workspace.'
);
}
const docs = await indexerService.searchDocsByKeyword( const docs = await indexerService.searchDocsByKeyword(
options.workspace, options.workspace,
query queryTrimmed
); );
// filter current user readable docs // filter current user readable docs
@@ -29,13 +45,15 @@ export const buildDocKeywordSearchGetter = (
.user(options.user) .user(options.user)
.workspace(options.workspace) .workspace(options.workspace)
.docs(docs, 'Doc.Read'); .docs(docs, 'Doc.Read');
return readableDocs; return readableDocs ?? [];
}; };
return searchDocs; return searchDocs;
}; };
export const createDocKeywordSearchTool = ( export const createDocKeywordSearchTool = (
searchDocs: (query: string) => Promise<SearchDoc[] | undefined> searchDocs: (
query: string
) => Promise<SearchDoc[] | ReturnType<typeof toolError>>
) => { ) => {
return defineTool({ return defineTool({
description: description:
@@ -50,8 +68,8 @@ export const createDocKeywordSearchTool = (
execute: async ({ query }) => { execute: async ({ query }) => {
try { try {
const docs = await searchDocs(query); const docs = await searchDocs(query);
if (!docs) { if (!Array.isArray(docs)) {
return; return docs;
} }
return docs.map(doc => ({ return docs.map(doc => ({
docId: doc.docId, docId: doc.docId,

View File

@@ -3,13 +3,20 @@ import { z } from 'zod';
import { DocReader } from '../../../core/doc'; import { DocReader } from '../../../core/doc';
import { AccessController } from '../../../core/permission'; import { AccessController } from '../../../core/permission';
import { Models, publicUserSelect } from '../../../models'; import { Models } from '../../../models';
import { toolError } from './error'; import {
documentSyncPendingError,
workspaceSyncRequiredError,
} from './doc-sync';
import { type ToolError, toolError } from './error';
import { defineTool } from './tool'; import { defineTool } from './tool';
import type { CopilotChatOptions } from './types'; import type { CopilotChatOptions } from './types';
const logger = new Logger('DocReadTool'); const logger = new Logger('DocReadTool');
const isToolError = (result: ToolError | object): result is ToolError =>
'type' in result && result.type === 'error';
export const buildDocContentGetter = ( export const buildDocContentGetter = (
ac: AccessController, ac: AccessController,
docReader: DocReader, docReader: DocReader,
@@ -17,8 +24,17 @@ export const buildDocContentGetter = (
) => { ) => {
const getDoc = async (options: CopilotChatOptions, docId?: string) => { const getDoc = async (options: CopilotChatOptions, docId?: string) => {
if (!options?.user || !options?.workspace || !docId) { if (!options?.user || !options?.workspace || !docId) {
return; return toolError(
'Doc Read Failed',
'Missing workspace, user, or document id for doc_read.'
);
} }
const workspace = await models.workspace.get(options.workspace);
if (!workspace) {
return workspaceSyncRequiredError();
}
const canAccess = await ac const canAccess = await ac
.user(options.user) .user(options.user)
.workspace(options.workspace) .workspace(options.workspace)
@@ -28,23 +44,15 @@ export const buildDocContentGetter = (
logger.warn( logger.warn(
`User ${options.user} does not have access to doc ${docId} in workspace ${options.workspace}` `User ${options.user} does not have access to doc ${docId} in workspace ${options.workspace}`
); );
return; return toolError(
'Doc Read Failed',
`You do not have permission to read document ${docId} in this workspace.`
);
} }
const docMeta = await models.doc.getSnapshot(options.workspace, docId, { const docMeta = await models.doc.getAuthors(options.workspace, docId);
select: {
createdAt: true,
updatedAt: true,
createdByUser: {
select: publicUserSelect,
},
updatedByUser: {
select: publicUserSelect,
},
},
});
if (!docMeta) { if (!docMeta) {
return; return documentSyncPendingError(docId);
} }
const content = await docReader.getDocMarkdown( const content = await docReader.getDocMarkdown(
@@ -53,7 +61,7 @@ export const buildDocContentGetter = (
true true
); );
if (!content) { if (!content) {
return; return documentSyncPendingError(docId);
} }
return { return {
@@ -69,8 +77,12 @@ export const buildDocContentGetter = (
return getDoc; return getDoc;
}; };
type DocReadToolResult = Awaited<
ReturnType<ReturnType<typeof buildDocContentGetter>>
>;
export const createDocReadTool = ( export const createDocReadTool = (
getDoc: (targetId?: string) => Promise<object | undefined> getDoc: (targetId?: string) => Promise<DocReadToolResult>
) => { ) => {
return defineTool({ return defineTool({
description: description:
@@ -81,13 +93,10 @@ export const createDocReadTool = (
execute: async ({ doc_id }) => { execute: async ({ doc_id }) => {
try { try {
const doc = await getDoc(doc_id); const doc = await getDoc(doc_id);
if (!doc) { return isToolError(doc) ? doc : { ...doc };
return;
}
return { ...doc };
} catch (err: any) { } catch (err: any) {
logger.error(`Failed to read the doc ${doc_id}`, err); logger.error(`Failed to read the doc ${doc_id}`, err);
return toolError('Doc Read Failed', err.message); return toolError('Doc Read Failed', err.message ?? String(err));
} }
}, },
}); });

View File

@@ -7,6 +7,7 @@ import {
clearEmbeddingChunk, clearEmbeddingChunk,
type Models, type Models,
} from '../../../models'; } from '../../../models';
import { workspaceSyncRequiredError } from './doc-sync';
import { toolError } from './error'; import { toolError } from './error';
import { defineTool } from './tool'; import { defineTool } from './tool';
import type { import type {
@@ -27,14 +28,24 @@ export const buildDocSearchGetter = (
signal?: AbortSignal signal?: AbortSignal
) => { ) => {
if (!options || !query?.trim() || !options.user || !options.workspace) { if (!options || !query?.trim() || !options.user || !options.workspace) {
return `Invalid search parameters.`; return toolError(
'Doc Semantic Search Failed',
'Missing workspace, user, or query for doc_semantic_search.'
);
}
const workspace = await models.workspace.get(options.workspace);
if (!workspace) {
return workspaceSyncRequiredError();
} }
const canAccess = await ac const canAccess = await ac
.user(options.user) .user(options.user)
.workspace(options.workspace) .workspace(options.workspace)
.can('Workspace.Read'); .can('Workspace.Read');
if (!canAccess) if (!canAccess)
return 'You do not have permission to access this workspace.'; return toolError(
'Doc Semantic Search Failed',
'You do not have permission to access this workspace.'
);
const [chunks, contextChunks] = await Promise.all([ const [chunks, contextChunks] = await Promise.all([
context.matchWorkspaceAll(options.workspace, query, 10, signal), context.matchWorkspaceAll(options.workspace, query, 10, signal),
docContext?.matchFiles(query, 10, signal) ?? [], docContext?.matchFiles(query, 10, signal) ?? [],
@@ -53,7 +64,7 @@ export const buildDocSearchGetter = (
fileChunks.push(...contextChunks); fileChunks.push(...contextChunks);
} }
if (!blobChunks.length && !docChunks.length && !fileChunks.length) { if (!blobChunks.length && !docChunks.length && !fileChunks.length) {
return `No results found for "${query}".`; return [];
} }
const docIds = docChunks.map(c => ({ const docIds = docChunks.map(c => ({
@@ -101,7 +112,7 @@ export const createDocSemanticSearchTool = (
searchDocs: ( searchDocs: (
query: string, query: string,
signal?: AbortSignal signal?: AbortSignal
) => Promise<ChunkSimilarity[] | string | undefined> ) => Promise<ChunkSimilarity[] | ReturnType<typeof toolError>>
) => { ) => {
return defineTool({ return defineTool({
description: description:

View File

@@ -0,0 +1,13 @@
import { toolError } from './error';
export const LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE =
'This workspace is local-only and does not have AFFiNE Cloud sync enabled yet. Ask the user to enable workspace sync, then try again.';
export const DOCUMENT_SYNC_PENDING_MESSAGE = (docId: string) =>
`Document ${docId} is not available on AFFiNE Cloud yet. Ask the user to wait for workspace sync to finish, then try again.`;
export const workspaceSyncRequiredError = () =>
toolError('Workspace Sync Required', LOCAL_WORKSPACE_SYNC_REQUIRED_MESSAGE);
export const documentSyncPendingError = (docId: string) =>
toolError('Document Sync Pending', DOCUMENT_SYNC_PENDING_MESSAGE(docId));

View File

@@ -7,6 +7,8 @@ import { html, nothing } from 'lit';
import { property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
import type { ToolResult } from './tool-result-card'; import type { ToolResult } from './tool-result-card';
import { getToolErrorDisplayName, isToolError } from './tool-result-utils';
import type { ToolError } from './type';
interface DocKeywordSearchToolCall { interface DocKeywordSearchToolCall {
type: 'tool-call'; type: 'tool-call';
@@ -20,10 +22,7 @@ interface DocKeywordSearchToolResult {
toolCallId: string; toolCallId: string;
toolName: string; toolName: string;
args: { query: string }; args: { query: string };
result: Array<{ result: Array<{ title: string; docId: string }> | ToolError | null;
title: string;
docId: string;
}>;
} }
export class DocKeywordSearchResult extends WithDisposable(ShadowlessElement) { export class DocKeywordSearchResult extends WithDisposable(ShadowlessElement) {
@@ -51,9 +50,23 @@ export class DocKeywordSearchResult extends WithDisposable(ShadowlessElement) {
if (this.data.type !== 'tool-result') { if (this.data.type !== 'tool-result') {
return nothing; return nothing;
} }
const result = this.data.result;
if (!result || isToolError(result)) {
return html`<tool-call-failed
.name=${getToolErrorDisplayName(
isToolError(result) ? result : null,
'Document search failed',
{
'Workspace Sync Required':
'Enable workspace sync to search documents',
}
)}
.icon=${SearchIcon()}
></tool-call-failed>`;
}
let results: ToolResult[] = []; let results: ToolResult[] = [];
try { try {
results = this.data.result.map(item => ({ results = result.map(item => ({
title: item.title, title: item.title,
icon: PageIcon(), icon: PageIcon(),
onClick: () => { onClick: () => {
@@ -69,7 +82,7 @@ export class DocKeywordSearchResult extends WithDisposable(ShadowlessElement) {
console.error('Failed to parse result', err); console.error('Failed to parse result', err);
} }
return html`<tool-result-card return html`<tool-result-card
.name=${`Found ${this.data.result.length} pages for "${this.data.args.query}"`} .name=${`Found ${result.length} pages for "${this.data.args.query}"`}
.icon=${SearchIcon()} .icon=${SearchIcon()}
.width=${this.width} .width=${this.width}
.results=${results} .results=${results}

View File

@@ -6,6 +6,9 @@ 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 { getToolErrorDisplayName, isToolError } from './tool-result-utils';
import type { ToolError } from './type';
interface DocReadToolCall { interface DocReadToolCall {
type: 'tool-call'; type: 'tool-call';
toolCallId: string; toolCallId: string;
@@ -18,13 +21,23 @@ interface DocReadToolResult {
toolCallId: string; toolCallId: string;
toolName: string; toolName: string;
args: { doc_id: string }; args: { doc_id: string };
result: { result:
| {
/** Old result may not have docId */ /** Old result may not have docId */
docId?: string; docId?: string;
title: string; title: string;
markdown: string; markdown: string;
};
} }
| ToolError
| null;
}
const getFailedName = (result: ToolError | null) => {
return getToolErrorDisplayName(result, 'Document read failed', {
'Workspace Sync Required': 'Enable workspace sync to read this document',
'Document Sync Pending': 'Wait for document sync to finish',
});
};
export class DocReadResult extends WithDisposable(ShadowlessElement) { export class DocReadResult extends WithDisposable(ShadowlessElement) {
@property({ attribute: false }) @property({ attribute: false })
@@ -49,18 +62,25 @@ export class DocReadResult extends WithDisposable(ShadowlessElement) {
if (this.data.type !== 'tool-result') { if (this.data.type !== 'tool-result') {
return nothing; return nothing;
} }
const result = this.data.result;
if (!result || isToolError(result)) {
return html`<tool-call-failed
.name=${getFailedName(isToolError(result) ? result : null)}
.icon=${ViewIcon()}
></tool-call-failed>`;
}
// TODO: better markdown rendering // TODO: better markdown rendering
return html`<tool-result-card return html`<tool-result-card
.name=${`Read "${this.data.result.title}"`} .name=${`Read "${result.title}"`}
.icon=${ViewIcon()} .icon=${ViewIcon()}
.width=${this.width} .width=${this.width}
.results=${[ .results=${[
{ {
title: this.data.result.title, title: result.title,
icon: PageIcon(), icon: PageIcon(),
content: this.data.result.markdown, content: result.markdown,
onClick: () => { onClick: () => {
const docId = (this.data as DocReadToolResult).result.docId; const docId = result.docId;
if (!docId) { if (!docId) {
return; return;
} }

View File

@@ -7,6 +7,8 @@ import { html, nothing } from 'lit';
import { property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
import type { DocDisplayConfig } from '../ai-chat-chips'; import type { DocDisplayConfig } from '../ai-chat-chips';
import { getToolErrorDisplayName, isToolError } from './tool-result-utils';
import type { ToolError } from './type';
interface DocSemanticSearchToolCall { interface DocSemanticSearchToolCall {
type: 'tool-call'; type: 'tool-call';
@@ -20,10 +22,7 @@ interface DocSemanticSearchToolResult {
toolCallId: string; toolCallId: string;
toolName: string; toolName: string;
args: { query: string }; args: { query: string };
result: Array<{ result: Array<{ content: string; docId: string }> | ToolError | null;
content: string;
docId: string;
}>;
} }
function parseResultContent(content: string) { function parseResultContent(content: string) {
@@ -82,11 +81,25 @@ export class DocSemanticSearchResult extends WithDisposable(ShadowlessElement) {
if (this.data.type !== 'tool-result') { if (this.data.type !== 'tool-result') {
return nothing; return nothing;
} }
const result = this.data.result;
if (!result || isToolError(result)) {
return html`<tool-call-failed
.name=${getToolErrorDisplayName(
isToolError(result) ? result : null,
'Semantic search failed',
{
'Workspace Sync Required':
'Enable workspace sync to search documents',
}
)}
.icon=${AiEmbeddingIcon()}
></tool-call-failed>`;
}
return html`<tool-result-card return html`<tool-result-card
.name=${`Found semantically related pages for "${this.data.args.query}"`} .name=${`Found semantically related pages for "${this.data.args.query}"`}
.icon=${AiEmbeddingIcon()} .icon=${AiEmbeddingIcon()}
.width=${this.width} .width=${this.width}
.results=${this.data.result .results=${result
.map(result => ({ .map(result => ({
...parseResultContent(result.content), ...parseResultContent(result.content),
title: this.docDisplayService.getTitle(result.docId), title: this.docDisplayService.getTitle(result.docId),

View File

@@ -0,0 +1,16 @@
import type { ToolError } from './type';
export const isToolError = (result: unknown): result is ToolError =>
!!result &&
typeof result === 'object' &&
'type' in result &&
(result as ToolError).type === 'error';
export const getToolErrorDisplayName = (
result: ToolError | null,
fallback: string,
overrides: Record<string, string> = {}
) => {
if (!result) return fallback;
return overrides[result.name] ?? result.name;
};