feat(core): add ai-chat-toolbar for independent chat (#13021)

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

* **New Features**
* Introduced an AI chat toolbar for improved session management and
interaction.
  * Added the ability to pin chat sessions and reset chat content.
  * Enhanced chat header layout for better usability.

* **Improvements**
* Streamlined session creation and management within the AI chat
interface.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Cats Juice
2025-07-04 13:16:20 +08:00
committed by GitHub
parent fe8cb6bb44
commit 2b0b20cdd4
3 changed files with 104 additions and 16 deletions

View File

@@ -310,6 +310,10 @@ export class AIChatContent extends SignalWatcher(
}
}
public reset() {
this.updateContext(DEFAULT_CHAT_CONTEXT_VALUE);
}
override connectedCallback() {
super.connectedCallback();
this.initChatContent().catch(console.error);

View File

@@ -7,3 +7,10 @@ export const chatRoot = style({
padding: '0px 16px',
margin: '0 auto',
});
export const chatHeader = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
});

View File

@@ -1,6 +1,7 @@
import { observeResize } from '@affine/component';
import { CopilotClient } from '@affine/core/blocksuite/ai';
import { AIChatContent } from '@affine/core/blocksuite/ai/components/ai-chat-content';
import { AIChatToolbar } from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
import { getCustomPageEditorBlockSpecs } from '@affine/core/blocksuite/ai/components/text-renderer';
import type { PromptKey } from '@affine/core/blocksuite/ai/provider/prompt';
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
@@ -28,6 +29,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as styles from './index.css';
type CopilotSession = Awaited<ReturnType<CopilotClient['getSession']>>;
function useCopilotClient() {
const graphqlService = useService(GraphQLService);
const eventSourceService = useService(EventSourceService);
@@ -48,10 +51,16 @@ export const Component = () => {
const t = useI18n();
const framework = useFramework();
const [isBodyProvided, setIsBodyProvided] = useState(false);
const [doc, setDoc] = useState<Doc | null>(null);
const [isHeaderProvided, setIsHeaderProvided] = useState(false);
const [host, setHost] = useState<EditorHost | null>(null);
const [chatContent, setChatContent] = useState<AIChatContent | null>(null);
const [chatTool, setChatTool] = useState<AIChatToolbar | null>(null);
const [currentSession, setCurrentSession] = useState<CopilotSession | null>(
null
);
const [isTogglingPin, setIsTogglingPin] = useState(false);
const chatContainerRef = useRef<HTMLDivElement>(null);
const chatToolContainerRef = useRef<HTMLDivElement>(null);
const widthSignalRef = useRef<Signal<number>>(signal(0));
const client = useCopilotClient();
@@ -64,6 +73,42 @@ export const Component = () => {
reasoningConfig,
} = useAIChatConfig();
const createSession = useCallback(
async (options: Partial<BlockSuitePresets.AICreateSessionOptions> = {}) => {
const sessionId = await client.createSession({
workspaceId,
promptName: 'Chat With AFFiNE AI' satisfies PromptKey,
...options,
});
const session = await client.getSession(workspaceId, sessionId);
setCurrentSession(session);
return session;
},
[client, workspaceId]
);
const togglePin = useCallback(async () => {
if (isTogglingPin) return;
setIsTogglingPin(true);
try {
const pinned = !currentSession?.pinned;
if (!currentSession) {
await createSession({ pinned });
} else {
await client.updateSession({
sessionId: currentSession.id,
pinned,
});
// retrieve the latest session and update the state
const session = await client.getSession(workspaceId, currentSession.id);
setCurrentSession(session);
}
} finally {
setIsTogglingPin(false);
}
}, [client, createSession, currentSession, isTogglingPin, workspaceId]);
// create a temp doc/host for ai-chat-content
useEffect(() => {
let tempDoc: Doc | null = null;
@@ -75,7 +120,6 @@ export const Component = () => {
store: tempDoc?.getStore() as Store,
extensions: getCustomPageEditorBlockSpecs(),
}).render();
setDoc(doc);
setHost(host);
});
@@ -86,7 +130,7 @@ export const Component = () => {
// init or update ai-chat-content
useEffect(() => {
if (!isBodyProvided || !host || !doc) {
if (!isBodyProvided || !host) {
return;
}
@@ -95,6 +139,8 @@ export const Component = () => {
if (!content) {
content = new AIChatContent();
}
content.session = currentSession;
content.host = host;
content.workspaceId = workspaceId;
content.docDisplayConfig = docDisplayConfig;
@@ -108,17 +154,6 @@ export const Component = () => {
if (!chatContent) {
// initial values that won't change
const createSession = async () => {
const sessionId = await client.createSession({
workspaceId,
docId: doc.id,
promptName: 'Chat With AFFiNE AI' satisfies PromptKey,
});
const session = await client.getSession(workspaceId, sessionId);
return session;
};
content.createSession = createSession;
content.independentMode = true;
content.onboardingOffsetY = -100;
@@ -128,7 +163,8 @@ export const Component = () => {
}, [
chatContent,
client,
doc,
createSession,
currentSession,
docDisplayConfig,
framework,
host,
@@ -139,6 +175,35 @@ export const Component = () => {
workspaceId,
]);
// init or update header ai-chat-toolbar
useEffect(() => {
if (!isHeaderProvided || !chatToolContainerRef.current || !chatContent) {
return;
}
let tool = chatTool;
if (!tool) {
tool = new AIChatToolbar();
}
tool.session = currentSession;
// initial props
if (!chatTool) {
tool.onNewSession = () => {
if (!currentSession) return;
setCurrentSession(null);
chatContent?.reset();
};
tool.onTogglePin = () => {
togglePin().catch(console.error);
};
// mount
chatToolContainerRef.current.append(tool);
setChatTool(tool);
}
}, [chatContent, chatTool, currentSession, isHeaderProvided, togglePin]);
const onChatContainerRef = useCallback((node: HTMLDivElement) => {
if (node) {
setIsBodyProvided(true);
@@ -147,6 +212,13 @@ export const Component = () => {
}
}, []);
const onChatToolContainerRef = useCallback((node: HTMLDivElement) => {
if (node) {
setIsHeaderProvided(true);
chatToolContainerRef.current = node;
}
}, []);
// observe chat container width and provide to ai-chat-content
useEffect(() => {
if (!isBodyProvided || !chatContainerRef.current) return;
@@ -159,7 +231,12 @@ export const Component = () => {
<>
<ViewTitle title={t['AFFiNE AI']()} />
<ViewIcon icon="ai" />
<ViewHeader></ViewHeader>
<ViewHeader>
<div className={styles.chatHeader}>
<div />
<div ref={onChatToolContainerRef} />
</div>
</ViewHeader>
<ViewBody>
<div className={styles.chatRoot} ref={onChatContainerRef} />
</ViewBody>