♻️ refactor(agent-documents): fix title/documentId flow + split Inspector per action (#13940)

- extract H1 from markdown content as document title (stripped from content)
- use title verbatim as filename (no extension); simplify dedup to `-2`, `-3`
- AgentDocumentModel.create accepts optional title; falls back to filename
- ExecutionRuntime createDocument returns documents.id (not agentDocuments.id)
  as state.documentId so the portal can resolve the row for openDocument
- sidebar DocumentItem prefers title over filename
- split AgentDocumentsInspector into 11 per-apiName components (Notebook pattern)
- tests: filename util (13), ExecutionRuntime wiring (5), updated model + service

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin Xu 2026-04-19 01:06:06 +08:00 committed by GitHub
parent b909e4ae20
commit 4e5db98ffc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 696 additions and 169 deletions

View file

@ -17,7 +17,18 @@ import type {
interface AgentDocumentRecord {
content?: string;
/**
* The underlying `documents` table id. Used for portal rendering
* (opening the document in the shared EditorCanvas), which must resolve
* the row in `documents` distinct from `id` which is the
* `agentDocuments` association row id.
*/
documentId?: string;
filename?: string;
/**
* The `agentDocuments` association row id. This is what the LLM receives
* and uses for subsequent operations (read/edit/remove/...).
*/
id: string;
title?: string;
}
@ -174,7 +185,7 @@ export class AgentDocumentsExecutionRuntime {
return {
content: `Created document "${created.title || args.title}" (${created.id}).`,
state: { documentId: created.id },
state: { documentId: created.documentId },
success: true,
};
}

View file

@ -1,124 +0,0 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import {
AgentDocumentsApiName,
type CopyDocumentArgs,
type CreateDocumentArgs,
type EditDocumentArgs,
type PatchDocumentArgs,
type ReadDocumentArgs,
type RemoveDocumentArgs,
type RenameDocumentArgs,
type UpdateLoadRuleArgs,
} from '../../../types';
type AgentDocumentsArgs =
| CopyDocumentArgs
| CreateDocumentArgs
| EditDocumentArgs
| PatchDocumentArgs
| ReadDocumentArgs
| RemoveDocumentArgs
| RenameDocumentArgs
| UpdateLoadRuleArgs;
const getInspectorSummary = (
apiName: string,
args?: Partial<AgentDocumentsArgs>,
): string | undefined => {
switch (apiName) {
case AgentDocumentsApiName.createDocument: {
return args && 'title' in args ? args.title : undefined;
}
case AgentDocumentsApiName.renameDocument: {
return args && 'newTitle' in args ? args.newTitle : undefined;
}
case AgentDocumentsApiName.copyDocument: {
if (args && 'newTitle' in args && args.newTitle) return args.newTitle;
return args && 'id' in args ? args.id : undefined;
}
case AgentDocumentsApiName.readDocument:
case AgentDocumentsApiName.editDocument:
case AgentDocumentsApiName.patchDocument:
case AgentDocumentsApiName.removeDocument:
case AgentDocumentsApiName.updateLoadRule: {
return args && 'id' in args ? args.id : undefined;
}
default: {
return undefined;
}
}
};
const getInspectorLabel = (apiName: string, t: (...args: any[]) => string) => {
switch (apiName) {
case AgentDocumentsApiName.createDocument: {
return t('builtins.lobe-agent-documents.apiName.createDocument');
}
case AgentDocumentsApiName.readDocument: {
return t('builtins.lobe-agent-documents.apiName.readDocument');
}
case AgentDocumentsApiName.editDocument: {
return t('builtins.lobe-agent-documents.apiName.editDocument');
}
case AgentDocumentsApiName.patchDocument: {
return t('builtins.lobe-agent-documents.apiName.patchDocument');
}
case AgentDocumentsApiName.removeDocument: {
return t('builtins.lobe-agent-documents.apiName.removeDocument');
}
case AgentDocumentsApiName.renameDocument: {
return t('builtins.lobe-agent-documents.apiName.renameDocument');
}
case AgentDocumentsApiName.copyDocument: {
return t('builtins.lobe-agent-documents.apiName.copyDocument');
}
case AgentDocumentsApiName.updateLoadRule: {
return t('builtins.lobe-agent-documents.apiName.updateLoadRule');
}
default: {
return apiName;
}
}
};
export const AgentDocumentsInspector = memo<BuiltinInspectorProps<AgentDocumentsArgs>>(
({ apiName, args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const summary = getInspectorSummary(apiName, args || partialArgs);
const label = getInspectorLabel(apiName, t);
if (isArgumentsStreaming && !summary) {
return <div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>{label}</div>;
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{label}</span>
{summary && (
<>
<span>: </span>
<span className={highlightTextStyles.primary}>{summary}</span>
</>
)}
</div>
);
},
);
AgentDocumentsInspector.displayName = 'AgentDocumentsInspector';
export default AgentDocumentsInspector;

View file

@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { CopyDocumentArgs, CopyDocumentState } from '../../../types';
export const CopyDocumentInspector = memo<
BuiltinInspectorProps<CopyDocumentArgs, CopyDocumentState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const summary = args?.newTitle || partialArgs?.newTitle || args?.id || partialArgs?.id;
if (isArgumentsStreaming && !summary) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.copyDocument')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.copyDocument')}: </span>
{summary && <span className={highlightTextStyles.primary}>{summary}</span>}
</div>
);
});
CopyDocumentInspector.displayName = 'CopyDocumentInspector';
export default CopyDocumentInspector;

