feat: update gtd tools( use editor & update metadata ) (#11029)

* feat: use lobehub editor to modify gtd plan

* merge origin/dev

* feat: show todo in doc portal

* feat: use the todoProcess in docs portal

* feat: add gtd context engine inject
This commit is contained in:
Shinji-Li
2025-12-29 20:20:11 +08:00
committed by GitHub
parent 8786628016
commit 4a47ea0d2f
22 changed files with 779 additions and 72 deletions

View File

@@ -10,6 +10,8 @@
"artifacts.svg.copySuccess": "Image copied successfully",
"artifacts.svg.download.png": "Download as PNG",
"artifacts.svg.download.svg": "Download as SVG",
"document.todos.allCompleted": "All tasks completed",
"document.todos.title": "Tasks",
"emptyArtifactList": "No Artifacts yet. Use Skills in the conversation, then come back here.",
"emptyKnowledgeList": "This list is empty.",
"files": "Files",

View File

@@ -10,6 +10,8 @@
"artifacts.svg.copySuccess": "图片复制成功",
"artifacts.svg.download.png": "下载为 PNG",
"artifacts.svg.download.svg": "下载为 SVG",
"document.todos.allCompleted": "所有任务已完成",
"document.todos.title": "任务",
"emptyArtifactList": "当前 Artifacts 列表为空,请在会话中按需使用技能后再查看",
"emptyKnowledgeList": "当前知识列表为空",
"files": "文件",

View File

@@ -13,9 +13,11 @@
},
"devDependencies": {
"@lobechat/types": "workspace:*",
"@lobehub/editor": "^1",
"@lobehub/ui": "^4.3.1"
},
"peerDependencies": {
"@lobehub/editor": "^1",
"@lobehub/ui": "^4",
"antd": "^6",
"antd-style": "*",

View File

@@ -1,98 +1,194 @@
'use client';
import {
ReactCodePlugin,
ReactCodeblockPlugin,
ReactHRPlugin,
ReactLinkPlugin,
ReactListPlugin,
ReactMathPlugin,
ReactTablePlugin,
} from '@lobehub/editor';
import { Editor, useEditor } from '@lobehub/editor/react';
import { BuiltinInterventionProps } from '@lobechat/types';
import { Flexbox, Input, Text, TextArea } from '@lobehub/ui';
import { Flexbox, TextArea } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { memo, useCallback, useRef, useState } from 'react';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { CreatePlanParams } from '../../types';
const useStyles = createStyles(({ css, token }) => ({
description: css`
font-size: 14px;
color: ${token.colorTextSecondary};
`,
title: css`
font-size: 28px;
font-weight: 600;
`,
}));
const CreatePlanIntervention = memo<BuiltinInterventionProps<CreatePlanParams>>(
({ args, onArgsChange, registerBeforeApprove }) => {
const { t } = useTranslation('tool');
const { styles } = useStyles();
const [goal, setGoal] = useState(args?.goal || '');
const [description, setDescription] = useState(args?.description || '');
const [context, setContext] = useState(args?.context || '');
const editor = useEditor();
const editorInitializedRef = useRef(false);
// Track pending changes
const pendingChangesRef = useRef<CreatePlanParams | null>(null);
// Initialize editor content when args.context changes
useEffect(() => {
if (editor && args?.context && !editorInitializedRef.current) {
editor.setDocument('text', args.context);
editorInitializedRef.current = true;
}
}, [editor, args?.context]);
// Get current context from editor
const getContext = useCallback(() => {
if (!editor) return args?.context || '';
return (editor.getDocument('text') as unknown as string) || '';
}, [editor, args?.context]);
// Save function
const save = useCallback(async () => {
if (pendingChangesRef.current) {
await onArgsChange?.(pendingChangesRef.current);
pendingChangesRef.current = null;
}
}, [onArgsChange]);
const context = getContext();
const changes: CreatePlanParams = {
context: context || undefined,
description,
goal,
};
// Always submit current state when approving
await onArgsChange?.(changes);
pendingChangesRef.current = null;
}, [onArgsChange, goal, description, getContext]);
// Register before approve callback
registerBeforeApprove?.('createPlan', save);
useEffect(() => {
return registerBeforeApprove?.('createPlan', save);
}, [registerBeforeApprove, save]);
const handleGoalChange = useCallback(
(value: string) => {
setGoal(value);
pendingChangesRef.current = {
context: context || undefined,
context: getContext() || undefined,
description,
goal: value,
};
},
[context, description],
[description, getContext],
);
const handleDescriptionChange = useCallback(
(value: string) => {
setDescription(value);
pendingChangesRef.current = {
context: context || undefined,
context: getContext() || undefined,
description: value,
goal,
};
},
[context, goal],
[goal, getContext],
);
const handleContextChange = useCallback(
(value: string) => {
setContext(value);
pendingChangesRef.current = {
context: value || undefined,
description,
goal,
};
const handleContentChange = useCallback(() => {
pendingChangesRef.current = {
context: getContext() || undefined,
description,
goal,
};
}, [description, goal, getContext]);
// Focus editor when pressing Enter in description
const handleDescriptionKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
editor?.focus();
}
},
[description, goal],
[editor],
);
// Focus description when pressing Enter in goal
const handleGoalKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
// Focus description textarea
const descriptionTextarea = document.querySelector(
'[data-testid="plan-description"]',
) as HTMLTextAreaElement;
descriptionTextarea?.focus();
}
}, []);
return (
<Flexbox gap={12}>
<Flexbox gap={8}>
<Text>{t('lobe-gtd.createPlan.goal.label')}</Text>
<Input
onChange={(e) => handleGoalChange(e.target.value)}
placeholder={t('lobe-gtd.createPlan.goal.placeholder')}
value={goal}
/>
</Flexbox>
<Flexbox gap={8}>
<Text>{t('lobe-gtd.createPlan.description.label')}</Text>
<Input
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder={t('lobe-gtd.createPlan.description.placeholder')}
value={description}
/>
</Flexbox>
<Flexbox gap={8}>
<Text>{t('lobe-gtd.createPlan.context.label')}</Text>
<TextArea
autoSize={{ minRows: 10 }}
onChange={(e) => handleContextChange(e.target.value)}
<Flexbox
gap={8}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
paddingBlock={16}
>
{/* Goal - Main Title */}
<TextArea
autoSize={{ minRows: 1 }}
className={styles.title}
onChange={(e) => handleGoalChange(e.target.value)}
onKeyDown={handleGoalKeyDown}
placeholder={t('lobe-gtd.createPlan.goal.placeholder')}
style={{ padding: 0, resize: 'none' }}
value={goal}
variant={'borderless'}
/>
{/* Description - Subtitle */}
<TextArea
autoSize={{ minRows: 1 }}
className={styles.description}
data-testid="plan-description"
onChange={(e) => handleDescriptionChange(e.target.value)}
onKeyDown={handleDescriptionKeyDown}
placeholder={t('lobe-gtd.createPlan.description.placeholder')}
style={{ padding: 0, resize: 'none' }}
value={description}
variant={'borderless'}
/>
{/* Context - Rich Text Editor */}
<div style={{ marginTop: 8, minHeight: 200 }}>
<Editor
content={args.context}
editor={editor}
lineEmptyPlaceholder={t('lobe-gtd.createPlan.context.placeholder')}
onTextChange={handleContentChange}
placeholder={t('lobe-gtd.createPlan.context.placeholder')}
value={context}
plugins={[
ReactListPlugin,
ReactCodePlugin,
ReactCodeblockPlugin,
ReactHRPlugin,
ReactLinkPlugin,
ReactTablePlugin,
ReactMathPlugin,
]}
style={{
minHeight: 200,
}}
type={'text'}
/>
</Flexbox>
</div>
</Flexbox>
);
},

