feat(core): add reasoning icon button (#11941)

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

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

## Summary by CodeRabbit

- **New Features**
  - Introduced a toggleable AI reasoning feature in the chat interface, allowing users to enable or disable advanced reasoning during AI chat interactions.
  - Added a new reasoning button to the chat input for quick access and control.
- **Enhancements**
  - Improved chat configuration options to include reasoning settings, providing more flexibility for AI responses.
  - Streamlined network search and image upload interactions for a smoother user experience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
akumatus
2025-04-24 14:32:54 +00:00
parent fd90b2541e
commit 807cba03ee
14 changed files with 175 additions and 38 deletions

View File

@@ -134,6 +134,7 @@ declare global {
sessionId?: string;
isRootSession?: boolean;
mustSearch?: boolean;
reasoning?: boolean;
contexts?: {
docs: AIDocContextOption[];
files: AIFileContextOption[];

View File

@@ -17,7 +17,10 @@ import type {
DocDisplayConfig,
SearchMenuConfig,
} from '../components/ai-chat-chips';
import type { AINetworkSearchConfig } from '../components/ai-chat-input';
import type {
AINetworkSearchConfig,
AIReasoningConfig,
} from '../components/ai-chat-input';
import { type HistoryMessage } from '../components/ai-chat-messages';
import { AIProvider } from '../provider';
import { extractSelectedContent } from '../utils/extract';
@@ -197,6 +200,9 @@ export class ChatPanel extends SignalWatcher(
@property({ attribute: false })
accessor networkSearchConfig!: AINetworkSearchConfig;
@property({ attribute: false })
accessor reasoningConfig!: AIReasoningConfig;
@property({ attribute: false })
accessor appSidebarConfig!: AppSidebarConfig;
@@ -415,6 +421,7 @@ export class ChatPanel extends SignalWatcher(
.onHistoryCleared=${this._updateHistory}
.isVisible=${this._isSidebarOpen}
.networkSearchConfig=${this.networkSearchConfig}
.reasoningConfig=${this.reasoningConfig}
.docDisplayConfig=${this.docDisplayConfig}
.searchMenuConfig=${this.searchMenuConfig}
.trackOptions=${{

View File

@@ -28,6 +28,7 @@ import { isCollectionChip, isDocChip, isTagChip } from '../ai-chat-chips';
import type {
AIChatInputContext,
AINetworkSearchConfig,
AIReasoningConfig,
} from '../ai-chat-input';
export class AIChatComposer extends SignalWatcher(
@@ -81,6 +82,9 @@ export class AIChatComposer extends SignalWatcher(
@property({ attribute: false })
accessor networkSearchConfig!: AINetworkSearchConfig;
@property({ attribute: false })
accessor reasoningConfig!: AIReasoningConfig;
@property({ attribute: false })
accessor searchMenuConfig!: SearchMenuConfig;
@@ -125,6 +129,7 @@ export class AIChatComposer extends SignalWatcher(
.chatContextValue=${this.chatContextValue}
.updateContext=${this.updateContext}
.networkSearchConfig=${this.networkSearchConfig}
.reasoningConfig=${this.reasoningConfig}
.docDisplayConfig=${this.docDisplayConfig}
.cleanupHistories=${this._cleanupHistories}
.onChatSuccess=${this.onChatSuccess}

View File

@@ -8,6 +8,7 @@ import {
CloseIcon,
ImageIcon,
PublishIcon,
ThinkingIcon,
} from '@blocksuite/icons/lit';
import { css, html, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
@@ -30,7 +31,11 @@ import {
isTagChip,
} from '../ai-chat-chips/utils';
import type { ChatMessage } from '../ai-chat-messages';
import type { AIChatInputContext, AINetworkSearchConfig } from './type';
import type {
AIChatInputContext,
AINetworkSearchConfig,
AIReasoningConfig,
} from './type';
const MaximumImageCount = 32;
@@ -241,6 +246,9 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
@property({ attribute: false })
accessor networkSearchConfig!: AINetworkSearchConfig;
@property({ attribute: false })
accessor reasoningConfig!: AIReasoningConfig;
@property({ attribute: false })
accessor docDisplayConfig!: DocDisplayConfig;
@@ -259,16 +267,12 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
private get _isNetworkActive() {
return (
!!this.networkSearchConfig.visible.value &&
!!this.networkSearchConfig.enabled.value &&
!this._isNetworkDisabled
!!this.networkSearchConfig.enabled.value
);
}
private get _isNetworkDisabled() {
return (
!!this.chatContextValue.images.length ||
!!this.chips.filter(chip => chip.state === 'finished').length
);
private get _isReasoningActive() {
return !!this.reasoningConfig.enabled.value;
}
private get _isClearDisabled() {
@@ -299,7 +303,6 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
const { images, status } = this.chatContextValue;
const hasImages = images.length > 0;
const maxHeight = hasImages ? 272 + 2 : 200 + 2;
const uploadDisabled = this._isNetworkActive;
return html` <div
class="chat-panel-input"
data-if-focused=${this.focused}
@@ -364,7 +367,6 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
<div
class="chat-network-search"
data-testid="chat-network-search"
aria-disabled=${this._isNetworkDisabled}
data-active=${this._isNetworkActive}
@click=${this._toggleNetworkSearch}
@pointerdown=${stopPropagation}
@@ -373,12 +375,20 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
</div>
`
: nothing}
<div
class="chat-network-search"
data-testid="chat-reasoning"
data-active=${this._isReasoningActive}
@click=${this._toggleReasoning}
@pointerdown=${stopPropagation}
>
${ThinkingIcon()}
</div>
${images.length < MaximumImageCount
? html`<div
data-testid="chat-panel-input-image-upload"
class="image-upload"
aria-disabled=${uploadDisabled}
@click=${uploadDisabled ? undefined : this._uploadImageFiles}
@click=${this._uploadImageFiles}
>
${ImageIcon()}
</div>`
@@ -457,13 +467,18 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
e.preventDefault();
e.stopPropagation();
if (this._isNetworkDisabled) {
return;
}
const enable = this.networkSearchConfig.enabled.value;
this.networkSearchConfig.setEnabled(!enable);
};
private readonly _toggleReasoning = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const enable = this.reasoningConfig.enabled.value;
this.reasoningConfig.setEnabled(!enable);
};
private _addImages(images: File[]) {
const oldImages = this.chatContextValue.images;
this.updateContext({
@@ -545,6 +560,7 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
where: this.trackOptions.where,
control: this.trackOptions.control,
mustSearch: this._isNetworkActive,
reasoning: this._isReasoningActive,
});
for await (const text of stream) {

View File

@@ -9,6 +9,11 @@ export interface AINetworkSearchConfig {
setEnabled: (state: boolean) => void;
}
export interface AIReasoningConfig {
enabled: Signal<boolean | undefined>;
setEnabled: (state: boolean) => void;
}
// TODO: remove this type
export type AIChatInputContext = {
messages: HistoryMessage[];

View File

@@ -28,7 +28,10 @@ import type {
DocDisplayConfig,
SearchMenuConfig,
} from '../components/ai-chat-chips';
import type { AINetworkSearchConfig } from '../components/ai-chat-input';
import type {
AINetworkSearchConfig,
AIReasoningConfig,
} from '../components/ai-chat-input';
import type { ChatMessage } from '../components/ai-chat-messages';
import { ChatMessagesSchema } from '../components/ai-chat-messages';
import type { TextRendererOptions } from '../components/text-renderer';
@@ -519,6 +522,7 @@ export class AIChatBlockPeekView extends LitElement {
control: 'chat-send',
}}
.portalContainer=${this.parentElement}
.reasoningConfig=${this.reasoningConfig}
></ai-chat-composer>
</div> `;
}
@@ -535,6 +539,9 @@ export class AIChatBlockPeekView extends LitElement {
@property({ attribute: false })
accessor networkSearchConfig!: AINetworkSearchConfig;
@property({ attribute: false })
accessor reasoningConfig!: AIReasoningConfig;
@property({ attribute: false })
accessor docDisplayConfig!: DocDisplayConfig;
@@ -568,7 +575,8 @@ export const AIChatBlockPeekViewTemplate = (
host: EditorHost,
docDisplayConfig: DocDisplayConfig,
searchMenuConfig: SearchMenuConfig,
networkSearchConfig: AINetworkSearchConfig
networkSearchConfig: AINetworkSearchConfig,
reasoningConfig: AIReasoningConfig
) => {
return html`<ai-chat-block-peek-view
.blockModel=${blockModel}
@@ -576,5 +584,6 @@ export const AIChatBlockPeekViewTemplate = (
.networkSearchConfig=${networkSearchConfig}
.docDisplayConfig=${docDisplayConfig}
.searchMenuConfig=${searchMenuConfig}
.reasoningConfig=${reasoningConfig}
></ai-chat-block-peek-view>`;
};

View File

@@ -350,15 +350,21 @@ export class CopilotClient {
async chatText({
sessionId,
messageId,
reasoning,
signal,
}: {
sessionId: string;
messageId?: string;
reasoning?: boolean;
signal?: AbortSignal;
}) {
let url = `/api/copilot/chat/${sessionId}`;
if (messageId) {
url += `?messageId=${encodeURIComponent(messageId)}`;
const queryString = this.paramsToQueryString({
messageId,
reasoning,
});
if (queryString) {
url += `?${queryString}`;
}
const response = await this.fetcher(url.toString(), { signal });
return response.text();
@@ -369,15 +375,21 @@ export class CopilotClient {
{
sessionId,
messageId,
reasoning,
}: {
sessionId: string;
messageId?: string;
reasoning?: boolean;
},
endpoint = 'stream'
) {
let url = `/api/copilot/chat/${sessionId}/${endpoint}`;
if (messageId) {
url += `?messageId=${encodeURIComponent(messageId)}`;
const queryString = this.paramsToQueryString({
messageId,
reasoning,
});
if (queryString) {
url += `?${queryString}`;
}
return this.eventSource(url);
}
@@ -390,17 +402,27 @@ export class CopilotClient {
endpoint = 'images'
) {
let url = `/api/copilot/chat/${sessionId}/${endpoint}`;
if (messageId || seed) {
url += '?';
url += new URLSearchParams(
Object.fromEntries(
Object.entries({ messageId, seed }).filter(
([_, v]) => v !== undefined
)
) as Record<string, string>
).toString();
const queryString = this.paramsToQueryString({
messageId,
seed,
});
if (queryString) {
url += `?${queryString}`;
}
return this.eventSource(url);
}
paramsToQueryString(params: Record<string, string | boolean | undefined>) {
const queryString = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (typeof value === 'boolean') {
if (value) {
queryString.append(key, 'true');
}
} else if (typeof value === 'string') {
queryString.append(key, value);
}
});
return queryString.toString();
}
}

View File

@@ -19,6 +19,7 @@ export type TextToTextOptions = {
workflow?: boolean;
isRootSession?: boolean;
postfix?: (text: string) => string;
reasoning?: boolean;
};
export type ToImageOptions = TextToTextOptions & {
@@ -113,6 +114,7 @@ export function textToText({
retry = false,
workflow = false,
postfix,
reasoning,
}: TextToTextOptions) {
let messageId: string | undefined;
@@ -132,6 +134,7 @@ export function textToText({
{
sessionId,
messageId,
reasoning,
},
workflow ? 'workflow' : undefined
);
@@ -191,6 +194,7 @@ export function textToText({
return client.chatText({
sessionId,
messageId,
reasoning,
});
})(),
]);

View File

@@ -1,5 +1,6 @@
// packages/frontend/core/src/blocksuite/ai/hooks/useChatPanelConfig.ts
import { AINetworkSearchService } from '@affine/core/modules/ai-button/services/network-search';
import { AIReasoningService } from '@affine/core/modules/ai-button/services/reasoning';
import { CollectionService } from '@affine/core/modules/collection';
import { DocsService } from '@affine/core/modules/doc';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
@@ -19,6 +20,7 @@ export function useAIChatConfig() {
const framework = useFramework();
const searchService = framework.get(AINetworkSearchService);
const reasoningService = framework.get(AIReasoningService);
const docDisplayMetaService = framework.get(DocDisplayMetaService);
const workspaceService = framework.get(WorkspaceService);
const searchMenuService = framework.get(SearchMenuService);
@@ -33,6 +35,11 @@ export function useAIChatConfig() {
setEnabled: searchService.setEnabled,
};
const reasoningConfig = {
enabled: reasoningService.enabled,
setEnabled: reasoningService.setEnabled,
};
const docDisplayConfig = {
getIcon: (docId: string) => {
return docDisplayMetaService.icon$(docId, { type: 'lit' }).value;
@@ -114,6 +121,7 @@ export function useAIChatConfig() {
return {
networkSearchConfig,
reasoningConfig,
docDisplayConfig,
searchMenuConfig,
};

View File

@@ -41,8 +41,12 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
}
}, [onLoad, ref]);
const { docDisplayConfig, searchMenuConfig, networkSearchConfig } =
useAIChatConfig();
const {
docDisplayConfig,
searchMenuConfig,
networkSearchConfig,
reasoningConfig,
} = useAIChatConfig();
useEffect(() => {
if (!editor || !editor.host) return;
@@ -67,6 +71,7 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
chatPanelRef.current.docDisplayConfig = docDisplayConfig;
chatPanelRef.current.searchMenuConfig = searchMenuConfig;
chatPanelRef.current.networkSearchConfig = networkSearchConfig;
chatPanelRef.current.reasoningConfig = reasoningConfig;
chatPanelRef.current.extensions = editor.host.std
.get(ViewExtensionManagerIdentifier)
.get('preview-page');
@@ -98,6 +103,7 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
framework,
networkSearchConfig,
searchMenuConfig,
reasoningConfig,
]);
return <div className={styles.root} ref={containerRef} />;

View File

@@ -8,6 +8,7 @@ import { GlobalStateService } from '../storage';
import { AIButtonProvider } from './provider/ai-button';
import { AIButtonService } from './services/ai-button';
import { AINetworkSearchService } from './services/network-search';
import { AIReasoningService } from './services/reasoning';
export const configureAIButtonModule = (framework: Framework) => {
framework.service(AIButtonService, container => {
@@ -21,3 +22,7 @@ export function configureAINetworkSearchModule(framework: Framework) {
FeatureFlagService,
]);
}
export function configureAIReasoningModule(framework: Framework) {
framework.service(AIReasoningService, [GlobalStateService]);
}

View File

@@ -0,0 +1,34 @@
import {
createSignalFromObservable,
type Signal,
} from '@blocksuite/affine/shared/utils';
import { LiveData, Service } from '@toeverything/infra';
import type { GlobalStateService } from '../../storage';
const AI_REASONING_KEY = 'AIReasoning';
export class AIReasoningService extends Service {
constructor(private readonly globalStateService: GlobalStateService) {
super();
const { signal: enabled, cleanup: enabledCleanup } =
createSignalFromObservable<boolean | undefined>(
this._enabled$,
undefined
);
this.enabled = enabled;
this.disposables.push(enabledCleanup);
}
enabled: Signal<boolean | undefined>;
private readonly _enabled$ = LiveData.from(
this.globalStateService.globalState.watch<boolean>(AI_REASONING_KEY),
undefined
);
setEnabled = (enabled: boolean) => {
this.globalStateService.globalState.set(AI_REASONING_KEY, enabled);
};
}

View File

@@ -4,6 +4,7 @@ import { type Framework } from '@toeverything/infra';
import {
configureAIButtonModule,
configureAINetworkSearchModule,
configureAIReasoningModule,
} from './ai-button';
import { configureAppSidebarModule } from './app-sidebar';
import { configAtMenuConfigModule } from './at-menu-config';
@@ -101,6 +102,7 @@ export function configureCommonModules(framework: Framework) {
configureDndModule(framework);
configureCommonGlobalStorageImpls(framework);
configureAINetworkSearchModule(framework);
configureAIReasoningModule(framework);
configureAIButtonModule(framework);
configureTemplateDocModule(framework);
configureBlobManagementModule(framework);

View File

@@ -14,16 +14,29 @@ export const AIChatBlockPeekView = ({
model,
host,
}: AIChatBlockPeekViewProps) => {
const { docDisplayConfig, searchMenuConfig, networkSearchConfig } =
useAIChatConfig();
const {
docDisplayConfig,
searchMenuConfig,
networkSearchConfig,
reasoningConfig,
} = useAIChatConfig();
return useMemo(() => {
const template = AIChatBlockPeekViewTemplate(
model,
host,
docDisplayConfig,
searchMenuConfig,
networkSearchConfig
networkSearchConfig,
reasoningConfig
);
return toReactNode(template);
}, [model, host, docDisplayConfig, searchMenuConfig, networkSearchConfig]);
}, [
model,
host,
docDisplayConfig,
searchMenuConfig,
networkSearchConfig,
reasoningConfig,
]);
};