View file

@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { CreateDocumentArgs, CreateDocumentState } from '../../../types';
export const CreateDocumentInspector = memo<
BuiltinInspectorProps<CreateDocumentArgs, CreateDocumentState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const title = args?.title || partialArgs?.title;
if (isArgumentsStreaming && !title) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.createDocument')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.createDocument')}: </span>
{title && <span className={highlightTextStyles.primary}>{title}</span>}
</div>
);
});
CreateDocumentInspector.displayName = 'CreateDocumentInspector';
export default CreateDocumentInspector;

View file

@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { EditDocumentArgs, EditDocumentState } from '../../../types';
export const EditDocumentInspector = memo<
BuiltinInspectorProps<EditDocumentArgs, EditDocumentState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const id = args?.id || partialArgs?.id;
if (isArgumentsStreaming && !id) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.editDocument')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.editDocument')}: </span>
{id && <span className={highlightTextStyles.primary}>{id}</span>}
</div>
);
});
EditDocumentInspector.displayName = 'EditDocumentInspector';
export default EditDocumentInspector;

View file

@ -0,0 +1,31 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { ListDocumentsArgs, ListDocumentsState } from '../../../types';
export const ListDocumentsInspector = memo<
BuiltinInspectorProps<ListDocumentsArgs, ListDocumentsState>
>(({ isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.listDocuments')}</span>
</div>
);
});
ListDocumentsInspector.displayName = 'ListDocumentsInspector';
export default ListDocumentsInspector;

View file

@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { PatchDocumentArgs, PatchDocumentState } from '../../../types';
export const PatchDocumentInspector = memo<
BuiltinInspectorProps<PatchDocumentArgs, PatchDocumentState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const id = args?.id || partialArgs?.id;
if (isArgumentsStreaming && !id) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.patchDocument')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.patchDocument')}: </span>
{id && <span className={highlightTextStyles.primary}>{id}</span>}
</div>
);
});
PatchDocumentInspector.displayName = 'PatchDocumentInspector';
export default PatchDocumentInspector;

View file

@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { ReadDocumentArgs, ReadDocumentState } from '../../../types';
export const ReadDocumentInspector = memo<
BuiltinInspectorProps<ReadDocumentArgs, ReadDocumentState>
>(({ args, partialArgs, pluginState, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const summary = pluginState?.title || args?.id || partialArgs?.id;
if (isArgumentsStreaming && !summary) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.readDocument')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.readDocument')}: </span>
{summary && <span className={highlightTextStyles.primary}>{summary}</span>}
</div>
);
});
ReadDocumentInspector.displayName = 'ReadDocumentInspector';
export default ReadDocumentInspector;

View file

