feat(core): support compose a doc tool (#13013)

#### PR Dependency Tree


* **PR #13013** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

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

* **New Features**
* Introduced a document composition tool for AI chat, allowing users to
generate, preview, and save structured markdown documents directly from
chat interactions.
* Added an artifact preview panel for enhanced document previews within
the chat interface.
* Enabled dynamic content rendering in the chat panel's right section
for richer user experiences.

* **Improvements**
  * Sidebar maximum width increased for greater workspace flexibility.
* Enhanced chat message and split view styling for improved layout and
usability.

* **Bug Fixes**
  * None.

* **Other**
* Registered new custom elements for AI tools and artifact preview
functionality.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Peng Xiao
2025-07-03 22:21:49 +08:00
committed by GitHub
parent 558279da29
commit cfc108613c
15 changed files with 530 additions and 4 deletions

View File

@@ -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<boolean | undefined> = signal(false);
private sidebarWidth: Signal<number | undefined> = signal(undefined);
@@ -329,7 +338,7 @@ export class ChatPanel extends SignalWatcher(
)}
</div>`;
const right = html`<div>Preview Panel</div>`;
const right = this.previewPanelContent;
return html`<chat-panel-split-view
.left=${left}

View File

@@ -7,6 +7,12 @@ import { type ChatMessage } from '../../components/ai-chat-messages';
export class ChatMessageUser extends WithDisposable(ShadowlessElement) {
static override styles = css`
chat-message-user {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.chat-message-user {
display: flex;
flex-direction: column;

View File

@@ -23,6 +23,9 @@ export class ChatPanelSplitView extends SignalWatcher(
.ai-chat-panel-split-view[data-dragging='true'] {
cursor: col-resize;
}
.ai-chat-panel-split-view-right {
position: relative;
}
.ai-chat-panel-split-view-left,
.ai-chat-panel-split-view-right,
.ai-chat-panel-split-view-divider {
@@ -42,7 +45,6 @@ export class ChatPanelSplitView extends SignalWatcher(
.ai-chat-panel-split-view-divider {
width: var(--gap);
position: relative;
border-left: 0.5px solid var(--affine-v2-layer-insideBorder-border);
}
.ai-chat-panel-split-view-divider[data-open='false'] {
width: 0;

View File

@@ -63,6 +63,15 @@ export class ChatContentStreamObjects extends WithDisposable(
.imageProxyService=${imageProxyService}
></web-search-tool>
`;
case 'doc_compose':
return html`
<doc-compose-tool
.std=${this.host?.std}
.data=${streamObject}
.width=${this.width}
.imageProxyService=${imageProxyService}
></doc-compose-tool>
`;
default: {
const name = streamObject.toolName + ' tool calling';
return html`
@@ -94,6 +103,15 @@ export class ChatContentStreamObjects extends WithDisposable(
.imageProxyService=${imageProxyService}
></web-search-tool>
`;
case 'doc_compose':
return html`
<doc-compose-tool
.std=${this.host?.std}
.data=${streamObject}
.width=${this.width}
.imageProxyService=${imageProxyService}
></doc-compose-tool>
`;
default: {
const name = streamObject.toolName + ' tool result';
return html`

View File

@@ -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`<artifact-preview-panel
.content=${content}
.controls=${controls ?? nothing}
></artifact-preview-panel>`;
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`<div class="artifact-panel-preview-root">
<div class="artifact-preview-panel">
<div class="artifact-panel-header">
<div class="artifact-panel-actions">
${this.controls ?? nothing}
<icon-button
class="artifact-panel-close"
@click=${this._handleClose}
>
${EmptyIcon({ width: '24', height: '24' })}
</icon-button>
</div>
</div>
<div class="artifact-panel-content">${this.content}</div>
</div>
</div>`;
}
}

View File

@@ -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<string, unknown>;
}
| 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<number | undefined> | 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`<tool-call-card
.name=${name}
.icon=${ToolIcon()}
></tool-call-card>`;
}
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`
<button class="doc-compose-result-save-as-doc" @click=${saveAsDoc}>
${PageIcon({
width: '20',
height: '20',
style: `color: ${unsafeCSSVarV2('icon/primary')}`,
})}
Save as doc
</button>
<icon-button @click=${copyMarkdown} title="Copy markdown">
${CopyIcon({ width: '20', height: '20' })}
</icon-button>
`;
renderPreviewPanel(
this,
html`<div class="doc-compose-result-preview">
<div class="doc-compose-result-preview-title">${title}</div>
<text-renderer
.answer=${result.markdown}
.host=${std.host}
.schema=${std.store.schema}
.options=${{
customHeading: true,
extensions: getCustomPageEditorBlockSpecs(),
}}
></text-renderer>
</div>`,
controls
);
};
return html`
<div
class="affine-embed-linked-doc-block doc-compose-result horizontal"
@click=${onClick}
>
<div class="affine-embed-linked-doc-content">
<div class="affine-embed-linked-doc-content-title">
<div class="affine-embed-linked-doc-content-title-icon">
${DocIcon}
</div>
<div class="affine-embed-linked-doc-content-title-text">
${title}
</div>
</div>
</div>
<div class="affine-embed-linked-doc-banner">
${LinkedDocEmptyBanner}
</div>
</div>
`;
}
// failed
return html`<tool-call-failed
.name=${'Doc compose failed'}
.icon=${ToolIcon()}
></tool-call-failed>`;
}
protected override render() {
if (this.data.type === 'tool-call') {
return this.renderToolCall();
}
if (this.data.type === 'tool-result') {
return this.renderToolResult();
}
return nothing;
}
}

View File

@@ -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);