feat(core): optimize artifact preview loading (#13224)

fix AI-369

#### PR Dependency Tree


* **PR #13224** 👈

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 loading skeleton component for artifact previews,
providing a smoother visual experience during loading states.
* Artifact loading skeleton is now globally available as a custom
element.

* **Refactor**
* Streamlined icon and loading state handling in AI tools, centralizing
logic and removing redundant loading indicators.
* Simplified card metadata by removing loading and icon properties from
card meta methods.

* **Chores**
* Improved resource management for code block highlighting, ensuring
efficient disposal and avoiding unnecessary operations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Peng Xiao
2025-07-16 10:08:32 +08:00
committed by GitHub
parent a444941b79
commit 04e002eb77
6 changed files with 277 additions and 48 deletions

View File

@@ -39,6 +39,13 @@ export class CodeBlockHighlighter extends LifeCycleWatcher {
private readonly _loadTheme = async ( private readonly _loadTheme = async (
highlighter: HighlighterCore highlighter: HighlighterCore
): Promise<void> => { ): Promise<void> => {
// It is possible that by the time the highlighter is ready all instances
// have already been unmounted. In that case there is no need to load
// themes or update state.
if (CodeBlockHighlighter._refCount === 0) {
return;
}
const config = this.std.getOptional(CodeBlockConfigExtension.identifier); const config = this.std.getOptional(CodeBlockConfigExtension.identifier);
const darkTheme = config?.theme?.dark ?? CODE_BLOCK_DEFAULT_DARK_THEME; const darkTheme = config?.theme?.dark ?? CODE_BLOCK_DEFAULT_DARK_THEME;
const lightTheme = config?.theme?.light ?? CODE_BLOCK_DEFAULT_LIGHT_THEME; const lightTheme = config?.theme?.light ?? CODE_BLOCK_DEFAULT_LIGHT_THEME;
@@ -78,14 +85,27 @@ export class CodeBlockHighlighter extends LifeCycleWatcher {
override unmounted(): void { override unmounted(): void {
CodeBlockHighlighter._refCount--; CodeBlockHighlighter._refCount--;
// Only dispose the shared highlighter when no instances are using it // Dispose the shared highlighter **after** any in-flight creation finishes.
if ( if (CodeBlockHighlighter._refCount !== 0) {
CodeBlockHighlighter._refCount === 0 && return;
CodeBlockHighlighter._sharedHighlighter }
) {
CodeBlockHighlighter._sharedHighlighter.dispose(); const doDispose = (highlighter: HighlighterCore | null) => {
if (highlighter) {
highlighter.dispose();
}
CodeBlockHighlighter._sharedHighlighter = null; CodeBlockHighlighter._sharedHighlighter = null;
CodeBlockHighlighter._highlighterPromise = null; CodeBlockHighlighter._highlighterPromise = null;
};
if (CodeBlockHighlighter._sharedHighlighter) {
// Highlighter already created dispose immediately.
doDispose(CodeBlockHighlighter._sharedHighlighter);
} else if (CodeBlockHighlighter._highlighterPromise) {
// Highlighter still being created wait for it, then dispose.
CodeBlockHighlighter._highlighterPromise
.then(doDispose)
.catch(console.error);
} }
} }
} }

View File

@@ -0,0 +1,196 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
/**
* ArtifactSkeleton
*
* A lightweight loading skeleton used while an artifact preview is fetching / processing.
* It mimics the layout of a document an optional icon followed by several animated grey lines.
*
* Animation is implemented with pure CSS keyframes (no framer-motion dependency).
* Only a single prop is supported for now:
* - `icon` TemplateResult that will be rendered at the top-left position.
*/
export class ArtifactSkeleton extends LitElement {
/* ----- Styling --------------------------------------------------------------------------- */
static override styles = css`
:host {
/* The host is an inline-block so it can size to its contents. */
display: inline-block;
position: relative;
/* The size roughly follows the design used in the legacy React implementation. */
width: 250px;
height: 200px;
box-sizing: border-box;
}
/* Optional icon wrapper */
.icon {
position: absolute;
top: 10px;
left: 11px;
width: 32px;
height: 32px;
svg {
color: ${unsafeCSSVarV2('icon/activated')};
width: 100%;
height: 100%;
}
}
/* Base line style */
.line {
position: absolute;
left: 11px;
height: 10px;
border-radius: 6px;
background-color: ${unsafeCSSVarV2('layer/background/tertiary')};
}
/* Keyframes for each line width cycles through a handful of values to create movement */
@keyframes line1Anim {
0%,
100% {
width: 98px;
}
25% {
width: 120px;
}
50% {
width: 85px;
}
75% {
width: 110px;
}
}
@keyframes line2Anim {
0%,
100% {
width: 195px;
}
30% {
width: 180px;
}
60% {
width: 210px;
}
80% {
width: 165px;
}
}
@keyframes line3Anim {
0%,
100% {
width: 163px;
}
40% {
width: 140px;
}
70% {
width: 180px;
}
90% {
width: 155px;
}
}
@keyframes line4Anim {
0%,
100% {
width: 107px;
}
20% {
width: 130px;
}
60% {
width: 90px;
}
85% {
width: 115px;
}
}
@keyframes line5Anim {
0%,
100% {
width: 134px;
}
35% {
width: 160px;
}
65% {
width: 120px;
}
80% {
width: 145px;
}
}
@keyframes line6Anim {
0%,
100% {
width: 154px;
}
30% {
width: 135px;
}
55% {
width: 175px;
}
75% {
width: 160px;
}
}
.line1 {
top: 48.5px;
animation: line1Anim 3.2s ease-in-out infinite;
}
.line2 {
top: 73.5px;
animation: line2Anim 4.1s ease-in-out infinite;
}
.line3 {
top: 98.5px;
animation: line3Anim 2.8s ease-in-out infinite;
}
.line4 {
top: 123.5px;
animation: line4Anim 3.7s ease-in-out infinite;
}
.line5 {
top: 148.5px;
animation: line5Anim 3.5s ease-in-out infinite;
}
.line6 {
top: 170.5px;
animation: line6Anim 4.3s ease-in-out infinite;
}
`;
/* ----- Public API ------------------------------------------------------------------------ */
/**
* Optional icon rendered at the top-left corner.
* It should be a lit `TemplateResult`, typically an inline SVG.
*/
@property({ attribute: false })
accessor icon: TemplateResult | null = null;
/* ----- Render --------------------------------------------------------------------------- */
override render() {
return html`
${this.icon ? html`<div class="icon">${this.icon}</div>` : nothing}
<div class="line line1"></div>
<div class="line line2"></div>
<div class="line line3"></div>
<div class="line line4"></div>
<div class="line line5"></div>
<div class="line line6"></div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'artifact-skeleton': ArtifactSkeleton;
}
}

View File

@@ -2,7 +2,6 @@ import { LoadingIcon } from '@blocksuite/affine/components/icons';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import type { ColorScheme } from '@blocksuite/affine/model'; import type { ColorScheme } from '@blocksuite/affine/model';
import { ShadowlessElement } from '@blocksuite/affine/std'; import { ShadowlessElement } from '@blocksuite/affine/std';
import { type NotificationService } from '@blocksuite/affine-shared/services';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import type { Signal } from '@preact/signals-core'; import type { Signal } from '@preact/signals-core';
import { import {
@@ -42,18 +41,23 @@ export abstract class ArtifactTool<
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')}; background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
} }
} }
.artifact-skeleton-container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
artifact-skeleton {
margin-top: -24px;
}
}
`; `;
/** Tool data coming from ChatGPT (tool-call / tool-result). */ /** Tool data coming from ChatGPT (tool-call / tool-result). */
@property({ attribute: false }) @property({ attribute: false })
accessor data!: TData; accessor data!: TData;
@property({ attribute: false })
accessor width: Signal<number | undefined> | undefined;
@property({ attribute: false })
accessor notificationService!: NotificationService;
@property({ attribute: false }) @property({ attribute: false })
accessor theme!: Signal<ColorScheme>; accessor theme!: Signal<ColorScheme>;
@@ -64,14 +68,15 @@ export abstract class ArtifactTool<
*/ */
protected abstract getCardMeta(): { protected abstract getCardMeta(): {
title: string; title: string;
/** Page / file icon shown when not loading */
icon: TemplateResult | HTMLElement | string | null;
/** Whether the spinner should be displayed */
loading: boolean;
/** Extra css class appended to card root */ /** Extra css class appended to card root */
className?: string; className?: string;
}; };
/**
* Icon shown in the card (when not loading) and in the loading skeleton.
*/
protected abstract getIcon(): TemplateResult | HTMLElement | string | null;
/** Banner shown on the right side of the card (can be undefined). */ /** Banner shown on the right side of the card (can be undefined). */
protected abstract getBanner( protected abstract getBanner(
theme: ColorScheme theme: ColorScheme
@@ -90,11 +95,14 @@ export abstract class ArtifactTool<
/** Open or refresh the preview panel. */ /** Open or refresh the preview panel. */
private openOrUpdatePreviewPanel() { private openOrUpdatePreviewPanel() {
renderPreviewPanel( const content = this.isLoading()
this, ? this.renderLoadingSkeleton()
this.getPreviewContent(), : this.getPreviewContent();
this.getPreviewControls() renderPreviewPanel(this, content, this.getPreviewControls());
); }
protected isLoading(): boolean {
return this.data.type !== 'tool-result';
} }
protected refreshPreviewPanel() { protected refreshPreviewPanel() {
@@ -108,18 +116,23 @@ export abstract class ArtifactTool<
return null; return null;
} }
protected renderLoadingSkeleton() {
const icon = this.getIcon();
return html`<div class="artifact-skeleton-container">
<artifact-skeleton .icon=${icon}></artifact-skeleton>
</div>`;
}
private readonly onCardClick = (_e: Event) => { private readonly onCardClick = (_e: Event) => {
this.openOrUpdatePreviewPanel(); this.openOrUpdatePreviewPanel();
}; };
protected renderCard() { protected renderCard() {
const { title, icon, loading, className } = this.getCardMeta(); const { title, className } = this.getCardMeta();
const resolvedIcon = loading const resolvedIcon = this.isLoading()
? LoadingIcon({ ? LoadingIcon({ size: '20px' })
size: '20px', : this.getIcon();
})
: icon;
const banner = this.getBanner(this.theme.value); const banner = this.getBanner(this.theme.value);

View File

@@ -3,6 +3,7 @@ import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { ColorScheme } from '@blocksuite/affine/model'; import { ColorScheme } from '@blocksuite/affine/model';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { type BlockStdScope } from '@blocksuite/affine/std'; import { type BlockStdScope } from '@blocksuite/affine/std';
import type { NotificationService } from '@blocksuite/affine-shared/services';
import { import {
CodeBlockIcon, CodeBlockIcon,
CopyIcon, CopyIcon,
@@ -437,6 +438,9 @@ export class CodeArtifactTool extends ArtifactTool<
@property({ attribute: false }) @property({ attribute: false })
accessor std: BlockStdScope | undefined; accessor std: BlockStdScope | undefined;
@property({ attribute: false })
accessor notificationService!: NotificationService;
@state() @state()
private accessor mode: 'preview' | 'code' = 'code'; private accessor mode: 'preview' | 'code' = 'code';
@@ -447,25 +451,19 @@ export class CodeArtifactTool extends ArtifactTool<
} }
protected getCardMeta() { protected getCardMeta() {
const loading = this.data.type === 'tool-call';
return { return {
title: this.data.args.title, title: this.data.args.title,
icon: CodeBlockIcon({ width: '20', height: '20' }),
loading,
className: 'code-artifact-result', className: 'code-artifact-result',
}; };
} }
protected override getIcon() {
return CodeBlockIcon();
}
protected override getPreviewContent() { protected override getPreviewContent() {
if (this.data.type !== 'tool-result' || !this.data.result) { if (this.data.type !== 'tool-result' || !this.data.result) {
// loading state return html``;
return html`<div class="code-artifact-preview">
<div
style="display:flex;justify-content:center;align-items:center;height:100%"
>
${CodeBlockIcon({ width: '24', height: '24' })}
</div>
</div>`;
} }
const result = this.data.result; const result = this.data.result;

View File

@@ -1,11 +1,11 @@
import { getStoreManager } from '@affine/core/blocksuite/manager/store'; import { getStoreManager } from '@affine/core/blocksuite/manager/store';
import { getAFFiNEWorkspaceSchema } from '@affine/core/modules/workspace'; import { getAFFiNEWorkspaceSchema } from '@affine/core/modules/workspace';
import { getEmbedLinkedDocIcons } from '@blocksuite/affine/blocks/embed-doc'; import { getEmbedLinkedDocIcons } from '@blocksuite/affine/blocks/embed-doc';
import { LoadingIcon } from '@blocksuite/affine/components/icons';
import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference'; import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference';
import type { ColorScheme } from '@blocksuite/affine/model'; import type { ColorScheme } from '@blocksuite/affine/model';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { MarkdownTransformer } from '@blocksuite/affine/widgets/linked-doc'; import { MarkdownTransformer } from '@blocksuite/affine/widgets/linked-doc';
import type { NotificationService } from '@blocksuite/affine-shared/services';
import { CopyIcon, PageIcon, ToolIcon } from '@blocksuite/icons/lit'; import { CopyIcon, PageIcon, ToolIcon } from '@blocksuite/icons/lit';
import type { BlockStdScope } from '@blocksuite/std'; import type { BlockStdScope } from '@blocksuite/std';
import { css, html } from 'lit'; import { css, html } from 'lit';
@@ -88,6 +88,9 @@ export class DocComposeTool extends ArtifactTool<
@property({ attribute: false }) @property({ attribute: false })
accessor std: BlockStdScope | undefined; accessor std: BlockStdScope | undefined;
@property({ attribute: false })
accessor notificationService!: NotificationService;
protected getBanner(theme: ColorScheme) { protected getBanner(theme: ColorScheme) {
const { LinkedDocEmptyBanner } = getEmbedLinkedDocIcons( const { LinkedDocEmptyBanner } = getEmbedLinkedDocIcons(
theme, theme,
@@ -98,15 +101,16 @@ export class DocComposeTool extends ArtifactTool<
} }
protected getCardMeta() { protected getCardMeta() {
const composing = this.data.type === 'tool-call';
return { return {
title: this.data.args.title, title: this.data.args.title,
icon: PageIcon(),
loading: composing,
className: 'doc-compose-result', className: 'doc-compose-result',
}; };
} }
protected override getIcon() {
return PageIcon();
}
protected override getPreviewContent() { protected override getPreviewContent() {
if (!this.std) return html``; if (!this.std) return html``;
const resultData = this.data; const resultData = this.data;
@@ -126,11 +130,7 @@ export class DocComposeTool extends ArtifactTool<
theme: this.theme, theme: this.theme,
}} }}
></text-renderer>` ></text-renderer>`
: html`<div class="doc-compose-result-preview-loading"> : html``}
${LoadingIcon({
size: '32px',
})}
</div>`}
</div>`; </div>`;
} }

View File

@@ -27,6 +27,7 @@ import { ChatMessageAction } from './chat-panel/message/action';
import { ChatMessageAssistant } from './chat-panel/message/assistant'; import { ChatMessageAssistant } from './chat-panel/message/assistant';
import { ChatMessageUser } from './chat-panel/message/user'; import { ChatMessageUser } from './chat-panel/message/user';
import { ChatPanelSplitView } from './chat-panel/split-view'; import { ChatPanelSplitView } from './chat-panel/split-view';
import { ArtifactSkeleton } from './components/ai-artifact-skeleton';
import { AIChatAddContext } from './components/ai-chat-add-context'; import { AIChatAddContext } from './components/ai-chat-add-context';
import { ChatPanelAddPopover } from './components/ai-chat-chips/add-popover'; import { ChatPanelAddPopover } from './components/ai-chat-chips/add-popover';
import { ChatPanelCandidatesPopover } from './components/ai-chat-chips/candidates-popover'; import { ChatPanelCandidatesPopover } from './components/ai-chat-chips/candidates-popover';
@@ -243,4 +244,5 @@ export function registerAIEffects() {
customElements.define('transcription-block', LitTranscriptionBlock); customElements.define('transcription-block', LitTranscriptionBlock);
customElements.define('chat-panel-split-view', ChatPanelSplitView); customElements.define('chat-panel-split-view', ChatPanelSplitView);
customElements.define('artifact-skeleton', ArtifactSkeleton);
} }