@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { ReadDocumentByFilenameArgs, ReadDocumentByFilenameState } from '../../../types';
export const ReadDocumentByFilenameInspector = memo<
BuiltinInspectorProps<ReadDocumentByFilenameArgs, ReadDocumentByFilenameState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const filename = args?.filename || partialArgs?.filename;
if (isArgumentsStreaming && !filename) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.readDocumentByFilename')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.readDocumentByFilename')}: </span>
{filename && <span className={highlightTextStyles.primary}>{filename}</span>}
</div>
);
});
ReadDocumentByFilenameInspector.displayName = 'ReadDocumentByFilenameInspector';
export default ReadDocumentByFilenameInspector;

View file

@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { RemoveDocumentArgs, RemoveDocumentState } from '../../../types';
export const RemoveDocumentInspector = memo<
BuiltinInspectorProps<RemoveDocumentArgs, RemoveDocumentState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const id = args?.id || partialArgs?.id;
if (isArgumentsStreaming && !id) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.removeDocument')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.removeDocument')}: </span>
{id && <span className={highlightTextStyles.primary}>{id}</span>}
</div>
);
});
RemoveDocumentInspector.displayName = 'RemoveDocumentInspector';
export default RemoveDocumentInspector;

View file

@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { RenameDocumentArgs, RenameDocumentState } from '../../../types';
export const RenameDocumentInspector = memo<
BuiltinInspectorProps<RenameDocumentArgs, RenameDocumentState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const newTitle = args?.newTitle || partialArgs?.newTitle;
if (isArgumentsStreaming && !newTitle) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.renameDocument')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.renameDocument')}: </span>
{newTitle && <span className={highlightTextStyles.primary}>{newTitle}</span>}
</div>
);
});
RenameDocumentInspector.displayName = 'RenameDocumentInspector';
export default RenameDocumentInspector;

View file

@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { UpdateLoadRuleArgs, UpdateLoadRuleState } from '../../../types';
export const UpdateLoadRuleInspector = memo<
BuiltinInspectorProps<UpdateLoadRuleArgs, UpdateLoadRuleState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const id = args?.id || partialArgs?.id;
if (isArgumentsStreaming && !id) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.updateLoadRule')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.updateLoadRule')}: </span>
{id && <span className={highlightTextStyles.primary}>{id}</span>}
</div>
);
});
UpdateLoadRuleInspector.displayName = 'UpdateLoadRuleInspector';
export default UpdateLoadRuleInspector;

View file

@ -0,0 +1,41 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { UpsertDocumentByFilenameArgs, UpsertDocumentByFilenameState } from '../../../types';
export const UpsertDocumentByFilenameInspector = memo<
BuiltinInspectorProps<UpsertDocumentByFilenameArgs, UpsertDocumentByFilenameState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const filename = args?.filename || partialArgs?.filename;
if (isArgumentsStreaming && !filename) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent-documents.apiName.upsertDocumentByFilename')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-agent-documents.apiName.upsertDocumentByFilename')}: </span>
{filename && <span className={highlightTextStyles.primary}>{filename}</span>}
</div>
);
});
UpsertDocumentByFilenameInspector.displayName = 'UpsertDocumentByFilenameInspector';
export default UpsertDocumentByFilenameInspector;

View file

