🐛 fix: register Notebook tool in server runtime (#12203)

* refactor Notebook Executor

* 🐛 fix: register Notebook tool in server runtime

Notebook tool (lobe-notebook) was only registered on the client side,
causing server-side tool calls to fail with "not implemented" error.

- Add NotebookRuntimeService wrapping DocumentModel/TopicDocumentModel
- Add notebook server runtime registration
- Pass context to runtime methods for topicId passthrough
- Add tests for NotebookRuntimeService and runtime registration

Resolves LOBE-4880

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-02-09 01:00:13 +08:00
committed by GitHub
parent 0e688d08b8
commit be6da39437
19 changed files with 470 additions and 31 deletions

View File

@@ -1,28 +1,56 @@
/**
* Lobe Notebook Executor
*
* Handles notebook document operations via tRPC API calls.
* All operations are delegated to the server since they require database access.
* Handles notebook document operations.
* The NotebookService is injected via constructor so both client and server can provide their own implementation.
*
* Note: listDocuments is not exposed as a tool - it's automatically injected by the system.
*/
import { BaseExecutor, type BuiltinToolContext, type BuiltinToolResult } from '@lobechat/types';
import { notebookService } from '@/services/notebook';
import {
type CreateDocumentArgs,
type DeleteDocumentArgs,
type DocumentType,
type GetDocumentArgs,
NotebookApiName,
NotebookIdentifier,
type UpdateDocumentArgs,
} from '../types';
class NotebookExecutor extends BaseExecutor<typeof NotebookApiName> {
interface CreateDocumentParams {
content: string;
description: string;
title: string;
topicId: string;
type?: DocumentType;
}
interface UpdateDocumentParams {
append?: boolean;
content?: string;
id: string;
title?: string;
}
export interface NotebookServiceApi {
createDocument: (params: CreateDocumentParams) => Promise<any>;
deleteDocument: (id: string) => Promise<any>;
getDocument: (id: string) => Promise<any>;
updateDocument: (params: UpdateDocumentParams) => Promise<any>;
}
export class NotebookExecutor extends BaseExecutor<typeof NotebookApiName> {
readonly identifier = NotebookIdentifier;
protected readonly apiEnum = NotebookApiName;
private notebookService: NotebookServiceApi;
constructor(notebookService: NotebookServiceApi) {
super();
this.notebookService = notebookService;
}
/**
* Create a new document
*/
@@ -42,7 +70,7 @@ class NotebookExecutor extends BaseExecutor<typeof NotebookApiName> {
};
}
const document = await notebookService.createDocument({
const document = await this.notebookService.createDocument({
content: params.content,
description: params.description,
title: params.title,
@@ -80,7 +108,7 @@ class NotebookExecutor extends BaseExecutor<typeof NotebookApiName> {
return { stop: true, success: false };
}
const document = await notebookService.updateDocument(params);
const document = await this.notebookService.updateDocument(params);
return {
content: `✏️ Document updated successfully`,
@@ -112,7 +140,7 @@ class NotebookExecutor extends BaseExecutor<typeof NotebookApiName> {
return { stop: true, success: false };
}
const document = await notebookService.getDocument(params.id);
const document = await this.notebookService.getDocument(params.id);
if (!document) {
return {
@@ -151,7 +179,7 @@ class NotebookExecutor extends BaseExecutor<typeof NotebookApiName> {
return { stop: true, success: false };
}
await notebookService.deleteDocument(params.id);
await this.notebookService.deleteDocument(params.id);
return {
content: `🗑️ Document deleted successfully`,
@@ -170,6 +198,3 @@ class NotebookExecutor extends BaseExecutor<typeof NotebookApiName> {
}
};
}
// Export the executor instance for registration
export const notebookExecutor = new NotebookExecutor();

View File

@@ -2,8 +2,6 @@
* Image component wrapper for Next.js Image.
* This module provides a unified interface that can be easily replaced
* with a generic <img> or custom image component in the future.
*
* @see Phase 3.4: LOBE-2991
*/
// Re-export the Image component

View File

@@ -2,8 +2,6 @@
* Link component wrapper for Next.js Link.
* This module provides a unified interface that can be easily replaced
* with react-router-dom Link in the future.
*
* @see Phase 3.2: LOBE-2989
*/
// Re-export the Link component

View File

@@ -3,7 +3,7 @@
* This module provides a unified interface that can be easily replaced
* with React.lazy + Suspense in the future.
*
* @see Phase 3.3: LOBE-2990
* @see Phase 3.3
*/
// Re-export the dynamic function

View File

@@ -10,7 +10,7 @@
* - import dynamic from '@/libs/next/dynamic';
* - import Image from '@/libs/next/Image';
*
* @see RFC 147: LOBE-2850 - Phase 3
* @see RFC 147
*/
// Navigation exports

View File

@@ -3,7 +3,7 @@
* This module provides a unified interface that can be easily replaced
* with react-router-dom in the future.
*
* @see Phase 3.1: LOBE-2988
* @see Phase 3.1
*/
// Re-export all navigation hooks and utilities from Next.js

View File

@@ -2,9 +2,8 @@
* React Router Link component wrapper.
* Provides a Next.js-like API (href prop) while using React Router internally.
*
* @see RFC 147: LOBE-2850 - Phase 3
* @see RFC 147
*/
import React, { memo } from 'react';
import { Link as ReactRouterLink, type LinkProps as ReactRouterLinkProps } from 'react-router-dom';

View File

@@ -8,7 +8,7 @@
* - import { useRouter, usePathname, useSearchParams } from '@/libs/router';
* - import Link from '@/libs/router/Link';
*
* @see RFC 147: LOBE-2850 - Phase 3
* @see RFC 147
*/
// Navigation exports

View File

@@ -5,9 +5,8 @@
* Usage:
* - import { useRouter, usePathname, useSearchParams, useQuery } from '@/libs/router/navigation';
*
* @see RFC 147: LOBE-2850 - Phase 3
* @see RFC 147
*/
import qs from 'query-string';
import { useMemo } from 'react';
import {
@@ -33,7 +32,7 @@ export const useRouter = () => {
push: (href: string) => navigate(href),
replace: (href: string) => navigate(href, { replace: true }),
}),
[navigate]
[navigate],
);
};
@@ -56,7 +55,9 @@ export const useSearchParams = () => {
/**
* Hook to get route params.
*/
export const useParams = <T extends Record<string, string | undefined> = Record<string, string | undefined>>() => {
export const useParams = <
T extends Record<string, string | undefined> = Record<string, string | undefined>,
>() => {
return useReactRouterParams<T>();
};

View File

@@ -0,0 +1,218 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DocumentModel } from '@/database/models/document';
import { TopicDocumentModel } from '@/database/models/topicDocument';
import { NotebookRuntimeService } from '../index';
vi.mock('@/database/models/document');
vi.mock('@/database/models/topicDocument');
describe('NotebookRuntimeService', () => {
let service: NotebookRuntimeService;
const mockDb = {} as any;
const mockUserId = 'test-user';
let mockDocumentModel: any;
let mockTopicDocumentModel: any;
beforeEach(() => {
vi.clearAllMocks();
mockDocumentModel = {
create: vi.fn(),
delete: vi.fn(),
findById: vi.fn(),
update: vi.fn(),
};
mockTopicDocumentModel = {
associate: vi.fn(),
deleteByDocumentId: vi.fn(),
findByTopicId: vi.fn(),
};
vi.mocked(DocumentModel).mockImplementation(() => mockDocumentModel);
vi.mocked(TopicDocumentModel).mockImplementation(() => mockTopicDocumentModel);
service = new NotebookRuntimeService({ serverDB: mockDb, userId: mockUserId });
});
const mockDocument = {
content: '# Hello',
createdAt: new Date('2025-01-01'),
description: 'A test doc',
fileType: 'markdown',
id: 'doc-1',
source: 'notebook:topic-1',
sourceType: 'api' as const,
title: 'Test Doc',
totalCharCount: 7,
totalLineCount: 1,
updatedAt: new Date('2025-01-01'),
};
describe('createDocument', () => {
it('should create a document and return service result', async () => {
mockDocumentModel.create.mockResolvedValue(mockDocument);
const params = {
content: '# Hello',
fileType: 'markdown',
source: 'notebook:topic-1',
sourceType: 'api' as const,
title: 'Test Doc',
totalCharCount: 7,
totalLineCount: 1,
};
const result = await service.createDocument(params);
expect(mockDocumentModel.create).toHaveBeenCalledWith(params);
expect(result).toEqual({
content: '# Hello',
createdAt: mockDocument.createdAt,
description: 'A test doc',
fileType: 'markdown',
id: 'doc-1',
source: 'notebook:topic-1',
sourceType: 'api',
title: 'Test Doc',
totalCharCount: 7,
updatedAt: mockDocument.updatedAt,
});
});
it('should convert topic sourceType to api', async () => {
mockDocumentModel.create.mockResolvedValue({
...mockDocument,
sourceType: 'topic',
});
const result = await service.createDocument({
content: 'test',
fileType: 'markdown',
source: 'test',
sourceType: 'api',
title: 'test',
totalCharCount: 4,
totalLineCount: 1,
});
expect(result.sourceType).toBe('api');
});
});
describe('getDocument', () => {
it('should return document when found', async () => {
mockDocumentModel.findById.mockResolvedValue(mockDocument);
const result = await service.getDocument('doc-1');
expect(mockDocumentModel.findById).toHaveBeenCalledWith('doc-1');
expect(result).toBeDefined();
expect(result!.id).toBe('doc-1');
});
it('should return undefined when not found', async () => {
mockDocumentModel.findById.mockResolvedValue(undefined);
const result = await service.getDocument('nonexistent');
expect(result).toBeUndefined();
});
});
describe('updateDocument', () => {
it('should update content and recalculate stats', async () => {
const newContent = 'line1\nline2\nline3';
mockDocumentModel.update.mockResolvedValue(undefined);
mockDocumentModel.findById.mockResolvedValue({
...mockDocument,
content: newContent,
totalCharCount: newContent.length,
totalLineCount: 3,
});
const result = await service.updateDocument('doc-1', { content: newContent });
expect(mockDocumentModel.update).toHaveBeenCalledWith('doc-1', {
content: newContent,
totalCharCount: newContent.length,
totalLineCount: 3,
});
expect(result.content).toBe(newContent);
});
it('should update title only', async () => {
mockDocumentModel.update.mockResolvedValue(undefined);
mockDocumentModel.findById.mockResolvedValue({
...mockDocument,
title: 'New Title',
});
const result = await service.updateDocument('doc-1', { title: 'New Title' });
expect(mockDocumentModel.update).toHaveBeenCalledWith('doc-1', { title: 'New Title' });
expect(result.title).toBe('New Title');
});
it('should throw if document not found after update', async () => {
mockDocumentModel.update.mockResolvedValue(undefined);
mockDocumentModel.findById.mockResolvedValue(undefined);
await expect(service.updateDocument('doc-1', { title: 'x' })).rejects.toThrow(
'Document not found after update: doc-1',
);
});
});
describe('deleteDocument', () => {
it('should delete associations first then the document', async () => {
mockTopicDocumentModel.deleteByDocumentId.mockResolvedValue(undefined);
mockDocumentModel.delete.mockResolvedValue(undefined);
await service.deleteDocument('doc-1');
expect(mockTopicDocumentModel.deleteByDocumentId).toHaveBeenCalledWith('doc-1');
expect(mockDocumentModel.delete).toHaveBeenCalledWith('doc-1');
});
});
describe('associateDocumentWithTopic', () => {
it('should associate document with topic', async () => {
mockTopicDocumentModel.associate.mockResolvedValue({
documentId: 'doc-1',
topicId: 'topic-1',
});
await service.associateDocumentWithTopic('doc-1', 'topic-1');
expect(mockTopicDocumentModel.associate).toHaveBeenCalledWith({
documentId: 'doc-1',
topicId: 'topic-1',
});
});
});
describe('getDocumentsByTopicId', () => {
it('should return documents for a topic', async () => {
mockTopicDocumentModel.findByTopicId.mockResolvedValue([mockDocument]);
const result = await service.getDocumentsByTopicId('topic-1');
expect(mockTopicDocumentModel.findByTopicId).toHaveBeenCalledWith('topic-1', undefined);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('doc-1');
});
it('should pass filter to findByTopicId', async () => {
mockTopicDocumentModel.findByTopicId.mockResolvedValue([]);
await service.getDocumentsByTopicId('topic-1', { type: 'markdown' });
expect(mockTopicDocumentModel.findByTopicId).toHaveBeenCalledWith('topic-1', {
type: 'markdown',
});
});
});
});

View File

@@ -0,0 +1,110 @@
import { type LobeChatDatabase } from '@lobechat/database';
import { DocumentModel } from '@/database/models/document';
import { TopicDocumentModel } from '@/database/models/topicDocument';
interface DocumentServiceResult {
content: string | null;
createdAt: Date;
description: string | null;
fileType: string;
id: string;
source: string;
sourceType: 'api' | 'file' | 'web';
title: string | null;
totalCharCount: number;
updatedAt: Date;
}
export interface NotebookRuntimeServiceOptions {
serverDB: LobeChatDatabase;
userId: string;
}
const toServiceResult = (doc: {
content: string | null;
createdAt: Date;
description: string | null;
fileType: string;
id: string;
source: string;
sourceType: 'api' | 'file' | 'web' | 'topic';
title: string | null;
totalCharCount: number;
updatedAt: Date;
}): DocumentServiceResult => ({
content: doc.content,
createdAt: doc.createdAt,
description: doc.description,
fileType: doc.fileType,
id: doc.id,
source: doc.source,
sourceType: doc.sourceType === 'topic' ? 'api' : doc.sourceType,
title: doc.title,
totalCharCount: doc.totalCharCount,
updatedAt: doc.updatedAt,
});
export class NotebookRuntimeService {
private documentModel: DocumentModel;
private topicDocumentModel: TopicDocumentModel;
constructor(options: NotebookRuntimeServiceOptions) {
this.documentModel = new DocumentModel(options.serverDB, options.userId);
this.topicDocumentModel = new TopicDocumentModel(options.serverDB, options.userId);
}
associateDocumentWithTopic = async (documentId: string, topicId: string): Promise<void> => {
await this.topicDocumentModel.associate({ documentId, topicId });
};
createDocument = async (params: {
content: string;
fileType: string;
source: string;
sourceType: 'api' | 'file' | 'web';
title: string;
totalCharCount: number;
totalLineCount: number;
}): Promise<DocumentServiceResult> => {
const doc = await this.documentModel.create(params);
return toServiceResult(doc);
};
deleteDocument = async (id: string): Promise<void> => {
await this.topicDocumentModel.deleteByDocumentId(id);
await this.documentModel.delete(id);
};
getDocument = async (id: string): Promise<DocumentServiceResult | undefined> => {
const doc = await this.documentModel.findById(id);
if (!doc) return undefined;
return toServiceResult(doc);
};
getDocumentsByTopicId = async (
topicId: string,
filter?: { type?: string },
): Promise<DocumentServiceResult[]> => {
const docs = await this.topicDocumentModel.findByTopicId(topicId, filter);
return docs.map(toServiceResult);
};
updateDocument = async (
id: string,
params: { content?: string; title?: string },
): Promise<DocumentServiceResult> => {
await this.documentModel.update(id, {
...(params.content !== undefined && {
content: params.content,
totalCharCount: params.content.length,
totalLineCount: params.content.split('\n').length,
}),
...(params.title !== undefined && { title: params.title }),
});
const doc = await this.documentModel.findById(id);
if (!doc) throw new Error(`Document not found after update: ${id}`);
return toServiceResult(doc);
};
}

View File

@@ -65,7 +65,7 @@ export class BuiltinToolsExecutor implements IToolExecutor {
}
try {
return await runtime[apiName](args);
return await runtime[apiName](args, context);
} catch (e) {
const error = e as Error;
console.error('Error executing builtin tool %s:%s: %O', identifier, apiName, error);

View File

@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from 'vitest';
import { notebookRuntime } from '../notebook';
vi.mock('@/database/models/document');
vi.mock('@/database/models/topicDocument');
describe('notebookRuntime', () => {
it('should have correct identifier', () => {
expect(notebookRuntime.identifier).toBe('lobe-notebook');
});
it('should create runtime from factory with valid context', () => {
const context = {
serverDB: {} as any,
toolManifestMap: {},
topicId: 'topic-1',
userId: 'user-1',
};
const runtime = notebookRuntime.factory(context);
expect(runtime).toBeDefined();
expect(typeof runtime.createDocument).toBe('function');
expect(typeof runtime.updateDocument).toBe('function');
expect(typeof runtime.getDocument).toBe('function');
expect(typeof runtime.deleteDocument).toBe('function');
});
it('should throw if userId is missing', () => {
const context = {
serverDB: {} as any,
toolManifestMap: {},
};
expect(() => notebookRuntime.factory(context)).toThrow(
'userId and serverDB are required for Notebook execution',
);
});
it('should throw if serverDB is missing', () => {
const context = {
toolManifestMap: {},
userId: 'user-1',
};
expect(() => notebookRuntime.factory(context)).toThrow(
'userId and serverDB are required for Notebook execution',
);
});
});

View File

@@ -8,6 +8,7 @@
*/
import { type ToolExecutionContext } from '../types';
import { cloudSandboxRuntime } from './cloudSandbox';
import { notebookRuntime } from './notebook';
import { type ServerRuntimeFactory, type ServerRuntimeRegistration } from './types';
import { webBrowsingRuntime } from './webBrowsing';
@@ -26,7 +27,7 @@ const registerRuntimes = (runtimes: ServerRuntimeRegistration[]) => {
};
// Register all server runtimes
registerRuntimes([webBrowsingRuntime, cloudSandboxRuntime]);
registerRuntimes([webBrowsingRuntime, cloudSandboxRuntime, notebookRuntime]);
// ==================== Registry API ====================

View File

@@ -0,0 +1,26 @@
import { NotebookIdentifier } from '@lobechat/builtin-tool-notebook';
import { NotebookExecutionRuntime } from '@lobechat/builtin-tool-notebook/executionRuntime';
import { NotebookRuntimeService } from '@/server/services/notebook';
import { type ServerRuntimeRegistration } from './types';
/**
* Notebook Server Runtime
* Per-request runtime (needs serverDB, userId, topicId)
*/
export const notebookRuntime: ServerRuntimeRegistration = {
factory: (context) => {
if (!context.userId || !context.serverDB) {
throw new Error('userId and serverDB are required for Notebook execution');
}
const notebookService = new NotebookRuntimeService({
serverDB: context.serverDB,
userId: context.userId,
});
return new NotebookExecutionRuntime(notebookService);
},
identifier: NotebookIdentifier,
};

View File

@@ -201,7 +201,7 @@ export const messagePublicApi: StateCreator<
get().internal_dispatchMessage({ type: 'deleteMessages', ids });
const ctx = get().internal_getConversationContext();
// CRUD operations pass agentId - backend handles sessionId mapping (LOBE-1086)
// CRUD operations pass agentId - backend handles sessionId mapping
const result = await messageService.removeMessages(ids, ctx);
if (result?.success && result.messages) {

View File

@@ -76,7 +76,7 @@ export interface OperationActions {
*
* Returns full MessageMapKeyInput for consistent key generation
*
* Migration Note (LOBE-1086):
* Migration Note:
* - Only agentId is used for message association
* - Backend handles sessionId mapping internally based on agentId
*/

View File

@@ -12,9 +12,9 @@ import { gtdExecutor } from '@lobechat/builtin-tool-gtd/executor';
import { knowledgeBaseExecutor } from '@lobechat/builtin-tool-knowledge-base/executor';
import { localSystemExecutor } from '@lobechat/builtin-tool-local-system/executor';
import { memoryExecutor } from '@lobechat/builtin-tool-memory/executor';
import { notebookExecutor } from '@lobechat/builtin-tool-notebook/executor';
import type { IBuiltinToolExecutor } from '../types';
import { notebookExecutor } from './lobe-notebook';
import { pageAgentExecutor } from './lobe-page-agent';
import { webBrowsing } from './lobe-web-browsing';

View File

@@ -0,0 +1,12 @@
/**
* Lobe Notebook Executor
*
* Creates and exports the NotebookExecutor instance for registration.
* Injects notebookService as dependency.
*/
import { NotebookExecutor } from '@lobechat/builtin-tool-notebook/executor';
import { notebookService } from '@/services/notebook';
// Create executor instance with client-side service
export const notebookExecutor = new NotebookExecutor(notebookService);