diff --git a/packages/backend/server/src/models/copilot-session.ts b/packages/backend/server/src/models/copilot-session.ts index aecc1a898..5d64e3667 100644 --- a/packages/backend/server/src/models/copilot-session.ts +++ b/packages/backend/server/src/models/copilot-session.ts @@ -194,6 +194,7 @@ export class CopilotSessionModel extends BaseModel { id: true, role: true, content: true, + streamObjects: true, attachments: true, params: true, createdAt: true, diff --git a/packages/frontend/core/src/blocksuite/ai/_common/chat-actions-handle.ts b/packages/frontend/core/src/blocksuite/ai/_common/chat-actions-handle.ts index 06dba04e3..54189fc47 100644 --- a/packages/frontend/core/src/blocksuite/ai/_common/chat-actions-handle.ts +++ b/packages/frontend/core/src/blocksuite/ai/_common/chat-actions-handle.ts @@ -91,7 +91,7 @@ export function constructUserInfoWithMessages( userInfo: AIUserInfo | null ) { return messages.map(message => { - const { role, id, content, createdAt } = message; + const { role, streamObjects } = message; const isUser = role === 'user'; const userInfoProps = isUser ? { @@ -101,12 +101,10 @@ export function constructUserInfoWithMessages( } : {}; return { - id, - role, - content, - createdAt, - attachments: [], + ...message, ...userInfoProps, + attachments: [], + streamObjects: streamObjects || [], }; }); } @@ -117,11 +115,11 @@ export async function constructRootChatBlockMessages( ) { // Convert chat messages to AI chat block messages const userInfo = await AIProvider.userInfo; - const forkMessages = await queryHistoryMessages( + const forkMessages = (await queryHistoryMessages( doc.workspace.id, doc.id, forkSessionId - ); + )) as ChatMessage[]; return constructUserInfoWithMessages(forkMessages, userInfo); } diff --git a/packages/frontend/core/src/blocksuite/ai/actions/types.ts b/packages/frontend/core/src/blocksuite/ai/actions/types.ts index cebe62617..d0e31668a 100644 --- a/packages/frontend/core/src/blocksuite/ai/actions/types.ts +++ b/packages/frontend/core/src/blocksuite/ai/actions/types.ts @@ -9,6 +9,7 @@ import type { CopilotSessionType, getCopilotHistoriesQuery, RequestOptions, + StreamObject, } from '@affine/graphql'; import type { EditorHost } from '@blocksuite/affine/std'; import type { GfxModel } from '@blocksuite/affine/std/gfx'; @@ -359,7 +360,8 @@ declare global { content: string; createdAt: string; role: MessageRole; - attachments?: string[]; + attachments?: string[] | null; + streamObjects?: StreamObject[] | null; }[]; } diff --git a/packages/frontend/core/src/blocksuite/ai/blocks/ai-chat-block/components/ai-chat-messages.ts b/packages/frontend/core/src/blocksuite/ai/blocks/ai-chat-block/components/ai-chat-messages.ts index 5f84a6603..7f697cbf4 100644 --- a/packages/frontend/core/src/blocksuite/ai/blocks/ai-chat-block/components/ai-chat-messages.ts +++ b/packages/frontend/core/src/blocksuite/ai/blocks/ai-chat-block/components/ai-chat-messages.ts @@ -1,14 +1,13 @@ 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 { css, html, LitElement } from 'lit'; import { property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { repeat } from 'lit/directives/repeat.js'; -import type { - ChatMessage, - MessageRole, +import { + type ChatMessage, + type StreamObject, } from '../../../components/ai-chat-messages'; import { UserInfoTemplate } from './user-info'; @@ -36,16 +35,14 @@ export class AIChatMessage extends LitElement { override render() { const { - host, - textRendererOptions, - state, content, attachments, - messageRole, - userId, userName, + userId, avatarUrl, - } = this; + role, + streamObjects, + } = this.message; const withAttachments = !!attachments && attachments.length > 0; const messageClasses = classMap({ @@ -54,48 +51,52 @@ export class AIChatMessage extends LitElement { return html`
- ${UserInfoTemplate({ userId, userName, avatarUrl }, messageRole)} + ${UserInfoTemplate({ userId, userName, avatarUrl }, role)}
- + ${streamObjects?.length + ? this.renderStreamObjects(streamObjects) + : this.renderRichText(content)}
`; } - @property({ attribute: false }) - accessor attachments: string[] | undefined = undefined; + private renderStreamObjects(answer: StreamObject[]) { + return html``; + } + + private renderRichText(text: string) { + return html``; + } @property({ attribute: false }) - accessor content: string = ''; + accessor message!: ChatMessage; @property({ attribute: false }) accessor host!: EditorHost; @property({ attribute: false }) - accessor messageRole: MessageRole | undefined = undefined; - - @property({ attribute: false }) - accessor state: AffineAIPanelState = 'finished'; + accessor state: 'finished' | 'generating' = 'finished'; @property({ attribute: false }) 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 { @@ -121,18 +122,11 @@ export class AIChatMessages extends LitElement { this.messages, message => message.id || message.createdAt, message => { - const { attachments, role, content, userId, userName, avatarUrl } = - message; return html` `; } diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/actions/image.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/actions/image.ts index 89be5d5a7..f089bf783 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/actions/image.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/actions/image.ts @@ -1,6 +1,3 @@ -import './action-wrapper'; -import '../content/images'; - import { WithDisposable } from '@blocksuite/affine/global/lit'; import type { EditorHost } from '@blocksuite/affine/std'; import { ShadowlessElement } from '@blocksuite/affine/std'; diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-panel-messages.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-panel-messages.ts index c9bba28a1..1c501f1b5 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-panel-messages.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-panel-messages.ts @@ -19,7 +19,6 @@ import type { AIReasoningConfig, } from '../components/ai-chat-input'; import { - type ChatMessage, isChatAction, isChatMessage, StreamObjectSchema, @@ -418,22 +417,27 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) { }); for await (const text of stream) { - const messages = [...this.chatContextValue.messages]; - const last = messages[messages.length - 1] as ChatMessage; - try { - const parsed = StreamObjectSchema.safeParse(JSON.parse(text)); - if (parsed.success) { - last.streamObjects = mergeStreamObjects([ + const messages = this.chatContextValue.messages.slice(0); + const last = messages.at(-1); + if (last && isChatMessage(last)) { + try { + const parsed = StreamObjectSchema.parse(JSON.parse(text)); + const streamObjects = mergeStreamObjects([ ...(last.streamObjects ?? []), - parsed.data, + parsed, ]); - } else { - last.content += text; + messages[messages.length - 1] = { + ...last, + streamObjects, + }; + } catch { + messages[messages.length - 1] = { + ...last, + content: last.content + text, + }; } - } catch { - last.content += text; + this.updateContext({ messages, status: 'transmitting' }); } - this.updateContext({ messages, status: 'transmitting' }); } this.updateContext({ status: 'success' }); diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts index aed072bce..1e32663af 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts @@ -24,7 +24,11 @@ import type { AINetworkSearchConfig, AIReasoningConfig, } 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 { AIProvider } from '../provider'; import { extractSelectedContent } from '../utils/extract'; @@ -146,12 +150,14 @@ export class ChatPanel extends SignalWatcher( return; } - const messages: HistoryMessage[] = actions ? [...actions] : []; + const chatActions = (actions || []) as ChatAction[]; + const messages: HistoryMessage[] = chatActions; const sessionId = await this._getSessionId(); const history = histories?.find(history => history.sessionId === sessionId); if (history) { - messages.push(...history.messages); + const chatMessages = (history.messages || []) as ChatMessage[]; + messages.push(...chatMessages); } this.chatContextValue = { diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/message/action.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/message/action.ts index 491bf7ef5..2c1b5eb5b 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/message/action.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/message/action.ts @@ -1,5 +1,3 @@ -import '../content/assistant-avatar'; - import { WithDisposable } from '@blocksuite/affine/global/lit'; import type { EditorHost } from '@blocksuite/affine/std'; import { ShadowlessElement } from '@blocksuite/affine/std'; diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/message/assistant.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/message/assistant.ts index 4409c40cb..8400d0be8 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/message/assistant.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/message/assistant.ts @@ -1,9 +1,5 @@ -import '../content/assistant-avatar'; -import '../content/rich-text'; - import type { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { WithDisposable } from '@blocksuite/affine/global/lit'; -import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; import { isInsidePageEditor } from '@blocksuite/affine/shared/utils'; import type { EditorHost } 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-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 }) @@ -82,6 +64,15 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) { @property({ attribute: false }) accessor panelWidth!: Signal; + get state() { + const { isLast, status } = this; + return isLast + ? status !== 'loading' && status !== 'transmitting' + ? 'finished' + : 'generating' + : 'finished'; + } + renderHeader() { const isWithDocs = 'content' in this.item && @@ -122,102 +113,21 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) { } private renderStreamObjects(answer: StreamObject[]) { - return html`
- ${answer.map(data => { - switch (data.type) { - case 'text-delta': - return this.renderRichText(data.textDelta); - case 'reasoning': - return html` -
- ${this.renderRichText(data.textDelta)} -
- `; - case 'tool-call': - return this.renderToolCall(data); - case 'tool-result': - return this.renderToolResult(data); - default: - return nothing; - } - })} -
`; - } - - private renderToolCall(streamObject: StreamObject) { - if (streamObject.type !== 'tool-call') { - return nothing; - } - - switch (streamObject.toolName) { - case 'web_crawl_exa': - return html` - - `; - case 'web_search_exa': - return html` - - `; - default: - return html` -
- ${streamObject.toolName} tool calling... -
- `; - } - } - - private renderToolResult(streamObject: StreamObject) { - if (streamObject.type !== 'tool-result') { - return nothing; - } - - switch (streamObject.toolName) { - case 'web_crawl_exa': - return html` - - `; - case 'web_search_exa': - return html` - - `; - default: - return html` -
- ${streamObject.toolName} tool result... -
- `; - } + return html``; } private renderRichText(text: string) { - const { host, isLast, status } = this; - const state = isLast - ? status !== 'loading' && status !== 'transmitting' - ? 'finished' - : 'generating' - : 'finished'; - return html``; diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/message/user.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/message/user.ts index 0deea19f8..73c78674b 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/message/user.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/message/user.ts @@ -1,6 +1,3 @@ -import '../content/images'; -import '../content/pure-text'; - import { WithDisposable } from '@blocksuite/affine/global/lit'; import { ShadowlessElement } from '@blocksuite/affine/std'; import { css, html, nothing } from 'lit'; diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts index bae888ae7..ae5dc5211 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts @@ -19,7 +19,11 @@ import { readBlobAsURL } from '../../utils/image'; import { mergeStreamObjects } from '../../utils/stream-objects'; import type { ChatChip, DocDisplayConfig } from '../ai-chat-chips/type'; 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 type { AIChatInputContext, @@ -609,22 +613,27 @@ export class AIChatInput extends SignalWatcher( }); for await (const text of stream) { - const messages = [...this.chatContextValue.messages]; - const last = messages[messages.length - 1] as ChatMessage; - try { - const parsed = StreamObjectSchema.safeParse(JSON.parse(text)); - if (parsed.success) { - last.streamObjects = mergeStreamObjects([ + const messages = this.chatContextValue.messages.slice(0); + const last = messages.at(-1); + if (last && isChatMessage(last)) { + try { + const parsed = StreamObjectSchema.parse(JSON.parse(text)); + const streamObjects = mergeStreamObjects([ ...(last.streamObjects ?? []), - parsed.data, + parsed, ]); - } else { - last.content += text; + messages[messages.length - 1] = { + ...last, + streamObjects, + }; + } catch { + messages[messages.length - 1] = { + ...last, + content: last.content + text, + }; } - } catch { - last.content += text; + this.updateContext({ messages, status: 'transmitting' }); } - this.updateContext({ messages, status: 'transmitting' }); } this.updateContext({ status: 'success' }); diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/content/assistant-avatar.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/assistant-avatar.ts similarity index 93% rename from packages/frontend/core/src/blocksuite/ai/chat-panel/content/assistant-avatar.ts rename to packages/frontend/core/src/blocksuite/ai/components/ai-message-content/assistant-avatar.ts index 4f62e7e97..dd12f9ba2 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/content/assistant-avatar.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/assistant-avatar.ts @@ -4,7 +4,7 @@ import { AiIcon } from '@blocksuite/icons/lit'; import { css, html } from 'lit'; import { property } from 'lit/decorators.js'; -import type { ChatStatus } from '../../components/ai-chat-messages'; +import type { ChatStatus } from '../ai-chat-messages'; const AffineAvatarIcon = AiIcon({ width: '20px', diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/content/images.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/images.ts similarity index 100% rename from packages/frontend/core/src/blocksuite/ai/chat-panel/content/images.ts rename to packages/frontend/core/src/blocksuite/ai/components/ai-message-content/images.ts diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/content/pure-text.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/pure-text.ts similarity index 100% rename from packages/frontend/core/src/blocksuite/ai/chat-panel/content/pure-text.ts rename to packages/frontend/core/src/blocksuite/ai/components/ai-message-content/pure-text.ts diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/content/rich-text.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/rich-text.ts similarity index 100% rename from packages/frontend/core/src/blocksuite/ai/chat-panel/content/rich-text.ts rename to packages/frontend/core/src/blocksuite/ai/components/ai-message-content/rich-text.ts diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/stream-objects.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/stream-objects.ts new file mode 100644 index 000000000..d72576c88 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-message-content/stream-objects.ts @@ -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 | 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` + + `; + case 'web_search_exa': + return html` + + `; + default: + return html` +
+ ${streamObject.toolName} tool calling... +
+ `; + } + } + + private renderToolResult(streamObject: StreamObject) { + if (streamObject.type !== 'tool-result') { + return nothing; + } + + switch (streamObject.toolName) { + case 'web_crawl_exa': + return html` + + `; + case 'web_search_exa': + return html` + + `; + default: + return html` +
+ ${streamObject.toolName} tool result... +
+ `; + } + } + + private renderRichText(text: string) { + return html``; + } + + protected override render() { + return html`
+ ${this.answer.map(data => { + switch (data.type) { + case 'text-delta': + return this.renderRichText(data.textDelta); + case 'reasoning': + return html` +
+ ${this.renderRichText(data.textDelta)} +
+ `; + case 'tool-call': + return this.renderToolCall(data); + case 'tool-result': + return this.renderToolResult(data); + default: + return nothing; + } + })} +
`; + } +} diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/tools/tool-call-card.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/tool-call-card.ts similarity index 100% rename from packages/frontend/core/src/blocksuite/ai/chat-panel/tools/tool-call-card.ts rename to packages/frontend/core/src/blocksuite/ai/components/ai-tools/tool-call-card.ts diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/tools/tool-result-card.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/tool-result-card.ts similarity index 96% rename from packages/frontend/core/src/blocksuite/ai/chat-panel/tools/tool-result-card.ts rename to packages/frontend/core/src/blocksuite/ai/components/ai-tools/tool-result-card.ts index 6da24e66e..7ef3f49e5 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/tools/tool-result-card.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/tool-result-card.ts @@ -3,7 +3,7 @@ import { ImageProxyService } from '@blocksuite/affine/shared/adapters'; import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; import { type EditorHost, ShadowlessElement } from '@blocksuite/affine/std'; 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 { property, state } from 'lit/decorators.js'; @@ -170,7 +170,7 @@ export class ToolResultCard extends SignalWatcher( accessor results!: ToolResult[]; @property({ attribute: false }) - accessor width: Signal = signal(undefined); + accessor width: Signal | undefined; @state() private accessor isCollapsed = true; @@ -209,7 +209,10 @@ export class ToolResultCard extends SignalWatcher( 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); return html` diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/tools/web-crawl.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/web-crawl.ts similarity index 97% rename from packages/frontend/core/src/blocksuite/ai/chat-panel/tools/web-crawl.ts rename to packages/frontend/core/src/blocksuite/ai/components/ai-tools/web-crawl.ts index b70a6c52b..7ba9d4a55 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/tools/web-crawl.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/web-crawl.ts @@ -35,7 +35,7 @@ export class WebCrawlTool extends WithDisposable(ShadowlessElement) { accessor host!: EditorHost; @property({ attribute: false }) - accessor width!: Signal; + accessor width: Signal | undefined; renderToolCall() { return html` diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/tools/web-search.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/web-search.ts similarity index 97% rename from packages/frontend/core/src/blocksuite/ai/chat-panel/tools/web-search.ts rename to packages/frontend/core/src/blocksuite/ai/components/ai-tools/web-search.ts index 7aaa78fd2..754c05faf 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/tools/web-search.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/web-search.ts @@ -35,7 +35,7 @@ export class WebSearchTool extends WithDisposable(ShadowlessElement) { accessor host!: EditorHost; @property({ attribute: false }) - accessor width!: Signal; + accessor width: Signal | undefined; renderToolCall() { return html` diff --git a/packages/frontend/core/src/blocksuite/ai/components/playground/chat.ts b/packages/frontend/core/src/blocksuite/ai/components/playground/chat.ts index b4753dfac..7372358d8 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/playground/chat.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/playground/chat.ts @@ -22,7 +22,11 @@ import type { AINetworkSearchConfig, AIReasoningConfig, } 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 = { quote: '', @@ -204,12 +208,14 @@ export class PlaygroundChat extends SignalWatcher( return; } - const messages: HistoryMessage[] = actions ? [...actions] : []; + const chatActions = (actions || []) as ChatAction[]; + const messages: HistoryMessage[] = chatActions; const sessionId = await this._getSessionId(); const history = histories?.find(history => history.sessionId === sessionId); if (history) { - messages.push(...history.messages); + const chatMessages = (history.messages || []) as ChatMessage[]; + messages.push(...chatMessages); } this.chatContextValue = { diff --git a/packages/frontend/core/src/blocksuite/ai/effects.ts b/packages/frontend/core/src/blocksuite/ai/effects.ts index 36342c502..058004bba 100644 --- a/packages/frontend/core/src/blocksuite/ai/effects.ts +++ b/packages/frontend/core/src/blocksuite/ai/effects.ts @@ -21,17 +21,9 @@ import { ActionSlides } from './chat-panel/actions/slides'; import { ActionText } from './chat-panel/actions/text'; import { AILoading } from './chat-panel/ai-loading'; 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 { ChatMessageAssistant } from './chat-panel/message/assistant'; 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 { ChatPanelCandidatesPopover } from './components/ai-chat-chips/candidates-popover'; 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 { AIHistoryClear } from './components/ai-history-clear'; 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 { 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 { AskAIIcon } from './components/ask-ai-icon'; import { AskAIPanel } from './components/ask-ai-panel'; @@ -156,6 +157,10 @@ export function registerAIEffects() { customElements.define('chat-content-images', ChatContentImages); customElements.define('chat-content-pure-text', ChatContentPureText); customElements.define('chat-content-rich-text', ChatContentRichText); + customElements.define( + 'chat-content-stream-objects', + ChatContentStreamObjects + ); customElements.define('chat-message-action', ChatMessageAction); customElements.define('chat-message-assistant', ChatMessageAssistant); customElements.define('chat-message-user', ChatMessageUser); diff --git a/packages/frontend/core/src/blocksuite/ai/peek-view/chat-block-peek-view.ts b/packages/frontend/core/src/blocksuite/ai/peek-view/chat-block-peek-view.ts index ecc666361..177f7773c 100644 --- a/packages/frontend/core/src/blocksuite/ai/peek-view/chat-block-peek-view.ts +++ b/packages/frontend/core/src/blocksuite/ai/peek-view/chat-block-peek-view.ts @@ -14,10 +14,11 @@ import { import type { Signal } from '@blocksuite/affine/shared/utils'; import type { EditorHost } from '@blocksuite/affine/std'; 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 { classMap } from 'lit/directives/class-map.js'; import { repeat } from 'lit/directives/repeat.js'; +import { throttle } from 'lodash-es'; import { ChatBlockPeekViewActions, @@ -36,12 +37,16 @@ import type { import type { ChatMessage } from '../components/ai-chat-messages'; import { ChatMessagesSchema, + isChatMessage, StreamObjectSchema, } from '../components/ai-chat-messages'; import type { TextRendererOptions } from '../components/text-renderer'; import { AIChatErrorRenderer } from '../messages/error'; import { type AIError, AIProvider } from '../provider'; -import { mergeStreamObjects } from '../utils/stream-objects'; +import { + mergeStreamContent, + mergeStreamObjects, +} from '../utils/stream-objects'; import { PeekViewStyles } from './styles'; import type { ChatContext } from './types'; import { calcChildBound } from './utils'; @@ -115,11 +120,11 @@ export class AIChatBlockPeekView extends LitElement { forkSessionId: string ) => { const currentUserInfo = await AIProvider.userInfo; - const forkMessages = await queryHistoryMessages( + const forkMessages = (await queryHistoryMessages( rootWorkspaceId, rootDocId, forkSessionId - ); + )) as ChatMessage[]; const forkLength = forkMessages.length; const historyLength = this._historyMessages.length; @@ -332,6 +337,18 @@ export class AIChatBlockPeekView extends LitElement { 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 */ @@ -372,22 +389,27 @@ export class AIChatBlockPeekView extends LitElement { }); for await (const text of stream) { - const messages = [...this.chatContext.messages]; - const last = messages[messages.length - 1] as ChatMessage; - try { - const parsed = StreamObjectSchema.safeParse(JSON.parse(text)); - if (parsed.success) { - last.streamObjects = mergeStreamObjects([ + const messages = this.chatContext.messages.slice(0); + const last = messages.at(-1); + if (last && isChatMessage(last)) { + try { + const parsed = StreamObjectSchema.parse(JSON.parse(text)); + const streamObjects = mergeStreamObjects([ ...(last.streamObjects ?? []), - parsed.data, + parsed, ]); - } else { - last.content += text; + messages[messages.length - 1] = { + ...last, + streamObjects, + }; + } catch { + messages[messages.length - 1] = { + ...last, + content: last.content + text, + }; } - } catch { - last.content += text; + this.updateContext({ messages, status: 'transmitting' }); } - this.updateContext({ messages, status: 'transmitting' }); } this.updateContext({ status: 'success' }); @@ -410,7 +432,7 @@ export class AIChatBlockPeekView extends LitElement { return html`${repeat( currentMessages, - message => message.id || message.createdAt, + (_, index) => index, (message, idx) => { const { status, error } = this.chatContext; const isAssistantMessage = message.role === 'assistant'; @@ -424,26 +446,24 @@ export class AIChatBlockPeekView extends LitElement { const isNotReady = status === 'transmitting' || status === 'loading'; const shouldRenderCopyMore = isAssistantMessage && !(isLastReply && isNotReady); - const shouldRenderActions = - isLastReply && !!message.content && !isNotReady; + const markdown = message.streamObjects?.length + ? mergeStreamContent(message.streamObjects) + : message.content; + const shouldRenderActions = isLastReply && !!markdown && !isNotReady; const messageClasses = classMap({ 'assistant-message-container': isAssistantMessage, }); - const { attachments, role, content, userId, userName, avatarUrl } = - message; + if (status === 'loading' && isLastReply) { + return html``; + } return html`
${shouldRenderError ? AIChatErrorRenderer(host, error) : nothing} @@ -451,7 +471,7 @@ export class AIChatBlockPeekView extends LitElement { ? html` { - if (this._chatMessagesContainer) { - this._chatMessagesContainer.scrollTop = - this._chatMessagesContainer.scrollHeight; - } - }); + this._scrollToEnd(); + } + + protected override updated(changedProperties: PropertyValues) { + 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() { @@ -529,6 +561,15 @@ export class AIChatBlockPeekView extends LitElement { const { messages: currentChatMessages } = chatContext; return html`
+
+ +
${this.CurrentMessages(currentChatMessages)}
-
- -