@ -1,15 +1,30 @@
import type { BuiltinInspector } from '@lobechat/types';
import { AgentDocumentsApiName } from '../../types';
import { AgentDocumentsInspector } from './AgentDocumentsInspector';
import { CopyDocumentInspector } from './CopyDocument';
import { CreateDocumentInspector } from './CreateDocument';
import { EditDocumentInspector } from './EditDocument';
import { ListDocumentsInspector } from './ListDocuments';
import { PatchDocumentInspector } from './PatchDocument';
import { ReadDocumentInspector } from './ReadDocument';
import { ReadDocumentByFilenameInspector } from './ReadDocumentByFilename';
import { RemoveDocumentInspector } from './RemoveDocument';
import { RenameDocumentInspector } from './RenameDocument';
import { UpdateLoadRuleInspector } from './UpdateLoadRule';
import { UpsertDocumentByFilenameInspector } from './UpsertDocumentByFilename';
export const AgentDocumentsInspectors: Record<string, BuiltinInspector> = {
[AgentDocumentsApiName.createDocument]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.copyDocument]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.editDocument]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.patchDocument]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.readDocument]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.removeDocument]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.renameDocument]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.updateLoadRule]: AgentDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.copyDocument]: CopyDocumentInspector as BuiltinInspector,
[AgentDocumentsApiName.createDocument]: CreateDocumentInspector as BuiltinInspector,
[AgentDocumentsApiName.editDocument]: EditDocumentInspector as BuiltinInspector,
[AgentDocumentsApiName.listDocuments]: ListDocumentsInspector as BuiltinInspector,
[AgentDocumentsApiName.patchDocument]: PatchDocumentInspector as BuiltinInspector,
[AgentDocumentsApiName.readDocument]: ReadDocumentInspector as BuiltinInspector,
[AgentDocumentsApiName.readDocumentByFilename]:
ReadDocumentByFilenameInspector as BuiltinInspector,
[AgentDocumentsApiName.removeDocument]: RemoveDocumentInspector as BuiltinInspector,
[AgentDocumentsApiName.renameDocument]: RenameDocumentInspector as BuiltinInspector,
[AgentDocumentsApiName.updateLoadRule]: UpdateLoadRuleInspector as BuiltinInspector,
[AgentDocumentsApiName.upsertDocumentByFilename]:
UpsertDocumentByFilenameInspector as BuiltinInspector,
};

View file

