mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 13:25:12 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export class WebSearchTool extends WithDisposable(ShadowlessElement) {
|
||||
></tool-call-card>
|
||||
`;
|
||||
}
|
||||
|
||||
renderToolResult() {
|
||||
if (this.data.type !== 'tool-result') {
|
||||
return nothing;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user