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
@@ -9,7 +9,7 @@ import type {
import { ImageProxyService } from '@blocksuite/affine-shared/adapters';
import {
DocModeProvider,
LinkPreviewerService,
LinkPreviewServiceIdentifier,
} from '@blocksuite/affine-shared/services';
import { BlockSelection } from '@blocksuite/std';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
@@ -72,8 +72,8 @@ export class BookmarkBlockComponent extends CaptionedBlockComponent<BookmarkBloc
this.loading = true;
this.error = false;
this.std.store
.get(LinkPreviewerService)
this.std
.get(LinkPreviewServiceIdentifier)
.query(this.model.props.url, this._fetchAbortController.signal)
.then(data => {
this._localLinkPreview$.value = {
@@ -1,4 +1,4 @@
import { LinkPreviewerService } from '@blocksuite/affine-shared/services';
import { LinkPreviewServiceIdentifier } from '@blocksuite/affine-shared/services';
import { isAbortError } from '@blocksuite/affine-shared/utils';
import type { BookmarkBlockComponent } from './bookmark-block.js';
@@ -15,7 +15,7 @@ export async function refreshBookmarkUrlData(
try {
bookmarkElement.loading = true;
const linkPreviewer = bookmarkElement.store.get(LinkPreviewerService);
const linkPreviewer = bookmarkElement.std.get(LinkPreviewServiceIdentifier);
const bookmarkUrlData = await linkPreviewer.query(
bookmarkElement.model.props.url,
signal
@@ -5,7 +5,7 @@ import {
} from '@blocksuite/affine-model';
import {
EmbedOptionConfig,
LinkPreviewerService,
LinkPreviewServiceIdentifier,
} from '@blocksuite/affine-shared/services';
import { BlockService } from '@blocksuite/std';
@@ -22,7 +22,7 @@ export class EmbedGithubBlockService extends BlockService {
queryUrlData = (embedGithubModel: EmbedGithubModel, signal?: AbortSignal) => {
return queryEmbedGithubData(
embedGithubModel,
this.doc.get(LinkPreviewerService),
this.std.get(LinkPreviewServiceIdentifier),
signal
);
};
@@ -2,7 +2,7 @@ import type {
EmbedGithubBlockUrlData,
EmbedGithubModel,
} from '@blocksuite/affine-model';
import type { LinkPreviewerService } from '@blocksuite/affine-shared/services';
import type { LinkPreviewProvider } from '@blocksuite/affine-shared/services';
import { isAbortError } from '@blocksuite/affine-shared/utils';
import { nothing } from 'lit';
@@ -19,7 +19,7 @@ import {
export async function queryEmbedGithubData(
embedGithubModel: EmbedGithubModel,
linkPreviewer: LinkPreviewerService,
linkPreviewer: LinkPreviewProvider,
signal?: AbortSignal
): Promise<Partial<EmbedGithubBlockUrlData>> {
const [githubApiData, openGraphData] = await Promise.all([
@@ -9,7 +9,7 @@ import {
type EmbedIframeData,
EmbedIframeService,
type IframeOptions,
LinkPreviewerService,
LinkPreviewServiceIdentifier,
NotificationProvider,
} from '@blocksuite/affine-shared/services';
import { matchModels } from '@blocksuite/affine-shared/utils';
@@ -94,7 +94,7 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
}
get linkPreviewService() {
return this.std.get(LinkPreviewerService);
return this.std.get(LinkPreviewServiceIdentifier);
}
get notificationService() {
@@ -156,7 +156,7 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
if (!embedIframeService || !linkPreviewService) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
'EmbedIframeService or LinkPreviewerService not found'
'EmbedIframeService or LinkPreviewService not found'
);
}
@@ -5,7 +5,7 @@ import {
} from '@blocksuite/affine-model';
import {
EmbedOptionConfig,
LinkPreviewerService,
LinkPreviewServiceIdentifier,
} from '@blocksuite/affine-shared/services';
import { BlockService } from '@blocksuite/std';
@@ -21,7 +21,7 @@ export class EmbedYoutubeBlockService extends BlockService {
) => {
return queryEmbedYoutubeData(
embedYoutubeModel,
this.doc.get(LinkPreviewerService),
this.std.get(LinkPreviewServiceIdentifier),
signal
);
};
@@ -2,14 +2,14 @@ import type {
EmbedYoutubeBlockUrlData,
EmbedYoutubeModel,
} from '@blocksuite/affine-model';
import type { LinkPreviewerService } from '@blocksuite/affine-shared/services';
import type { LinkPreviewProvider } from '@blocksuite/affine-shared/services';
import { isAbortError } from '@blocksuite/affine-shared/utils';
import type { EmbedYoutubeBlockComponent } from './embed-youtube-block.js';
export async function queryEmbedYoutubeData(
embedYoutubeModel: EmbedYoutubeModel,
linkPreviewer: LinkPreviewerService,
linkPreviewer: LinkPreviewProvider,
signal?: AbortSignal
): Promise<Partial<EmbedYoutubeBlockUrlData>> {
const url = embedYoutubeModel.props.url;
@@ -15,7 +15,6 @@ import { HighlightSelectionExtension } from '@blocksuite/affine-shared/selection
import {
BlockMetaService,
FeatureFlagService,
LinkPreviewerService,
} from '@blocksuite/affine-shared/services';
import {
BlockSelectionExtension,
@@ -49,7 +48,6 @@ export class FoundationStoreExtension extends StoreExtensionProvider {
FeatureFlagService,
BlockMetaService,
// TODO(@mirone): maybe merge these services into a file setting service
LinkPreviewerService,
ImageProxyService,
]);
}
+4
View File
@@ -13,6 +13,8 @@ import {
EmbedOptionService,
FileSizeLimitService,
FontLoaderService,
LinkPreviewCache,
LinkPreviewService,
PageViewportServiceExtension,
ThemeService,
ToolbarRegistryExtension,
@@ -47,6 +49,8 @@ export class FoundationViewExtension extends ViewExtensionProvider {
ToolbarRegistryExtension,
AutoClearSelectionService,
FileSizeLimitService,
LinkPreviewCache,
LinkPreviewService,
]);
context.register(clipboardConfigs);
if (this.isEdgeless(context.scope)) {
@@ -150,6 +150,20 @@ export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) {
window.open(url, '_blank');
};
private readonly _updateFootnoteAttributes = (footnote: FootNote) => {
if (!this.footnote || this.readonly) {
return;
}
if (!this.inlineEditor || !this.selfInlineRange) {
return;
}
this.inlineEditor.formatText(this.selfInlineRange, {
footnote: footnote,
});
};
private readonly _FootNoteDefaultContent = (footnote: FootNote) => {
return html`<span
class="footnote-content-default"
@@ -169,6 +183,7 @@ export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) {
.std=${this.std}
.abortController=${abortController}
.onPopupClick=${this.onPopupClick ?? this.onFootnoteClick}
.updateFootnoteAttributes=${this._updateFootnoteAttributes}
></footnote-popup>`;
};
@@ -7,7 +7,7 @@ import type { FootNote } from '@blocksuite/affine-model';
import { ImageProxyService } from '@blocksuite/affine-shared/adapters';
import {
DocDisplayMetaProvider,
LinkPreviewerService,
LinkPreviewServiceIdentifier,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
@@ -121,7 +121,7 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) {
if (referenceType === 'url') {
const title = this._linkPreview$.value?.title;
const url = this.footnote.reference.url;
return [title, url].filter(Boolean).join(' ') || '';
return [title, url].filter(Boolean).join('\n') || '';
}
return this._popupLabel$.value;
});
@@ -160,15 +160,29 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) {
isTitleAndDescriptionEmpty
) {
this._isLoading$.value = true;
this.std.store
.get(LinkPreviewerService)
this.std
.get(LinkPreviewServiceIdentifier)
.query(this.footnote.reference.url)
.then(data => {
// update the local link preview data
this._linkPreview$.value = {
favicon: data.icon ?? undefined,
title: data.title ?? undefined,
description: data.description ?? undefined,
};
// update the footnote attributes in the node with the link preview data
// to avoid fetching the same data multiple times
const footnote: FootNote = {
...this.footnote,
reference: {
...this.footnote.reference,
...(data.icon && { favicon: data.icon }),
...(data.title && { title: data.title }),
...(data.description && { description: data.description }),
},
};
this.updateFootnoteAttributes(footnote);
})
.catch(console.error)
.finally(() => {
@@ -209,4 +223,7 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) {
@property({ attribute: false })
accessor onPopupClick: FootNotePopupClickHandler | (() => void) = () => {};
@property({ attribute: false })
accessor updateFootnoteAttributes: (footnote: FootNote) => void = () => {};
}
+9 -6
View File
@@ -1,4 +1,5 @@
import type { GfxModel } from '@blocksuite/std/gfx';
import { z } from 'zod';
import type {
BrushElementModel,
@@ -20,12 +21,14 @@ export type EmbedCardStyle =
| 'pdf'
| 'citation';
export type LinkPreviewData = {
description: string | null;
icon: string | null;
image: string | null;
title: string | null;
};
export const LinkPreviewDataSchema = z.object({
description: z.string().nullable(),
icon: z.string().nullable(),
image: z.string().nullable(),
title: z.string().nullable(),
});
export type LinkPreviewData = z.infer<typeof LinkPreviewDataSchema>;
export type Connectable = Exclude<
GfxModel,
+1
View File
@@ -38,6 +38,7 @@
"micromark-extension-gfm-task-list-item": "^2.1.0",
"micromark-util-combine-extensions": "^2.0.0",
"minimatch": "^10.0.1",
"quick-lru": "^7.0.1",
"rehype-parse": "^9.0.0",
"rehype-stringify": "^10.0.0",
"remark-math": "^6.0.0",
@@ -22,6 +22,7 @@ export interface BlockSuiteFlags {
enable_embed_doc_with_alias: boolean;
enable_turbo_renderer: boolean;
enable_citation: boolean;
enable_link_preview_cache: boolean;
}
export class FeatureFlagService extends StoreExtension {
@@ -48,6 +49,7 @@ export class FeatureFlagService extends StoreExtension {
enable_embed_doc_with_alias: false,
enable_turbo_renderer: false,
enable_citation: false,
enable_link_preview_cache: false,
});
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
@@ -11,7 +11,7 @@ export * from './feature-flag-service';
export * from './file-size-limit-service';
export * from './font-loader';
export * from './generate-url-service';
export * from './link-previewer-service';
export * from './link-preview-service';
export * from './native-clipboard-service';
export * from './notification-service';
export * from './open-doc-config';
@@ -0,0 +1,3 @@
export * from './link-preview-cache';
export * from './link-preview-service';
export * from './link-preview-storage';
@@ -0,0 +1,240 @@
import type { LinkPreviewData } from '@blocksuite/affine-model';
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { Extension, type ExtensionType } from '@blocksuite/store';
import debounce from 'lodash-es/debounce';
import QuickLRU from 'quick-lru';
import { z } from 'zod';
import { LinkPreviewStorage } from './link-preview-storage';
export const LinkPreviewCacheConfigSchema = z.object({
/**
* The maximum number of items in the cache
*/
cacheSize: z.number(),
/**
* The time to live for the memory cache
*/
memoryTTL: z.number(),
/**
* The time to live for the local storage cache
*/
localStorageTTL: z.number(),
});
export type LinkPreviewCacheConfig = z.infer<
typeof LinkPreviewCacheConfigSchema
>;
const DEFAULT_LINK_PREVIEW_CACHE_CONFIG: LinkPreviewCacheConfig = {
cacheSize: 50,
memoryTTL: 1000 * 60 * 60, // 60 minutes
localStorageTTL: 1000 * 60 * 60 * 6, // 6 hours
};
/**
* It's used to debounce the save to local storage to avoid frequent writes
*/
const DEBOUNCE_TIME = 1000;
/**
* The interface for the link preview cache provider
*/
export interface LinkPreviewCacheProvider {
/**
* Get the link preview data for a given URL
* @param url The URL to get the link preview data for
* @returns The link preview data for the given URL
*/
get(url: string): Partial<LinkPreviewData> | undefined;
/**
* Set the link preview data for a given URL
* @param url The URL to set the link preview data for
* @param data The link preview data to set
*/
set(url: string, data: Partial<LinkPreviewData>): void;
/**
* Get the pending request for a given URL
* @param url The URL to get the pending request for
* @returns The pending request for the given URL
*/
getPendingRequest(url: string): Promise<Partial<LinkPreviewData>> | undefined;
/**
* Set the pending request for a given URL
* @param url The URL to set the pending request for
* @param promise The promise to set for the given URL
*/
setPendingRequest(
url: string,
promise: Promise<Partial<LinkPreviewData>>
): void;
/**
* Delete the pending request for a given URL
* @param url The URL to delete the pending request for
*/
deletePendingRequest(url: string): void;
/**
* Clear the cache
*/
clear(): void;
}
export const LinkPreviewCacheIdentifier =
createIdentifier<LinkPreviewCacheProvider>('AffineLinkPreviewCache');
/**
* The link preview cache, it will cache the link preview data in the memory and local storage
*/
export class LinkPreviewCache
extends Extension
implements LinkPreviewCacheProvider
{
/**
* The singleton instance of the link preview cache
*/
private static instance: LinkPreviewCache | null = null;
/**
* The memory cache for the link preview
*/
private readonly memoryCache: QuickLRU<string, Partial<LinkPreviewData>>;
/**
* The pending requests for the link preview
* The promise will be resolved when the data is fetched
*/
private readonly pendingRequests: Map<
string,
Promise<Partial<LinkPreviewData>>
>;
/**
* The local storage manager for the link preview
*/
private readonly storage: LinkPreviewStorage;
constructor(
private readonly config: LinkPreviewCacheConfig = DEFAULT_LINK_PREVIEW_CACHE_CONFIG
) {
super();
this.storage = new LinkPreviewStorage();
this.memoryCache = new QuickLRU({
maxSize: this.config.cacheSize,
maxAge: this.config.memoryTTL,
onEviction: key => {
this._clearItemFromStorage(key);
},
});
this.pendingRequests = new Map();
this._loadFromStorage();
}
static getInstance(config?: LinkPreviewCacheConfig): LinkPreviewCache {
if (!LinkPreviewCache.instance) {
LinkPreviewCache.instance = new LinkPreviewCache(config);
}
return LinkPreviewCache.instance;
}
get(url: string): Partial<LinkPreviewData> | undefined {
return this.memoryCache.get(url);
}
set(url: string, data: Partial<LinkPreviewData>): void {
this.memoryCache.set(url, data);
this._saveToStorage();
}
getPendingRequest(
url: string
): Promise<Partial<LinkPreviewData>> | undefined {
return this.pendingRequests.get(url);
}
setPendingRequest(
url: string,
promise: Promise<Partial<LinkPreviewData>>
): void {
this.pendingRequests.set(url, promise);
}
deletePendingRequest(url: string): void {
this.pendingRequests.delete(url);
}
/**
* Load the cache from local storage
*/
private readonly _loadFromStorage = (): void => {
const data = this.storage.load();
// Check if the data is expired
const localDataExpires = data.expires;
// If the data is expired, clear the data
if (localDataExpires && localDataExpires < Date.now()) {
this.storage.clear();
return;
}
// load the data to the memory cache
Object.entries(data.data).forEach(([url, item]) => {
this.memoryCache.set(url, item);
});
};
/**
* Save the cache to local storage
* Debounce the save to local storage to avoid frequent writes
*/
private readonly _saveToStorage = debounce(() => {
const entries = Array.from(this.memoryCache.entriesDescending());
const linkPreviewData = Object.fromEntries(
entries.slice(0, this.memoryCache.size).map(([url, data]) => [url, data])
);
const data = {
data: linkPreviewData,
expires: Date.now() + this.config.localStorageTTL,
};
this.storage.save(data);
}, DEBOUNCE_TIME);
/**
* Clear a link preview record from local storage with specific URL
* Called when the item is evicted from the memory cache
* @param {string} url The URL key to remove from storage
* @returns {boolean} Whether the item was successfully removed
*/
private readonly _clearItemFromStorage = (url: string): void => {
this.storage.clearItem(url);
};
clear(): void {
this.memoryCache.clear();
this.pendingRequests.clear();
}
clearLocalStorage(): void {
this.storage.clear();
}
static override setup(di: Container) {
di.addImpl(LinkPreviewCacheIdentifier, () =>
LinkPreviewCache.getInstance()
);
}
}
/**
* The extension for the link preview cache, it will override the link preview cache instance
* @param config - The configuration for the link preview cache
* @returns The extension for the link preview cache
*/
export const LinkPreviewCacheExtension = (
config?: LinkPreviewCacheConfig
): ExtensionType => {
return {
setup: (di: Container) => {
di.override(LinkPreviewCacheIdentifier, () =>
LinkPreviewCache.getInstance(config)
);
},
};
};
@@ -0,0 +1,225 @@
import { type LinkPreviewData } from '@blocksuite/affine-model';
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { type BlockStdScope, StdIdentifier } from '@blocksuite/std';
import { Extension } from '@blocksuite/store';
import { DEFAULT_LINK_PREVIEW_ENDPOINT } from '../../consts';
import { isAbortError } from '../../utils/is-abort-error';
import { FeatureFlagService } from '../feature-flag-service';
import {
LinkPreviewCacheIdentifier,
type LinkPreviewCacheProvider,
} from './link-preview-cache';
export type LinkPreviewResponseData = {
url: string;
title?: string;
siteName?: string;
description?: string;
images?: string[];
mediaType?: string;
contentType?: string;
charset?: string;
videos?: string[];
favicons?: string[];
};
export interface LinkPreviewProvider {
/**
* Query link preview data for a given URL
*/
query: (
url: string,
signal?: AbortSignal
) => Promise<Partial<LinkPreviewData>>;
/**
* Set the endpoint for link preview
*/
setEndpoint: (endpoint: string) => void;
/**
* Get the endpoint for link preview
*/
endpoint: string;
}
export const LinkPreviewServiceIdentifier =
createIdentifier<LinkPreviewProvider>('AffineLinkPreviewService');
export class LinkPreviewService
extends Extension
implements LinkPreviewProvider
{
static override setup(di: Container) {
di.addImpl(LinkPreviewServiceIdentifier, LinkPreviewService, [
StdIdentifier,
LinkPreviewCacheIdentifier,
]);
}
private _endpoint: string = DEFAULT_LINK_PREVIEW_ENDPOINT;
constructor(
private readonly _std: BlockStdScope,
private readonly _cache: LinkPreviewCacheProvider
) {
super();
}
get endpoint() {
return this._endpoint;
}
setEndpoint = (endpoint: string) => {
this._endpoint = endpoint;
};
private readonly _fetchTwitterPreview = async (
url: string,
signal?: AbortSignal
): Promise<Partial<LinkPreviewData>> => {
try {
const match = /\/status\/(\d+)/.exec(url);
if (!match) {
throw new BlockSuiteError(
ErrorCode.DefaultRuntimeError,
`Invalid tweet URL: ${url}`
);
}
const apiUrl = `https://api.fxtwitter.com/status/${match[1]}`;
const response = await fetch(apiUrl, { signal }).then(res => res.json());
const tweet = response?.tweet;
if (!tweet) {
throw new BlockSuiteError(
ErrorCode.DefaultRuntimeError,
`Invalid tweet response: ${url}`
);
}
return {
title: tweet.author?.name ?? null,
icon: tweet.author?.avatar_url ?? null,
description: tweet.text ?? null,
image:
tweet.media?.photos?.[0]?.url || tweet.author?.banner_url || null,
};
} catch (e) {
console.error(`Failed to fetch tweet: ${url}`);
console.error(e);
return {};
}
};
private readonly _fetchStandardPreview = async (
url: string,
signal?: AbortSignal
): Promise<Partial<LinkPreviewData>> => {
const response = await fetch(this.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url }),
signal,
})
.then(r => {
if (!r || !r.ok) {
throw new BlockSuiteError(
ErrorCode.DefaultRuntimeError,
`Failed to fetch link preview: ${url}`
);
}
return r;
})
.catch(err => {
if (isAbortError(err)) return null;
console.error(`Failed to fetch link preview: ${url}`);
console.error(err);
return null;
});
if (!response) return {};
const data: LinkPreviewResponseData = await response.json();
return {
title: data.title ?? null,
description: data.description ?? null,
icon: data.favicons?.[0],
image: data.images?.[0],
};
};
private readonly _isTwitterUrl = (url: string): boolean => {
const twitterDomains = [
'https://x.com/',
'https://www.x.com/',
'https://www.twitter.com/',
'https://twitter.com/',
];
return (
twitterDomains.some(domain => url.startsWith(domain)) &&
url.includes('/status/')
);
};
private readonly _fetchPreview = async (
url: string,
signal?: AbortSignal
): Promise<Partial<LinkPreviewData>> => {
if (this._isTwitterUrl(url)) {
return this._fetchTwitterPreview(url, signal);
}
return this._fetchStandardPreview(url, signal);
};
/**
* Fetch link preview data for a given URL
*/
query = async (
url: string,
signal?: AbortSignal
): Promise<Partial<LinkPreviewData>> => {
const featureFlagService = this._std.store.get(FeatureFlagService);
const cacheEnabled = featureFlagService.getFlag(
'enable_link_preview_cache'
);
// If the cache is not enabled, fetch the preview directly
if (!cacheEnabled) {
return this._fetchPreview(url, signal);
}
// Check memory cache, if hit, return the cached data
const cached = this._cache.get(url);
if (cached) {
return cached;
}
// Check pending requests, if there is a pending request, return the promise
const pendingRequest = this._cache.getPendingRequest(url);
if (pendingRequest) {
return pendingRequest;
}
// Fetch new data
const promise = (async () => {
try {
// Fetch new data
const data = await this._fetchPreview(url, signal);
// If the data is not empty, set the data to the cache
if (data && Object.keys(data).length > 0) {
this._cache.set(url, data);
}
return data;
} finally {
// Delete the pending request regardless of success or failure
this._cache.deletePendingRequest(url);
}
})();
// Set the promise to the cache
this._cache.setPendingRequest(url, promise);
return promise;
};
}
@@ -0,0 +1,81 @@
import { LinkPreviewDataSchema } from '@blocksuite/affine-model';
import { z } from 'zod';
const _StorageSchema = z.object({
data: z.record(LinkPreviewDataSchema.partial()),
expires: z.number().optional(),
});
type StorageData = z.infer<typeof _StorageSchema>;
/**
* The local storage manager for the link preview cache data
*/
export class LinkPreviewStorage {
/**
* The storage key for the link preview
*/
storageKey = 'blocksuite:link-preview-cache';
/**
* Load the cache from local storage
* @returns StorageData
*/
load(): StorageData {
try {
const stored = localStorage.getItem(this.storageKey);
if (stored) {
const parsed = JSON.parse(stored);
const safe = _StorageSchema.safeParse(parsed);
if (safe.success) {
return safe.data;
}
// if the data is invalid, clear the data
this.clear();
}
} catch (e) {
console.error('Failed to load cache from storage:', e);
}
return { data: {} };
}
/**
* Save the cache to local storage
* @param {StorageData} data
*/
save(data: StorageData): void {
try {
const serialized = JSON.stringify(data);
localStorage.setItem(this.storageKey, serialized);
} catch (e) {
console.error('Failed to save cache to storage:', e);
}
}
/**
* Clear a link preview record from local storage with specific URL
* @param {string} url The URL key to remove from storage
* @returns {boolean} Whether the item was successfully removed
*/
clearItem(url: string): boolean {
try {
const data = this.load();
if (!(url in data.data)) {
return false;
}
delete data.data[url];
this.save(data);
return true;
} catch (e) {
console.error('Failed to clear item from storage:', e);
return false;
}
}
/**
* Clear all records from local storage
*/
clear(): void {
localStorage.removeItem(this.storageKey);
}
}
@@ -1,99 +0,0 @@
import type { LinkPreviewData } from '@blocksuite/affine-model';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { StoreExtension } from '@blocksuite/store';
import { DEFAULT_LINK_PREVIEW_ENDPOINT } from '../consts';
import { isAbortError } from '../utils/is-abort-error';
export type LinkPreviewResponseData = {
url: string;
title?: string;
siteName?: string;
description?: string;
images?: string[];
mediaType?: string;
contentType?: string;
charset?: string;
videos?: string[];
favicons?: string[];
};
export class LinkPreviewerService extends StoreExtension {
static override key = 'link-previewer';
private _endpoint = DEFAULT_LINK_PREVIEW_ENDPOINT;
query = async (
url: string,
signal?: AbortSignal
): Promise<Partial<LinkPreviewData>> => {
if (
(url.startsWith('https://x.com/') ||
url.startsWith('https://www.x.com/') ||
url.startsWith('https://www.twitter.com/') ||
url.startsWith('https://twitter.com/')) &&
url.includes('/status/')
) {
// use api.fxtwitter.com
url =
'https://api.fxtwitter.com/status/' + /\/status\/(.*)/.exec(url)?.[1];
try {
const { tweet } = await fetch(url, { signal }).then(res => res.json());
return {
title: tweet.author.name,
icon: tweet.author.avatar_url,
description: tweet.text,
image: tweet.media?.photos?.[0].url || tweet.author.banner_url,
};
} catch (e) {
console.error(`Failed to fetch tweet: ${url}`);
console.error(e);
return {};
}
} else {
const response = await fetch(this._endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url,
}),
signal,
})
.then(r => {
if (!r || !r.ok) {
throw new BlockSuiteError(
ErrorCode.DefaultRuntimeError,
`Failed to fetch link preview: ${url}`
);
}
return r;
})
.catch(err => {
if (isAbortError(err)) return null;
console.error(`Failed to fetch link preview: ${url}`);
console.error(err);
return null;
});
if (!response) return {};
const data: LinkPreviewResponseData = await response.json();
return {
title: data.title ?? null,
description: data.description ?? null,
icon: data.favicons?.[0],
image: data.images?.[0],
};
}
};
get endpoint() {
return this._endpoint;
}
setEndpoint = (endpoint: string) => {
this._endpoint = endpoint;
};
}
@@ -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",
+8
View File
@@ -3707,6 +3707,7 @@ __metadata:
micromark-extension-gfm-task-list-item: "npm:^2.1.0"
micromark-util-combine-extensions: "npm:^2.0.0"
minimatch: "npm:^10.0.1"
quick-lru: "npm:^7.0.1"
rehype-parse: "npm:^9.0.0"
rehype-stringify: "npm:^10.0.0"
remark-math: "npm:^6.0.0"
@@ -29524,6 +29525,13 @@ __metadata:
languageName: node
linkType: hard
"quick-lru@npm:^7.0.1":
version: 7.0.1
resolution: "quick-lru@npm:7.0.1"
checksum: 10/00bed5bcc4683221bf3da5c767f9dd8197c18fb6b0b7f9b79ddada62b52ff2a7ce17bb49dd4fce783c89b29974cf47a93422d7094766c8593c127ffe6c70054a
languageName: node
linkType: hard
"raf-schd@npm:^4.0.3":
version: 4.0.3
resolution: "raf-schd@npm:4.0.3"