akumatus
2025-04-24 14:32:54 +00:00
parent 807cba03ee
commit 3d2f0e7b5b
10 changed files with 235 additions and 115 deletions

View File

@@ -5,7 +5,6 @@ import { SignalWatcher, 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';
import type { ExtensionType, Store } from '@blocksuite/affine/store'; import type { ExtensionType, Store } from '@blocksuite/affine/store';
import { HelpIcon } from '@blocksuite/icons/lit';
import { type Signal, signal } from '@preact/signals-core'; import { type Signal, signal } from '@preact/signals-core';
import { css, html, type PropertyValues } from 'lit'; import { css, html, type PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js'; import { property, state } from 'lit/decorators.js';
@@ -68,21 +67,12 @@ export class ChatPanel extends SignalWatcher(
align-items: center; align-items: center;
z-index: 1; z-index: 1;
div:first-child { .chat-panel-title-text {
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: var(--affine-text-secondary-color); color: var(--affine-text-secondary-color);
} }
div:last-child {
width: 24px;
height: 24px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
svg { svg {
width: 18px; width: 18px;
height: 18px; height: 18px;
@@ -385,20 +375,20 @@ export class ChatPanel extends SignalWatcher(
return html`<div class="chat-panel-container" style=${style}> return html`<div class="chat-panel-container" style=${style}>
<div class="chat-panel-title"> <div class="chat-panel-title">
<div> <div class="chat-panel-title-text">
${isEmbedding ${isEmbedding
? html`<span data-testid="chat-panel-embedding-progress" ? html`<span data-testid="chat-panel-embedding-progress"
>Embedding ${done}/${total}</span >Embedding ${done}/${total}</span
>` >`
: 'AFFiNE AI'} : 'AFFiNE AI'}
</div> </div>
<div <ai-history-clear
@click=${() => { .host=${this.host}
AIProvider.toggleGeneralAIOnboarding?.(true); .doc=${this.doc}
}} .getSessionId=${this._getSessionId}
> .onHistoryCleared=${this._updateHistory}
${HelpIcon()} .chatContextValue=${this.chatContextValue}
</div> ></ai-history-clear>
</div> </div>
<chat-panel-messages <chat-panel-messages
${ref(this._chatMessagesRef)} ${ref(this._chatMessagesRef)}
@@ -418,7 +408,6 @@ export class ChatPanel extends SignalWatcher(
.chatContextValue=${this.chatContextValue} .chatContextValue=${this.chatContextValue}
.updateContext=${this.updateContext} .updateContext=${this.updateContext}
.updateEmbeddingProgress=${this._updateEmbeddingProgress} .updateEmbeddingProgress=${this._updateEmbeddingProgress}
.onHistoryCleared=${this._updateHistory}
.isVisible=${this._isSidebarOpen} .isVisible=${this._isSidebarOpen}
.networkSearchConfig=${this.networkSearchConfig} .networkSearchConfig=${this.networkSearchConfig}
.reasoningConfig=${this.reasoningConfig} .reasoningConfig=${this.reasoningConfig}
@@ -428,6 +417,7 @@ export class ChatPanel extends SignalWatcher(
where: 'chat-panel', where: 'chat-panel',
control: 'chat-send', control: 'chat-send',
}} }}
.sideBarWidth=${this._sidebarWidth}
></ai-chat-composer> ></ai-chat-composer>
</div>`; </div>`;
} }

View File

@@ -5,7 +5,6 @@ import type {
CopilotDocType, CopilotDocType,
} from '@affine/graphql'; } from '@affine/graphql';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { NotificationProvider } from '@blocksuite/affine/shared/services';
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';
import type { Store } from '@blocksuite/affine/store'; import type { Store } from '@blocksuite/affine/store';
@@ -65,9 +64,6 @@ export class AIChatComposer extends SignalWatcher(
@property({ attribute: false }) @property({ attribute: false })
accessor updateContext!: (context: Partial<AIChatInputContext>) => void; accessor updateContext!: (context: Partial<AIChatInputContext>) => void;
@property({ attribute: false })
accessor onHistoryCleared: (() => void) | undefined;
@property({ attribute: false }) @property({ attribute: false })
accessor isVisible: Signal<boolean | undefined> = signal(false); accessor isVisible: Signal<boolean | undefined> = signal(false);
@@ -97,6 +93,9 @@ export class AIChatComposer extends SignalWatcher(
@property({ attribute: false }) @property({ attribute: false })
accessor portalContainer: HTMLElement | null = null; accessor portalContainer: HTMLElement | null = null;
@property({ attribute: false })
accessor sideBarWidth: Signal<number | undefined> = signal(undefined);
@state() @state()
accessor chips: ChatChip[] = []; accessor chips: ChatChip[] = [];
@@ -131,9 +130,9 @@ export class AIChatComposer extends SignalWatcher(
.networkSearchConfig=${this.networkSearchConfig} .networkSearchConfig=${this.networkSearchConfig}
.reasoningConfig=${this.reasoningConfig} .reasoningConfig=${this.reasoningConfig}
.docDisplayConfig=${this.docDisplayConfig} .docDisplayConfig=${this.docDisplayConfig}
.cleanupHistories=${this._cleanupHistories}
.onChatSuccess=${this.onChatSuccess} .onChatSuccess=${this.onChatSuccess}
.trackOptions=${this.trackOptions} .trackOptions=${this.trackOptions}
.sideBarWidth=${this.sideBarWidth}
></ai-chat-input> ></ai-chat-input>
<div class="chat-panel-footer"> <div class="chat-panel-footer">
${InformationIcon()} ${InformationIcon()}
@@ -354,36 +353,6 @@ export class AIChatComposer extends SignalWatcher(
this._pollAbortController = null; this._pollAbortController = null;
}; };
private readonly _cleanupHistories = async () => {
const sessionId = await this.getSessionId();
const notification = this.host.std.getOptional(NotificationProvider);
if (!notification) return;
try {
if (
await notification.confirm({
title: 'Clear History',
message:
'Are you sure you want to clear all history? This action will permanently delete all content, including all chat logs and data, and cannot be undone.',
confirmText: 'Confirm',
cancelText: 'Cancel',
})
) {
const actionIds = this.chatContextValue.messages
.filter(item => 'sessionId' in item)
.map(item => item.sessionId);
await AIProvider.histories?.cleanup(
this.doc.workspace.id,
this.doc.id,
[...(sessionId ? [sessionId] : []), ...(actionIds || [])]
);
notification.toast('History cleared');
this.onHistoryCleared?.();
}
} catch {
notification.toast('Failed to clear history');
}
};
private readonly _initComposer = async () => { private readonly _initComposer = async () => {
if (!this.isVisible.value) return; if (!this.isVisible.value) return;
if (this._isLoading) return; if (this._isLoading) return;

View File

@@ -4,12 +4,12 @@ import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { openFileOrFiles } from '@blocksuite/affine/shared/utils'; import { openFileOrFiles } from '@blocksuite/affine/shared/utils';
import type { EditorHost } from '@blocksuite/affine/std'; import type { EditorHost } from '@blocksuite/affine/std';
import { import {
BroomIcon,
CloseIcon, CloseIcon,
ImageIcon, ImageIcon,
PublishIcon, PublishIcon,
ThinkingIcon, ThinkingIcon,
} from '@blocksuite/icons/lit'; } from '@blocksuite/icons/lit';
import { type Signal, signal } from '@preact/signals-core';
import { css, html, LitElement, nothing } from 'lit'; import { css, html, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js'; import { property, query, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js'; import { repeat } from 'lit/directives/repeat.js';
@@ -118,46 +118,55 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
gap: 8px; gap: 8px;
align-items: center; align-items: center;
div { .chat-input-icon {
width: 24px;
height: 24px;
cursor: pointer; cursor: pointer;
} padding: 2px;
div:nth-child(2) {
margin-left: auto;
}
.image-upload,
.chat-history-clear,
.chat-network-search {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
border-radius: 4px;
svg { svg {
width: 20px; width: 20px;
height: 20px; height: 20px;
color: ${unsafeCSSVarV2('icon/primary')}; color: ${unsafeCSSVarV2('icon/primary')};
} }
.chat-input-icon-label {
font-size: 14px;
line-height: 22px;
font-weight: 500;
color: ${unsafeCSSVarV2('icon/primary')};
margin: 0 4px 0 4px;
}
} }
.chat-history-clear svg { .chat-input-icon:nth-child(2) {
color: var(--affine-text-secondary-color); margin-left: auto;
}
.chat-network-search[data-active='true'] svg {
color: ${unsafeCSSVarV2('icon/activated')};
} }
.chat-history-clear[aria-disabled='true'], .chat-input-icon:hover {
.image-upload[aria-disabled='true'], background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
.chat-network-search[aria-disabled='true'] { }
.chat-input-icon[data-active='true'] {
background-color: #1e96eb14;
svg {
color: ${unsafeCSSVarV2('icon/activated')};
}
.chat-input-icon-label {
color: ${unsafeCSSVarV2('icon/activated')};
}
}
.chat-input-icon[aria-disabled='true'] {
cursor: not-allowed; cursor: not-allowed;
}
.chat-history-clear[aria-disabled='true'] svg, svg {
.image-upload[aria-disabled='true'] svg, color: ${unsafeCSSVarV2('icon/secondary')} !important;
.chat-network-search[aria-disabled='true'] svg { }
color: var(--affine-text-disable-color) !important;
} }
} }
@@ -240,9 +249,6 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
@property({ attribute: false }) @property({ attribute: false })
accessor updateContext!: (context: Partial<AIChatInputContext>) => void; accessor updateContext!: (context: Partial<AIChatInputContext>) => void;
@property({ attribute: false })
accessor cleanupHistories!: () => Promise<void>;
@property({ attribute: false }) @property({ attribute: false })
accessor networkSearchConfig!: AINetworkSearchConfig; accessor networkSearchConfig!: AINetworkSearchConfig;
@@ -264,6 +270,9 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
@property({ attribute: 'data-testid', reflect: true }) @property({ attribute: 'data-testid', reflect: true })
accessor testId = 'chat-panel-input-container'; accessor testId = 'chat-panel-input-container';
@property({ attribute: false })
accessor sideBarWidth: Signal<number | undefined> = signal(undefined);
private get _isNetworkActive() { private get _isNetworkActive() {
return ( return (
!!this.networkSearchConfig.visible.value && !!this.networkSearchConfig.visible.value &&
@@ -275,12 +284,8 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
return !!this.reasoningConfig.enabled.value; return !!this.reasoningConfig.enabled.value;
} }
private get _isClearDisabled() { private get _isImageUploadDisabled() {
return ( return this.chatContextValue.images.length >= MaximumImageCount;
this.chatContextValue.status === 'loading' ||
this.chatContextValue.status === 'transmitting' ||
!this.chatContextValue.messages.length
);
} }
override connectedCallback() { override connectedCallback() {
@@ -303,6 +308,8 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
const { images, status } = this.chatContextValue; const { images, status } = this.chatContextValue;
const hasImages = images.length > 0; const hasImages = images.length > 0;
const maxHeight = hasImages ? 272 + 2 : 200 + 2; const maxHeight = hasImages ? 272 + 2 : 200 + 2;
const showLabel = this.sideBarWidth.value && this.sideBarWidth.value > 400;
return html` <div return html` <div
class="chat-panel-input" class="chat-panel-input"
data-if-focused=${this.focused} data-if-focused=${this.focused}
@@ -355,44 +362,48 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
></textarea> ></textarea>
<div class="chat-panel-input-actions"> <div class="chat-panel-input-actions">
<div <div
class="chat-history-clear" class="chat-input-icon"
aria-disabled=${this._isClearDisabled} data-testid="chat-panel-input-image-upload"
@click=${this._handleClear} aria-disabled=${this._isImageUploadDisabled}
data-testid="chat-panel-clear" @click=${this._uploadImageFiles}
> >
${BroomIcon()} ${ImageIcon()}
<affine-tooltip>Upload</affine-tooltip>
</div> </div>
${this.networkSearchConfig.visible.value ${this.networkSearchConfig.visible.value
? html` ? html`
<div <div
class="chat-network-search" class="chat-input-icon"
data-testid="chat-network-search" data-testid="chat-network-search"
data-active=${this._isNetworkActive} data-active=${this._isNetworkActive}
@click=${this._toggleNetworkSearch} @click=${this._toggleNetworkSearch}
@pointerdown=${stopPropagation} @pointerdown=${stopPropagation}
> >
${PublishIcon()} ${PublishIcon()}
${!showLabel
? html`<affine-tooltip>Search</affine-tooltip>`
: nothing}
${showLabel
? html`<span class="chat-input-icon-label">Search</span>`
: nothing}
</div> </div>
` `
: nothing} : nothing}
<div <div
class="chat-network-search" class="chat-input-icon"
data-testid="chat-reasoning" data-testid="chat-reasoning"
data-active=${this._isReasoningActive} data-active=${this._isReasoningActive}
@click=${this._toggleReasoning} @click=${this._toggleReasoning}
@pointerdown=${stopPropagation} @pointerdown=${stopPropagation}
> >
${ThinkingIcon()} ${ThinkingIcon()}
${!showLabel
? html`<affine-tooltip>Reason</affine-tooltip>`
: nothing}
${showLabel
? html`<span class="chat-input-icon-label">Reason</span>`
: nothing}
</div> </div>
${images.length < MaximumImageCount
? html`<div
data-testid="chat-panel-input-image-upload"
class="image-upload"
@click=${this._uploadImageFiles}
>
${ImageIcon()}
</div>`
: nothing}
${status === 'transmitting' ${status === 'transmitting'
? html`<div @click=${this._handleAbort} data-testid="chat-panel-stop"> ? html`<div @click=${this._handleAbort} data-testid="chat-panel-stop">
${ChatAbortIcon} ${ChatAbortIcon}
@@ -456,13 +467,6 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
reportResponse('aborted:stop'); reportResponse('aborted:stop');
}; };
private readonly _handleClear = async () => {
if (this._isClearDisabled) {
return;
}
await this.cleanupHistories();
};
private readonly _toggleNetworkSearch = (e: MouseEvent) => { private readonly _toggleNetworkSearch = (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -493,6 +497,8 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
}; };
private readonly _uploadImageFiles = async (_e: MouseEvent) => { private readonly _uploadImageFiles = async (_e: MouseEvent) => {
if (this._isImageUploadDisabled) return;
const images = await openFileOrFiles({ const images = await openFileOrFiles({
acceptType: 'Images', acceptType: 'Images',
multiple: true, multiple: true,

View File

@@ -0,0 +1,93 @@
import { WithDisposable } from '@blocksuite/affine/global/lit';
import { NotificationProvider } from '@blocksuite/affine/shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
import type { Store } from '@blocksuite/affine/store';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import type { ChatContextValue } from '../../chat-panel/chat-context';
import { AIProvider } from '../../provider';
export class AIHistoryClear extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor chatContextValue!: ChatContextValue;
@property({ attribute: false })
accessor getSessionId!: () => Promise<string | undefined>;
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor doc!: Store;
@property({ attribute: false })
accessor onHistoryCleared!: () => void;
static override styles = css`
.chat-history-clear {
cursor: pointer;
color: ${unsafeCSSVarV2('icon/primary')};
}
.chat-history-clear[aria-disabled='true'] {
cursor: not-allowed;
color: ${unsafeCSSVarV2('icon/secondary')};
}
`;
private get _isHistoryClearDisabled() {
return (
this.chatContextValue.status === 'loading' ||
this.chatContextValue.status === 'transmitting' ||
!this.chatContextValue.messages.length
);
}
private readonly _cleanupHistories = async () => {
if (this._isHistoryClearDisabled) {
return;
}
const sessionId = await this.getSessionId();
const notification = this.host.std.getOptional(NotificationProvider);
if (!notification) return;
try {
if (
await notification.confirm({
title: 'Clear History',
message:
'Are you sure you want to clear all history? This action will permanently delete all content, including all chat logs and data, and cannot be undone.',
confirmText: 'Confirm',
cancelText: 'Cancel',
})
) {
const actionIds = this.chatContextValue.messages
.filter(item => 'sessionId' in item)
.map(item => item.sessionId);
await AIProvider.histories?.cleanup(
this.doc.workspace.id,
this.doc.id,
[...(sessionId ? [sessionId] : []), ...(actionIds || [])]
);
notification.toast('History cleared');
this.onHistoryCleared?.();
}
} catch {
notification.toast('Failed to clear history');
}
};
override render() {
return html`
<div
class="chat-history-clear"
aria-disabled=${this._isHistoryClearDisabled}
@click=${this._cleanupHistories}
data-testid="chat-panel-clear"
>
Clear
</div>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './ai-history-clear';

View File

@@ -37,7 +37,8 @@ import { ChatPanelDocChip } from './components/ai-chat-chips/doc-chip';
import { ChatPanelFileChip } from './components/ai-chat-chips/file-chip'; import { ChatPanelFileChip } from './components/ai-chat-chips/file-chip';
import { ChatPanelTagChip } from './components/ai-chat-chips/tag-chip'; import { ChatPanelTagChip } from './components/ai-chat-chips/tag-chip';
import { AIChatComposer } from './components/ai-chat-composer'; import { AIChatComposer } from './components/ai-chat-composer';
import { AIChatInput } from './components/ai-chat-input/ai-chat-input'; import { AIChatInput } from './components/ai-chat-input';
import { AIHistoryClear } from './components/ai-history-clear';
import { effects as componentAiItemEffects } from './components/ai-item'; import { effects as componentAiItemEffects } from './components/ai-item';
import { AIScrollableTextRenderer } from './components/ai-scrollable-text-renderer'; import { AIScrollableTextRenderer } from './components/ai-scrollable-text-renderer';
import { AskAIButton } from './components/ask-ai-button'; import { AskAIButton } from './components/ask-ai-button';
@@ -98,6 +99,7 @@ export function registerAIEffects() {
customElements.define('ai-chat-input', AIChatInput); customElements.define('ai-chat-input', AIChatInput);
customElements.define('ai-chat-composer', AIChatComposer); customElements.define('ai-chat-composer', AIChatComposer);
customElements.define('chat-panel-chips', ChatPanelChips); customElements.define('chat-panel-chips', ChatPanelChips);
customElements.define('ai-history-clear', AIHistoryClear);
customElements.define('chat-panel-add-popover', ChatPanelAddPopover); customElements.define('chat-panel-add-popover', ChatPanelAddPopover);
customElements.define( customElements.define(
'chat-panel-candidates-popover', 'chat-panel-candidates-popover',

View File

@@ -502,6 +502,15 @@ 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.doc}
.getSessionId=${this._getSessionId}
.onHistoryCleared=${this._onHistoryCleared}
.chatContextValue=${chatContext}
></ai-history-clear>
</div>
</div> </div>
<ai-chat-composer <ai-chat-composer
.host=${host} .host=${host}
@@ -510,7 +519,6 @@ export class AIChatBlockPeekView extends LitElement {
.createSessionId=${this._createSessionId} .createSessionId=${this._createSessionId}
.chatContextValue=${chatContext} .chatContextValue=${chatContext}
.updateContext=${updateContext} .updateContext=${updateContext}
.onHistoryCleared=${this._onHistoryCleared}
.isVisible=${this.isComposerVisible} .isVisible=${this.isComposerVisible}
.updateEmbeddingProgress=${this._updateEmbeddingProgress} .updateEmbeddingProgress=${this._updateEmbeddingProgress}
.networkSearchConfig=${networkSearchConfig} .networkSearchConfig=${networkSearchConfig}

View File

@@ -57,4 +57,11 @@ export const PeekViewStyles = css`
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
.history-clear-container {
display: flex;
align-items: center;
flex-direction: row-reverse;
width: 100%;
}
`; `;

View File

@@ -0,0 +1,30 @@
import { test } from '../base/base-test';
test.describe('AIChatWith/Reasoning', () => {
test.beforeEach(async ({ loggedInPage: page, utils }) => {
await utils.testUtils.setupTestEnvironment(page);
await utils.chatPanel.openChatPanel(page);
});
test('should support chat with reasoning', async ({
loggedInPage: page,
utils,
}) => {
await utils.chatPanel.enableReasoning(page);
await utils.chatPanel.makeChat(
page,
'How do you measure exactly 4 liters of water using a jug that only holds 3 and 5 liters?'
);
await utils.chatPanel.waitForHistory(page, [
{
role: 'user',
content:
'How do you measure exactly 4 liters of water using a jug that only holds 3 and 5 liters?',
},
{
role: 'assistant',
status: 'success',
},
]);
});
});

View File

@@ -305,6 +305,20 @@ export class ChatPanelUtils {
} }
} }
public static async enableReasoning(page: Page) {
const reasoning = page.getByTestId('chat-reasoning');
if ((await reasoning.getAttribute('data-active')) === 'false') {
await reasoning.click();
}
}
public static async disableReasoning(page: Page) {
const reasoning = page.getByTestId('chat-reasoning');
if ((await reasoning.getAttribute('data-active')) === 'true') {
await reasoning.click();
}
}
public static async isNetworkSearchEnabled(page: Page) { public static async isNetworkSearchEnabled(page: Page) {
const networkSearch = await page.getByTestId('chat-network-search'); const networkSearch = await page.getByTestId('chat-network-search');
return (await networkSearch.getAttribute('aria-disabled')) === 'false'; return (await networkSearch.getAttribute('aria-disabled')) === 'false';