View File

@@ -16,11 +16,37 @@ import {
type Plan,
type RemoveTodosParams,
type TodoItem,
type TodoState,
type UpdatePlanParams,
type UpdateTodosParams,
} from '../types';
import { getTodosFromContext } from './helper';
/**
* Sync todos to the Plan document's metadata
* This allows the Plan to track todos persistently
*/
const syncTodosToPlan = async (topicId: string, todos: TodoState): Promise<void> => {
try {
// List all documents for this topic with type 'agent/plan'
const result = await notebookService.listDocuments({ topicId, type: 'agent/plan' });
// If there's a plan document, update its metadata with the todos
if (result.data.length > 0) {
// Update the first (most recent) plan document
const planDoc = result.data[0];
await notebookService.updateDocument({
id: planDoc.id,
metadata: { todos },
});
}
} catch (error) {
// Silently fail - todo sync is a non-critical feature
console.warn('Failed to sync todos to plan:', error);
}
};
// API enum for MVP (Todo + Plan)
const GTDApiNameEnum = {
clearTodos: GTDApiName.clearTodos,
completeTodos: GTDApiName.completeTodos,
@@ -77,11 +103,18 @@ class GTDExecutor extends BaseExecutor<typeof GTDApiNameEnum> {
const addedList = itemsToAdd.map((item) => `- ${item.text}`).join('\n');
const actionSummary = `✅ Added ${itemsToAdd.length} item${itemsToAdd.length > 1 ? 's' : ''}:\n${addedList}`;
const todoState = { items: updatedTodos, updatedAt: now };
// Sync todos to Plan document if topic exists
if (ctx.topicId) {
await syncTodosToPlan(ctx.topicId, todoState);
}
return {
content: actionSummary + '\n\n' + formatTodoStateSummary(updatedTodos, now),
state: {
createdItems: itemsToAdd.map((item) => item.text),
todos: { items: updatedTodos, updatedAt: now },
todos: todoState,
},
success: true,
};
@@ -154,10 +187,17 @@ class GTDExecutor extends BaseExecutor<typeof GTDApiNameEnum> {
? `🔄 Applied ${results.length} operation${results.length > 1 ? 's' : ''}:\n${results.map((r) => `- ${r}`).join('\n')}`
: 'No operations applied.';
const todoState = { items: updatedTodos, updatedAt: now };
// Sync todos to Plan document if topic exists
if (ctx.topicId) {
await syncTodosToPlan(ctx.topicId, todoState);
}
return {
content: actionSummary + '\n\n' + formatTodoStateSummary(updatedTodos, now),
state: {
todos: { items: updatedTodos, updatedAt: now },
todos: todoState,
},
success: true,
};
@@ -223,11 +263,18 @@ class GTDExecutor extends BaseExecutor<typeof GTDApiNameEnum> {
actionSummary += `\n\nNote: Ignored invalid indices: ${invalidIndices.join(', ')}`;
}
const todoState = { items: updatedTodos, updatedAt: now };
// Sync todos to Plan document if topic exists
if (ctx.topicId) {
await syncTodosToPlan(ctx.topicId, todoState);
}
return {
content: actionSummary + '\n\n' + formatTodoStateSummary(updatedTodos, now),
state: {
completedIndices: validIndices,
todos: { items: updatedTodos, updatedAt: now },
todos: todoState,
},
success: true,
};
@@ -287,11 +334,18 @@ class GTDExecutor extends BaseExecutor<typeof GTDApiNameEnum> {
actionSummary += `\n\nNote: Ignored invalid indices: ${invalidIndices.join(', ')}`;
}
const todoState = { items: updatedTodos, updatedAt: now };
// Sync todos to Plan document if topic exists
if (ctx.topicId) {
await syncTodosToPlan(ctx.topicId, todoState);
}
return {
content: actionSummary + '\n\n' + formatTodoStateSummary(updatedTodos, now),
state: {
removedIndices: validIndices,
todos: { items: updatedTodos, updatedAt: now },
todos: todoState,
},
success: true,
};
@@ -342,13 +396,19 @@ class GTDExecutor extends BaseExecutor<typeof GTDApiNameEnum> {
}
const now = new Date().toISOString();
const todoState = { items: updatedTodos, updatedAt: now };
// Sync todos to Plan document if topic exists
if (ctx.topicId) {
await syncTodosToPlan(ctx.topicId, todoState);
}
return {
content: actionSummary + '\n\n' + formatTodoStateSummary(updatedTodos, now),
state: {
clearedCount,
mode,
todos: { items: updatedTodos, updatedAt: now },
todos: todoState,
},
success: true,
};

View File

@@ -57,6 +57,9 @@ export interface TodoList {
updatedAt: string;
}
/** Alias for TodoList, used for state storage in Plan metadata */
export type TodoState = TodoList;
// ==================== Todo Params ====================
/**

View File

@@ -22,6 +22,8 @@ import {
AgentBuilderContextInjector,
GroupAgentBuilderContextInjector,
GroupContextInjector,
GTDPlanInjector,
GTDTodoInjector,
HistorySummaryProvider,
KnowledgeInjector,
PageEditorContextInjector,
@@ -121,6 +123,7 @@ export class MessagesEngine {
agentBuilderContext,
groupAgentBuilderContext,
agentGroup,
gtd,
userMemory,
initialContext,
stepContext,
@@ -135,6 +138,9 @@ export class MessagesEngine {
const isUserMemoryEnabled = userMemory?.enabled && userMemory?.memories;
// Page editor is enabled if either direct pageContentContext or initialContext.pageEditor is provided
const isPageEditorEnabled = !!pageContentContext || !!initialContext?.pageEditor;
// GTD is enabled if gtd.enabled is true and either plan or todos is provided
const isGTDPlanEnabled = gtd?.enabled && gtd?.plan;
const isGTDTodoEnabled = gtd?.enabled && gtd?.todos;
return [
// =============================================
@@ -174,6 +180,11 @@ export class MessagesEngine {
// 4. User memory injection (conditionally added, injected first)
...(isUserMemoryEnabled ? [new UserMemoryInjector(userMemory)] : []),
// 4.5. GTD Plan injection (conditionally added, after user memory, before knowledge)
...(isGTDPlanEnabled
? [new GTDPlanInjector({ enabled: true, plan: gtd.plan })]
: []),
// 5. Knowledge injection (full content for agent files + metadata for knowledge bases)
new KnowledgeInjector({
fileContents: knowledge?.fileContents,
@@ -199,13 +210,13 @@ export class MessagesEngine {
// 8. Tool system role injection (conditionally added)
...(toolsConfig?.manifests && toolsConfig.manifests.length > 0
? [
new ToolSystemRoleProvider({
isCanUseFC: capabilities?.isCanUseFC || (() => true),
manifests: toolsConfig.manifests,
model,
provider,
}),
]
new ToolSystemRoleProvider({
isCanUseFC: capabilities?.isCanUseFC || (() => true),
manifests: toolsConfig.manifests,
model,
provider,
}),
]
: []),
// 9. History summary injection
@@ -222,23 +233,28 @@ export class MessagesEngine {
? pageContentContext
: initialContext?.pageEditor
? {
markdown: initialContext.pageEditor.markdown,
metadata: {
charCount: initialContext.pageEditor.metadata.charCount,
lineCount: initialContext.pageEditor.metadata.lineCount,
title: initialContext.pageEditor.metadata.title,
},
// Use latest XML from stepContext if available, otherwise fallback to initial XML
xml: stepContext?.stepPageEditor?.xml || initialContext.pageEditor.xml,
}
markdown: initialContext.pageEditor.markdown,
metadata: {
charCount: initialContext.pageEditor.metadata.charCount,
lineCount: initialContext.pageEditor.metadata.lineCount,
title: initialContext.pageEditor.metadata.title,
},
// Use latest XML from stepContext if available, otherwise fallback to initial XML
xml: stepContext?.stepPageEditor?.xml || initialContext.pageEditor.xml,
}
: undefined,
}),
// 10.5. GTD Todo injection (conditionally added, at end of last user message)
...(isGTDTodoEnabled
? [new GTDTodoInjector({ enabled: true, todos: gtd.todos })]
: []),
// =============================================
// Phase 3: Message Transformation
// =============================================
// 10. Input template processing
// 11. Input template processing
new InputTemplateProcessor({ inputTemplate }),
// 11. Placeholder variables processing
@@ -261,10 +277,10 @@ export class MessagesEngine {
// 16. Group message sender identity injection (for multi-agent chat)
...(isAgentGroupEnabled
? [
new GroupMessageSenderProcessor({
agentMap: agentGroup.agentMap!,
}),
]
new GroupMessageSenderProcessor({
agentMap: agentGroup.agentMap!,
}),
]
: []),
// =============================================

View File

@@ -5,6 +5,10 @@ export type {
AgentInfo,
FileContent,
FileContextConfig,
GTDConfig,
GTDPlan,
GTDTodoItem,
GTDTodoList,
KnowledgeBaseInfo,
KnowledgeConfig,
MessagesEngineParams,

View File

@@ -8,6 +8,8 @@ import type { AgentInfo } from '../../processors/GroupMessageSender';
import type { AgentBuilderContext } from '../../providers/AgentBuilderContextInjector';
import type { GroupAgentBuilderContext } from '../../providers/GroupAgentBuilderContextInjector';
import type { GroupMemberInfo } from '../../providers/GroupContextInjector';
import type { GTDPlan } from '../../providers/GTDPlanInjector';
import type { GTDTodoList } from '../../providers/GTDTodoInjector';
import type { LobeToolManifest } from '../tools/types';
/**
@@ -139,6 +141,19 @@ export interface AgentGroupConfig {
systemPrompt?: string;
}
/**
* GTD (Getting Things Done) configuration
* Used to inject plan and todo context for task management
*/
export interface GTDConfig {
/** Whether GTD context injection is enabled */
enabled?: boolean;
/** The current plan to inject (injected before first user message) */
plan?: GTDPlan;
/** The current todo list to inject (injected at end of last user message) */
todos?: GTDTodoList;
}
/**
* MessagesEngine main parameters
*/
@@ -190,6 +205,8 @@ export interface MessagesEngineParams {
agentGroup?: AgentGroupConfig;
/** Group Agent Builder context */
groupAgentBuilderContext?: GroupAgentBuilderContext;
/** GTD (Getting Things Done) configuration */
gtd?: GTDConfig;
/** User memory configuration */
userMemory?: UserMemoryConfig;
@@ -235,5 +252,7 @@ export interface MessagesEngineResult {
export { type AgentInfo } from '../../processors/GroupMessageSender';
export { type AgentBuilderContext } from '../../providers/AgentBuilderContextInjector';
export { type GroupAgentBuilderContext } from '../../providers/GroupAgentBuilderContextInjector';
export { type GTDPlan } from '../../providers/GTDPlanInjector';
export { type GTDTodoItem, type GTDTodoList } from '../../providers/GTDTodoInjector';
export { type OpenAIChatMessage, type UIChatMessage } from '@/types/index';
export { type FileContent, type KnowledgeBaseInfo } from '@lobechat/prompts';

View File

@@ -0,0 +1,106 @@
import debug from 'debug';
import { BaseFirstUserContentProvider } from '../base/BaseFirstUserContentProvider';
import type { PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:provider:GTDPlanInjector');
/**
* GTD Plan data structure
* Represents a high-level plan document
*/
export interface GTDPlan {
/** Whether the plan is completed */
completed: boolean;
/** Detailed context, background, constraints */
context?: string;
/** Creation timestamp */
createdAt: string;
/** Brief summary of the plan */
description: string;
/** The main goal or objective */
goal: string;
/** Unique plan identifier */
id: string;
/** Last update timestamp */
updatedAt: string;
}
export interface GTDPlanInjectorConfig {
/** Whether GTD Plan injection is enabled */
enabled?: boolean;
/** The current plan to inject */
plan?: GTDPlan;
}
/**
* Format GTD Plan content for injection
*/
function formatGTDPlan(plan: GTDPlan): string {
const lines: string[] = ['<gtd_plan>'];
lines.push(`<goal>${plan.goal}</goal>`);
if (plan.description) {
lines.push(`<description>${plan.description}</description>`);
}
if (plan.context) {
lines.push(`<context>${plan.context}</context>`);
}
lines.push(`<status>${plan.completed ? 'completed' : 'in_progress'}</status>`);
lines.push('</gtd_plan>');
return lines.join('\n');
}
/**
* GTD Plan Injector
* Responsible for injecting the current plan into context before the first user message
* This provides the AI with awareness of the user's current goal and plan context
*/
export class GTDPlanInjector extends BaseFirstUserContentProvider {
readonly name = 'GTDPlanInjector';
constructor(
private config: GTDPlanInjectorConfig,
options: ProcessorOptions = {},
) {
super(options);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected buildContent(_context: PipelineContext): string | null {
const { enabled, plan } = this.config;
if (!enabled || !plan) {
log('GTD Plan not enabled or no plan provided');
return null;
}
// Skip if plan is completed
if (plan.completed) {
log('Plan is completed, skipping injection');
return null;
}
const formattedContent = formatGTDPlan(plan);
log(`GTD Plan prepared: goal="${plan.goal}"`);
return formattedContent;
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const result = await super.doProcess(context);
// Update metadata
if (this.config.enabled && this.config.plan && !this.config.plan.completed) {
result.metadata.gtdPlanInjected = true;
result.metadata.gtdPlanId = this.config.plan.id;
}
return result;
}
}

View File

@@ -0,0 +1,127 @@
import debug from 'debug';
import { BaseLastUserContentProvider } from '../base/BaseLastUserContentProvider';
import type { PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:provider:GTDTodoInjector');
/**
* GTD Todo item structure
*/
export interface GTDTodoItem {
/** Whether the item is completed */
completed: boolean;
/** The todo item text */
text: string;
}
/**
* GTD Todo list structure
*/
export interface GTDTodoList {
items: GTDTodoItem[];
updatedAt: string;
}
export interface GTDTodoInjectorConfig {
/** Whether GTD Todo injection is enabled */
enabled?: boolean;
/** The current todo list to inject */
todos?: GTDTodoList;
}
/**
* Format GTD Todo list content for injection
*/
function formatGTDTodos(todos: GTDTodoList): string | null {
const { items } = todos;
if (!items || items.length === 0) {
return null;
}
const lines: string[] = ['<gtd_todos>'];
items.forEach((item, index) => {
const status = item.completed ? 'done' : 'pending';
lines.push(`<todo index="${index}" status="${status}">${item.text}</todo>`);
});
const completedCount = items.filter((item) => item.completed).length;
const totalCount = items.length;
lines.push(`<progress completed="${completedCount}" total="${totalCount}" />`);
lines.push('</gtd_todos>');
return lines.join('\n');
}
/**
* GTD Todo Injector
* Responsible for injecting the current todo list at the end of the last user message
* This provides the AI with real-time awareness of task progress
*/
export class GTDTodoInjector extends BaseLastUserContentProvider {
readonly name = 'GTDTodoInjector';
constructor(
private config: GTDTodoInjectorConfig,
options: ProcessorOptions = {},
) {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
log('doProcess called');
log('config.enabled:', this.config.enabled);
const clonedContext = this.cloneContext(context);
// Skip if GTD Todo is not enabled or no todos
if (!this.config.enabled || !this.config.todos) {
log('GTD Todo not enabled or no todos, skipping injection');
return this.markAsExecuted(clonedContext);
}
// Format todo list content
const formattedContent = formatGTDTodos(this.config.todos);
// Skip if no content to inject (empty todo list)
if (!formattedContent) {
log('No todos to inject (empty list)');
return this.markAsExecuted(clonedContext);
}
log('Formatted content length:', formattedContent.length);
// Find the last user message index
const lastUserIndex = this.findLastUserMessageIndex(clonedContext.messages);
log('Last user message index:', lastUserIndex);
if (lastUserIndex === -1) {
log('No user messages found, skipping injection');
return this.markAsExecuted(clonedContext);
}
// Check if system context wrapper already exists
// If yes, only insert context block; if no, use full wrapper
const hasExistingWrapper = this.hasExistingSystemContext(clonedContext);
const contentToAppend = hasExistingWrapper
? this.createContextBlock(formattedContent, 'gtd_todo_context')
: this.wrapWithSystemContext(formattedContent, 'gtd_todo_context');
this.appendToLastUserMessage(clonedContext, contentToAppend);
// Update metadata
clonedContext.metadata.gtdTodoInjected = true;
clonedContext.metadata.gtdTodoCount = this.config.todos.items.length;
clonedContext.metadata.gtdTodoCompletedCount = this.config.todos.items.filter(
(item) => item.completed,
).length;
log('GTD Todo context appended to last user message');
return this.markAsExecuted(clonedContext);
}
}

View File

@@ -2,6 +2,8 @@
export { AgentBuilderContextInjector } from './AgentBuilderContextInjector';
export { GroupAgentBuilderContextInjector } from './GroupAgentBuilderContextInjector';
export { GroupContextInjector } from './GroupContextInjector';
export { GTDPlanInjector } from './GTDPlanInjector';
export { GTDTodoInjector } from './GTDTodoInjector';
export { HistorySummaryProvider } from './HistorySummary';
export { KnowledgeInjector } from './KnowledgeInjector';
export { PageEditorContextInjector } from './PageEditorContextInjector';
@@ -26,6 +28,8 @@ export type {
GroupMemberInfo as GroupContextMemberInfo,
} from './GroupContextInjector';
export type { HistorySummaryConfig } from './HistorySummary';
export type { GTDPlan, GTDPlanInjectorConfig } from './GTDPlanInjector';
export type { GTDTodoInjectorConfig, GTDTodoItem, GTDTodoList } from './GTDTodoInjector';
export type { KnowledgeInjectorConfig } from './KnowledgeInjector';
export type { PageEditorContextInjectorConfig } from './PageEditorContextInjector';
export type { SystemRoleInjectorConfig } from './SystemRoleInjector';

View File

@@ -221,6 +221,10 @@ export interface NotebookDocument {
* Document ID
*/
id: string;
/**
* Document metadata (e.g., todos for agent/plan documents)
*/
metadata: Record<string, any> | null;
/**
* Document title
*/

View File

@@ -6,6 +6,7 @@ import { memo } from 'react';
import EditorCanvas from './EditorCanvas';
import Title from './Title';
import TodoList from './TodoList';
const styles = createStaticStyles(({ css }) => ({
content: css`
@@ -13,6 +14,11 @@ const styles = createStaticStyles(({ css }) => ({
flex: 1;
padding-inline: 12px;
`,
todoContainer: css`
flex-shrink: 0;
padding-block-end: 12px;
padding-inline: 12px;
`,
}));
const DocumentBody = memo(() => {
@@ -22,6 +28,9 @@ const DocumentBody = memo(() => {
<Title />
<EditorCanvas />
</div>
<div className={styles.todoContainer}>
<TodoList />
</div>
</Flexbox>
);
});

View File

@@ -0,0 +1,187 @@
'use client';
import { Checkbox, Flexbox, Icon, Tag } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { ChevronDown, ChevronUp, ListTodo } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNotebookStore } from '@/store/notebook';
import { notebookSelectors } from '@/store/notebook/selectors';
import { useDocumentEditorStore } from './store';
interface TodoItem {
completed: boolean;
text: string;
}
interface TodoState {
items: TodoItem[];
updatedAt: string;
}
const styles = createStaticStyles(({ css, cssVar }) => ({
collapsed: css`
max-height: 0;
padding-block: 0 !important;
opacity: 0;
`,
container: css`
cursor: pointer;
user-select: none;
padding-block: 8px;
padding-inline: 12px;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: 8px;
background: ${cssVar.colorBgElevated};
transition: all 0.2s ${cssVar.motionEaseInOut};
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
count: css`
font-family: ${cssVar.fontFamilyCode};
font-size: 12px;
color: ${cssVar.colorTextSecondary};
`,
expanded: css`
max-height: 300px;
opacity: 1;
`,
header: css`
overflow: hidden;
font-size: 13px;
font-weight: 500;
color: ${cssVar.colorText};
text-overflow: ellipsis;
white-space: nowrap;
`,
itemRow: css`
padding-block: 6px;
padding-inline: 4px;
border-block-end: 1px dashed ${cssVar.colorBorderSecondary};
font-size: 13px;
&:last-child {
border-block-end: none;
}
`,
listContainer: css`
overflow: hidden;
margin-block-start: 8px;
padding-block: 4px;
border-block-start: 1px solid ${cssVar.colorBorderSecondary};
transition:
max-height 0.25s ${cssVar.motionEaseInOut},
opacity 0.2s ${cssVar.motionEaseInOut},
padding 0.2s ${cssVar.motionEaseInOut};
`,
progress: css`
flex: 1;
height: 4px;
border-radius: 2px;
background: ${cssVar.colorFillSecondary};
`,
progressFill: css`
height: 100%;
border-radius: 2px;
background: ${cssVar.colorSuccess};
transition: width 0.3s ${cssVar.motionEaseInOut};
`,
textChecked: css`
color: ${cssVar.colorTextQuaternary};
text-decoration: line-through;
`,
}));
const TodoList = memo(() => {
const { t } = useTranslation('portal');
const [expanded, setExpanded] = useState(false);
const documentId = useDocumentEditorStore((s) => s.documentId);
const topicId = useDocumentEditorStore((s) => s.topicId);
const document = useNotebookStore(notebookSelectors.getDocumentById(topicId, documentId));
// Only show for agent/plan documents with todos in metadata
if (!document || document.fileType !== 'agent/plan') return null;
const todos: TodoState | undefined = document.metadata?.todos;
const items = todos?.items || [];
if (items.length === 0) return null;
const total = items.length;
const completed = items.filter((item) => item.completed).length;
const progressPercent = total > 0 ? (completed / total) * 100 : 0;
// Find current pending task (first incomplete item)
const currentPendingTask = items.find((item) => !item.completed);
const toggleExpanded = () => setExpanded(!expanded);
return (
<div className={styles.container} onClick={toggleExpanded}>
{/* Header */}
<Flexbox align="center" gap={8} horizontal justify="space-between">
<Flexbox align="center" gap={8} horizontal style={{ flex: 1, minWidth: 0 }}>
<Icon icon={ListTodo} size={16} style={{ color: cssVar.colorPrimary, flexShrink: 0 }} />
<span className={styles.header}>
{currentPendingTask?.text || t('document.todos.allCompleted')}
</span>
<Tag size="small" style={{ flexShrink: 0 }}>
<span className={styles.count}>
{completed}/{total}
</span>
</Tag>
</Flexbox>
<Icon
icon={expanded ? ChevronUp : ChevronDown}
size={16}
style={{ color: cssVar.colorTextTertiary, flexShrink: 0 }}
/>
</Flexbox>
{/* Progress Bar */}
<Flexbox gap={8} horizontal style={{ marginTop: 8 }}>
<div className={styles.progress}>
<div className={styles.progressFill} style={{ width: `${progressPercent}%` }} />
</div>
</Flexbox>
{/* Expandable Todo List */}
<div className={cx(styles.listContainer, expanded ? styles.expanded : styles.collapsed)}>
{items.map((item, index) => (
<Checkbox
backgroundColor={cssVar.colorSuccess}
checked={item.completed}
classNames={{
text: item.completed ? styles.textChecked : undefined,
wrapper: styles.itemRow,
}}
key={index}
shape="circle"
style={{ borderWidth: 1.5, cursor: 'default', pointerEvents: 'none' }}
textProps={{
type: item.completed ? 'secondary' : undefined,
}}
>
{item.text}
</Checkbox>
))}
</div>
</div>
);
});
TodoList.displayName = 'TodoList';
export default TodoList;

View File

@@ -10,6 +10,8 @@ export default {
'artifacts.svg.copySuccess': 'Image copied successfully',
'artifacts.svg.download.png': 'Download as PNG',
'artifacts.svg.download.svg': 'Download as SVG',
'document.todos.allCompleted': 'All tasks completed',
'document.todos.title': 'Tasks',
'emptyArtifactList': 'No Artifacts yet. Use Skills in the conversation, then come back here.',
'emptyKnowledgeList': 'This list is empty.',
'files': 'Files',

View File

@@ -23,6 +23,7 @@ export const notebookRouter = router({
z.object({
content: z.string(),
description: z.string(),
metadata: z.record(z.string(), z.any()).optional(),
title: z.string(),
topicId: z.string(),
type: z
@@ -37,6 +38,7 @@ export const notebookRouter = router({
content: input.content,
description: input.description,
fileType: input.type,
metadata: input.metadata,
source: 'notebook',
sourceType: 'api',
title: input.title,
@@ -90,6 +92,7 @@ export const notebookRouter = router({
description: doc.description,
fileType: doc.fileType,
id: doc.id,
metadata: doc.metadata,
title: doc.title,
totalCharCount: doc.totalCharCount,
totalLineCount: doc.totalLineCount,
@@ -106,6 +109,7 @@ export const notebookRouter = router({
content: z.string().optional(),
description: z.string().optional(),
id: z.string(),
metadata: z.record(z.string(), z.any()).optional(),
title: z.string().optional(),
}),
)
@@ -127,6 +131,7 @@ export const notebookRouter = router({
totalLineCount: contentToUpdate.split('\n').length,
}),
...(input.description !== undefined && { description: input.description }),
...(input.metadata !== undefined && { metadata: input.metadata }),
...(input.title && { title: input.title }),
});

View File

@@ -67,6 +67,7 @@ interface GetChatCompletionPayload extends Partial<Omit<ChatStreamPayload, 'mess
groupId?: string;
messages: UIChatMessage[];
scope?: MessageMapScope;
topicId?: string;
}
type ChatStreamInputParams = Partial<Omit<ChatStreamPayload, 'messages'>> & {
@@ -107,6 +108,7 @@ class ChatService {
agentId,
groupId,
scope,
topicId,
...params
}: GetChatCompletionPayload,
options?: FetchOptions,
@@ -246,6 +248,7 @@ class ChatService {
stepContext: options?.stepContext,
systemRole: agentConfig.systemRole,
tools: enabledToolIds,
topicId,
});
// ============ 3. process extend params ============ //

View File

@@ -1,9 +1,11 @@
import { AgentBuilderIdentifier } from '@lobechat/builtin-tool-agent-builder';
import { GroupAgentBuilderIdentifier } from '@lobechat/builtin-tool-group-agent-builder';
import { GTDIdentifier } from '@lobechat/builtin-tool-gtd';
import { KLAVIS_SERVER_TYPES, isDesktop } from '@lobechat/const';
import {
type AgentBuilderContext,
type AgentGroupConfig,
type GTDConfig,
type GroupAgentBuilderContext,
type GroupOfficialToolItem,
type LobeToolManifest,
@@ -20,6 +22,7 @@ import { VARIABLE_GENERATORS } from '@lobechat/utils/client';
import debug from 'debug';
import { isCanUseFC } from '@/helpers/isCanUseFC';
import { notebookService } from '@/services/notebook';
import { getAgentStoreState } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { getChatGroupStoreState } from '@/store/agentGroup';
@@ -67,6 +70,8 @@ interface ContextEngineeringContext {
stepContext?: RuntimeStepContext;
systemRole?: string;
tools?: string[];
/** Topic ID for GTD context injection */
topicId?: string;
}
// REVIEW可能这里可以约束一下 identitypreferenceexp 的 重新排序或者裁切过的上下文进来而不是全部丢进来
@@ -87,6 +92,7 @@ export const contextEngineering = async ({
groupId,
initialContext,
stepContext,
topicId,
}: ContextEngineeringContext): Promise<OpenAIChatMessage[]> => {
log('tools: %o', tools);
@@ -255,6 +261,50 @@ export const contextEngineering = async ({
userMemoryData = combineUserMemoryData(topicMemories, globalIdentities);
}
// Resolve GTD context: plan and todos
// GTD tool must be enabled and topicId must be provided
const isGTDEnabled = tools?.includes(GTDIdentifier) ?? false;
let gtdConfig: GTDConfig | undefined;
if (isGTDEnabled && topicId) {
try {
// Fetch plan document for the current topic
const planResult = await notebookService.listDocuments({
topicId,
type: 'agent/plan',
});
if (planResult.data.length > 0) {
const planDoc = planResult.data[0]; // Most recent plan
// Build plan object for injection
const plan = {
completed: false, // TODO: Add completed field to document if needed
context: planDoc.content ?? undefined,
createdAt: planDoc.createdAt.toISOString(),
description: planDoc.description || '',
goal: planDoc.title || '',
id: planDoc.id,
updatedAt: planDoc.updatedAt.toISOString(),
};
// Get todos from plan's metadata
const todos = planDoc.metadata?.todos;
gtdConfig = {
enabled: true,
plan,
todos,
};
log('GTD context resolved: plan=%s, todos=%o', plan.goal, todos?.items?.length ?? 0);
}
} catch (error) {
// Silently fail - GTD context is optional
log('Failed to resolve GTD context:', error);
}
}
// Create MessagesEngine with injected dependencies
/* eslint-disable sort-keys-fix/sort-keys-fix */
const engine = new MessagesEngine({
@@ -315,6 +365,7 @@ export const contextEngineering = async ({
...(isAgentBuilderEnabled && { agentBuilderContext }),
...(isGroupAgentBuilderEnabled && { groupAgentBuilderContext }),
...(agentGroup && { agentGroup }),
...(gtdConfig && { gtd: gtdConfig }),
});
const result = await engine.process();

View File

@@ -7,6 +7,7 @@ type ExtendedDocumentType = DocumentType | 'agent/plan';
interface CreateDocumentParams {
content: string;
description: string;
metadata?: Record<string, any>;
title: string;
topicId: string;
type?: ExtendedDocumentType;
@@ -17,6 +18,7 @@ interface UpdateDocumentParams {
content?: string;
description?: string;
id: string;
metadata?: Record<string, any>;
title?: string;
}

View File

@@ -404,6 +404,7 @@ export const streamingExecutor: StateCreator<
model,
provider,
scope, // Pass scope to chat service for page-agent injection
topicId, // Pass topicId for GTD context injection
...finalAgentConfig.params,
plugins: finalAgentConfig.plugins,
},

View File

@@ -21,6 +21,7 @@ type ExtendedDocumentType = DocumentType | 'agent/plan';
interface CreateDocumentParams {
content: string;
description: string;
metadata?: Record<string, any>;
title: string;
topicId: string;
type?: ExtendedDocumentType;
@@ -30,6 +31,7 @@ interface UpdateDocumentParams {
content?: string;
description?: string;
id: string;
metadata?: Record<string, any>;
title?: string;
}