feat: ai apply ui (#12962)

## New Features
* **Block Meta Markdown Adapter**:Inject the Block's metadata into
Markdown.
* **UI**:Apply interaction
   * **Widget**
* Block-Level Widget: Displays the diffs of individual blocks within the
main content and supports accepting/rejecting individual diffs.
* Page-Level Widget: Displays global options (Accept all/Reject all).
   * **Block Diff Service**:Bridge widget and diff data
* Widget subscribes to DiffMap(RenderDiff) data, refreshing the view
when the data changes.
* Widget performs operations such as Accept/Reject via methods provided
by Service.
   * **Doc Edit Tool Card**:
     * Display apply preview of semantic doc edit
     * Support apply & accept/reject to the main content
* **Apply Playground**:A devtool for testing apply new content to
current

> CLOSE AI-274 AI-275 AI-276  AI-278 

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced block-level markdown diffing with accept/reject controls
for insertions, deletions, and updates.
* Added block diff widgets for individual blocks and pages, featuring
navigation and bulk accept/reject actions.
* Provided a block diff playground for testing and previewing markdown
changes (development mode only).
* Added a new document editing AI tool component with interactive diff
viewing and change application.
* Supported rendering of the document editing tool within AI chat
content streams.

* **Improvements**
* Enhanced widget rendering in list, paragraph, data view, and database
blocks for improved extensibility.
* Improved widget flavour matching with hierarchical wildcard support
for more flexible UI integration.

* **Chores**
* Updated the "@toeverything/theme" dependency to version ^1.1.16 across
multiple packages.
* Added new workspace dependencies for core frontend packages to improve
module linkage.
* Extended global styles with visual highlights for deleted blocks in AI
block diff feature.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
德布劳外 · 贾贵
2025-07-08 11:44:44 +08:00
committed by GitHub
parent 810143be87
commit 6fd9524521
88 changed files with 1873 additions and 152 deletions

View File

@@ -0,0 +1,32 @@
import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
export const blockTagMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = {
flavour: 'affine:page/affine:note/*',
toMatch: () => false,
fromMatch: o => {
const block = o.node;
const parent = o.parent;
if (block.type === 'block' && parent?.node.flavour === 'affine:note') {
return true;
}
return false;
},
toBlockSnapshot: {},
fromBlockSnapshot: {
async enter(block, adapterContext) {
adapterContext.walkerContext
.openNode({
type: 'html',
value: `<!-- block_id=${block.node.id} flavour=${block.node.flavour} -->`,
})
.closeNode();
},
},
};
export const BlockTagMarkdownAdapterExtension = BlockMarkdownAdapterExtension(
blockTagMarkdownAdapterMatcher
);

View File

@@ -8,6 +8,7 @@ import type { Signal } from '@preact/signals-core';
import { css, html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { BlockDiffProvider } from '../../services/block-diff';
import type { AffineAIPanelState } from '../../widgets/ai-panel/type';
import type { StreamObject } from '../ai-chat-messages';
@@ -46,6 +47,8 @@ export class ChatContentStreamObjects extends WithDisposable(
return nothing;
}
const imageProxyService = this.host?.store.get(ImageProxyService);
const blockDiffService = this.host?.view.std.getOptional(BlockDiffProvider);
switch (streamObject.toolName) {
case 'web_crawl_exa':
return html`
@@ -81,6 +84,14 @@ export class ChatContentStreamObjects extends WithDisposable(
.imageProxyService=${imageProxyService}
></code-artifact-tool>
`;
case 'doc_edit':
return html`
<doc-edit-tool
.data=${streamObject}
.doc=${this.host?.store}
.blockDiffService=${blockDiffService}
></doc-edit-tool>
`;
default: {
const name = streamObject.toolName + ' tool calling';
return html`
@@ -95,6 +106,8 @@ export class ChatContentStreamObjects extends WithDisposable(
return nothing;
}
const imageProxyService = this.host?.store.get(ImageProxyService);
const blockDiffService = this.host?.view.std.getOptional(BlockDiffProvider);
switch (streamObject.toolName) {
case 'web_crawl_exa':
return html`
@@ -130,6 +143,15 @@ export class ChatContentStreamObjects extends WithDisposable(
.imageProxyService=${imageProxyService}
></code-artifact-tool>
`;
case 'doc_edit':
return html`
<doc-edit-tool
.data=${streamObject}
.host=${this.host}
.blockDiffService=${blockDiffService}
.renderRichText=${this.renderRichText.bind(this)}
></doc-edit-tool>
`;
default: {
const name = streamObject.toolName + ' tool result';
return html`

View File

@@ -0,0 +1,417 @@
import { WithDisposable } from '@blocksuite/affine/global/lit';
import { NotificationProvider } from '@blocksuite/affine/shared/services';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { type EditorHost, ShadowlessElement } from '@blocksuite/affine/std';
import {
CloseIcon,
CopyIcon,
DoneIcon,
ExpandCloseIcon,
ExpandFullIcon,
PenIcon as EditIcon,
PenIcon,
} from '@blocksuite/icons/lit';
import { css, html, nothing, type PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js';
import type { BlockDiffService } from '../../services/block-diff';
import { diffMarkdown } from '../../utils/apply-model/markdown-diff';
import { copyText } from '../../utils/editor-actions';
import type { ToolError } from './type';
interface DocEditToolCall {
type: 'tool-call';
toolCallId: string;
toolName: 'doc_edit';
}
interface DocEditToolResult {
type: 'tool-result';
toolCallId: string;
toolName: 'doc_edit';
args: {
instructions: string;
code_edit: string;
doc_id: string;
};
result:
| {
result: string;
}
| ToolError
| null;
}
function removeMarkdownComments(markdown: string): string {
return markdown.replace(/<!--[\s\S]*?-->/g, '');
}
export class DocEditTool extends WithDisposable(ShadowlessElement) {
static override styles = css`
:host {
display: block;
}
.doc-edit-tool-result-wrapper {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 8px;
svg {
width: 20px;
height: 20px;
}
}
.doc-edit-tool-result-title {
color: ${unsafeCSSVarV2('text/primary')};
padding: 8px;
margin-bottom: 8px;
}
.doc-edit-tool-result-card {
display: flex;
flex-direction: column;
align-items: flex-start;
background: ${unsafeCSSVarV2('layer/background/overlayPanel')};
box-shadow: ${unsafeCSSVar('shadow1')};
border-radius: 8px;
width: 100%;
.doc-edit-tool-result-card-header {
display: flex;
flex-direction: row;
align-items: center;
padding: 8px;
width: 100%;
justify-content: space-between;
border-bottom: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
.doc-edit-tool-result-card-header-title {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
color: ${unsafeCSSVarV2('text/primary')};
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.doc-edit-tool-result-card-header-operations {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
cursor: pointer;
padding-right: 8px;
color: ${unsafeCSSVarV2('text/secondary')};
span {
width: 20px;
height: 20px;
}
}
}
.doc-edit-tool-result-card-content {
padding: 8px;
width: 100%;
}
.doc-edit-tool-result-card-footer {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 8px;
gap: 4px;
width: 100%;
cursor: pointer;
.doc-edit-tool-result-reject,
.doc-edit-tool-result-accept {
display: flex;
align-items: center;
gap: 4px;
padding: 8px;
}
}
&.collapsed .doc-edit-tool-result-card-content,
&.collapsed .doc-edit-tool-result-card-footer {
display: none;
}
.doc-edit-tool-result-card-diff {
border-radius: 4px;
padding: 8px;
width: 100%;
}
.doc-edit-tool-result-card-diff-replace {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-bottom: 8px;
gap: 8px;
.doc-edit-tool-result-card-diff.original {
background: ${unsafeCSSVarV2('aI/applyDeleteHighlight')};
}
.doc-edit-tool-result-card-diff.modified {
background: ${unsafeCSSVarV2('aI/applyTextHighlightBackground')};
}
}
.doc-edit-tool-result-card-diff.deleted {
background: ${unsafeCSSVarV2('aI/applyDeleteHighlight')};
margin-bottom: 8px;
}
.doc-edit-tool-result-card-diff.insert {
background: ${unsafeCSSVarV2('aI/applyTextHighlightBackground')};
margin-bottom: 8px;
}
.doc-edit-tool-result-card-diff-title {
font-size: 12px;
}
}
`;
@property({ attribute: false })
accessor host!: EditorHost | null;
@property({ attribute: false })
accessor data!: DocEditToolCall | DocEditToolResult;
@property({ attribute: false })
accessor blockDiffService: BlockDiffService | undefined;
@property({ attribute: false })
accessor renderRichText!: (text: string) => string;
@state()
accessor isCollapsed = false;
@state()
accessor originalMarkdown: string | undefined;
private async _handleApply(markdown: string) {
if (!this.host) {
return;
}
await this.blockDiffService?.apply(this.host.store, markdown);
}
private async _handleReject(changedMarkdown: string) {
if (!this.host) {
return;
}
this.blockDiffService?.setChangedMarkdown(changedMarkdown);
this.blockDiffService?.rejectAll();
}
private async _handleAccept(changedMarkdown: string) {
if (!this.host) {
return;
}
await this.blockDiffService?.apply(this.host.store, changedMarkdown);
await this.blockDiffService?.acceptAll(this.host.store);
}
private async _toggleCollapse() {
this.isCollapsed = !this.isCollapsed;
}
private async _handleCopy(changedMarkdown: string) {
if (!this.host) {
return;
}
const success = await copyText(
this.host,
removeMarkdownComments(changedMarkdown)
);
if (success) {
const notificationService =
this.host?.std.getOptional(NotificationProvider);
notificationService?.notify({
title: 'Copied to clipboard',
accent: 'success',
onClose: function (): void {},
});
}
}
renderToolCall() {
return html`
<tool-call-card
.name=${'Editing the document'}
.icon=${EditIcon()}
></tool-call-card>
`;
}
renderSantizedText(text: string) {
return this.renderRichText(removeMarkdownComments(text));
}
renderBlockDiffs(originalMarkdown: string, changedMarkdown: string) {
const { patches, oldBlocks } = diffMarkdown(
originalMarkdown,
changedMarkdown
);
const oldBlockMap = new Map(oldBlocks.map(b => [b.id, b]));
return html`
<div>
${patches.map(patch => {
if (patch.op === 'replace') {
const oldBlock = oldBlockMap.get(patch.id);
return html`
<div class="doc-edit-tool-result-card-diff-replace">
<div class="doc-edit-tool-result-card-diff original">
<div class="doc-edit-tool-result-card-diff-title">
Original
</div>
<div>${this.renderSantizedText(oldBlock?.content ?? '')}</div>
</div>
<div class="doc-edit-tool-result-card-diff modified">
<div class="doc-edit-tool-result-card-diff-title">
Modified
</div>
<div>${this.renderSantizedText(patch.content)}</div>
</div>
</div>
`;
} else if (patch.op === 'delete') {
const oldBlock = oldBlockMap.get(patch.id);
return html`
<div class="doc-edit-tool-result-card-diff deleted">
<div class="doc-edit-tool-result-card-diff-title">Deleted</div>
<div>${this.renderSantizedText(oldBlock?.content ?? '')}</div>
</div>
`;
} else if (patch.op === 'insert') {
return html`
<div class="doc-edit-tool-result-card-diff insert">
<div class="doc-edit-tool-result-card-diff-title">Inserted</div>
<div>${this.renderSantizedText(patch.block.content)}</div>
</div>
`;
}
return nothing;
})}
</div>
`;
}
renderToolResult() {
if (this.data.type !== 'tool-result' || !this.originalMarkdown) {
return nothing;
}
const result = this.data.result;
if (result && 'result' in result) {
const changedMarkdown = result.result;
const { instructions, doc_id: docId } = this.data.args;
return html`
<div class="doc-edit-tool-result-wrapper">
<div class="doc-edit-tool-result-title">${instructions}</div>
<div
class="doc-edit-tool-result-card ${this.isCollapsed
? 'collapsed'
: ''}"
>
<div class="doc-edit-tool-result-card-header">
<div class="doc-edit-tool-result-card-header-title">
${PenIcon({
style: `color: ${unsafeCSSVarV2('icon/activated')}`,
})}
${docId}
</div>
<div class="doc-edit-tool-result-card-header-operations">
<span @click=${() => this._toggleCollapse()}
>${this.isCollapsed
? ExpandFullIcon()
: ExpandCloseIcon()}</span
>
<span @click=${() => this._handleCopy(changedMarkdown)}>
${CopyIcon()}
</span>
<button @click=${() => this._handleApply(changedMarkdown)}>
Apply
</button>
</div>
</div>
<div class="doc-edit-tool-result-card-content">
<div class="doc-edit-tool-result-card-content-title">
${this.renderBlockDiffs(this.originalMarkdown, changedMarkdown)}
</div>
</div>
<div class="doc-edit-tool-result-card-footer">
<div
class="doc-edit-tool-result-reject"
@click=${() => this._handleReject(changedMarkdown)}
>
${CloseIcon({
style: `color: ${unsafeCSSVarV2('icon/secondary')}`,
})}
Reject
</div>
<div
class="doc-edit-tool-result-accept"
@click=${() => this._handleAccept(changedMarkdown)}
>
${DoneIcon({
style: `color: ${unsafeCSSVarV2('icon/activated')}`,
})}
Accept
</div>
</div>
</div>
</div>
`;
}
return html`
<tool-call-failed
.name=${'Document editing failed'}
.icon=${EditIcon()}
></tool-call-failed>
`;
}
protected override firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
if (!this.host) {
return;
}
this.blockDiffService
?.getMarkdownFromDoc(this.host.store)
.then(markdown => {
this.originalMarkdown = markdown;
})
.catch(() => {
// TODO: handle error
});
}
protected override render() {
const { data } = this;
if (data.type === 'tool-call') {
return this.renderToolCall();
}
if (data.type === 'tool-result') {
return this.renderToolResult();
}
return nothing;
}
}

View File

@@ -53,6 +53,7 @@ import {
CodeHighlighter,
} from './components/ai-tools/code-artifact';
import { DocComposeTool } from './components/ai-tools/doc-compose';
import { DocEditTool } from './components/ai-tools/doc-edit';
import { ToolCallCard } from './components/ai-tools/tool-call-card';
import { ToolFailedCard } from './components/ai-tools/tool-failed-card';
import { ToolResultCard } from './components/ai-tools/tool-result-card';
@@ -86,6 +87,21 @@ import {
} from './widgets/ai-panel/components';
import { AIFinishTip } from './widgets/ai-panel/components/finish-tip';
import { GeneratingPlaceholder } from './widgets/ai-panel/components/generating-placeholder';
import {
AFFINE_BLOCK_DIFF_WIDGET_FOR_BLOCK,
AffineBlockDiffWidgetForBlock,
} from './widgets/block-diff/block';
import { BlockDiffOptions } from './widgets/block-diff/options';
import {
AFFINE_BLOCK_DIFF_WIDGET_FOR_PAGE,
AffineBlockDiffWidgetForPage,
} from './widgets/block-diff/page';
import {
AFFINE_BLOCK_DIFF_PLAYGROUND,
AFFINE_BLOCK_DIFF_PLAYGROUND_MODAL,
BlockDiffPlayground,
BlockDiffPlaygroundModal,
} from './widgets/block-diff/playground';
import {
AFFINE_EDGELESS_COPILOT_WIDGET,
EdgelessCopilotWidget,
@@ -177,6 +193,12 @@ export function registerAIEffects() {
customElements.define('chat-message-action', ChatMessageAction);
customElements.define('chat-message-assistant', ChatMessageAssistant);
customElements.define('chat-message-user', ChatMessageUser);
customElements.define('ai-block-diff-options', BlockDiffOptions);
customElements.define(AFFINE_BLOCK_DIFF_PLAYGROUND, BlockDiffPlayground);
customElements.define(
AFFINE_BLOCK_DIFF_PLAYGROUND_MODAL,
BlockDiffPlaygroundModal
);
customElements.define('tool-call-card', ToolCallCard);
customElements.define('tool-result-card', ToolResultCard);
@@ -187,9 +209,18 @@ export function registerAIEffects() {
customElements.define('code-artifact-tool', CodeArtifactTool);
customElements.define('code-highlighter', CodeHighlighter);
customElements.define('artifact-preview-panel', ArtifactPreviewPanel);
customElements.define('doc-edit-tool', DocEditTool);
customElements.define(AFFINE_AI_PANEL_WIDGET, AffineAIPanelWidget);
customElements.define(AFFINE_EDGELESS_COPILOT_WIDGET, EdgelessCopilotWidget);
customElements.define(
AFFINE_BLOCK_DIFF_WIDGET_FOR_BLOCK,
AffineBlockDiffWidgetForBlock
);
customElements.define(
AFFINE_BLOCK_DIFF_WIDGET_FOR_PAGE,
AffineBlockDiffWidgetForPage
);
customElements.define('edgeless-copilot-panel', EdgelessCopilotPanel);
customElements.define(

View File

@@ -0,0 +1,434 @@
import { LifeCycleWatcher } from '@blocksuite/affine/std';
import { Extension, type Store } from '@blocksuite/affine/store';
import {
BlockMarkdownAdapterMatcherIdentifier,
MarkdownAdapter,
} from '@blocksuite/affine-shared/adapters';
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { LiveData } from '@toeverything/infra';
import type { Subscription } from 'rxjs';
import { blockTagMarkdownAdapterMatcher } from '../adapters/block-tag';
import { applyPatchToDoc } from '../utils/apply-model/apply-patch-to-doc';
import {
generateRenderDiff,
type RenderDiffs,
} from '../utils/apply-model/generate-render-diff';
interface RejectMap {
deletes: string[];
inserts: string[];
updates: string[];
}
type AcceptDelete = {
type: 'delete';
payload: {
id: string;
};
};
type AcceptUpdate = {
type: 'update';
payload: {
id: string;
content: string;
};
};
type AcceptInsert = {
type: 'insert';
payload: {
from: string;
offset: number;
content: string;
};
};
type Accept = AcceptDelete | AcceptUpdate | AcceptInsert;
type RejectDelete = {
type: 'delete';
payload: {
id: string;
};
};
type RejectUpdate = {
type: 'update';
payload: {
id: string;
};
};
type RejectInsert = {
type: 'insert';
payload: {
from: string;
offset: number;
};
};
type Reject = RejectDelete | RejectUpdate | RejectInsert;
export interface BlockDiffProvider {
diffMap$: LiveData<RenderDiffs>;
rejects$: LiveData<RejectMap>;
isBatchingApply: boolean;
/**
* Set the original markdown
* @param originalMarkdown - The original markdown
*/
setOriginalMarkdown(originalMarkdown: string): void;
/**
* Set the changed markdown
* @param changedMarkdown - The changed markdown
*/
setChangedMarkdown(changedMarkdown: string): void;
/**
* Apply the diff to the doc
* @param doc - The doc
* @param changedMarkdown - The changed markdown
*/
apply(doc: Store, changedMarkdown: string): Promise<void>;
/**
* Clear the diff map
*/
clearDiff(): void;
/**
* Get the diff map
*/
getDiff(): RenderDiffs;
/**
* Check if there is any diff
*/
hasDiff(): boolean;
/**
* Accept all the diffs
*/
acceptAll(doc: Store): Promise<void>;
/**
* Accept a diff
*/
accept(accept: Accept, doc: Store): Promise<void>;
/**
* Reject all the diffs
*/
rejectAll(): void;
/**
* Reject a diff
*/
reject(reject: Reject): void;
/**
* Check if a diff is rejected
*/
isRejected(type: 'delete' | 'update' | 'insert', index: string): boolean;
/**
* Get the total number of diffs
*/
getTotalDiffs(): number;
/**
* Get the markdown from the doc
* @param doc - The doc
*/
getMarkdownFromDoc(doc: Store): Promise<string>;
/**
* Get the index of a block in the doc
* @param doc - The doc
* @param blockId - The id of the block
*/
getBlockIndexById(doc: Store, blockId: string): number;
}
export const BlockDiffProvider = createIdentifier<BlockDiffProvider>(
'AffineBlockDiffService'
);
export class BlockDiffService extends Extension implements BlockDiffProvider {
rejects$ = new LiveData<RejectMap>({
deletes: [],
inserts: [],
updates: [],
});
diffMap$ = new LiveData<RenderDiffs>({
deletes: [],
inserts: {},
updates: {},
});
private originalMarkdown: string | null = null;
private changedMarkdown: string | null = null;
isBatchingApply = false;
static override setup(di: Container) {
di.addImpl(BlockDiffProvider, BlockDiffService);
}
getBlockIndexById(doc: Store, blockId: string): number {
const notes = doc.getBlocksByFlavour('affine:note');
if (notes.length === 0) return 0;
const note = notes[0].model;
return note.children.findIndex(child => child.id === blockId);
}
hasDiff(): boolean {
const { deletes, updates, inserts } = this.diffMap$.value;
if (
deletes.length > 0 ||
Object.keys(updates).length > 0 ||
Object.keys(inserts).length > 0
) {
return true;
}
return false;
}
setOriginalMarkdown(originalMarkdown: string) {
this.originalMarkdown = originalMarkdown;
this._refreshDiff();
}
setChangedMarkdown(changedMarkdown: string) {
this.changedMarkdown = changedMarkdown;
this.clearRejects();
this._refreshDiff();
}
async apply(doc: Store, changedMarkdown: string) {
this.originalMarkdown = await this.getMarkdownFromDoc(doc);
this.changedMarkdown = changedMarkdown;
this.clearRejects();
this._refreshDiff();
}
private _refreshDiff(): void {
if (!this.originalMarkdown || !this.changedMarkdown) {
this.clearDiff();
return;
}
const diffMap = generateRenderDiff(
this.originalMarkdown,
this.changedMarkdown
);
this.diffMap$.next(diffMap);
}
getDiff(): RenderDiffs {
return this.diffMap$.value;
}
clearDiff(): void {
this.diffMap$.next({
deletes: [],
inserts: {},
updates: {},
});
}
clearRejects(): void {
this.rejects$.next({
deletes: [],
inserts: [],
updates: [],
});
}
async acceptAll(doc: Store): Promise<void> {
this.isBatchingApply = true;
const { deletes, updates, inserts } = this.diffMap$.value;
try {
for (const [id, content] of Object.entries(updates)) {
await applyPatchToDoc(doc, [{ op: 'replace', id, content }]);
}
for (const [from, blocks] of Object.entries(inserts)) {
let baseIndex = 0;
if (from !== 'HEAD') {
baseIndex = this.getBlockIndexById(doc, from) + 1;
}
for (const [offset, block] of blocks.entries()) {
await applyPatchToDoc(doc, [
{ op: 'insert', index: baseIndex + offset, block },
]);
}
}
for (const id of deletes) {
await applyPatchToDoc(doc, [{ op: 'delete', id }]);
}
this.diffMap$.next({
deletes: [],
inserts: {},
updates: {},
});
} finally {
this.isBatchingApply = false;
}
}
async accept(accept: Accept, doc: Store) {
const { type, payload } = accept;
switch (type) {
case 'delete': {
await applyPatchToDoc(doc, [{ op: 'delete', id: payload.id }]);
break;
}
case 'update': {
await applyPatchToDoc(doc, [
{ op: 'replace', id: payload.id, content: payload.content },
]);
break;
}
case 'insert': {
const block = this.diffMap$.value.inserts[payload.from][payload.offset];
let baseIndex = 0;
if (payload.from !== 'HEAD') {
baseIndex = this.getBlockIndexById(doc, payload.from) + 1;
}
await applyPatchToDoc(doc, [
{ op: 'insert', index: baseIndex + payload.offset, block },
]);
break;
}
}
}
rejectAll(): void {
this.clearDiff();
this.clearRejects();
this.changedMarkdown = null;
}
reject(reject: Reject): void {
const rejects = this.rejects$.value;
switch (reject.type) {
case 'delete':
this.rejects$.next({
...rejects,
deletes: [...rejects.deletes, reject.payload.id],
});
break;
case 'update':
this.rejects$.next({
...rejects,
updates: [...rejects.updates, reject.payload.id],
});
break;
case 'insert':
this.rejects$.next({
...rejects,
inserts: [
...rejects.inserts,
`${reject.payload.from}:${reject.payload.offset}`,
],
});
break;
}
}
isRejected(type: 'delete' | 'update' | 'insert', index: string): boolean {
const rejects = this.rejects$.value;
if (type === 'delete') {
return rejects.deletes.includes(index);
}
if (type === 'update') {
return rejects.updates.includes(index);
}
if (type === 'insert') {
return rejects.inserts.includes(index);
}
return false;
}
getTotalDiffs(): number {
const rejects = this.rejects$.value;
const { deletes, updates, inserts } = this.diffMap$.value;
const insertCount = Object.values(inserts).reduce(
(sum, arr) => sum + arr.length,
0
);
const rejectDeleteCount = rejects.deletes.length;
const rejectUpdateCount = rejects.updates.length;
const rejectInsertCount = rejects.inserts.length;
return (
deletes.length +
Object.keys(updates).length +
insertCount -
rejectDeleteCount -
rejectUpdateCount -
rejectInsertCount
);
}
getMarkdownFromDoc = async (doc: Store) => {
const cloned = doc.provider.container.clone();
cloned.addImpl(
BlockMarkdownAdapterMatcherIdentifier,
blockTagMarkdownAdapterMatcher
);
const job = doc.getTransformer();
const snapshot = job.docToSnapshot(doc);
const adapter = new MarkdownAdapter(job, cloned.provider());
if (!snapshot) {
return 'Failed to get markdown from doc';
}
// FIXME: reverse the block matchers to make the block tag adapter the first one
adapter.blockMatchers.reverse();
const markdown = await adapter.fromDocSnapshot({
snapshot,
assets: job.assetsManager,
});
return markdown.file;
};
}
export class BlockDiffWatcher extends LifeCycleWatcher {
static override key = 'block-diff-watcher';
private _blockUpdatedSubscription: Subscription | null = null;
override created() {
super.created();
}
private readonly _refreshOriginalMarkdown = async () => {
const diffService = this.std.get(BlockDiffProvider);
if (!diffService.hasDiff() || diffService.isBatchingApply) {
return;
}
const markdown = await diffService.getMarkdownFromDoc(this.std.store);
if (markdown) {
diffService.setOriginalMarkdown(markdown);
}
};
override mounted() {
super.mounted();
this._blockUpdatedSubscription =
this.std.store.slots.blockUpdated.subscribe(() => {
this._refreshOriginalMarkdown().catch(err => {
console.error('Failed to refresh original markdown', err);
});
});
}
override unmounted() {
super.unmounted();
this._blockUpdatedSubscription?.unsubscribe();
}
}

View File

@@ -0,0 +1,219 @@
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/affine/std';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { css, html, nothing, type TemplateResult } from 'lit';
import { literal, unsafeStatic } from 'lit/static-html.js';
import { BlockDiffProvider } from '../../services/block-diff';
import type { Block } from '../../utils/apply-model/markdown-diff';
import { blockDiffWidgetForPage } from './page';
export const AFFINE_BLOCK_DIFF_WIDGET_FOR_BLOCK =
'affine-block-diff-widget-for-block';
export class AffineBlockDiffWidgetForBlock extends WidgetComponent {
static override styles = css`
.ai-block-diff {
position: relative;
margin-top: 8px;
margin-bottom: 8px;
pointer-events: none;
background-color: ${unsafeCSSVarV2('aI/applyTextHighlightBackground')};
padding: 8px 0px;
border-radius: 4px;
}
.ai-block-diff.delete {
position: absolute;
top: -8px;
left: 4px;
width: 100%;
height: 100%;
margin: 0;
background: transparent;
}
`;
private _setDeletedStyle(blockId: string) {
const deleted = document.querySelector<HTMLDivElement>(
`[data-block-id="${blockId}"]`
);
if (!deleted) {
return;
}
deleted.classList.add('ai-block-diff-deleted');
}
private _clearDeletedStyle(blockId: string) {
const block = document.querySelector<HTMLDivElement>(
`[data-block-id="${blockId}"]`
);
if (!block) {
return;
}
block.classList.remove('ai-block-diff-deleted');
}
private _renderDelete(blockId: string) {
if (this.diffService.isRejected('delete', blockId)) {
return nothing;
}
this._setDeletedStyle(blockId);
return html`<div
class="ai-block-diff delete"
data-diff-id=${`delete-${blockId}`}
>
<ai-block-diff-options
.onAccept=${() =>
this.diffService.accept(
{ type: 'delete', payload: { id: blockId } },
this.std.store
)}
.onReject=${() =>
this.diffService.reject({ type: 'delete', payload: { id: blockId } })}
></ai-block-diff-options>
</div>`;
}
private _renderInsert(from: string, blocks: Block[]) {
return blocks
.map((block, offset) => {
return this.diffService.isRejected('insert', `${from}:${offset}`)
? null
: html`<div
class="ai-block-diff insert"
data-diff-id=${`insert-${block.id}-${offset}`}
>
<chat-content-rich-text
.text=${block.content}
.host=${this.host}
.state="finished"
.extensions=${this.userExtensions}
></chat-content-rich-text>
<ai-block-diff-options
.onAccept=${() =>
this.diffService.accept(
{
type: 'insert',
payload: { from, offset, content: block.content },
},
this.std.store
)}
.onReject=${() =>
this.diffService.reject({
type: 'insert',
payload: { from, offset },
})}
></ai-block-diff-options>
</div>`;
})
.filter(Boolean) as TemplateResult[];
}
private _renderUpdate(blockId: string, content: string) {
if (this.diffService.isRejected('update', blockId)) {
return nothing;
}
return html`
<div class="ai-block-diff update" data-diff-id=${`update-${blockId}`}>
<chat-content-rich-text
.text=${content}
.host=${this.host}
.state="finished"
.extensions=${this.userExtensions}
></chat-content-rich-text>
<ai-block-diff-options
.onAccept=${() =>
this.diffService.accept(
{
type: 'update',
payload: { id: blockId, content },
},
this.std.store
)}
.onReject=${() =>
this.diffService.reject({
type: 'update',
payload: { id: blockId },
})}
></ai-block-diff-options>
</div>
`;
}
get diffService() {
return this.std.get(BlockDiffProvider);
}
get userExtensions() {
return this.std.userExtensions.filter(
extension => extension !== blockDiffWidgetForPage
);
}
get blockIndex(): number {
const attached = this.block?.blockId;
if (!attached) {
return -1;
}
return this.diffService.getBlockIndexById(this.std.store, attached);
}
override render() {
const attached = this.block?.blockId;
const service = this.std.get(BlockDiffProvider);
const blockIndex = this.blockIndex;
if (attached) {
this._clearDeletedStyle(attached);
}
if (!attached || blockIndex < 0 || !service.hasDiff()) {
return nothing;
}
const { deletes, inserts, updates } = service.getDiff();
let deleteDiff: TemplateResult | symbol = nothing;
let updateDiff: TemplateResult | symbol = nothing;
let insertDiff: TemplateResult[] | symbol = nothing;
if (deletes.includes(attached)) {
deleteDiff = this._renderDelete(attached);
}
if (updates[attached]) {
updateDiff = this._renderUpdate(attached, updates[attached]);
}
if (inserts[attached]) {
const blocks = inserts[attached];
insertDiff = this._renderInsert(attached, blocks);
}
return html`${deleteDiff} ${updateDiff} ${insertDiff}`;
}
override connectedCallback() {
super.connectedCallback();
this.disposables.add(
this.diffService.diffMap$.subscribe(() => {
this.requestUpdate();
})
);
this.disposables.add(
this.diffService.rejects$.subscribe(() => {
this.requestUpdate();
})
);
}
}
export const blockDiffWidgetForBlock = WidgetViewExtension(
'affine:note/*',
AFFINE_BLOCK_DIFF_WIDGET_FOR_BLOCK,
literal`${unsafeStatic(AFFINE_BLOCK_DIFF_WIDGET_FOR_BLOCK)}`
);

View File

@@ -0,0 +1,84 @@
import { WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { CloseIcon, DoneIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import type { PatchOp } from '../../utils/apply-model/markdown-diff';
export class BlockDiffOptions extends WithDisposable(LitElement) {
static override styles = css`
:host {
position: absolute;
right: -20px;
top: 0;
display: flex;
flex-direction: column;
gap: 4px;
cursor: pointer;
pointer-events: auto;
}
.ai-block-diff-option {
padding: 2px;
border-radius: 4px;
box-shadow: ${unsafeCSSVar('shadow1')};
display: flex;
background-color: ${unsafeCSSVarV2('layer/background/overlayPanel')};
align-items: center;
justify-content: center;
border-radius: 4px;
}
.ai-block-diff-option.accept {
color: ${unsafeCSSVarV2('icon/activated')};
}
.ai-block-diff-option.reject {
color: ${unsafeCSSVarV2('icon/secondary')};
}
`;
@property({ attribute: false })
accessor onAccept!: (op: PatchOp) => void;
@property({ attribute: false })
accessor op!: PatchOp;
@property({ attribute: false })
accessor onReject!: (op: PatchOp) => void;
private readonly _handleAcceptClick = () => {
console.log('accept', this.op);
this.onAccept(this.op);
};
private readonly _handleRejectClick = () => {
console.log('reject', this.op);
this.onReject(this.op);
};
override render() {
return html`
<div
class="ai-block-diff-option accept"
@click=${this._handleAcceptClick}
>
${DoneIcon()}
</div>
<div
class="ai-block-diff-option reject"
@click=${this._handleRejectClick}
>
${CloseIcon()}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'ai-block-diff-options': BlockDiffOptions;
}
}

View File

@@ -0,0 +1,152 @@
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/affine/std';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import {
ArrowDownSmallIcon,
ArrowUpSmallIcon,
CloseIcon,
DoneIcon,
} from '@blocksuite/icons/lit';
import { css, html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import { literal, unsafeStatic } from 'lit/static-html.js';
import { BlockDiffProvider } from '../../services/block-diff';
export const AFFINE_BLOCK_DIFF_WIDGET_FOR_PAGE =
'affine-block-diff-widget-for-page';
export class AffineBlockDiffWidgetForPage extends WidgetComponent {
static override styles = css`
.ai-block-diff-scroller-container {
margin: auto;
display: flex;
gap: 4px;
justify-content: center;
align-items: center;
background-color: ${unsafeCSSVarV2('layer/background/overlayPanel')};
box-shadow: ${unsafeCSSVar('shadow1')};
border-radius: 8px;
width: 350px;
padding: 8px 4px;
cursor: pointer;
}
.ai-block-diff-scroller {
display: flex;
align-items: center;
gap: 4px;
}
.ai-block-diff-scroller span {
display: inline-flex;
}
.ai-block-diff-scroller svg {
color: ${unsafeCSSVarV2('icon/primary')};
}
.ai-block-diff-all-option {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 4px 8px;
}
`;
@property({ type: Number })
accessor currentIndex = 0;
_handleScroll(dir: 'prev' | 'next') {
const total = this.diffService.getTotalDiffs();
const diffWidgets = Array.from(
this.std.host.querySelectorAll('affine-block-diff-widget-for-block')
);
const diffs = diffWidgets.reduce<Element[]>((acc, widget) => {
const aiDiffs = widget.shadowRoot?.querySelectorAll('.ai-block-diff');
if (aiDiffs && aiDiffs.length > 0) {
acc.push(...aiDiffs);
}
return acc;
}, []);
if (dir === 'prev') {
this.currentIndex = Math.max(0, this.currentIndex - 1);
} else {
this.currentIndex = Math.min(total - 1, this.currentIndex + 1);
}
diffs[this.currentIndex].scrollIntoView({ behavior: 'smooth' });
}
get diffService() {
return this.std.get(BlockDiffProvider);
}
override render() {
if (!this.diffService.hasDiff()) {
return nothing;
}
const total = this.diffService.getTotalDiffs();
return total === 0
? null
: html`
<div class="ai-block-diff-scroller-container">
<div class="ai-block-diff-scroller">
<span @click=${() => this._handleScroll('next')}
>${ArrowDownSmallIcon()}</span
>
<span class="ai-block-diff-scroller-current"
>${Math.min(this.currentIndex + 1, total)}</span
>
<span>/</span>
<span class="ai-block-diff-scroller-total">${total}</span>
<span @click=${() => this._handleScroll('prev')}
>${ArrowUpSmallIcon()}</span
>
</div>
<div
class="ai-block-diff-all-option"
@click=${() => this.diffService.rejectAll()}
>
${CloseIcon({
style: `color: ${unsafeCSSVarV2('icon/secondary')}`,
})}
Reject all
</div>
<div
class="ai-block-diff-all-option"
@click=${() => this.diffService.acceptAll(this.std.store)}
>
${DoneIcon({
style: `color: ${unsafeCSSVarV2('icon/activated')}`,
})}
Accept all
</div>
</div>
`;
}
override connectedCallback() {
super.connectedCallback();
this.disposables.add(
this.diffService.diffMap$.subscribe(() => {
this.requestUpdate();
})
);
this.disposables.add(
this.diffService.rejects$.subscribe(() => {
this.requestUpdate();
})
);
}
}
export const blockDiffWidgetForPage = WidgetViewExtension(
'affine:page',
AFFINE_BLOCK_DIFF_WIDGET_FOR_PAGE,
literal`${unsafeStatic(AFFINE_BLOCK_DIFF_WIDGET_FOR_PAGE)}`
);

View File

@@ -0,0 +1,230 @@
import { WithDisposable } from '@blocksuite/affine/global/lit';
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/affine/std';
import type { Store } from '@blocksuite/affine/store';
import { createLitPortal } from '@blocksuite/affine-components/portal';
import { css, html, LitElement } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { literal, unsafeStatic } from 'lit/static-html.js';
import { BlockDiffProvider } from '../../services/block-diff';
export const AFFINE_BLOCK_DIFF_PLAYGROUND = 'affine-block-diff-playground';
export const AFFINE_BLOCK_DIFF_PLAYGROUND_MODAL =
'affine-block-diff-playground-modal';
export class BlockDiffPlaygroundModal extends WithDisposable(LitElement) {
static override styles = css`
.playground-modal {
z-index: 10000;
width: 600px;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18);
padding: 24px 20px 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.playground-textarea {
width: 100%;
min-height: 300px;
resize: vertical;
font-size: 15px;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 8px;
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.playground-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 8px;
}
.playground-btn {
padding: 6px 18px;
border: none;
border-radius: 4px;
font-size: 15px;
cursor: pointer;
background: #f5f5f5;
color: #333;
transition: background 0.2s;
}
.playground-btn.primary {
background: #1976d2;
color: #fff;
}
.playground-btn.primary:hover {
background: #1565c0;
}
.playground-btn:hover {
background: #e0e0e0;
}
`;
@state()
private accessor markdown = '';
@property({ attribute: false })
accessor diffService!: BlockDiffProvider;
@property({ attribute: false })
accessor store!: Store;
@property({ attribute: false })
accessor onClose!: () => void;
private readonly handleInput = (e: Event) => {
this.markdown = (e.target as HTMLTextAreaElement).value;
};
private readonly handleClear = () => {
this.markdown = '';
this.diffService.setChangedMarkdown('');
};
private async getOriginalMarkdown() {
const markdown = await this.diffService.getMarkdownFromDoc(this.store);
return markdown;
}
private readonly handleConfirm = async () => {
const originalMarkdown = await this.getOriginalMarkdown();
this.diffService.setOriginalMarkdown(originalMarkdown);
this.diffService.setChangedMarkdown(this.markdown);
this.onClose();
};
private readonly handleInsertCurrentMarkdown = async () => {
this.markdown = await this.getOriginalMarkdown();
};
private readonly stopPropagation = (e: MouseEvent) => {
e.stopPropagation();
};
override render() {
return html`
<div class="playground-modal">
<div class="playground-modal-title">Block Diff Playground</div>
<div class="playground-modal-content">
<textarea
class="playground-textarea"
placeholder="Please input the markdown you want to apply."
.value=${this.markdown}
@input=${this.handleInput}
@focus=${(e: FocusEvent) => e.stopPropagation()}
@pointerdown=${this.stopPropagation}
@mousedown=${this.stopPropagation}
@mouseup=${this.stopPropagation}
@click=${this.stopPropagation}
@keydown=${this.stopPropagation}
@keyup=${this.stopPropagation}
@copy=${this.stopPropagation}
@cut=${this.stopPropagation}
@paste=${this.stopPropagation}
@blur=${(e: FocusEvent) => e.stopPropagation()}
></textarea>
<div class="playground-actions">
<button
class="playground-btn"
@click=${this.handleInsertCurrentMarkdown}
>
Insert Current Doc MD
</button>
<button class="playground-btn" @click=${this.handleClear}>
Clear
</button>
<button class="playground-btn primary" @click=${this.handleConfirm}>
Confirm
</button>
</div>
</div>
</div>
`;
}
}
export class BlockDiffPlayground extends WidgetComponent {
static override styles = css`
.playground-fab {
position: fixed;
right: 32px;
bottom: 32px;
z-index: 9999;
width: 56px;
height: 56px;
border-radius: 50%;
background: #1976d2;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
cursor: pointer;
transition: background 0.2s;
}
.playground-fab:hover {
background: #1565c0;
}
`;
@query('.playground-fab')
accessor fab!: HTMLDivElement;
private _abortController: AbortController | null = null;
private get diffService() {
return this.std.get(BlockDiffProvider);
}
private readonly handleOpen = () => {
this._abortController?.abort();
this._abortController = new AbortController();
createLitPortal({
template: html`
<affine-block-diff-playground-modal
.diffService=${this.diffService}
.store=${this.std.store}
.onClose=${this.handleClose}
></affine-block-diff-playground-modal>
`,
container: this.host,
computePosition: {
referenceElement: this.fab,
placement: 'top-end',
},
closeOnClickAway: true,
abortController: this._abortController,
});
};
private readonly handleClose = () => {
this._abortController?.abort();
};
override render() {
return html`
<div>
<div
class="playground-fab"
@click=${this.handleOpen}
title="Block Diff Playground"
>
🧪
</div>
</div>
`;
}
}
export const blockDiffPlayground = WidgetViewExtension(
'affine:page',
AFFINE_BLOCK_DIFF_PLAYGROUND,
literal`${unsafeStatic(AFFINE_BLOCK_DIFF_PLAYGROUND)}`
);

View File

@@ -19,6 +19,13 @@ import { BlockFlavourIdentifier } from '@blocksuite/affine/std';
import { FrameworkProvider } from '@toeverything/infra';
import { z } from 'zod';
import {
BlockDiffService,
BlockDiffWatcher,
} from '../../ai/services/block-diff';
import { blockDiffWidgetForBlock } from '../../ai/widgets/block-diff/block';
import { blockDiffWidgetForPage } from '../../ai/widgets/block-diff/page';
import { blockDiffPlayground } from '../../ai/widgets/block-diff/playground';
import { EdgelessClipboardAIChatConfig } from './edgeless-clipboard';
const optionsSchema = z.object({
@@ -50,6 +57,7 @@ export class AIViewExtension extends ViewExtensionProvider<AIViewOptions> {
config: imageToolbarAIEntryConfig(),
})
);
if (context.scope === 'edgeless' || context.scope === 'page') {
context.register([
aiPanelWidget,
@@ -73,7 +81,17 @@ export class AIViewExtension extends ViewExtensionProvider<AIViewOptions> {
]);
}
if (context.scope === 'page') {
context.register(getAIPageRootWatcher(framework));
context.register([
blockDiffWidgetForPage,
blockDiffWidgetForBlock,
getAIPageRootWatcher(framework),
BlockDiffService,
BlockDiffWatcher,
]);
if (process.env.NODE_ENV === 'development') {
context.register([blockDiffPlayground]);
}
}
}
}