feat(core): support objects render in ai chat block (#12906)

Close [AI-195](https://linear.app/affine-design/issue/AI-195)


<img width="1219" alt="截屏2025-06-24 10 55 18"
src="https://github.com/user-attachments/assets/8c54ad41-2bbf-443a-a41a-9cea9aede7b4"
/>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced a new component to render various AI stream objects within
chat messages, supporting richer content types like reasoning blocks and
tool calls.

- **Improvements**
- Enhanced chat message rendering by consolidating message properties
into a single object and simplifying component interfaces.
- Improved scrolling behavior with smooth, throttled scrolling and
better synchronization with chat status.
- Updated chat history and streaming updates to use immutable data
handling for more reliable message updates.
- Refined rendering keys and content merging for more accurate display
of streamed AI responses.
- Improved handling and display of tool results and web search/crawl
outputs.

- **Bug Fixes**
- Ensured consistent presence of attachments and streaming objects in
chat message data.
- Enhanced parsing and merging of streamed AI responses for better
message accuracy.

- **Refactor**
- Streamlined imports, component registrations, and internal message
handling for maintainability.
- Updated type definitions to support new optional message properties
and improve compatibility.

- **Style**
- Adjusted layout by moving history clear controls for a more intuitive
chat interface.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Wu Yue
2025-06-24 13:03:11 +08:00
committed by GitHub
parent 616e755dde
commit 63de20c3d5
24 changed files with 369 additions and 259 deletions

View File

@@ -194,6 +194,7 @@ export class CopilotSessionModel extends BaseModel {
id: true, id: true,
role: true, role: true,
content: true, content: true,
streamObjects: true,
attachments: true, attachments: true,
params: true, params: true,
createdAt: true, createdAt: true,

View File

@@ -91,7 +91,7 @@ export function constructUserInfoWithMessages(
userInfo: AIUserInfo | null userInfo: AIUserInfo | null
) { ) {
return messages.map(message => { return messages.map(message => {
const { role, id, content, createdAt } = message; const { role, streamObjects } = message;
const isUser = role === 'user'; const isUser = role === 'user';
const userInfoProps = isUser const userInfoProps = isUser
? { ? {
@@ -101,12 +101,10 @@ export function constructUserInfoWithMessages(
} }
: {}; : {};
return { return {
id, ...message,
role,
content,
createdAt,
attachments: [],
...userInfoProps, ...userInfoProps,
attachments: [],
streamObjects: streamObjects || [],
}; };
}); });
} }
@@ -117,11 +115,11 @@ export async function constructRootChatBlockMessages(
) { ) {
// Convert chat messages to AI chat block messages // Convert chat messages to AI chat block messages
const userInfo = await AIProvider.userInfo; const userInfo = await AIProvider.userInfo;
const forkMessages = await queryHistoryMessages( const forkMessages = (await queryHistoryMessages(
doc.workspace.id, doc.workspace.id,
doc.id, doc.id,
forkSessionId forkSessionId
); )) as ChatMessage[];
return constructUserInfoWithMessages(forkMessages, userInfo); return constructUserInfoWithMessages(forkMessages, userInfo);
} }

View File

@@ -9,6 +9,7 @@ import type {
CopilotSessionType, CopilotSessionType,
getCopilotHistoriesQuery, getCopilotHistoriesQuery,
RequestOptions, RequestOptions,
StreamObject,
} from '@affine/graphql'; } from '@affine/graphql';
import type { EditorHost } from '@blocksuite/affine/std'; import type { EditorHost } from '@blocksuite/affine/std';
import type { GfxModel } from '@blocksuite/affine/std/gfx'; import type { GfxModel } from '@blocksuite/affine/std/gfx';
@@ -359,7 +360,8 @@ declare global {
content: string; content: string;
createdAt: string; createdAt: string;
role: MessageRole; role: MessageRole;
attachments?: string[]; attachments?: string[] | null;
streamObjects?: StreamObject[] | null;
}[]; }[];
} }

View File

