Use topic titles for auto-created page documents

This commit is contained in:
Innei
2026-04-21 16:30:44 +08:00
parent a08b3e396f
commit 62dc91e444
45 changed files with 1489 additions and 227 deletions
@@ -35,8 +35,13 @@ interface AgentDocumentRecord {
interface AgentDocumentOperationContext {
agentId?: string | null;
currentDocumentId?: string | null;
scope?: string | null;
}
const CURRENT_PAGE_DOCUMENT_WRITE_ERROR_CODE = 'CURRENT_PAGE_DOCUMENT_WRITE_FORBIDDEN';
const CURRENT_PAGE_DOCUMENT_WRITE_ERROR_TYPE = 'CurrentPageDocumentWriteForbidden';
export interface AgentDocumentsRuntimeService {
copyDocument: (
params: CopyDocumentArgs & {
@@ -98,6 +103,52 @@ export class AgentDocumentsExecutionRuntime {
return context.agentId;
}
private getCurrentDocumentId(context?: AgentDocumentOperationContext) {
if (context?.scope !== 'page') return;
return context.currentDocumentId ?? undefined;
}
private buildCurrentPageDocumentWriteBlockedResult(apiName: string): BuiltinServerRuntimeOutput {
const message =
`Cannot use lobe-agent-documents.${apiName} on the current page document ` +
`while page scope is active. Use lobe-page-agent so the open editor shows a diff node ` +
`for review instead of writing directly to the database.`;
return {
content: message,
error: {
code: CURRENT_PAGE_DOCUMENT_WRITE_ERROR_CODE,
kind: 'replan',
message,
type: CURRENT_PAGE_DOCUMENT_WRITE_ERROR_TYPE,
},
success: false,
};
}
private isCurrentPageDocument(
doc: AgentDocumentRecord | undefined,
context?: AgentDocumentOperationContext,
) {
const currentDocumentId = this.getCurrentDocumentId(context);
if (!currentDocumentId || !doc?.documentId) return false;
return doc.documentId === currentDocumentId;
}
private async shouldBlockUpsertForCurrentPageDocument(
agentId: string,
filename: string,
context?: AgentDocumentOperationContext,
) {
const currentDocumentId = this.getCurrentDocumentId(context);
if (!currentDocumentId) return false;
const docs = await this.service.listDocuments({ agentId });
return docs.some((doc) => doc.documentId === currentDocumentId && doc.filename === filename);
}
async listDocuments(
_args: ListDocumentsArgs,
context?: AgentDocumentOperationContext,
@@ -158,6 +209,10 @@ export class AgentDocumentsExecutionRuntime {
};
}
if (await this.shouldBlockUpsertForCurrentPageDocument(agentId, args.filename, context)) {
return this.buildCurrentPageDocumentWriteBlockedResult('upsertDocumentByFilename');
}
const doc = await this.service.upsertDocumentByFilename({ ...args, agentId });
if (!doc) return { content: `Failed to upsert document: ${args.filename}`, success: false };
@@ -224,8 +279,15 @@ export class AgentDocumentsExecutionRuntime {
};
}
const existing = await this.service.readDocument({ agentId, id: args.id });
if (!existing) return { content: `Document not found: ${args.id}`, success: false };
if (this.isCurrentPageDocument(existing, context)) {
return this.buildCurrentPageDocumentWriteBlockedResult('editDocument');
}
const doc = await this.service.editDocument({ ...args, agentId });
if (!doc) return { content: `Document not found: ${args.id}`, success: false };
if (!doc) return { content: `Failed to update document ${args.id}.`, success: false };
return {
content: `Updated document ${args.id}.`,
@@ -249,6 +311,10 @@ export class AgentDocumentsExecutionRuntime {
const doc = await this.service.readDocument({ agentId, id: args.id });
if (!doc) return { content: `Document not found: ${args.id}`, success: false };
if (this.isCurrentPageDocument(doc, context)) {
return this.buildCurrentPageDocumentWriteBlockedResult('patchDocument');
}
const patched = applyMarkdownPatch(doc.content ?? '', args.hunks);
if (!patched.ok) {
const message = formatMarkdownPatchError(patched.error);
@@ -308,8 +374,15 @@ export class AgentDocumentsExecutionRuntime {
};
}
const existing = await this.service.readDocument({ agentId, id: args.id });
if (!existing) return { content: `Document not found: ${args.id}`, success: false };
if (this.isCurrentPageDocument(existing, context)) {
return this.buildCurrentPageDocumentWriteBlockedResult('renameDocument');
}
const doc = await this.service.renameDocument({ ...args, agentId });
if (!doc) return { content: `Document not found: ${args.id}`, success: false };
if (!doc) return { content: `Failed to rename document ${args.id}.`, success: false };
return {
content: `Renamed document ${args.id} to "${args.newTitle}".`,
@@ -32,77 +32,121 @@ export class AgentDocumentsExecutor extends BaseExecutor<typeof AgentDocumentsAp
params: ListDocumentsArgs,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.listDocuments(params, { agentId: ctx.agentId });
return this.runtime.listDocuments(params, {
agentId: ctx.agentId,
currentDocumentId: ctx.documentId,
scope: ctx.scope,
});
};
readDocumentByFilename = async (
params: ReadDocumentByFilenameArgs,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.readDocumentByFilename(params, { agentId: ctx.agentId });
return this.runtime.readDocumentByFilename(params, {
agentId: ctx.agentId,
currentDocumentId: ctx.documentId,
scope: ctx.scope,
});
};
upsertDocumentByFilename = async (
params: UpsertDocumentByFilenameArgs,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.upsertDocumentByFilename(params, { agentId: ctx.agentId });
return this.runtime.upsertDocumentByFilename(params, {
agentId: ctx.agentId,
currentDocumentId: ctx.documentId,
scope: ctx.scope,
});
};
createDocument = async (
params: CreateDocumentArgs,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.createDocument(params, { agentId: ctx.agentId });
return this.runtime.createDocument(params, {
agentId: ctx.agentId,
currentDocumentId: ctx.documentId,
scope: ctx.scope,
});
};
readDocument = async (
params: ReadDocumentArgs,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.readDocument(params, { agentId: ctx.agentId });
return this.runtime.readDocument(params, {
agentId: ctx.agentId,
currentDocumentId: ctx.documentId,
scope: ctx.scope,
});
};
editDocument = async (
params: EditDocumentArgs,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.editDocument(params, { agentId: ctx.agentId });
return this.runtime.editDocument(params, {
agentId: ctx.agentId,
currentDocumentId: ctx.documentId,
scope: ctx.scope,
});
};
patchDocument = async (
params: PatchDocumentArgs,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.patchDocument(params, { agentId: ctx.agentId });
return this.runtime.patchDocument(params, {
agentId: ctx.agentId,
currentDocumentId: ctx.documentId,
scope: ctx.scope,
});
};
removeDocument = async (
params: RemoveDocumentArgs,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.removeDocument(params, { agentId: ctx.agentId });
return this.runtime.removeDocument(params, {
agentId: ctx.agentId,
currentDocumentId: ctx.documentId,
scope: ctx.scope,
});
};
renameDocument = async (
params: RenameDocumentArgs,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.renameDocument(params, { agentId: ctx.agentId });
return this.runtime.renameDocument(params, {
agentId: ctx.agentId,
currentDocumentId: ctx.documentId,
scope: ctx.scope,
});
};
copyDocument = async (
params: CopyDocumentArgs,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.copyDocument(params, { agentId: ctx.agentId });
return this.runtime.copyDocument(params, {
agentId: ctx.agentId,
currentDocumentId: ctx.documentId,
scope: ctx.scope,
});
};
updateLoadRule = async (
params: UpdateLoadRuleArgs,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
return this.runtime.updateLoadRule(params, { agentId: ctx.agentId });
return this.runtime.updateLoadRule(params, {
agentId: ctx.agentId,
currentDocumentId: ctx.documentId,
scope: ctx.scope,
});
};
}
@@ -5,6 +5,11 @@
* The NotebookService is injected via constructor so both client and server can provide their own implementation.
*
* Note: listDocuments is not exposed as a tool - it's automatically injected by the system.
*
* @deprecated The notebook tool is no longer injected into the LLM tools
* engine. This executor is retained so that legacy tool-call messages can
* still resolve on execution. New flows should use
* `@lobechat/builtin-tool-agent-documents`.
*/
import { BaseExecutor, type BuiltinToolContext, type BuiltinToolResult } from '@lobechat/types';
@@ -3,6 +3,14 @@ import type { BuiltinToolManifest } from '@lobechat/types';
import { systemPrompt } from './systemRole';
import { NotebookApiName, NotebookIdentifier } from './types';
/**
* @deprecated Notebook is no longer injected into the LLM tools engine.
* Use `@lobechat/builtin-tool-agent-documents` for new document tooling; the
* topic → page flow now calls `agentDocumentService.createForTopic`, which
* creates an agent document and records a `topic_documents` association in
* the same transaction. This manifest is kept for type/import compatibility
* with legacy code that still references `NotebookManifest.identifier`.
*/
export const NotebookManifest: BuiltinToolManifest = {
api: [
{
@@ -1,3 +1,14 @@
/**
* @deprecated The notebook builtin tool is deprecated. Topic-scoped documents
* now flow through the agent-documents pipeline (see
* `@lobechat/builtin-tool-agent-documents`) while retaining the
* `topic_documents` junction for topic-scoped listing.
*
* The manifest is no longer registered with the LLM tools engine, so the model
* can no longer invoke this tool. The identifier, executor, and server runtime
* are preserved only to keep legacy tool-call messages rendering and
* executing. Do not use these exports in new code.
*/
export const NotebookIdentifier = 'lobe-notebook';
export const NotebookApiName = {
@@ -5,6 +5,8 @@ import type { ChatTopic } from '../topic';
* Application context for message storage
*/
export interface ExecAgentAppContext {
/** Current document ID for page-scoped conversations */
documentId?: string | null;
/** Group ID for group chat */
groupId?: string | null;
/** Scope identifier */
+6
View File
@@ -118,6 +118,12 @@ export interface MessageMapContext {
*/
export interface ConversationContext {
agentId: string;
/**
* Current document ID for page-scoped conversations.
* Used by page editor integrations to distinguish the active document from
* other agent resources tied to the same topic.
*/
documentId?: string;
/**
* Group ID for group conversations
* Used when scope is 'group' or 'group_agent'
+11
View File
@@ -405,6 +405,12 @@ export interface BuiltinToolContext {
*/
agentId?: string;
/**
* The current page document ID when the conversation is scoped to an open editor
* Uses the underlying `documents.id`, not tool-specific association IDs
*/
documentId?: string | null;
/**
* The current group ID (only available in group chat context)
* Used by group management tools to access group member information
@@ -446,6 +452,11 @@ export interface BuiltinToolContext {
*/
registerAfterCompletion?: (callback: AfterCompletionCallback) => void;
/**
* Conversation scope captured when the operation was created
*/
scope?: string | null;
/**
* AbortSignal for cancellation detection
*/
+1
View File
@@ -1,5 +1,6 @@
export { openFileSelector } from './actions';
export { default as AutoSaveHint, type AutoSaveHintProps } from './AutoSaveHint';
export { default as DiffAllToolbar } from './DiffAllToolbar';
export {
EditorCanvas,
type EditorCanvasProps,
+46 -2
View File
@@ -1,4 +1,5 @@
import { render } from '@testing-library/react';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { __resetFloatingChatPanelRegistry } from './guard';
@@ -8,6 +9,35 @@ vi.mock('./ChatBody', () => ({
default: () => <div data-testid="chat-body">body</div>,
}));
vi.mock('@lobehub/ui/base-ui', () => ({
FloatingSheet: ({
children,
dismissible,
headerActions,
snapPoints,
title,
variant,
}: {
children: ReactNode;
dismissible?: boolean;
headerActions?: ReactNode;
snapPoints?: number[];
title?: ReactNode;
variant?: string;
}) => (
<div
data-dismissible={String(dismissible)}
data-snap-points={JSON.stringify(snapPoints ?? [])}
data-testid="floating-panel-shell"
data-variant={variant ?? ''}
>
<div data-testid="sheet-title">{title}</div>
<div data-testid="sheet-actions">{headerActions}</div>
{children}
</div>
),
}));
vi.mock('@/features/Conversation', () => ({
ChatList: () => null,
ConversationProvider: ({ children, context }: any) => (
@@ -62,6 +92,20 @@ describe('FloatingChatPanel', () => {
expect(ctx.threadId).toBe('thread-1');
});
it('supports page-scoped context with the active document id', () => {
const { getByTestId } = render(
<FloatingChatPanel agentId="agent-1" documentId="doc-1" scope="page" topicId="topic-1" />,
);
const ctx = JSON.parse(getByTestId('provider').dataset.context!);
expect(ctx).toEqual({
agentId: 'agent-1',
documentId: 'doc-1',
scope: 'page',
threadId: null,
topicId: 'topic-1',
});
});
it('forwards title and headerActions to floating panel header', () => {
const { getByTestId } = render(
<FloatingChatPanel
@@ -78,8 +122,8 @@ describe('FloatingChatPanel', () => {
it('applies default shell props', () => {
const { getByTestId } = render(<FloatingChatPanel agentId="a" topicId="t" />);
const sheet = getByTestId('floating-panel-shell');
expect(sheet.dataset.snapPoints).toBe(JSON.stringify([0.5, 0.9]));
expect(sheet.dataset.variant).toBe('elevated');
expect(sheet.dataset.snapPoints).toBe(JSON.stringify([180, 320, 520]));
expect(sheet.dataset.variant).toBe('embedded');
expect(sheet.dataset.dismissible).toBe('false');
});
});
+52 -10
View File
@@ -6,7 +6,11 @@ import { createStaticStyles } from 'antd-style';
import type { ReactNode } from 'react';
import { memo, useMemo, useState } from 'react';
import { type ActionsBarConfig, ConversationProvider } from '@/features/Conversation';
import {
type ActionsBarConfig,
type ConversationHooks,
ConversationProvider,
} from '@/features/Conversation';
import { type ConversationContext } from '@/features/Conversation/types';
import { useOperationState } from '@/hooks/useOperationState';
import { useActionsBarConfig } from '@/routes/(main)/agent/features/Conversation/useActionsBarConfig';
@@ -16,6 +20,10 @@ import { messageMapKey } from '@/store/chat/utils/messageMapKey';
import ChatBody from './ChatBody';
import { useSingleInstanceGuard } from './guard';
const SNAP_POINTS = [180, 320, 520, 800] as const;
const MAX_SNAP_POINT = SNAP_POINTS.at(-1)!;
const REST_SNAP_POINT = SNAP_POINTS[0];
const styles = createStaticStyles(({ css }) => ({
sheet: css`
overflow: hidden;
@@ -59,13 +67,23 @@ export interface FloatingChatPanelProps {
agentId: string;
className?: string;
dismissible?: boolean;
/** Current document identifier for page-scoped conversations. */
documentId?: string;
headerActions?: ReactNode;
/**
* Conversation lifecycle hooks. Forwarded into the internal
* `ConversationProvider`. The panel wraps `onAfterSendMessage` to auto-expand
* the sheet to its tallest snap point on send.
*/
hooks?: ConversationHooks;
maxHeight?: number;
minHeight?: number;
mode?: 'embedded' | 'overlay';
onOpenChange?: (open: boolean) => void;
onSnapPointChange?: (point: number) => void;
open?: boolean;
/** Optional conversation scope override for non-thread contexts. */
scope?: 'main' | 'page';
snapPoints?: number[];
/** Optional thread identifier. When provided, scope becomes `'thread'`. */
threadId?: string | null;
@@ -96,10 +114,13 @@ const FloatingChatPanel = memo<FloatingChatPanelProps>(
agentId,
topicId,
threadId = null,
documentId,
scope,
actionsBar,
hooks,
minHeight = 240,
maxHeight = 0.9,
minHeight: _minHeight = 240,
maxHeight: _maxHeight = 0.9,
width = '100%',
@@ -111,11 +132,12 @@ const FloatingChatPanel = memo<FloatingChatPanelProps>(
const context = useMemo<ConversationContext>(
() => ({
agentId,
scope: threadId ? 'thread' : 'main',
documentId,
scope: threadId ? 'thread' : (scope ?? 'main'),
threadId,
topicId,
}),
[agentId, topicId, threadId],
[agentId, documentId, scope, topicId, threadId],
);
const chatKey = useMemo(() => messageMapKey(context), [context]);
@@ -134,22 +156,41 @@ const FloatingChatPanel = memo<FloatingChatPanelProps>(
);
const [open, setOpen] = useState(true);
const [activeSnapPoint, setActiveSnapPoint] = useState<number>(REST_SNAP_POINT);
const mergedHooks = useMemo<ConversationHooks>(
() => ({
...hooks,
// Expand the sheet the moment the user presses Send, so the chat grows
// into view before the AI response streams in — not after it finishes.
onBeforeSendMessage: async (params) => {
setActiveSnapPoint(MAX_SNAP_POINT);
return hooks?.onBeforeSendMessage?.(params);
},
}),
[hooks],
);
const sheetProps: FloatingSheetProps = {
activeSnapPoint,
className: 'floating-sheet-demo-inline',
closeThreshold: 0.3,
defaultOpen: true,
dismissible: false,
headerActions,
maxHeight: 520,
minHeight: 320,
maxHeight: MAX_SNAP_POINT,
minHeight: SNAP_POINTS[1],
mode: 'inline',
onOpenChange: setOpen,
onSnapPointChange: setActiveSnapPoint,
open,
restingHeight: 180,
snapPoints: [180, 320, 520],
restingHeight: REST_SNAP_POINT,
snapPoints: [...SNAP_POINTS],
title,
variant: 'embedded',
width: '100%',
width,
};
return (
@@ -159,6 +200,7 @@ const FloatingChatPanel = memo<FloatingChatPanelProps>(
actionsBar={resolvedActionsBar}
context={context}
hasInitMessages={!!messages}
hooks={mergedHooks}
messages={messages}
operationState={operationState}
onMessagesChange={handleMessagesChange}
+39 -103
View File
@@ -1,124 +1,60 @@
'use client';
import { Button, Flexbox, Icon, TextArea } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { SmilePlus } from 'lucide-react';
import { Flexbox, TextArea } from '@lobehub/ui';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import EmojiPicker from '@/components/EmojiPicker';
import { useGlobalStore } from '@/store/global';
import { globalGeneralSelectors } from '@/store/global/selectors';
import { truncateByWeightedLength } from '@/utils/textLength';
export interface TitleSectionProps {
emoji?: string;
onEmojiChange?: (emoji: string | undefined) => void;
onTitleChange?: (title: string) => void;
title?: string;
}
const TitleSection = memo<TitleSectionProps>(
({ emoji: emojiProp, title: titleProp, onEmojiChange, onTitleChange }) => {
const { t } = useTranslation('file');
const locale = useGlobalStore(globalGeneralSelectors.currentLanguage);
const TitleSection = memo<TitleSectionProps>(({ title: titleProp, onTitleChange }) => {
const { t } = useTranslation('file');
const isTitleControlled = titleProp !== undefined;
const isEmojiControlled = onEmojiChange !== undefined;
const isTitleControlled = titleProp !== undefined;
const [innerTitle, setInnerTitle] = useState('');
const [innerEmoji, setInnerEmoji] = useState<string | undefined>();
const [innerTitle, setInnerTitle] = useState('');
const title = isTitleControlled ? titleProp : innerTitle;
const emoji = isEmojiControlled ? emojiProp : innerEmoji;
const title = isTitleControlled ? titleProp : innerTitle;
const setTitle = (value: string) => {
if (!isTitleControlled) setInnerTitle(value);
onTitleChange?.(value);
};
const setEmoji = (value: string | undefined) => {
if (!isEmojiControlled) setInnerEmoji(value);
onEmojiChange?.(value);
};
const setTitle = (value: string) => {
if (!isTitleControlled) setInnerTitle(value);
onTitleChange?.(value);
};
const [isHoveringTitle, setIsHoveringTitle] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
return (
<Flexbox
gap={16}
paddingBlock={16}
style={{ cursor: 'default' }}
onMouseEnter={() => setIsHoveringTitle(true)}
onMouseLeave={() => setIsHoveringTitle(false)}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
return (
<Flexbox
gap={16}
paddingBlock={16}
style={{ cursor: 'default' }}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<TextArea
autoSize={{ minRows: 1 }}
placeholder={t('pageEditor.titlePlaceholder')}
value={title}
variant={'borderless'}
style={{
fontSize: 36,
fontWeight: 600,
padding: 0,
resize: 'none',
width: '100%',
}}
>
{(emoji || showEmojiPicker) && (
<EmojiPicker
allowDelete
locale={locale}
open={showEmojiPicker}
shape={'square'}
size={72}
title={t('pageEditor.chooseIcon')}
value={emoji}
onChange={(e) => {
setEmoji(e);
setShowEmojiPicker(false);
}}
onDelete={() => {
setEmoji(undefined);
setShowEmojiPicker(false);
}}
onOpenChange={(open) => {
setShowEmojiPicker(open);
}}
/>
)}
{!emoji && !showEmojiPicker && (
<Button
icon={<Icon icon={SmilePlus} />}
size="small"
type="text"
style={{
opacity: isHoveringTitle ? 1 : 0,
transition: `opacity ${cssVar.motionDurationMid} ${cssVar.motionEaseInOut}`,
width: 'fit-content',
}}
onClick={() => {
setEmoji('📄');
setShowEmojiPicker(true);
}}
>
{t('pageEditor.chooseIcon')}
</Button>
)}
<TextArea
autoSize={{ minRows: 1 }}
placeholder={t('pageEditor.titlePlaceholder')}
value={title}
variant={'borderless'}
style={{
fontSize: 36,
fontWeight: 600,
padding: 0,
resize: 'none',
width: '100%',
}}
onChange={(e) => {
const truncated = truncateByWeightedLength(e.target.value, 100);
setTitle(truncated);
}}
/>
</Flexbox>
);
},
);
onChange={(e) => {
const truncated = truncateByWeightedLength(e.target.value, 100);
setTitle(truncated);
}}
/>
</Flexbox>
);
});
TitleSection.displayName = 'TopicCanvasTitleSection';
+109
View File
@@ -0,0 +1,109 @@
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import TopicCanvas from './index';
const mockEditor = {
focus: vi.fn(),
getDocument: vi.fn(() => ({ root: true })),
} as const;
const runtimeSpies = vi.hoisted(() => ({
setBeforeMutateHandler: vi.fn(),
setCurrentDocId: vi.fn(),
setEditor: vi.fn(),
setTitleHandlers: vi.fn(),
}));
vi.mock('@lobehub/editor/react', () => ({
EditorProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
useEditor: () => mockEditor,
}));
vi.mock('@lobehub/ui', () => ({
Flexbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
<div {...props}>{children}</div>
),
TextArea: ({
value,
onChange,
}: {
onChange?: (event: { target: { value: string } }) => void;
value?: string;
}) => (
<textarea
data-testid="topic-title-input"
value={value ?? ''}
onChange={(event) => onChange?.({ target: { value: event.target.value } })}
/>
),
}));
vi.mock('@/features/EditorCanvas', () => ({
DiffAllToolbar: ({ documentId, editor }: { documentId: string; editor: typeof mockEditor }) => (
<div
data-document-id={documentId}
data-editor={editor === mockEditor ? 'shared' : 'other'}
data-testid="diff-toolbar"
/>
),
EditorCanvas: ({ documentId, editor }: { documentId?: string; editor?: typeof mockEditor }) => (
<div
data-document-id={documentId ?? ''}
data-editor={editor === mockEditor ? 'shared' : 'other'}
data-testid="editor-canvas"
/>
),
}));
vi.mock('@/features/WideScreenContainer', () => ({
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/hooks/useHotkeys', () => ({
useRegisterFilesHotkeys: vi.fn(),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('@/services/documentHistoryQueue', () => ({
documentHistoryQueueService: {
enqueue: vi.fn(),
flush: vi.fn(),
},
}));
vi.mock('@/store/tool/slices/builtin/executors/lobe-page-agent', () => ({
pageAgentRuntime: runtimeSpies,
}));
afterEach(() => {
vi.clearAllMocks();
});
describe('TopicCanvas', () => {
it('mounts diff toolbar and page-agent runtime bridge for the current document', () => {
const { unmount } = render(
<TopicCanvas documentId="doc-1" title="Topic Title" onTitleChange={vi.fn()} />,
);
expect(screen.getByTestId('editor-canvas')).toHaveAttribute('data-document-id', 'doc-1');
expect(screen.getByTestId('diff-toolbar')).toHaveAttribute('data-document-id', 'doc-1');
expect(screen.getByTestId('diff-toolbar')).toHaveAttribute('data-editor', 'shared');
expect(runtimeSpies.setEditor).toHaveBeenCalledWith(mockEditor);
expect(runtimeSpies.setCurrentDocId).toHaveBeenCalledWith('doc-1');
expect(runtimeSpies.setTitleHandlers).toHaveBeenCalledTimes(1);
expect(runtimeSpies.setBeforeMutateHandler).toHaveBeenCalledWith(expect.any(Function));
unmount();
expect(runtimeSpies.setCurrentDocId).toHaveBeenLastCalledWith(undefined);
expect(runtimeSpies.setTitleHandlers).toHaveBeenLastCalledWith(null, null);
expect(runtimeSpies.setBeforeMutateHandler).toHaveBeenLastCalledWith(null);
expect(runtimeSpies.setEditor).toHaveBeenLastCalledWith(null);
});
});
+103 -27
View File
@@ -4,11 +4,13 @@ import { EditorProvider, useEditor } from '@lobehub/editor/react';
import { Flexbox } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import type { CSSProperties } from 'react';
import { memo } from 'react';
import { memo, useEffect, useRef } from 'react';
import { EditorCanvas as SharedEditorCanvas } from '@/features/EditorCanvas';
import { DiffAllToolbar, EditorCanvas as SharedEditorCanvas } from '@/features/EditorCanvas';
import WideScreenContainer from '@/features/WideScreenContainer';
import { useRegisterFilesHotkeys } from '@/hooks/useHotkeys';
import { documentHistoryQueueService } from '@/services/documentHistoryQueue';
import { pageAgentRuntime } from '@/store/tool/slices/builtin/executors/lobe-page-agent';
import { StyleSheet } from '@/utils/styles';
import TitleSection, { type TitleSectionProps } from './TitleSection';
@@ -19,6 +21,10 @@ const styles = StyleSheet.create({
overflowY: 'auto',
position: 'relative',
},
editorContainer: {
minWidth: 0,
position: 'relative',
},
editorContent: {
overflowY: 'auto',
paddingBlock: 16,
@@ -39,38 +45,108 @@ export interface TopicCanvasProps extends TitleSectionProps {
topicId?: string | null;
}
type PageAgentEditor = NonNullable<Parameters<typeof pageAgentRuntime.setEditor>[0]>;
const TopicCanvasPageAgentBridge = memo<
Pick<TopicCanvasProps, 'documentId' | 'onTitleChange' | 'title'>
>(({ documentId, onTitleChange, title }) => {
const editor = useEditor();
const pageAgentEditor = editor as unknown as PageAgentEditor | undefined;
const titleRef = useRef(title ?? '');
const onTitleChangeRef = useRef(onTitleChange);
useEffect(() => {
titleRef.current = title ?? '';
}, [title]);
useEffect(() => {
onTitleChangeRef.current = onTitleChange;
}, [onTitleChange]);
useEffect(() => {
if (pageAgentEditor) {
pageAgentRuntime.setEditor(pageAgentEditor);
}
return () => {
pageAgentRuntime.setEditor(null);
};
}, [pageAgentEditor]);
useEffect(() => {
pageAgentRuntime.setCurrentDocId(documentId);
pageAgentRuntime.setTitleHandlers(
(nextTitle) => {
titleRef.current = nextTitle;
onTitleChangeRef.current?.(nextTitle);
},
() => titleRef.current,
);
pageAgentRuntime.setBeforeMutateHandler(() => {
if (!documentId || !editor) return;
try {
const editorData = editor.getDocument('json');
documentHistoryQueueService.enqueue({
documentId,
editorData: JSON.stringify(editorData),
saveSource: 'llm_call',
});
} catch (error) {
console.error('[TopicCanvas] Failed to capture history snapshot before mutation:', error);
}
});
return () => {
pageAgentRuntime.setCurrentDocId(undefined);
pageAgentRuntime.setTitleHandlers(null, null);
pageAgentRuntime.setBeforeMutateHandler(null);
void documentHistoryQueueService.flush();
};
}, [documentId, editor]);
return null;
});
TopicCanvasPageAgentBridge.displayName = 'TopicCanvasPageAgentBridge';
const TopicCanvasBody = memo<TopicCanvasProps>(
({ placeholder, style, emoji, title, documentId, onEmojiChange, onTitleChange }) => {
({ placeholder, style, title, documentId, onTitleChange }) => {
const editor = useEditor();
useRegisterFilesHotkeys();
return (
<Flexbox
horizontal
height={'100%'}
style={styles.contentWrapper}
width={'100%'}
onClick={() => editor?.focus()}
>
<WideScreenContainer wrapperStyle={{ cursor: 'text' }}>
<Flexbox flex={1} style={styles.editorContent}>
<TitleSection
emoji={emoji}
title={title}
onEmojiChange={onEmojiChange}
onTitleChange={onTitleChange}
/>
<SharedEditorCanvas
documentId={documentId}
editor={editor}
placeholder={placeholder}
sourceType={'notebook'}
style={style}
/>
<>
<TopicCanvasPageAgentBridge
documentId={documentId}
title={title}
onTitleChange={onTitleChange}
/>
<Flexbox
horizontal
height={'100%'}
style={styles.contentWrapper}
width={'100%'}
onClick={() => editor?.focus()}
>
<Flexbox flex={1} height={'100%'} style={styles.editorContainer}>
<WideScreenContainer wrapperStyle={{ cursor: 'text' }}>
<Flexbox flex={1} style={styles.editorContent}>
<TitleSection title={title} onTitleChange={onTitleChange} />
<SharedEditorCanvas
documentId={documentId}
editor={editor}
placeholder={placeholder}
sourceType={'notebook'}
style={style}
/>
</Flexbox>
</WideScreenContainer>
{documentId && editor && <DiffAllToolbar documentId={documentId} editor={editor} />}
</Flexbox>
</WideScreenContainer>
</Flexbox>
</Flexbox>
</>
);
},
);
@@ -0,0 +1,113 @@
/**
* @vitest-environment happy-dom
*/
import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAutoCreateTopicDocument } from './useAutoCreateTopicDocument';
const createForTopicMock = vi.hoisted(() => vi.fn());
const mutateMock = vi.hoisted(() => vi.fn());
const notebookMock = vi.hoisted(() => ({
documents: [] as Array<{ id: string }>,
isLoading: false,
useFetchDocuments: vi.fn(),
}));
const chatMock = vi.hoisted(() => ({
topicTitle: 'Research Topic',
}));
interface MockNotebookState {
notebookMap: Record<string, Array<{ id: string }>>;
useFetchDocuments: typeof notebookMock.useFetchDocuments;
}
interface MockChatState {
topicDataMap: Record<string, { items: Array<{ id: string; title: string }> }>;
}
vi.mock('@/libs/swr', () => ({
mutate: mutateMock,
}));
vi.mock('@/services/agentDocument', () => ({
agentDocumentService: {
createForTopic: createForTopicMock,
},
}));
vi.mock('@/store/chat', () => ({
useChatStore: (selector: (state: MockChatState) => unknown) =>
selector({
topicDataMap: {
'agent:agt_test': { items: [{ id: 'tpc_test', title: chatMock.topicTitle }] },
},
}),
}));
vi.mock('@/store/chat/selectors', () => ({
topicSelectors: {
getTopicById: (id: string) => (state: MockChatState) =>
Object.values(state.topicDataMap)
.flatMap((data) => data.items)
.find((topic) => topic.id === id),
},
}));
vi.mock('@/store/notebook/action', () => ({
SWR_USE_FETCH_NOTEBOOK_DOCUMENTS: 'SWR_USE_FETCH_NOTEBOOK_DOCUMENTS',
}));
vi.mock('@/store/notebook', () => ({
notebookSelectors: {
getDocumentsByTopicId: (topicId: string | undefined) => (state: MockNotebookState) => {
if (!topicId) return [];
return state.notebookMap[topicId] ?? [];
},
},
useNotebookStore: (selector: (state: MockNotebookState) => unknown) =>
selector({
notebookMap: { tpc_test: notebookMock.documents },
useFetchDocuments: notebookMock.useFetchDocuments,
}),
}));
describe('useAutoCreateTopicDocument', () => {
beforeEach(() => {
createForTopicMock.mockReset();
mutateMock.mockReset();
chatMock.topicTitle = 'Research Topic';
notebookMock.documents = [];
notebookMock.isLoading = false;
notebookMock.useFetchDocuments.mockReset();
notebookMock.useFetchDocuments.mockImplementation(() => ({
isLoading: notebookMock.isLoading,
}));
});
it('returns an existing topic document id without creating a new page', () => {
notebookMock.documents = [{ id: 'doc_existing' }];
const { result } = renderHook(() => useAutoCreateTopicDocument('tpc_test', 'agt_test'));
expect(result.current.documentId).toBe('doc_existing');
expect(createForTopicMock).not.toHaveBeenCalled();
});
it('creates a topic page and exposes the created document id before the list refresh is observed', async () => {
createForTopicMock.mockResolvedValue({ documentId: 'doc_created' });
mutateMock.mockResolvedValue(undefined);
const { result } = renderHook(() => useAutoCreateTopicDocument('tpc_test', 'agt_test'));
await waitFor(() => expect(result.current.documentId).toBe('doc_created'));
expect(createForTopicMock).toHaveBeenCalledWith({
agentId: 'agt_test',
content: '',
title: 'Research Topic',
topicId: 'tpc_test',
});
expect(mutateMock).toHaveBeenCalledWith(['SWR_USE_FETCH_NOTEBOOK_DOCUMENTS', 'tpc_test']);
});
});
@@ -1,57 +1,98 @@
'use client';
import type { NotebookDocument } from '@lobechat/types';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { mutate } from '@/libs/swr';
import { agentDocumentService } from '@/services/agentDocument';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import { notebookSelectors, useNotebookStore } from '@/store/notebook';
import { SWR_USE_FETCH_NOTEBOOK_DOCUMENTS } from '@/store/notebook/action';
interface UseAutoCreateTopicDocumentResult {
document: NotebookDocument | undefined;
documentId: string | undefined;
isLoading: boolean;
}
const inflight = new Map<string, Promise<unknown>>();
const inflight = new Map<string, Promise<string | undefined>>();
/**
* Fetch the topic-scoped notebook document for a topic; auto-create one when
* the list is empty. Returns the first document (topic → page is 1:1 in practice).
* Fetch the topic-scoped document for a topic; auto-create one via the
* agent-document pipeline when the list is empty, then associate it with the
* topic through `topic_documents`. Returns the first document (topic → page is
* 1:1 in practice).
*
* Deduplicates concurrent creations across component instances via a module-level
* promise map keyed by topicId.
* Deduplicates concurrent creations across component instances via a
* module-level promise map keyed by topicId.
*/
export const useAutoCreateTopicDocument = (
topicId: string | undefined,
agentId: string | undefined,
): UseAutoCreateTopicDocumentResult => {
const useFetchDocuments = useNotebookStore((s) => s.useFetchDocuments);
const createDocument = useNotebookStore((s) => s.createDocument);
const topicTitle = useChatStore((s) =>
topicId ? topicSelectors.getTopicById(topicId)(s)?.title : undefined,
);
const [createdDocument, setCreatedDocument] = useState<
{ documentId: string; topicId: string } | undefined
>();
const { isLoading } = useFetchDocuments(topicId);
const documents = useNotebookStore(notebookSelectors.getDocumentsByTopicId(topicId));
useEffect(() => {
if (!topicId || isLoading) return;
if (!topicId || !agentId || isLoading) return;
if (documents.length > 0) return;
if (inflight.has(topicId)) return;
const promise = createDocument({
content: '',
description: '',
title: '',
topicId,
type: 'markdown',
})
.catch((error) => {
console.error('[TopicCanvas] Failed to auto-create topic document:', error);
})
.finally(() => {
inflight.delete(topicId);
});
let ignore = false;
const promise =
inflight.get(topicId) ??
agentDocumentService
.createForTopic({
agentId,
content: '',
title: topicTitle?.trim() || '',
topicId,
})
.then(async (document) => {
try {
await mutate([SWR_USE_FETCH_NOTEBOOK_DOCUMENTS, topicId]);
} catch (error) {
console.error('[TopicCanvas] Failed to refresh topic documents:', error);
}
inflight.set(topicId, promise);
}, [topicId, isLoading, documents.length, createDocument]);
return document.documentId;
})
.catch((error) => {
console.error('[TopicCanvas] Failed to auto-create topic document:', error);
return undefined;
})
.finally(() => {
inflight.delete(topicId);
});
if (!inflight.has(topicId)) inflight.set(topicId, promise);
void promise.then((documentId) => {
if (ignore || !documentId) return;
setCreatedDocument({ documentId, topicId });
});
return () => {
ignore = true;
};
}, [topicId, agentId, topicTitle, isLoading, documents.length]);
const document = documents[0];
const createdDocumentId =
createdDocument && createdDocument.topicId === topicId ? createdDocument.documentId : undefined;
return {
document: documents[0],
document,
documentId: document?.id ?? createdDocumentId,
isLoading,
};
};
@@ -0,0 +1,158 @@
/**
* @vitest-environment happy-dom
*/
import { fireEvent, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import Nav from './Nav';
const mutateMock = vi.hoisted(() => vi.fn());
const openNewTopicOrSaveTopicMock = vi.hoisted(() => vi.fn());
const pushMock = vi.hoisted(() => vi.fn());
const switchTopicMock = vi.hoisted(() => vi.fn());
const toggleCommandMenuMock = vi.hoisted(() => vi.fn());
const useParamsMock = vi.hoisted(() => vi.fn());
const usePathnameMock = vi.hoisted(() => vi.fn());
vi.mock('@lobehub/ui', () => ({
Flexbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
<div {...props}>{children}</div>
),
}));
vi.mock('@lobehub/ui/icons', () => ({
BotPromptIcon: () => null,
}));
vi.mock('lucide-react', () => ({
MessageSquarePlusIcon: () => null,
RadioTowerIcon: () => null,
SearchIcon: () => null,
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('react-router-dom', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = (await vi.importActual('react-router-dom')) as typeof import('react-router-dom');
return {
...actual,
useParams: useParamsMock,
};
});
vi.mock('@/const/url', () => ({
SESSION_CHAT_URL: (agentId: string) => `/agent/${agentId}`,
}));
vi.mock('@/features/NavPanel/components/NavItem', () => ({
default: ({
active,
onClick,
title,
}: {
active?: boolean;
onClick?: () => void;
title: ReactNode;
}) => (
<button data-active={String(active)} type="button" onClick={onClick}>
{title}
</button>
),
}));
vi.mock('@/hooks/useQueryRoute', () => ({
useQueryRoute: () => ({
push: pushMock,
}),
}));
vi.mock('@/libs/router/navigation', () => ({
usePathname: usePathnameMock,
}));
vi.mock('@/libs/swr', () => ({
useActionSWR: () => ({
mutate: mutateMock,
}),
}));
vi.mock('@/store/agent', () => ({
useAgentStore: (selector: (state: unknown) => unknown) => selector({}),
}));
vi.mock('@/store/agent/selectors', () => ({
agentSelectors: {
isCurrentAgentHeterogeneous: () => false,
},
}));
vi.mock('@/store/chat', () => ({
useChatStore: (
selector: (state: {
openNewTopicOrSaveTopic: () => void;
switchTopic: (topicId: string | null, options?: unknown) => void;
}) => unknown,
) =>
selector({
openNewTopicOrSaveTopic: openNewTopicOrSaveTopicMock,
switchTopic: switchTopicMock,
}),
}));
vi.mock('@/store/global', () => ({
useGlobalStore: (selector: (state: { toggleCommandMenu: (open: boolean) => void }) => unknown) =>
selector({ toggleCommandMenu: toggleCommandMenuMock }),
}));
vi.mock('@/store/serverConfig', () => ({
featureFlagsSelectors: (state: { featureFlags: { isAgentEditable: boolean } }) =>
state.featureFlags,
useServerConfigStore: (
selector: (state: { featureFlags: { isAgentEditable: boolean } }) => unknown,
) => selector({ featureFlags: { isAgentEditable: true } }),
}));
describe('Agent sidebar header nav', () => {
beforeEach(() => {
mutateMock.mockReset();
openNewTopicOrSaveTopicMock.mockReset();
pushMock.mockReset();
switchTopicMock.mockReset();
toggleCommandMenuMock.mockReset();
useParamsMock.mockReset();
usePathnameMock.mockReset();
useParamsMock.mockReturnValue({ aid: 'agt_eH4zL98zBx5u', topicId: 'tpc_2FCHvjS7d4CA' });
});
it('returns to the agent chat route before opening a new topic from a topic page document route', () => {
usePathnameMock.mockReturnValue(
'/agent/agt_eH4zL98zBx5u/tpc_2FCHvjS7d4CA/page/docs_9B8hFkmEOZyPZb60',
);
render(<Nav />);
fireEvent.click(screen.getByRole('button', { name: 'actions.addNewTopic' }));
expect(pushMock).toHaveBeenCalledWith('/agent/agt_eH4zL98zBx5u');
expect(mutateMock).toHaveBeenCalledTimes(1);
});
it('pushes the agent chat route even when already on it', () => {
usePathnameMock.mockReturnValue('/agent/agt_eH4zL98zBx5u');
render(<Nav />);
fireEvent.click(screen.getByRole('button', { name: 'actions.addNewTopic' }));
expect(pushMock).toHaveBeenCalledWith('/agent/agt_eH4zL98zBx5u');
expect(mutateMock).toHaveBeenCalledTimes(1);
});
});
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import urlJoin from 'url-join';
import { SESSION_CHAT_URL } from '@/const/url';
import NavItem from '@/features/NavPanel/components/NavItem';
import { useQueryRoute } from '@/hooks/useQueryRoute';
import { usePathname } from '@/libs/router/navigation';
@@ -37,9 +38,8 @@ const Nav = memo(() => {
const { mutate } = useActionSWR('openNewTopicOrSaveTopic', openNewTopicOrSaveTopic);
const handleNewTopic = () => {
// If in agent sub-route, navigate back to agent chat first
if ((isProfileActive || isChannelActive) && agentId) {
router.push(urlJoin('/agent', agentId));
if (agentId) {
router.push(SESSION_CHAT_URL(agentId));
}
mutate();
};
@@ -0,0 +1,68 @@
/**
* @vitest-environment happy-dom
*/
import { render, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import PageRedirect from './PageRedirect';
const navigateMock = vi.hoisted(() => vi.fn());
const useAutoCreateTopicDocumentMock = vi.hoisted(() => vi.fn());
const useParamsMock = vi.hoisted(() => vi.fn());
vi.mock('react-router-dom', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = (await vi.importActual('react-router-dom')) as typeof import('react-router-dom');
return {
...actual,
useNavigate: () => navigateMock,
useParams: useParamsMock,
};
});
vi.mock('@/components/Loading/BrandTextLoading', () => ({
default: ({ debugId }: { debugId: string }) => <div data-testid={debugId} />,
}));
vi.mock('@/features/TopicCanvas/useAutoCreateTopicDocument', () => ({
useAutoCreateTopicDocument: useAutoCreateTopicDocumentMock,
}));
describe('PageRedirect', () => {
beforeEach(() => {
navigateMock.mockReset();
useAutoCreateTopicDocumentMock.mockReset();
useParamsMock.mockReset();
});
it('redirects to the page created for an empty topic', async () => {
useParamsMock.mockReturnValue({ aid: 'agt_test', topicId: 'tpc_test' });
useAutoCreateTopicDocumentMock.mockReturnValue({
document: undefined,
documentId: 'doc_created',
isLoading: false,
});
render(<PageRedirect />);
await waitFor(() =>
expect(navigateMock).toHaveBeenCalledWith('/agent/agt_test/tpc_test/page/doc_created', {
replace: true,
}),
);
});
it('keeps the loading state while the topic document id is not available', () => {
useParamsMock.mockReturnValue({ aid: 'agt_test', topicId: 'tpc_test' });
useAutoCreateTopicDocumentMock.mockReturnValue({
document: undefined,
documentId: undefined,
isLoading: true,
});
render(<PageRedirect />);
expect(navigateMock).not.toHaveBeenCalled();
});
});
@@ -10,12 +10,12 @@ const PageRedirect = memo(() => {
const { aid, topicId } = useParams<{ aid?: string; topicId?: string }>();
const navigate = useNavigate();
const { document } = useAutoCreateTopicDocument(topicId);
const { documentId } = useAutoCreateTopicDocument(topicId, aid);
useEffect(() => {
if (!aid || !topicId || !document?.id) return;
navigate(`/agent/${aid}/${topicId}/page/${document.id}`, { replace: true });
}, [aid, topicId, document?.id, navigate]);
if (!aid || !topicId || !documentId) return;
navigate(`/agent/${aid}/${topicId}/page/${documentId}`, { replace: true });
}, [aid, topicId, documentId, navigate]);
return <BrandTextLoading debugId={'PageRedirect'} />;
});
@@ -25,6 +25,12 @@ vi.mock('swr', () => ({
mutate: vi.fn(),
}));
vi.mock('@/features/EditorCanvas', () => ({
AutoSaveHint: ({ documentId }: { documentId: string }) => (
<div data-document-id={documentId} data-testid="autosave-hint" />
),
}));
vi.mock('@/libs/swr', () => ({
useClientDataSWR: () => ({ data: null, error: undefined, isLoading: false }),
}));
@@ -40,6 +46,21 @@ vi.mock('@/services/agentDocument', () => ({
agentDocumentSWRKeys: { documentsList: (id: string) => ['agent-documents-list', id] },
}));
vi.mock('@/store/document', () => ({
documentEvents: { subscribe: vi.fn(() => vi.fn()) },
useDocumentStore: Object.assign(
vi.fn(() => undefined),
{
getState: () => ({
activeDocumentId: 'doc_test',
editor: undefined,
onEditorInit: vi.fn(),
performSave: vi.fn(),
}),
},
),
}));
vi.mock('@/store/agent', () => ({
useAgentStore: (selector: (s: { activeAgentId?: string }) => unknown) =>
selector({ activeAgentId: 'agt_test' }),
@@ -62,20 +83,26 @@ vi.mock('@lobehub/ui', () => ({
vi.mock('@/features/FloatingChatPanel', () => ({
default: ({
agentId,
documentId,
open,
scope,
title,
topicId,
variant,
}: {
agentId: string;
documentId?: string;
open?: boolean;
scope?: string;
title?: string;
topicId: string | null;
variant?: string;
}) => (
<div
data-agent-id={agentId}
data-document-id={documentId ?? ''}
data-open={String(open)}
data-scope={scope ?? ''}
data-testid="floating-chat-panel"
data-title={title ?? ''}
data-topic-id={topicId ?? 'null'}
@@ -119,9 +146,11 @@ describe('Topic page route', () => {
expect(screen.getByTestId('topic-canvas')).toHaveAttribute('data-document-id', 'doc_test');
expect(screen.getByTestId('floating-chat-panel')).toHaveAttribute('data-agent-id', 'agt_test');
expect(screen.getByTestId('floating-chat-panel')).toHaveAttribute(
'data-title',
'Floating Chat Panel',
'data-document-id',
'doc_test',
);
expect(screen.getByTestId('floating-chat-panel')).toHaveAttribute('data-scope', 'page');
expect(screen.getByTestId('floating-chat-panel')).toHaveAttribute('data-topic-id', 'tpc_test');
expect(screen.getByTestId('floating-chat-panel')).toHaveAttribute('data-variant', 'embedded');
});
@@ -4,17 +4,17 @@ import { Flexbox } from '@lobehub/ui';
import { debounce } from 'es-toolkit/compat';
import { memo, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { mutate } from 'swr';
import { AutoSaveHint } from '@/features/EditorCanvas';
import FloatingChatPanel from '@/features/FloatingChatPanel';
import TopicCanvas from '@/features/TopicCanvas';
import { useAutoCreateTopicDocument } from '@/features/TopicCanvas/useAutoCreateTopicDocument';
import { useClientDataSWR } from '@/libs/swr';
import { mutate, useClientDataSWR } from '@/libs/swr';
import HeaderSlot from '@/routes/(main)/agent/(chat)/_layout/HeaderSlot';
import { agentDocumentSWRKeys } from '@/services/agentDocument';
import { documentService } from '@/services/document';
import { useAgentStore } from '@/store/agent';
import { documentEvents, useDocumentStore } from '@/store/document';
import { SWR_USE_FETCH_NOTEBOOK_DOCUMENTS } from '@/store/notebook/action';
const MAX_PANEL_WIDTH = 1024;
@@ -25,7 +25,7 @@ const TopicPage = memo(() => {
const navigate = useNavigate();
const agentId = useAgentStore((s) => s.activeAgentId);
const { document: topicDocument } = useAutoCreateTopicDocument(topicId);
const { document: topicDocument } = useAutoCreateTopicDocument(topicId, agentId);
const [titleDraft, setTitleDraft] = useState<string | undefined>();
@@ -78,6 +78,38 @@ const TopicPage = memo(() => {
if (docId) debouncedSaveTitle(docId, next, { agentId, topicId });
};
// Refresh the editor when this document is mutated by an agent-documents
// write. Emissions come from two places:
// - `src/services/agentDocument.ts` (UI actions / client-dispatched tools):
// targeted events carrying `documentId`.
// - `src/store/chat/.../gatewayEventHandler.ts` (server-executed tools):
// broadcast events without `documentId` — we can't map agent_documents.id
// back to documents.id cheaply there.
// See `src/store/document/events.ts` for the contract.
useEffect(() => {
if (!docId) return;
return documentEvents.subscribe((event) => {
// Ignore targeted events aimed at a different document. Broadcast events
// (no documentId) fall through and refresh the current page.
if (event.documentId && event.documentId !== docId) return;
void mutate(['document/editor', docId]);
void mutate(['page-document-meta', docId]);
// Only re-hydrate the live editor for operations that change content.
// `rename` / `updateLoadRule` / `copy` leave content untouched — skip
// them to avoid stomping cursor state.
const contentMutating =
event.operation === 'edit' || event.operation === 'upsert' || event.operation === 'create';
if (!contentMutating) return;
const { activeDocumentId, editor, onEditorInit } = useDocumentStore.getState();
if (activeDocumentId === docId && editor) {
void onEditorInit(editor);
}
});
}, [docId]);
if (!aid || !topicId) return null;
const displayTitle = titleDraft ?? documentMeta?.title ?? '';
@@ -111,9 +143,10 @@ const TopicPage = memo(() => {
</Flexbox>
<FloatingChatPanel
agentId={aid}
documentId={docId}
maxHeight={0.92}
minHeight={320}
title={'Floating Chat Panel'}
scope={'page'}
topicId={topicId}
variant={'embedded'}
/>
@@ -1435,7 +1435,9 @@ export const createRuntimeExecutors = (
toolExecutionService.executeTool(chatToolPayload, {
activeDeviceId: state.metadata?.activeDeviceId,
agentId: state.metadata?.agentId,
documentId: state.metadata?.documentId,
memoryToolPermission: agentConfig?.chatConfig?.memory?.toolPermission,
scope: state.metadata?.scope,
serverDB: ctx.serverDB,
taskId: state.metadata?.taskId,
toolManifestMap: effectiveManifestMap,
@@ -1776,7 +1778,9 @@ export const createRuntimeExecutors = (
toolExecutionService.executeTool(chatToolPayload, {
activeDeviceId: state.metadata?.activeDeviceId,
agentId: state.metadata?.agentId,
documentId: state.metadata?.documentId,
memoryToolPermission: batchAgentConfig?.chatConfig?.memory?.toolPermission,
scope: state.metadata?.scope,
serverDB: ctx.serverDB,
taskId: state.metadata?.taskId,
toolManifestMap: batchManifestMap,
@@ -6,6 +6,8 @@ import {
import { z } from 'zod';
import { AgentDocumentModel } from '@/database/models/agentDocuments';
import { TopicModel } from '@/database/models/topic';
import { TopicDocumentModel } from '@/database/models/topicDocument';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { AgentDocumentsService } from '@/server/services/agentDocuments';
@@ -37,6 +39,8 @@ const agentDocumentProcedure = authedProcedure.use(serverDatabase).use(async (op
ctx: {
agentDocumentModel: new AgentDocumentModel(ctx.serverDB, ctx.userId),
agentDocumentService: new AgentDocumentsService(ctx.serverDB, ctx.userId),
topicModel: new TopicModel(ctx.serverDB, ctx.userId),
topicDocumentModel: new TopicDocumentModel(ctx.serverDB, ctx.userId),
},
});
});
@@ -259,6 +263,36 @@ export const agentDocumentRouter = router({
return ctx.agentDocumentService.createDocument(input.agentId, input.title, input.content);
}),
/**
* Create an agent document and associate it with a topic in one call.
* Used by the topic → page flow to replace the legacy notebook entry point.
*/
createForTopic: agentDocumentProcedure
.input(
z.object({
agentId: z.string(),
content: z.string(),
title: z.string(),
topicId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const topic = input.title.trim() ? undefined : await ctx.topicModel.findById(input.topicId);
const title = input.title.trim() || topic?.title || '';
const doc = await ctx.agentDocumentService.createDocument(
input.agentId,
title,
input.content,
);
await ctx.topicDocumentModel.associate({
documentId: doc.documentId,
topicId: input.topicId,
});
return doc;
}),
/**
* Tool-oriented: read document by id
*/
+1
View File
@@ -89,6 +89,7 @@ const ExecAgentSchema = z
/** Application context for message storage */
appContext: z
.object({
documentId: z.string().optional().nullable(),
groupId: z.string().optional().nullable(),
scope: z.string().optional().nullable(),
sessionId: z.string().optional(),
+6 -4
View File
@@ -1,6 +1,5 @@
import { TaskIdentifier as TaskSkillIdentifier } from '@lobechat/builtin-skills';
import { BriefIdentifier } from '@lobechat/builtin-tool-brief';
import { NotebookIdentifier } from '@lobechat/builtin-tool-notebook';
import { buildTaskRunPrompt } from '@lobechat/prompts';
import type {
TaskListItem,
@@ -840,11 +839,14 @@ export const taskRouter = router({
const db = ctx.serverDB;
const userId = ctx.userId;
// Task execution always injects: Task skill (auto-activated) + Notebook tool (for document output)
// Conditionally inject Brief tool based on checkpoint/review config
// Task execution auto-injects the Task skill. The legacy Notebook tool
// has been deprecated and is no longer injected — document output now
// flows through the agent-documents tool, which is enabled via the
// agent config (see INBOX runtime / createEnableChecker).
// Conditionally inject Brief tool based on checkpoint/review config.
const checkpoint = model.getCheckpointConfig(task);
const reviewConfig = model.getReviewConfig(task);
const pluginIds = [TaskSkillIdentifier, NotebookIdentifier];
const pluginIds = [TaskSkillIdentifier];
if (!reviewConfig?.enabled && checkpoint.onAgentRequest !== false) {
pluginIds.push(BriefIdentifier);
}
+31 -5
View File
@@ -87,10 +87,24 @@ describe('AgentDocumentsService', () => {
});
describe('listDocuments', () => {
it('should return a list of documents with filename, id, and title', async () => {
it('should return a list of documents with documentId, filename, id, and title', async () => {
mockModel.findByAgent.mockResolvedValue([
{ content: 'c1', filename: 'a.md', id: 'doc-1', policy: null, title: 'A' },
{ content: 'c2', filename: 'b.md', id: 'doc-2', policy: null, title: 'B' },
{
content: 'c1',
documentId: 'documents-1',
filename: 'a.md',
id: 'doc-1',
policy: null,
title: 'A',
},
{
content: 'c2',
documentId: 'documents-2',
filename: 'b.md',
id: 'doc-2',
policy: null,
title: 'B',
},
]);
const service = new AgentDocumentsService(db, userId);
@@ -98,8 +112,20 @@ describe('AgentDocumentsService', () => {
expect(mockModel.findByAgent).toHaveBeenCalledWith('agent-1');
expect(result).toEqual([
{ filename: 'a.md', id: 'doc-1', loadPosition: undefined, title: 'A' },
{ filename: 'b.md', id: 'doc-2', loadPosition: undefined, title: 'B' },
{
documentId: 'documents-1',
filename: 'a.md',
id: 'doc-1',
loadPosition: undefined,
title: 'A',
},
{
documentId: 'documents-2',
filename: 'b.md',
id: 'doc-2',
loadPosition: undefined,
title: 'B',
},
]);
});
});
+1
View File
@@ -315,6 +315,7 @@ export class AgentDocumentsService {
async listDocuments(agentId: string) {
const docs = await this.agentDocumentModel.findByAgent(agentId);
return docs.map((d) => ({
documentId: d.documentId,
filename: d.filename,
id: d.id,
loadPosition: d.policy?.context?.position,
@@ -1,6 +1,9 @@
import { PageAgentIdentifier } from '@lobechat/builtin-tool-page-agent';
import type * as ModelBankModule from 'model-bank';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createServerAgentToolsEngine } from '@/server/modules/Mecha';
import { AiAgentService } from '../index';
const { mockCreateOperation, mockGetAgentConfig, mockMessageCreate } = vi.hoisted(() => ({
@@ -210,4 +213,46 @@ describe('AiAgentService.execAgent - builtin agent runtime config', () => {
const callArgs = mockCreateOperation.mock.calls[0][0];
expect(callArgs.agentConfig.systemRole).toBe('');
});
it('should inject page-agent runtime for regular agents in page scope', async () => {
mockGetAgentConfig.mockResolvedValue({
chatConfig: { enableHistoryCount: true },
id: 'agent-custom',
model: 'gpt-4',
plugins: ['lobe-agent-documents'],
provider: 'openai',
systemRole: 'Custom role.',
});
await service.execAgent({
agentId: 'agent-custom',
appContext: {
documentId: 'docs-1',
scope: 'page',
topicId: 'topic-1',
},
prompt: 'Rewrite this page',
});
const callArgs = mockCreateOperation.mock.calls[0][0];
expect(callArgs.appContext).toMatchObject({
documentId: 'docs-1',
scope: 'page',
});
expect(callArgs.agentConfig.plugins).toEqual([PageAgentIdentifier, 'lobe-agent-documents']);
expect(callArgs.agentConfig.chatConfig.enableHistoryCount).toBe(false);
expect(callArgs.agentConfig.systemRole).toContain('Custom role.');
expect(callArgs.agentConfig.systemRole).toContain(
'You are a helpful document (page) editing assistant',
);
expect(createServerAgentToolsEngine).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
agentConfig: expect.objectContaining({
plugins: [PageAgentIdentifier, 'lobe-agent-documents'],
}),
}),
);
});
});
+26
View File
@@ -3,6 +3,7 @@ import { BUILTIN_AGENT_SLUGS, getAgentRuntimeConfig } from '@lobechat/builtin-ag
import { builtinSkills } from '@lobechat/builtin-skills';
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
import { MessageToolIdentifier } from '@lobechat/builtin-tool-message';
import { PageAgentIdentifier } from '@lobechat/builtin-tool-page-agent';
import {
type DeviceAttachment,
generateSystemPrompt,
@@ -355,6 +356,29 @@ export class AiAgentService {
}
}
if (appContext?.scope === 'page' && agentSlug !== BUILTIN_AGENT_SLUGS.pageAgent) {
const pageAgentRuntime = getAgentRuntimeConfig(BUILTIN_AGENT_SLUGS.pageAgent, {
model: agentConfig.model,
plugins: agentConfig.plugins ?? [],
});
const pageAgentSystemRole = pageAgentRuntime?.systemRole || '';
if (pageAgentSystemRole) {
agentConfig.systemRole = agentConfig.systemRole
? `${agentConfig.systemRole}\n\n${pageAgentSystemRole}`
: pageAgentSystemRole;
}
agentConfig.plugins = agentConfig.plugins?.includes(PageAgentIdentifier)
? agentConfig.plugins
: [PageAgentIdentifier, ...(agentConfig.plugins ?? [])];
agentConfig.chatConfig = {
...agentConfig.chatConfig,
enableHistoryCount: false,
};
log('execAgent: injected page-agent runtime for page scope');
}
await throwIfExecutionAborted('agent configuration');
// 2.5. Append additional instructions to agent's systemRole
@@ -1438,7 +1462,9 @@ export class AiAgentService {
userTimezone,
appContext: {
agentId: resolvedAgentId,
documentId: appContext?.documentId,
groupId: appContext?.groupId,
scope: appContext?.scope,
taskId,
threadId: appContext?.threadId,
topicId,
@@ -65,4 +65,93 @@ describe('AgentDocumentsExecutionRuntime.createDocument', () => {
expect(result.success).toBe(false);
expect(stub.createDocument).not.toHaveBeenCalled();
});
it('blocks editDocument for the current page document', async () => {
const stub = makeStub();
stub.readDocument.mockResolvedValue({
content: 'body',
documentId: 'documents-row-id',
id: 'agent-doc-assoc-id',
title: 'Daily Brief',
});
const runtime = new AgentDocumentsExecutionRuntime(stub);
const result = await runtime.editDocument(
{ content: 'updated', id: 'agent-doc-assoc-id' },
{
agentId: 'agent-1',
currentDocumentId: 'documents-row-id',
scope: 'page',
},
);
expect(result.success).toBe(false);
expect(result.error).toMatchObject({
code: 'CURRENT_PAGE_DOCUMENT_WRITE_FORBIDDEN',
kind: 'replan',
});
expect(stub.editDocument).not.toHaveBeenCalled();
});
it('blocks upsertDocumentByFilename when the filename resolves to the current page document', async () => {
const stub = makeStub();
stub.listDocuments.mockResolvedValue([
{
documentId: 'documents-row-id',
filename: 'current-doc.md',
id: 'agent-doc-assoc-id',
title: 'Current Doc',
},
]);
const runtime = new AgentDocumentsExecutionRuntime(stub);
const result = await runtime.upsertDocumentByFilename(
{ content: 'updated', filename: 'current-doc.md' },
{
agentId: 'agent-1',
currentDocumentId: 'documents-row-id',
scope: 'page',
},
);
expect(result.success).toBe(false);
expect(result.error).toMatchObject({
code: 'CURRENT_PAGE_DOCUMENT_WRITE_FORBIDDEN',
kind: 'replan',
});
expect(stub.upsertDocumentByFilename).not.toHaveBeenCalled();
});
it('still allows editing a different agent document in page scope', async () => {
const stub = makeStub();
stub.readDocument.mockResolvedValue({
content: 'body',
documentId: 'documents-row-id-2',
id: 'agent-doc-assoc-id-2',
title: 'Other Doc',
});
stub.editDocument.mockResolvedValue({
content: 'updated',
documentId: 'documents-row-id-2',
id: 'agent-doc-assoc-id-2',
title: 'Other Doc',
});
const runtime = new AgentDocumentsExecutionRuntime(stub);
const result = await runtime.editDocument(
{ content: 'updated', id: 'agent-doc-assoc-id-2' },
{
agentId: 'agent-1',
currentDocumentId: 'documents-row-id',
scope: 'page',
},
);
expect(result.success).toBe(true);
expect(stub.editDocument).toHaveBeenCalledWith({
agentId: 'agent-1',
content: 'updated',
id: 'agent-doc-assoc-id-2',
});
});
});
@@ -21,7 +21,12 @@ export const agentDocumentsRuntime: ServerRuntimeRegistration = {
editDocument: ({ agentId, content, id }) => service.editDocumentById(id, content, agentId),
listDocuments: async ({ agentId }) => {
const docs = await service.listDocuments(agentId);
return docs.map((d) => ({ filename: d.filename, id: d.id, title: d.title }));
return docs.map((d) => ({
documentId: d.documentId,
filename: d.filename,
id: d.id,
title: d.title,
}));
},
readDocument: ({ agentId, id }) => service.getDocumentById(id, agentId),
readDocumentByFilename: ({ agentId, filename }) =>
@@ -7,8 +7,12 @@ export interface ToolExecutionContext {
activeDeviceId?: string;
/** Agent ID executing the tool call */
agentId?: string;
/** Current page document ID for page-scoped conversations */
documentId?: string | null;
/** Memory tool permission from agent chat config */
memoryToolPermission?: 'read-only' | 'read-write';
/** Conversation scope captured when the operation was created */
scope?: string | null;
/** Server database for LobeHub Skills execution */
serverDB?: LobeChatDatabase;
/** Task ID when executing within the Task system */
+33
View File
@@ -6,6 +6,7 @@ import {
import { mutate } from '@/libs/swr';
import { lambdaClient } from '@/libs/trpc/client';
import { type DocumentChangeOperation, documentEvents } from '@/store/document/events';
export const agentDocumentSWRKeys = {
documents: (agentId: string) => ['agent-documents', agentId] as const,
@@ -42,6 +43,19 @@ const revalidateReadDocument = async (agentId: string, id: string) => {
await mutate(agentDocumentSWRKeys.readDocument(agentId, id));
};
const emitDocumentChange = (
operation: DocumentChangeOperation,
documentId: string | undefined | null,
agentId?: string,
) => {
if (!documentId) return;
console.info(
`[AgentDocumentService] Emitting document change event: ${operation} (agentId: ${agentId}, documentId: ${documentId})`,
);
documentEvents.emit({ agentId, documentId, operation });
};
class AgentDocumentService {
getTemplates = async () => {
return lambdaClient.agentDocument.getTemplates.query();
@@ -73,6 +87,7 @@ class AgentDocumentService {
}) => {
const result = await lambdaClient.agentDocument.upsertDocumentByFilename.mutate(params);
await revalidateAgentDocuments(params.agentId);
emitDocumentChange('upsert', result?.documentId, params.agentId);
return result;
};
@@ -87,6 +102,20 @@ class AgentDocumentService {
createDocument = async (params: { agentId: string; content: string; title: string }) => {
const result = await lambdaClient.agentDocument.createDocument.mutate(params);
await revalidateAgentDocuments(params.agentId);
emitDocumentChange('create', result?.documentId, params.agentId);
return result;
};
createForTopic = async (params: {
agentId: string;
content: string;
title: string;
topicId: string;
}) => {
const result = await lambdaClient.agentDocument.createForTopic.mutate(params);
await revalidateAgentDocuments(params.agentId);
emitDocumentChange('create', result?.documentId, params.agentId);
return result;
};
@@ -98,6 +127,7 @@ class AgentDocumentService {
editDocument = async (params: { agentId: string; content: string; id: string }) => {
const result = await lambdaClient.agentDocument.editDocument.mutate(params);
await revalidateAgentDocuments(params.agentId);
emitDocumentChange('edit', result?.documentId, params.agentId);
return result;
};
@@ -112,6 +142,7 @@ class AgentDocumentService {
copyDocument = async (params: { agentId: string; id: string; newTitle?: string }) => {
const result = await lambdaClient.agentDocument.copyDocument.mutate(params);
await revalidateAgentDocuments(params.agentId);
emitDocumentChange('copy', result?.documentId, params.agentId);
return result;
};
@@ -120,6 +151,7 @@ class AgentDocumentService {
const result = await lambdaClient.agentDocument.renameDocument.mutate(params);
await revalidateAgentDocuments(params.agentId);
await revalidateReadDocument(params.agentId, params.id);
emitDocumentChange('rename', result?.documentId, params.agentId);
return result;
};
@@ -143,6 +175,7 @@ class AgentDocumentService {
}) => {
const result = await lambdaClient.agentDocument.updateLoadRule.mutate(params);
await revalidateAgentDocuments(params.agentId);
emitDocumentChange('updateLoadRule', result?.documentId, params.agentId);
return result;
};
+1
View File
@@ -27,6 +27,7 @@ export interface ResumeApprovalParam {
export interface ExecAgentTaskParams {
agentId?: string;
appContext?: {
documentId?: string | null;
groupId?: string | null;
scope?: string | null;
sessionId?: string;
@@ -58,7 +58,12 @@ function setup(options: { hasConnection?: boolean; sendReturns?: boolean } = {})
operations: {
'op-1': {
abortController: { signal: { aborted: false } },
context: { agentId: 'agent-1', topicId: 'topic-1' },
context: {
agentId: 'agent-1',
documentId: 'documents-row-id',
scope: 'page',
topicId: 'topic-1',
},
},
},
pendingClientToolExecutions: {},
@@ -101,8 +106,10 @@ describe('internal_executeClientTool', () => {
{ path: '/tmp/a.txt' },
expect.objectContaining({
agentId: 'agent-1',
documentId: 'documents-row-id',
messageId: 'call_1',
operationId: 'op-1',
scope: 'page',
topicId: 'topic-1',
}),
);
@@ -94,11 +94,13 @@ export class ClientToolExecutionActionImpl {
const operation = this.#get().operations[operationId];
const ctx: BuiltinToolContext = {
agentId: operation?.context?.agentId,
documentId: operation?.context?.documentId,
groupId: operation?.context?.groupId,
// Gateway-side tool messages are persisted on the server; the client
// has no local message id, so reuse toolCallId as the context key.
messageId: toolCallId,
operationId,
scope: operation?.context?.scope,
signal: operation?.abortController?.signal,
topicId: operation?.context?.topicId ?? undefined,
};
@@ -235,6 +235,7 @@ export class GatewayActionImpl {
const result = await aiAgentService.execAgentTask({
agentId: context.agentId,
appContext: {
documentId: context.documentId,
groupId: context.groupId,
scope: context.scope,
threadId: context.threadId,
@@ -5,10 +5,31 @@ import type {
StreamStartData,
ToolExecuteData,
} from '@lobechat/agent-gateway-client';
import {
AgentDocumentsApiName,
AgentDocumentsIdentifier,
} from '@lobechat/builtin-tool-agent-documents';
import type { ConversationContext } from '@lobechat/types';
import { messageService } from '@/services/message';
import type { ChatStore } from '@/store/chat/store';
import { type DocumentChangeOperation, documentEvents } from '@/store/document/events';
/**
* Agent-documents tool APIs that mutate a document. Used to bridge server-side
* tool executions into the client-side `documentEvents` bus so open editors
* refresh without requiring a blanket post-send revalidation.
*/
const AGENT_DOCUMENTS_WRITE_OPS: Record<string, DocumentChangeOperation> = {
[AgentDocumentsApiName.copyDocument]: 'copy',
[AgentDocumentsApiName.createDocument]: 'create',
[AgentDocumentsApiName.editDocument]: 'edit',
[AgentDocumentsApiName.patchDocument]: 'edit',
[AgentDocumentsApiName.removeDocument]: 'remove',
[AgentDocumentsApiName.renameDocument]: 'rename',
[AgentDocumentsApiName.updateLoadRule]: 'updateLoadRule',
[AgentDocumentsApiName.upsertDocumentByFilename]: 'upsert',
};
/**
* Fetch messages from DB and replace them in the chat store's dbMessagesMap.
@@ -182,6 +203,30 @@ export const createGatewayEventHandler = (
}
case 'tool_end': {
// Bridge server-executed agent-documents writes into the document
// event bus. The payload shape is `{ parentMessageId, toolCalling }`
// where `toolCalling` is a `ChatToolPayload` carrying identifier +
// apiName. We cannot recover `documents.id` from here (`toolCalling`
// args only carry `agent_documents.id`), so we emit without a
// `documentId` and rely on listeners to refresh their own doc.
const toolEndData = event.data as
| { isSuccess?: boolean; payload?: { toolCalling?: unknown } }
| undefined;
const toolCalling = toolEndData?.payload?.toolCalling as
| { apiName?: string; identifier?: string }
| undefined;
if (
toolEndData?.isSuccess &&
toolCalling?.identifier === AgentDocumentsIdentifier &&
toolCalling.apiName &&
AGENT_DOCUMENTS_WRITE_OPS[toolCalling.apiName]
) {
documentEvents.emit({
agentId: context.agentId,
operation: AGENT_DOCUMENTS_WRITE_OPS[toolCalling.apiName],
});
}
enqueue(async () => {
await fetchAndReplaceMessages(get, context).catch(console.error);
});
@@ -392,6 +392,61 @@ describe('ChatPluginAction', () => {
expect(returnValue).toEqual({ error: 'Invalid arguments', success: false });
});
it('should pass page document context to Tool Store executor', async () => {
const hasExecutorModule = await import('@/store/tool/slices/builtin/executors');
vi.spyOn(hasExecutorModule, 'hasExecutor').mockReturnValue(true);
const { result } = renderHook(() => useChatStore());
const messageId = 'page-tool-message-id';
act(() => {
const rootOperationId = result.current.startOperation({
type: 'execAgentRuntime',
context: {
agentId: 'agent-1',
documentId: 'docs-current',
scope: 'page',
topicId: 'topic-1',
},
}).operationId;
const toolOperationId = result.current.startOperation({
type: 'executeToolCall',
context: { messageId },
parentOperationId: rootOperationId,
}).operationId;
result.current.associateMessageWithOperation(messageId, toolOperationId);
});
let capturedContext: any;
vi.spyOn(useToolStore.getState(), 'invokeBuiltinTool').mockImplementation(
async (_id, _api, _params, ctx) => {
capturedContext = ctx;
return { success: true };
},
);
const payload = {
identifier: 'lobe-agent-documents',
apiName: 'editDocument',
arguments: JSON.stringify({ content: 'test', id: 'agent-document-id' }),
type: 'builtin',
} as ChatToolPayload;
await act(async () => {
await result.current.invokeBuiltinTool(messageId, payload);
});
expect(capturedContext).toMatchObject({
agentId: 'agent-1',
documentId: 'docs-current',
messageId,
scope: 'page',
topicId: 'topic-1',
});
});
describe('registerAfterCompletion with Tool Store executor', () => {
it('should create registerAfterCompletion when root execAgentRuntime operation exists', async () => {
// Mock hasExecutor to return true
@@ -63,10 +63,30 @@ export class PluginTypesActionImpl {
const operation = operationId ? this.#get().operations[operationId] : undefined;
const context = operationId ? { operationId } : undefined;
// Get agent ID, group ID, and topic ID from operation context
let agentId = operation?.context?.agentId;
let groupId = operation?.context?.groupId;
const topicId = operation?.context?.topicId;
let rootRuntimeOperationId: string | undefined;
let rootRuntimeOperationContext = operation?.context;
if (operationId) {
let currentOp = operation;
while (currentOp) {
if (AI_RUNTIME_OPERATION_TYPES.includes(currentOp.type)) {
rootRuntimeOperationId = currentOp.id;
rootRuntimeOperationContext = currentOp.context;
break;
}
// Move up to parent operation
const parentId = currentOp.parentOperationId;
currentOp = parentId ? this.#get().operations[parentId] : undefined;
}
}
// Get agent ID, group ID, topic ID, and page scope from operation context.
// Prefer the concrete tool operation; fall back to the runtime root for
// legacy operations created before child context inheritance was complete.
let agentId = operation?.context?.agentId ?? rootRuntimeOperationContext?.agentId;
let groupId = operation?.context?.groupId ?? rootRuntimeOperationContext?.groupId;
const documentId = operation?.context?.documentId ?? rootRuntimeOperationContext?.documentId;
const scope = operation?.context?.scope ?? rootRuntimeOperationContext?.scope;
const topicId = operation?.context?.topicId ?? rootRuntimeOperationContext?.topicId;
// For agent-builder tools, inject activeAgentId from store if not in context
// This is needed because AgentBuilderProvider uses a separate scope for messages
@@ -89,22 +109,6 @@ export class PluginTypesActionImpl {
// Get group orchestration callbacks if available (for group management tools)
const groupOrchestration = this.#get().getGroupOrchestrationCallbacks?.();
// Find root execAgentRuntime operation for registering afterCompletion callbacks
// Navigate up the operation tree to find the root runtime operation
let rootRuntimeOperationId: string | undefined;
if (operationId) {
let currentOp = operation;
while (currentOp) {
if (AI_RUNTIME_OPERATION_TYPES.includes(currentOp.type)) {
rootRuntimeOperationId = currentOp.id;
break;
}
// Move up to parent operation
const parentId = currentOp.parentOperationId;
currentOp = parentId ? this.#get().operations[parentId] : undefined;
}
}
// Create registerAfterCompletion function that registers callback to root runtime operation
const registerAfterCompletion = rootRuntimeOperationId
? (callback: Parameters<typeof registerAfterCompletionCallback>[1]) => {
@@ -129,11 +133,13 @@ export class PluginTypesActionImpl {
.getState()
.invokeBuiltinTool(payload.identifier, payload.apiName, params, {
agentId,
documentId,
groupId,
groupOrchestration,
messageId: id,
operationId,
registerAfterCompletion,
scope,
signal: operation?.abortController?.signal,
stepContext,
topicId,
+59
View File
@@ -0,0 +1,59 @@
/**
* Document mutation event bus.
*
* Emits when a `documents` row has been mutated by a client-initiated write
* (UI action or a client-side tool-executor dispatch). Listeners receive the
* affected `documentId` so they can narrow reactions to a single open editor
* instead of blindly refreshing after every chat turn.
*
* Not wired to server-only tool executions — those don't pass through the
* client service layer. If you need that coverage, hook the `tool_end` event
* in `gatewayEventHandler` as well.
*/
export type DocumentChangeOperation =
| 'edit'
| 'create'
| 'remove'
| 'rename'
| 'copy'
| 'upsert'
| 'updateLoadRule';
export interface DocumentChangeEvent {
agentId?: string;
/**
* The `documents.id` of the mutated row.
*
* Optional because server-side tool executions only expose the
* `agent_documents.id` at the point where we observe the write, not the
* underlying `documents.id` that the editor is keyed on. When undefined,
* treat the event as a broadcast — listeners should refresh whichever
* document they currently display.
*/
documentId?: string;
operation: DocumentChangeOperation;
}
type Listener = (event: DocumentChangeEvent) => void;
const listeners = new Set<Listener>();
export const documentEvents = {
emit: (event: DocumentChangeEvent): void => {
for (const listener of listeners) {
try {
listener(event);
} catch (error) {
console.error('[documentEvents] listener threw', error);
}
}
},
subscribe: (listener: Listener): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
+2
View File
@@ -1,3 +1,5 @@
// Events
export { type DocumentChangeEvent, type DocumentChangeOperation, documentEvents } from './events';
// Selectors
export { editorSelectors } from './slices/editor';
+1 -2
View File
@@ -3,9 +3,8 @@ import { type DocumentItem } from '@lobechat/database/schemas';
import { type NotebookDocument } from '@lobechat/types';
import isEqual from 'fast-deep-equal';
import { type SWRResponse } from 'swr';
import { mutate } from 'swr';
import { useClientDataSWR } from '@/libs/swr';
import { mutate, useClientDataSWR } from '@/libs/swr';
import { notebookService } from '@/services/notebook';
import { useChatStore } from '@/store/chat';
import { type StoreSetter } from '@/store/types';
@@ -13,7 +13,12 @@ const runtime = new AgentDocumentsExecutionRuntime({
agentDocumentService.editDocument({ agentId, content, id }),
listDocuments: async ({ agentId }) => {
const docs = await agentDocumentService.listDocuments({ agentId });
return docs.map((d) => ({ filename: d.filename, id: d.id, title: d.title }));
return docs.map((d) => ({
documentId: d.documentId,
filename: d.filename,
id: d.id,
title: d.title,
}));
},
readDocument: ({ agentId, id }) => agentDocumentService.readDocument({ agentId, id }),
readDocumentByFilename: ({ agentId, filename }) =>