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
2025-05-13 05:11:33 +00:00
parent cfe7b7cf29
commit 0d518adc5b
42 changed files with 830 additions and 169 deletions

View File

@@ -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 {

View File

@@ -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>`;

View File

@@ -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)}`;
}
}

View File

@@ -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}

View File

@@ -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()}

View File

@@ -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)

View File

@@ -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>`;
};

View File

@@ -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]);

View File

@@ -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

View File

@@ -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') {

View File

@@ -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));
}
}

View File

@@ -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)
);
});
},
};
}

View File

@@ -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() {

View File

@@ -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({

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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',

View File

@@ -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,
]);
};

View File

@@ -14,7 +14,7 @@
"it-IT": 96,
"it": 1,
"ja": 96,
"ko": 56,
"ko": 55,
"pl": 96,
"pt-BR": 96,
"ru": 96,

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`
*/

View File

@@ -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",