feat(core): code artifact tool (#13015)

<img width="1272" alt="image"
src="https://github.com/user-attachments/assets/429ec60a-48a9-490b-b45f-3ce7150ef32a"
/>


#### PR Dependency Tree


* **PR #13015** 👈

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 new AI tool for generating self-contained HTML artifacts,
including a dedicated interface for previewing, copying, and downloading
generated HTML.
* Added syntax highlighting and preview capabilities for HTML artifacts
in chat and tool panels.
* Integrated the new HTML artifact tool into the AI chat prompt and
Copilot toolset.

* **Enhancements**
* Improved artifact preview panel layout and sizing for a better user
experience.
* Enhanced HTML preview components to support both model-based and raw
HTML rendering.

* **Dependency Updates**
  * Added the "shiki" library for advanced syntax highlighting.

* **Bug Fixes**
  * None.

* **Chores**
* Updated internal imports and code structure to support new features
and maintain consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Peng Xiao
2025-07-04 07:39:51 +08:00
committed by GitHub
parent 53968f6f8c
commit 8ed7dea823
16 changed files with 671 additions and 56 deletions

View File

@@ -72,6 +72,15 @@ export class ChatContentStreamObjects extends WithDisposable(
.imageProxyService=${imageProxyService}
></doc-compose-tool>
`;
case 'code_artifact':
return html`
<code-artifact-tool
.std=${this.host?.std}
.data=${streamObject}
.width=${this.width}
.imageProxyService=${imageProxyService}
></code-artifact-tool>
`;
default: {
const name = streamObject.toolName + ' tool calling';
return html`
@@ -112,6 +121,15 @@ export class ChatContentStreamObjects extends WithDisposable(
.imageProxyService=${imageProxyService}
></doc-compose-tool>
`;
case 'code_artifact':
return html`
<code-artifact-tool
.std=${this.host?.std}
.data=${streamObject}
.width=${this.width}
.imageProxyService=${imageProxyService}
></code-artifact-tool>
`;
default: {
const name = streamObject.toolName + ' tool result';
return html`

View File

@@ -61,7 +61,7 @@ export class ArtifactPreviewPanel extends WithDisposable(ShadowlessElement) {
border-radius: 16px;
background-color: ${unsafeCSSVarV2('layer/background/overlayPanel')};
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
max-height: 100%;
height: 100%;
overflow-y: auto;
}
@@ -87,6 +87,7 @@ export class ArtifactPreviewPanel extends WithDisposable(ShadowlessElement) {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
}
.artifact-panel-close {
@@ -102,6 +103,10 @@ export class ArtifactPreviewPanel extends WithDisposable(ShadowlessElement) {
color: ${unsafeCSSVarV2('icon/secondary')};
}
.artifact-panel-content {
height: calc(100% - 52px);
}
.artifact-panel-close:hover {
background-color: ${unsafeCSSVarV2('layer/background/tertiary')};
}

View File

@@ -0,0 +1,466 @@
import { CodeBlockHighlighter } from '@blocksuite/affine/blocks/code';
import { toast } from '@blocksuite/affine/components/toast';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import type { ImageProxyService } from '@blocksuite/affine/shared/adapters';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { type BlockStdScope, ShadowlessElement } from '@blocksuite/affine/std';
import { CopyIcon, PageIcon, ToolIcon } from '@blocksuite/icons/lit';
import type { Signal } from '@preact/signals-core';
import { effect, signal } from '@preact/signals-core';
import { css, html, LitElement, nothing } from 'lit';
import { property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { bundledLanguagesInfo, type ThemedToken } from 'shiki';
import {
closePreviewPanel,
isPreviewPanelOpen,
renderPreviewPanel,
} from './artifacts-preview-panel';
import type { ToolError } from './type';
interface CodeArtifactToolCall {
type: 'tool-call';
toolCallId: string;
toolName: string; // 'code_artifact'
args: { title: string };
}
interface CodeArtifactToolResult {
type: 'tool-result';
toolCallId: string;
toolName: string; // 'code_artifact'
args: { title: string };
result:
| {
title: string;
html: string;
size: number;
}
| ToolError
| null;
}
export class CodeHighlighter extends SignalWatcher(WithDisposable(LitElement)) {
static override styles = css`
.code-highlighter {
}
/* Container */
.code-highlighter pre {
margin: 0;
display: flex;
overflow: auto;
font-family: ${unsafeCSSVar('fontMonoFamily')};
}
/* Line numbers */
.code-highlighter .line-numbers {
user-select: none;
text-align: right;
line-height: 20px;
color: ${unsafeCSSVarV2('text/secondary')};
white-space: nowrap;
min-width: 3rem;
padding: 0 0 12px 12px;
font-size: 12px;
}
.code-highlighter .line-number {
display: block;
white-space: nowrap;
}
/* Code area */
.code-highlighter .code-container {
flex: 1;
white-space: pre;
line-height: 20px;
font-size: 12px;
padding: 0 12px 12px 12px;
}
.code-highlighter .code-line {
display: flex;
min-height: 20px;
}
`;
@property({ attribute: false })
accessor std!: BlockStdScope;
@property({ attribute: false })
accessor code: string = '';
@property({ attribute: false })
accessor language: string = 'html';
@property({ attribute: false })
accessor showLineNumbers: boolean = false;
// signal holding tokens generated by shiki
highlightTokens: Signal<ThemedToken[][]> = signal([]);
get highlighter() {
return this.std.get(CodeBlockHighlighter);
}
override connectedCallback() {
super.connectedCallback();
// recompute highlight when code / language changes
this.disposables.add(
effect(() => {
return this._updateHighlightTokens();
})
);
}
private _updateHighlightTokens() {
let cancelled = false;
const language = this.language;
const highlighter = this.highlighter.highlighter$.value;
if (!highlighter) return;
const updateTokens = () => {
if (cancelled) return;
this.highlightTokens.value = highlighter.codeToTokensBase(this.code, {
lang: language,
theme: this.highlighter.themeKey,
});
};
const loadedLanguages = highlighter.getLoadedLanguages();
if (!loadedLanguages.includes(language)) {
const matchedInfo = bundledLanguagesInfo.find(
info =>
info.id === language ||
info.name === language ||
info.aliases?.includes(language)
);
if (matchedInfo) {
highlighter
.loadLanguage(matchedInfo.import)
.then(updateTokens)
.catch(console.error);
} else {
console.warn(`Language not supported: ${language}`);
}
} else {
updateTokens();
}
return () => {
cancelled = true;
};
}
private _tokenStyle(token: ThemedToken): string {
let result = '';
if (token.color) {
result += `color: ${token.color};`;
}
if (token.fontStyle) {
result += `font-style: ${token.fontStyle};`;
}
if (token.bgColor) {
result += `background-color: ${token.bgColor};`;
}
return result;
}
override render() {
const tokens = this.highlightTokens.value;
const lineCount =
tokens.length > 0 ? tokens.length : this.code.split('\n').length;
const lineNumbersTemplate = this.showLineNumbers
? html`<div class="line-numbers">
${Array.from(
{ length: lineCount },
(_, i) => html`<span class="line-number">${i + 1}</span>`
)}
</div>`
: nothing;
const renderedCode =
tokens.length === 0
? this.code
: html`${tokens.map(lineTokens => {
const line = lineTokens.map(token => {
const style = this._tokenStyle(token);
return html`<span style="${style}">${token.content}</span>`;
});
return html`<div class="code-line">${line}</div>`;
})}`;
return html`<div class="code-highlighter">
<pre>
${lineNumbersTemplate}
<div class="code-container">${renderedCode}</div>
</pre>
</div>`;
}
}
/**
* Component to render code artifact tool call/result inside chat.
*/
export class CodeArtifactTool extends WithDisposable(ShadowlessElement) {
static override styles = css`
.code-artifact-result {
cursor: pointer;
margin: 8px 0;
}
.code-artifact-result:hover {
background-color: var(--affine-hover-color);
}
.code-artifact-preview {
padding: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.code-artifact-preview > html-preview {
height: 100%;
}
.code-artifact-preview :is(.html-preview-iframe, .html-preview-container) {
height: 100%;
}
.code-artifact-control-btn {
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;
}
.code-artifact-control-btn:hover {
background: ${unsafeCSSVarV2('switch/buttonBackground/hover')};
}
/* Toggle styles (migrated from PreviewButton) */
.code-artifact-toggle-container {
display: flex;
padding: 2px;
align-items: flex-start;
gap: 4px;
border-radius: 4px;
background: ${unsafeCSSVarV2('segment/background')};
}
.code-artifact-toggle-container .toggle-button {
display: flex;
padding: 0px 4px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 4px;
color: ${unsafeCSSVarV2('text/primary')};
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 20px;
cursor: pointer;
}
.code-artifact-toggle-container .toggle-button:hover {
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
.code-artifact-toggle-container .toggle-button.active {
background: ${unsafeCSSVarV2('segment/button')};
box-shadow:
var(--Shadow-buttonShadow-1-x, 0px) var(--Shadow-buttonShadow-1-y, 0px)
var(--Shadow-buttonShadow-1-blur, 1px) 0px
var(--Shadow-buttonShadow-1-color, rgba(0, 0, 0, 0.12)),
var(--Shadow-buttonShadow-2-x, 0px) var(--Shadow-buttonShadow-2-y, 1px)
var(--Shadow-buttonShadow-2-blur, 5px) 0px
var(--Shadow-buttonShadow-2-color, rgba(0, 0, 0, 0.12));
}
`;
@property({ attribute: false })
accessor data!: CodeArtifactToolCall | CodeArtifactToolResult;
@property({ attribute: false })
accessor width: Signal<number | undefined> | undefined;
@property({ attribute: false })
accessor imageProxyService: ImageProxyService | null | undefined;
@property({ attribute: false })
accessor std: BlockStdScope | undefined;
@state()
private accessor mode: 'preview' | 'code' = 'code';
private renderToolCall() {
const { args } = this.data as CodeArtifactToolCall;
const name = `Generating HTML artifact "${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 resultData = this.data as CodeArtifactToolResult;
const result = resultData.result;
if (result && typeof result === 'object' && 'title' in result) {
const { title, html: htmlContent } = result as {
title: string;
html: string;
};
const onClick = () => {
if (isPreviewPanelOpen(this)) {
closePreviewPanel(this);
return;
}
const copyHTML = async () => {
if (this.std) {
await navigator.clipboard
.writeText(htmlContent)
.catch(console.error);
toast(this.std.host, 'Copied HTML to clipboard');
}
};
const downloadHTML = () => {
try {
const blob = new Blob([htmlContent], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || 'artifact'}.html`;
document.body.append(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
console.error(e);
}
};
const setCodeMode = () => {
if (this.mode !== 'code') {
this.mode = 'code';
renderPreview();
}
};
const setPreviewMode = () => {
if (this.mode !== 'preview') {
this.mode = 'preview';
renderPreview();
}
};
const renderPreview = () => {
const controls = html`
<div class="code-artifact-toggle-container">
<div
class=${classMap({
'toggle-button': true,
active: this.mode === 'code',
})}
@click=${setCodeMode}
>
Code
</div>
<div
class=${classMap({
'toggle-button': true,
active: this.mode === 'preview',
})}
@click=${setPreviewMode}
>
Preview
</div>
</div>
<div style="flex: 1"></div>
<button class="code-artifact-control-btn" @click=${downloadHTML}>
${PageIcon({
width: '20',
height: '20',
style: `color: ${unsafeCSSVarV2('icon/primary')}`,
})}
Download
</button>
<icon-button @click=${copyHTML} title="Copy HTML">
${CopyIcon({ width: '20', height: '20' })}
</icon-button>
`;
renderPreviewPanel(
this,
html`<div class="code-artifact-preview">
${this.mode === 'preview'
? html`<html-preview .html=${htmlContent}></html-preview>`
: html`<code-highlighter
.std=${this.std}
.code=${htmlContent}
.language=${'html'}
.showLineNumbers=${true}
></code-highlighter>`}
</div>`,
controls
);
};
renderPreview();
};
return html`
<div
class="affine-embed-linked-doc-block code-artifact-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">
${PageIcon({ width: '20', height: '20' })}
</div>
<div class="affine-embed-linked-doc-content-title-text">
${title}
</div>
</div>
</div>
</div>
`;
}
return html`<tool-call-failed
.name=${'Code artifact 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

@@ -14,7 +14,7 @@ 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 { type Signal } from '@preact/signals-core';
import { css, html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
@@ -23,7 +23,7 @@ import {
closePreviewPanel,
isPreviewPanelOpen,
renderPreviewPanel,
} from './artifacts';
} from './artifacts-preview-panel';
import type { ToolError } from './type';
interface DocComposeToolCall {

View File

@@ -47,7 +47,11 @@ 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 { ArtifactPreviewPanel } from './components/ai-tools/artifacts-preview-panel';
import {
CodeArtifactTool,
CodeHighlighter,
} from './components/ai-tools/code-artifact';
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';
@@ -179,6 +183,8 @@ export function registerAIEffects() {
customElements.define('web-crawl-tool', WebCrawlTool);
customElements.define('web-search-tool', WebSearchTool);
customElements.define('doc-compose-tool', DocComposeTool);
customElements.define('code-artifact-tool', CodeArtifactTool);
customElements.define('code-highlighter', CodeHighlighter);
customElements.define('artifact-preview-panel', ArtifactPreviewPanel);
customElements.define(AFFINE_AI_PANEL_WIDGET, AffineAIPanelWidget);

View File

@@ -2,7 +2,8 @@ import { CodeBlockPreviewExtension } from '@blocksuite/affine/blocks/code';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import type { CodeBlockModel } from '@blocksuite/affine/model';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { css, html, LitElement, type PropertyValues } from 'lit';
import { ShadowlessElement } from '@blocksuite/std';
import { css, html, type PropertyValues } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { styleMap } from 'lit/directives/style-map.js';
@@ -14,7 +15,9 @@ export const CodeBlockHtmlPreview = CodeBlockPreviewExtension(
model => html`<html-preview .model=${model}></html-preview>`
);
export class HTMLPreview extends SignalWatcher(WithDisposable(LitElement)) {
export class HTMLPreview extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.html-preview-loading {
color: ${unsafeCSSVarV2('text/placeholder')};
@@ -53,7 +56,10 @@ export class HTMLPreview extends SignalWatcher(WithDisposable(LitElement)) {
`;
@property({ attribute: false })
accessor model!: CodeBlockModel;
accessor model: CodeBlockModel | null = null;
@property({ attribute: false })
accessor html: string | null = null;
@state()
accessor state: 'loading' | 'error' | 'finish' | 'fallback' = 'loading';
@@ -66,20 +72,39 @@ export class HTMLPreview extends SignalWatcher(WithDisposable(LitElement)) {
this._link();
this.disposables.add(
this.model.props.text$.subscribe(() => {
this._link();
})
);
if (this.model) {
this.disposables.add(
this.model.props.text$.subscribe(() => {
this._link();
})
);
}
return result;
}
override updated(changedProperties: PropertyValues): void {
const result = super.updated(changedProperties);
if (changedProperties.has('html')) {
this._link();
}
return result;
}
get normalizedHtml() {
return this.model?.props.text.toString() ?? this.html;
}
private _link() {
this.state = 'loading';
if (!this.normalizedHtml) {
this.state = 'fallback';
return;
}
try {
linkIframe(this.iframe, this.model);
linkIframe(this.iframe, this.normalizedHtml);
this.state = 'finish';
} catch (error) {
console.error('HTML preview iframe failed:', error);

View File

@@ -1,7 +1,4 @@
import type { CodeBlockModel } from '@blocksuite/affine/model';
export function linkIframe(iframe: HTMLIFrameElement, model: CodeBlockModel) {
const html = model.props.text.toString();
export function linkIframe(iframe: HTMLIFrameElement, html: string) {
// force reload iframe
iframe.src = '';
iframe.src = 'https://affine.run/static/container.html';