mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-05 03:25:10 +08:00
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:
@@ -2,7 +2,9 @@ export * from './adapters';
|
||||
export * from './clipboard';
|
||||
export * from './code-block';
|
||||
export * from './code-block-config';
|
||||
export * from './code-block-service';
|
||||
export * from './code-preview-extension';
|
||||
export * from './code-toolbar';
|
||||
export * from './highlight/const';
|
||||
export * from './turbo/code-layout-handler';
|
||||
export * from './turbo/code-painter.worker';
|
||||
|
||||
@@ -1753,6 +1753,7 @@ Below is the user's query. Please respond in the user's preferred language witho
|
||||
'docSemanticSearch',
|
||||
'webSearch',
|
||||
'docCompose',
|
||||
'codeArtifact',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
buildDocContentGetter,
|
||||
buildDocKeywordSearchGetter,
|
||||
buildDocSearchGetter,
|
||||
createCodeArtifactTool,
|
||||
createDocComposeTool,
|
||||
createDocEditTool,
|
||||
createDocKeywordSearchTool,
|
||||
@@ -203,6 +204,10 @@ export abstract class CopilotProvider<C = any> {
|
||||
tools.doc_compose = createDocComposeTool();
|
||||
break;
|
||||
}
|
||||
case 'codeArtifact': {
|
||||
tools.code_artifact = createCodeArtifactTool();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
|
||||
@@ -71,6 +71,7 @@ export const PromptConfigStrictSchema = z.object({
|
||||
'webSearch',
|
||||
// artifact tools
|
||||
'docCompose',
|
||||
'codeArtifact',
|
||||
])
|
||||
.array()
|
||||
.nullable()
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { ZodType } from 'zod';
|
||||
|
||||
import {
|
||||
createCodeArtifactTool,
|
||||
createDocComposeTool,
|
||||
createDocEditTool,
|
||||
createDocKeywordSearchTool,
|
||||
@@ -392,6 +393,7 @@ export interface CustomAITools extends ToolSet {
|
||||
doc_compose: ReturnType<typeof createDocComposeTool>;
|
||||
web_search_exa: ReturnType<typeof createExaSearchTool>;
|
||||
web_crawl_exa: ReturnType<typeof createExaCrawlTool>;
|
||||
code_artifact: ReturnType<typeof createCodeArtifactTool>;
|
||||
}
|
||||
|
||||
type ChunkType = TextStreamPart<CustomAITools>['type'];
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { toolError } from './error';
|
||||
|
||||
const logger = new Logger('CodeArtifactTool');
|
||||
|
||||
/**
|
||||
* A copilot tool that produces a completely self-contained HTML artifact.
|
||||
* The returned HTML must include <style> and <script> tags directly so that
|
||||
* it can be saved as a single .html file and opened in any browser with no
|
||||
* external dependencies.
|
||||
*/
|
||||
export const createCodeArtifactTool = () => {
|
||||
return tool({
|
||||
description:
|
||||
'Generate a single-file HTML snippet (with inline <style> and <script>) that accomplishes the requested functionality. The final HTML should be runnable when saved as an .html file and opened in a browser. Do NOT reference external resources (CSS, JS, images) except through data URIs.',
|
||||
parameters: z.object({
|
||||
/**
|
||||
* The <title> text that will appear in the browser tab.
|
||||
*/
|
||||
title: z.string().describe('The title of the HTML page'),
|
||||
/**
|
||||
* The raw HTML that should be placed inside <body>. *Do not* include
|
||||
* <body> tags here – the tool will wrap it for you.
|
||||
*/
|
||||
body: z
|
||||
.string()
|
||||
.describe('HTML markup that goes inside the <body> element'),
|
||||
/**
|
||||
* Optional CSS rules to be wrapped in a single <style> tag inside <head>.
|
||||
*/
|
||||
css: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('CSS to inline in a <style> tag (omit if none).'),
|
||||
/**
|
||||
* Optional JavaScript code to be wrapped in a single <script> tag before
|
||||
* </body>.
|
||||
*/
|
||||
js: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('JavaScript to inline in a <script> tag (omit if none).'),
|
||||
}),
|
||||
execute: async ({ title, body, css = '', js = '' }) => {
|
||||
try {
|
||||
const parts: string[] = [];
|
||||
parts.push('<!DOCTYPE html>');
|
||||
parts.push('<html lang="en">');
|
||||
parts.push('<head>');
|
||||
parts.push('<meta charset="UTF-8" />');
|
||||
parts.push(`<title>${title}</title>`);
|
||||
if (css.trim().length) {
|
||||
parts.push('<style>');
|
||||
parts.push(css);
|
||||
parts.push('</style>');
|
||||
}
|
||||
parts.push('</head>');
|
||||
parts.push('<body>');
|
||||
parts.push(body);
|
||||
if (js.trim().length) {
|
||||
parts.push('<script>');
|
||||
parts.push(js);
|
||||
parts.push('</script>');
|
||||
}
|
||||
parts.push('</body>');
|
||||
parts.push('</html>');
|
||||
|
||||
const html = parts.join('\n');
|
||||
|
||||
return {
|
||||
title,
|
||||
html,
|
||||
size: html.length,
|
||||
};
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to compose code artifact (${title})`, err);
|
||||
return toolError('Code Artifact Failed', err.message ?? String(err));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './code-artifact';
|
||||
export * from './doc-compose';
|
||||
export * from './doc-edit';
|
||||
export * from './doc-keyword-search';
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"react-virtuoso": "^4.12.3",
|
||||
"rxjs": "^7.8.1",
|
||||
"ses": "^1.10.0",
|
||||
"shiki": "^3.7.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"swr": "2.3.3",
|
||||
"tinykeys": "patch:tinykeys@npm%3A2.1.0#~/.yarn/patches/tinykeys-npm-2.1.0-819feeaed0.patch",
|
||||
|
||||
+18
@@ -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`
|
||||
|
||||
+6
-1
@@ -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')};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
+34
-9
@@ -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);
|
||||
|
||||
+1
-4
@@ -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';
|
||||
|
||||
@@ -471,6 +471,7 @@ __metadata:
|
||||
react-virtuoso: "npm:^4.12.3"
|
||||
rxjs: "npm:^7.8.1"
|
||||
ses: "npm:^1.10.0"
|
||||
shiki: "npm:^3.7.0"
|
||||
socket.io-client: "npm:^4.8.1"
|
||||
swr: "npm:2.3.3"
|
||||
tinykeys: "patch:tinykeys@npm%3A2.1.0#~/.yarn/patches/tinykeys-npm-2.1.0-819feeaed0.patch"
|
||||
@@ -13003,64 +13004,64 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/core@npm:3.4.2":
|
||||
version: 3.4.2
|
||||
resolution: "@shikijs/core@npm:3.4.2"
|
||||
"@shikijs/core@npm:3.7.0":
|
||||
version: 3.7.0
|
||||
resolution: "@shikijs/core@npm:3.7.0"
|
||||
dependencies:
|
||||
"@shikijs/types": "npm:3.4.2"
|
||||
"@shikijs/types": "npm:3.7.0"
|
||||
"@shikijs/vscode-textmate": "npm:^10.0.2"
|
||||
"@types/hast": "npm:^3.0.4"
|
||||
hast-util-to-html: "npm:^9.0.5"
|
||||
checksum: 10/82f044575fbc9cf89090f2f884f47cb7d12f6b1a6de1a52bbe3a30f739eb2fdf07e69fc45a14f8955b94d2ae927db063221379f40b311526ff2891e0700c4998
|
||||
checksum: 10/0832dd870a07528cc71765ae5451262966d93894fa3f0a2cb503306b9f743c422e1d9a18027e352218cf8caf5d36801dbfa1a8c44526ddf2e45044660d0a93ae
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/engine-javascript@npm:3.4.2":
|
||||
version: 3.4.2
|
||||
resolution: "@shikijs/engine-javascript@npm:3.4.2"
|
||||
"@shikijs/engine-javascript@npm:3.7.0":
|
||||
version: 3.7.0
|
||||
resolution: "@shikijs/engine-javascript@npm:3.7.0"
|
||||
dependencies:
|
||||
"@shikijs/types": "npm:3.4.2"
|
||||
"@shikijs/types": "npm:3.7.0"
|
||||
"@shikijs/vscode-textmate": "npm:^10.0.2"
|
||||
oniguruma-to-es: "npm:^4.3.3"
|
||||
checksum: 10/c522ebd464023145efaa30360fb44390da6a4b74e561cb84156f9635d0e4676eaa10c7820eceb6e0cfe657a602a33d93d5b4f54fcc3154029a5586cda830f53d
|
||||
checksum: 10/e5d45a10ad5e9e71880a9a0b55d3c523aadfec8b04e3f080e3130195d0e17926c8f5ee05ce475788b377b909e6a5259bda61f58fea8a2cb33f078f1167f1856c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/engine-oniguruma@npm:3.4.2, @shikijs/engine-oniguruma@npm:^3.4.0":
|
||||
version: 3.4.2
|
||||
resolution: "@shikijs/engine-oniguruma@npm:3.4.2"
|
||||
"@shikijs/engine-oniguruma@npm:3.7.0, @shikijs/engine-oniguruma@npm:^3.4.0":
|
||||
version: 3.7.0
|
||||
resolution: "@shikijs/engine-oniguruma@npm:3.7.0"
|
||||
dependencies:
|
||||
"@shikijs/types": "npm:3.4.2"
|
||||
"@shikijs/types": "npm:3.7.0"
|
||||
"@shikijs/vscode-textmate": "npm:^10.0.2"
|
||||
checksum: 10/3dc4f5cb63961b5b0b1f619d83edeb7780c5d4232aa68b056b9f4809660ac88ac038a56e0ca09d46c7008f46973e6d7df94fa7186ab410ddca2936eb254dd0b7
|
||||
checksum: 10/dee77bddb90efd2b164d46ed2b88793503d00cd2fb484b869cf7c78531b75fd024bbbd2d7b8be2eb3f063179d79aa441341478411403415156ad76672d751547
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/langs@npm:3.4.2, @shikijs/langs@npm:^3.4.0":
|
||||
version: 3.4.2
|
||||
resolution: "@shikijs/langs@npm:3.4.2"
|
||||
"@shikijs/langs@npm:3.7.0, @shikijs/langs@npm:^3.4.0":
|
||||
version: 3.7.0
|
||||
resolution: "@shikijs/langs@npm:3.7.0"
|
||||
dependencies:
|
||||
"@shikijs/types": "npm:3.4.2"
|
||||
checksum: 10/6d217d0ba6e550c86a5172533d3cd0cbe325b79fd2d018a8c85cc0c834bdc2cd29012bd8864cd701bfd819a32c7c9090685b3c2e2d49000d535b44826a260a44
|
||||
"@shikijs/types": "npm:3.7.0"
|
||||
checksum: 10/11bc671751c6cd82ae83575bdf84ecf51f54b13b3c8e3b51ff299a8c1288b8eac0fa2c99c6905753a4790b7af9160c6ed5a15be4c54851be3d339540a8d292ee
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/themes@npm:3.4.2, @shikijs/themes@npm:^3.4.0":
|
||||
version: 3.4.2
|
||||
resolution: "@shikijs/themes@npm:3.4.2"
|
||||
"@shikijs/themes@npm:3.7.0, @shikijs/themes@npm:^3.4.0":
|
||||
version: 3.7.0
|
||||
resolution: "@shikijs/themes@npm:3.7.0"
|
||||
dependencies:
|
||||
"@shikijs/types": "npm:3.4.2"
|
||||
checksum: 10/8e22fbd593a080fee63567f00c767b67d005613715932c0c72168b88febee86fb84036d8f2b6213129c9c91d363c5d988f773fab1a5bfbec3a1b0c758c9eaacf
|
||||
"@shikijs/types": "npm:3.7.0"
|
||||
checksum: 10/9fb5085bc9121124577823a28f9b2d1335875296232856e37c7cf9a9672958dff93b6d821896f6dbcbd44953043254acf28407c6e4cfba62d5fa8bf38142fd08
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shikijs/types@npm:3.4.2, @shikijs/types@npm:^3.4.0":
|
||||
version: 3.4.2
|
||||
resolution: "@shikijs/types@npm:3.4.2"
|
||||
"@shikijs/types@npm:3.7.0, @shikijs/types@npm:^3.4.0":
|
||||
version: 3.7.0
|
||||
resolution: "@shikijs/types@npm:3.7.0"
|
||||
dependencies:
|
||||
"@shikijs/vscode-textmate": "npm:^10.0.2"
|
||||
"@types/hast": "npm:^3.0.4"
|
||||
checksum: 10/cf993b69399c4e0866637f92bba0bc42382d9380366fc355d1641a0925bc46812b218ae02a42bd23cf26e5355ca21a6afab9b5336b1f1d9fa54eb919ca91e5ab
|
||||
checksum: 10/f6f4c6166968a24620b390bc4dc7d2238f8cbcb209d0b55583dafe0fea2de742479795b2bdede2b3f070159550b2e36519d3933ac9f23ca02e036c87fed74db4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -31234,19 +31235,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"shiki@npm:^3.0.0":
|
||||
version: 3.4.2
|
||||
resolution: "shiki@npm:3.4.2"
|
||||
"shiki@npm:^3.0.0, shiki@npm:^3.7.0":
|
||||
version: 3.7.0
|
||||
resolution: "shiki@npm:3.7.0"
|
||||
dependencies:
|
||||
"@shikijs/core": "npm:3.4.2"
|
||||
"@shikijs/engine-javascript": "npm:3.4.2"
|
||||
"@shikijs/engine-oniguruma": "npm:3.4.2"
|
||||
"@shikijs/langs": "npm:3.4.2"
|
||||
"@shikijs/themes": "npm:3.4.2"
|
||||
"@shikijs/types": "npm:3.4.2"
|
||||
"@shikijs/core": "npm:3.7.0"
|
||||
"@shikijs/engine-javascript": "npm:3.7.0"
|
||||
"@shikijs/engine-oniguruma": "npm:3.7.0"
|
||||
"@shikijs/langs": "npm:3.7.0"
|
||||
"@shikijs/themes": "npm:3.7.0"
|
||||
"@shikijs/types": "npm:3.7.0"
|
||||
"@shikijs/vscode-textmate": "npm:^10.0.2"
|
||||
"@types/hast": "npm:^3.0.4"
|
||||
checksum: 10/e1737635772e355ef1035f23084b5520e9ce4ea051d821b1792c6ff8703379826ba5dbfd74e7a67dfe3996f29776f8040c1f742c3d7ff6f24ba83d313e455e36
|
||||
checksum: 10/bd9b2495e72fba00393ae99df7ebd7f29df89a194fe607f6a1c7e8980a3a95c1a2cf0699b72e970020a522bc03f446c254ece41755d3765d66dc35bdd1de19f3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user