feat: refactor doc write in native (#14272)

This commit is contained in:
DarkSky
2026-01-18 16:31:12 +08:00
committed by GitHub
parent 753b11deeb
commit f373e08583
52 changed files with 7140 additions and 3559 deletions

View File

@@ -70,6 +70,33 @@ Generated by [AVA](https://avajs.dev).
[](Bookmark,https://affine.pro/)␊
[](Bookmark,https://www.youtube.com/@affinepro)␊
<img␊
src="blob://BFZk3c2ERp-sliRvA7MQ_p3NdkdCLt2Ze0DQ9i21dpA="␊
alt=""␊
width="1302"␊
height="728"␊
/>␊
<img␊
src="blob://HWvCItS78DzPGbwcuaGcfkpVDUvL98IvH5SIK8-AcL8="␊
alt=""␊
width="1463"␊
height="374"␊
/>␊
<img␊
src="blob://ZRKpsBoC88qEMmeiXKXqywfA1rLvWoLa5rpEh9x9Oj0="␊
alt=""␊
width="862"␊
height="1388"␊
/>␊
`,
title: 'Write, Draw, Plan all at Once.',
}

View File

@@ -70,6 +70,33 @@ Generated by [AVA](https://avajs.dev).
[](Bookmark,https://affine.pro/)␊
[](Bookmark,https://www.youtube.com/@affinepro)␊
<img␊
src="blob://BFZk3c2ERp-sliRvA7MQ_p3NdkdCLt2Ze0DQ9i21dpA="␊
alt=""␊
width="1302"␊
height="728"␊
/>␊
<img␊
src="blob://HWvCItS78DzPGbwcuaGcfkpVDUvL98IvH5SIK8-AcL8="␊
alt=""␊
width="1463"␊
height="374"␊
/>␊
<img␊
src="blob://ZRKpsBoC88qEMmeiXKXqywfA1rLvWoLa5rpEh9x9Oj0="␊
alt=""␊
width="862"␊
height="1388"␊
/>␊
`,
title: 'Write, Draw, Plan all at Once.',
}

View File

@@ -70,6 +70,33 @@ Generated by [AVA](https://avajs.dev).
[](Bookmark,https://affine.pro/)␊
[](Bookmark,https://www.youtube.com/@affinepro)␊
<img␊
src="blob://BFZk3c2ERp-sliRvA7MQ_p3NdkdCLt2Ze0DQ9i21dpA="␊
alt=""␊
width="1302"␊
height="728"␊
/>␊
<img␊
src="blob://HWvCItS78DzPGbwcuaGcfkpVDUvL98IvH5SIK8-AcL8="␊
alt=""␊
width="1463"␊
height="374"␊
/>␊
<img␊
src="blob://ZRKpsBoC88qEMmeiXKXqywfA1rLvWoLa5rpEh9x9Oj0="␊
alt=""␊
width="862"␊
height="1388"␊
/>␊
`,
title: 'Write, Draw, Plan all at Once.',
}

View File

@@ -1,10 +1,14 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { nanoid } from 'nanoid';
import { EventBus } from '../../base';
import {
addDocToRootDoc,
markdownToDocBinary,
createDocWithMarkdown,
updateDocProperties,
updateDocTitle,
updateDocWithMarkdown,
updateRootDocMetaTitle,
} from '../../native';
import { PgWorkspaceDocStorageAdapter } from './adapters/workspace';
@@ -16,22 +20,40 @@ export interface UpdateDocResult {
success: boolean;
}
declare global {
interface Events {
'doc.updates.pushed': {
spaceType: 'workspace' | 'userspace';
spaceId: string;
docId: string;
updates: Uint8Array[];
timestamp: number;
editor?: string;
};
}
}
@Injectable()
export class DocWriter {
private readonly logger = new Logger(DocWriter.name);
constructor(private readonly storage: PgWorkspaceDocStorageAdapter) {}
constructor(
private readonly storage: PgWorkspaceDocStorageAdapter,
private readonly event: EventBus
) {}
/**
* Creates a new document from markdown content.
*
* @param workspaceId - The workspace ID
* @param markdown - The markdown content
* @param title - The document title
* @param markdown - The markdown content (body only)
* @param editorId - Optional editor ID for tracking
* @returns The created document ID
*/
async createDoc(
workspaceId: string,
title: string,
markdown: string,
editorId?: string
): Promise<CreateDocResult> {
@@ -58,24 +80,50 @@ export class DocWriter {
`Creating doc ${docId} in workspace ${workspaceId} from markdown`
);
// Convert markdown to y-octo binary
const binary = markdownToDocBinary(markdown, docId);
// Extract title from markdown (first H1 heading)
const titleMatch = markdown.match(/^#\s+(.+?)(?:\s*#+)?\s*$/m);
const title = titleMatch ? titleMatch[1].trim() : undefined;
// Convert markdown to y-octo binary using the provided title
const binary = createDocWithMarkdown(title, markdown, docId);
// Prepare root doc update to register the new document
const rootDocUpdate = addDocToRootDoc(rootDocBin, docId, title);
// Push both updates together - root doc first, then the new doc
await this.storage.pushDocUpdates(
const rootTimestamp = await this.storage.pushDocUpdates(
workspaceId,
workspaceId,
[rootDocUpdate],
editorId
);
await this.storage.pushDocUpdates(workspaceId, docId, [binary], editorId);
this.emitDocUpdatesPushed({
spaceId: workspaceId,
docId: workspaceId,
updates: [rootDocUpdate],
timestamp: rootTimestamp,
editor: editorId,
});
const docTimestamp = await this.storage.pushDocUpdates(
workspaceId,
docId,
[binary],
editorId
);
this.emitDocUpdatesPushed({
spaceId: workspaceId,
docId,
updates: [binary],
timestamp: docTimestamp,
editor: editorId,
});
await this.updateDocProperties(
workspaceId,
docId,
{
createdBy: editorId,
updatedBy: editorId,
},
editorId
);
this.logger.debug(
`Created and registered doc ${docId} in workspace ${workspaceId}`
@@ -88,8 +136,10 @@ export class DocWriter {
* Updates an existing document with new markdown content.
*
* Uses structural diffing to compute minimal changes between the existing
* document and new markdown, then applies only the delta. This preserves
* document history and enables proper CRDT merging with concurrent edits.
* document and new markdown, then applies block-level replacements for
* changed blocks. This preserves document history and enables proper CRDT
* merging with concurrent edits.
* Note: this does not update the document title.
*
* @param workspaceId - The workspace ID
* @param docId - The document ID to update
@@ -124,8 +174,194 @@ export class DocWriter {
const delta = updateDocWithMarkdown(existingBinary, markdown, docId);
// Push only the delta changes
await this.storage.pushDocUpdates(workspaceId, docId, [delta], editorId);
const timestamp = await this.storage.pushDocUpdates(
workspaceId,
docId,
[delta],
editorId
);
this.emitDocUpdatesPushed({
spaceId: workspaceId,
docId,
updates: [delta],
timestamp,
editor: editorId,
});
await this.updateDocProperties(
workspaceId,
docId,
{ updatedBy: editorId },
editorId
);
return { success: true };
}
/**
* Updates document metadata (currently title only).
*
* @param workspaceId - The workspace ID
* @param docId - The document ID to update
* @param meta - Metadata updates
* @param editorId - Optional editor ID for tracking
*/
async updateDocMeta(
workspaceId: string,
docId: string,
meta: { title?: string },
editorId?: string
): Promise<UpdateDocResult> {
if (meta.title === undefined) {
throw new Error('No metadata provided');
}
this.logger.debug(`Updating doc meta ${docId} in workspace ${workspaceId}`);
const existingDoc = await this.storage.getDoc(workspaceId, docId);
if (!existingDoc?.bin) {
throw new NotFoundException(`Document ${docId} not found`);
}
const rootDoc = await this.storage.getDoc(workspaceId, workspaceId);
if (!rootDoc?.bin) {
throw new NotFoundException(
`Workspace ${workspaceId} not found or has no root document`
);
}
const existingBinary = Buffer.isBuffer(existingDoc.bin)
? existingDoc.bin
: Buffer.from(
existingDoc.bin.buffer,
existingDoc.bin.byteOffset,
existingDoc.bin.byteLength
);
const rootDocBin = Buffer.isBuffer(rootDoc.bin)
? rootDoc.bin
: Buffer.from(
rootDoc.bin.buffer,
rootDoc.bin.byteOffset,
rootDoc.bin.byteLength
);
const titleUpdate = updateDocTitle(existingBinary, meta.title, docId);
const rootMetaUpdate = updateRootDocMetaTitle(
rootDocBin,
docId,
meta.title
);
const rootTimestamp = await this.storage.pushDocUpdates(
workspaceId,
workspaceId,
[rootMetaUpdate],
editorId
);
this.emitDocUpdatesPushed({
spaceId: workspaceId,
docId: workspaceId,
updates: [rootMetaUpdate],
timestamp: rootTimestamp,
editor: editorId,
});
const docTimestamp = await this.storage.pushDocUpdates(
workspaceId,
docId,
[titleUpdate],
editorId
);
this.emitDocUpdatesPushed({
spaceId: workspaceId,
docId,
updates: [titleUpdate],
timestamp: docTimestamp,
editor: editorId,
});
await this.updateDocProperties(
workspaceId,
docId,
{ updatedBy: editorId },
editorId
);
return { success: true };
}
private emitDocUpdatesPushed(payload: {
spaceId: string;
docId: string;
updates: Uint8Array[];
timestamp: number;
editor?: string;
}) {
this.event.emit('doc.updates.pushed', {
spaceType: 'workspace',
spaceId: payload.spaceId,
docId: payload.docId,
updates: payload.updates,
timestamp: payload.timestamp,
editor: payload.editor,
});
}
private async updateDocProperties(
workspaceId: string,
docId: string,
props: { createdBy?: string; updatedBy?: string },
editorId?: string
) {
if (!editorId) {
return;
}
if (
workspaceId === docId ||
docId.startsWith('db$') ||
docId.startsWith('userdata$')
) {
return;
}
if (!props.createdBy && !props.updatedBy) {
return;
}
const propertiesDocId = `db$${workspaceId}$docProperties`;
const existingDoc = await this.storage.getDoc(workspaceId, propertiesDocId);
const existingBinary = existingDoc?.bin
? Buffer.isBuffer(existingDoc.bin)
? existingDoc.bin
: Buffer.from(
existingDoc.bin.buffer,
existingDoc.bin.byteOffset,
existingDoc.bin.byteLength
)
: Buffer.alloc(0);
const update = updateDocProperties(
existingBinary,
propertiesDocId,
docId,
props.createdBy,
props.updatedBy
);
if (this.storage.isEmptyBin(update)) {
return;
}
const timestamp = await this.storage.pushDocUpdates(
workspaceId,
propertiesDocId,
[update],
editorId
);
this.emitDocUpdatesPushed({
spaceId: workspaceId,
docId: propertiesDocId,
updates: [update],
timestamp,
editor: editorId,
});
}
}

View File

@@ -6,9 +6,10 @@ import {
OnGatewayDisconnect,
SubscribeMessage as RawSubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { ClsInterceptor } from 'nestjs-cls';
import { Socket } from 'socket.io';
import { type Server, Socket } from 'socket.io';
import {
CallMetric,
@@ -18,6 +19,7 @@ import {
GatewayErrorWrapper,
metrics,
NotInSpace,
OnEvent,
SpaceAccessDenied,
} from '../../base';
import { Models } from '../../models';
@@ -141,6 +143,9 @@ export class SpaceSyncGateway
{
protected logger = new Logger(SpaceSyncGateway.name);
@WebSocketServer()
private readonly server!: Server;
private connectionCount = 0;
constructor(
@@ -166,6 +171,46 @@ export class SpaceSyncGateway
metrics.socketio.gauge('connections').record(this.connectionCount);
}
@OnEvent('doc.updates.pushed')
onDocUpdatesPushed({
spaceType,
spaceId,
docId,
updates,
timestamp,
editor,
}: Events['doc.updates.pushed']) {
if (!this.server || updates.length === 0) {
return;
}
const encodedUpdates = updates.map(update =>
Buffer.from(update).toString('base64')
);
this.server
.to(Room(spaceId, 'sync-019'))
.emit('space:broadcast-doc-updates', {
spaceType,
spaceId,
docId,
updates: encodedUpdates,
timestamp,
});
const room = `${spaceType}:${Room(spaceId)}`;
encodedUpdates.forEach(update => {
this.server.to(room).emit('space:broadcast-doc-update', {
spaceType,
spaceId,
docId,
update,
timestamp,
editor,
});
});
}
selectAdapter(client: Socket, spaceType: SpaceType): SyncSocketAdapter {
let adapters: Record<SpaceType, SyncSocketAdapter> = (client as any)
.affineSyncAdapters;

View File

@@ -133,14 +133,24 @@ export class TelemetryService {
};
} catch (error) {
const err = error as Error;
this.logger.error('Telemetry forwarding failed', err);
return {
ok: false,
error: {
name: err?.name ?? 'TelemetryForwardingError',
message: err?.message ?? 'Telemetry forwarding failed',
},
};
if (env.dev) {
this.logger.error('Telemetry forwarding failed', err);
return {
ok: false,
error: {
name: err?.name ?? 'TelemetryForwardingError',
message: err?.message ?? 'Telemetry forwarding failed',
},
};
} else {
return {
ok: false,
error: {
name: 'TelemetryForwardingError',
message: 'Telemetry forwarding failed',
},
};
}
}
}

View File

@@ -1440,6 +1440,33 @@ Generated by [AVA](https://avajs.dev).
[](Bookmark,https://affine.pro/)␊
[](Bookmark,https://www.youtube.com/@affinepro)␊
<img␊
src="blob://BFZk3c2ERp-sliRvA7MQ_p3NdkdCLt2Ze0DQ9i21dpA="␊
alt=""␊
width="1302"␊
height="728"␊
/>␊
<img␊
src="blob://HWvCItS78DzPGbwcuaGcfkpVDUvL98IvH5SIK8-AcL8="␊
alt=""␊
width="1463"␊
height="374"␊
/>␊
<img␊
src="blob://ZRKpsBoC88qEMmeiXKXqywfA1rLvWoLa5rpEh9x9Oj0="␊
alt=""␊
width="862"␊
height="1388"␊
/>␊
`,
title: 'Write, Draw, Plan all at Once.',
}
@@ -1449,55 +1476,74 @@ Generated by [AVA](https://avajs.dev).
> Snapshot 1
{
markdown: `<!-- block_id=RX4CG2zsBk flavour=affine:note -->␊
markdown: `<!-- block_id=FoPQcAyV_m flavour=affine:paragraph -->␊
AFFiNE is an open source all in one workspace, an operating system for all the building blocks of your team wiki, knowledge management and digital assets and a better alternative to Notion and Miro.␊
<!-- block_id=oz48nn_zp8 flavour=affine:paragraph -->␊
<!-- block_id=g8a-D9-jXS flavour=affine:paragraph -->␊
# You own your data, with no compromises␊
<!-- block_id=J8lHN1GR_5 flavour=affine:paragraph -->␊
## Local-first & Real-time collaborative␊
<!-- block_id=xCuWdM0VLz flavour=affine:paragraph -->␊
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊
<!-- block_id=zElMi0tViK flavour=affine:paragraph -->␊
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊
<!-- block_id=Z4rK0OF9Wk flavour=affine:paragraph -->␊
<!-- block_id=S1mkc8zUoU flavour=affine:note -->␊
<!-- block_id=DQ0Ryb-SpW flavour=affine:paragraph -->␊
### Blocks that assemble your next docs, tasks kanban or whiteboard␊
<!-- block_id=yGlBdshAqN flavour=affine:note -->␊
<!-- block_id=HAZC3URZp_ flavour=affine:paragraph -->␊
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊
<!-- block_id=0H87ypiuv8 flavour=affine:paragraph -->␊
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊
<!-- block_id=Sp4G1KD0Wn flavour=affine:paragraph -->␊
If you want to learn more about the product design of AFFiNE, here goes the concepts:␊
<!-- block_id=RsUhDuEqXa flavour=affine:paragraph -->␊
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊
<!-- block_id=6lDiuDqZGL flavour=affine:note -->␊
<!-- block_id=Z2HibKzAr- flavour=affine:paragraph -->␊
## A true canvas for blocks in any form␊
<!-- block_id=UwvWddamzM flavour=affine:paragraph -->␊
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊
<!-- block_id=g9xKUjhJj1 flavour=affine:paragraph -->␊
<!-- block_id=wDTn4YJ4pm flavour=affine:paragraph -->␊
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊
<!-- block_id=xFrrdiP3-V flavour=affine:list -->␊
* Quip & Notion with their great concept of "everything is a block"␊
<!-- block_id=Tp9xyN4Okl flavour=affine:list -->␊
* Trello with their Kanban␊
<!-- block_id=K_4hUzKZFQ flavour=affine:list -->␊
* Airtable & Miro with their no-code programable datasheets␊
<!-- block_id=QwMzON2s7x flavour=affine:list -->␊
* Miro & Whimiscal with their edgeless visual whiteboard␊
<!-- block_id=FFVmit6u1T flavour=affine:list -->␊
* Remnote & Capacities with their object-based tag system␊
<!-- block_id=cauvaHOQmh flavour=affine:note -->␊
<!-- block_id=YqnG5O6AE6 flavour=affine:paragraph -->␊
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊
<!-- block_id=sbDTmZMZcq flavour=affine:paragraph -->␊
## Self Host␊
<!-- block_id=QVvitesfbj flavour=affine:paragraph -->␊
Self host AFFiNE␊
<!-- block_id=2jwCeO8Yot flavour=affine:note -->␊
<!-- block_id=U_GoHFD9At flavour=affine:database -->␊
### Learning From␊
||Title|Tag|␊
@@ -1510,14 +1556,47 @@ Generated by [AVA](https://avajs.dev).
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊
<!-- block_id=c9MF_JiRgx flavour=affine:note -->␊
<!-- block_id=NyHXrMX3R1 flavour=affine:paragraph -->␊
## Affine Development␊
<!-- block_id=9-K49otbCv flavour=affine:paragraph -->␊
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊
<!-- block_id=faFteK9eG- flavour=affine:paragraph -->␊
<!-- block_id=6x7ALjUDjj flavour=affine:surface -->␊
<!-- block_id=ECrtbvW6xx flavour=affine:bookmark -->␊
[](Bookmark,https://affine.pro/)␊
<!-- block_id=5W--UQLN11 flavour=affine:bookmark -->␊
[](Bookmark,https://www.youtube.com/@affinepro)␊
<!-- block_id=lcZphIJe63 flavour=affine:image -->␊
<img␊
src="blob://BFZk3c2ERp-sliRvA7MQ_p3NdkdCLt2Ze0DQ9i21dpA="␊
alt=""␊
width="1302"␊
height="728"␊
/>␊
<!-- block_id=JlgVJdWU12 flavour=affine:image -->␊
<img␊
src="blob://HWvCItS78DzPGbwcuaGcfkpVDUvL98IvH5SIK8-AcL8="␊
alt=""␊
width="1463"␊
height="374"␊
/>␊
<!-- block_id=lht7AqBqnF flavour=affine:image -->␊
<img␊
src="blob://ZRKpsBoC88qEMmeiXKXqywfA1rLvWoLa5rpEh9x9Oj0="␊
alt=""␊
width="862"␊
height="1388"␊
/>␊
`,
title: 'Write, Draw, Plan all at Once.',
}

View File

@@ -51,6 +51,9 @@ export const AFFINE_PRO_LICENSE_AES_KEY =
serverNativeModule.AFFINE_PRO_LICENSE_AES_KEY;
// MCP write tools exports
export const markdownToDocBinary = serverNativeModule.markdownToDocBinary;
export const createDocWithMarkdown = serverNativeModule.createDocWithMarkdown;
export const updateDocWithMarkdown = serverNativeModule.updateDocWithMarkdown;
export const addDocToRootDoc = serverNativeModule.addDocToRootDoc;
export const updateDocTitle = serverNativeModule.updateDocTitle;
export const updateDocProperties = serverNativeModule.updateDocProperties;
export const updateRootDocMetaTitle = serverNativeModule.updateRootDocMetaTitle;

View File

@@ -166,146 +166,217 @@ export class WorkspaceMcpProvider {
}
);
// Write tools - create and update documents
server.registerTool(
'create_document',
{
title: 'Create Document',
description:
'Create a new document in the workspace with the given title and markdown content. Returns the ID of the created document.',
inputSchema: z.object({
title: z.string().min(1).describe('The title of the new document'),
content: z
.string()
.describe(
'The markdown content for the document body (should NOT include a title H1 - the title parameter will be used)'
),
}),
},
async ({ title, content }) => {
try {
// Check if user can create docs in this workspace
await this.ac
if (env.dev || env.namespaces.canary) {
// Write tools - create and update documents
server.registerTool(
'create_document',
{
title: 'Create Document',
description:
'Create a new document in the workspace with the given title and markdown content. Returns the ID of the created document. This tool not support insert or update database block and image yet.',
inputSchema: z.object({
title: z.string().min(1).describe('The title of the new document'),
content: z
.string()
.describe('The markdown content for the document body'),
}),
},
async ({ title, content }) => {
try {
// Check if user can create docs in this workspace
await this.ac
.user(userId)
.workspace(workspaceId)
.assert('Workspace.CreateDoc');
// Sanitize title by removing newlines and trimming
const sanitizedTitle = title.replace(/[\r\n]+/g, ' ').trim();
if (!sanitizedTitle) {
throw new Error('Title cannot be empty');
}
// Strip any leading H1 from content to prevent duplicates
// Per CommonMark spec, ATX headings allow only 0-3 spaces before the #
// Handles: "# Title", " # Title", "# Title #"
const strippedContent = content.replace(
/^[ \t]{0,3}#\s+[^\n]*#*\s*\n*/,
''
);
// Create the document
const result = await this.writer.createDoc(
workspaceId,
sanitizedTitle,
strippedContent,
userId
);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
docId: result.docId,
message: `Document "${title}" created successfully`,
}),
},
],
} as const;
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `Failed to create document: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
],
};
}
}
);
server.registerTool(
'update_document',
{
title: 'Update Document',
description:
'Update an existing document with new markdown content (body only). Uses structural diffing to apply minimal changes, preserving document history and enabling real-time collaboration. This does NOT update the document title. This tool not support insert or update database block and image yet.',
inputSchema: z.object({
docId: z.string().describe('The ID of the document to update'),
content: z
.string()
.describe(
'The complete new markdown content for the document body (do NOT include a title H1)'
),
}),
},
async ({ docId, content }) => {
const notFoundError: CallToolResult = {
isError: true,
content: [
{
type: 'text',
text: `Doc with id ${docId} not found.`,
},
],
};
// Use can() instead of assert() to avoid leaking doc existence info
const accessible = await this.ac
.user(userId)
.workspace(workspaceId)
.assert('Workspace.CreateDoc');
.doc(docId)
.can('Doc.Update');
// Combine title and content into markdown
// Sanitize title by removing newlines and trimming
const sanitizedTitle = title.replace(/[\r\n]+/g, ' ').trim();
if (!sanitizedTitle) {
throw new Error('Title cannot be empty');
if (!accessible) {
return notFoundError;
}
// Strip any leading H1 from content to prevent duplicates
// Per CommonMark spec, ATX headings allow only 0-3 spaces before the #
// Handles: "# Title", " # Title", "# Title #"
const strippedContent = content.replace(
/^[ \t]{0,3}#\s+[^\n]*#*\s*\n*/,
''
);
try {
// Update the document
await this.writer.updateDoc(workspaceId, docId, content, userId);
const markdown = `# ${sanitizedTitle}\n\n${strippedContent}`;
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
docId,
message: `Document updated successfully`,
}),
},
],
} as const;
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `Failed to update document: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
],
};
}
}
);
// Create the document
const result = await this.writer.createDoc(
workspaceId,
markdown,
userId
);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
docId: result.docId,
message: `Document "${title}" created successfully`,
}),
},
],
} as const;
} catch (error) {
return {
server.registerTool(
'update_document_meta',
{
title: 'Update Document Metadata',
description: 'Update document metadata (currently title only).',
inputSchema: z.object({
docId: z.string().describe('The ID of the document to update'),
title: z.string().min(1).describe('The new document title'),
}),
},
async ({ docId, title }) => {
const notFoundError: CallToolResult = {
isError: true,
content: [
{
type: 'text',
text: `Failed to create document: ${error instanceof Error ? error.message : 'Unknown error'}`,
text: `Doc with id ${docId} not found.`,
},
],
};
}
}
);
server.registerTool(
'update_document',
{
title: 'Update Document',
description:
'Update an existing document with new markdown content. Uses structural diffing to apply minimal changes, preserving document history and enabling real-time collaboration.',
inputSchema: z.object({
docId: z.string().describe('The ID of the document to update'),
content: z
.string()
.describe(
'The complete new markdown content for the document (including title as H1)'
),
}),
},
async ({ docId, content }) => {
const notFoundError: CallToolResult = {
isError: true,
content: [
{
type: 'text',
text: `Doc with id ${docId} not found.`,
},
],
};
// Use can() instead of assert() to avoid leaking doc existence info
const accessible = await this.ac
.user(userId)
.workspace(workspaceId)
.doc(docId)
.can('Doc.Update');
// Use can() instead of assert() to avoid leaking doc existence info
const accessible = await this.ac
.user(userId)
.workspace(workspaceId)
.doc(docId)
.can('Doc.Update');
if (!accessible) {
return notFoundError;
}
if (!accessible) {
return notFoundError;
}
try {
const sanitizedTitle = title.replace(/[\r\n]+/g, ' ').trim();
if (!sanitizedTitle) {
throw new Error('Title cannot be empty');
}
try {
// Update the document
await this.writer.updateDoc(workspaceId, docId, content, userId);
return {
content: [
await this.writer.updateDocMeta(
workspaceId,
docId,
{
type: 'text',
text: JSON.stringify({
success: true,
docId,
message: `Document updated successfully`,
}),
title: sanitizedTitle,
},
],
} as const;
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `Failed to update document: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
],
};
userId
);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
docId,
message: `Document title updated successfully`,
}),
},
],
} as const;
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `Failed to update document metadata: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
],
};
}
}
}
);
);
}
return server;
}

View File

@@ -127,6 +127,7 @@ export class ChatPrompt {
selectedMarkdown,
selectedSnapshot,
html,
currentDocId,
} = params;
return {
'affine::date': new Date().toLocaleDateString(),
@@ -135,6 +136,8 @@ export class ChatPrompt {
'affine::hasDocsRef': Array.isArray(docs) && docs.length > 0,
'affine::hasFilesRef': Array.isArray(files) && files.length > 0,
'affine::hasSelected': !!selectedMarkdown || !!selectedSnapshot || !!html,
'affine::hasCurrentDoc':
typeof currentDocId === 'string' && currentDocId.trim().length > 0,
};
}

View File

@@ -1950,6 +1950,13 @@ User's preferred language is {{affine::language}}.
User's timezone is {{affine::timezone}}.
</real_world_info>
{{#affine::hasCurrentDoc}}
<current_document_context>
The user is chatting within the current document: {{currentDocId}}.
If the user's request relates to this document, call the doc_read tool with docId {{currentDocId}} to read it before answering.
</current_document_context>
{{/affine::hasCurrentDoc}}
<content_analysis>
- If documents are provided, analyze all documents based on the user's query
- Identify key information relevant to the user's specific request
@@ -2086,7 +2093,10 @@ Below is the user's query. Please respond in the user's preferred language witho
config: {
tools: [
'docRead',
'sectionEdit',
'docCreate',
'docUpdate',
'docUpdateMeta',
// 'sectionEdit',
'docKeywordSearch',
'docSemanticSearch',
'webSearch',

View File

@@ -9,7 +9,7 @@ import {
CopilotProviderNotSupported,
OnEvent,
} from '../../../base';
import { DocReader } from '../../../core/doc';
import { DocReader, DocWriter } from '../../../core/doc';
import { AccessController } from '../../../core/permission';
import { Models } from '../../../models';
import { IndexerService } from '../../indexer';
@@ -19,16 +19,22 @@ import {
buildBlobContentGetter,
buildContentGetter,
buildDocContentGetter,
buildDocCreateHandler,
buildDocKeywordSearchGetter,
buildDocSearchGetter,
buildDocUpdateHandler,
buildDocUpdateMetaHandler,
createBlobReadTool,
createCodeArtifactTool,
createConversationSummaryTool,
createDocComposeTool,
createDocCreateTool,
createDocEditTool,
createDocKeywordSearchTool,
createDocReadTool,
createDocSemanticSearchTool,
createDocUpdateMetaTool,
createDocUpdateTool,
createExaCrawlTool,
createExaSearchTool,
createSectionEditTool,
@@ -163,6 +169,7 @@ export abstract class CopilotProvider<C = any> {
strict: false,
});
const docReader = this.moduleRef.get(DocReader, { strict: false });
const docWriter = this.moduleRef.get(DocWriter, { strict: false });
const models = this.moduleRef.get(Models, { strict: false });
const prompt = this.moduleRef.get(PromptService, {
strict: false,
@@ -177,6 +184,12 @@ export abstract class CopilotProvider<C = any> {
}
continue;
}
if (
!(env.dev || env.namespaces.canary) &&
['docCreate', 'docUpdate', 'docUpdateMeta'].includes(tool)
) {
continue;
}
switch (tool) {
case 'blobRead': {
const docContext = options.session
@@ -244,6 +257,27 @@ export abstract class CopilotProvider<C = any> {
tools.doc_read = createDocReadTool(getDoc.bind(null, options));
break;
}
case 'docCreate': {
const createDoc = buildDocCreateHandler(ac, docWriter);
tools.doc_create = createDocCreateTool(
createDoc.bind(null, options)
);
break;
}
case 'docUpdate': {
const updateDoc = buildDocUpdateHandler(ac, docWriter);
tools.doc_update = createDocUpdateTool(
updateDoc.bind(null, options)
);
break;
}
case 'docUpdateMeta': {
const updateDocMeta = buildDocUpdateMetaHandler(ac, docWriter);
tools.doc_update_meta = createDocUpdateMetaTool(
updateDocMeta.bind(null, options)
);
break;
}
case 'webSearch': {
tools.web_search_exa = createExaSearchTool(this.AFFiNEConfig);
tools.web_crawl_exa = createExaCrawlTool(this.AFFiNEConfig);

View File

@@ -66,6 +66,9 @@ export const PromptToolsSchema = z
'docEdit',
// work with indexer
'docRead',
'docCreate',
'docUpdate',
'docUpdateMeta',
'docKeywordSearch',
// work with embeddings
'docSemanticSearch',

View File

@@ -0,0 +1,207 @@
import { Logger } from '@nestjs/common';
import { tool } from 'ai';
import { z } from 'zod';
import { DocWriter } from '../../../core/doc';
import { AccessController } from '../../../core/permission';
import type { CopilotChatOptions } from '../providers';
import { toolError } from './error';
const logger = new Logger('DocWriteTool');
const stripLeadingH1 = (content: string) =>
content.replace(/^[ \t]{0,3}#\s+[^\n]*#*\s*\n*/, '');
const sanitizeTitle = (title: string) => title.replace(/[\r\n]+/g, ' ').trim();
export const buildDocCreateHandler = (
ac: AccessController,
writer: DocWriter
) => {
return async (
options: CopilotChatOptions,
title: string,
content: string
) => {
if (!options?.user || !options.workspace) {
return toolError(
'Doc Create Failed',
'Missing user or workspace context'
);
}
await ac
.user(options.user)
.workspace(options.workspace)
.assert('Workspace.CreateDoc');
const sanitizedTitle = sanitizeTitle(title);
if (!sanitizedTitle) {
return toolError('Doc Create Failed', 'Title cannot be empty');
}
const strippedContent = stripLeadingH1(content);
const result = await writer.createDoc(
options.workspace,
sanitizedTitle,
strippedContent,
options.user
);
return {
success: true,
docId: result.docId,
message: `Document "${sanitizedTitle}" created successfully`,
};
};
};
export const buildDocUpdateHandler = (
ac: AccessController,
writer: DocWriter
) => {
return async (
options: CopilotChatOptions,
docId: string,
content: string
) => {
const notFound = toolError(
'Doc Update Failed',
`Doc with id ${docId} not found.`
);
if (!options?.user || !options.workspace) {
return notFound;
}
const canAccess = await ac
.user(options.user)
.workspace(options.workspace)
.doc(docId)
.can('Doc.Update');
if (!canAccess) {
return notFound;
}
await writer.updateDoc(options.workspace, docId, content, options.user);
return {
success: true,
docId,
message: 'Document updated successfully',
};
};
};
export const buildDocUpdateMetaHandler = (
ac: AccessController,
writer: DocWriter
) => {
return async (options: CopilotChatOptions, docId: string, title: string) => {
const notFound = toolError(
'Doc Meta Update Failed',
`Doc with id ${docId} not found.`
);
if (!options?.user || !options.workspace) {
return notFound;
}
const canAccess = await ac
.user(options.user)
.workspace(options.workspace)
.doc(docId)
.can('Doc.Update');
if (!canAccess) {
return notFound;
}
const sanitizedTitle = sanitizeTitle(title);
if (!sanitizedTitle) {
return toolError('Doc Meta Update Failed', 'Title cannot be empty');
}
await writer.updateDocMeta(
options.workspace,
docId,
{ title: sanitizedTitle },
options.user
);
return {
success: true,
docId,
message: 'Document title updated successfully',
};
};
};
export const createDocCreateTool = (
createDoc: (title: string, content: string) => Promise<object>
) => {
return tool({
description:
'Create a new document in the workspace with the given title and markdown content. Returns the ID of the created document. This tool not support insert or update database block and image yet.',
inputSchema: z.object({
title: z.string().min(1).describe('The title of the new document'),
content: z
.string()
.describe('The markdown content for the document body'),
}),
execute: async ({ title, content }) => {
try {
return await createDoc(title, content);
} catch (err: any) {
logger.error(`Failed to create document: ${title}`, err);
return toolError('Doc Create Failed', err.message);
}
},
});
};
export const createDocUpdateTool = (
updateDoc: (docId: string, content: string) => Promise<object>
) => {
return tool({
description:
'Update an existing document with new markdown content (body only). Uses structural diffing to apply minimal changes. This does NOT update the document title. This tool not support insert or update database block and image yet.',
inputSchema: z.object({
doc_id: z.string().describe('The ID of the document to update'),
content: z
.string()
.describe(
'The complete new markdown content for the document body (do NOT include a title H1)'
),
}),
execute: async ({ doc_id, content }) => {
try {
return await updateDoc(doc_id, content);
} catch (err: any) {
logger.error(`Failed to update document: ${doc_id}`, err);
return toolError('Doc Update Failed', err.message);
}
},
});
};
export const createDocUpdateMetaTool = (
updateDocMeta: (docId: string, title: string) => Promise<object>
) => {
return tool({
description: 'Update document metadata (currently title only).',
inputSchema: z.object({
doc_id: z.string().describe('The ID of the document to update'),
title: z.string().min(1).describe('The new document title'),
}),
execute: async ({ doc_id, title }) => {
try {
return await updateDocMeta(doc_id, title);
} catch (err: any) {
logger.error(`Failed to update document meta: ${doc_id}`, err);
return toolError('Doc Meta Update Failed', err.message);
}
},
});
};

View File

@@ -8,6 +8,11 @@ import { createDocEditTool } from './doc-edit';
import { createDocKeywordSearchTool } from './doc-keyword-search';
import { createDocReadTool } from './doc-read';
import { createDocSemanticSearchTool } from './doc-semantic-search';
import {
createDocCreateTool,
createDocUpdateMetaTool,
createDocUpdateTool,
} from './doc-write';
import { createExaCrawlTool } from './exa-crawl';
import { createExaSearchTool } from './exa-search';
import { createSectionEditTool } from './section-edit';
@@ -20,6 +25,9 @@ export interface CustomAITools extends ToolSet {
doc_semantic_search: ReturnType<typeof createDocSemanticSearchTool>;
doc_keyword_search: ReturnType<typeof createDocKeywordSearchTool>;
doc_read: ReturnType<typeof createDocReadTool>;
doc_create: ReturnType<typeof createDocCreateTool>;
doc_update: ReturnType<typeof createDocUpdateTool>;
doc_update_meta: ReturnType<typeof createDocUpdateMetaTool>;
doc_compose: ReturnType<typeof createDocComposeTool>;
section_edit: ReturnType<typeof createSectionEditTool>;
web_search_exa: ReturnType<typeof createExaSearchTool>;
@@ -34,6 +42,7 @@ export * from './doc-edit';
export * from './doc-keyword-search';
export * from './doc-read';
export * from './doc-semantic-search';
export * from './doc-write';
export * from './error';
export * from './exa-crawl';
export * from './exa-search';