feat: support notebook tool (#10902)

* add notebook builtin tool

* document init workflow

* gtd support plan mode

* add notebook tools
This commit is contained in:
Arvin Xu 2025-12-23 20:31:37 +08:00 committed by arvinxx
parent c6a6e246d8
commit e05375f796
82 changed files with 3499 additions and 152 deletions

View file

@ -269,6 +269,7 @@ table chat_groups_agents {
table documents {
id varchar(255) [pk, not null]
slug varchar(255)
title text
description text
content text
@ -286,7 +287,6 @@ table documents {
user_id text [not null]
client_id text
editor_data jsonb
slug varchar(255)
accessed_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]

View file

@ -363,13 +363,16 @@
"title": "No plugins available"
},
"error": {
"details": "Error Details",
"fetchError": "Failed to request the manifest link, please ensure the link is valid and allows cross-origin access",
"installError": "Plugin {{name}} installation failed",
"manifestInvalid": "Manifest does not comply with specifications, validation result: \n\n {{error}}",
"noManifest": "Manifest file does not exist",
"openAPIInvalid": "OpenAPI parsing failed, error: \n\n {{error}}",
"reinstallError": "Plugin {{name}} refresh failed",
"renderError": "Tool Render Error",
"testConnectionFailed": "Failed to get Manifest: {{error}}",
"unknownError": "An unknown error occurred",
"urlError": "The link did not return JSON content, please ensure it is a valid link"
},
"inspector": {

View file

@ -363,13 +363,16 @@
"title": "暂无插件"
},
"error": {
"details": "错误详情",
"fetchError": "请求该 manifest 链接失败,请确保链接的有效性,并检查链接是否允许跨域访问",
"installError": "插件 {{name}} 安装失败",
"manifestInvalid": "manifest 不符合规范,校验结果: \n\n {{error}}",
"noManifest": "描述文件不存在",
"openAPIInvalid": "OpenAPI 解析失败,错误: \n\n {{error}}",
"reinstallError": "插件 {{name}} 刷新失败",
"renderError": "工具渲染错误",
"testConnectionFailed": "获取 Manifest 失败: {{error}}",
"unknownError": "发生未知错误",
"urlError": "该链接没有返回 JSON 格式的内容, 请确保是有效的链接"
},
"inspector": {

View file

@ -171,6 +171,7 @@
"@lobechat/builtin-tool-knowledge-base": "workspace:*",
"@lobechat/builtin-tool-local-system": "workspace:*",
"@lobechat/builtin-tool-memory": "workspace:*",
"@lobechat/builtin-tool-notebook": "workspace:*",
"@lobechat/const": "workspace:*",
"@lobechat/context-engine": "workspace:*",
"@lobechat/conversation-flow": "workspace:*",
@ -195,8 +196,8 @@
"@lobehub/editor": "^2.0.5",
"@lobehub/icons": "^3.0.0",
"@lobehub/market-sdk": "^0.24.0",
"@lobehub/tts": "^3.0.0",
"@lobehub/ui": "^3.4.4",
"@lobehub/tts": "^3.0.2",
"@lobehub/ui": "^3.4.5",
"@modelcontextprotocol/sdk": "^1.25.1",
"@neondatabase/serverless": "^1.0.2",
"@next/third-parties": "^16.1.0",

View file

@ -8,6 +8,7 @@
"@lobechat/builtin-tool-group-agent-builder": "workspace:*",
"@lobechat/builtin-tool-group-management": "workspace:*",
"@lobechat/builtin-tool-gtd": "workspace:*",
"@lobechat/builtin-tool-notebook": "workspace:*",
"@lobechat/types": "workspace:*"
}
}

View file

@ -1,4 +1,5 @@
import { GTDIdentifier } from '@lobechat/builtin-tool-gtd';
import { NotebookIdentifier } from '@lobechat/builtin-tool-notebook';
import type { BuiltinAgentDefinition } from '../../types';
import { BUILTIN_AGENT_SLUGS } from '../../types';
@ -12,7 +13,7 @@ import { systemRole } from './systemRole';
export const INBOX: BuiltinAgentDefinition = {
avatar: '/avatars/lobe-ai.png',
runtime: (ctx) => ({
plugins: [GTDIdentifier, ...(ctx.plugins || [])],
plugins: [GTDIdentifier, NotebookIdentifier, ...(ctx.plugins || [])],
systemRole: systemRole,
}),

View file

@ -14,4 +14,48 @@ Your role is to:
- Provide clear and concise explanations
- Be friendly and professional in your responses
<builtin_tools_guidelines>
You have access to two built-in tools: **Notebook** for content creation and **GTD** for task management.
## Notebook Tool (createDocument)
Use Notebook to create documents when:
- User requests relatively long content (articles, reports, analyses, tutorials, guides)
- User explicitly asks to "write", "draft", "create", "generate" substantial content
- Output would exceed ~500 words or benefit from structured formatting
- Content should be preserved for future reference or editing
- Creating deliverables: blog posts, documentation, summaries, research notes
**When to respond directly in chat instead**:
- Short answers, explanations, or clarifications
- Quick Q&A interactions
- Code snippets or brief examples
- Conversational exchanges
## GTD Tool (createPlan, createTodos)
**ONLY use GTD when user explicitly requests task/project management**:
- User explicitly asks to "create a plan", "make a todo list", "track tasks"
- User says "help me plan [project]", "organize my tasks", "remind me to..."
- User provides a list of things they need to do and wants to track them
**When NOT to use GTD** (respond in chat instead):
- Answering questions (even if about "what to do" or "steps to take")
- Providing advice, analysis, or opinions
- Code review or technical consultations
- Explaining concepts or procedures
- Any question that starts with "Is...", "Can...", "Should...", "Would...", "What if..."
- Security assessments or risk analysis
**Key principle**: GTD is for ACTION TRACKING, not for answering questions. If the user is asking a question (even about tasks or plans), just answer it directly.
## Choosing the Right Tool
- "Write me an article about..." Notebook
- "Help me plan my project" GTD (plan + todos)
- "Create a to-do list for..." GTD (todos)
- "Draft a report on..." Notebook
- "What are the steps to..." Chat (just explain)
- "Is this code secure?" Chat (just answer)
- "Should I do X or Y?" Chat (just advise)
- "Remember to..." / "Add to my list..." GTD (todos)
</builtin_tools_guidelines>
Respond in the same language the user is using.`;

View file

@ -15,7 +15,7 @@
"type-fest": "^4.18.3"
},
"peerDependencies": {
"@lobehub/ui": "^3.3.3",
"@lobehub/ui": "^3",
"antd": "^6",
"lucide-react": "*",
"next": "*",

View file

@ -15,7 +15,7 @@
"react": "*"
},
"peerDependencies": {
"@lobehub/ui": "^3.3.3",
"@lobehub/ui": "^3",
"antd": "^6",
"antd-style": "*"
}

View file

@ -0,0 +1,118 @@
'use client';
import { BuiltinInterventionProps } from '@lobechat/types';
import { Flexbox, Input, TextArea } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { memo, useCallback, useRef, useState } from 'react';
import type { CreatePlanParams } from '../../types';
const useStyles = createStyles(({ css, token }) => ({
container: css`
padding: 12px;
border-radius: ${token.borderRadius}px;
background: ${token.colorFillQuaternary};
`,
label: css`
margin-block-end: 4px;
font-size: 12px;
font-weight: 500;
color: ${token.colorTextSecondary};
`,
}));
const CreatePlanIntervention = memo<BuiltinInterventionProps<CreatePlanParams>>(
({ args, onArgsChange, registerBeforeApprove }) => {
const { styles } = useStyles();
const [goal, setGoal] = useState(args?.goal || '');
const [description, setDescription] = useState(args?.description || '');
const [context, setContext] = useState(args?.context || '');
// Track pending changes
const pendingChangesRef = useRef<CreatePlanParams | null>(null);
// Save function
const save = useCallback(async () => {
if (pendingChangesRef.current) {
await onArgsChange?.(pendingChangesRef.current);
pendingChangesRef.current = null;
}
}, [onArgsChange]);
// Register before approve callback
registerBeforeApprove?.('createPlan', save);
const handleGoalChange = useCallback(
(value: string) => {
setGoal(value);
pendingChangesRef.current = {
context: context || undefined,
description,
goal: value,
};
},
[context, description],
);
const handleDescriptionChange = useCallback(
(value: string) => {
setDescription(value);
pendingChangesRef.current = {
context: context || undefined,
description: value,
goal,
};
},
[context, goal],
);
const handleContextChange = useCallback(
(value: string) => {
setContext(value);
pendingChangesRef.current = {
context: value || undefined,
description,
goal,
};
},
[description, goal],
);
return (
<Flexbox className={styles.container} gap={12}>
<Flexbox>
<div className={styles.label}>Goal</div>
<Input
onChange={(e) => handleGoalChange(e.target.value)}
placeholder="What do you want to achieve?"
value={goal}
/>
</Flexbox>
<Flexbox>
<div className={styles.label}>Description</div>
<Input
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder="Brief summary of the plan"
value={description}
/>
</Flexbox>
<Flexbox>
<div className={styles.label}>Context (optional)</div>
<TextArea
autoSize={{ minRows: 10 }}
onChange={(e) => handleContextChange(e.target.value)}
placeholder="Background, constraints, considerations..."
value={context}
/>
</Flexbox>
</Flexbox>
);
},
isEqual,
);
CreatePlanIntervention.displayName = 'CreatePlanIntervention';
export default CreatePlanIntervention;

View file

@ -3,6 +3,7 @@ import { BuiltinIntervention } from '@lobechat/types';
import { GTDApiName } from '../../types';
import AddTodoIntervention from './AddTodo';
import ClearTodosIntervention from './ClearTodos';
import CreatePlanIntervention from './CreatePlan';
/**
* GTD Tool Intervention Components Registry
@ -11,9 +12,11 @@ import ClearTodosIntervention from './ClearTodos';
* before the tool is executed.
*/
export const GTDInterventions: Record<string, BuiltinIntervention> = {
[GTDApiName.createTodos]: AddTodoIntervention as BuiltinIntervention,
[GTDApiName.clearTodos]: ClearTodosIntervention as BuiltinIntervention,
[GTDApiName.createPlan]: CreatePlanIntervention as BuiltinIntervention,
[GTDApiName.createTodos]: AddTodoIntervention as BuiltinIntervention,
};
export { default as AddTodoIntervention } from './AddTodo';
export { default as ClearTodosIntervention } from './ClearTodos';
export { default as CreatePlanIntervention } from './CreatePlan';

View file

@ -0,0 +1,24 @@
'use client';
import { BuiltinRenderProps } from '@lobechat/types';
import { memo } from 'react';
import type { CreatePlanParams, CreatePlanState } from '../../types';
import PlanCard from './PlanCard';
export type CreatePlanRenderProps = Pick<
BuiltinRenderProps<CreatePlanParams, CreatePlanState>,
'pluginState'
>;
const CreatePlan = memo<CreatePlanRenderProps>(({ pluginState }) => {
const { plan } = pluginState || {};
if (!plan) {
return null;
}
return <PlanCard plan={plan} />;
});
export default CreatePlan;

View file

@ -0,0 +1,84 @@
'use client';
import { Flexbox, Tag, Text } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { ClipboardList } from 'lucide-react';
import { memo } from 'react';
import { useChatStore } from '@/store/chat';
import type { Plan } from '../../types';
const useStyles = createStyles(({ token, css }) => {
return {
container: css`
cursor: pointer;
overflow: hidden;
width: 100%;
padding-block: 12px;
padding-inline: 12px;
border: 1px solid ${token.colorBorderSecondary};
border-radius: 8px;
background: ${token.colorBgElevated};
&:hover {
background: ${token.colorFillSecondary};
}
`,
description: css`
font-size: 12px;
line-height: 1.5;
color: ${token.colorTextSecondary};
`,
icon: css`
color: ${token.colorPrimary};
`,
title: css`
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
font-weight: 500;
color: ${token.colorText};
`,
typeTag: css`
font-size: 11px;
`,
};
});
interface PlanCardProps {
plan: Plan;
}
const PlanCard = memo<PlanCardProps>(({ plan }) => {
const { styles } = useStyles();
const openDocument = useChatStore((s) => s.openDocument);
const handleClick = () => {
openDocument(plan.id);
};
return (
<Flexbox className={styles.container} gap={8} onClick={handleClick}>
<Flexbox align={'center'} gap={8} horizontal>
<ClipboardList className={styles.icon} size={16} />
<div className={styles.title}>{plan.goal}</div>
<Tag className={styles.typeTag} size={'small'}>
plan
</Tag>
</Flexbox>
{plan.description && (
<Text className={styles.description} ellipsis={{ rows: 2 }}>
{plan.description}
</Text>
)}
</Flexbox>
);
});
export default PlanCard;

View file

@ -1,4 +1,5 @@
import { GTDApiName } from '../../types';
import CreatePlan from './CreatePlan';
import TodoListRender from './TodoList';
/**
@ -6,15 +7,22 @@ import TodoListRender from './TodoList';
*
* All todo operations use the same TodoList render component
* which displays the current state of the todo list.
* Plan operations use the CreatePlan render component.
*/
export const GTDRenders = {
// All todo operations render the same TodoList UI
[GTDApiName.createTodos]: TodoListRender,
[GTDApiName.updateTodos]: TodoListRender,
[GTDApiName.completeTodos]: TodoListRender,
[GTDApiName.removeTodos]: TodoListRender,
[GTDApiName.clearTodos]: TodoListRender,
[GTDApiName.completeTodos]: TodoListRender,
[GTDApiName.createTodos]: TodoListRender,
[GTDApiName.removeTodos]: TodoListRender,
[GTDApiName.updateTodos]: TodoListRender,
// Plan operations render the PlanCard UI
[GTDApiName.createPlan]: CreatePlan,
[GTDApiName.updatePlan]: CreatePlan,
};
export { default as CreatePlan } from './CreatePlan';
export { default as PlanCard } from './PlanCard';
export type { TodoListRenderState } from './TodoList';
export { default as TodoListRender, TodoListUI } from './TodoList';

View file

@ -1,35 +1,32 @@
/**
* Lobe GTD (Getting Things Done) Executor
*
* Handles GTD tool calls for task management.
* MVP: Only implements Todo functionality.
*
* Todo data flow:
* - Todo items are stored in messagePlugins.state.todos
* - The executor receives current state via ctx and returns updated state in result
* - The framework handles persisting state to the database
*/
import { formatTodoStateSummary } from '@lobechat/prompts';
import { BaseExecutor, type BuiltinToolContext, type BuiltinToolResult } from '@lobechat/types';
import { notebookService } from '@/services/notebook';
import { useNotebookStore } from '@/store/notebook';
import { GTDIdentifier } from '../manifest';
import {
type ClearTodosParams,
type CompleteTodosParams,
type CreatePlanParams,
type CreateTodosParams,
GTDApiName,
type Plan,
type RemoveTodosParams,
type TodoItem,
type UpdatePlanParams,
type UpdateTodosParams,
} from '../types';
import { getTodosFromContext } from './helper';
// API enum for MVP (Todo only)
// API enum for MVP (Todo + Plan)
const GTDApiNameMVP = {
clearTodos: GTDApiName.clearTodos,
completeTodos: GTDApiName.completeTodos,
createPlan: GTDApiName.createPlan,
createTodos: GTDApiName.createTodos,
removeTodos: GTDApiName.removeTodos,
updatePlan: GTDApiName.updatePlan,
updateTodos: GTDApiName.updateTodos,
} as const;
@ -353,6 +350,133 @@ class GTDExecutor extends BaseExecutor<typeof GTDApiNameMVP> {
success: true,
};
};
// ==================== Plan APIs ====================
/**
* Create a new plan document
*/
createPlan = async (
params: CreatePlanParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
try {
if (ctx.signal?.aborted) {
return { stop: true, success: false };
}
if (!ctx.topicId) {
return {
content: 'Cannot create plan: no topic selected',
success: false,
};
}
const { goal, description, context } = params;
// Create document with type 'agent/plan'
// Field mapping: goal -> title, description -> description, context -> content
const document = await useNotebookStore.getState().createDocument({
content: context || '',
description,
title: goal,
topicId: ctx.topicId,
type: 'agent/plan',
});
const plan: Plan = {
completed: false,
context,
createdAt: document.createdAt.toISOString(),
description: document.description || '',
goal: document.title || '',
id: document.id,
updatedAt: document.updatedAt.toISOString(),
};
return {
content: `📋 Created plan: "${plan.goal}"\n\nYou can view this plan in the Portal sidebar.`,
state: { plan },
success: true,
};
} catch (e) {
const err = e as Error;
return {
error: {
body: e,
message: err.message,
type: 'PluginServerError',
},
success: false,
};
}
};
/**
* Update an existing plan document
*/
updatePlan = async (
params: UpdatePlanParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
try {
if (ctx.signal?.aborted) {
return { stop: true, success: false };
}
const { planId, goal, description, context, completed } = params;
if (!ctx.topicId) {
return {
content: 'Cannot update plan: no topic selected',
success: false,
};
}
// Get existing document
const existingDoc = await notebookService.getDocument(planId);
if (!existingDoc) {
return {
content: `Plan not found: ${planId}`,
success: false,
};
}
// Update document using store (triggers refresh)
// Field mapping: goal -> title, description -> description, context -> content
const document = await useNotebookStore.getState().updateDocument(
{
content: context,
description,
id: planId,
title: goal,
},
ctx.topicId,
);
const plan: Plan = {
completed: completed ?? false,
context: context ?? existingDoc.content ?? undefined,
createdAt: document?.createdAt.toISOString() || '',
description: document?.description || existingDoc.description || '',
goal: document?.title || existingDoc.title || '',
id: planId,
updatedAt: document?.updatedAt.toISOString() || '',
};
return {
content: `📝 Updated plan: "${plan.goal}"`,
state: { plan },
success: true,
};
} catch (e) {
const err = e as Error;
return {
error: { body: e, message: err.message, type: 'PluginServerError' },
success: false,
};
}
};
}
// Export the executor instance for registration

View file

@ -8,6 +8,65 @@ export const GTDIdentifier = 'lobe-gtd';
export const GTDManifest: BuiltinToolManifest = {
/* eslint-disable sort-keys-fix/sort-keys-fix */
api: [
// ==================== Planning ====================
{
description:
'Create a high-level plan document. Plans define the strategic direction (the "what" and "why"), while todos handle the actionable steps.',
name: GTDApiName.createPlan,
humanIntervention: 'always',
renderDisplayControl: 'expand',
parameters: {
properties: {
goal: {
description: 'The main goal or objective to achieve (used as document title).',
type: 'string',
},
description: {
description: 'A brief summary of the plan (1-2 sentences).',
type: 'string',
},
context: {
description:
'Detailed context, constraints, background information, or strategic considerations relevant to the goal.',
type: 'string',
},
},
required: ['goal', 'description'],
type: 'object',
},
},
{
description:
'Update an existing plan document. Use this to modify the goal, description, context, or mark the plan as completed.',
name: GTDApiName.updatePlan,
parameters: {
properties: {
planId: {
description: 'The ID of the plan to update.',
type: 'string',
},
goal: {
description: 'Updated goal (document title).',
type: 'string',
},
description: {
description: 'Updated brief summary.',
type: 'string',
},
context: {
description: 'Updated detailed context.',
type: 'string',
},
completed: {
description: 'Mark the plan as completed.',
type: 'boolean',
},
},
required: ['planId'],
type: 'object',
},
},
// ==================== Quick Todo ====================
{
description: 'Create new todo items. Pass an array of text strings.',
@ -120,55 +179,6 @@ export const GTDManifest: BuiltinToolManifest = {
type: 'object',
},
},
// ==================== Planning ====================
{
description:
'Create a high-level plan document describing a goal and its context. Plans define the strategic direction (the "what" and "why"), while todos handle the actionable steps.',
name: GTDApiName.createPlan,
parameters: {
properties: {
goal: {
description: 'The main goal or objective to achieve.',
type: 'string',
},
context: {
description:
'Additional context, constraints, background information, or strategic considerations relevant to the goal.',
type: 'string',
},
},
required: ['goal'],
type: 'object',
},
},
{
description:
'Update an existing plan document. Use this to modify the goal, context, or mark the plan as completed.',
name: GTDApiName.updatePlan,
parameters: {
properties: {
planId: {
description: 'The ID of the plan to update.',
type: 'string',
},
goal: {
description: 'Updated goal description.',
type: 'string',
},
context: {
description: 'Updated context information.',
type: 'string',
},
completed: {
description: 'Mark the plan as completed.',
type: 'boolean',
},
},
required: ['planId'],
type: 'object',
},
},
],
identifier: GTDIdentifier,
meta: {

View file

@ -16,39 +16,41 @@ export const systemPrompt = `You have GTD (Getting Things Done) tools to help ma
- \`clearTodos\`: Clear completed or all items
</tool_overview>
<default_workflow>
**IMPORTANT: Always create a Plan first, then Todos.**
When a user asks you to help with a task, goal, or project:
1. **First**, use \`createPlan\` to document the goal and relevant context
2. **Then**, use \`createTodos\` to break down the plan into actionable steps
This "Plan-First" approach ensures:
- Clear documentation of the objective before execution
- Better organized and contextual todo items
- Trackable progress from goal to completion
**Exception**: Only skip the plan and create todos directly when the user explicitly says:
- "Just give me a todo list"
- "I only need action items"
- "Skip the plan, just todos"
- Or similar explicit requests for todos only
</default_workflow>
<when_to_use>
**Use Plans when:**
- Documenting high-level goals and objectives
- Capturing context, constraints, and background information
- Defining the strategic direction before execution
- Recording the "why" behind a project or initiative
- User states a goal, project, or objective
- There's context, constraints, or background to capture
- The task requires strategic thinking before execution
- You need to document the "why" behind the work
**Use Todos when:**
- Breaking down a plan into actionable steps
- Capturing specific tasks to execute
- Breaking down a plan into actionable steps (after creating a plan)
- User explicitly requests only action items
- Capturing quick, simple tasks that don't need planning
- Tracking progress on concrete deliverables
- Managing day-to-day action items
</when_to_use>
<workflow_patterns>
**Pattern 1: Plan-First Approach**
1. User states a goal Use \`createPlan\` to document the goal and context
2. Break down the plan into todos using \`createTodos\`
3. Execute and track with \`completeTodos\`
4. Mark plan completed with \`updatePlan\` when all todos are done
**Pattern 2: Quick Capture**
1. Capture action items directly with \`createTodos\`
2. Track progress with \`completeTodos\`
3. Clean up with \`clearTodos\` mode: "completed"
**Pattern 3: Plan + Todo Combined**
1. Create a plan to document the high-level goal and context
2. Use todos as the execution checklist derived from the plan
3. Update plan status when execution is complete
</workflow_patterns>
<best_practices>
- **Plan first, then todos**: Always start with a plan unless explicitly told otherwise
- **Separate concerns**: Plans describe goals; Todos list actions
- **Actionable todos**: Each todo should be a concrete, completable task
- **Context in plans**: Use plan's context field to capture constraints and background

View file

@ -162,20 +162,29 @@ export interface ClearTodosState {
/**
* Create a high-level plan document
* Plans define the strategic direction (what and why), not actionable steps
*
* Field mapping to Document:
* - goal -> document.title
* - description -> document.description
* - context -> document.content
*/
export interface CreatePlanParams {
/** Additional context, constraints, or strategic considerations */
/** Detailed context, background, constraints (maps to document.content) */
context?: string;
/** The main goal or objective to achieve */
/** Brief summary of the plan (maps to document.description) */
description: string;
/** The main goal or objective to achieve (maps to document.title) */
goal: string;
}
export interface UpdatePlanParams {
/** Mark plan as completed */
completed?: boolean;
/** Updated context information */
/** Updated context (maps to document.content) */
context?: string;
/** Updated goal */
/** Updated description (maps to document.description) */
description?: string;
/** Updated goal (maps to document.title) */
goal?: string;
/** Plan ID to update */
planId: string;
@ -186,18 +195,37 @@ export interface UpdatePlanParams {
/**
* A high-level plan document
* Contains goal and context, but no steps (steps are managed via Todos)
*
* Field mapping to Document:
* - goal -> document.title
* - description -> document.description
* - context -> document.content
*/
export interface Plan {
/** Whether the plan is completed */
completed: boolean;
/** Additional context and strategic information */
/** Detailed context, background, constraints (maps to document.content) */
context?: string;
/** Creation timestamp */
createdAt: string;
/** The main goal or objective */
/** Brief summary of the plan (maps to document.description) */
description: string;
/** The main goal or objective (maps to document.title) */
goal: string;
/** Unique plan identifier */
id: string;
/** Last update timestamp */
updatedAt: string;
}
// ==================== Plan State Types for Render ====================
export interface CreatePlanState {
/** The created plan document */
plan: Plan;
}
export interface UpdatePlanState {
/** The updated plan document */
plan: Plan;
}

View file

@ -13,7 +13,7 @@
"@lobechat/types": "workspace:*"
},
"peerDependencies": {
"@lobehub/ui": "^3.3.3",
"@lobehub/ui": "^3",
"antd": "^6",
"antd-style": "*",
"polished": "*",

View file

@ -0,0 +1,24 @@
{
"name": "@lobechat/builtin-tool-notebook",
"version": "1.0.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./client": "./src/client/index.ts",
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
},
"main": "./src/index.ts",
"dependencies": {
"@lobechat/prompts": "workspace:*",
"@lobechat/types": "workspace:*"
},
"peerDependencies": {
"@lobehub/ui": "^3.3.3",
"antd": "^6",
"antd-style": "*",
"lucide-react": "*",
"polished": "*",
"react": "*",
"react-i18next": "*"
}
}

View file

@ -0,0 +1,276 @@
import { BuiltinServerRuntimeOutput } from '@lobechat/types';
import {
CreateDocumentArgs,
CreateDocumentState,
DeleteDocumentArgs,
DeleteDocumentState,
DocumentType,
GetDocumentArgs,
GetDocumentState,
NotebookDocument,
UpdateDocumentArgs,
UpdateDocumentState,
} from '../types';
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;
}
interface NotebookService {
/**
* Associate a document with a topic
*/
associateDocumentWithTopic: (documentId: string, topicId: string) => Promise<void>;
/**
* Create a new document
*/
createDocument: (params: {
content: string;
fileType: string;
source: string;
sourceType: 'api' | 'file' | 'web';
title: string;
totalCharCount: number;
totalLineCount: number;
}) => Promise<DocumentServiceResult>;
/**
* Delete a document by ID
*/
deleteDocument: (id: string) => Promise<void>;
/**
* Get a document by ID
*/
getDocument: (id: string) => Promise<DocumentServiceResult | undefined>;
/**
* Get documents by topic ID
*/
getDocumentsByTopicId: (
topicId: string,
filter?: { type?: string },
) => Promise<DocumentServiceResult[]>;
/**
* Update a document by ID
*/
updateDocument: (
id: string,
params: { content?: string; title?: string },
) => Promise<DocumentServiceResult>;
}
/**
* Transform database document to NotebookDocument format
*/
const toNotebookDocument = (doc: DocumentServiceResult): NotebookDocument => {
return {
content: doc.content || '',
createdAt: doc.createdAt.toISOString(),
description: doc.description || '',
id: doc.id,
sourceType: doc.sourceType === 'api' ? 'ai' : doc.sourceType,
title: doc.title || 'Untitled',
type: (doc.fileType as DocumentType) || 'markdown',
updatedAt: doc.updatedAt.toISOString(),
wordCount: doc.totalCharCount,
};
};
/**
* Count words in content (simple implementation)
*/
const countWords = (content: string): number => {
return content.trim().split(/\s+/).filter(Boolean).length;
};
/**
* Count lines in content
*/
const countLines = (content: string): number => {
return content.split('\n').length;
};
export class NotebookExecutionRuntime {
private notebookService: NotebookService;
constructor(notebookService: NotebookService) {
this.notebookService = notebookService;
}
/**
* Create a new document in the notebook
*/
async createDocument(
args: CreateDocumentArgs,
options?: { topicId?: string | null },
): Promise<BuiltinServerRuntimeOutput> {
try {
const { title, content, type = 'markdown' } = args;
if (!options?.topicId) {
return {
content: 'Error: No topic context. Documents must be created within a topic.',
success: false,
};
}
// Create document
const doc = await this.notebookService.createDocument({
content,
fileType: type,
source: `notebook:${options.topicId}`,
sourceType: 'api',
title,
totalCharCount: countWords(content),
totalLineCount: countLines(content),
});
// Associate with topic
await this.notebookService.associateDocumentWithTopic(doc.id, options.topicId);
const notebookDoc = toNotebookDocument(doc);
const state: CreateDocumentState = { document: notebookDoc };
return {
content: `📄 Created document: "${title}"\n\nYou can view and edit this document in the Portal sidebar.`,
state,
success: true,
};
} catch (e) {
return {
content: `Error creating document: ${(e as Error).message}`,
error: e,
success: false,
};
}
}
/**
* Update an existing document
*/
async updateDocument(args: UpdateDocumentArgs): Promise<BuiltinServerRuntimeOutput> {
try {
const { id, title, content, append } = args;
// Get existing document
const existingDoc = await this.notebookService.getDocument(id);
if (!existingDoc) {
return {
content: `Error: Document not found: ${id}`,
success: false,
};
}
// Prepare update data
const updateData: { content?: string; title?: string } = {};
if (title !== undefined) {
updateData.title = title;
}
if (content !== undefined) {
if (append && existingDoc.content) {
updateData.content = existingDoc.content + '\n\n' + content;
} else {
updateData.content = content;
}
}
const updatedDoc = await this.notebookService.updateDocument(id, updateData);
const notebookDoc = toNotebookDocument(updatedDoc);
const state: UpdateDocumentState = { document: notebookDoc };
const actionDesc = append ? 'Appended to' : 'Updated';
return {
content: `📝 ${actionDesc} document: "${notebookDoc.title}"`,
state,
success: true,
};
} catch (e) {
return {
content: `Error updating document: ${(e as Error).message}`,
error: e,
success: false,
};
}
}
/**
* Get a document by ID
*/
async getDocument(args: GetDocumentArgs): Promise<BuiltinServerRuntimeOutput> {
try {
const { id } = args;
const doc = await this.notebookService.getDocument(id);
if (!doc) {
return {
content: `Error: Document not found: ${id}`,
success: false,
};
}
const notebookDoc = toNotebookDocument(doc);
const state: GetDocumentState = { document: notebookDoc };
return {
content: `📄 Document: "${notebookDoc.title}"\n\n${notebookDoc.content}`,
state,
success: true,
};
} catch (e) {
return {
content: `Error retrieving document: ${(e as Error).message}`,
error: e,
success: false,
};
}
}
/**
* Delete a document from the notebook
*/
async deleteDocument(args: DeleteDocumentArgs): Promise<BuiltinServerRuntimeOutput> {
try {
const { id } = args;
// Verify document exists
const doc = await this.notebookService.getDocument(id);
if (!doc) {
return {
content: `Error: Document not found: ${id}`,
success: false,
};
}
await this.notebookService.deleteDocument(id);
const state: DeleteDocumentState = { deletedId: id };
return {
content: `🗑️ Deleted document: "${doc.title}"`,
state,
success: true,
};
} catch (e) {
return {
content: `Error deleting document: ${(e as Error).message}`,
error: e,
success: false,
};
}
}
}

View file

@ -0,0 +1,9 @@
import { BuiltinIntervention } from '@lobechat/types';
/**
* Notebook Tool Intervention Components Registry
*
* Intervention components allow users to review and modify tool parameters
* before the tool is executed.
*/
export const NotebookInterventions: Record<string, BuiltinIntervention> = {};

View file

@ -0,0 +1,88 @@
'use client';
import { Flexbox, Tag, Text } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { FileText, NotebookText } from 'lucide-react';
import { memo } from 'react';
import { useChatStore } from '@/store/chat';
import { NotebookDocument } from '../../../types';
const useStyles = createStyles(({ token, css }) => {
return {
container: css`
cursor: pointer;
overflow: hidden;
width: 100%;
padding-block: 12px;
padding-inline: 12px;
border: 1px solid ${token.colorBorderSecondary};
border-radius: 8px;
background: ${token.colorBgElevated};
&:hover {
background: ${token.colorFillSecondary};
}
`,
description: css`
font-size: 12px;
line-height: 1.5;
color: ${token.colorTextSecondary};
`,
icon: css`
color: ${token.colorPrimary};
`,
title: css`
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
font-weight: 500;
color: ${token.colorText};
`,
typeTag: css`
font-size: 11px;
`,
};
});
interface DocumentCardProps {
document: NotebookDocument;
}
const DocumentCard = memo<DocumentCardProps>(({ document }) => {
const { styles } = useStyles();
const openDocument = useChatStore((s) => s.openDocument);
const handleClick = () => {
openDocument(document.id);
};
return (
<Flexbox className={styles.container} gap={8} onClick={handleClick}>
<Flexbox align={'center'} gap={8} horizontal>
{document.type === 'note' ? (
<NotebookText className={styles.icon} size={16} />
) : (
<FileText className={styles.icon} size={16} />
)}
<div className={styles.title}>{document.title}</div>
<Tag className={styles.typeTag} size={'small'}>
{document.type}
</Tag>
</Flexbox>
{document.description && (
<Text className={styles.description} ellipsis={{ rows: 2 }}>
{document.description}
</Text>
)}
</Flexbox>
);
});
export default DocumentCard;

View file

@ -0,0 +1,24 @@
'use client';
import { BuiltinRenderProps } from '@lobechat/types';
import { memo } from 'react';
import { CreateDocumentArgs, CreateDocumentState } from '../../../types';
import DocumentCard from './DocumentCard';
export type CreateDocumentRenderProps = Pick<
BuiltinRenderProps<CreateDocumentArgs, CreateDocumentState>,
'pluginState'
>;
const CreateDocument = memo<CreateDocumentRenderProps>(({ pluginState }) => {
const { document } = pluginState || {};
if (!document) {
return null;
}
return <DocumentCard document={document} />;
});
export default CreateDocument;

View file

@ -0,0 +1,7 @@
import CreateDocument from './CreateDocument';
export const NotebookRenders = {
createDocument: CreateDocument,
};
export { default as CreateDocument } from './CreateDocument';

View file

@ -0,0 +1,6 @@
export { NotebookInterventions } from './Intervention';
export { CreateDocument, NotebookRenders } from './Render';
// Re-export types and manifest for convenience
export { NotebookManifest } from '../manifest';
export * from '../types';

View file

@ -0,0 +1,17 @@
export { NotebookManifest } from './manifest';
export { systemPrompt } from './systemRole';
export {
type CreateDocumentArgs,
type CreateDocumentState,
type DeleteDocumentArgs,
type DeleteDocumentState,
type DocumentSourceType,
type DocumentType,
type GetDocumentArgs,
type GetDocumentState,
NotebookApiName,
type NotebookDocument,
NotebookIdentifier,
type UpdateDocumentArgs,
type UpdateDocumentState,
} from './types';

View file

@ -0,0 +1,102 @@
import { BuiltinToolManifest } from '@lobechat/types';
import { systemPrompt } from './systemRole';
import { NotebookApiName, NotebookIdentifier } from './types';
export const NotebookManifest: BuiltinToolManifest = {
api: [
{
description:
'Create a new document in the notebook. Use this to save reports, notes, articles, or any content that should persist in the topic.',
name: NotebookApiName.createDocument,
parameters: {
properties: {
content: {
description: 'The document content in Markdown format',
type: 'string',
},
description: {
description: 'A brief summary of the document (1-2 sentences)',
type: 'string',
},
title: {
description: 'A descriptive title for the document',
type: 'string',
},
type: {
default: 'markdown',
description: 'The type of document',
enum: ['markdown', 'note', 'report', 'article'],
type: 'string',
},
},
required: ['title', 'description', 'content'],
type: 'object',
},
},
{
description:
'Update an existing document. Can modify title, content, or append new content to existing document.',
name: NotebookApiName.updateDocument,
parameters: {
properties: {
append: {
default: false,
description: 'If true, append content to existing document instead of replacing',
type: 'boolean',
},
content: {
description: 'New content for the document',
type: 'string',
},
id: {
description: 'The document ID to update',
type: 'string',
},
title: {
description: 'New title for the document',
type: 'string',
},
},
required: ['id'],
type: 'object',
},
},
{
description: 'Retrieve the full content of a specific document from the notebook.',
name: NotebookApiName.getDocument,
parameters: {
properties: {
id: {
description: 'The document ID to retrieve',
type: 'string',
},
},
required: ['id'],
type: 'object',
},
},
{
description: 'Delete a document from the notebook. This action cannot be undone.',
name: NotebookApiName.deleteDocument,
parameters: {
properties: {
id: {
description: 'The document ID to delete',
type: 'string',
},
},
required: ['id'],
type: 'object',
},
},
],
identifier: NotebookIdentifier,
meta: {
avatar: '📓',
description: 'Create and manage documents in the topic notebook',
title: 'Notebook',
},
systemRole: systemPrompt,
type: 'builtin',
};

View file

@ -0,0 +1,50 @@
export const systemPrompt = `You have access to the Notebook tool for creating and managing documents in the current topic's notebook.
<tool_overview>
**Notebook** is your external storage for this conversation topic.
- createDocument: Save a new document to the notebook
- updateDocument: Edit an existing document
- getDocument: Read a document's full content
- deleteDocument: Remove a document
Note: The list of existing documents is automatically provided in the context, so you don't need to query for it.
</tool_overview>
<when_to_use>
**Save to Notebook when**:
- Creating reports, analyses, or summaries that should persist
- User explicitly asks to "save", "write down", or "document" something
- Generating structured content like articles, notes, or reports
- Web browsing results worth keeping for later reference
- Any content the user might want to review or edit later
**Document Types**:
- markdown: General formatted text (default)
- note: Quick notes and memos
- report: Structured reports and analyses
- article: Long-form content and articles
</when_to_use>
<workflow>
1. When creating content that should persist, use createDocument
2. For incremental updates, use updateDocument with append=true
3. Review the provided document list to check existing documents
4. Use getDocument to retrieve full content when needed
5. Use deleteDocument only when user explicitly requests removal
</workflow>
<best_practices>
- Use descriptive titles that summarize the content
- Choose appropriate document types based on content nature
- For long content, consider breaking into multiple documents
- Use append mode when adding to existing documents
- Always confirm before deleting documents
</best_practices>
<response_format>
After creating/updating documents:
- Briefly confirm the action: "Saved to Notebook: [title]"
- Don't repeat the full content in your response
- Mention that user can view/edit in the Portal sidebar
</response_format>
`;

View file

@ -0,0 +1,65 @@
export const NotebookIdentifier = 'lobe-notebook';
export const NotebookApiName = {
createDocument: 'createDocument',
deleteDocument: 'deleteDocument',
getDocument: 'getDocument',
updateDocument: 'updateDocument',
} as const;
export type DocumentType = 'article' | 'markdown' | 'note' | 'report';
export type DocumentSourceType = 'ai' | 'file' | 'user' | 'web';
export interface NotebookDocument {
content: string;
createdAt: string;
description: string;
id: string;
sourceType: DocumentSourceType;
title: string;
type: DocumentType;
updatedAt: string;
wordCount: number;
}
// ==================== API Arguments ====================
export interface CreateDocumentArgs {
content: string;
description: string;
title: string;
type?: DocumentType;
}
export interface UpdateDocumentArgs {
append?: boolean;
content?: string;
id: string;
title?: string;
}
export interface GetDocumentArgs {
id: string;
}
export interface DeleteDocumentArgs {
id: string;
}
// ==================== API States ====================
export interface CreateDocumentState {
document: NotebookDocument;
}
export interface UpdateDocumentState {
document: NotebookDocument;
}
export interface GetDocumentState {
document: NotebookDocument;
}
export interface DeleteDocumentState {
deletedId: string;
}

View file

@ -0,0 +1,4 @@
ALTER TABLE "documents" ADD COLUMN IF NOT EXISTS "description" text;--> statement-breakpoint
ALTER TABLE "documents" ADD COLUMN IF NOT EXISTS "knowledge_base_id" text;--> statement-breakpoint
ALTER TABLE "documents" ADD CONSTRAINT "documents_knowledge_base_id_knowledge_bases_id_fk" FOREIGN KEY ("knowledge_base_id") REFERENCES "public"."knowledge_bases"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "documents_knowledge_base_id_idx" ON "documents" USING btree ("knowledge_base_id");

View file

@ -0,0 +1,392 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { documents, sessions, topicDocuments, topics, users } from '../../schemas';
import { LobeChatDatabase } from '../../type';
import { DocumentModel } from '../document';
import { TopicDocumentModel } from '../topicDocument';
import { getTestDB } from './_util';
const serverDB: LobeChatDatabase = await getTestDB();
const userId = 'topic-document-model-test-user-id';
const userId2 = 'topic-document-model-test-user-id-2';
const sessionId = 'topic-document-session';
const topicId = 'topic-document-topic';
const topicId2 = 'topic-document-topic-2';
const topicDocumentModel = new TopicDocumentModel(serverDB, userId);
const topicDocumentModel2 = new TopicDocumentModel(serverDB, userId2);
const documentModel = new DocumentModel(serverDB, userId);
const documentModel2 = new DocumentModel(serverDB, userId2);
// Helper to create a test document
const createTestDocument = async (model: DocumentModel, title: string, fileType = 'markdown') => {
return model.create({
content: `Content for ${title}`,
fileType,
source: `notebook:${topicId}`,
sourceType: 'api',
title,
totalCharCount: 100,
totalLineCount: 5,
});
};
describe('TopicDocumentModel', () => {
beforeEach(async () => {
await serverDB.delete(users);
// Create test users, session and topics
await serverDB.transaction(async (tx) => {
await tx.insert(users).values([{ id: userId }, { id: userId2 }]);
await tx.insert(sessions).values({ id: sessionId, userId });
await tx.insert(topics).values([
{ id: topicId, sessionId, userId },
{ id: topicId2, sessionId, userId },
]);
});
});
afterEach(async () => {
await serverDB.delete(topicDocuments);
await serverDB.delete(documents);
await serverDB.delete(topics);
await serverDB.delete(sessions);
await serverDB.delete(users);
});
describe('associate', () => {
it('should associate a document with a topic', async () => {
const doc = await createTestDocument(documentModel, 'Test Document');
const result = await topicDocumentModel.associate({
documentId: doc.id,
topicId,
});
expect(result.documentId).toBe(doc.id);
expect(result.topicId).toBe(topicId);
});
it('should associate the same document with multiple topics', async () => {
const doc = await createTestDocument(documentModel, 'Shared Document');
await topicDocumentModel.associate({ documentId: doc.id, topicId });
await topicDocumentModel.associate({ documentId: doc.id, topicId: topicId2 });
const topicIds = await topicDocumentModel.findByDocumentId(doc.id);
expect(topicIds).toHaveLength(2);
expect(topicIds).toContain(topicId);
expect(topicIds).toContain(topicId2);
});
it('should associate multiple documents with the same topic', async () => {
const doc1 = await createTestDocument(documentModel, 'Document 1');
const doc2 = await createTestDocument(documentModel, 'Document 2');
await topicDocumentModel.associate({ documentId: doc1.id, topicId });
await topicDocumentModel.associate({ documentId: doc2.id, topicId });
const docs = await topicDocumentModel.findByTopicId(topicId);
expect(docs).toHaveLength(2);
});
});
describe('disassociate', () => {
it('should remove association between document and topic', async () => {
const doc = await createTestDocument(documentModel, 'Test Document');
await topicDocumentModel.associate({ documentId: doc.id, topicId });
await topicDocumentModel.disassociate(doc.id, topicId);
const isStillAssociated = await topicDocumentModel.isAssociated(doc.id, topicId);
expect(isStillAssociated).toBe(false);
});
it('should not affect other associations', async () => {
const doc = await createTestDocument(documentModel, 'Shared Document');
await topicDocumentModel.associate({ documentId: doc.id, topicId });
await topicDocumentModel.associate({ documentId: doc.id, topicId: topicId2 });
await topicDocumentModel.disassociate(doc.id, topicId);
const isAssociatedWithTopic1 = await topicDocumentModel.isAssociated(doc.id, topicId);
const isAssociatedWithTopic2 = await topicDocumentModel.isAssociated(doc.id, topicId2);
expect(isAssociatedWithTopic1).toBe(false);
expect(isAssociatedWithTopic2).toBe(true);
});
it('should not affect associations of other users', async () => {
// Create document and topic for user2
await serverDB.insert(topics).values({ id: 'user2-topic', sessionId, userId: userId2 });
const doc2 = await documentModel2.create({
content: 'User 2 content',
fileType: 'markdown',
source: 'notebook:user2-topic',
sourceType: 'api',
title: 'User 2 Doc',
totalCharCount: 50,
totalLineCount: 2,
});
await topicDocumentModel2.associate({ documentId: doc2.id, topicId: 'user2-topic' });
// User 1 tries to disassociate user 2's document
await topicDocumentModel.disassociate(doc2.id, 'user2-topic');
// User 2's association should still exist
const isStillAssociated = await topicDocumentModel2.isAssociated(doc2.id, 'user2-topic');
expect(isStillAssociated).toBe(true);
});
});
describe('findByTopicId', () => {
it('should return all documents associated with a topic', async () => {
const doc1 = await createTestDocument(documentModel, 'Document 1');
const doc2 = await createTestDocument(documentModel, 'Document 2');
await topicDocumentModel.associate({ documentId: doc1.id, topicId });
await topicDocumentModel.associate({ documentId: doc2.id, topicId });
const docs = await topicDocumentModel.findByTopicId(topicId);
expect(docs).toHaveLength(2);
expect(docs.map((d) => d.title)).toContain('Document 1');
expect(docs.map((d) => d.title)).toContain('Document 2');
});
it('should return documents with associatedAt timestamp', async () => {
const doc = await createTestDocument(documentModel, 'Test Document');
await topicDocumentModel.associate({ documentId: doc.id, topicId });
const docs = await topicDocumentModel.findByTopicId(topicId);
expect(docs).toHaveLength(1);
expect(docs[0].associatedAt).toBeInstanceOf(Date);
});
it('should filter documents by type', async () => {
const markdownDoc = await createTestDocument(documentModel, 'Markdown Doc', 'markdown');
const reportDoc = await createTestDocument(documentModel, 'Report Doc', 'report');
await topicDocumentModel.associate({ documentId: markdownDoc.id, topicId });
await topicDocumentModel.associate({ documentId: reportDoc.id, topicId });
const markdownDocs = await topicDocumentModel.findByTopicId(topicId, { type: 'markdown' });
const reportDocs = await topicDocumentModel.findByTopicId(topicId, { type: 'report' });
expect(markdownDocs).toHaveLength(1);
expect(markdownDocs[0].title).toBe('Markdown Doc');
expect(reportDocs).toHaveLength(1);
expect(reportDocs[0].title).toBe('Report Doc');
});
it('should return documents ordered by createdAt desc', async () => {
const doc1 = await createTestDocument(documentModel, 'First Document');
await new Promise((resolve) => setTimeout(resolve, 50));
const doc2 = await createTestDocument(documentModel, 'Second Document');
await topicDocumentModel.associate({ documentId: doc1.id, topicId });
await new Promise((resolve) => setTimeout(resolve, 50));
await topicDocumentModel.associate({ documentId: doc2.id, topicId });
const docs = await topicDocumentModel.findByTopicId(topicId);
expect(docs).toHaveLength(2);
// Most recently associated should be first
expect(docs[0].title).toBe('Second Document');
expect(docs[1].title).toBe('First Document');
});
it('should return empty array for topic with no documents', async () => {
const docs = await topicDocumentModel.findByTopicId(topicId);
expect(docs).toHaveLength(0);
});
it('should only return documents for the current user', async () => {
const doc = await createTestDocument(documentModel, 'User 1 Doc');
await topicDocumentModel.associate({ documentId: doc.id, topicId });
// User 2 tries to find documents in user 1's topic
const docsForUser2 = await topicDocumentModel2.findByTopicId(topicId);
expect(docsForUser2).toHaveLength(0);
});
});
describe('findByDocumentId', () => {
it('should return all topic IDs associated with a document', async () => {
const doc = await createTestDocument(documentModel, 'Shared Document');
await topicDocumentModel.associate({ documentId: doc.id, topicId });
await topicDocumentModel.associate({ documentId: doc.id, topicId: topicId2 });
const topicIds = await topicDocumentModel.findByDocumentId(doc.id);
expect(topicIds).toHaveLength(2);
expect(topicIds).toContain(topicId);
expect(topicIds).toContain(topicId2);
});
it('should return empty array for document with no associations', async () => {
const doc = await createTestDocument(documentModel, 'Unassociated Document');
const topicIds = await topicDocumentModel.findByDocumentId(doc.id);
expect(topicIds).toHaveLength(0);
});
it('should only return associations for the current user', async () => {
const doc = await createTestDocument(documentModel, 'User 1 Doc');
await topicDocumentModel.associate({ documentId: doc.id, topicId });
// User 2 tries to find associations
const topicIdsForUser2 = await topicDocumentModel2.findByDocumentId(doc.id);
expect(topicIdsForUser2).toHaveLength(0);
});
});
describe('isAssociated', () => {
it('should return true when document is associated with topic', async () => {
const doc = await createTestDocument(documentModel, 'Test Document');
await topicDocumentModel.associate({ documentId: doc.id, topicId });
const isAssociated = await topicDocumentModel.isAssociated(doc.id, topicId);
expect(isAssociated).toBe(true);
});
it('should return false when document is not associated with topic', async () => {
const doc = await createTestDocument(documentModel, 'Test Document');
const isAssociated = await topicDocumentModel.isAssociated(doc.id, topicId);
expect(isAssociated).toBe(false);
});
it('should return false for non-existent document', async () => {
const isAssociated = await topicDocumentModel.isAssociated('non-existent-doc', topicId);
expect(isAssociated).toBe(false);
});
it('should return false for non-existent topic', async () => {
const doc = await createTestDocument(documentModel, 'Test Document');
await topicDocumentModel.associate({ documentId: doc.id, topicId });
const isAssociated = await topicDocumentModel.isAssociated(doc.id, 'non-existent-topic');
expect(isAssociated).toBe(false);
});
it('should respect user isolation', async () => {
const doc = await createTestDocument(documentModel, 'User 1 Doc');
await topicDocumentModel.associate({ documentId: doc.id, topicId });
// User 2 checks if document is associated
const isAssociatedForUser2 = await topicDocumentModel2.isAssociated(doc.id, topicId);
expect(isAssociatedForUser2).toBe(false);
});
});
describe('deleteByTopicId', () => {
it('should remove all associations for a topic', async () => {
const doc1 = await createTestDocument(documentModel, 'Document 1');
const doc2 = await createTestDocument(documentModel, 'Document 2');
await topicDocumentModel.associate({ documentId: doc1.id, topicId });
await topicDocumentModel.associate({ documentId: doc2.id, topicId });
await topicDocumentModel.deleteByTopicId(topicId);
const docs = await topicDocumentModel.findByTopicId(topicId);
expect(docs).toHaveLength(0);
});
it('should not affect associations in other topics', async () => {
const doc = await createTestDocument(documentModel, 'Shared Document');
await topicDocumentModel.associate({ documentId: doc.id, topicId });
await topicDocumentModel.associate({ documentId: doc.id, topicId: topicId2 });
await topicDocumentModel.deleteByTopicId(topicId);
const isAssociatedWithTopic1 = await topicDocumentModel.isAssociated(doc.id, topicId);
const isAssociatedWithTopic2 = await topicDocumentModel.isAssociated(doc.id, topicId2);
expect(isAssociatedWithTopic1).toBe(false);
expect(isAssociatedWithTopic2).toBe(true);
});
it('should not affect other users associations', async () => {
// Create topic for user 2
await serverDB.insert(topics).values({ id: 'user2-topic', sessionId, userId: userId2 });
const doc2 = await documentModel2.create({
content: 'User 2 content',
fileType: 'markdown',
source: 'notebook:user2-topic',
sourceType: 'api',
title: 'User 2 Doc',
totalCharCount: 50,
totalLineCount: 2,
});
await topicDocumentModel2.associate({ documentId: doc2.id, topicId: 'user2-topic' });
// User 1 deletes their topic associations
const doc1 = await createTestDocument(documentModel, 'User 1 Doc');
await topicDocumentModel.associate({ documentId: doc1.id, topicId });
await topicDocumentModel.deleteByTopicId(topicId);
// User 2's associations should remain
const isUser2Associated = await topicDocumentModel2.isAssociated(doc2.id, 'user2-topic');
expect(isUser2Associated).toBe(true);
});
});
describe('deleteByDocumentId', () => {
it('should remove all associations for a document', async () => {
const doc = await createTestDocument(documentModel, 'Shared Document');
await topicDocumentModel.associate({ documentId: doc.id, topicId });
await topicDocumentModel.associate({ documentId: doc.id, topicId: topicId2 });
await topicDocumentModel.deleteByDocumentId(doc.id);
const topicIds = await topicDocumentModel.findByDocumentId(doc.id);
expect(topicIds).toHaveLength(0);
});
it('should not affect other documents associations', async () => {
const doc1 = await createTestDocument(documentModel, 'Document 1');
const doc2 = await createTestDocument(documentModel, 'Document 2');
await topicDocumentModel.associate({ documentId: doc1.id, topicId });
await topicDocumentModel.associate({ documentId: doc2.id, topicId });
await topicDocumentModel.deleteByDocumentId(doc1.id);
const isDoc1Associated = await topicDocumentModel.isAssociated(doc1.id, topicId);
const isDoc2Associated = await topicDocumentModel.isAssociated(doc2.id, topicId);
expect(isDoc1Associated).toBe(false);
expect(isDoc2Associated).toBe(true);
});
it('should not affect other users associations', async () => {
// Create document for user 2
await serverDB.insert(topics).values({ id: 'user2-topic', sessionId, userId: userId2 });
const doc2 = await documentModel2.create({
content: 'User 2 content',
fileType: 'markdown',
source: 'notebook:user2-topic',
sourceType: 'api',
title: 'User 2 Doc',
totalCharCount: 50,
totalLineCount: 2,
});
await topicDocumentModel2.associate({ documentId: doc2.id, topicId: 'user2-topic' });
// User 1 tries to delete associations for user 2's document
await topicDocumentModel.deleteByDocumentId(doc2.id);
// User 2's association should remain
const isUser2Associated = await topicDocumentModel2.isAssociated(doc2.id, 'user2-topic');
expect(isUser2Associated).toBe(true);
});
});
});

View file

@ -0,0 +1,125 @@
import { and, desc, eq } from 'drizzle-orm';
import { DocumentItem, NewTopicDocument, documents, topicDocuments } from '../schemas';
import { LobeChatDatabase } from '../type';
export interface TopicDocumentWithDetails extends DocumentItem {
associatedAt: Date;
}
export class TopicDocumentModel {
private userId: string;
private db: LobeChatDatabase;
constructor(db: LobeChatDatabase, userId: string) {
this.userId = userId;
this.db = db;
}
/**
* Associate a document with a topic
*/
associate = async (
params: Omit<NewTopicDocument, 'userId'>,
): Promise<{ documentId: string; topicId: string }> => {
const [result] = await this.db
.insert(topicDocuments)
.values({ ...params, userId: this.userId })
.returning();
return { documentId: result.documentId, topicId: result.topicId };
};
/**
* Remove association between a document and a topic
*/
disassociate = async (documentId: string, topicId: string) => {
return this.db
.delete(topicDocuments)
.where(
and(
eq(topicDocuments.documentId, documentId),
eq(topicDocuments.topicId, topicId),
eq(topicDocuments.userId, this.userId),
),
);
};
/**
* Get all documents associated with a topic
*/
findByTopicId = async (
topicId: string,
filter?: { type?: string },
): Promise<TopicDocumentWithDetails[]> => {
const results = await this.db
.select({
associatedAt: topicDocuments.createdAt,
document: documents,
})
.from(topicDocuments)
.innerJoin(documents, eq(topicDocuments.documentId, documents.id))
.where(
and(
eq(topicDocuments.topicId, topicId),
eq(topicDocuments.userId, this.userId),
filter?.type ? eq(documents.fileType, filter.type) : undefined,
),
)
.orderBy(desc(topicDocuments.createdAt));
return results.map((r) => ({
...r.document,
associatedAt: r.associatedAt,
}));
};
/**
* Get all topics associated with a document
*/
findByDocumentId = async (documentId: string): Promise<string[]> => {
const results = await this.db
.select({ topicId: topicDocuments.topicId })
.from(topicDocuments)
.where(
and(eq(topicDocuments.documentId, documentId), eq(topicDocuments.userId, this.userId)),
);
return results.map((r) => r.topicId);
};
/**
* Check if a document is associated with a topic
*/
isAssociated = async (documentId: string, topicId: string): Promise<boolean> => {
const result = await this.db.query.topicDocuments.findFirst({
where: and(
eq(topicDocuments.documentId, documentId),
eq(topicDocuments.topicId, topicId),
eq(topicDocuments.userId, this.userId),
),
});
return !!result;
};
/**
* Remove all associations for a topic
*/
deleteByTopicId = async (topicId: string) => {
return this.db
.delete(topicDocuments)
.where(and(eq(topicDocuments.topicId, topicId), eq(topicDocuments.userId, this.userId)));
};
/**
* Remove all associations for a document
*/
deleteByDocumentId = async (documentId: string) => {
return this.db
.delete(topicDocuments)
.where(
and(eq(topicDocuments.documentId, documentId), eq(topicDocuments.userId, this.userId)),
);
};
}

View file

@ -187,3 +187,54 @@ export enum DocumentSourceType {
*/
WEB = 'web',
}
/**
* Notebook document type for topic-associated documents
*/
export type NotebookDocumentType = 'article' | 'markdown' | 'note' | 'report';
/**
* Notebook document - a document associated with a topic
*/
export interface NotebookDocument {
/**
* When the document was associated with the topic
*/
associatedAt: Date;
/**
* Document content
*/
content: string | null;
/**
* Document creation timestamp
*/
createdAt: Date;
/**
* Brief summary of the document (1-2 sentences)
*/
description: string | null;
/**
* Document type
*/
fileType: string;
/**
* Document ID
*/
id: string;
/**
* Document title
*/
title: string | null;
/**
* Total character count
*/
totalCharCount: number;
/**
* Total line count
*/
totalLineCount: number;
/**
* Document last modified timestamp
*/
updatedAt: Date;
}

View file

@ -0,0 +1,32 @@
'use client';
import { DESKTOP_HEADER_ICON_SIZE, MOBILE_HEADER_ICON_SIZE } from '@lobechat/const';
import { ActionIcon } from '@lobehub/ui';
import { NotebookIcon, NotebookTabsIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
interface NotebookButtonProps {
mobile?: boolean;
}
const NotebookButton = memo<NotebookButtonProps>(({ mobile }) => {
const { t } = useTranslation('portal');
const [showNotebook, toggleNotebook] = useChatStore((s) => [s.showNotebook, s.toggleNotebook]);
return (
<ActionIcon
icon={showNotebook ? NotebookTabsIcon : NotebookIcon}
onClick={() => toggleNotebook()}
size={mobile ? MOBILE_HEADER_ICON_SIZE : DESKTOP_HEADER_ICON_SIZE}
title={t('notebook.title')}
tooltipProps={{
placement: 'bottom',
}}
/>
);
});
export default NotebookButton;

View file

@ -7,6 +7,7 @@ import { memo } from 'react';
import NavHeader from '@/features/NavHeader';
import WideScreenButton from '../../../../../../../features/WideScreenContainer/WideScreenButton';
import NotebookButton from './NotebookButton';
import ShareButton from './ShareButton';
import Tags from './Tags';
@ -22,6 +23,7 @@ const Header = memo(() => {
right={
<Flexbox horizontal style={{ backgroundColor: theme.colorBgContainer }}>
<WideScreenButton />
<NotebookButton />
<ShareButton />
</Flexbox>
}

View file

@ -2,6 +2,8 @@
import { type ReactNode, memo, useCallback } from 'react';
import { useFetchNotebookDocuments } from '@/hooks/useFetchNotebookDocuments';
import WideScreenContainer from '../../WideScreenContainer';
import MessageItem from '../Messages';
import { MessageActionProvider } from '../Messages/Contexts/MessageActionProvider';
@ -33,6 +35,9 @@ const ChatList = memo<ChatListProps>(({ welcome, itemContent }) => {
]);
useFetchMessages(context, skipFetch);
// Fetch notebook documents when topic is selected
useFetchNotebookDocuments(context.topicId!);
// Use selectors for data
const displayMessageIds = useConversationStore(dataSelectors.displayMessageIds);

View file

@ -0,0 +1,42 @@
'use client';
import { Alert, Highlighter } from '@lobehub/ui';
import { ErrorInfo, memo } from 'react';
interface ErrorDisplayProps {
apiName?: string;
error: Error | null;
errorInfo: ErrorInfo | null;
identifier?: string;
}
/**
* Error display component using @lobehub/ui Alert
*/
export const ErrorDisplay = memo<ErrorDisplayProps>(({ error, identifier, apiName }) => {
const title = identifier ? `${identifier}${apiName ? ` / ${apiName}` : ''}` : 'Tool Render Error';
return (
<Alert
extra={
error?.stack ? (
<Highlighter actionIconSize="small" language="plaintext" padding={8} variant="borderless">
{error.stack}
</Highlighter>
) : undefined
}
extraIsolate={false}
message={error?.message || 'An unknown error occurred'}
showIcon
style={{
overflow: 'hidden',
position: 'relative',
width: '100%',
}}
title={title}
type="secondary"
/>
);
});
ErrorDisplay.displayName = 'ToolErrorDisplay';

View file

@ -0,0 +1,73 @@
'use client';
import { Component, ErrorInfo, ReactNode } from 'react';
import { ErrorDisplay } from './ErrorResult';
interface ToolErrorBoundaryProps {
/**
* API name being called
*/
apiName?: string;
children: ReactNode;
/**
* Identifier of the tool (e.g., plugin name)
*/
identifier?: string;
}
interface ToolErrorBoundaryState {
error: Error | null;
errorInfo: ErrorInfo | null;
hasError: boolean;
}
/**
* ErrorBoundary for Tool rendering components.
* Catches rendering errors in tool UI and displays a fallback error UI
* instead of crashing the entire chat interface.
*/
class ToolErrorBoundary extends Component<ToolErrorBoundaryProps, ToolErrorBoundaryState> {
public state: ToolErrorBoundaryState = {
error: null,
errorInfo: null,
hasError: false,
};
public static getDerivedStateFromError(error: Error): Partial<ToolErrorBoundaryState> {
return { error, hasError: true };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Only log, don't setState to avoid re-render loop
// errorInfo is captured here for logging but we use the error from getDerivedStateFromError
console.error('[ToolErrorBoundary] Caught error in tool render:', {
apiName: this.props.apiName,
componentStack: errorInfo.componentStack,
error: error.message,
identifier: this.props.identifier,
});
// Store errorInfo without triggering re-render if already has error
if (!this.state.errorInfo) {
this.setState({ errorInfo });
}
}
public render() {
if (this.state.hasError) {
return (
<ErrorDisplay
apiName={this.props.apiName}
error={this.state.error}
errorInfo={this.state.errorInfo}
identifier={this.props.identifier}
/>
);
}
return this.props.children;
}
}
export { ToolErrorBoundary };

View file

@ -1,5 +1,5 @@
import { UIChatMessage } from '@lobechat/types';
import { Alert, Flexbox , Button } from '@lobehub/ui';
import { Alert, Button, Flexbox } from '@lobehub/ui';
import isEqual from 'fast-deep-equal';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';

View file

@ -2,6 +2,8 @@ import { LobeToolRenderType } from '@lobechat/types';
import { PluginRequestPayload } from '@lobehub/chat-plugin-sdk';
import { memo } from 'react';
import { ToolErrorBoundary } from '@/features/Conversation/Messages/Tool/ErrorBoundary';
import BuiltinType from './BuiltinType';
import DefaultType from './DefaultType';
import MCP from './MCPType';
@ -40,54 +42,65 @@ const PluginRender = memo<PluginRenderProps>(
loading,
pluginError,
}) => {
switch (type) {
case 'standalone': {
return (
<Standalone id={toolCallId || messageId || ''} name={identifier} payload={payload} />
);
}
const renderContent = () => {
switch (type) {
case 'standalone': {
return (
<Standalone id={toolCallId || messageId || ''} name={identifier} payload={payload} />
);
}
case 'builtin': {
return (
<BuiltinType
apiName={payload?.apiName}
arguments={argumentsStr}
content={content}
identifier={identifier}
loading={loading}
messageId={messageId}
pluginError={pluginError}
pluginState={pluginState}
toolCallId={toolCallId}
/>
);
}
case 'builtin': {
return (
<BuiltinType
apiName={payload?.apiName}
arguments={argumentsStr}
content={content}
identifier={identifier}
loading={loading}
messageId={messageId}
pluginError={pluginError}
pluginState={pluginState}
toolCallId={toolCallId}
/>
);
}
// @ts-expect-error need to update types
case 'mcp': {
return (
<MCP
apiName={payload?.apiName}
arguments={argumentsStr}
content={content}
identifier={identifier}
loading={loading}
messageId={messageId}
pluginError={pluginError}
pluginState={pluginState}
toolCallId={toolCallId}
/>
);
}
// @ts-expect-error need to update types
case 'mcp': {
return (
<MCP
apiName={payload?.apiName}
arguments={argumentsStr}
content={content}
identifier={identifier}
loading={loading}
messageId={messageId}
pluginError={pluginError}
pluginState={pluginState}
toolCallId={toolCallId}
/>
);
}
case 'markdown': {
return <Markdown content={content} loading={loading} />;
}
case 'markdown': {
return <Markdown content={content} loading={loading} />;
}
default: {
return <DefaultType content={content} loading={loading} name={identifier} />;
default: {
return <DefaultType content={content} loading={loading} name={identifier} />;
}
}
}
};
// Use stable key to prevent ErrorBoundary from resetting on parent re-renders
const boundaryKey = `${identifier}-${payload?.apiName}-${toolCallId || messageId}`;
return (
<ToolErrorBoundary apiName={payload?.apiName} identifier={identifier} key={boundaryKey}>
{renderContent()}
</ToolErrorBoundary>
);
},
);

View file

@ -0,0 +1,47 @@
'use client';
import { Flexbox, Markdown } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
import { useNotebookStore } from '@/store/notebook';
import { notebookSelectors } from '@/store/notebook/selectors';
const useStyles = createStyles(({ token, css }) => ({
content: css`
overflow: auto;
flex: 1;
padding-inline: 12px;
`,
description: css`
padding: 12px;
border-block-end: 1px solid ${token.colorBorderSecondary};
font-size: 13px;
color: ${token.colorTextSecondary};
`,
}));
const DocumentBody = memo(() => {
const { styles } = useStyles();
const [topicId, documentId] = useChatStore((s) => [
s.activeTopicId,
chatPortalSelectors.portalDocumentId(s),
]);
const document = useNotebookStore(notebookSelectors.getDocumentById(topicId, documentId));
if (!document) return null;
return (
<Flexbox flex={1} height={'100%'} style={{ overflow: 'hidden' }}>
<div className={styles.content}>
<Markdown>{document.content || ''}</Markdown>
</div>
</Flexbox>
);
});
export default DocumentBody;

View file

@ -0,0 +1,36 @@
'use client';
import { ActionIcon, Flexbox, Text } from '@lobehub/ui';
import { cx } from 'antd-style';
import { ArrowLeft } from 'lucide-react';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
import { useNotebookStore } from '@/store/notebook';
import { notebookSelectors } from '@/store/notebook/selectors';
import { oneLineEllipsis } from '@/styles';
const Header = () => {
const [topicId, documentId, closeDocument] = useChatStore((s) => [
s.activeTopicId,
chatPortalSelectors.portalDocumentId(s),
s.closeDocument,
]);
const document = useNotebookStore(notebookSelectors.getDocumentById(topicId, documentId));
if (!document) return null;
return (
<Flexbox align={'center'} flex={1} gap={12} horizontal justify={'space-between'} width={'100%'}>
<Flexbox align={'center'} gap={4} horizontal>
<ActionIcon icon={ArrowLeft} onClick={closeDocument} size={'small'} />
<Text className={cx(oneLineEllipsis)} type={'secondary'}>
{document.title}
</Text>
</Flexbox>
</Flexbox>
);
};
export default Header;

View file

@ -0,0 +1,10 @@
import { PortalImpl } from '../type';
import Body from './Body';
import Header from './Header';
import { useEnable } from './useEnable';
export const Document: PortalImpl = {
Body,
Title: Header,
useEnable,
};

View file

@ -0,0 +1,8 @@
'use client';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
export const useEnable = () => {
return useChatStore(chatPortalSelectors.showDocument);
};

View file

@ -0,0 +1,87 @@
'use client';
import { Avatar, Center, Flexbox, Icon, Text } from '@lobehub/ui';
import { Spin } from 'antd';
import { useTheme } from 'antd-style';
import { BookOpenIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import Balancer from 'react-wrap-balancer';
import { useFetchNotebookDocuments } from '@/hooks/useFetchNotebookDocuments';
import { useChatStore } from '@/store/chat';
import DocumentItem from './DocumentItem';
const NotebookBody = memo(() => {
const { t } = useTranslation('portal');
const theme = useTheme();
const topicId = useChatStore((s) => s.activeTopicId);
const { documents, isLoading } = useFetchNotebookDocuments(topicId);
// Show message when no topic is selected
if (!topicId) {
return (
<Center
flex={1}
gap={8}
paddingBlock={24}
style={{ border: `1px dashed ${theme.colorSplit}`, borderRadius: 8, marginInline: 12 }}
>
<Avatar
avatar={<Icon icon={BookOpenIcon} size={'large'} />}
background={theme.colorFillTertiary}
shape={'square'}
size={48}
/>
<Balancer>
<Text type={'secondary'}>{t('notebook.empty')}</Text>
</Balancer>
</Center>
);
}
// Show loading state
if (isLoading) {
return (
<Center flex={1}>
<Spin />
</Center>
);
}
// Show empty state
if (documents.length === 0) {
return (
<Center
flex={1}
gap={8}
paddingBlock={24}
style={{ border: `1px dashed ${theme.colorSplit}`, borderRadius: 8, marginInline: 12 }}
>
<Avatar
avatar={<Icon icon={BookOpenIcon} size={'large'} />}
background={theme.colorFillTertiary}
shape={'square'}
size={48}
/>
<Balancer>
<Text style={{ textAlign: 'center' }} type={'secondary'}>
{t('notebook.empty')}
</Text>
</Balancer>
</Center>
);
}
// Render document list
return (
<Flexbox gap={8} height={'100%'} paddingInline={12} style={{ overflow: 'auto' }}>
{documents.map((doc) => (
<DocumentItem document={doc} key={doc.id} topicId={topicId} />
))}
</Flexbox>
);
});
export default NotebookBody;

View file

@ -0,0 +1,95 @@
import { NotebookDocument } from '@lobechat/types';
import { ActionIcon, Flexbox, Text } from '@lobehub/ui';
import { App } from 'antd';
import { createStyles } from 'antd-style';
import { FileTextIcon, Trash2Icon } from 'lucide-react';
import { MouseEvent, memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
import { useNotebookStore } from '@/store/notebook';
const useStyles = createStyles(({ token, css }) => ({
container: css`
cursor: pointer;
padding: 12px;
border-radius: 8px;
background: ${token.colorFillTertiary};
&:hover {
background: ${token.colorFillSecondary};
}
`,
description: css`
font-size: 12px;
line-height: 1.5;
color: ${token.colorTextSecondary};
`,
title: css`
font-weight: 500;
`,
}));
interface DocumentItemProps {
document: NotebookDocument;
topicId: string;
}
const DocumentItem = memo<DocumentItemProps>(({ document, topicId }) => {
const { t } = useTranslation('portal');
const { styles } = useStyles();
const { modal } = App.useApp();
const [deleting, setDeleting] = useState(false);
const openDocument = useChatStore((s) => s.openDocument);
const deleteDocument = useNotebookStore((s) => s.deleteDocument);
const handleClick = () => {
openDocument(document.id);
};
const handleDelete = async (e: MouseEvent) => {
e.stopPropagation();
modal.confirm({
centered: true,
okButtonProps: { danger: true },
onOk: async () => {
setDeleting(true);
try {
await deleteDocument(document.id, topicId);
} finally {
setDeleting(false);
}
},
title: t('notebook.confirmDelete'),
});
};
return (
<Flexbox className={styles.container} gap={8} horizontal onClick={handleClick}>
<FileTextIcon size={16} />
<Flexbox gap={4} style={{ flex: 1, minWidth: 0 }}>
<Flexbox align={'center'} distribution={'space-between'} horizontal>
<Text className={styles.title} ellipsis>
{document.title}
</Text>
<ActionIcon
icon={Trash2Icon}
loading={deleting}
onClick={handleDelete}
size={'small'}
title={t('notebook.delete')}
/>
</Flexbox>
{document.description && (
<Text className={styles.description} ellipsis={{ rows: 2 }}>
{document.description}
</Text>
)}
</Flexbox>
</Flexbox>
);
});
export default DocumentItem;

View file

@ -0,0 +1,17 @@
'use client';
import { Text } from '@lobehub/ui';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
const Title = memo(() => {
const { t } = useTranslation('portal');
return (
<Text style={{ fontSize: 16 }} type={'secondary'}>
{t('notebook.title')}
</Text>
);
});
export default Title;

View file

@ -0,0 +1,10 @@
import { PortalImpl } from '../type';
import Body from './Body';
import Title from './Title';
import { useEnable } from './useEnable';
export const Notebook: PortalImpl = {
Body,
Title,
useEnable,
};

View file

@ -0,0 +1,6 @@
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
export const useEnable = () => {
return useChatStore(chatPortalSelectors.showNotebook);
};

View file

@ -3,17 +3,29 @@
import { memo } from 'react';
import { Artifacts } from './Artifacts';
import { Document } from './Document';
import { FilePreview } from './FilePreview';
import { GroupThread } from './GroupThread';
import { HomeBody, HomeTitle } from './Home';
import { MessageDetail } from './MessageDetail';
import { Notebook } from './Notebook';
import { Plugins } from './Plugins';
import { Thread } from './Thread';
import Header from './components/Header';
import { PortalImpl } from './type';
// Keep GroupThread before Thread so group DM threads take precedence when enabled
const items: PortalImpl[] = [GroupThread, Thread, MessageDetail, Artifacts, Plugins, FilePreview];
// Document should be before Notebook so detail view takes precedence
const items: PortalImpl[] = [
GroupThread,
Thread,
MessageDetail,
Artifacts,
Plugins,
FilePreview,
Document,
Notebook,
];
export const PortalTitle = memo(() => {
const enabledList: boolean[] = [];

View file

@ -0,0 +1,18 @@
import { useNotebookStore } from '@/store/notebook';
import { notebookSelectors } from '@/store/notebook/selectors';
/**
* Fetch notebook documents for the current topic
*/
export const useFetchNotebookDocuments = (topicId?: string) => {
const useFetchDocuments = useNotebookStore((s) => s.useFetchDocuments);
const documents = useNotebookStore((s) => notebookSelectors.getDocumentsByTopicId(topicId)(s));
const { isLoading } = useFetchDocuments(topicId);
return {
documents,
isLoading,
topicId,
};
};

View file

@ -36,10 +36,11 @@ export const setScopedMutate = (m: ScopedMutator) => {
*
* Use this instead of `import { mutate } from 'swr'` when using localStorage cache provider
*/
export const mutate: ScopedMutator = ((key: any, data?: any, opts?: any) => {
export const mutate: ScopedMutator = (async (key: any, data?: any, opts?: any) => {
if (!scopedMutate) {
console.warn('[SWR] Scoped mutate not initialized, this may cause cache sync issues');
return Promise.resolve([]);
return [];
}
return scopedMutate(key, data, opts);
return await scopedMutate(key, data, opts);
}) as ScopedMutator;

View file

@ -364,13 +364,16 @@ export default {
title: '暂无插件',
},
error: {
details: '错误详情',
fetchError: '请求该 manifest 链接失败,请确保链接的有效性,并检查链接是否允许跨域访问',
installError: '插件 {{name}} 安装失败',
manifestInvalid: 'manifest 不符合规范,校验结果: \n\n {{error}}',
noManifest: '描述文件不存在',
openAPIInvalid: 'OpenAPI 解析失败,错误: \n\n {{error}}',
reinstallError: '插件 {{name}} 刷新失败',
renderError: '工具渲染错误',
testConnectionFailed: '获取 Manifest 失败: {{error}}',
unknownError: '发生未知错误',
urlError: '该链接没有返回 JSON 格式的内容, 请确保是有效的链接',
},
inspector: {

View file

@ -26,5 +26,11 @@ export default {
emptyKnowledgeList: '当前知识列表为空',
files: '文件',
messageDetail: '消息详情',
notebook: {
confirmDelete: '确定要删除这个文档吗?',
delete: '删除',
empty: '暂无文档,当前话题关联的文档将会显示在这里',
title: 'Notebook',
},
title: '工作区',
};

View file

@ -26,6 +26,7 @@ import { klavisRouter } from './klavis';
import { knowledgeBaseRouter } from './knowledgeBase';
import { marketRouter } from './market';
import { messageRouter } from './message';
import { notebookRouter } from './notebook';
import { pluginRouter } from './plugin';
import { ragEvalRouter } from './ragEval';
import { searchRouter } from './search';
@ -64,6 +65,7 @@ export const lambdaRouter = router({
knowledgeBase: knowledgeBaseRouter,
market: marketRouter,
message: messageRouter,
notebook: notebookRouter,
plugin: pluginRouter,
ragEval: ragEvalRouter,
search: searchRouter,

View file

@ -0,0 +1,135 @@
import { NotebookDocument } from '@lobechat/types';
import { z } from 'zod';
import { DocumentModel } from '@/database/models/document';
import { TopicDocumentModel } from '@/database/models/topicDocument';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
const notebookProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
return opts.next({
ctx: {
documentModel: new DocumentModel(ctx.serverDB, ctx.userId),
topicDocumentModel: new TopicDocumentModel(ctx.serverDB, ctx.userId),
},
});
});
export const notebookRouter = router({
createDocument: notebookProcedure
.input(
z.object({
content: z.string(),
description: z.string(),
title: z.string(),
topicId: z.string(),
type: z
.enum(['article', 'markdown', 'note', 'report', 'agent/plan'])
.optional()
.default('markdown'),
}),
)
.mutation(async ({ ctx, input }) => {
// Create the document
const document = await ctx.documentModel.create({
content: input.content,
description: input.description,
fileType: input.type,
source: 'notebook',
sourceType: 'api',
title: input.title,
totalCharCount: input.content.length,
totalLineCount: input.content.split('\n').length,
});
// Associate with topic
await ctx.topicDocumentModel.associate({
documentId: document.id,
topicId: input.topicId,
});
return document;
}),
deleteDocument: notebookProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
// Remove associations first
await ctx.topicDocumentModel.deleteByDocumentId(input.id);
// Delete the document
await ctx.documentModel.delete(input.id);
return { success: true };
}),
getDocument: notebookProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.documentModel.findById(input.id);
}),
listDocuments: notebookProcedure
.input(
z.object({
topicId: z.string(),
type: z.enum(['article', 'markdown', 'note', 'report', 'agent/plan']).optional(),
}),
)
.query(async ({ ctx, input }): Promise<{ data: NotebookDocument[]; total: number }> => {
const documents = await ctx.topicDocumentModel.findByTopicId(input.topicId, {
type: input.type,
});
return {
data: documents.map((doc) => ({
associatedAt: doc.associatedAt,
content: doc.content,
createdAt: doc.createdAt,
description: doc.description,
fileType: doc.fileType,
id: doc.id,
title: doc.title,
totalCharCount: doc.totalCharCount,
totalLineCount: doc.totalLineCount,
updatedAt: doc.updatedAt,
})),
total: documents.length,
};
}),
updateDocument: notebookProcedure
.input(
z.object({
append: z.boolean().optional(),
content: z.string().optional(),
description: z.string().optional(),
id: z.string(),
title: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
let contentToUpdate = input.content;
// Handle append mode
if (input.append && input.content) {
const existing = await ctx.documentModel.findById(input.id);
if (existing?.content) {
contentToUpdate = existing.content + '\n\n' + input.content;
}
}
await ctx.documentModel.update(input.id, {
...(contentToUpdate !== undefined && {
content: contentToUpdate,
totalCharCount: contentToUpdate.length,
totalLineCount: contentToUpdate.split('\n').length,
}),
...(input.description !== undefined && { description: input.description }),
...(input.title && { title: input.title }),
});
return ctx.documentModel.findById(input.id);
}),
});

50
src/services/notebook.ts Normal file
View file

@ -0,0 +1,50 @@
import { DocumentType } from '@lobechat/builtin-tool-notebook';
import { lambdaClient } from '@/libs/trpc/client';
type ExtendedDocumentType = DocumentType | 'agent/plan';
interface CreateDocumentParams {
content: string;
description: string;
title: string;
topicId: string;
type?: ExtendedDocumentType;
}
interface UpdateDocumentParams {
append?: boolean;
content?: string;
description?: string;
id: string;
title?: string;
}
interface ListDocumentsParams {
topicId: string;
type?: ExtendedDocumentType;
}
class NotebookService {
createDocument = async (params: CreateDocumentParams) => {
return lambdaClient.notebook.createDocument.mutate(params);
};
updateDocument = async (params: UpdateDocumentParams) => {
return lambdaClient.notebook.updateDocument.mutate(params);
};
getDocument = async (id: string) => {
return lambdaClient.notebook.getDocument.query({ id });
};
listDocuments = async (params: ListDocumentsParams) => {
return lambdaClient.notebook.listDocuments.query(params);
};
deleteDocument = async (id: string) => {
return lambdaClient.notebook.deleteDocument.mutate({ id });
};
}
export const notebookService = new NotebookService();

View file

@ -7,13 +7,18 @@ import { PortalFile } from './initialState';
export interface ChatPortalAction {
closeArtifact: () => void;
closeDocument: () => void;
closeFilePreview: () => void;
closeMessageDetail: () => void;
closeNotebook: () => void;
closeToolUI: () => void;
openArtifact: (artifact: PortalArtifact) => void;
openDocument: (documentId: string) => void;
openFilePreview: (portal: PortalFile) => void;
openMessageDetail: (messageId: string) => void;
openNotebook: () => void;
openToolUI: (messageId: string, identifier: string) => void;
toggleNotebook: (open?: boolean) => void;
togglePortal: (open?: boolean) => void;
}
@ -27,12 +32,18 @@ export const chatPortalSlice: StateCreator<
get().togglePortal(false);
set({ portalArtifact: undefined }, false, 'closeArtifact');
},
closeDocument: () => {
set({ portalDocumentId: undefined }, false, 'closeDocument');
},
closeFilePreview: () => {
set({ portalFile: undefined }, false, 'closeFilePreview');
},
closeMessageDetail: () => {
set({ portalMessageDetail: undefined }, false, 'openMessageDetail');
},
closeNotebook: () => {
set({ showNotebook: false }, false, 'closeNotebook');
},
closeToolUI: () => {
set({ portalToolMessage: undefined }, false, 'closeToolUI');
},
@ -41,6 +52,11 @@ export const chatPortalSlice: StateCreator<
set({ portalArtifact: artifact }, false, 'openArtifact');
},
openDocument: (documentId) => {
get().togglePortal(true);
set({ portalDocumentId: documentId, showNotebook: true }, false, 'openDocument');
},
openFilePreview: (portal) => {
get().togglePortal(true);
@ -52,11 +68,24 @@ export const chatPortalSlice: StateCreator<
set({ portalMessageDetail: messageId }, false, 'openMessageDetail');
},
openNotebook: () => {
get().togglePortal(true);
set({ showNotebook: true }, false, 'openNotebook');
},
openToolUI: (id, identifier) => {
get().togglePortal(true);
set({ portalToolMessage: { id, identifier } }, false, 'openToolUI');
},
toggleNotebook: (open) => {
const showNotebook = open === undefined ? !get().showNotebook : open;
get().togglePortal(showNotebook);
set({ showNotebook }, false, 'toggleNotebook');
},
togglePortal: (open) => {
const showInspector = open === undefined ? !get().showPortal : open;
set({ showPortal: showInspector }, false, 'toggleInspector');

View file

@ -14,10 +14,12 @@ export interface PortalFile {
export interface ChatPortalState {
portalArtifact?: PortalArtifact;
portalArtifactDisplayMode?: ArtifactDisplayMode;
portalDocumentId?: string;
portalFile?: PortalFile;
portalMessageDetail?: string;
portalThreadId?: string;
portalToolMessage?: { id: string; identifier: string };
showNotebook?: boolean;
showPortal: boolean;
}

View file

@ -19,6 +19,11 @@ const showFilePreview = (s: ChatStoreState) => !!s.portalFile;
const previewFileId = (s: ChatStoreState) => s.portalFile?.fileId;
const chunkText = (s: ChatStoreState) => s.portalFile?.chunkText;
const showNotebook = (s: ChatStoreState) => !!s.showNotebook;
const showDocument = (s: ChatStoreState) => !!s.portalDocumentId;
const portalDocumentId = (s: ChatStoreState) => s.portalDocumentId;
const showArtifactUI = (s: ChatStoreState) => !!s.portalArtifact;
const artifactTitle = (s: ChatStoreState) => s.portalArtifact?.title;
const artifactIdentifier = (s: ChatStoreState) => s.portalArtifact?.identifier || '';
@ -60,6 +65,11 @@ export const chatPortalSelectors = {
messageDetailId,
showMessageDetail,
showNotebook,
showDocument,
portalDocumentId,
showPluginUI,
showPortal,

View file

@ -0,0 +1,11 @@
// Selectors
export { editorSelectors } from './slices/editor';
export { notebookSelectors } from './slices/notebook';
// Store
export type { DocumentAction, DocumentState, DocumentStore } from './store';
export { getDocumentStoreState, useDocumentStore } from './store';
// Re-export slice types
export type { EditorAction, EditorState } from './slices/editor';
export type { NotebookAction, NotebookState } from './slices/notebook';

View file

@ -0,0 +1,230 @@
import { EDITOR_DEBOUNCE_TIME, EDITOR_MAX_WAIT } from '@lobechat/const';
import { IEditor } from '@lobehub/editor';
import { debounce } from 'es-toolkit/compat';
import { StateCreator } from 'zustand/vanilla';
import { setNamespace } from '@/utils/storeDebug';
import type { DocumentStore } from '../../store';
const n = setNamespace('document/editor');
export interface EditorAction {
/**
* Close the current document
*/
closeDocument: () => void;
/**
* Flush any pending debounced save
*/
flushSave: () => void;
/**
* Handle content change from editor
*/
handleContentChange: () => void;
/**
* Called when editor is initialized
*/
onEditorInit: () => void;
/**
* Open a document for editing
*/
openDocument: (params: {
content: string;
documentId: string;
title: string;
topicId: string;
}) => void;
/**
* Perform save operation
*/
performSave: () => Promise<void>;
/**
* Set editor instance
*/
setEditor: (editor: IEditor | undefined) => void;
/**
* Set editor state
*/
setEditorState: (editorState: any) => void;
/**
* Set edit mode
*/
setMode: (mode: 'edit' | 'preview') => void;
/**
* Update document title
*/
setTitle: (title: string) => void;
}
// Create debounced save outside store
let debouncedSave: ReturnType<typeof debounce> | null = null;
const createDebouncedSave = (get: () => DocumentStore) => {
if (debouncedSave) return debouncedSave;
debouncedSave = debounce(
async () => {
try {
await get().performSave();
} catch (error) {
console.error('[DocumentEditor] Failed to auto-save:', error);
}
},
EDITOR_DEBOUNCE_TIME,
{ leading: false, maxWait: EDITOR_MAX_WAIT, trailing: true },
);
return debouncedSave;
};
export const createEditorSlice: StateCreator<
DocumentStore,
[['zustand/devtools', never]],
[],
EditorAction
> = (set, get) => ({
closeDocument: () => {
// Flush any pending saves before closing
const save = createDebouncedSave(get);
save.flush();
set(
{
activeContent: '',
activeDocumentId: undefined,
activeTopicId: undefined,
isDirty: false,
lastSavedContent: '',
lastUpdatedTime: null,
mode: 'edit',
saveStatus: 'idle',
title: '',
},
false,
n('closeDocument'),
);
},
flushSave: () => {
const save = createDebouncedSave(get);
save.flush();
},
handleContentChange: () => {
const { editor, lastSavedContent } = get();
if (!editor) return;
try {
const markdownContent = (editor.getDocument('markdown') as unknown as string) || '';
// Check if content actually changed
const contentChanged = markdownContent !== lastSavedContent;
set(
{ activeContent: markdownContent, isDirty: contentChanged },
false,
n('handleContentChange'),
);
// Only trigger auto-save if content actually changed
if (contentChanged) {
const save = createDebouncedSave(get);
save();
}
} catch (error) {
console.error('[DocumentEditor] Failed to update content:', error);
}
},
onEditorInit: () => {
const { editor, activeContent } = get();
if (editor && activeContent) {
// Set initial content when editor is ready
editor.setDocument('markdown', activeContent);
}
},
openDocument: ({ content, documentId, title, topicId }) => {
const { editor } = get();
set(
{
activeContent: content,
activeDocumentId: documentId,
activeTopicId: topicId,
isDirty: false,
lastSavedContent: content,
mode: 'edit',
saveStatus: 'idle',
title,
},
false,
n('openDocument', { documentId, topicId }),
);
// Set editor content if editor exists
if (editor && content) {
editor.setDocument('markdown', content);
}
},
performSave: async () => {
const { editor, activeDocumentId, title, activeTopicId, isDirty, updateDocument } = get();
if (!editor || !activeDocumentId || !activeTopicId) return;
// Skip save if no changes
if (!isDirty) return;
set({ saveStatus: 'saving' }, false, n('performSave:start'));
try {
const currentContent = (editor.getDocument('markdown') as unknown as string) || '';
// Update document via notebook slice
await updateDocument(
{
content: currentContent,
id: activeDocumentId,
title,
},
activeTopicId,
);
// Mark as clean and update save status
set(
{
isDirty: false,
lastSavedContent: currentContent,
lastUpdatedTime: new Date(),
saveStatus: 'saved',
},
false,
n('performSave:success'),
);
} catch (error) {
console.error('[DocumentEditor] Failed to save:', error);
set({ saveStatus: 'idle' }, false, n('performSave:error'));
}
},
setEditor: (editor) => {
set({ editor }, false, n('setEditor'));
},
setEditorState: (editorState) => {
set({ editorState }, false, n('setEditorState'));
},
setMode: (mode) => {
set({ mode }, false, n('setMode', { mode }));
},
setTitle: (title) => {
set({ isDirty: true, title }, false, n('setTitle'));
const save = createDebouncedSave(get);
save();
},
});

View file

@ -0,0 +1,3 @@
export { createEditorSlice, type EditorAction } from './action';
export { type EditorState,initialEditorState } from './initialState';
export { editorSelectors } from './selectors';

View file

@ -0,0 +1,62 @@
import { IEditor } from '@lobehub/editor';
export interface EditorState {
/**
* Current document content (markdown)
*/
activeContent: string;
/**
* Current document ID being edited
*/
activeDocumentId: string | undefined;
/**
* Current topic ID for the active document
*/
activeTopicId: string | undefined;
/**
* Editor instance from @lobehub/editor
*/
editor: IEditor | undefined;
/**
* Editor state from useEditorState hook
*/
editorState: any;
/**
* Whether there are unsaved changes
*/
isDirty: boolean;
/**
* Last saved content for comparison
*/
lastSavedContent: string;
/**
* Last updated time
*/
lastUpdatedTime: Date | null;
/**
* Edit mode: 'edit' or 'preview'
*/
mode: 'edit' | 'preview';
/**
* Current save status
*/
saveStatus: 'idle' | 'saving' | 'saved';
/**
* Current document title
*/
title: string;
}
export const initialEditorState: EditorState = {
activeContent: '',
activeDocumentId: undefined,
activeTopicId: undefined,
editor: undefined,
editorState: undefined,
isDirty: false,
lastSavedContent: '',
lastUpdatedTime: null,
mode: 'edit',
saveStatus: 'idle',
title: '',
};

View file

@ -0,0 +1,16 @@
import type { DocumentStore } from '../../store';
const isEditing = (s: DocumentStore) => !!s.activeDocumentId;
const isEditMode = (s: DocumentStore) => s.mode === 'edit';
const isPreviewMode = (s: DocumentStore) => s.mode === 'preview';
const canSave = (s: DocumentStore) => s.isDirty && s.saveStatus !== 'saving';
export const editorSelectors = {
canSave,
isEditMode,
isEditing,
isPreviewMode,
};

View file

@ -0,0 +1,119 @@
import { DocumentType } from '@lobechat/builtin-tool-notebook';
import { DocumentItem } from '@lobechat/database/schemas';
import { NotebookDocument } from '@lobechat/types';
import isEqual from 'fast-deep-equal';
import { SWRResponse, mutate } from 'swr';
import { StateCreator } from 'zustand/vanilla';
import { useClientDataSWR } from '@/libs/swr';
import { notebookService } from '@/services/notebook';
import { useChatStore } from '@/store/chat';
import { setNamespace } from '@/utils/storeDebug';
import type { DocumentStore } from '../../store';
const n = setNamespace('document/notebook');
const SWR_USE_FETCH_NOTEBOOK_DOCUMENTS = 'SWR_USE_FETCH_NOTEBOOK_DOCUMENTS';
type ExtendedDocumentType = DocumentType | 'agent/plan';
interface CreateDocumentParams {
content: string;
description: string;
title: string;
topicId: string;
type?: ExtendedDocumentType;
}
interface UpdateDocumentParams {
content?: string;
description?: string;
id: string;
title?: string;
}
export interface NotebookAction {
createDocument: (params: CreateDocumentParams) => Promise<DocumentItem>;
deleteDocument: (id: string, topicId: string) => Promise<void>;
refreshDocuments: (topicId: string) => Promise<void>;
updateDocument: (
params: UpdateDocumentParams,
topicId: string,
) => Promise<DocumentItem | undefined>;
useFetchDocuments: (topicId: string | undefined) => SWRResponse<NotebookDocument[]>;
}
export const createNotebookSlice: StateCreator<
DocumentStore,
[['zustand/devtools', never]],
[],
NotebookAction
> = (set, get) => ({
createDocument: async (params) => {
const document = await notebookService.createDocument(params);
// Refresh the documents list
await mutate([SWR_USE_FETCH_NOTEBOOK_DOCUMENTS, params.topicId]);
return document;
},
deleteDocument: async (id, topicId) => {
// If the deleted document is currently open, close it
const portalDocumentId = useChatStore.getState().portalDocumentId;
if (portalDocumentId === id) {
useChatStore.getState().closeDocument();
}
// Call API to delete
await notebookService.deleteDocument(id);
// Refresh the documents list
await mutate([SWR_USE_FETCH_NOTEBOOK_DOCUMENTS, topicId]);
},
refreshDocuments: async (topicId) => {
await mutate([SWR_USE_FETCH_NOTEBOOK_DOCUMENTS, topicId]);
},
updateDocument: async (params, topicId) => {
const document = await notebookService.updateDocument(params);
// Refresh the documents list
await mutate([SWR_USE_FETCH_NOTEBOOK_DOCUMENTS, topicId]);
return document;
},
useFetchDocuments: (topicId) => {
return useClientDataSWR<NotebookDocument[]>(
topicId ? [SWR_USE_FETCH_NOTEBOOK_DOCUMENTS, topicId] : null,
async () => {
if (!topicId) return [];
const result = await notebookService.listDocuments({ topicId });
return result.data;
},
{
onSuccess: (documents) => {
if (!topicId) return;
const currentDocuments = get().notebookMap[topicId];
// Skip update if data is the same
if (currentDocuments && isEqual(documents, currentDocuments)) return;
set(
{
notebookMap: { ...get().notebookMap, [topicId]: documents },
},
false,
n('useFetchDocuments(onSuccess)', { topicId }),
);
},
},
);
},
});

View file

@ -0,0 +1,3 @@
export { createNotebookSlice, type NotebookAction } from './action';
export { initialNotebookState, type NotebookState } from './initialState';
export { notebookSelectors } from './selectors';

View file

@ -0,0 +1,12 @@
import { NotebookDocument } from '@lobechat/types';
export interface NotebookState {
/**
* Map of topicId -> notebook documents list
*/
notebookMap: Record<string, NotebookDocument[]>;
}
export const initialNotebookState: NotebookState = {
notebookMap: {},
};

View file

@ -0,0 +1,26 @@
import type { DocumentStore } from '../../store';
const getDocumentById =
(topicId: string | undefined, documentId: string | undefined) => (s: DocumentStore) => {
if (!topicId || !documentId) return null;
const docs = s.notebookMap[topicId];
if (!docs) return null;
return docs.find((d) => d.id === documentId) || null;
};
const getDocumentsByTopicId = (topicId: string | undefined) => (s: DocumentStore) => {
if (!topicId) return [];
return s.notebookMap[topicId] || [];
};
const hasDocuments = (topicId: string | undefined) => (s: DocumentStore) => {
if (!topicId) return false;
const docs = s.notebookMap[topicId];
return docs && docs.length > 0;
};
export const notebookSelectors = {
getDocumentById,
getDocumentsByTopicId,
hasDocuments,
};

View file

@ -0,0 +1,44 @@
import { shallow } from 'zustand/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
import { StateCreator } from 'zustand/vanilla';
import { createDevtools } from '../middleware/createDevtools';
import { EditorAction, EditorState, createEditorSlice, initialEditorState } from './slices/editor';
import {
NotebookAction,
NotebookState,
createNotebookSlice,
initialNotebookState,
} from './slices/notebook';
// Combined state type
export type DocumentState = EditorState & NotebookState;
// Combined action type
export type DocumentAction = EditorAction & NotebookAction;
// Full store type
export type DocumentStore = DocumentState & DocumentAction;
// Initial state
const initialState: DocumentState = {
...initialEditorState,
...initialNotebookState,
};
const createStore: StateCreator<DocumentStore, [['zustand/devtools', never]]> = (
...parameters
) => ({
...initialState,
...createEditorSlice(...parameters),
...createNotebookSlice(...parameters),
});
const devtools = createDevtools('document');
export const useDocumentStore = createWithEqualityFn<DocumentStore>()(
devtools(createStore),
shallow,
);
export const getDocumentStoreState = () => useDocumentStore.getState();

View file

@ -0,0 +1,119 @@
import { DocumentType } from '@lobechat/builtin-tool-notebook';
import { DocumentItem } from '@lobechat/database/schemas';
import { NotebookDocument } from '@lobechat/types';
import isEqual from 'fast-deep-equal';
import { SWRResponse, mutate } from 'swr';
import { StateCreator } from 'zustand/vanilla';
import { useClientDataSWR } from '@/libs/swr';
import { notebookService } from '@/services/notebook';
import { useChatStore } from '@/store/chat';
import { setNamespace } from '@/utils/storeDebug';
import type { NotebookStore } from './store';
const n = setNamespace('notebook');
const SWR_USE_FETCH_NOTEBOOK_DOCUMENTS = 'SWR_USE_FETCH_NOTEBOOK_DOCUMENTS';
type ExtendedDocumentType = DocumentType | 'agent/plan';
interface CreateDocumentParams {
content: string;
description: string;
title: string;
topicId: string;
type?: ExtendedDocumentType;
}
interface UpdateDocumentParams {
content?: string;
description?: string;
id: string;
title?: string;
}
export interface NotebookAction {
createDocument: (params: CreateDocumentParams) => Promise<DocumentItem>;
deleteDocument: (id: string, topicId: string) => Promise<void>;
refreshDocuments: (topicId: string) => Promise<void>;
updateDocument: (
params: UpdateDocumentParams,
topicId: string,
) => Promise<DocumentItem | undefined>;
useFetchDocuments: (topicId: string | undefined) => SWRResponse<NotebookDocument[]>;
}
export const createNotebookAction: StateCreator<
NotebookStore,
[['zustand/devtools', never]],
[],
NotebookAction
> = (set, get) => ({
createDocument: async (params) => {
const document = await notebookService.createDocument(params);
// Refresh the documents list
await mutate([SWR_USE_FETCH_NOTEBOOK_DOCUMENTS, params.topicId]);
return document;
},
deleteDocument: async (id, topicId) => {
// If the deleted document is currently open, close it
const portalDocumentId = useChatStore.getState().portalDocumentId;
if (portalDocumentId === id) {
useChatStore.getState().closeDocument();
}
// Call API to delete
await notebookService.deleteDocument(id);
// Refresh the documents list
await mutate([SWR_USE_FETCH_NOTEBOOK_DOCUMENTS, topicId]);
},
refreshDocuments: async (topicId) => {
await mutate([SWR_USE_FETCH_NOTEBOOK_DOCUMENTS, topicId]);
},
updateDocument: async (params, topicId) => {
const document = await notebookService.updateDocument(params);
// Refresh the documents list
await mutate([SWR_USE_FETCH_NOTEBOOK_DOCUMENTS, topicId]);
return document;
},
useFetchDocuments: (topicId) => {
return useClientDataSWR<NotebookDocument[]>(
topicId ? [SWR_USE_FETCH_NOTEBOOK_DOCUMENTS, topicId] : null,
async () => {
if (!topicId) return [];
const result = await notebookService.listDocuments({ topicId });
return result.data;
},
{
onSuccess: (documents) => {
if (!topicId) return;
const currentDocuments = get().notebookMap[topicId];
// Skip update if data is the same
if (currentDocuments && isEqual(documents, currentDocuments)) return;
set(
{
notebookMap: { ...get().notebookMap, [topicId]: documents },
},
false,
n('useFetchDocuments(onSuccess)', { topicId }),
);
},
},
);
},
});

View file

@ -0,0 +1,4 @@
/**
* @deprecated Use `@/store/document` instead. This is a compatibility layer.
*/
export { notebookSelectors, useDocumentStore as useNotebookStore } from '../document';

View file

@ -0,0 +1,12 @@
import { NotebookDocument } from '@lobechat/types';
export interface NotebookState {
/**
* Map of topicId -> notebook documents list
*/
notebookMap: Record<string, NotebookDocument[]>;
}
export const initialNotebookState: NotebookState = {
notebookMap: {},
};

View file

@ -0,0 +1,26 @@
import type { NotebookStore } from './store';
const getDocumentById =
(topicId: string | undefined, documentId: string | undefined) => (s: NotebookStore) => {
if (!topicId || !documentId) return null;
const docs = s.notebookMap[topicId];
if (!docs) return null;
return docs.find((d) => d.id === documentId) || null;
};
const getDocumentsByTopicId = (topicId: string | undefined) => (s: NotebookStore) => {
if (!topicId) return [];
return s.notebookMap[topicId] || [];
};
const hasDocuments = (topicId: string | undefined) => (s: NotebookStore) => {
if (!topicId) return false;
const docs = s.notebookMap[topicId];
return docs && docs.length > 0;
};
export const notebookSelectors = {
getDocumentById,
getDocumentsByTopicId,
hasDocuments,
};

View file

@ -0,0 +1,25 @@
import { shallow } from 'zustand/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
import { StateCreator } from 'zustand/vanilla';
import { createDevtools } from '../middleware/createDevtools';
import { NotebookAction, createNotebookAction } from './action';
import { NotebookState, initialNotebookState } from './initialState';
export type NotebookStore = NotebookState & NotebookAction;
const createStore: StateCreator<NotebookStore, [['zustand/devtools', never]]> = (
...parameters
) => ({
...initialNotebookState,
...createNotebookAction(...parameters),
});
const devtools = createDevtools('notebook');
export const useNotebookStore = createWithEqualityFn<NotebookStore>()(
devtools(createStore),
shallow,
);
export const getNotebookStoreState = () => useNotebookStore.getState();

View file

@ -10,6 +10,7 @@ import { gtdExecutor } from '@lobechat/builtin-tool-gtd/executor';
import type { IBuiltinToolExecutor } from '../types';
// ==================== Import and register all executors ====================
import { notebookExecutor } from './lobe-notebook';
import { webBrowsing } from './lobe-web-browsing';
/**
@ -111,4 +112,5 @@ export const invokeExecutor = async (
// Register all executor instances
registerExecutor(groupManagementExecutor);
registerExecutor(gtdExecutor);
registerExecutor(notebookExecutor);
registerExecutor(webBrowsing);

View file

@ -0,0 +1,174 @@
/**
* Lobe Notebook Executor
*
* Handles notebook document operations via tRPC API calls.
* All operations are delegated to the server since they require database access.
*
* Note: listDocuments is not exposed as a tool - it's automatically injected by the system.
*/
import {
CreateDocumentArgs,
DeleteDocumentArgs,
GetDocumentArgs,
NotebookApiName,
NotebookIdentifier,
UpdateDocumentArgs,
} from '@lobechat/builtin-tool-notebook';
import { BaseExecutor, type BuiltinToolContext, type BuiltinToolResult } from '@lobechat/types';
import { notebookService } from '@/services/notebook';
class NotebookExecutor extends BaseExecutor<typeof NotebookApiName> {
readonly identifier = NotebookIdentifier;
protected readonly apiEnum = NotebookApiName;
/**
* Create a new document
*/
createDocument = async (
params: CreateDocumentArgs,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
try {
if (ctx.signal?.aborted) {
return { stop: true, success: false };
}
if (!ctx.topicId) {
return {
content: 'Cannot create document: no topic selected',
success: false,
};
}
const document = await notebookService.createDocument({
content: params.content,
description: params.description,
title: params.title,
topicId: ctx.topicId,
type: params.type,
});
return {
content: `Document "${document.title}" created successfully`,
state: { document },
success: true,
};
} catch (e) {
const err = e as Error;
return {
error: {
body: e,
message: err.message,
type: 'PluginServerError',
},
success: false,
};
}
};
/**
* Update an existing document
*/
updateDocument = async (
params: UpdateDocumentArgs,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
try {
if (ctx.signal?.aborted) {
return { stop: true, success: false };
}
const document = await notebookService.updateDocument(params);
return {
content: `Document updated successfully`,
state: { document },
success: true,
};
} catch (e) {
const err = e as Error;
return {
error: {
body: e,
message: err.message,
type: 'PluginServerError',
},
success: false,
};
}
};
/**
* Get a document by ID
*/
getDocument = async (
params: GetDocumentArgs,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
try {
if (ctx.signal?.aborted) {
return { stop: true, success: false };
}
const document = await notebookService.getDocument(params.id);
if (!document) {
return {
content: `Document not found: ${params.id}`,
success: false,
};
}
return {
content: document.content || '',
state: { document },
success: true,
};
} catch (e) {
const err = e as Error;
return {
error: {
body: e,
message: err.message,
type: 'PluginServerError',
},
success: false,
};
}
};
/**
* Delete a document
*/
deleteDocument = async (
params: DeleteDocumentArgs,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
try {
if (ctx.signal?.aborted) {
return { stop: true, success: false };
}
await notebookService.deleteDocument(params.id);
return {
content: `Document deleted successfully`,
success: true,
};
} catch (e) {
const err = e as Error;
return {
error: {
body: e,
message: err.message,
type: 'PluginServerError',
},
success: false,
};
}
};
}
// Export the executor instance for registration
export const notebookExecutor = new NotebookExecutor();

View file

@ -3,6 +3,7 @@ import { GroupAgentBuilderManifest } from '@lobechat/builtin-tool-group-agent-bu
import { GroupManagementManifest } from '@lobechat/builtin-tool-group-management';
import { GTDManifest } from '@lobechat/builtin-tool-gtd';
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
import { NotebookManifest } from '@lobechat/builtin-tool-notebook';
import { ArtifactsManifest } from './artifacts';
import { CodeInterpreterManifest } from './code-interpreter';
@ -21,4 +22,5 @@ export const builtinToolIdentifiers: string[] = [
GroupAgentBuilderManifest.identifier,
GroupManagementManifest.identifier,
GTDManifest.identifier,
NotebookManifest.identifier,
];

View file

@ -4,6 +4,7 @@ import { GroupManagementManifest } from '@lobechat/builtin-tool-group-management
import { GTDManifest } from '@lobechat/builtin-tool-gtd';
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
import { MemoryManifest } from '@lobechat/builtin-tool-memory';
import { NotebookManifest } from '@lobechat/builtin-tool-notebook';
import { LobeBuiltinTool } from '@lobechat/types';
import { isDesktop } from '@/const/version';
@ -78,4 +79,10 @@ export const builtinTools: LobeBuiltinTool[] = [
manifest: GTDManifest,
type: 'builtin',
},
{
hidden: true,
identifier: NotebookManifest.identifier,
manifest: NotebookManifest,
type: 'builtin',
},
];

View file

@ -8,6 +8,8 @@ import {
} from '@lobechat/builtin-tool-group-management/client';
import { GTDInterventions, GTDManifest } from '@lobechat/builtin-tool-gtd/client';
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
import { NotebookManifest } from '@lobechat/builtin-tool-notebook';
import { NotebookInterventions } from '@lobechat/builtin-tool-notebook/client';
import { BuiltinIntervention } from '@lobechat/types';
import { CodeInterpreterManifest as CloudCodeInterpreterManifest } from './code-interpreter';
@ -25,6 +27,7 @@ export const BuiltinToolInterventions: Record<string, Record<string, any>> = {
[GroupManagementManifest.identifier]: GroupManagementInterventions,
[GTDManifest.identifier]: GTDInterventions,
[LocalSystemManifest.identifier]: LocalSystemInterventions,
[NotebookManifest.identifier]: NotebookInterventions,
};
/**

View file

@ -8,6 +8,7 @@ import { GroupManagementRenders } from '@lobechat/builtin-tool-group-management/
import { GTDManifest, GTDRenders } from '@lobechat/builtin-tool-gtd/client';
// local-system
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
import { NotebookManifest, NotebookRenders } from '@lobechat/builtin-tool-notebook/client';
import { BuiltinRender } from '@lobechat/types';
// code-interpreter
@ -30,6 +31,7 @@ const BuiltinToolsRenders: Record<string, Record<string, BuiltinRender>> = {
[CodeInterpreterManifest.identifier]: CodeInterpreterRenders as Record<string, BuiltinRender>,
[GroupManagementManifest.identifier]: GroupManagementRenders as Record<string, BuiltinRender>,
[GTDManifest.identifier]: GTDRenders as Record<string, BuiltinRender>,
[NotebookManifest.identifier]: NotebookRenders as Record<string, BuiltinRender>,
[KnowledgeBaseManifest.identifier]: KnowledgeBaseRenders as Record<string, BuiltinRender>,
[LocalSystemManifest.identifier]: LocalSystemRenders as Record<string, BuiltinRender>,
[WebBrowsingManifest.identifier]: WebBrowsingRenders as Record<string, BuiltinRender>,