feat(core): add section edit tool (#13313)

Close [AI-396](https://linear.app/affine-design/issue/AI-396)

<img width="798" height="294" alt="截屏2025-07-25 11 30 32"
src="https://github.com/user-attachments/assets/6366dab2-688b-470b-8b24-29a2d50a38c9"
/>



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

## Summary by CodeRabbit

* **New Features**
* Introduced a "Section Edit" AI tool for expert editing of specific
markdown sections based on user instructions, preserving formatting and
style.
* Added a new interface and UI component for section editing, allowing
users to view, copy, insert, or save edited content directly from chat
interactions.

* **Improvements**
* Enhanced AI chat and tool rendering to support and display section
editing results.
* Updated chat input handling for improved draft management and message
sending order.

* **Other Changes**
* Registered the new section editing tool in the system for seamless
integration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Wu Yue
2025-07-25 17:02:52 +08:00
committed by GitHub
parent ff9a4f4322
commit 0d43350afd
15 changed files with 392 additions and 5 deletions

View File

@@ -252,7 +252,7 @@ async function insertBelowBlock(
return true;
}
const PAGE_INSERT = {
export const PAGE_INSERT = {
icon: InsertBelowIcon({ width: '20px', height: '20px' }),
title: 'Insert',
showWhen: (host: EditorHost) => {
@@ -291,7 +291,7 @@ const PAGE_INSERT = {
},
};
const EDGELESS_INSERT = {
export const EDGELESS_INSERT = {
...PAGE_INSERT,
handler: async (
host: EditorHost,
@@ -469,7 +469,7 @@ const ADD_TO_EDGELESS_AS_NOTE = {
},
};
const SAVE_AS_DOC = {
export const SAVE_AS_DOC = {
icon: PageIcon({ width: '20px', height: '20px' }),
title: 'Save as doc',
showWhen: () => true,

View File

@@ -78,6 +78,7 @@ export class AIChatBlockMessage extends LitElement {
.affineFeatureFlagService=${this.textRendererOptions
.affineFeatureFlagService}
.notificationService=${notificationService}
.independentMode=${false}
.theme=${this.host.std.get(ThemeProvider).app$}
></chat-content-stream-objects>`;
}

View File

@@ -148,6 +148,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
.affineFeatureFlagService=${this.affineFeatureFlagService}
.notificationService=${this.notificationService}
.theme=${this.affineThemeService.appTheme.themeSignal}
.independentMode=${this.independentMode}
.docDisplayService=${this.docDisplayService}
.onOpenDoc=${this.onOpenDoc}
></chat-content-stream-objects>`;

View File

@@ -593,10 +593,10 @@ export class AIChatInput extends SignalWatcher(
this.isInputEmpty = true;
this.textarea.style.height = 'unset';
await this.send(value);
await this.aiDraftService.setDraft({
input: '',
});
await this.send(value);
};
private readonly _handleModelChange = (modelId: string) => {

View File

@@ -52,6 +52,9 @@ export class ChatContentStreamObjects extends WithDisposable(
@property({ attribute: false })
accessor theme!: Signal<ColorScheme>;
@property({ attribute: false })
accessor independentMode: boolean | undefined;
@property({ attribute: false })
accessor notificationService!: NotificationService;
@@ -123,6 +126,18 @@ export class ChatContentStreamObjects extends WithDisposable(
.data=${streamObject}
.width=${this.width}
></doc-read-result>`;
case 'section_edit':
return html`
<section-edit-tool
.data=${streamObject}
.extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.notificationService=${this.notificationService}
.theme=${this.theme}
.host=${this.host}
.independentMode=${this.independentMode}
></section-edit-tool>
`;
default: {
const name = streamObject.toolName + ' tool calling';
return html`
@@ -199,6 +214,18 @@ export class ChatContentStreamObjects extends WithDisposable(
.data=${streamObject}
.width=${this.width}
></doc-read-result>`;
case 'section_edit':
return html`
<section-edit-tool
.data=${streamObject}
.extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.notificationService=${this.notificationService}
.theme=${this.theme}
.host=${this.host}
.independentMode=${this.independentMode}
></section-edit-tool>
`;
default: {
const name = streamObject.toolName + ' tool result';
return html`

View File

@@ -0,0 +1,260 @@
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import type { ColorScheme } from '@blocksuite/affine/model';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import {
type BlockSelection,
type EditorHost,
ShadowlessElement,
type TextSelection,
} from '@blocksuite/affine/std';
import type { ExtensionType } from '@blocksuite/affine/store';
import type { NotificationService } from '@blocksuite/affine-shared/services';
import { isInsidePageEditor } from '@blocksuite/affine-shared/utils';
import {
CopyIcon,
InsertBleowIcon,
LinkedPageIcon,
PageIcon,
} from '@blocksuite/icons/lit';
import type { Signal } from '@preact/signals-core';
import { css, html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import {
EDGELESS_INSERT,
PAGE_INSERT,
SAVE_AS_DOC,
} from '../../_common/chat-actions-handle';
import { copyText } from '../../utils/editor-actions';
import type { ToolError } from './type';
interface SectionEditToolCall {
type: 'tool-call';
toolCallId: string;
toolName: string;
args: { section: string; instructions: string };
}
interface SectionEditToolResult {
type: 'tool-result';
toolCallId: string;
toolName: string;
args: { section: string; instructions: string };
result: { content: string } | ToolError | null;
}
export class SectionEditTool extends WithDisposable(ShadowlessElement) {
static override styles = css`
.section-edit-result {
padding: 12px;
margin: 8px 0;
border-radius: 8px;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
.section-edit-header {
height: 24px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
.section-edit-title {
display: flex;
align-items: center;
gap: 8px;
svg {
width: 24px;
height: 24px;
color: ${unsafeCSSVarV2('icon/primary')};
}
span {
font-size: 14px;
font-weight: 500;
color: ${unsafeCSSVarV2('icon/primary')};
line-height: 24px;
}
}
.section-edit-actions {
display: flex;
align-items: center;
gap: 8px;
.edit-button {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: ${unsafeCSSVarV2(
'layer/background/hoverOverlay'
)};
}
svg {
width: 20px;
height: 20px;
color: ${unsafeCSSVarV2('icon/primary')};
}
}
}
}
}
`;
@property({ attribute: false })
accessor data!: SectionEditToolCall | SectionEditToolResult;
@property({ attribute: false })
accessor extensions!: ExtensionType[];
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@property({ attribute: false })
accessor notificationService!: NotificationService;
@property({ attribute: false })
accessor theme!: Signal<ColorScheme>;
@property({ attribute: false })
accessor host: EditorHost | null | undefined;
@property({ attribute: false })
accessor independentMode: boolean | undefined;
private get selection() {
const value = this.host?.selection.value ?? [];
return {
text: value.find(v => v.type === 'text') as TextSelection | undefined,
blocks: value.filter(v => v.type === 'block') as BlockSelection[],
};
}
renderToolCall() {
return html`
<tool-call-card
.name=${`Editing: ${this.data.args.instructions}`}
.icon=${PageIcon()}
></tool-call-card>
`;
}
renderToolResult() {
if (this.data.type !== 'tool-result') {
return nothing;
}
const result = this.data.result;
if (result && 'content' in result) {
return html`
<div class="section-edit-result">
<div class="section-edit-header">
<div class="section-edit-title">
${PageIcon()}
<span>Edited Content</span>
</div>
<div class="section-edit-actions">
<div
class="edit-button"
@click=${async () => {
const success = await copyText(result.content);
if (success) {
this.notifySuccess('Copied to clipboard');
}
}}
>
${CopyIcon()}
<affine-tooltip>Copy</affine-tooltip>
</div>
${this.independentMode
? nothing
: html`<div
class="edit-button"
@click=${async () => {
if (!this.host) return;
if (this.host.std.store.readonly$.value) {
this.notificationService.notify({
title: 'Cannot insert in read-only mode',
accent: 'error',
onClose: () => {},
});
return;
}
if (isInsidePageEditor(this.host)) {
await PAGE_INSERT.handler(
this.host,
result.content,
this.selection
);
} else {
await EDGELESS_INSERT.handler(
this.host,
result.content,
this.selection
);
}
}}
>
${InsertBleowIcon()}
<affine-tooltip>Insert below</affine-tooltip>
</div>`}
${this.independentMode
? nothing
: html`<div
class="edit-button"
@click=${async () => {
if (!this.host) return;
SAVE_AS_DOC.handler(this.host, result.content);
}}
>
${LinkedPageIcon()}
<affine-tooltip>Create new doc</affine-tooltip>
</div>`}
</div>
</div>
<chat-content-rich-text
.text=${result.content}
.state=${'finished'}
.extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.theme=${this.theme}
></chat-content-rich-text>
</div>
`;
}
return html`
<tool-call-failed
.name=${'Section edit failed'}
.icon=${PageIcon()}
></tool-call-failed>
`;
}
private readonly notifySuccess = (title: string) => {
this.notificationService.notify({
title: title,
accent: 'success',
onClose: function (): void {},
});
};
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

@@ -47,6 +47,7 @@ export class WebSearchTool extends WithDisposable(ShadowlessElement) {
></tool-call-card>
`;
}
renderToolResult() {
if (this.data.type !== 'tool-result') {
return nothing;

View File

@@ -62,6 +62,7 @@ import { DocEditTool } from './components/ai-tools/doc-edit';
import { DocKeywordSearchResult } from './components/ai-tools/doc-keyword-search-result';
import { DocReadResult } from './components/ai-tools/doc-read-result';
import { DocSemanticSearchResult } from './components/ai-tools/doc-semantic-search-result';
import { SectionEditTool } from './components/ai-tools/section-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';
@@ -219,6 +220,7 @@ export function registerAIEffects() {
customElements.define('doc-read-result', DocReadResult);
customElements.define('web-crawl-tool', WebCrawlTool);
customElements.define('web-search-tool', WebSearchTool);
customElements.define('section-edit-tool', SectionEditTool);
customElements.define('doc-compose-tool', DocComposeTool);
customElements.define('code-artifact-tool', CodeArtifactTool);
customElements.define('code-highlighter', CodeHighlighter);