@ -268,22 +268,22 @@ describe('AgentDocumentModel', () => {
const renamed = await agentDocumentModel.rename(created.id, 'New Name');
expect(renamed?.title).toBe('New Name');
expect(renamed?.filename).toBe('New Name.md');
expect(renamed?.filename).toBe('New Name');
const [doc] = await serverDB
.select()
.from(documents)
.where(eq(documents.id, created.documentId));
expect(doc?.source).toBe(`agent-document://${agentId}/${encodeURIComponent('New Name.md')}`);
expect(doc?.source).toBe(`agent-document://${agentId}/${encodeURIComponent('New Name')}`);
});
it('should preserve typed extension when renaming', async () => {
it('uses the new title verbatim as filename when renaming', async () => {
const created = await agentDocumentModel.create(agentId, 'identity.md', 'hello');
const renamed = await agentDocumentModel.rename(created.id, 'IDENTITY 2.md');
const renamed = await agentDocumentModel.rename(created.id, 'IDENTITY 2');
expect(renamed?.filename).toBe('IDENTITY 2.md');
expect(renamed?.filename).toBe('IDENTITY 2');
});
it('should copy into a new record and keep policy/template metadata', async () => {
@ -299,7 +299,7 @@ describe('AgentDocumentModel', () => {
expect(copied).toBeDefined();
expect(copied?.id).not.toBe(created.id);
expect(copied?.documentId).not.toBe(created.documentId);
expect(copied?.filename).toBe('Copied Title.md');
expect(copied?.filename).toBe('Copied Title');
expect(copied?.templateId).toBe('claw');
expect(copied?.policy?.context?.maxTokens).toBe(200);
expect(copied?.metadata).toMatchObject({ description: 'source desc', domain: 'A' });

View file

@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { buildDocumentFilename, extractMarkdownH1Title, slugifyDocumentTitle } from '../filename';
describe('buildDocumentFilename', () => {
it('returns the title as-is without appending an extension', () => {
expect(buildDocumentFilename('Daily Brief')).toBe('Daily Brief');
});
it('preserves non-ASCII titles', () => {
expect(buildDocumentFilename('Daily Brief 提取框架')).toBe('Daily Brief 提取框架');
});
it('replaces path separators to prevent traversal', () => {
expect(buildDocumentFilename('foo/bar\\baz')).toBe('foo-bar-baz');
});
it('falls back to a default when title is empty after sanitisation', () => {
expect(buildDocumentFilename(' ')).toBe('document');
expect(buildDocumentFilename('')).toBe('document');
});
it('trims trailing dots and whitespace', () => {
expect(buildDocumentFilename('note... ')).toBe('note');
});
});
describe('slugifyDocumentTitle', () => {
it('lowercases and replaces spaces with hyphens', () => {
expect(slugifyDocumentTitle('Daily Brief')).toBe('daily-brief');
});
it('drops non-ascii characters', () => {
expect(slugifyDocumentTitle('提取框架 extract')).toBe('extract');
});
});
describe('extractMarkdownH1Title', () => {
it('extracts a leading H1 heading and strips it from content', () => {
const result = extractMarkdownH1Title('# My Title\n\nbody line');
expect(result).toEqual({ content: 'body line', title: 'My Title' });
});
it('ignores leading blank lines before the H1', () => {
const result = extractMarkdownH1Title('\n\n# Hi\nbody');
expect(result.title).toBe('Hi');
expect(result.content).toBe('body');
});
it('returns the original content when no H1 is present', () => {
const result = extractMarkdownH1Title('just body\n## Secondary');
expect(result).toEqual({ content: 'just body\n## Secondary' });
});
it('does not treat ## headings as H1', () => {
const result = extractMarkdownH1Title('## Not H1\nbody');
expect(result.title).toBeUndefined();
});
it('handles empty H1 titles as no-op', () => {
const result = extractMarkdownH1Title('# \nbody');
expect(result.title).toBeUndefined();
});
it('trims whitespace inside the heading', () => {
const result = extractMarkdownH1Title('# Spaced Title \nbody');
expect(result.title).toBe('Spaced Title');
});
});

View file

@ -138,6 +138,7 @@ export class AgentDocumentModel {
policy?: AgentDocumentPolicy;
policyLoad?: PolicyLoad;
templateId?: string;
title?: string;
updatedAt?: Date;
},
): Promise<AgentDocument> {
@ -149,10 +150,11 @@ export class AgentDocumentModel {
policy,
policyLoad,
templateId,
title: providedTitle,
updatedAt,
} = params ?? {};
const title = filename.replace(/\.[^.]+$/, '');
const title = providedTitle?.trim() || filename.replace(/\.[^.]+$/, '');
const stats = this.getDocumentStats(content);
const normalizedPolicy = normalizePolicy(loadPosition, loadRules, policy);
@ -290,7 +292,7 @@ export class AgentDocumentModel {
const title = newTitle.trim();
if (!title) return existing;
const filename = buildDocumentFilename(title, existing.filename);
const filename = buildDocumentFilename(title);
const source = `agent-document://${existing.agentId}/${encodeURIComponent(filename)}`;
await this.db
@ -311,10 +313,11 @@ export class AgentDocumentModel {
const title = newTitle?.trim();
const filename = title
? buildDocumentFilename(title, existing.filename)
? buildDocumentFilename(title)
: `copy-${Date.now()}-${existing.filename}`;
return this.create(existing.agentId, filename, existing.content, {
title,
loadPosition:
(existing.policy?.context?.position as DocumentLoadPosition | undefined) ||
DocumentLoadPosition.BEFORE_FIRST_USER,

View file

@ -1,4 +1,4 @@
const SLUG_FALLBACK = 'document';
const FILENAME_FALLBACK = 'document';
export const slugifyDocumentTitle = (title: string): string =>
title
@ -18,14 +18,34 @@ const sanitizeDocumentFilename = (value: string): string =>
.replaceAll('\0', '')
.replaceAll(/[.\s]+$/g, '');
export const buildDocumentFilename = (title: string, fallbackFilename = 'document.txt'): string => {
const extensionMatch = fallbackFilename.match(/(\.[^./\\]+)$/);
const extension = extensionMatch?.[1] || '.txt';
const sanitizedTitle = sanitizeDocumentFilename(title);
if (!sanitizedTitle) return `${SLUG_FALLBACK}${extension}`;
const typedExtensionMatch = sanitizedTitle.match(/(\.[^./\\]+)$/);
if (typedExtensionMatch) return sanitizedTitle;
return `${sanitizedTitle}${extension}`;
export const buildDocumentFilename = (title: string): string => {
const sanitized = sanitizeDocumentFilename(title);
return sanitized || FILENAME_FALLBACK;
};
/**
* Extract the first-level markdown heading (# Title) from content and return
* the remaining content with that heading stripped.
*
* Only matches a `# ` heading that appears at the top of the document
* (optionally preceded by blank lines). Setext-style (`===`) and indented
* headings are not recognised.
*/
export const extractMarkdownH1Title = (content: string): { content: string; title?: string } => {
const lines = content.split(/\r?\n/);
let i = 0;
while (i < lines.length && lines[i].trim() === '') i += 1;
if (i >= lines.length) return { content };
const headingMatch = lines[i].match(/^[ \t]*#[ \t]+(\S.*)$/);
if (!headingMatch) return { content };
const title = headingMatch[1].trim();
if (!title) return { content };
let j = i + 1;
while (j < lines.length && lines[j].trim() === '') j += 1;
return { content: lines.slice(j).join('\n'), title };
};

View file

@ -107,7 +107,7 @@ const DocumentItem = memo<DocumentItemProps>(({ agentId, document, isActive, mut
const openDocument = useChatStore((s) => s.openDocument);
const closeDocument = useChatStore((s) => s.closeDocument);
const title = document.filename || document.title || '';
const title = document.title || document.filename || '';
const description = document.description ?? undefined;
const isWeb = document.sourceType === 'web';
const IconComponent: LucideIcon = isWeb ? GlobeIcon : FileTextIcon;

View file

@ -1,7 +1,11 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AgentDocumentModel, buildDocumentFilename } from '@/database/models/agentDocuments';
import {
AgentDocumentModel,
buildDocumentFilename,
extractMarkdownH1Title,
} from '@/database/models/agentDocuments';
import type { LobeChatDatabase } from '@/database/type';
import { AgentDocumentsService } from './agentDocuments';
@ -12,6 +16,7 @@ vi.mock('@/database/models/agentDocuments', () => ({
BEFORE_FIRST_USER: 'before_first_user',
},
buildDocumentFilename: vi.fn(),
extractMarkdownH1Title: vi.fn((content: string) => ({ content })),
}));
describe('AgentDocumentsService', () => {
@ -30,7 +35,8 @@ describe('AgentDocumentsService', () => {
beforeEach(() => {
vi.clearAllMocks();
(AgentDocumentModel as any).mockImplementation(() => mockModel);
vi.mocked(buildDocumentFilename).mockImplementation((title: string) => `${title}.md`);
vi.mocked(buildDocumentFilename).mockImplementation((title: string) => title);
vi.mocked(extractMarkdownH1Title).mockImplementation((content: string) => ({ content }));
});
describe('createDocument', () => {
@ -38,15 +44,17 @@ describe('AgentDocumentsService', () => {
mockModel.findByFilename
.mockResolvedValueOnce({ id: 'existing-doc' })
.mockResolvedValueOnce(undefined);
mockModel.create.mockResolvedValue({ id: 'new-doc', filename: 'note-2.md' });
mockModel.create.mockResolvedValue({ id: 'new-doc', filename: 'note-2' });
const service = new AgentDocumentsService(db, userId);
const result = await service.createDocument('agent-1', 'note', 'content');
expect(mockModel.findByFilename).toHaveBeenNthCalledWith(1, 'agent-1', 'note.md');
expect(mockModel.findByFilename).toHaveBeenNthCalledWith(2, 'agent-1', 'note-2.md');
expect(mockModel.create).toHaveBeenCalledWith('agent-1', 'note-2.md', 'content', undefined);
expect(result).toEqual({ id: 'new-doc', filename: 'note-2.md' });
expect(mockModel.findByFilename).toHaveBeenNthCalledWith(1, 'agent-1', 'note');
expect(mockModel.findByFilename).toHaveBeenNthCalledWith(2, 'agent-1', 'note-2');
expect(mockModel.create).toHaveBeenCalledWith('agent-1', 'note-2', 'content', {
title: 'note',
});
expect(result).toEqual({ id: 'new-doc', filename: 'note-2' });
});
it('should throw after too many filename collisions', async () => {
@ -59,6 +67,23 @@ describe('AgentDocumentsService', () => {
);
expect(mockModel.create).not.toHaveBeenCalled();
});
it('should extract H1 from markdown content as the document title', async () => {
vi.mocked(extractMarkdownH1Title).mockReturnValueOnce({
content: 'body',
title: 'My Title',
});
mockModel.findByFilename.mockResolvedValue(undefined);
mockModel.create.mockResolvedValue({ id: 'new-doc', filename: 'My Title' });
const service = new AgentDocumentsService(db, userId);
await service.createDocument('agent-1', 'fallback', '# My Title\n\nbody');
expect(vi.mocked(buildDocumentFilename)).toHaveBeenCalledWith('My Title');
expect(mockModel.create).toHaveBeenCalledWith('agent-1', 'My Title', 'body', {
title: 'My Title',
});
});
});
describe('listDocuments', () => {

View file

@ -14,7 +14,7 @@ import {
type AgentDocumentWithRules,
type ToolUpdateLoadRule,
} from '@/database/models/agentDocuments';
import { buildDocumentFilename } from '@/database/models/agentDocuments';
import { buildDocumentFilename, extractMarkdownH1Title } from '@/database/models/agentDocuments';
const MAX_UNIQUE_FILENAME_ATTEMPTS = 1000;
@ -56,9 +56,6 @@ export class AgentDocumentsService {
},
) {
const baseFilename = buildDocumentFilename(title);
const extensionMatch = baseFilename.match(/(\.[^./\\]+)$/);
const extension = extensionMatch?.[1] || '.txt';
const baseName = baseFilename.slice(0, -extension.length);
let filename = baseFilename;
let suffix = 2;
@ -70,11 +67,11 @@ export class AgentDocumentsService {
);
}
filename = `${baseName}-${suffix}${extension}`;
filename = `${baseFilename}-${suffix}`;
suffix += 1;
}
return this.agentDocumentModel.create(agentId, filename, content, params);
return this.agentDocumentModel.create(agentId, filename, content, { ...params, title });
}
/**
@ -213,7 +210,9 @@ export class AgentDocumentsService {
}
async createDocument(agentId: string, title: string, content: string) {
return this.createWithUniqueFilename(agentId, title, content);
const { title: extractedTitle, content: strippedContent } = extractMarkdownH1Title(content);
const finalTitle = extractedTitle || title;
return this.createWithUniqueFilename(agentId, finalTitle, strippedContent);
}
async deleteDocument(documentId: string) {

View file

@ -0,0 +1,68 @@
import { AgentDocumentsExecutionRuntime } from '@lobechat/builtin-tool-agent-documents/executionRuntime';
import { describe, expect, it, vi } from 'vitest';
import { agentDocumentsRuntime } from '../agentDocuments';
vi.mock('@/server/services/agentDocuments');
describe('agentDocumentsRuntime', () => {
it('should have correct identifier', () => {
expect(agentDocumentsRuntime.identifier).toBe('lobe-agent-documents');
});
it('should throw if userId is missing', () => {
expect(() =>
agentDocumentsRuntime.factory({ serverDB: {} as any, toolManifestMap: {} }),
).toThrow('userId and serverDB are required for Agent Documents execution');
});
it('should throw if serverDB is missing', () => {
expect(() => agentDocumentsRuntime.factory({ toolManifestMap: {}, userId: 'user-1' })).toThrow(
'userId and serverDB are required for Agent Documents execution',
);
});
});
describe('AgentDocumentsExecutionRuntime.createDocument', () => {
const makeStub = () => ({
copyDocument: vi.fn(),
createDocument: vi.fn(),
editDocument: vi.fn(),
listDocuments: vi.fn(),
readDocument: vi.fn(),
readDocumentByFilename: vi.fn(),
removeDocument: vi.fn(),
renameDocument: vi.fn(),
updateLoadRule: vi.fn(),
upsertDocumentByFilename: vi.fn(),
});
it('returns documents.id (not agentDocuments.id) for state.documentId', async () => {
const stub = makeStub();
stub.createDocument.mockResolvedValue({
documentId: 'documents-row-id',
filename: 'daily-brief',
id: 'agent-doc-assoc-id',
title: 'Daily Brief',
});
const runtime = new AgentDocumentsExecutionRuntime(stub);
const result = await runtime.createDocument(
{ content: 'body', title: 'Daily Brief' },
{ agentId: 'agent-1' },
);
expect(result.success).toBe(true);
expect(result.state).toEqual({ documentId: 'documents-row-id' });
});
it('refuses to run without agentId', async () => {
const stub = makeStub();
const runtime = new AgentDocumentsExecutionRuntime(stub);
const result = await runtime.createDocument({ content: 'body', title: 'T' }, {});
expect(result.success).toBe(false);
expect(stub.createDocument).not.toHaveBeenCalled();
});
});