feat(core): add ai playground components (#12588)
Close [AI-86](https://linear.app/affine-design/issue/AI-86)  <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced an AI Playground accessible from the chat panel, allowing users to experiment with AI chat sessions in a dedicated modal interface. - Added a playground icon to the chat panel for quick access to the new playground feature. - Added new interactive components for managing AI chat sessions, including chat panels, session lists, and modal dialogs. - **Improvements** - Enhanced chat panel session management for a smoother experience by simplifying session filtering. - Updated property names in chat input and composer components for improved clarity and consistency. - Made tracking options optional in chat input and composer components to improve flexibility. - **Bug Fixes** - Corrected property bindings in AI chat composer to ensure proper panel sizing. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -85,8 +85,8 @@ declare global {
|
|||||||
// internal context
|
// internal context
|
||||||
host: EditorHost;
|
host: EditorHost;
|
||||||
models?: (BlockModel | GfxModel)[];
|
models?: (BlockModel | GfxModel)[];
|
||||||
control: TrackerControl;
|
control?: TrackerControl;
|
||||||
where: TrackerWhere;
|
where?: TrackerWhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AIForkChatSessionOptions {
|
interface AIForkChatSessionOptions {
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import './chat-panel-messages';
|
|||||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||||
import type { ContextEmbedStatus, CopilotSessionType } from '@affine/graphql';
|
import type { ContextEmbedStatus, CopilotSessionType } from '@affine/graphql';
|
||||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||||
|
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
||||||
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 { CenterPeekIcon } 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, nothing, type PropertyValues } from 'lit';
|
||||||
import { property, state } from 'lit/decorators.js';
|
import { property, state } from 'lit/decorators.js';
|
||||||
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
|
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
|
||||||
import { styleMap } from 'lit/directives/style-map.js';
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
@@ -23,6 +25,7 @@ import type {
|
|||||||
AIReasoningConfig,
|
AIReasoningConfig,
|
||||||
} from '../components/ai-chat-input';
|
} from '../components/ai-chat-input';
|
||||||
import { type HistoryMessage } from '../components/ai-chat-messages';
|
import { type HistoryMessage } from '../components/ai-chat-messages';
|
||||||
|
import { createPlaygroundModal } from '../components/playground/modal';
|
||||||
import { AIProvider } from '../provider';
|
import { AIProvider } from '../provider';
|
||||||
import { extractSelectedContent } from '../utils/extract';
|
import { extractSelectedContent } from '../utils/extract';
|
||||||
import {
|
import {
|
||||||
@@ -104,6 +107,20 @@ export class ChatPanel extends SignalWatcher(
|
|||||||
.chat-panel-hints :nth-child(2) {
|
.chat-panel-hints :nth-child(2) {
|
||||||
color: var(--affine-text-secondary-color);
|
color: var(--affine-text-secondary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-panel-playground {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
margin-left: 8px;
|
||||||
|
margin-right: auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-playground:hover svg {
|
||||||
|
color: ${unsafeCSSVarV2('icon/activated')};
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
private readonly _chatMessagesRef: Ref<ChatPanelMessages> =
|
private readonly _chatMessagesRef: Ref<ChatPanelMessages> =
|
||||||
@@ -165,13 +182,7 @@ export class ChatPanel extends SignalWatcher(
|
|||||||
this.doc.id,
|
this.doc.id,
|
||||||
{ action: false }
|
{ action: false }
|
||||||
)) || []
|
)) || []
|
||||||
).filter(session => {
|
).filter(session => !session.parentSessionId);
|
||||||
if (this.parentSessionId) {
|
|
||||||
return session.parentSessionId === this.parentSessionId;
|
|
||||||
} else {
|
|
||||||
return !session.parentSessionId;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.session = sessions.at(-1);
|
this.session = sessions.at(-1);
|
||||||
return this.session?.id;
|
return this.session?.id;
|
||||||
};
|
};
|
||||||
@@ -224,9 +235,6 @@ export class ChatPanel extends SignalWatcher(
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor affineFeatureFlagService!: FeatureFlagService;
|
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
accessor parentSessionId: string | undefined = undefined;
|
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor isLoading = false;
|
accessor isLoading = false;
|
||||||
|
|
||||||
@@ -277,6 +285,25 @@ export class ChatPanel extends SignalWatcher(
|
|||||||
this.embeddingProgress = [0, 0];
|
this.embeddingProgress = [0, 0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private readonly _openPlayground = () => {
|
||||||
|
const playgroundContent = html`
|
||||||
|
<playground-content
|
||||||
|
.host=${this.host}
|
||||||
|
.doc=${this.doc}
|
||||||
|
.networkSearchConfig=${this.networkSearchConfig}
|
||||||
|
.reasoningConfig=${this.reasoningConfig}
|
||||||
|
.modelSwitchConfig=${this.modelSwitchConfig}
|
||||||
|
.appSidebarConfig=${this.appSidebarConfig}
|
||||||
|
.searchMenuConfig=${this.searchMenuConfig}
|
||||||
|
.docDisplayConfig=${this.docDisplayConfig}
|
||||||
|
.extensions=${this.extensions}
|
||||||
|
.affineFeatureFlagService=${this.affineFeatureFlagService}
|
||||||
|
></playground-content>
|
||||||
|
`;
|
||||||
|
|
||||||
|
createPlaygroundModal(playgroundContent, 'AI Playground');
|
||||||
|
};
|
||||||
|
|
||||||
protected override willUpdate(_changedProperties: PropertyValues) {
|
protected override willUpdate(_changedProperties: PropertyValues) {
|
||||||
if (_changedProperties.has('doc')) {
|
if (_changedProperties.has('doc')) {
|
||||||
this._resetPanel();
|
this._resetPanel();
|
||||||
@@ -404,6 +431,13 @@ export class ChatPanel extends SignalWatcher(
|
|||||||
>`
|
>`
|
||||||
: 'AFFiNE AI'}
|
: 'AFFiNE AI'}
|
||||||
</div>
|
</div>
|
||||||
|
${this.modelSwitchConfig.visible.value
|
||||||
|
? html`
|
||||||
|
<div class="chat-panel-playground" @click=${this._openPlayground}>
|
||||||
|
${CenterPeekIcon()}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
<ai-history-clear
|
<ai-history-clear
|
||||||
.host=${this.host}
|
.host=${this.host}
|
||||||
.doc=${this.doc}
|
.doc=${this.doc}
|
||||||
@@ -442,7 +476,7 @@ export class ChatPanel extends SignalWatcher(
|
|||||||
where: 'chat-panel',
|
where: 'chat-panel',
|
||||||
control: 'chat-send',
|
control: 'chat-send',
|
||||||
}}
|
}}
|
||||||
.sideBarWidth=${this._sidebarWidth}
|
.panelWidth=${this._sidebarWidth}
|
||||||
></ai-chat-composer>
|
></ai-chat-composer>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,13 +97,13 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
accessor onChatSuccess: (() => void) | undefined;
|
accessor onChatSuccess: (() => void) | undefined;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor trackOptions!: BlockSuitePresets.TrackerOptions;
|
accessor trackOptions: BlockSuitePresets.TrackerOptions | undefined;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor portalContainer: HTMLElement | null = null;
|
accessor portalContainer: HTMLElement | null = null;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor sideBarWidth: Signal<number | undefined> = signal(undefined);
|
accessor panelWidth: Signal<number | undefined> = signal(undefined);
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor chips: ChatChip[] = [];
|
accessor chips: ChatChip[] = [];
|
||||||
@@ -144,7 +144,7 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
.docDisplayConfig=${this.docDisplayConfig}
|
.docDisplayConfig=${this.docDisplayConfig}
|
||||||
.onChatSuccess=${this.onChatSuccess}
|
.onChatSuccess=${this.onChatSuccess}
|
||||||
.trackOptions=${this.trackOptions}
|
.trackOptions=${this.trackOptions}
|
||||||
.sideBarWidth=${this.sideBarWidth}
|
.panelWidth=${this.panelWidth}
|
||||||
.addImages=${this.addImages}
|
.addImages=${this.addImages}
|
||||||
></ai-chat-input>
|
></ai-chat-input>
|
||||||
<div class="chat-panel-footer">
|
<div class="chat-panel-footer">
|
||||||
|
|||||||
@@ -293,13 +293,13 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
|
|||||||
accessor onChatSuccess: (() => void) | undefined;
|
accessor onChatSuccess: (() => void) | undefined;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor trackOptions!: BlockSuitePresets.TrackerOptions;
|
accessor trackOptions: BlockSuitePresets.TrackerOptions | undefined;
|
||||||
|
|
||||||
@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 })
|
@property({ attribute: false })
|
||||||
accessor sideBarWidth: Signal<number | undefined> = signal(undefined);
|
accessor panelWidth: Signal<number | undefined> = signal(undefined);
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor addImages!: (images: File[]) => void;
|
accessor addImages!: (images: File[]) => void;
|
||||||
@@ -339,7 +339,7 @@ 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;
|
const showLabel = this.panelWidth.value && this.panelWidth.value > 400;
|
||||||
|
|
||||||
return html` <div
|
return html` <div
|
||||||
class="chat-panel-input"
|
class="chat-panel-input"
|
||||||
@@ -601,8 +601,8 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
|
|||||||
stream: true,
|
stream: true,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
isRootSession: this.isRootSession,
|
isRootSession: this.isRootSession,
|
||||||
where: this.trackOptions.where,
|
where: this.trackOptions?.where,
|
||||||
control: this.trackOptions.control,
|
control: this.trackOptions?.control,
|
||||||
webSearch: this._isNetworkActive,
|
webSearch: this._isNetworkActive,
|
||||||
reasoning: this._isReasoningActive,
|
reasoning: this._isReasoningActive,
|
||||||
modelId: this.modelId,
|
modelId: this.modelId,
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||||
|
import type { ContextEmbedStatus, CopilotSessionType } from '@affine/graphql';
|
||||||
|
import { SignalWatcher, 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, Store } from '@blocksuite/affine/store';
|
||||||
|
import { DeleteIcon, NewPageIcon } from '@blocksuite/icons/lit';
|
||||||
|
import { type Signal, signal } from '@preact/signals-core';
|
||||||
|
import { css, html, type PropertyValues } from 'lit';
|
||||||
|
import { property, state } from 'lit/decorators.js';
|
||||||
|
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
|
||||||
|
import { throttle } from 'lodash-es';
|
||||||
|
|
||||||
|
import type { AppSidebarConfig } from '../../chat-panel/chat-config';
|
||||||
|
import type { ChatContextValue } from '../../chat-panel/chat-context';
|
||||||
|
import type { ChatPanelMessages } from '../../chat-panel/chat-panel-messages';
|
||||||
|
import { AIProvider } from '../../provider';
|
||||||
|
import type { DocDisplayConfig, SearchMenuConfig } from '../ai-chat-chips';
|
||||||
|
import type {
|
||||||
|
AIModelSwitchConfig,
|
||||||
|
AINetworkSearchConfig,
|
||||||
|
AIReasoningConfig,
|
||||||
|
} from '../ai-chat-input';
|
||||||
|
import { type HistoryMessage } from '../ai-chat-messages';
|
||||||
|
|
||||||
|
const DEFAULT_CHAT_CONTEXT_VALUE: ChatContextValue = {
|
||||||
|
quote: '',
|
||||||
|
images: [],
|
||||||
|
abortController: null,
|
||||||
|
messages: [],
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
markdown: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PlaygroundChat extends SignalWatcher(
|
||||||
|
WithDisposable(ShadowlessElement)
|
||||||
|
) {
|
||||||
|
static override styles = css`
|
||||||
|
playground-chat {
|
||||||
|
.chat-panel-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-title {
|
||||||
|
background: var(--affine-background-primary-color);
|
||||||
|
position: relative;
|
||||||
|
padding: 8px 0px;
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
.chat-panel-title-text {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--affine-text-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--affine-text-secondary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chat-panel-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-hints {
|
||||||
|
margin: 0 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--affine-border-color);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-hints :first-child {
|
||||||
|
color: var(--affine-text-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-hints :nth-child(2) {
|
||||||
|
color: var(--affine-text-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-add,
|
||||||
|
.chat-panel-delete {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-add {
|
||||||
|
margin-left: 8px;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-delete {
|
||||||
|
margin-left: 8px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-add:hover svg,
|
||||||
|
.chat-panel-delete:hover svg {
|
||||||
|
color: ${unsafeCSSVarV2('icon/activated')};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor doc!: Store;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor networkSearchConfig!: AINetworkSearchConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor reasoningConfig!: AIReasoningConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor modelSwitchConfig!: AIModelSwitchConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor appSidebarConfig!: AppSidebarConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor searchMenuConfig!: SearchMenuConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor docDisplayConfig!: DocDisplayConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor extensions!: ExtensionType[];
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor session: CopilotSessionType | undefined = undefined;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor addChat!: () => Promise<void>;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor isLoading = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor chatContextValue: ChatContextValue = DEFAULT_CHAT_CONTEXT_VALUE;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor embeddingProgress: [number, number] = [0, 0];
|
||||||
|
|
||||||
|
private readonly _isVisible: Signal<boolean | undefined> = signal(true);
|
||||||
|
|
||||||
|
private readonly _chatMessagesRef: Ref<ChatPanelMessages> =
|
||||||
|
createRef<ChatPanelMessages>();
|
||||||
|
|
||||||
|
// request counter to track the latest request
|
||||||
|
private _updateHistoryCounter = 0;
|
||||||
|
|
||||||
|
private readonly _initPanel = async () => {
|
||||||
|
const userId = (await AIProvider.userInfo)?.id;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
await this._updateHistory();
|
||||||
|
this.isLoading = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _getSessionId = async () => {
|
||||||
|
return this.session?.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _createSessionId = async () => {
|
||||||
|
return this.session?.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _updateHistory = async () => {
|
||||||
|
const { doc } = this;
|
||||||
|
|
||||||
|
const currentRequest = ++this._updateHistoryCounter;
|
||||||
|
|
||||||
|
const [histories, actions] = await Promise.all([
|
||||||
|
AIProvider.histories?.chats(doc.workspace.id, doc.id),
|
||||||
|
AIProvider.histories?.actions(doc.workspace.id, doc.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check if this is still the latest request
|
||||||
|
if (currentRequest !== this._updateHistoryCounter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages: HistoryMessage[] = actions ? [...actions] : [];
|
||||||
|
|
||||||
|
const sessionId = await this._getSessionId();
|
||||||
|
const history = histories?.find(history => history.sessionId === sessionId);
|
||||||
|
if (history) {
|
||||||
|
messages.push(...history.messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chatContextValue = {
|
||||||
|
...this.chatContextValue,
|
||||||
|
messages: messages.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
this._scrollToEnd();
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _updateEmbeddingProgress = (
|
||||||
|
count: Record<ContextEmbedStatus, number>
|
||||||
|
) => {
|
||||||
|
const total = count.finished + count.processing + count.failed;
|
||||||
|
this.embeddingProgress = [count.finished, total];
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly updateContext = (context: Partial<ChatContextValue>) => {
|
||||||
|
this.chatContextValue = { ...this.chatContextValue, ...context };
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _scrollToEnd = () => {
|
||||||
|
this._chatMessagesRef.value?.scrollToEnd();
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _throttledScrollToEnd = throttle(this._scrollToEnd, 600);
|
||||||
|
|
||||||
|
override connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
this._initPanel().catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override updated(_changedProperties: PropertyValues) {
|
||||||
|
if (
|
||||||
|
_changedProperties.has('chatContextValue') &&
|
||||||
|
(this.chatContextValue.status === 'loading' ||
|
||||||
|
this.chatContextValue.status === 'error' ||
|
||||||
|
this.chatContextValue.status === 'success')
|
||||||
|
) {
|
||||||
|
setTimeout(this._scrollToEnd, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
_changedProperties.has('chatContextValue') &&
|
||||||
|
this.chatContextValue.status === 'transmitting'
|
||||||
|
) {
|
||||||
|
this._throttledScrollToEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
const [done, total] = this.embeddingProgress;
|
||||||
|
const isEmbedding = total > 0 && done < total;
|
||||||
|
|
||||||
|
return html`<div class="chat-panel-container">
|
||||||
|
<div class="chat-panel-title">
|
||||||
|
<div class="chat-panel-title-text">
|
||||||
|
${isEmbedding
|
||||||
|
? html`<span data-testid="chat-panel-embedding-progress"
|
||||||
|
>Embedding ${done}/${total}</span
|
||||||
|
>`
|
||||||
|
: 'AFFiNE AI'}
|
||||||
|
</div>
|
||||||
|
<div class="chat-panel-add" @click=${this.addChat}>
|
||||||
|
${NewPageIcon()}
|
||||||
|
<affine-tooltip>Add chat</affine-tooltip>
|
||||||
|
</div>
|
||||||
|
<ai-history-clear
|
||||||
|
.host=${this.host}
|
||||||
|
.doc=${this.doc}
|
||||||
|
.getSessionId=${this._getSessionId}
|
||||||
|
.onHistoryCleared=${this._updateHistory}
|
||||||
|
.chatContextValue=${this.chatContextValue}
|
||||||
|
></ai-history-clear>
|
||||||
|
<div class="chat-panel-delete">${DeleteIcon()}</div>
|
||||||
|
</div>
|
||||||
|
<chat-panel-messages
|
||||||
|
${ref(this._chatMessagesRef)}
|
||||||
|
.chatContextValue=${this.chatContextValue}
|
||||||
|
.getSessionId=${this._getSessionId}
|
||||||
|
.createSessionId=${this._createSessionId}
|
||||||
|
.updateContext=${this.updateContext}
|
||||||
|
.host=${this.host}
|
||||||
|
.isLoading=${this.isLoading}
|
||||||
|
.extensions=${this.extensions}
|
||||||
|
.affineFeatureFlagService=${this.affineFeatureFlagService}
|
||||||
|
></chat-panel-messages>
|
||||||
|
<ai-chat-composer
|
||||||
|
.host=${this.host}
|
||||||
|
.doc=${this.doc}
|
||||||
|
.session=${this.session}
|
||||||
|
.getSessionId=${this._getSessionId}
|
||||||
|
.createSessionId=${this._createSessionId}
|
||||||
|
.chatContextValue=${this.chatContextValue}
|
||||||
|
.updateContext=${this.updateContext}
|
||||||
|
.updateEmbeddingProgress=${this._updateEmbeddingProgress}
|
||||||
|
.isVisible=${this._isVisible}
|
||||||
|
.networkSearchConfig=${this.networkSearchConfig}
|
||||||
|
.reasoningConfig=${this.reasoningConfig}
|
||||||
|
.modelSwitchConfig=${this.modelSwitchConfig}
|
||||||
|
.docDisplayConfig=${this.docDisplayConfig}
|
||||||
|
.searchMenuConfig=${this.searchMenuConfig}
|
||||||
|
></ai-chat-composer>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||||
|
import type { CopilotSessionType } from '@affine/graphql';
|
||||||
|
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||||
|
import type { EditorHost } from '@blocksuite/affine/std';
|
||||||
|
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||||
|
import type { ExtensionType, Store } from '@blocksuite/affine/store';
|
||||||
|
import { css, html } from 'lit';
|
||||||
|
import { property, state } from 'lit/decorators.js';
|
||||||
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
|
|
||||||
|
import type { AppSidebarConfig } from '../../chat-panel/chat-config';
|
||||||
|
import { AIProvider } from '../../provider';
|
||||||
|
import type { DocDisplayConfig, SearchMenuConfig } from '../ai-chat-chips';
|
||||||
|
import type {
|
||||||
|
AIModelSwitchConfig,
|
||||||
|
AINetworkSearchConfig,
|
||||||
|
AIReasoningConfig,
|
||||||
|
} from '../ai-chat-input';
|
||||||
|
|
||||||
|
export class PlaygroundContent extends SignalWatcher(
|
||||||
|
WithDisposable(ShadowlessElement)
|
||||||
|
) {
|
||||||
|
static override styles = css`
|
||||||
|
playground-content {
|
||||||
|
.playground-content {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat-item {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--affine-border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--affine-background-primary-color);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat-item:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-chat-item:only-child {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor doc!: Store;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor networkSearchConfig!: AINetworkSearchConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor reasoningConfig!: AIReasoningConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor modelSwitchConfig!: AIModelSwitchConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor appSidebarConfig!: AppSidebarConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor searchMenuConfig!: SearchMenuConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor docDisplayConfig!: DocDisplayConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor extensions!: ExtensionType[];
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor sessions: CopilotSessionType[] = [];
|
||||||
|
|
||||||
|
private rootSessionId: string | undefined = undefined;
|
||||||
|
|
||||||
|
private readonly _getSessions = async () => {
|
||||||
|
const sessions =
|
||||||
|
(await AIProvider.session?.getSessions(
|
||||||
|
this.doc.workspace.id,
|
||||||
|
this.doc.id,
|
||||||
|
{ action: false }
|
||||||
|
)) || [];
|
||||||
|
const rootSession = sessions
|
||||||
|
?.filter(session => !session.parentSessionId)
|
||||||
|
.at(-1);
|
||||||
|
if (!rootSession) {
|
||||||
|
// Create a new session
|
||||||
|
const rootSessionId = await AIProvider.session?.createSession({
|
||||||
|
docId: this.doc.id,
|
||||||
|
workspaceId: this.doc.workspace.id,
|
||||||
|
promptName: 'Chat With AFFiNE AI',
|
||||||
|
});
|
||||||
|
if (rootSessionId) {
|
||||||
|
this.rootSessionId = rootSessionId;
|
||||||
|
const forkSession = await this._forkSession(rootSessionId);
|
||||||
|
if (forkSession) {
|
||||||
|
this.sessions = [forkSession];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.rootSessionId = rootSession.id;
|
||||||
|
const childSessions = sessions.filter(
|
||||||
|
session => session.parentSessionId === rootSession.id
|
||||||
|
);
|
||||||
|
if (childSessions.length > 0) {
|
||||||
|
this.sessions = childSessions;
|
||||||
|
} else {
|
||||||
|
const forkSession = await this._forkSession(rootSession.id);
|
||||||
|
if (forkSession) {
|
||||||
|
this.sessions = [forkSession];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _forkSession = async (parentSessionId: string) => {
|
||||||
|
const forkSessionId = await AIProvider.forkChat?.({
|
||||||
|
workspaceId: this.doc.workspace.id,
|
||||||
|
docId: this.doc.id,
|
||||||
|
sessionId: parentSessionId,
|
||||||
|
latestMessageId: '',
|
||||||
|
});
|
||||||
|
if (!forkSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return await AIProvider.session?.getSession(
|
||||||
|
this.doc.workspace.id,
|
||||||
|
forkSessionId
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _addChat = async () => {
|
||||||
|
if (!this.rootSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const forkSession = await this._forkSession(this.rootSessionId);
|
||||||
|
if (forkSession) {
|
||||||
|
this.sessions = [...this.sessions, forkSession];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
override connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
this._getSessions().catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<div class="playground-content">
|
||||||
|
${repeat(
|
||||||
|
this.sessions,
|
||||||
|
session => session.id,
|
||||||
|
session => html`
|
||||||
|
<div class="playground-chat-item">
|
||||||
|
<playground-chat
|
||||||
|
.host=${this.host}
|
||||||
|
.doc=${this.doc}
|
||||||
|
.networkSearchConfig=${this.networkSearchConfig}
|
||||||
|
.reasoningConfig=${this.reasoningConfig}
|
||||||
|
.modelSwitchConfig=${this.modelSwitchConfig}
|
||||||
|
.appSidebarConfig=${this.appSidebarConfig}
|
||||||
|
.searchMenuConfig=${this.searchMenuConfig}
|
||||||
|
.docDisplayConfig=${this.docDisplayConfig}
|
||||||
|
.extensions=${this.extensions}
|
||||||
|
.affineFeatureFlagService=${this.affineFeatureFlagService}
|
||||||
|
.session=${session}
|
||||||
|
.addChat=${this._addChat}
|
||||||
|
></playground-chat>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { PlaygroundChat } from './chat';
|
||||||
|
import { PlaygroundContent } from './content';
|
||||||
|
import { PlaygroundModal } from './modal';
|
||||||
|
|
||||||
|
export function effects() {
|
||||||
|
customElements.define('playground-chat', PlaygroundChat);
|
||||||
|
customElements.define('playground-content', PlaygroundContent);
|
||||||
|
customElements.define('playground-modal', PlaygroundModal);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||||
|
import { CloseIcon } from '@blocksuite/icons/lit';
|
||||||
|
import { css, html, type TemplateResult } from 'lit';
|
||||||
|
import { property } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A modal component for AI Playground
|
||||||
|
*/
|
||||||
|
export class PlaygroundModal extends ShadowlessElement {
|
||||||
|
static override styles = css`
|
||||||
|
playground-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 100;
|
||||||
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-modal-container {
|
||||||
|
position: relative;
|
||||||
|
width: 90%;
|
||||||
|
height: 90%;
|
||||||
|
background-color: var(--affine-background-primary-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--affine-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-modal-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--affine-text-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-modal-close {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-modal-close:hover {
|
||||||
|
background-color: var(--affine-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playground-modal-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
private _close() {
|
||||||
|
this.remove();
|
||||||
|
this.onClose?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly _handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
this._close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
override connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
document.addEventListener('keydown', this._handleKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
document.removeEventListener('keydown', this._handleKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<div class="playground-modal-container">
|
||||||
|
<div class="playground-modal-header">
|
||||||
|
<div class="playground-modal-title">${this.modalTitle}</div>
|
||||||
|
<div @click="${this._close}" class="playground-modal-close">
|
||||||
|
${CloseIcon()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="playground-modal-content">${this.content}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor modalTitle: string = '';
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor content: TemplateResult = html`
|
||||||
|
<div>Welcome to the AI Playground!</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor onClose: (() => void) | undefined = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'playground-modal': PlaygroundModal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and displays a modal with the provided content
|
||||||
|
*/
|
||||||
|
export const createPlaygroundModal = (
|
||||||
|
content: TemplateResult,
|
||||||
|
title: string
|
||||||
|
) => {
|
||||||
|
// Create the modal element
|
||||||
|
const modal = document.createElement('playground-modal') as PlaygroundModal;
|
||||||
|
modal.modalTitle = title;
|
||||||
|
modal.content = content;
|
||||||
|
|
||||||
|
// Add the modal to the document body
|
||||||
|
document.body.append(modal);
|
||||||
|
|
||||||
|
return modal;
|
||||||
|
};
|
||||||
@@ -50,6 +50,7 @@ import { AskAIToolbarButton } from './components/ask-ai-toolbar';
|
|||||||
import { ChatActionList } from './components/chat-action-list';
|
import { ChatActionList } from './components/chat-action-list';
|
||||||
import { ChatCopyMore } from './components/copy-more';
|
import { ChatCopyMore } from './components/copy-more';
|
||||||
import { ImagePreviewGrid } from './components/image-preview-grid';
|
import { ImagePreviewGrid } from './components/image-preview-grid';
|
||||||
|
import { effects as componentPlaygroundEffects } from './components/playground';
|
||||||
import { TextRenderer } from './components/text-renderer';
|
import { TextRenderer } from './components/text-renderer';
|
||||||
import { AIErrorWrapper } from './messages/error';
|
import { AIErrorWrapper } from './messages/error';
|
||||||
import { AISlidesRenderer } from './messages/slides-renderer';
|
import { AISlidesRenderer } from './messages/slides-renderer';
|
||||||
@@ -80,6 +81,7 @@ import { EdgelessCopilotToolbarEntry } from './widgets/edgeless-copilot-panel/to
|
|||||||
export function registerAIEffects() {
|
export function registerAIEffects() {
|
||||||
registerMiniMindmapBlocks();
|
registerMiniMindmapBlocks();
|
||||||
componentAiItemEffects();
|
componentAiItemEffects();
|
||||||
|
componentPlaygroundEffects();
|
||||||
|
|
||||||
customElements.define('ask-ai-icon', AskAIIcon);
|
customElements.define('ask-ai-icon', AskAIIcon);
|
||||||
customElements.define('ask-ai-button', AskAIButton);
|
customElements.define('ask-ai-button', AskAIButton);
|
||||||
|
|||||||
Reference in New Issue
Block a user