feat(core): embedding status tooltip (#12382)

### TL;DR

feat: display embedding tip for ai chat

![截屏2025-05-20 10.35.11.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/MyktQ6Qwc7H6TiRCFoYN/c3a4fe47-1995-4ec0-bde0-ddbe19ce95a2.png)

> CLOSE BS-3051

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

- **New Features**
  - Introduced an embedding status tooltip in the AI chat interface, providing real-time feedback on embedding progress for your workspace.
  - Added support for embedding status tracking within the AI provider and client services.
- **Style**
  - Updated the AI chat footer layout for improved clarity and usability.
- **Tests**
  - Added an end-to-end test to ensure the embedding status tooltip displays correctly.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
yoyoyohamapi
2025-05-20 11:08:33 +00:00
parent afbda482de
commit 3f762cc87b
8 changed files with 161 additions and 4 deletions

View File

@@ -13,6 +13,7 @@ import type { EditorHost } from '@blocksuite/affine/std';
import type { GfxModel } from '@blocksuite/affine/std/gfx';
import type { BlockModel } from '@blocksuite/affine/store';
import type { AIEmbeddingStatus } from '../provider';
import type { PromptKey } from '../provider/prompt';
export const translateLangs = [
@@ -422,5 +423,9 @@ declare global {
query: string;
}): Promise<string[]>;
}
interface AIEmbeddingService {
getEmbeddingStatus(workspaceId: string): Promise<AIEmbeddingStatus>;
}
}
}

View File

@@ -39,14 +39,19 @@ export class AIChatComposer extends SignalWatcher(
static override styles = css`
.chat-panel-footer {
margin: 8px 0px;
height: 20px;
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
color: var(--affine-text-secondary-color);
font-size: 12px;
user-select: none;
}
.ai-misleading-info {
display: flex;
align-items: center;
gap: 4px;
}
`;
@property({ attribute: false })
@@ -148,8 +153,8 @@ export class AIChatComposer extends SignalWatcher(
.addImages=${this.addImages}
></ai-chat-input>
<div class="chat-panel-footer">
${InformationIcon()}
<div>AI outputs can be misleading or wrong</div>
<div class="ai-misleading-info">${InformationIcon()} AI outputs can be misleading or wrong</div>
<ai-chat-embedding-status-tooltip .host=${this.host} />
</div>
</div>`;
}

View File

@@ -0,0 +1,95 @@
import { SignalWatcher } from '@blocksuite/affine/global/lit';
import { unsafeCSSVar } from '@blocksuite/affine/shared/theme';
import type { EditorHost } from '@blocksuite/affine/std';
import { InformationIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { property, state } from 'lit/decorators.js';
import { debounce, noop } from 'lodash-es';
import { AIProvider } from '../../provider/ai-provider';
export class AIChatEmbeddingStatusTooltip extends SignalWatcher(LitElement) {
static override styles = css`
.embedding-status {
display: flex;
align-items: center;
gap: 4px;
user-select: none;
}
.check-status {
padding: 4px;
cursor: pointer;
border-radius: 4px;
}
.check-status:hover {
background-color: ${unsafeCSSVar('--affine-hover-color')};
}
`;
@property({ attribute: false })
accessor host!: EditorHost;
@state()
accessor progressText = 'Loading embedding status...';
override connectedCallback() {
super.connectedCallback();
this._updateEmbeddingStatus().catch(noop);
}
private async _updateEmbeddingStatus() {
try {
const status = await AIProvider.embedding?.getEmbeddingStatus(
this.host.std.workspace.id
);
if (!status) {
this.progressText = 'Loading embedding status...';
return;
}
const completed = status.embedded === status.total;
if (completed) {
this.progressText =
'Embedding finished. You are getting the best results!';
} else {
this.progressText =
'File not embedded yet. Results will improve after embedding.';
}
this.requestUpdate();
} catch {
this.progressText = 'Failed to load embedding status...';
}
}
private readonly _handleCheckStatusMouseEnter = debounce(
() => {
this._updateEmbeddingStatus().catch(noop);
},
1000,
{ leading: true }
);
override render() {
return html`
<div
class="embedding-status"
data-testid="ai-chat-embedding-status-tooltip"
>
${InformationIcon()} Better results after embedding finished.
<span
class="check-status"
data-testid="ai-chat-embedding-status-tooltip-check"
@mouseenter=${this._handleCheckStatusMouseEnter}
>
Check status
</span>
<affine-tooltip>${this.progressText}</affine-tooltip>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'ai-chat-embedding-status-tooltip': AIChatEmbeddingStatusTooltip;
}
}

