feat(core): adjust the layout, style, and structure of the AI chat input (#12828)

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

## Summary by CodeRabbit

- **New Features**
- Added support for image uploads in the chat panel, including upload
limits and user feedback when limits are exceeded.
- Introduced a unified chat input preference menu for selecting AI
models, toggling extended thinking, and enabling web search.
- Menu buttons and menus now support test identifiers for improved
testing.

- **Improvements**
- Updated chat input UI with enhanced styling, consolidated controls,
and simplified feature toggling.
  - Improved layout and spacing for chat chips and image preview grids.
  - Chat abort icon now adapts to the current color theme.

- **Refactor**
- Replaced the separate AI model selection component with the new chat
input preference menu.
- Streamlined imports and custom element registrations for chat input
preferences.

- **Tests**
- Enhanced test utilities to support the new chat input preference menu
interactions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Cats Juice
2025-06-17 09:26:29 +08:00
committed by GitHub
parent cdaaa52845
commit 2366c1aba6
13 changed files with 326 additions and 211 deletions

View File

@@ -155,7 +155,7 @@ export const ChatAbortIcon = html`<svg
fill-rule="evenodd"
clip-rule="evenodd"
d="M19.0833 11.9993C19.0833 15.9114 15.912 19.0827 12 19.0827C8.08798 19.0827 4.91667 15.9114 4.91667 11.9993C4.91667 8.08733 8.08798 4.91602 12 4.91602C15.912 4.91602 19.0833 8.08733 19.0833 11.9993ZM20.3333 11.9993C20.3333 16.6017 16.6024 20.3327 12 20.3327C7.39763 20.3327 3.66667 16.6017 3.66667 11.9993C3.66667 7.39698 7.39763 3.66602 12 3.66602C16.6024 3.66602 20.3333 7.39698 20.3333 11.9993ZM10.3333 8.66602C9.41286 8.66602 8.66667 9.41221 8.66667 10.3327V13.666C8.66667 14.5865 9.41286 15.3327 10.3333 15.3327H13.6667C14.5871 15.3327 15.3333 14.5865 15.3333 13.666V10.3327C15.3333 9.41221 14.5871 8.66602 13.6667 8.66602H10.3333Z"
fill="#1E96EB"
fill="currentColor"
/>
</g>
<defs>

View File

@@ -10,6 +10,7 @@ import { ShadowlessElement } from '@blocksuite/affine/std';
import type { DocMeta } from '@blocksuite/affine/store';
import {
CollectionsIcon,
ImageIcon,
MoreHorizontalIcon,
SearchIcon,
TagsIcon,
@@ -20,6 +21,7 @@ import { css, html, type TemplateResult } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { MAX_IMAGE_COUNT } from '../ai-chat-input';
import type { ChatChip, DocDisplayConfig, SearchMenuConfig } from './type';
enum AddPopoverMode {
@@ -182,9 +184,28 @@ export class ChatPanelAddPopover extends SignalWatcher(
this.abortController.abort();
};
private readonly _addImageChip = async () => {
if (this.isImageUploadDisabled) return;
const images = await openFilesWith('Images');
if (!images) return;
if (this.uploadImageCount + images.length > MAX_IMAGE_COUNT) {
toast(`You can only upload up to ${MAX_IMAGE_COUNT} images`);
return;
}
this.addImages(images);
};
private readonly uploadGroup: MenuGroup = {
name: 'Upload',
items: [
{
key: 'images',
name: 'Upload images',
testId: 'ai-chat-with-images',
icon: ImageIcon(),
action: this._addImageChip,
},
{
key: 'files',
name: 'Upload files (pdf, txt, csv)',
@@ -267,6 +288,12 @@ export class ChatPanelAddPopover extends SignalWatcher(
@property({ attribute: 'data-testid', reflect: true })
accessor testId: string = 'ai-search-input';
@property({ attribute: false })
accessor isImageUploadDisabled!: boolean;
@property({ attribute: false })
accessor uploadImageCount!: number;
@query('.search-input')
accessor searchInput!: HTMLInputElement;

View File

@@ -41,23 +41,27 @@ export class ChatPanelChips extends SignalWatcher(
static override styles = css`
.chips-wrapper {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin: 0 -4px 0 -4px;
padding: 4px 12px;
}
.add-button,
.collapse-button,
.more-candidate-button {
display: flex;
flex-shrink: 0;
flex-grow: 0;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
border-radius: 4px;
margin: 4px;
box-sizing: border-box;
cursor: pointer;
font-size: 12px;
color: ${unsafeCSSVarV2('icon/primary')};
}
.add-button:hover,
.collapse-button:hover,

View File

@@ -16,7 +16,6 @@ export class ChatPanelChip extends SignalWatcher(
height: 24px;
align-items: center;
justify-content: center;
margin: 4px;
padding: 0 4px;
border-radius: 4px;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};

View File

@@ -1,24 +1,18 @@
import { toast } from '@affine/component';
import { stopPropagation } from '@affine/core/utils';
import type { CopilotSessionType } from '@affine/graphql';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { openFilesWith } from '@blocksuite/affine/shared/utils';
import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
import {
CloseIcon,
ImageIcon,
PublishIcon,
ThinkingIcon,
} from '@blocksuite/icons/lit';
import { ArrowUpBigIcon, CloseIcon, ImageIcon } from '@blocksuite/icons/lit';
import { type Signal, signal } from '@preact/signals-core';
import { css, html, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { ChatAbortIcon, ChatSendIcon } from '../../_common/icons';
import { ChatAbortIcon } from '../../_common/icons';
import { type AIError, AIProvider } from '../../provider';
import { reportResponse } from '../../utils/action-reporter';
import { readBlobAsURL } from '../../utils/image';
@@ -45,20 +39,42 @@ export class AIChatInput extends SignalWatcher(
:host {
width: 100%;
}
[data-theme='dark'] .chat-panel-input {
box-shadow:
var(--border-shadow),
0px 0px 0px 0px rgba(28, 158, 228, 0),
0px 0px 0px 2px transparent;
}
[data-theme='light'] .chat-panel-input {
box-shadow:
var(--border-shadow),
0px 0px 0px 3px transparent,
0px 2px 3px rgba(0, 0, 0, 0.05);
}
[data-theme='dark'] .chat-panel-input[data-if-focused='true'] {
box-shadow:
var(--border-shadow),
0px 0px 0px 3px rgba(28, 158, 228, 0.3),
0px 2px 3px rgba(0, 0, 0, 0.05);
}
.chat-panel-input {
--input-border-width: 0.5px;
--input-border-color: var(--affine-v2-layer-insideBorder-border);
--border-shadow: 0px 0px 0px var(--input-border-width)
var(--input-border-color);
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 12px;
gap: 4px;
position: relative;
margin-top: 12px;
border-radius: 4px;
padding: 8px;
border-radius: 12px;
padding: 8px 6px 6px 8px;
min-height: 94px;
box-sizing: border-box;
border-width: 1px;
border-style: solid;
border-color: var(--affine-border-color);
transition: box-shadow 0.23s ease;
background-color: var(--affine-v2-input-background);
.chat-selection-quote {
padding: 4px 0px 8px 0px;
@@ -207,7 +223,7 @@ export class AIChatInput extends SignalWatcher(
font-size: 14px;
font-weight: 400;
font-family: var(--affine-font-family);
color: var(--affine-placeholder-color);
color: var(--affine-v2-text-placeholder);
}
textarea:focus {
@@ -216,8 +232,8 @@ export class AIChatInput extends SignalWatcher(
}
.chat-panel-input[data-if-focused='true'] {
border-color: var(--affine-primary-color);
box-shadow: var(--affine-active-shadow);
--input-border-width: 1px;
--input-border-color: var(--affine-v2-layer-insideBorder-primaryBorder);
user-select: none;
}
@@ -225,16 +241,32 @@ export class AIChatInput extends SignalWatcher(
display: flex;
justify-content: center;
align-items: center;
}
.chat-panel-send svg rect {
fill: var(--affine-primary-color);
width: 28px;
height: 28px;
flex-shrink: 0;
border-radius: 50%;
font-size: 20px;
background: var(--affine-v2-icon-activated);
color: var(--affine-v2-layer-pureWhite);
}
.chat-panel-send[aria-disabled='true'] {
cursor: not-allowed;
background: var(--affine-v2-button-disable);
}
.chat-panel-send[aria-disabled='true'] svg rect {
fill: var(--affine-text-disable-color);
.chat-panel-stop {
cursor: pointer;
width: 28px;
height: 28px;
flex-shrink: 0;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
font-size: 24px;
color: var(--affine-v2-icon-activated);
}
.chat-input-footer-spacer {
flex: 1;
}
`;
@@ -342,7 +374,6 @@ export class AIChatInput extends SignalWatcher(
const { images, status } = this.chatContextValue;
const hasImages = images.length > 0;
const maxHeight = hasImages ? 272 + 2 : 200 + 2;
const showLabel = this.panelWidth.value && this.panelWidth.value > 400;
return html` <div
class="chat-panel-input"
@@ -404,62 +435,34 @@ export class AIChatInput extends SignalWatcher(
${ImageIcon()}
<affine-tooltip>Upload</affine-tooltip>
</div>
${this.modelSwitchConfig?.visible.value
? html`
<ai-chat-models
class="chat-input-icon"
.modelId=${this.modelId}
.session=${this.session}
.onModelChange=${this._handleModelChange}
></ai-chat-models>
`
: nothing}
${this.networkSearchConfig.visible.value
? html`
<div
class="chat-input-icon"
data-testid="chat-network-search"
data-active=${this._isNetworkActive}
@click=${this._toggleNetworkSearch}
@pointerdown=${stopPropagation}
>
${PublishIcon()}
${!showLabel
? html`<affine-tooltip>Search</affine-tooltip>`
: nothing}
${showLabel
? html`<span class="chat-input-icon-label">Search</span>`
: nothing}
</div>
`
: nothing}
<div
class="chat-input-icon"
data-testid="chat-reasoning"
data-active=${this._isReasoningActive}
@click=${this._toggleReasoning}
@pointerdown=${stopPropagation}
>
${ThinkingIcon()}
${!showLabel
? html`<affine-tooltip>Reason</affine-tooltip>`
: nothing}
${showLabel
? html`<span class="chat-input-icon-label">Reason</span>`
: nothing}
</div>
<div class="chat-input-footer-spacer"></div>
<chat-input-preference
.modelSwitchConfig=${this.modelSwitchConfig}
.session=${this.session}
.onModelChange=${this._handleModelChange}
.modelId=${this.modelId}
.extendedThinking=${this._isReasoningActive}
.onExtendedThinkingChange=${this._toggleReasoning}
.networkSearchVisible=${!!this.networkSearchConfig.visible.value}
.isNetworkActive=${this._isNetworkActive}
.onNetworkActiveChange=${this._toggleNetworkSearch}
></chat-input-preference>
${status === 'transmitting' || status === 'loading'
? html`<div @click=${this._handleAbort} data-testid="chat-panel-stop">
? html`<button
class="chat-panel-stop"
@click=${this._handleAbort}
data-testid="chat-panel-stop"
>
${ChatAbortIcon}
</div>`
: html`<div
</button>`
: html`<button
@click="${this._onTextareaSend}"
class="chat-panel-send"
aria-disabled=${this.isInputEmpty}
data-testid="chat-panel-send"
>
${ChatSendIcon}
</div>`}
${ArrowUpBigIcon()}
</button>`}
</div>
</div>`;
}
@@ -512,20 +515,12 @@ export class AIChatInput extends SignalWatcher(
reportResponse('aborted:stop');
};
private readonly _toggleNetworkSearch = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const enable = this.networkSearchConfig.enabled.value;
this.networkSearchConfig.setEnabled(!enable);
private readonly _toggleNetworkSearch = (isNetworkActive: boolean) => {
this.networkSearchConfig.setEnabled(isNetworkActive);
};
private readonly _toggleReasoning = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const enable = this.reasoningConfig.enabled.value;
this.reasoningConfig.setEnabled(!enable);
private readonly _toggleReasoning = (extendedThinking: boolean) => {
this.reasoningConfig.setEnabled(extendedThinking);
};
private readonly _handleImageRemove = (index: number) => {

View File

@@ -0,0 +1,168 @@
import type { CopilotSessionType } from '@affine/graphql';
import {
menu,
popMenu,
popupTargetFromElement,
} from '@blocksuite/affine/components/context-menu';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import {
AiOutlineIcon,
ArrowDownSmallIcon,
ThinkingIcon,
WebIcon,
} from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import type { AIModelSwitchConfig } from './type';
export class ChatInputPreference extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.chat-input-preference-trigger {
display: flex;
align-items: center;
padding: 0px 4px;
color: var(--affine-v2-icon-primary);
transition: all 0.23s ease;
border-radius: 4px;
}
.chat-input-preference-trigger:hover {
background-color: var(--affine-v2-layer-background-hoverOverlay);
}
.chat-input-preference-trigger-label {
font-size: 14px;
line-height: 22px;
font-weight: 500;
padding: 0px 4px;
}
.chat-input-preference-trigger-icon {
font-size: 20px;
line-height: 0;
}
.preference-action {
white-space: nowrap;
min-width: 220px;
}
`;
@property({ attribute: false })
accessor session!: CopilotSessionType | undefined;
// --------- model props start ---------
@property({ attribute: false })
accessor modelSwitchConfig: AIModelSwitchConfig | undefined = undefined;
@property({ attribute: false })
accessor onModelChange: ((modelId: string) => void) | undefined;
@property({ attribute: false })
accessor modelId: string | undefined = undefined;
// --------- model props end ---------
// --------- extended thinking props start ---------
@property({ attribute: false })
accessor extendedThinking: boolean = false;
@property({ attribute: false })
accessor onExtendedThinkingChange:
| ((extendedThinking: boolean) => void)
| undefined;
// --------- extended thinking props end ---------
// --------- search props start ---------
@property({ attribute: false })
accessor networkSearchVisible: boolean = false;
@property({ attribute: false })
accessor isNetworkActive: boolean = false;
@property({ attribute: false })
accessor onNetworkActiveChange:
| ((isNetworkActive: boolean) => void)
| undefined;
// --------- search props end ---------
private readonly _onModelChange = (modelId: string) => {
this.onModelChange?.(modelId);
};
openPreference(e: Event) {
const element = e.currentTarget;
if (!(element instanceof HTMLElement)) return;
const modelItems = [];
const searchItems = [];
// model switch
if (this.modelSwitchConfig?.visible.value) {
modelItems.push(
menu.subMenu({
name: 'Model',
prefix: AiOutlineIcon(),
options: {
items: (this.session?.optionalModels ?? []).map(modelId => {
return menu.action({
name: modelId,
select: () => this._onModelChange(modelId),
});
}),
},
})
);
}
modelItems.push(
menu.toggleSwitch({
name: 'Extended Thinking',
prefix: ThinkingIcon(),
on: this.extendedThinking,
onChange: (value: boolean) => this.onExtendedThinkingChange?.(value),
class: { 'preference-action': true },
})
);
if (this.networkSearchVisible) {
searchItems.push(
menu.toggleSwitch({
name: 'Web Search',
prefix: WebIcon(),
on: this.isNetworkActive,
onChange: (value: boolean) => this.onNetworkActiveChange?.(value),
class: { 'preference-action': true },
testId: 'chat-network-search',
})
);
}
popMenu(popupTargetFromElement(element), {
options: {
items: [
menu.group({
items: [...modelItems],
}),
menu.group({
items: [...searchItems],
}),
],
testId: 'chat-input-preference',
},
});
}
override render() {
return html`<button
@click=${this.openPreference}
data-testid="chat-input-preference-trigger"
class="chat-input-preference-trigger"
>
<span class="chat-input-preference-trigger-label">
${this.modelId || this.session?.model}
</span>
<span class="chat-input-preference-trigger-icon">
${ArrowDownSmallIcon()}
</span>
</button>`;
}
}

View File

@@ -1,112 +0,0 @@
import type { CopilotSessionType } from '@affine/graphql';
import { createLitPortal } from '@blocksuite/affine/components/portal';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { flip, offset } from '@floating-ui/dom';
import { css, html, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
export class AIChatModels extends WithDisposable(ShadowlessElement) {
@property()
accessor modelId: string | undefined = undefined;
@property({ attribute: false })
accessor onModelChange: ((modelId: string) => void) | undefined;
@property({ attribute: false })
accessor session!: CopilotSessionType | undefined;
@query('.ai-chat-models')
accessor modelsButton!: HTMLDivElement;
private _abortController: AbortController | null = null;
static override styles = css`
ai-chat-models {
font-size: 13px;
cursor: pointer;
}
`;
private readonly _onItemClick = (modelId: string) => {
this.onModelChange?.(modelId);
this._abortController?.abort();
this._abortController = null;
};
private readonly _toggleSwitchModelMenu = () => {
if (this._abortController) {
this._abortController.abort();
return;
}
this._abortController = new AbortController();
this._abortController.signal.addEventListener('abort', () => {
this._abortController = null;
});
createLitPortal({
template: html` <style>
.ai-model-list {
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
border-radius: 4px;
background: ${unsafeCSSVarV2('layer/background/overlayPanel')};
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
padding: 8px;
}
.ai-model-item {
font-size: 13px;
padding: 4px;
cursor: pointer;
}
.ai-model-item:hover {
background: var(--affine-hover-color);
}
</style>
<div class="ai-model-list">
${repeat(
this.session?.optionalModels ?? [],
modelId => modelId,
modelId => {
return html`<div
class="ai-model-item"
@click=${() => this._onItemClick(modelId)}
>
${modelId}
</div>`;
}
)}
</div>`,
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
container: document.body,
computePosition: {
referenceElement: this.modelsButton,
placement: 'top-start',
middleware: [offset({ crossAxis: -30, mainAxis: 8 }), flip()],
autoUpdate: { animationFrame: true },
},
abortController: this._abortController,
closeOnClickAway: true,
});
};
override render() {
if (!this.session) {
return nothing;
}
return html`
<div
class="ai-chat-models"
@click=${this._toggleSwitchModelMenu}
data-testid="ai-chat-models"
>
${this.modelId || this.session.model}
</div>
`;
}
}

View File

@@ -1 +0,0 @@
export * from './ai-chat-models';

View File

@@ -18,16 +18,15 @@ export class ImagePreviewGrid extends LitElement {
.images-container {
display: flex;
flex-direction: row;
gap: 4px;
gap: 8px;
flex-wrap: nowrap;
position: relative;
}
.image-container {
width: 58px;
height: 58px;
width: 68px;
height: 68px;
border-radius: 4px;
border: 1px solid var(--affine-border-color);
cursor: pointer;
overflow: hidden;
position: relative;

View File

@@ -39,7 +39,7 @@ import { ChatPanelTagChip } from './components/ai-chat-chips/tag-chip';
import { AIChatComposer } from './components/ai-chat-composer';
import { AIChatInput } from './components/ai-chat-input';
import { AIChatEmbeddingStatusTooltip } from './components/ai-chat-input/embedding-status-tooltip';
import { AIChatModels } from './components/ai-chat-models/ai-chat-models';
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 { AIScrollableTextRenderer } from './components/ai-scrollable-text-renderer';
@@ -109,6 +109,7 @@ export function registerAIEffects() {
customElements.define('chat-panel-chips', ChatPanelChips);
customElements.define('ai-history-clear', AIHistoryClear);
customElements.define('chat-panel-add-popover', ChatPanelAddPopover);
customElements.define('chat-input-preference', ChatInputPreference);
customElements.define(
'chat-panel-candidates-popover',
ChatPanelCandidatesPopover
@@ -118,7 +119,6 @@ export function registerAIEffects() {
customElements.define('chat-panel-tag-chip', ChatPanelTagChip);
customElements.define('chat-panel-collection-chip', ChatPanelCollectionChip);
customElements.define('chat-panel-chip', ChatPanelChip);
customElements.define('ai-chat-models', AIChatModels);
customElements.define('ai-error-wrapper', AIErrorWrapper);
customElements.define('ai-slides-renderer', AISlidesRenderer);
customElements.define('ai-answer-wrapper', AIAnswerWrapper);