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:
@@ -5,3 +5,4 @@ export * from './edgeless-clipboard-config';
|
|||||||
export * from './embed-edgeless-linked-doc-block';
|
export * from './embed-edgeless-linked-doc-block';
|
||||||
export * from './embed-linked-doc-block';
|
export * from './embed-linked-doc-block';
|
||||||
export * from './embed-linked-doc-spec';
|
export * from './embed-linked-doc-spec';
|
||||||
|
export { getEmbedLinkedDocIcons } from './utils';
|
||||||
|
|||||||
@@ -1752,6 +1752,7 @@ Below is the user's query. Please respond in the user's preferred language witho
|
|||||||
'docKeywordSearch',
|
'docKeywordSearch',
|
||||||
'docSemanticSearch',
|
'docSemanticSearch',
|
||||||
'webSearch',
|
'webSearch',
|
||||||
|
'docCompose',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
buildDocContentGetter,
|
buildDocContentGetter,
|
||||||
buildDocKeywordSearchGetter,
|
buildDocKeywordSearchGetter,
|
||||||
buildDocSearchGetter,
|
buildDocSearchGetter,
|
||||||
|
createDocComposeTool,
|
||||||
createDocEditTool,
|
createDocEditTool,
|
||||||
createDocKeywordSearchTool,
|
createDocKeywordSearchTool,
|
||||||
createDocReadTool,
|
createDocReadTool,
|
||||||
@@ -198,6 +199,10 @@ export abstract class CopilotProvider<C = any> {
|
|||||||
tools.web_crawl_exa = createExaCrawlTool(this.AFFiNEConfig);
|
tools.web_crawl_exa = createExaCrawlTool(this.AFFiNEConfig);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'docCompose': {
|
||||||
|
tools.doc_compose = createDocComposeTool();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tools;
|
return tools;
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export const PromptConfigStrictSchema = z.object({
|
|||||||
'docSemanticSearch',
|
'docSemanticSearch',
|
||||||
// work with exa/model internal tools
|
// work with exa/model internal tools
|
||||||
'webSearch',
|
'webSearch',
|
||||||
|
// artifact tools
|
||||||
|
'docCompose',
|
||||||
])
|
])
|
||||||
.array()
|
.array()
|
||||||
.nullable()
|
.nullable()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { ZodType } from 'zod';
|
import { ZodType } from 'zod';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
createDocComposeTool,
|
||||||
createDocEditTool,
|
createDocEditTool,
|
||||||
createDocKeywordSearchTool,
|
createDocKeywordSearchTool,
|
||||||
createDocReadTool,
|
createDocReadTool,
|
||||||
@@ -388,6 +389,7 @@ export interface CustomAITools extends ToolSet {
|
|||||||
doc_semantic_search: ReturnType<typeof createDocSemanticSearchTool>;
|
doc_semantic_search: ReturnType<typeof createDocSemanticSearchTool>;
|
||||||
doc_keyword_search: ReturnType<typeof createDocKeywordSearchTool>;
|
doc_keyword_search: ReturnType<typeof createDocKeywordSearchTool>;
|
||||||
doc_read: ReturnType<typeof createDocReadTool>;
|
doc_read: ReturnType<typeof createDocReadTool>;
|
||||||
|
doc_compose: ReturnType<typeof createDocComposeTool>;
|
||||||
web_search_exa: ReturnType<typeof createExaSearchTool>;
|
web_search_exa: ReturnType<typeof createExaSearchTool>;
|
||||||
web_crawl_exa: ReturnType<typeof createExaCrawlTool>;
|
web_crawl_exa: ReturnType<typeof createExaCrawlTool>;
|
||||||
}
|
}
|
||||||
@@ -457,6 +459,10 @@ export class TextStreamParser {
|
|||||||
result += `\nReading the doc "${chunk.args.doc_id}"\n`;
|
result += `\nReading the doc "${chunk.args.doc_id}"\n`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'doc_compose': {
|
||||||
|
result += `\nWriting document "${chunk.args.title}"\n`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result = this.markAsCallout(result);
|
result = this.markAsCallout(result);
|
||||||
break;
|
break;
|
||||||
@@ -486,6 +492,16 @@ export class TextStreamParser {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'doc_compose': {
|
||||||
|
if (
|
||||||
|
chunk.result &&
|
||||||
|
typeof chunk.result === 'object' &&
|
||||||
|
'title' in chunk.result
|
||||||
|
) {
|
||||||
|
result += `\nDocument "${chunk.result.title}" created successfully with ${chunk.result.wordCount} words.\n`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'web_search_exa': {
|
case 'web_search_exa': {
|
||||||
if (Array.isArray(chunk.result)) {
|
if (Array.isArray(chunk.result)) {
|
||||||
result += `\n${this.getWebSearchLinks(chunk.result)}\n`;
|
result += `\n${this.getWebSearchLinks(chunk.result)}\n`;
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { tool } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { toolError } from './error';
|
||||||
|
|
||||||
|
const logger = new Logger('DocComposeTool');
|
||||||
|
|
||||||
|
export const createDocComposeTool = () => {
|
||||||
|
return tool({
|
||||||
|
description:
|
||||||
|
'Write a new document with markdown content. This tool creates structured markdown content for documents including titles, sections, and formatting.',
|
||||||
|
parameters: z.object({
|
||||||
|
title: z.string().describe('The title of the document'),
|
||||||
|
content: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
'The main content to write in markdown format. Include proper markdown formatting like headers (# ## ###), lists (- * 1.), links [text](url), code blocks ```code```, and other markdown elements as needed.'
|
||||||
|
),
|
||||||
|
sections: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
heading: z.string().describe('Section heading'),
|
||||||
|
content: z.string().describe('Section content in markdown'),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.describe('Optional structured sections for the document'),
|
||||||
|
metadata: z
|
||||||
|
.object({
|
||||||
|
tags: z
|
||||||
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
|
.describe('Optional tags for the document'),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Optional brief description of the document'),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.describe('Optional metadata for the document'),
|
||||||
|
}),
|
||||||
|
execute: async ({ title, content, sections, metadata }) => {
|
||||||
|
try {
|
||||||
|
let markdownContent = '';
|
||||||
|
|
||||||
|
markdownContent += `${content}\n\n`;
|
||||||
|
|
||||||
|
if (sections && sections.length > 0) {
|
||||||
|
for (const section of sections) {
|
||||||
|
markdownContent += `## ${section.heading}\n\n`;
|
||||||
|
markdownContent += `${section.content}\n\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
markdown: markdownContent.trim(),
|
||||||
|
wordCount: content.split(/\s+/).length,
|
||||||
|
metadata: metadata || {},
|
||||||
|
tags: metadata?.tags || [],
|
||||||
|
description: metadata?.description || '',
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(`Failed to write document: ${title}`, err);
|
||||||
|
return toolError('Doc Write Failed', err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './doc-compose';
|
||||||
export * from './doc-edit';
|
export * from './doc-edit';
|
||||||
export * from './doc-keyword-search';
|
export * from './doc-keyword-search';
|
||||||
export * from './doc-read';
|
export * from './doc-read';
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ import { ShadowlessElement } from '@blocksuite/affine/std';
|
|||||||
import type { ExtensionType, Store } from '@blocksuite/affine/store';
|
import type { ExtensionType, Store } from '@blocksuite/affine/store';
|
||||||
import { CenterPeekIcon } from '@blocksuite/icons/lit';
|
import { CenterPeekIcon } from '@blocksuite/icons/lit';
|
||||||
import { type Signal, signal } from '@preact/signals-core';
|
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 { property, state } from 'lit/decorators.js';
|
||||||
import { keyed } from 'lit/directives/keyed.js';
|
import { keyed } from 'lit/directives/keyed.js';
|
||||||
import { styleMap } from 'lit/directives/style-map.js';
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
@@ -106,6 +112,9 @@ export class ChatPanel extends SignalWatcher(
|
|||||||
@state()
|
@state()
|
||||||
accessor showPreviewPanel = false;
|
accessor showPreviewPanel = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor previewPanelContent: TemplateResult<1> | null = null;
|
||||||
|
|
||||||
private isSidebarOpen: Signal<boolean | undefined> = signal(false);
|
private isSidebarOpen: Signal<boolean | undefined> = signal(false);
|
||||||
|
|
||||||
private sidebarWidth: Signal<number | undefined> = signal(undefined);
|
private sidebarWidth: Signal<number | undefined> = signal(undefined);
|
||||||
@@ -329,7 +338,7 @@ export class ChatPanel extends SignalWatcher(
|
|||||||
)}
|
)}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
const right = html`<div>Preview Panel</div>`;
|
const right = this.previewPanelContent;
|
||||||
|
|
||||||
return html`<chat-panel-split-view
|
return html`<chat-panel-split-view
|
||||||
.left=${left}
|
.left=${left}
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import { type ChatMessage } from '../../components/ai-chat-messages';
|
|||||||
|
|
||||||
export class ChatMessageUser extends WithDisposable(ShadowlessElement) {
|
export class ChatMessageUser extends WithDisposable(ShadowlessElement) {
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
|
chat-message-user {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-message-user {
|
.chat-message-user {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export class ChatPanelSplitView extends SignalWatcher(
|
|||||||
.ai-chat-panel-split-view[data-dragging='true'] {
|
.ai-chat-panel-split-view[data-dragging='true'] {
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
}
|
}
|
||||||
|
.ai-chat-panel-split-view-right {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
.ai-chat-panel-split-view-left,
|
.ai-chat-panel-split-view-left,
|
||||||
.ai-chat-panel-split-view-right,
|
.ai-chat-panel-split-view-right,
|
||||||
.ai-chat-panel-split-view-divider {
|
.ai-chat-panel-split-view-divider {
|
||||||
@@ -42,7 +45,6 @@ export class ChatPanelSplitView extends SignalWatcher(
|
|||||||
.ai-chat-panel-split-view-divider {
|
.ai-chat-panel-split-view-divider {
|
||||||
width: var(--gap);
|
width: var(--gap);
|
||||||
position: relative;
|
position: relative;
|
||||||
border-left: 0.5px solid var(--affine-v2-layer-insideBorder-border);
|
|
||||||
}
|
}
|
||||||
.ai-chat-panel-split-view-divider[data-open='false'] {
|
.ai-chat-panel-split-view-divider[data-open='false'] {
|
||||||
width: 0;
|
width: 0;
|
||||||
|
|||||||
@@ -63,6 +63,15 @@ export class ChatContentStreamObjects extends WithDisposable(
|
|||||||
.imageProxyService=${imageProxyService}
|
.imageProxyService=${imageProxyService}
|
||||||
></web-search-tool>
|
></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: {
|
default: {
|
||||||
const name = streamObject.toolName + ' tool calling';
|
const name = streamObject.toolName + ' tool calling';
|
||||||
return html`
|
return html`
|
||||||
@@ -94,6 +103,15 @@ export class ChatContentStreamObjects extends WithDisposable(
|
|||||||
.imageProxyService=${imageProxyService}
|
.imageProxyService=${imageProxyService}
|
||||||
></web-search-tool>
|
></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: {
|
default: {
|
||||||
const name = streamObject.toolName + ' tool result';
|
const name = streamObject.toolName + ' tool result';
|
||||||
return html`
|
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 { ChatContentRichText } from './components/ai-message-content/rich-text';
|
||||||
import { ChatContentStreamObjects } from './components/ai-message-content/stream-objects';
|
import { ChatContentStreamObjects } from './components/ai-message-content/stream-objects';
|
||||||
import { AIScrollableTextRenderer } from './components/ai-scrollable-text-renderer';
|
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 { ToolCallCard } from './components/ai-tools/tool-call-card';
|
||||||
import { ToolFailedCard } from './components/ai-tools/tool-failed-card';
|
import { ToolFailedCard } from './components/ai-tools/tool-failed-card';
|
||||||
import { ToolResultCard } from './components/ai-tools/tool-result-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('tool-call-failed', ToolFailedCard);
|
||||||
customElements.define('web-crawl-tool', WebCrawlTool);
|
customElements.define('web-crawl-tool', WebCrawlTool);
|
||||||
customElements.define('web-search-tool', WebSearchTool);
|
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_AI_PANEL_WIDGET, AffineAIPanelWidget);
|
||||||
customElements.define(AFFINE_EDGELESS_COPILOT_WIDGET, EdgelessCopilotWidget);
|
customElements.define(AFFINE_EDGELESS_COPILOT_WIDGET, EdgelessCopilotWidget);
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ const WorkbenchView = ({ view }: { view: View }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MIN_SIDEBAR_WIDTH = 320;
|
const MIN_SIDEBAR_WIDTH = 320;
|
||||||
const MAX_SIDEBAR_WIDTH = 800;
|
const MAX_SIDEBAR_WIDTH = 1400;
|
||||||
|
|
||||||
const WorkbenchSidebar = () => {
|
const WorkbenchSidebar = () => {
|
||||||
const { clientBorder } = useAtomValue(appSettingAtom);
|
const { clientBorder } = useAtomValue(appSettingAtom);
|
||||||
|
|||||||
Reference in New Issue
Block a user