mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
✨ feat: support notebook tool (#10902)
* add notebook builtin tool * document init workflow * gtd support plan mode * add notebook tools
This commit is contained in:
parent
c6a6e246d8
commit
e05375f796
82 changed files with 3499 additions and 152 deletions
|
|
@ -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()`]
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:*"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
"type-fest": "^4.18.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@lobehub/ui": "^3.3.3",
|
||||
"@lobehub/ui": "^3",
|
||||
"antd": "^6",
|
||||
"lucide-react": "*",
|
||||
"next": "*",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
"react": "*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@lobehub/ui": "^3.3.3",
|
||||
"@lobehub/ui": "^3",
|
||||
"antd": "^6",
|
||||
"antd-style": "*"
|
||||
}
|
||||
|
|
|
|||
118
packages/builtin-tool-gtd/src/client/Intervention/CreatePlan.tsx
Normal file
118
packages/builtin-tool-gtd/src/client/Intervention/CreatePlan.tsx
Normal 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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
24
packages/builtin-tool-gtd/src/client/Render/CreatePlan.tsx
Normal file
24
packages/builtin-tool-gtd/src/client/Render/CreatePlan.tsx
Normal 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;
|
||||
84
packages/builtin-tool-gtd/src/client/Render/PlanCard.tsx
Normal file
84
packages/builtin-tool-gtd/src/client/Render/PlanCard.tsx
Normal 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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
"@lobechat/types": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@lobehub/ui": "^3.3.3",
|
||||
"@lobehub/ui": "^3",
|
||||
"antd": "^6",
|
||||
"antd-style": "*",
|
||||
"polished": "*",
|
||||
|
|
|
|||
24
packages/builtin-tool-notebook/package.json
Normal file
24
packages/builtin-tool-notebook/package.json
Normal 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": "*"
|
||||
}
|
||||
}
|
||||
276
packages/builtin-tool-notebook/src/ExecutionRuntime/index.ts
Normal file
276
packages/builtin-tool-notebook/src/ExecutionRuntime/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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> = {};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import CreateDocument from './CreateDocument';
|
||||
|
||||
export const NotebookRenders = {
|
||||
createDocument: CreateDocument,
|
||||
};
|
||||
|
||||
export { default as CreateDocument } from './CreateDocument';
|
||||
6
packages/builtin-tool-notebook/src/client/index.ts
Normal file
6
packages/builtin-tool-notebook/src/client/index.ts
Normal 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';
|
||||
17
packages/builtin-tool-notebook/src/index.ts
Normal file
17
packages/builtin-tool-notebook/src/index.ts
Normal 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';
|
||||
102
packages/builtin-tool-notebook/src/manifest.ts
Normal file
102
packages/builtin-tool-notebook/src/manifest.ts
Normal 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',
|
||||
};
|
||||
50
packages/builtin-tool-notebook/src/systemRole.ts
Normal file
50
packages/builtin-tool-notebook/src/systemRole.ts
Normal 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>
|
||||
`;
|
||||
65
packages/builtin-tool-notebook/src/types.ts
Normal file
65
packages/builtin-tool-notebook/src/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
392
packages/database/src/models/__tests__/topicDocument.test.ts
Normal file
392
packages/database/src/models/__tests__/topicDocument.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
125
packages/database/src/models/topicDocument.ts
Normal file
125
packages/database/src/models/topicDocument.ts
Normal 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)),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
47
src/features/Portal/Document/Body.tsx
Normal file
47
src/features/Portal/Document/Body.tsx
Normal 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;
|
||||
36
src/features/Portal/Document/Header.tsx
Normal file
36
src/features/Portal/Document/Header.tsx
Normal 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;
|
||||
10
src/features/Portal/Document/index.ts
Normal file
10
src/features/Portal/Document/index.ts
Normal 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,
|
||||
};
|
||||
8
src/features/Portal/Document/useEnable.ts
Normal file
8
src/features/Portal/Document/useEnable.ts
Normal 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);
|
||||
};
|
||||
87
src/features/Portal/Notebook/Body.tsx
Normal file
87
src/features/Portal/Notebook/Body.tsx
Normal 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;
|
||||
95
src/features/Portal/Notebook/DocumentItem.tsx
Normal file
95
src/features/Portal/Notebook/DocumentItem.tsx
Normal 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;
|
||||
17
src/features/Portal/Notebook/Title.tsx
Normal file
17
src/features/Portal/Notebook/Title.tsx
Normal 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;
|
||||
10
src/features/Portal/Notebook/index.ts
Normal file
10
src/features/Portal/Notebook/index.ts
Normal 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,
|
||||
};
|
||||
6
src/features/Portal/Notebook/useEnable.ts
Normal file
6
src/features/Portal/Notebook/useEnable.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { useChatStore } from '@/store/chat';
|
||||
import { chatPortalSelectors } from '@/store/chat/selectors';
|
||||
|
||||
export const useEnable = () => {
|
||||
return useChatStore(chatPortalSelectors.showNotebook);
|
||||
};
|
||||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
18
src/hooks/useFetchNotebookDocuments.ts
Normal file
18
src/hooks/useFetchNotebookDocuments.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -26,5 +26,11 @@ export default {
|
|||
emptyKnowledgeList: '当前知识列表为空',
|
||||
files: '文件',
|
||||
messageDetail: '消息详情',
|
||||
notebook: {
|
||||
confirmDelete: '确定要删除这个文档吗?',
|
||||
delete: '删除',
|
||||
empty: '暂无文档,当前话题关联的文档将会显示在这里',
|
||||
title: 'Notebook',
|
||||
},
|
||||
title: '工作区',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
135
src/server/routers/lambda/notebook.ts
Normal file
135
src/server/routers/lambda/notebook.ts
Normal 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
50
src/services/notebook.ts
Normal 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();
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
11
src/store/document/index.ts
Normal file
11
src/store/document/index.ts
Normal 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';
|
||||
230
src/store/document/slices/editor/action.ts
Normal file
230
src/store/document/slices/editor/action.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
3
src/store/document/slices/editor/index.ts
Normal file
3
src/store/document/slices/editor/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { createEditorSlice, type EditorAction } from './action';
|
||||
export { type EditorState,initialEditorState } from './initialState';
|
||||
export { editorSelectors } from './selectors';
|
||||
62
src/store/document/slices/editor/initialState.ts
Normal file
62
src/store/document/slices/editor/initialState.ts
Normal 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: '',
|
||||
};
|
||||
16
src/store/document/slices/editor/selectors.ts
Normal file
16
src/store/document/slices/editor/selectors.ts
Normal 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,
|
||||
};
|
||||
119
src/store/document/slices/notebook/action.ts
Normal file
119
src/store/document/slices/notebook/action.ts
Normal 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 }),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
3
src/store/document/slices/notebook/index.ts
Normal file
3
src/store/document/slices/notebook/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { createNotebookSlice, type NotebookAction } from './action';
|
||||
export { initialNotebookState, type NotebookState } from './initialState';
|
||||
export { notebookSelectors } from './selectors';
|
||||
12
src/store/document/slices/notebook/initialState.ts
Normal file
12
src/store/document/slices/notebook/initialState.ts
Normal 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: {},
|
||||
};
|
||||
26
src/store/document/slices/notebook/selectors.ts
Normal file
26
src/store/document/slices/notebook/selectors.ts
Normal 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,
|
||||
};
|
||||
44
src/store/document/store.ts
Normal file
44
src/store/document/store.ts
Normal 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();
|
||||
119
src/store/notebook/action.ts
Normal file
119
src/store/notebook/action.ts
Normal 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 }),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
4
src/store/notebook/index.ts
Normal file
4
src/store/notebook/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* @deprecated Use `@/store/document` instead. This is a compatibility layer.
|
||||
*/
|
||||
export { notebookSelectors, useDocumentStore as useNotebookStore } from '../document';
|
||||
12
src/store/notebook/initialState.ts
Normal file
12
src/store/notebook/initialState.ts
Normal 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: {},
|
||||
};
|
||||
26
src/store/notebook/selectors.ts
Normal file
26
src/store/notebook/selectors.ts
Normal 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,
|
||||
};
|
||||
25
src/store/notebook/store.ts
Normal file
25
src/store/notebook/store.ts
Normal 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();
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
174
src/store/tool/slices/builtin/executors/lobe-notebook.ts
Normal file
174
src/store/tool/slices/builtin/executors/lobe-notebook.ts
Normal 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();
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
Loading…
Reference in a new issue