diff --git a/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/index.ts b/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/index.ts index 4b0218566..315e16727 100644 --- a/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/index.ts +++ b/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/index.ts @@ -5,3 +5,4 @@ export * from './edgeless-clipboard-config'; export * from './embed-edgeless-linked-doc-block'; export * from './embed-linked-doc-block'; export * from './embed-linked-doc-spec'; +export { getEmbedLinkedDocIcons } from './utils'; diff --git a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts index 1980aa427..8989e53b4 100644 --- a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts +++ b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts @@ -1752,6 +1752,7 @@ Below is the user's query. Please respond in the user's preferred language witho 'docKeywordSearch', 'docSemanticSearch', 'webSearch', + 'docCompose', ], }, }; diff --git a/packages/backend/server/src/plugins/copilot/providers/provider.ts b/packages/backend/server/src/plugins/copilot/providers/provider.ts index 213fbe317..1b5842920 100644 --- a/packages/backend/server/src/plugins/copilot/providers/provider.ts +++ b/packages/backend/server/src/plugins/copilot/providers/provider.ts @@ -19,6 +19,7 @@ import { buildDocContentGetter, buildDocKeywordSearchGetter, buildDocSearchGetter, + createDocComposeTool, createDocEditTool, createDocKeywordSearchTool, createDocReadTool, @@ -198,6 +199,10 @@ export abstract class CopilotProvider { tools.web_crawl_exa = createExaCrawlTool(this.AFFiNEConfig); break; } + case 'docCompose': { + tools.doc_compose = createDocComposeTool(); + 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 093596726..673436909 100644 --- a/packages/backend/server/src/plugins/copilot/providers/types.ts +++ b/packages/backend/server/src/plugins/copilot/providers/types.ts @@ -69,6 +69,8 @@ export const PromptConfigStrictSchema = z.object({ 'docSemanticSearch', // work with exa/model internal tools 'webSearch', + // artifact tools + 'docCompose', ]) .array() .nullable() diff --git a/packages/backend/server/src/plugins/copilot/providers/utils.ts b/packages/backend/server/src/plugins/copilot/providers/utils.ts index 602271dbd..949e26fbf 100644 --- a/packages/backend/server/src/plugins/copilot/providers/utils.ts +++ b/packages/backend/server/src/plugins/copilot/providers/utils.ts @@ -11,6 +11,7 @@ import { import { ZodType } from 'zod'; import { + createDocComposeTool, createDocEditTool, createDocKeywordSearchTool, createDocReadTool, @@ -388,6 +389,7 @@ export interface CustomAITools extends ToolSet { doc_semantic_search: ReturnType; doc_keyword_search: ReturnType; doc_read: ReturnType; + doc_compose: ReturnType; web_search_exa: ReturnType; web_crawl_exa: ReturnType; } @@ -457,6 +459,10 @@ export class TextStreamParser { result += `\nReading the doc "${chunk.args.doc_id}"\n`; break; } + case 'doc_compose': { + result += `\nWriting document "${chunk.args.title}"\n`; + break; + } } result = this.markAsCallout(result); break; @@ -486,6 +492,16 @@ export class TextStreamParser { } break; } + case 'doc_compose': { + if ( + chunk.result && + typeof chunk.result === 'object' && + 'title' in chunk.result + ) { + result += `\nDocument "${chunk.result.title}" created successfully with ${chunk.result.wordCount} words.\n`; + } + break; + } case 'web_search_exa': { if (Array.isArray(chunk.result)) { result += `\n${this.getWebSearchLinks(chunk.result)}\n`; diff --git a/packages/backend/server/src/plugins/copilot/tools/doc-compose.ts b/packages/backend/server/src/plugins/copilot/tools/doc-compose.ts new file mode 100644 index 000000000..0df7a4c87 --- /dev/null +++ b/packages/backend/server/src/plugins/copilot/tools/doc-compose.ts @@ -0,0 +1,70 @@ +import { Logger } from '@nestjs/common'; +import { tool } from 'ai'; +import { z } from 'zod'; + +import { toolError } from './error'; + +const logger = new Logger('DocComposeTool'); + +export const createDocComposeTool = () => { + return tool({ + description: + 'Write a new document with markdown content. This tool creates structured markdown content for documents including titles, sections, and formatting.', + parameters: z.object({ + title: z.string().describe('The title of the document'), + content: z + .string() + .describe( + 'The main content to write in markdown format. Include proper markdown formatting like headers (# ## ###), lists (- * 1.), links [text](url), code blocks ```code```, and other markdown elements as needed.' + ), + sections: z + .array( + z.object({ + heading: z.string().describe('Section heading'), + content: z.string().describe('Section content in markdown'), + }) + ) + .optional() + .describe('Optional structured sections for the document'), + metadata: z + .object({ + tags: z + .array(z.string()) + .optional() + .describe('Optional tags for the document'), + description: z + .string() + .optional() + .describe('Optional brief description of the document'), + }) + .optional() + .describe('Optional metadata for the document'), + }), + execute: async ({ title, content, sections, metadata }) => { + try { + let markdownContent = ''; + + markdownContent += `${content}\n\n`; + + if (sections && sections.length > 0) { + for (const section of sections) { + markdownContent += `## ${section.heading}\n\n`; + markdownContent += `${section.content}\n\n`; + } + } + + return { + title, + markdown: markdownContent.trim(), + wordCount: content.split(/\s+/).length, + metadata: metadata || {}, + tags: metadata?.tags || [], + description: metadata?.description || '', + }; + } catch (err: any) { + logger.error(`Failed to write document: ${title}`, err); + return toolError('Doc Write Failed', err.message); + } + }, + }); +}; diff --git a/packages/backend/server/src/plugins/copilot/tools/index.ts b/packages/backend/server/src/plugins/copilot/tools/index.ts index d4f8f339a..9e1472e93 100644 --- a/packages/backend/server/src/plugins/copilot/tools/index.ts +++ b/packages/backend/server/src/plugins/copilot/tools/index.ts @@ -1,3 +1,4 @@ +export * from './doc-compose'; export * from './doc-edit'; export * from './doc-keyword-search'; export * from './doc-read'; diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts index bca275918..e543ed430 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts @@ -12,7 +12,13 @@ import { ShadowlessElement } from '@blocksuite/affine/std'; import type { ExtensionType, Store } from '@blocksuite/affine/store'; import { CenterPeekIcon } from '@blocksuite/icons/lit'; import { type Signal, signal } from '@preact/signals-core'; -import { css, html, nothing, type PropertyValues } from 'lit'; +import { + css, + html, + nothing, + type PropertyValues, + type TemplateResult, +} from 'lit'; import { property, state } from 'lit/decorators.js'; import { keyed } from 'lit/directives/keyed.js'; import { styleMap } from 'lit/directives/style-map.js'; @@ -106,6 +112,9 @@ export class ChatPanel extends SignalWatcher( @state() accessor showPreviewPanel = false; + @state() + accessor previewPanelContent: TemplateResult<1> | null = null; + private isSidebarOpen: Signal = signal(false); private sidebarWidth: Signal = signal(undefined); @@ -329,7 +338,7 @@ export class ChatPanel extends SignalWatcher( )} `; - const right = html`
Preview Panel
`; + const right = this.previewPanelContent; return html` `; + case 'doc_compose': + return html` + + `; default: { const name = streamObject.toolName + ' tool calling'; return html` @@ -94,6 +103,15 @@ export class ChatContentStreamObjects extends WithDisposable( .imageProxyService=${imageProxyService} > `; + case 'doc_compose': + return html` + + `; default: { const name = streamObject.toolName + ' tool result'; return html` diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/artifacts.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/artifacts.ts new file mode 100644 index 000000000..5e469b275 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/artifacts.ts @@ -0,0 +1,138 @@ +import { WithDisposable } from '@blocksuite/affine/global/lit'; +import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; +import { ShadowlessElement } from '@blocksuite/affine/std'; +import { EmptyIcon } from '@blocksuite/icons/lit'; +import { css, html, nothing, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; + +function getChatPanel(target: HTMLElement) { + return target.closest('chat-panel'); +} + +export const isPreviewPanelOpen = (target: HTMLElement) => { + const chatPanel = getChatPanel(target); + return chatPanel?.showPreviewPanel ?? false; +}; + +export const renderPreviewPanel = ( + target: HTMLElement, + content: TemplateResult<1>, + controls?: TemplateResult<1> +) => { + const chatPanel = getChatPanel(target); + + if (!chatPanel) { + console.error('chat-panel not found'); + return; + } + + chatPanel.showPreviewPanel = true; + + const preview = html``; + chatPanel.previewPanelContent = preview; +}; + +export const closePreviewPanel = (target: HTMLElement) => { + const chatPanel = getChatPanel(target); + + if (!chatPanel) { + console.error('chat-panel not found'); + return; + } + + chatPanel.showPreviewPanel = false; +}; + +export class ArtifactPreviewPanel extends WithDisposable(ShadowlessElement) { + static override styles = css` + .artifact-panel-preview-root { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + padding: 16px; + } + + .artifact-preview-panel { + border-radius: 16px; + background-color: ${unsafeCSSVarV2('layer/background/overlayPanel')}; + box-shadow: ${unsafeCSSVar('overlayPanelShadow')}; + max-height: 100%; + overflow-y: auto; + } + + .artifact-panel-header { + display: flex; + align-items: center; + justify-content: flex-end; + padding: 0 12px; + height: 52px; + position: sticky; + z-index: 1; + top: 0; + background: ${unsafeCSSVarV2('layer/background/overlayPanel')}; + } + + .artifact-panel-title { + font-size: 16px; + font-weight: 600; + color: ${unsafeCSSVarV2('text/primary')}; + } + + .artifact-panel-actions { + display: flex; + align-items: center; + gap: 4px; + } + + .artifact-panel-close { + margin-left: 4px; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + cursor: pointer; + transition: background-color 0.2s ease-in-out; + color: ${unsafeCSSVarV2('icon/secondary')}; + } + + .artifact-panel-close:hover { + background-color: ${unsafeCSSVarV2('layer/background/tertiary')}; + } + `; + + @property({ attribute: false }) + accessor content: TemplateResult<1> | null = null; + + @property({ attribute: false }) + accessor controls: TemplateResult<1> | null = null; + + private readonly _handleClose = () => { + closePreviewPanel(this); + }; + + protected override render() { + return html`
+
+
+
+ ${this.controls ?? nothing} + + ${EmptyIcon({ width: '24', height: '24' })} + +
+
+
${this.content}
+
+
`; + } +} diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-compose.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-compose.ts new file mode 100644 index 000000000..52f9daed7 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-compose.ts @@ -0,0 +1,253 @@ +import { getStoreManager } from '@affine/core/blocksuite/manager/store'; +import { getAFFiNEWorkspaceSchema } from '@affine/core/modules/workspace'; +import { getEmbedLinkedDocIcons } from '@blocksuite/affine/blocks/embed-doc'; +import { DocIcon } from '@blocksuite/affine/components/icons'; +import { toast } from '@blocksuite/affine/components/toast'; +import { WithDisposable } from '@blocksuite/affine/global/lit'; +import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference'; +import type { ImageProxyService } from '@blocksuite/affine/shared/adapters'; +import { + NotificationProvider, + ThemeProvider, +} from '@blocksuite/affine/shared/services'; +import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; +import { type BlockStdScope, ShadowlessElement } from '@blocksuite/affine/std'; +import { MarkdownTransformer } from '@blocksuite/affine/widgets/linked-doc'; +import { CopyIcon, PageIcon, ToolIcon } from '@blocksuite/icons/lit'; +import type { Signal } from '@preact/signals-core'; +import { css, html, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { getCustomPageEditorBlockSpecs } from '../text-renderer'; +import { + closePreviewPanel, + isPreviewPanelOpen, + renderPreviewPanel, +} from './artifacts'; +import type { ToolError } from './type'; + +interface DocComposeToolCall { + type: 'tool-call'; + toolCallId: string; + toolName: string; // 'doc_compose' + args: { title: string }; +} + +interface DocComposeToolResult { + type: 'tool-result'; + toolCallId: string; + toolName: string; // 'doc_compose' + args: { title: string }; + result: + | { + title: string; + markdown: string; + wordCount: number; + metadata: Record; + } + | ToolError + | null; +} + +/** + * Component to render doc compose tool call/result inside chat. + */ +export class DocComposeTool extends WithDisposable(ShadowlessElement) { + static override styles = css` + .doc-compose-result { + cursor: pointer; + margin: 8px 0; + } + + .doc-compose-result:hover { + background-color: var(--affine-hover-color); + } + + .doc-compose-result-preview { + padding: 24px; + } + + .doc-compose-result-preview-title { + font-size: 36px; + font-weight: 600; + padding: 14px 0px 38px 0px; + } + + .doc-compose-result-save-as-doc { + background: transparent; + border-radius: 8px; + border: 1px solid ${unsafeCSSVarV2('button/innerBlackBorder')}; + cursor: pointer; + font-size: 15px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0 8px; + height: 32px; + font-weight: 500; + } + + .doc-compose-result-save-as-doc:hover { + background: ${unsafeCSSVarV2('switch/buttonBackground/hover')}; + } + `; + + @property({ attribute: false }) + accessor data!: DocComposeToolCall | DocComposeToolResult; + + @property({ attribute: false }) + accessor width: Signal | undefined; + + @property({ attribute: false }) + accessor imageProxyService: ImageProxyService | null | undefined; + + @property({ attribute: false }) + accessor std: BlockStdScope | undefined; + + private renderToolCall() { + const { args } = this.data as DocComposeToolCall; + const name = `Composing document "${args.title}"`; + return html``; + } + + private renderToolResult() { + if (!this.std) return nothing; + if (this.data.type !== 'tool-result') return nothing; + const std = this.std; + const resultData = this.data as DocComposeToolResult; + const result = resultData.result; + + if (result && typeof result === 'object' && 'title' in result) { + const { title } = result as { title: string }; + + const theme = this.std.get(ThemeProvider).theme; + + const { LinkedDocEmptyBanner } = getEmbedLinkedDocIcons( + theme, + 'page', + 'horizontal' + ); + + const onClick = () => { + if (isPreviewPanelOpen(this)) { + closePreviewPanel(this); + return; + } + + const copyMarkdown = async () => { + await navigator.clipboard + .writeText(result.markdown) + .catch(console.error); + toast(std.host, 'Copied markdown to clipboard'); + }; + + const saveAsDoc = async () => { + try { + const workspace = std.store.workspace; + const notificationService = std.get(NotificationProvider); + const refNodeSlots = std.getOptional(RefNodeSlotsProvider); + const docId = await MarkdownTransformer.importMarkdownToDoc({ + collection: workspace, + schema: getAFFiNEWorkspaceSchema(), + markdown: result.markdown, + fileName: title, + extensions: getStoreManager().config.init().value.get('store'), + }); + if (docId) { + const open = await notificationService.confirm({ + title: 'Open the doc you just created', + message: + 'Doc saved successfully! Would you like to open it now?', + cancelText: 'Cancel', + confirmText: 'Open', + }); + if (open) { + refNodeSlots?.docLinkClicked.next({ + pageId: docId, + openMode: 'open-in-active-view', + host: std.host, + }); + } + } else { + toast(std.host, 'Failed to create document'); + } + } catch (e) { + console.error(e); + toast(std.host, 'Failed to create document'); + } + }; + + const controls = html` + + + ${CopyIcon({ width: '20', height: '20' })} + + `; + + renderPreviewPanel( + this, + html`
+
${title}
+ +
`, + controls + ); + }; + + return html` +
+
+
+
+ ${DocIcon} +
+
+ ${title} +
+
+
+
+ ${LinkedDocEmptyBanner} +
+
+ `; + } + + // failed + return html``; + } + + protected override render() { + if (this.data.type === 'tool-call') { + return this.renderToolCall(); + } + if (this.data.type === 'tool-result') { + return this.renderToolResult(); + } + return nothing; + } +} diff --git a/packages/frontend/core/src/blocksuite/ai/effects.ts b/packages/frontend/core/src/blocksuite/ai/effects.ts index f845126b0..84f42fdf6 100644 --- a/packages/frontend/core/src/blocksuite/ai/effects.ts +++ b/packages/frontend/core/src/blocksuite/ai/effects.ts @@ -47,6 +47,8 @@ import { ChatContentPureText } from './components/ai-message-content/pure-text'; import { ChatContentRichText } from './components/ai-message-content/rich-text'; import { ChatContentStreamObjects } from './components/ai-message-content/stream-objects'; import { AIScrollableTextRenderer } from './components/ai-scrollable-text-renderer'; +import { ArtifactPreviewPanel } from './components/ai-tools/artifacts'; +import { DocComposeTool } from './components/ai-tools/doc-compose'; 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'; @@ -176,6 +178,8 @@ export function registerAIEffects() { customElements.define('tool-call-failed', ToolFailedCard); customElements.define('web-crawl-tool', WebCrawlTool); customElements.define('web-search-tool', WebSearchTool); + customElements.define('doc-compose-tool', DocComposeTool); + customElements.define('artifact-preview-panel', ArtifactPreviewPanel); customElements.define(AFFINE_AI_PANEL_WIDGET, AffineAIPanelWidget); customElements.define(AFFINE_EDGELESS_COPILOT_WIDGET, EdgelessCopilotWidget); diff --git a/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx b/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx index fcce6d126..10de067cd 100644 --- a/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx +++ b/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx @@ -109,7 +109,7 @@ const WorkbenchView = ({ view }: { view: View }) => { }; const MIN_SIDEBAR_WIDTH = 320; -const MAX_SIDEBAR_WIDTH = 800; +const MAX_SIDEBAR_WIDTH = 1400; const WorkbenchSidebar = () => { const { clientBorder } = useAtomValue(appSettingAtom);