refactor(editor): add cache extension for link preview service (#12196)

Closes: [BS-2578](https://linear.app/affine-design/issue/BS-2578/优化-footnote-预览的逻辑:支持缓存结果,避免重复-loading)

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

- **New Features**
  - Introduced a link preview caching mechanism, enabling faster and more efficient reuse of link preview data across the app.
  - Added a feature flag for enabling or disabling link preview cache, configurable through workspace experimental settings.
  - Enhanced localization with new entries describing the link preview cache feature.

- **Improvements**
  - Updated link preview service architecture for better extensibility and maintainability.
  - Improved integration of feature flags throughout chat and rendering components.

- **Bug Fixes**
  - Fixed tooltip formatting for footnote URLs.

- **Chores**
  - Updated dependencies and localization completeness tracking.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
donteatfriedrice committed 2025-05-13 05:11:34 +00:00
1 parent cfe7b7cf29
commit 0d518adc5b
42 files changed
+830 -169

No files matched your search

@@ -1,9 +1,11 @@
import { Peekable } from '@blocksuite/affine/components/peek';
import { ViewExtensionManagerIdentifier } from '@blocksuite/affine/ext-loader';
import { BlockComponent } from '@blocksuite/affine/std';
import { computed } from '@preact/signals-core';
import { html } from 'lit';
import { ChatMessagesSchema } from '../../components/ai-chat-messages';
import type { TextRendererOptions } from '../../components/text-renderer';
import { ChatWithAIIcon } from './components/icon';
import { type AIChatBlockModel } from './model';
import { AIChatBlockStyles } from './styles';
@@ -17,6 +19,8 @@ import { AIChatBlockStyles } from './styles';
export class AIChatBlockComponent extends BlockComponent<AIChatBlockModel> {
static override styles = AIChatBlockStyles;
private _textRendererOptions: TextRendererOptions = {};
// Deserialize messages from JSON string and verify the type using zod
private readonly _deserializeChatMessages = computed(() => {
const messages = this.model.props.messages$.value;
@@ -32,18 +36,23 @@ export class AIChatBlockComponent extends BlockComponent<AIChatBlockModel> {
}
});
override connectedCallback() {
super.connectedCallback();
this._textRendererOptions = {
customHeading: true,
extensions: this.previewExtensions,
};
}
override renderBlock() {
const messages = this._deserializeChatMessages.value.slice(-2);
const textRendererOptions = {
customHeading: true,
};
return html`<div class="affine-ai-chat-block-container">
<div class="ai-chat-messages-container">
<ai-chat-messages
.host=${this.host}
.messages=${messages}
.textRendererOptions=${textRendererOptions}
.textRendererOptions=${this._textRendererOptions}
.withMask=${true}
></ai-chat-messages>
</div>
@@ -52,6 +61,10 @@ export class AIChatBlockComponent extends BlockComponent<AIChatBlockModel> {
</div>
</div> `;
}
get previewExtensions() {
return this.std.get(ViewExtensionManagerIdentifier).get('preview-page');
}
}
declare global {
@@ -158,6 +158,9 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor extensions!: ExtensionType[];
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@query('.chat-panel-messages-container')
accessor messagesContainer: HTMLDivElement | null = null;
@@ -271,6 +274,7 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
.status=${isLast ? status : 'idle'}
.error=${isLast ? error : null}
.extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.getSessionId=${this.getSessionId}
.retry=${() => this.retry()}
></chat-message-assistant>`;
@@ -1,3 +1,4 @@
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
@@ -20,11 +21,15 @@ export class ChatContentRichText extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor extensions!: ExtensionType[];
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
protected override render() {
const { text, host } = this;
return html`${createTextRenderer(host, {
customHeading: true,
extensions: this.extensions,
affineFeatureFlagService: this.affineFeatureFlagService,
})(text, this.state)}`;
}
}
@@ -1,5 +1,6 @@
import './chat-panel-messages';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { ContextEmbedStatus } from '@affine/graphql';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import type { EditorHost } from '@blocksuite/affine/std';
@@ -205,6 +206,9 @@ export class ChatPanel extends SignalWatcher(
@property({ attribute: false })
accessor extensions!: ExtensionType[];
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@state()
accessor isLoading = false;
@@ -399,6 +403,7 @@ export class ChatPanel extends SignalWatcher(
.host=${this.host}
.isLoading=${this.isLoading}
.extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService}
></chat-panel-messages>
<ai-chat-composer
.host=${this.host}
@@ -1,6 +1,7 @@
import '../content/assistant-avatar';
import '../content/rich-text';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import { isInsidePageEditor } from '@blocksuite/affine/shared/utils';
import type { EditorHost } from '@blocksuite/affine/std';
@@ -48,6 +49,9 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor extensions!: ExtensionType[];
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@property({ attribute: false })
accessor getSessionId!: () => Promise<string | undefined>;
@@ -93,6 +97,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
.text=${item.content}
.state=${state}
.extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService}
></chat-content-rich-text>
${shouldRenderError ? AIChatErrorRenderer(host, error) : nothing}
${this.renderEditorActions()}
@@ -1,4 +1,5 @@
import { createReactComponentFromLit } from '@affine/component';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { Container, type ServiceProvider } from '@blocksuite/affine/global/di';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import {
@@ -6,10 +7,7 @@ import {
defaultImageProxyMiddleware,
ImageProxyService,
} from '@blocksuite/affine/shared/adapters';
import {
LinkPreviewerService,
ThemeProvider,
} from '@blocksuite/affine/shared/services';
import { ThemeProvider } from '@blocksuite/affine/shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import {
BlockStdScope,
@@ -104,6 +102,7 @@ export type TextRendererOptions = {
extensions?: ExtensionType[];
additionalMiddlewares?: TransformerMiddleware[];
testId?: string;
affineFeatureFlagService?: FeatureFlagService;
};
// todo: refactor it for more general purpose usage instead of AI only?
@@ -266,7 +265,14 @@ export class TextRenderer extends WithDisposable(ShadowlessElement) {
codeBlockWrapMiddleware(true),
...(this.options.additionalMiddlewares ?? []),
];
markDownToDoc(provider, schema, latestAnswer, middlewares)
const affineFeatureFlagService = this.options.affineFeatureFlagService;
markDownToDoc(
provider,
schema,
latestAnswer,
middlewares,
affineFeatureFlagService
)
.then(doc => {
this.disposeDoc();
this._doc = doc.doc.getStore({
@@ -278,16 +284,10 @@ export class TextRenderer extends WithDisposable(ShadowlessElement) {
this._doc.readonly = true;
this.requestUpdate();
if (this.state !== 'generating') {
// LinkPreviewerService & ImageProxyService config should read from host settings
const linkPreviewerService =
this.host?.std.store.get(LinkPreviewerService);
this._doc.load();
// LinkPreviewService & ImageProxyService config should read from host settings
const imageProxyService =
this.host?.std.store.get(ImageProxyService);
if (linkPreviewerService) {
this._doc
?.get(LinkPreviewerService)
.setEndpoint(linkPreviewerService.endpoint);
}
if (imageProxyService) {
this._doc
?.get(ImageProxyService)
@@ -1,3 +1,4 @@
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { ContextEmbedStatus } from '@affine/graphql';
import {
CanvasElementType,
@@ -445,7 +446,10 @@ export class AIChatBlockPeekView extends LitElement {
.get(ViewExtensionManagerIdentifier)
.get('preview-page');
this._textRendererOptions = { extensions };
this._textRendererOptions = {
extensions,
affineFeatureFlagService: this.affineFeatureFlagService,
};
this._historyMessages = this._deserializeHistoryChatMessages(
this.historyMessagesString
);
@@ -556,6 +560,9 @@ export class AIChatBlockPeekView extends LitElement {
@property({ attribute: false })
accessor searchMenuConfig!: SearchMenuConfig;
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@state()
accessor _historyMessages: ChatMessage[] = [];
@@ -584,7 +591,8 @@ export const AIChatBlockPeekViewTemplate = (
docDisplayConfig: DocDisplayConfig,
searchMenuConfig: SearchMenuConfig,
networkSearchConfig: AINetworkSearchConfig,
reasoningConfig: AIReasoningConfig
reasoningConfig: AIReasoningConfig,
affineFeatureFlagService: FeatureFlagService
) => {
return html`<ai-chat-block-peek-view
.blockModel=${blockModel}
@@ -593,5 +601,6 @@ export const AIChatBlockPeekViewTemplate = (
.docDisplayConfig=${docDisplayConfig}
.searchMenuConfig=${searchMenuConfig}
.reasoningConfig=${reasoningConfig}
.affineFeatureFlagService=${affineFeatureFlagService}
></ai-chat-block-peek-view>`;
};
@@ -170,7 +170,8 @@ const usePreviewExtensions = () => {
.theme(framework)
.database(framework)
.linkedDoc(framework)
.paragraph(enableAI).value;
.paragraph(enableAI)
.linkPreview(framework).value;
const specs = manager.get('preview-page');
return [...specs, patchReferenceRenderer(reactToLit, referenceRenderer)];
}, [reactToLit, referenceRenderer, framework, enableAI]);
@@ -19,7 +19,6 @@ import {
ImageProxyService,
} from '@blocksuite/affine/shared/adapters';
import { focusBlockEnd } from '@blocksuite/affine/shared/commands';
import { LinkPreviewerService } from '@blocksuite/affine/shared/services';
import { getLastNoteBlock } from '@blocksuite/affine/shared/utils';
import type { BlockStdScope, EditorHost } from '@blocksuite/affine/std';
import type { Store } from '@blocksuite/affine/store';
@@ -212,13 +211,7 @@ const BlockSuiteEditorImpl = ({
server.baseUrl
).toString();
const linkPreviewUrl = new URL(
BUILD_CONFIG.linkPreviewUrl,
server.baseUrl
).toString();
editor.std.clipboard.use(customImageProxyMiddleware(imageProxyUrl));
page.get(LinkPreviewerService).setEndpoint(linkPreviewUrl);
page.get(ImageProxyService).setImageProxyURL(imageProxyUrl);
editor.updateComplete
@@ -101,7 +101,8 @@ const usePatchSpecs = (mode: DocMode) => {
.linkedDoc(framework)
.paragraph(enableAI)
.mobile(framework)
.electron(framework).value;
.electron(framework)
.linkPreview(framework).value;
if (BUILD_CONFIG.isMobileEdition) {
if (mode === 'page') {
@@ -0,0 +1,32 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine/ext-loader';
import { FrameworkProvider } from '@toeverything/infra';
import { z } from 'zod';
import { patchLinkPreviewService } from './link-preview-service';
const optionsSchema = z.object({
framework: z.instanceof(FrameworkProvider).optional(),
});
type AffineLinkPreviewViewOptions = z.infer<typeof optionsSchema>;
export class AffineLinkPreviewExtension extends ViewExtensionProvider<AffineLinkPreviewViewOptions> {
override name = 'affine-link-preview-extension';
override schema = optionsSchema;
override setup(
context: ViewExtensionContext,
options?: AffineLinkPreviewViewOptions
) {
super.setup(context, options);
if (!options?.framework) {
return;
}
const { framework } = options;
context.register(patchLinkPreviewService(framework));
}
}
@@ -0,0 +1,61 @@
import { DEFAULT_LINK_PREVIEW_ENDPOINT } from '@blocksuite/affine/shared/consts';
import {
LinkPreviewCacheIdentifier,
type LinkPreviewCacheProvider,
LinkPreviewService,
LinkPreviewServiceIdentifier,
} from '@blocksuite/affine/shared/services';
import { type BlockStdScope, StdIdentifier } from '@blocksuite/affine/std';
import { type ExtensionType } from '@blocksuite/affine/store';
import type { Container } from '@blocksuite/global/di';
import type { FrameworkProvider } from '@toeverything/infra';
import { ServerService } from '../../../modules/cloud/services/server';
class AffineLinkPreviewService extends LinkPreviewService {
constructor(
endpoint: string,
std: BlockStdScope,
cache: LinkPreviewCacheProvider
) {
super(std, cache);
this.setEndpoint(endpoint);
}
}
/**
* Patch the link preview service, set the endpoint and cache
* @param framework
* @returns
*/
export function patchLinkPreviewService(
framework: FrameworkProvider
): ExtensionType {
// get link preview service endpoint from server and BUILD_CONFIG
let linkPreviewUrl: string;
try {
const server = framework.get(ServerService).server;
linkPreviewUrl = new URL(
BUILD_CONFIG.linkPreviewUrl || '/',
server.baseUrl
).toString();
} catch (err) {
console.error(
'Invalid BUILD_CONFIG.linkPreviewUrl, falling back to default',
err
);
linkPreviewUrl = DEFAULT_LINK_PREVIEW_ENDPOINT;
}
return {
setup: (di: Container) => {
di.override(LinkPreviewServiceIdentifier, provider => {
return new AffineLinkPreviewService(
linkPreviewUrl,
provider.get(StdIdentifier),
provider.get(LinkPreviewCacheIdentifier)
);
});
},
};
}
@@ -9,6 +9,7 @@ import { AffineEditorConfigViewExtension } from '@affine/core/blocksuite/extensi
import { createDatabaseOptionsConfig } from '@affine/core/blocksuite/extensions/editor-config/database';
import { createLinkedWidgetConfig } from '@affine/core/blocksuite/extensions/editor-config/linked';
import { ElectronViewExtension } from '@affine/core/blocksuite/extensions/electron';
import { AffineLinkPreviewExtension } from '@affine/core/blocksuite/extensions/link-preview-service';
import { MobileViewExtension } from '@affine/core/blocksuite/extensions/mobile';
import { PdfViewExtension } from '@affine/core/blocksuite/extensions/pdf';
import { AffineThemeViewExtension } from '@affine/core/blocksuite/extensions/theme';
@@ -44,6 +45,7 @@ type Configure = {
mobile: (framework?: FrameworkProvider) => Configure;
ai: (enable?: boolean, framework?: FrameworkProvider) => Configure;
electron: (framework?: FrameworkProvider) => Configure;
linkPreview: (framework?: FrameworkProvider) => Configure;
value: ViewExtensionManager;
};
@@ -75,6 +77,7 @@ class ViewProvider {
MobileViewExtension,
AIViewExtension,
ElectronViewExtension,
AffineLinkPreviewExtension,
]);
}
@@ -99,6 +102,7 @@ class ViewProvider {
mobile: this._configureMobile,
ai: this._configureAI,
electron: this._configureElectron,
linkPreview: this._configureLinkPreview,
value: this._manager,
};
}
@@ -118,7 +122,8 @@ class ViewProvider {
.pdf()
.mobile()
.ai()
.electron();
.electron()
.linkPreview();
return this.config;
};
@@ -256,6 +261,11 @@ class ViewProvider {
this._manager.configure(ElectronViewExtension, { framework });
return this.config;
};
private readonly _configureLinkPreview = (framework?: FrameworkProvider) => {
this._manager.configure(AffineLinkPreviewExtension, { framework });
return this.config;
};
}
export function getViewManager() {
@@ -1,3 +1,4 @@
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkspaceImpl } from '@affine/core/modules/workspace/impls/workspace';
import type { ServiceProvider } from '@blocksuite/affine/global/di';
import {
@@ -161,11 +162,13 @@ export async function markDownToDoc(
provider: ServiceProvider,
schema: Schema,
answer: string,
middlewares?: TransformerMiddleware[]
middlewares?: TransformerMiddleware[],
affineFeatureFlagService?: FeatureFlagService
) {
// Should not create a new doc in the original collection
const collection = new WorkspaceImpl({
rootDoc: new YDoc({ guid: 'markdownToDoc' }),
featureFlagService: affineFeatureFlagService,
});
collection.meta.initialize();
const transformer = new Transformer({
@@ -1,6 +1,7 @@
import { ChatPanel } from '@affine/core/blocksuite/ai';
import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor';
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { ViewExtensionManagerIdentifier } from '@blocksuite/affine/ext-loader';
import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference';
@@ -75,6 +76,8 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
chatPanelRef.current.extensions = editor.host.std
.get(ViewExtensionManagerIdentifier)
.get('preview-page');
chatPanelRef.current.affineFeatureFlagService =
framework.get(FeatureFlagService);
containerRef.current?.append(chatPanelRef.current);
} else {
@@ -27,7 +27,6 @@ import {
customImageProxyMiddleware,
ImageProxyService,
} from '@blocksuite/affine/shared/adapters';
import { LinkPreviewerService } from '@blocksuite/affine/shared/services';
import {
FrameworkScope,
useLiveData,
@@ -139,11 +138,6 @@ const DetailPageImpl = () => {
server.baseUrl
).toString();
const linkPreviewUrl = new URL(
BUILD_CONFIG.linkPreviewUrl,
server.baseUrl
).toString();
editorContainer.std.clipboard.use(
customImageProxyMiddleware(imageProxyUrl)
);
@@ -151,9 +145,6 @@ const DetailPageImpl = () => {
.get(ImageProxyService)
.setImageProxyURL(imageProxyUrl);
// provide link preview endpoint to blocksuite
editorContainer.doc.get(LinkPreviewerService).setEndpoint(linkPreviewUrl);
// provide page mode and updated date to blocksuite
const refNodeService =
editorContainer.std.getOptional(RefNodeSlotsProvider);
@@ -106,6 +106,16 @@ export const AFFINE_FLAGS = {
configurable: isCanaryBuild,
defaultState: isCanaryBuild,
},
enable_link_preview_cache: {
category: 'blocksuite',
bsFlag: 'enable_link_preview_cache',
displayName:
'com.affine.settings.workspace.experimental-features.enable-link-preview-cache.name',
description:
'com.affine.settings.workspace.experimental-features.enable-link-preview-cache.description',
configurable: isCanaryBuild,
defaultState: isCanaryBuild,
},
enable_emoji_folder_icon: {
category: 'affine',
@@ -2,7 +2,9 @@ import { toReactNode } from '@affine/component';
import { AIChatBlockPeekViewTemplate } from '@affine/core/blocksuite/ai';
import type { AIChatBlockModel } from '@affine/core/blocksuite/ai/blocks/ai-chat-block/model/ai-chat-model';
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { EditorHost } from '@blocksuite/affine/std';
import { useFramework } from '@toeverything/infra';
import { useMemo } from 'react';
export type AIChatBlockPeekViewProps = {
@@ -21,6 +23,9 @@ export const AIChatBlockPeekView = ({
reasoningConfig,
} = useAIChatConfig();
const framework = useFramework();
const affineFeatureFlagService = framework.get(FeatureFlagService);
return useMemo(() => {
const template = AIChatBlockPeekViewTemplate(
model,
@@ -28,7 +33,8 @@ export const AIChatBlockPeekView = ({
docDisplayConfig,
searchMenuConfig,
networkSearchConfig,
reasoningConfig
reasoningConfig,
affineFeatureFlagService
);
return toReactNode(template);
}, [
@@ -38,5 +44,6 @@ export const AIChatBlockPeekView = ({
searchMenuConfig,
networkSearchConfig,
reasoningConfig,
affineFeatureFlagService,
]);
};
@@ -14,7 +14,7 @@
"it-IT": 96,
"it": 1,
"ja": 96,
"ko": 56,
"ko": 55,
"pl": 96,
"pt-BR": 96,
"ru": 96,
+8
View File
@@ -5722,6 +5722,14 @@ export function useAFFiNEI18N(): {
* `Enable citation feature.`
*/
["com.affine.settings.workspace.experimental-features.enable-citation.description"](): string;
/**
* `Link Preview Cache`
*/
["com.affine.settings.workspace.experimental-features.enable-link-preview-cache.name"](): string;
/**
* `Once enabled, the link preview will be cached and cached data will be used when the same link is fetched again. Otherwise, the link preview will be fetched every time.`
*/
["com.affine.settings.workspace.experimental-features.enable-link-preview-cache.description"](): string;
/**
* `Embed Iframe Block`
*/
@@ -1429,6 +1429,8 @@
"com.affine.settings.workspace.experimental-features.enable-callout.description": "Let your words stand out. This also include the callout in the transcription block.",
"com.affine.settings.workspace.experimental-features.enable-citation.name": "Citation",
"com.affine.settings.workspace.experimental-features.enable-citation.description": "Enable citation feature.",
"com.affine.settings.workspace.experimental-features.enable-link-preview-cache.name": "Link Preview Cache",
"com.affine.settings.workspace.experimental-features.enable-link-preview-cache.description": "Once enabled, the link preview will be cached and cached data will be used when the same link is fetched again. Otherwise, the link preview will be fetched every time.",
"com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.name": "Embed Iframe Block",
"com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.description": "Enables Embed Iframe Block.",
"com.affine.settings.workspace.experimental-features.enable-emoji-folder-icon.name": "Emoji Folder Icon",