mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-16 05:47:09 +08:00
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:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user