From 0d43350afd20fd90d896c33fa71caa38fc70244b Mon Sep 17 00:00:00 2001 From: Wu Yue Date: Fri, 25 Jul 2025 17:02:52 +0800 Subject: [PATCH] feat(core): add section edit tool (#13313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close [AI-396](https://linear.app/affine-design/issue/AI-396) 截屏2025-07-25 11 30 32 ## 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. --- oxlint.json | 1 + .../src/__tests__/copilot-provider.spec.ts | 1 + .../src/plugins/copilot/prompt/prompts.ts | 25 +- .../src/plugins/copilot/providers/provider.ts | 5 + .../src/plugins/copilot/providers/types.ts | 2 + .../server/src/plugins/copilot/tools/index.ts | 3 + .../src/plugins/copilot/tools/section-edit.ts | 60 ++++ .../ai/_common/chat-actions-handle.ts | 6 +- .../components/ai-chat-messages.ts | 1 + .../ai/chat-panel/message/assistant.ts | 1 + .../components/ai-chat-input/ai-chat-input.ts | 2 +- .../ai-message-content/stream-objects.ts | 27 ++ .../ai/components/ai-tools/section-edit.ts | 260 ++++++++++++++++++ .../ai/components/ai-tools/web-search.ts | 1 + .../core/src/blocksuite/ai/effects.ts | 2 + 15 files changed, 392 insertions(+), 5 deletions(-) create mode 100644 packages/backend/server/src/plugins/copilot/tools/section-edit.ts create mode 100644 packages/frontend/core/src/blocksuite/ai/components/ai-tools/section-edit.ts diff --git a/oxlint.json b/oxlint.json index f80b1ca56f..261fff18d0 100644 --- a/oxlint.json +++ b/oxlint.json @@ -159,6 +159,7 @@ } ], "unicorn/prefer-array-some": "error", + "unicorn/prefer-array-flat-map": "off", "unicorn/no-useless-promise-resolve-reject": "error", "unicorn/no-unnecessary-await": "error", "unicorn/no-useless-fallback-in-spread": "error", diff --git a/packages/backend/server/src/__tests__/copilot-provider.spec.ts b/packages/backend/server/src/__tests__/copilot-provider.spec.ts index 2d40e69bbf..a65a684b44 100644 --- a/packages/backend/server/src/__tests__/copilot-provider.spec.ts +++ b/packages/backend/server/src/__tests__/copilot-provider.spec.ts @@ -531,6 +531,7 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca 'Make it longer', 'Make it shorter', 'Continue writing', + 'Section Edit', 'Chat With AFFiNE AI', 'Search With AFFiNE AI', ], diff --git a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts index e419c74a52..8484c2a0fd 100644 --- a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts +++ b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts @@ -1468,6 +1468,29 @@ When sent new notes, respond ONLY with the contents of the html file.`, }, ], }, + { + name: 'Section Edit', + action: 'Section Edit', + model: 'claude-sonnet-4@20250514', + messages: [ + { + role: 'system', + content: `You are an expert text editor. Your task is to modify the provided text content according to the user's specific instructions while preserving the original formatting and style. +Key requirements: +- Follow the user's instructions precisely +- Maintain the original markdown formatting +- Preserve the tone and style unless specifically asked to change it +- Only make the requested changes +- Return only the modified text without any explanations or comments`, + }, + { + role: 'user', + content: `Please modify the following text according to these instructions: "{{instructions}}" +Original text: +{{content}}`, + }, + ], + }, ]; const imageActions: Prompt[] = [ @@ -1924,7 +1947,7 @@ Below is the user's query. Please respond in the user's preferred language witho config: { tools: [ 'docRead', - 'docEdit', + 'sectionEdit', 'docKeywordSearch', 'docSemanticSearch', 'webSearch', diff --git a/packages/backend/server/src/plugins/copilot/providers/provider.ts b/packages/backend/server/src/plugins/copilot/providers/provider.ts index 5d2315afa0..4ab382e066 100644 --- a/packages/backend/server/src/plugins/copilot/providers/provider.ts +++ b/packages/backend/server/src/plugins/copilot/providers/provider.ts @@ -29,6 +29,7 @@ import { createDocSemanticSearchTool, createExaCrawlTool, createExaSearchTool, + createSectionEditTool, } from '../tools'; import { CopilotProviderFactory } from './factory'; import { @@ -224,6 +225,10 @@ export abstract class CopilotProvider { tools.doc_compose = createDocComposeTool(prompt, this.factory); break; } + case 'sectionEdit': { + tools.section_edit = createSectionEditTool(prompt, this.factory); + break; + } } } return tools; diff --git a/packages/backend/server/src/plugins/copilot/providers/types.ts b/packages/backend/server/src/plugins/copilot/providers/types.ts index 4d3945681e..b480c1b30f 100644 --- a/packages/backend/server/src/plugins/copilot/providers/types.ts +++ b/packages/backend/server/src/plugins/copilot/providers/types.ts @@ -73,6 +73,8 @@ export const PromptConfigStrictSchema = z.object({ 'webSearch', // artifact tools 'docCompose', + // section editing + 'sectionEdit', ]) .array() .nullable() diff --git a/packages/backend/server/src/plugins/copilot/tools/index.ts b/packages/backend/server/src/plugins/copilot/tools/index.ts index 71588f9c52..166967d780 100644 --- a/packages/backend/server/src/plugins/copilot/tools/index.ts +++ b/packages/backend/server/src/plugins/copilot/tools/index.ts @@ -9,6 +9,7 @@ import { createDocReadTool } from './doc-read'; import { createDocSemanticSearchTool } from './doc-semantic-search'; import { createExaCrawlTool } from './exa-crawl'; import { createExaSearchTool } from './exa-search'; +import { createSectionEditTool } from './section-edit'; export interface CustomAITools extends ToolSet { code_artifact: ReturnType; @@ -18,6 +19,7 @@ export interface CustomAITools extends ToolSet { doc_keyword_search: ReturnType; doc_read: ReturnType; doc_compose: ReturnType; + section_edit: ReturnType; web_search_exa: ReturnType; web_crawl_exa: ReturnType; } @@ -32,3 +34,4 @@ export * from './doc-semantic-search'; export * from './error'; export * from './exa-crawl'; export * from './exa-search'; +export * from './section-edit'; diff --git a/packages/backend/server/src/plugins/copilot/tools/section-edit.ts b/packages/backend/server/src/plugins/copilot/tools/section-edit.ts new file mode 100644 index 0000000000..9952e434fb --- /dev/null +++ b/packages/backend/server/src/plugins/copilot/tools/section-edit.ts @@ -0,0 +1,60 @@ +import { Logger } from '@nestjs/common'; +import { tool } from 'ai'; +import { z } from 'zod'; + +import type { PromptService } from '../prompt'; +import type { CopilotProviderFactory } from '../providers'; +import { toolError } from './error'; + +const logger = new Logger('SectionEditTool'); + +export const createSectionEditTool = ( + promptService: PromptService, + factory: CopilotProviderFactory +) => { + return tool({ + description: + 'Intelligently edit and modify a specific section of a document based on user instructions. This tool can refine, rewrite, translate, restructure, or enhance any part of markdown content while preserving formatting and maintaining contextual coherence. Perfect for targeted improvements without affecting the entire document.', + parameters: z.object({ + section: z + .string() + .describe( + 'The section of the document to be modified (in markdown format)' + ), + instructions: z + .string() + .describe( + 'Clear instructions from the user describing the desired changes (e.g., "make this more formal", "translate to Chinese", "add more details", "fix grammar errors")' + ), + }), + execute: async ({ section, instructions }) => { + try { + const prompt = await promptService.get('Section Edit'); + if (!prompt) { + throw new Error('Prompt not found'); + } + const provider = await factory.getProviderByModel(prompt.model); + if (!provider) { + throw new Error('Provider not found'); + } + + const content = await provider.text( + { + modelId: prompt.model, + }, + prompt.finish({ + content: section, + instructions, + }) + ); + + return { + content: content.trim(), + }; + } catch (err: any) { + logger.error(`Failed to edit section`, err); + return toolError('Section Edit Failed', err.message); + } + }, + }); +}; diff --git a/packages/frontend/core/src/blocksuite/ai/_common/chat-actions-handle.ts b/packages/frontend/core/src/blocksuite/ai/_common/chat-actions-handle.ts index 585c095fa7..431d1e2c21 100644 --- a/packages/frontend/core/src/blocksuite/ai/_common/chat-actions-handle.ts +++ b/packages/frontend/core/src/blocksuite/ai/_common/chat-actions-handle.ts @@ -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, diff --git a/packages/frontend/core/src/blocksuite/ai/blocks/ai-chat-block/components/ai-chat-messages.ts b/packages/frontend/core/src/blocksuite/ai/blocks/ai-chat-block/components/ai-chat-messages.ts index 4ae8414d1b..54c6e859f6 100644 --- a/packages/frontend/core/src/blocksuite/ai/blocks/ai-chat-block/components/ai-chat-messages.ts +++ b/packages/frontend/core/src/blocksuite/ai/blocks/ai-chat-block/components/ai-chat-messages.ts @@ -78,6 +78,7 @@ export class AIChatBlockMessage extends LitElement { .affineFeatureFlagService=${this.textRendererOptions .affineFeatureFlagService} .notificationService=${notificationService} + .independentMode=${false} .theme=${this.host.std.get(ThemeProvider).app$} >`; } diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/message/assistant.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/message/assistant.ts index 45f43a6d50..a9d46764b3 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/message/assistant.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/message/assistant.ts @@ -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} >`; diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts index 80e3a56fb4..fa9c9b3f2c 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts @@ -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) => { diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/stream-objects.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/stream-objects.ts index 4da07e29a4..695c39b30e 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/stream-objects.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/stream-objects.ts @@ -52,6 +52,9 @@ export class ChatContentStreamObjects extends WithDisposable( @property({ attribute: false }) accessor theme!: Signal; + @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} >`; + case 'section_edit': + return html` + + `; default: { const name = streamObject.toolName + ' tool calling'; return html` @@ -199,6 +214,18 @@ export class ChatContentStreamObjects extends WithDisposable( .data=${streamObject} .width=${this.width} >`; + case 'section_edit': + return html` + + `; default: { const name = streamObject.toolName + ' tool result'; return html` diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/section-edit.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/section-edit.ts new file mode 100644 index 0000000000..4246ecc745 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/section-edit.ts @@ -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; + + @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` + + `; + } + + renderToolResult() { + if (this.data.type !== 'tool-result') { + return nothing; + } + + const result = this.data.result; + if (result && 'content' in result) { + return html` +
+
+
+ ${PageIcon()} + Edited Content +
+
+
{ + const success = await copyText(result.content); + if (success) { + this.notifySuccess('Copied to clipboard'); + } + }} + > + ${CopyIcon()} + Copy +
+ ${this.independentMode + ? nothing + : html`
{ + 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()} + Insert below +
`} + ${this.independentMode + ? nothing + : html`
{ + if (!this.host) return; + SAVE_AS_DOC.handler(this.host, result.content); + }} + > + ${LinkedPageIcon()} + Create new doc +
`} +
+
+ +
+ `; + } + + return html` + + `; + } + + 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; + } +} diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/web-search.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/web-search.ts index f59af6642e..2f51c63e12 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/web-search.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/web-search.ts @@ -47,6 +47,7 @@ export class WebSearchTool extends WithDisposable(ShadowlessElement) { > `; } + renderToolResult() { if (this.data.type !== 'tool-result') { return nothing; diff --git a/packages/frontend/core/src/blocksuite/ai/effects.ts b/packages/frontend/core/src/blocksuite/ai/effects.ts index 39255d49f2..ce2498be71 100644 --- a/packages/frontend/core/src/blocksuite/ai/effects.ts +++ b/packages/frontend/core/src/blocksuite/ai/effects.ts @@ -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);