View File

@@ -38,6 +38,7 @@ import { ChatPanelFileChip } from './components/ai-chat-chips/file-chip';
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 { AIHistoryClear } from './components/ai-history-clear';
import { effects as componentAiItemEffects } from './components/ai-item';
@@ -98,6 +99,10 @@ export function registerAIEffects() {
customElements.define('chat-panel-messages', ChatPanelMessages);
customElements.define('chat-panel', ChatPanel);
customElements.define('ai-chat-input', AIChatInput);
customElements.define(
'ai-chat-embedding-status-tooltip',
AIChatEmbeddingStatusTooltip
);
customElements.define('ai-chat-composer', AIChatComposer);
customElements.define('chat-panel-chips', ChatPanelChips);
customElements.define('ai-history-clear', AIHistoryClear);

View File

@@ -29,6 +29,11 @@ export interface AISendParams {
context?: Partial<ChatContextValue | null>;
}
export interface AIEmbeddingStatus {
embedded: number;
total: number;
}
export type ActionEventType =
| 'started'
| 'finished'
@@ -96,6 +101,10 @@ export class AIProvider {
return AIProvider.instance.forkChat;
}
static get embedding() {
return AIProvider.instance.embedding;
}
private static readonly instance = new AIProvider();
static LAST_ACTION_SESSIONID = '';
@@ -152,6 +161,8 @@ export class AIProvider {
private userInfoFn: () => AIUserInfo | Promise<AIUserInfo> | null = () =>
null;
private embedding: BlockSuitePresets.AIEmbeddingService | null = null;
private provideAction<T extends keyof BlockSuitePresets.AIActions>(
id: T,
action: (
@@ -307,6 +318,11 @@ export class AIProvider {
static provide(id: 'onboarding', fn: (value: boolean) => void): void;
static provide(
id: 'embedding',
service: BlockSuitePresets.AIEmbeddingService
): void;
// actions:
static provide<T extends keyof BlockSuitePresets.AIActions>(
id: T,
@@ -338,6 +354,9 @@ export class AIProvider {
AIProvider.instance.forkChat = action as (
options: BlockSuitePresets.AIForkChatSessionOptions
) => string | Promise<string>;
} else if (id === 'embedding') {
AIProvider.instance.embedding =
action as BlockSuitePresets.AIEmbeddingService;
} else {
AIProvider.instance.provideAction(id as any, action as any);
}

View File

@@ -13,6 +13,7 @@ import {
getCopilotHistoryIdsQuery,
getCopilotSessionQuery,
getCopilotSessionsQuery,
getWorkspaceEmbeddingStatusQuery,
type GraphQLQuery,
listContextObjectQuery,
listContextQuery,
@@ -460,4 +461,11 @@ export class CopilotClient {
});
return queryString.toString();
}
getEmbeddingStatus(workspaceId: string) {
return this.gql({
query: getWorkspaceEmbeddingStatusQuery,
variables: { workspaceId },
}).then(res => res.queryWorkspaceEmbeddingStatus);
}
}

View File

@@ -796,6 +796,12 @@ Could you make a new website based on these notes and send back just the html fi
return client.forkSession(options);
});
AIProvider.provide('embedding', {
getEmbeddingStatus: (workspaceId: string) => {
return client.getEmbeddingStatus(workspaceId);
},
});
const disposeRequestLoginHandler = AIProvider.slots.requestLogin.subscribe(
() => {
globalDialogService.open('sign-in', {});