@@ -1,14 +1,13 @@
import type { TextRendererOptions } from '@affine/core/blocksuite/ai/components/text-renderer'; import type { TextRendererOptions } from '@affine/core/blocksuite/ai/components/text-renderer';
import type { AffineAIPanelState } from '@affine/core/blocksuite/ai/widgets/ai-panel/type';
import type { EditorHost } from '@blocksuite/affine/std'; import type { EditorHost } from '@blocksuite/affine/std';
import { css, html, LitElement } from 'lit'; import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js'; import { repeat } from 'lit/directives/repeat.js';
import type { import {
ChatMessage, type ChatMessage,
MessageRole, type StreamObject,
} from '../../../components/ai-chat-messages'; } from '../../../components/ai-chat-messages';
import { UserInfoTemplate } from './user-info'; import { UserInfoTemplate } from './user-info';
@@ -36,16 +35,14 @@ export class AIChatMessage extends LitElement {
override render() { override render() {
const { const {
host,
textRendererOptions,
state,
content, content,
attachments, attachments,
messageRole,
userId,
userName, userName,
userId,
avatarUrl, avatarUrl,
} = this; role,
streamObjects,
} = this.message;
const withAttachments = !!attachments && attachments.length > 0; const withAttachments = !!attachments && attachments.length > 0;
const messageClasses = classMap({ const messageClasses = classMap({
@@ -54,48 +51,52 @@ export class AIChatMessage extends LitElement {
return html` return html`
<div class="ai-chat-message"> <div class="ai-chat-message">
${UserInfoTemplate({ userId, userName, avatarUrl }, messageRole)} ${UserInfoTemplate({ userId, userName, avatarUrl }, role)}
<div class="ai-chat-content"> <div class="ai-chat-content">
<chat-images .attachments=${attachments}></chat-images> <chat-images .attachments=${attachments}></chat-images>
<div class=${messageClasses}> <div class=${messageClasses}>
<text-renderer ${streamObjects?.length
.host=${host} ? this.renderStreamObjects(streamObjects)
.answer=${content} : this.renderRichText(content)}
.options=${textRendererOptions}
.state=${state}
></text-renderer>
</div> </div>
</div> </div>
</div> </div>
`; `;
} }
@property({ attribute: false }) private renderStreamObjects(answer: StreamObject[]) {
accessor attachments: string[] | undefined = undefined; return html`<chat-content-stream-objects
.answer=${answer}
.host=${this.host}
.state=${this.state}
.extensions=${this.textRendererOptions.extensions}
.affineFeatureFlagService=${this.textRendererOptions
.affineFeatureFlagService}
></chat-content-stream-objects>`;
}
private renderRichText(text: string) {
return html`<chat-content-rich-text
.host=${this.host}
.text=${text}
.state=${this.state}
.extensions=${this.textRendererOptions.extensions}
.affineFeatureFlagService=${this.textRendererOptions
.affineFeatureFlagService}
></chat-content-rich-text>`;
}
@property({ attribute: false }) @property({ attribute: false })
accessor content: string = ''; accessor message!: ChatMessage;
@property({ attribute: false }) @property({ attribute: false })
accessor host!: EditorHost; accessor host!: EditorHost;
@property({ attribute: false }) @property({ attribute: false })
accessor messageRole: MessageRole | undefined = undefined; accessor state: 'finished' | 'generating' = 'finished';
@property({ attribute: false })
accessor state: AffineAIPanelState = 'finished';
@property({ attribute: false }) @property({ attribute: false })
accessor textRendererOptions: TextRendererOptions = {}; accessor textRendererOptions: TextRendererOptions = {};
@property({ attribute: false })
accessor userId: string | undefined = undefined;
@property({ attribute: false })
accessor userName: string | undefined = undefined;
@property({ attribute: false })
accessor avatarUrl: string | undefined = undefined;
} }
export class AIChatMessages extends LitElement { export class AIChatMessages extends LitElement {
@@ -121,18 +122,11 @@ export class AIChatMessages extends LitElement {
this.messages, this.messages,
message => message.id || message.createdAt, message => message.id || message.createdAt,
message => { message => {
const { attachments, role, content, userId, userName, avatarUrl } =
message;
return html` return html`
<ai-chat-message <ai-chat-message
.host=${this.host} .host=${this.host}
.textRendererOptions=${this.textRendererOptions} .textRendererOptions=${this.textRendererOptions}
.content=${content} .message=${message}
.attachments=${attachments}
.messageRole=${role}
.userId=${userId}
.userName=${userName}
.avatarUrl=${avatarUrl}
></ai-chat-message> ></ai-chat-message>
`; `;
} }

View File

@@ -1,6 +1,3 @@
import './action-wrapper';
import '../content/images';
import { WithDisposable } from '@blocksuite/affine/global/lit'; import { WithDisposable } from '@blocksuite/affine/global/lit';
import type { EditorHost } from '@blocksuite/affine/std'; import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std'; import { ShadowlessElement } from '@blocksuite/affine/std';

View File

@@ -19,7 +19,6 @@ import type {
AIReasoningConfig, AIReasoningConfig,
} from '../components/ai-chat-input'; } from '../components/ai-chat-input';
import { import {
type ChatMessage,
isChatAction, isChatAction,
isChatMessage, isChatMessage,
StreamObjectSchema, StreamObjectSchema,
@@ -418,22 +417,27 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
}); });
for await (const text of stream) { for await (const text of stream) {
const messages = [...this.chatContextValue.messages]; const messages = this.chatContextValue.messages.slice(0);
const last = messages[messages.length - 1] as ChatMessage; const last = messages.at(-1);
try { if (last && isChatMessage(last)) {
const parsed = StreamObjectSchema.safeParse(JSON.parse(text)); try {
if (parsed.success) { const parsed = StreamObjectSchema.parse(JSON.parse(text));
last.streamObjects = mergeStreamObjects([ const streamObjects = mergeStreamObjects([
...(last.streamObjects ?? []), ...(last.streamObjects ?? []),
parsed.data, parsed,
]); ]);
} else { messages[messages.length - 1] = {
last.content += text; ...last,
streamObjects,
};
} catch {
messages[messages.length - 1] = {
...last,
content: last.content + text,
};
} }
} catch { this.updateContext({ messages, status: 'transmitting' });
last.content += text;
} }
this.updateContext({ messages, status: 'transmitting' });
} }
this.updateContext({ status: 'success' }); this.updateContext({ status: 'success' });

View File

@@ -24,7 +24,11 @@ import type {
AINetworkSearchConfig, AINetworkSearchConfig,
AIReasoningConfig, AIReasoningConfig,
} from '../components/ai-chat-input'; } from '../components/ai-chat-input';
import { type HistoryMessage } from '../components/ai-chat-messages'; import {
type ChatAction,
type ChatMessage,
type HistoryMessage,
} from '../components/ai-chat-messages';
import { createPlaygroundModal } from '../components/playground/modal'; import { createPlaygroundModal } from '../components/playground/modal';
import { AIProvider } from '../provider'; import { AIProvider } from '../provider';
import { extractSelectedContent } from '../utils/extract'; import { extractSelectedContent } from '../utils/extract';
@@ -146,12 +150,14 @@ export class ChatPanel extends SignalWatcher(
return; return;
} }
const messages: HistoryMessage[] = actions ? [...actions] : []; const chatActions = (actions || []) as ChatAction[];
const messages: HistoryMessage[] = chatActions;
const sessionId = await this._getSessionId(); const sessionId = await this._getSessionId();
const history = histories?.find(history => history.sessionId === sessionId); const history = histories?.find(history => history.sessionId === sessionId);
if (history) { if (history) {
messages.push(...history.messages); const chatMessages = (history.messages || []) as ChatMessage[];
messages.push(...chatMessages);
} }
this.chatContextValue = { this.chatContextValue = {

View File

@@ -1,5 +1,3 @@
import '../content/assistant-avatar';
import { WithDisposable } from '@blocksuite/affine/global/lit'; import { WithDisposable } from '@blocksuite/affine/global/lit';
import type { EditorHost } from '@blocksuite/affine/std'; import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std'; import { ShadowlessElement } from '@blocksuite/affine/std';

View File

@@ -1,9 +1,5 @@
import '../content/assistant-avatar';
import '../content/rich-text';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag'; import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WithDisposable } from '@blocksuite/affine/global/lit'; import { WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { isInsidePageEditor } from '@blocksuite/affine/shared/utils'; import { isInsidePageEditor } from '@blocksuite/affine/shared/utils';
import type { EditorHost } from '@blocksuite/affine/std'; import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std'; import { ShadowlessElement } from '@blocksuite/affine/std';
@@ -33,20 +29,6 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
font-size: var(--affine-font-xs); font-size: var(--affine-font-xs);
font-weight: 400; font-weight: 400;
} }
.reasoning-wrapper {
padding: 16px 20px;
margin: 8px 0;
border-radius: 8px;
background-color: rgba(0, 0, 0, 0.05);
}
.tool-wrapper {
padding: 12px;
margin: 8px 0;
border-radius: 8px;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
}
`; `;
@property({ attribute: false }) @property({ attribute: false })
@@ -82,6 +64,15 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
@property({ attribute: false }) @property({ attribute: false })
accessor panelWidth!: Signal<number | undefined>; accessor panelWidth!: Signal<number | undefined>;
get state() {
const { isLast, status } = this;
return isLast
? status !== 'loading' && status !== 'transmitting'
? 'finished'
: 'generating'
: 'finished';
}
renderHeader() { renderHeader() {
const isWithDocs = const isWithDocs =
'content' in this.item && 'content' in this.item &&
@@ -122,102 +113,21 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
} }
private renderStreamObjects(answer: StreamObject[]) { private renderStreamObjects(answer: StreamObject[]) {
return html`<div> return html`<chat-content-stream-objects
${answer.map(data => { .answer=${answer}
switch (data.type) { .host=${this.host}
case 'text-delta': .state=${this.state}
return this.renderRichText(data.textDelta); .width=${this.panelWidth}
case 'reasoning': .extensions=${this.extensions}
return html` .affineFeatureFlagService=${this.affineFeatureFlagService}
<div class="reasoning-wrapper"> ></chat-content-stream-objects>`;
${this.renderRichText(data.textDelta)}
</div>
`;
case 'tool-call':
return this.renderToolCall(data);
case 'tool-result':
return this.renderToolResult(data);
default:
return nothing;
}
})}
</div>`;
}
private renderToolCall(streamObject: StreamObject) {
if (streamObject.type !== 'tool-call') {
return nothing;
}
switch (streamObject.toolName) {
case 'web_crawl_exa':
return html`
<web-crawl-tool
.data=${streamObject}
.host=${this.host}
.width=${this.panelWidth}
></web-crawl-tool>
`;
case 'web_search_exa':
return html`
<web-search-tool
.data=${streamObject}
.host=${this.host}
.width=${this.panelWidth}
></web-search-tool>
`;
default:
return html`
<div class="tool-wrapper">
${streamObject.toolName} tool calling...
</div>
`;
}
}
private renderToolResult(streamObject: StreamObject) {
if (streamObject.type !== 'tool-result') {
return nothing;
}
switch (streamObject.toolName) {
case 'web_crawl_exa':
return html`
<web-crawl-tool
.data=${streamObject}
.host=${this.host}
.width=${this.panelWidth}
></web-crawl-tool>
`;
case 'web_search_exa':
return html`
<web-search-tool
.data=${streamObject}
.host=${this.host}
.width=${this.panelWidth}
></web-search-tool>
`;
default:
return html`
<div class="tool-wrapper">
${streamObject.toolName} tool result...
</div>
`;
}
} }
private renderRichText(text: string) { private renderRichText(text: string) {
const { host, isLast, status } = this;
const state = isLast
? status !== 'loading' && status !== 'transmitting'
? 'finished'
: 'generating'
: 'finished';
return html`<chat-content-rich-text return html`<chat-content-rich-text
.host=${host} .host=${this.host}
.text=${text} .text=${text}
.state=${state} .state=${this.state}
.extensions=${this.extensions} .extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService} .affineFeatureFlagService=${this.affineFeatureFlagService}
></chat-content-rich-text>`; ></chat-content-rich-text>`;

View File

@@ -1,6 +1,3 @@
import '../content/images';
import '../content/pure-text';
import { WithDisposable } from '@blocksuite/affine/global/lit'; import { WithDisposable } from '@blocksuite/affine/global/lit';
import { ShadowlessElement } from '@blocksuite/affine/std'; import { ShadowlessElement } from '@blocksuite/affine/std';
import { css, html, nothing } from 'lit'; import { css, html, nothing } from 'lit';

View File

@@ -19,7 +19,11 @@ import { readBlobAsURL } from '../../utils/image';
import { mergeStreamObjects } from '../../utils/stream-objects'; import { mergeStreamObjects } from '../../utils/stream-objects';
import type { ChatChip, DocDisplayConfig } from '../ai-chat-chips/type'; import type { ChatChip, DocDisplayConfig } from '../ai-chat-chips/type';
import { isDocChip } from '../ai-chat-chips/utils'; import { isDocChip } from '../ai-chat-chips/utils';
import { type ChatMessage, StreamObjectSchema } from '../ai-chat-messages'; import {
type ChatMessage,
isChatMessage,
StreamObjectSchema,
} from '../ai-chat-messages';
import { MAX_IMAGE_COUNT } from './const'; import { MAX_IMAGE_COUNT } from './const';
import type { import type {
AIChatInputContext, AIChatInputContext,
@@ -609,22 +613,27 @@ export class AIChatInput extends SignalWatcher(
}); });
for await (const text of stream) { for await (const text of stream) {
const messages = [...this.chatContextValue.messages]; const messages = this.chatContextValue.messages.slice(0);
const last = messages[messages.length - 1] as ChatMessage; const last = messages.at(-1);
try { if (last && isChatMessage(last)) {
const parsed = StreamObjectSchema.safeParse(JSON.parse(text)); try {
if (parsed.success) { const parsed = StreamObjectSchema.parse(JSON.parse(text));
last.streamObjects = mergeStreamObjects([ const streamObjects = mergeStreamObjects([
...(last.streamObjects ?? []), ...(last.streamObjects ?? []),
parsed.data, parsed,
]); ]);
} else { messages[messages.length - 1] = {
last.content += text; ...last,
streamObjects,
};
} catch {
messages[messages.length - 1] = {
...last,
content: last.content + text,
};
} }
} catch { this.updateContext({ messages, status: 'transmitting' });
last.content += text;
} }
this.updateContext({ messages, status: 'transmitting' });
} }
this.updateContext({ status: 'success' }); this.updateContext({ status: 'success' });

View File

@@ -4,7 +4,7 @@ import { AiIcon } from '@blocksuite/icons/lit';
import { css, html } from 'lit'; import { css, html } from 'lit';
import { property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
import type { ChatStatus } from '../../components/ai-chat-messages'; import type { ChatStatus } from '../ai-chat-messages';
const AffineAvatarIcon = AiIcon({ const AffineAvatarIcon = AiIcon({
width: '20px', width: '20px',

View File

@@ -0,0 +1,145 @@
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
import type { ExtensionType } from '@blocksuite/affine/store';
import type { Signal } from '@preact/signals-core';
import { css, html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import type { AffineAIPanelState } from '../../widgets/ai-panel/type';
import type { StreamObject } from '../ai-chat-messages';
export class ChatContentStreamObjects extends WithDisposable(
ShadowlessElement
) {
static override styles = css`
.reasoning-wrapper {
padding: 16px 20px;
margin: 8px 0;
border-radius: 8px;
background-color: rgba(0, 0, 0, 0.05);
}
.tool-wrapper {
padding: 12px;
margin: 8px 0;
border-radius: 8px;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
}
`;
@property({ attribute: false })
accessor answer!: StreamObject[];
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor state: AffineAIPanelState = 'finished';
@property({ attribute: false })
accessor width: Signal<number | undefined> | undefined;
@property({ attribute: false })
accessor extensions!: ExtensionType[];
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
private renderToolCall(streamObject: StreamObject) {
if (streamObject.type !== 'tool-call') {
return nothing;
}
switch (streamObject.toolName) {
case 'web_crawl_exa':
return html`
<web-crawl-tool
.data=${streamObject}
.host=${this.host}
.width=${this.width}
></web-crawl-tool>
`;
case 'web_search_exa':
return html`
<web-search-tool
.data=${streamObject}
.host=${this.host}
.width=${this.width}
></web-search-tool>
`;
default:
return html`
<div class="tool-wrapper">
${streamObject.toolName} tool calling...
</div>
`;
}
}
private renderToolResult(streamObject: StreamObject) {
if (streamObject.type !== 'tool-result') {
return nothing;
}
switch (streamObject.toolName) {
case 'web_crawl_exa':
return html`
<web-crawl-tool
.data=${streamObject}
.host=${this.host}
.width=${this.width}
></web-crawl-tool>
`;
case 'web_search_exa':
return html`
<web-search-tool
.data=${streamObject}
.host=${this.host}
.width=${this.width}
></web-search-tool>
`;
default:
return html`
<div class="tool-wrapper">
${streamObject.toolName} tool result...
</div>
`;
}
}
private renderRichText(text: string) {
return html`<chat-content-rich-text
.host=${this.host}
.text=${text}
.state=${this.state}
.extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService}
></chat-content-rich-text>`;
}
protected override render() {
return html`<div>
${this.answer.map(data => {
switch (data.type) {
case 'text-delta':
return this.renderRichText(data.textDelta);
case 'reasoning':
return html`
<div class="reasoning-wrapper">
${this.renderRichText(data.textDelta)}
</div>
`;
case 'tool-call':
return this.renderToolCall(data);
case 'tool-result':
return this.renderToolResult(data);
default:
return nothing;
}
})}
</div>`;
}
}

View File

@@ -3,7 +3,7 @@ import { ImageProxyService } from '@blocksuite/affine/shared/adapters';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { type EditorHost, ShadowlessElement } from '@blocksuite/affine/std'; import { type EditorHost, ShadowlessElement } from '@blocksuite/affine/std';
import { ToggleDownIcon } from '@blocksuite/icons/lit'; import { ToggleDownIcon } from '@blocksuite/icons/lit';
import { type Signal, signal } from '@preact/signals-core'; import { type Signal } from '@preact/signals-core';
import { css, html, nothing, type TemplateResult } from 'lit'; import { css, html, nothing, type TemplateResult } from 'lit';
import { property, state } from 'lit/decorators.js'; import { property, state } from 'lit/decorators.js';
@@ -170,7 +170,7 @@ export class ToolResultCard extends SignalWatcher(
accessor results!: ToolResult[]; accessor results!: ToolResult[];
@property({ attribute: false }) @property({ attribute: false })
accessor width: Signal<number | undefined> = signal(undefined); accessor width: Signal<number | undefined> | undefined;
@state() @state()
private accessor isCollapsed = true; private accessor isCollapsed = true;
@@ -209,7 +209,10 @@ export class ToolResultCard extends SignalWatcher(
return nothing; return nothing;
} }
const maxIcons = Number(this.width.value) <= 400 ? 1 : 3; let maxIcons = 3;
if (this.width && this.width.value !== undefined) {
maxIcons = this.width.value <= 400 ? 1 : 3;
}
const visibleIcons = this.footerIcons.slice(0, maxIcons); const visibleIcons = this.footerIcons.slice(0, maxIcons);
return html` return html`

View File

@@ -35,7 +35,7 @@ export class WebCrawlTool extends WithDisposable(ShadowlessElement) {
accessor host!: EditorHost; accessor host!: EditorHost;
@property({ attribute: false }) @property({ attribute: false })
accessor width!: Signal<number | undefined>; accessor width: Signal<number | undefined> | undefined;
renderToolCall() { renderToolCall() {
return html` return html`

View File

@@ -35,7 +35,7 @@ export class WebSearchTool extends WithDisposable(ShadowlessElement) {
accessor host!: EditorHost; accessor host!: EditorHost;
@property({ attribute: false }) @property({ attribute: false })
accessor width!: Signal<number | undefined>; accessor width: Signal<number | undefined> | undefined;
renderToolCall() { renderToolCall() {
return html` return html`

View File

@@ -22,7 +22,11 @@ import type {
AINetworkSearchConfig, AINetworkSearchConfig,
AIReasoningConfig, AIReasoningConfig,
} from '../ai-chat-input'; } from '../ai-chat-input';
import { type HistoryMessage } from '../ai-chat-messages'; import {
type ChatAction,
type ChatMessage,
type HistoryMessage,
} from '../ai-chat-messages';
const DEFAULT_CHAT_CONTEXT_VALUE: ChatContextValue = { const DEFAULT_CHAT_CONTEXT_VALUE: ChatContextValue = {
quote: '', quote: '',
@@ -204,12 +208,14 @@ export class PlaygroundChat extends SignalWatcher(
return; return;
} }
const messages: HistoryMessage[] = actions ? [...actions] : []; const chatActions = (actions || []) as ChatAction[];
const messages: HistoryMessage[] = chatActions;
const sessionId = await this._getSessionId(); const sessionId = await this._getSessionId();
const history = histories?.find(history => history.sessionId === sessionId); const history = histories?.find(history => history.sessionId === sessionId);
if (history) { if (history) {
messages.push(...history.messages); const chatMessages = (history.messages || []) as ChatMessage[];
messages.push(...chatMessages);
} }
this.chatContextValue = { this.chatContextValue = {

View File

@@ -21,17 +21,9 @@ import { ActionSlides } from './chat-panel/actions/slides';
import { ActionText } from './chat-panel/actions/text'; import { ActionText } from './chat-panel/actions/text';
import { AILoading } from './chat-panel/ai-loading'; import { AILoading } from './chat-panel/ai-loading';
import { ChatPanelMessages } from './chat-panel/chat-panel-messages'; import { ChatPanelMessages } from './chat-panel/chat-panel-messages';
import { AssistantAvatar } from './chat-panel/content/assistant-avatar';
import { ChatContentImages } from './chat-panel/content/images';
import { ChatContentPureText } from './chat-panel/content/pure-text';
import { ChatContentRichText } from './chat-panel/content/rich-text';
import { ChatMessageAction } from './chat-panel/message/action'; 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 { ToolCallCard } from './chat-panel/tools/tool-call-card';
import { ToolResultCard } from './chat-panel/tools/tool-result-card';
import { WebCrawlTool } from './chat-panel/tools/web-crawl';
import { WebSearchTool } from './chat-panel/tools/web-search';
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';
import { ChatPanelChips } from './components/ai-chat-chips/chat-panel-chips'; import { ChatPanelChips } from './components/ai-chat-chips/chat-panel-chips';
@@ -46,7 +38,16 @@ import { AIChatEmbeddingStatusTooltip } from './components/ai-chat-input/embeddi
import { ChatInputPreference } from './components/ai-chat-input/preference-popup'; import { ChatInputPreference } from './components/ai-chat-input/preference-popup';
import { AIHistoryClear } from './components/ai-history-clear'; import { AIHistoryClear } from './components/ai-history-clear';
import { effects as componentAiItemEffects } from './components/ai-item'; import { effects as componentAiItemEffects } from './components/ai-item';
import { AssistantAvatar } from './components/ai-message-content/assistant-avatar';
import { ChatContentImages } from './components/ai-message-content/images';
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 { AIScrollableTextRenderer } from './components/ai-scrollable-text-renderer';
import { ToolCallCard } from './components/ai-tools/tool-call-card';
import { ToolResultCard } from './components/ai-tools/tool-result-card';
import { WebCrawlTool } from './components/ai-tools/web-crawl';
import { WebSearchTool } from './components/ai-tools/web-search';
import { AskAIButton } from './components/ask-ai-button'; import { AskAIButton } from './components/ask-ai-button';
import { AskAIIcon } from './components/ask-ai-icon'; import { AskAIIcon } from './components/ask-ai-icon';
import { AskAIPanel } from './components/ask-ai-panel'; import { AskAIPanel } from './components/ask-ai-panel';
@@ -156,6 +157,10 @@ export function registerAIEffects() {
customElements.define('chat-content-images', ChatContentImages); customElements.define('chat-content-images', ChatContentImages);
customElements.define('chat-content-pure-text', ChatContentPureText); customElements.define('chat-content-pure-text', ChatContentPureText);
customElements.define('chat-content-rich-text', ChatContentRichText); customElements.define('chat-content-rich-text', ChatContentRichText);
customElements.define(
'chat-content-stream-objects',
ChatContentStreamObjects
);
customElements.define('chat-message-action', ChatMessageAction); customElements.define('chat-message-action', ChatMessageAction);
customElements.define('chat-message-assistant', ChatMessageAssistant); customElements.define('chat-message-assistant', ChatMessageAssistant);
customElements.define('chat-message-user', ChatMessageUser); customElements.define('chat-message-user', ChatMessageUser);

View File

@@ -14,10 +14,11 @@ import {
import type { Signal } from '@blocksuite/affine/shared/utils'; import type { Signal } from '@blocksuite/affine/shared/utils';
import type { EditorHost } from '@blocksuite/affine/std'; import type { EditorHost } from '@blocksuite/affine/std';
import { signal } from '@preact/signals-core'; import { signal } from '@preact/signals-core';
import { html, LitElement, nothing } from 'lit'; import { html, LitElement, nothing, type PropertyValues } from 'lit';
import { property, query, state } from 'lit/decorators.js'; import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js'; import { repeat } from 'lit/directives/repeat.js';
import { throttle } from 'lodash-es';
import { import {
ChatBlockPeekViewActions, ChatBlockPeekViewActions,
@@ -36,12 +37,16 @@ import type {
import type { ChatMessage } from '../components/ai-chat-messages'; import type { ChatMessage } from '../components/ai-chat-messages';
import { import {
ChatMessagesSchema, ChatMessagesSchema,
isChatMessage,
StreamObjectSchema, StreamObjectSchema,
} from '../components/ai-chat-messages'; } from '../components/ai-chat-messages';
import type { TextRendererOptions } from '../components/text-renderer'; import type { TextRendererOptions } from '../components/text-renderer';
import { AIChatErrorRenderer } from '../messages/error'; import { AIChatErrorRenderer } from '../messages/error';
import { type AIError, AIProvider } from '../provider'; import { type AIError, AIProvider } from '../provider';
import { mergeStreamObjects } from '../utils/stream-objects'; import {
mergeStreamContent,
mergeStreamObjects,
} from '../utils/stream-objects';
import { PeekViewStyles } from './styles'; import { PeekViewStyles } from './styles';
import type { ChatContext } from './types'; import type { ChatContext } from './types';
import { calcChildBound } from './utils'; import { calcChildBound } from './utils';
@@ -115,11 +120,11 @@ export class AIChatBlockPeekView extends LitElement {
forkSessionId: string forkSessionId: string
) => { ) => {
const currentUserInfo = await AIProvider.userInfo; const currentUserInfo = await AIProvider.userInfo;
const forkMessages = await queryHistoryMessages( const forkMessages = (await queryHistoryMessages(
rootWorkspaceId, rootWorkspaceId,
rootDocId, rootDocId,
forkSessionId forkSessionId
); )) as ChatMessage[];
const forkLength = forkMessages.length; const forkLength = forkMessages.length;
const historyLength = this._historyMessages.length; const historyLength = this._historyMessages.length;
@@ -332,6 +337,18 @@ export class AIChatBlockPeekView extends LitElement {
this._resetContext(); this._resetContext();
}; };
private readonly _scrollToEnd = () => {
requestAnimationFrame(() => {
if (!this._chatMessagesContainer) return;
this._chatMessagesContainer.scrollTo({
top: this._chatMessagesContainer.scrollHeight,
behavior: 'smooth',
});
});
};
private readonly _throttledScrollToEnd = throttle(this._scrollToEnd, 600);
/** /**
* Retry the last chat message * Retry the last chat message
*/ */
@@ -372,22 +389,27 @@ export class AIChatBlockPeekView extends LitElement {
}); });
for await (const text of stream) { for await (const text of stream) {
const messages = [...this.chatContext.messages]; const messages = this.chatContext.messages.slice(0);
const last = messages[messages.length - 1] as ChatMessage; const last = messages.at(-1);
try { if (last && isChatMessage(last)) {
const parsed = StreamObjectSchema.safeParse(JSON.parse(text)); try {
if (parsed.success) { const parsed = StreamObjectSchema.parse(JSON.parse(text));
last.streamObjects = mergeStreamObjects([ const streamObjects = mergeStreamObjects([
...(last.streamObjects ?? []), ...(last.streamObjects ?? []),
parsed.data, parsed,
]); ]);
} else { messages[messages.length - 1] = {
last.content += text; ...last,
streamObjects,
};
} catch {
messages[messages.length - 1] = {
...last,
content: last.content + text,
};
} }
} catch { this.updateContext({ messages, status: 'transmitting' });
last.content += text;
} }
this.updateContext({ messages, status: 'transmitting' });
} }
this.updateContext({ status: 'success' }); this.updateContext({ status: 'success' });
@@ -410,7 +432,7 @@ export class AIChatBlockPeekView extends LitElement {
return html`${repeat( return html`${repeat(
currentMessages, currentMessages,
message => message.id || message.createdAt, (_, index) => index,
(message, idx) => { (message, idx) => {
const { status, error } = this.chatContext; const { status, error } = this.chatContext;
const isAssistantMessage = message.role === 'assistant'; const isAssistantMessage = message.role === 'assistant';
@@ -424,26 +446,24 @@ export class AIChatBlockPeekView extends LitElement {
const isNotReady = status === 'transmitting' || status === 'loading'; const isNotReady = status === 'transmitting' || status === 'loading';
const shouldRenderCopyMore = const shouldRenderCopyMore =
isAssistantMessage && !(isLastReply && isNotReady); isAssistantMessage && !(isLastReply && isNotReady);
const shouldRenderActions = const markdown = message.streamObjects?.length
isLastReply && !!message.content && !isNotReady; ? mergeStreamContent(message.streamObjects)
: message.content;
const shouldRenderActions = isLastReply && !!markdown && !isNotReady;
const messageClasses = classMap({ const messageClasses = classMap({
'assistant-message-container': isAssistantMessage, 'assistant-message-container': isAssistantMessage,
}); });
const { attachments, role, content, userId, userName, avatarUrl } = if (status === 'loading' && isLastReply) {
message; return html`<ai-loading></ai-loading>`;
}
return html`<div class=${messageClasses}> return html`<div class=${messageClasses}>
<ai-chat-message <ai-chat-message
.host=${host} .host=${host}
.state=${messageState} .state=${messageState}
.content=${content} .message=${message}
.attachments=${attachments}
.messageRole=${role}
.userId=${userId}
.userName=${userName}
.avatarUrl=${avatarUrl}
.textRendererOptions=${this._textRendererOptions} .textRendererOptions=${this._textRendererOptions}
></ai-chat-message> ></ai-chat-message>
${shouldRenderError ? AIChatErrorRenderer(host, error) : nothing} ${shouldRenderError ? AIChatErrorRenderer(host, error) : nothing}
@@ -451,7 +471,7 @@ export class AIChatBlockPeekView extends LitElement {
? html` <chat-copy-more ? html` <chat-copy-more
.host=${host} .host=${host}
.actions=${actions} .actions=${actions}
.content=${message.content} .content=${markdown}
.isLast=${isLastReply} .isLast=${isLastReply}
.getSessionId=${this._getSessionId} .getSessionId=${this._getSessionId}
.messageId=${message.id ?? undefined} .messageId=${message.id ?? undefined}
@@ -462,7 +482,7 @@ export class AIChatBlockPeekView extends LitElement {
? html`<chat-action-list ? html`<chat-action-list
.host=${host} .host=${host}
.actions=${actions} .actions=${actions}
.content=${message.content} .content=${markdown}
.getSessionId=${this._getSessionId} .getSessionId=${this._getSessionId}
.messageId=${message.id ?? undefined} .messageId=${message.id ?? undefined}
.layoutDirection=${'horizontal'} .layoutDirection=${'horizontal'}
@@ -502,13 +522,25 @@ export class AIChatBlockPeekView extends LitElement {
} }
override firstUpdated() { override firstUpdated() {
// first time render, scroll ai-chat-messages-container to bottom this._scrollToEnd();
requestAnimationFrame(() => { }
if (this._chatMessagesContainer) {
this._chatMessagesContainer.scrollTop = protected override updated(changedProperties: PropertyValues) {
this._chatMessagesContainer.scrollHeight; if (
} changedProperties.has('chatContext') &&
}); (this.chatContext.status === 'loading' ||
this.chatContext.status === 'error' ||
this.chatContext.status === 'success')
) {
setTimeout(this._scrollToEnd, 500);
}
if (
changedProperties.has('chatContext') &&
this.chatContext.status === 'transmitting'
) {
this._throttledScrollToEnd();
}
} }
override render() { override render() {
@@ -529,6 +561,15 @@ export class AIChatBlockPeekView extends LitElement {
const { messages: currentChatMessages } = chatContext; const { messages: currentChatMessages } = chatContext;
return html`<div class="ai-chat-block-peek-view-container"> return html`<div class="ai-chat-block-peek-view-container">
<div class="history-clear-container">
<ai-history-clear
.host=${this.host}
.doc=${this.host.store}
.getSessionId=${this._getSessionId}
.onHistoryCleared=${this._onHistoryCleared}
.chatContextValue=${chatContext}
></ai-history-clear>
</div>
<div class="ai-chat-messages-container"> <div class="ai-chat-messages-container">
<ai-chat-messages <ai-chat-messages
.host=${host} .host=${host}
@@ -539,15 +580,6 @@ export class AIChatBlockPeekView extends LitElement {
<div class="new-chat-messages-container"> <div class="new-chat-messages-container">
${this.CurrentMessages(currentChatMessages)} ${this.CurrentMessages(currentChatMessages)}
</div> </div>
<div class="history-clear-container">
<ai-history-clear
.host=${this.host}
.doc=${this.host.store}
.getSessionId=${this._getSessionId}
.onHistoryCleared=${this._onHistoryCleared}
.chatContextValue=${chatContext}
></ai-history-clear>
</div>
</div> </div>
<ai-chat-composer <ai-chat-composer
.host=${host} .host=${host}

View File

@@ -7,7 +7,10 @@ export function mergeStreamObjects(chunks: StreamObject[] = []) {
case 'reasoning': case 'reasoning':
case 'text-delta': { case 'text-delta': {
if (prev && prev.type === curr.type) { if (prev && prev.type === curr.type) {
prev.textDelta += curr.textDelta; acc[acc.length - 1] = {
...prev,
textDelta: prev.textDelta + curr.textDelta,
};
} else { } else {
acc.push(curr); acc.push(curr);
} }