mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Use topic titles for auto-created page documents
This commit is contained in:
@@ -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 */
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
// Events
|
||||
export { type DocumentChangeEvent, type DocumentChangeOperation, documentEvents } from './events';
|
||||
// Selectors
|
||||
export { editorSelectors } from './slices/editor';
|
||||
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
Reference in New Issue
Block a user