diff --git a/blocksuite/affine/blocks/bookmark/src/bookmark-block.ts b/blocksuite/affine/blocks/bookmark/src/bookmark-block.ts index 8ee5f387d..4d63bed35 100644 --- a/blocksuite/affine/blocks/bookmark/src/bookmark-block.ts +++ b/blocksuite/affine/blocks/bookmark/src/bookmark-block.ts @@ -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 { this._localLinkPreview$.value = { diff --git a/blocksuite/affine/blocks/bookmark/src/utils.ts b/blocksuite/affine/blocks/bookmark/src/utils.ts index 457ef51a1..7e9f309a5 100644 --- a/blocksuite/affine/blocks/bookmark/src/utils.ts +++ b/blocksuite/affine/blocks/bookmark/src/utils.ts @@ -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 diff --git a/blocksuite/affine/blocks/embed/src/embed-github-block/embed-github-service.ts b/blocksuite/affine/blocks/embed/src/embed-github-block/embed-github-service.ts index 853522d70..7d532bfdd 100644 --- a/blocksuite/affine/blocks/embed/src/embed-github-block/embed-github-service.ts +++ b/blocksuite/affine/blocks/embed/src/embed-github-block/embed-github-service.ts @@ -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 ); }; diff --git a/blocksuite/affine/blocks/embed/src/embed-github-block/utils.ts b/blocksuite/affine/blocks/embed/src/embed-github-block/utils.ts index a03cdde5a..100edb78b 100644 --- a/blocksuite/affine/blocks/embed/src/embed-github-block/utils.ts +++ b/blocksuite/affine/blocks/embed/src/embed-github-block/utils.ts @@ -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> { const [githubApiData, openGraphData] = await Promise.all([ diff --git a/blocksuite/affine/blocks/embed/src/embed-iframe-block/embed-iframe-block.ts b/blocksuite/affine/blocks/embed/src/embed-iframe-block/embed-iframe-block.ts index 8f33be0dd..c63d7123d 100644 --- a/blocksuite/affine/blocks/embed/src/embed-iframe-block/embed-iframe-block.ts +++ b/blocksuite/affine/blocks/embed/src/embed-iframe-block/embed-iframe-block.ts @@ -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 { return queryEmbedYoutubeData( embedYoutubeModel, - this.doc.get(LinkPreviewerService), + this.std.get(LinkPreviewServiceIdentifier), signal ); }; diff --git a/blocksuite/affine/blocks/embed/src/embed-youtube-block/utils.ts b/blocksuite/affine/blocks/embed/src/embed-youtube-block/utils.ts index 9f651ba99..3d78913c2 100644 --- a/blocksuite/affine/blocks/embed/src/embed-youtube-block/utils.ts +++ b/blocksuite/affine/blocks/embed/src/embed-youtube-block/utils.ts @@ -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> { const url = embedYoutubeModel.props.url; diff --git a/blocksuite/affine/foundation/src/store.ts b/blocksuite/affine/foundation/src/store.ts index c5499721d..90589767d 100644 --- a/blocksuite/affine/foundation/src/store.ts +++ b/blocksuite/affine/foundation/src/store.ts @@ -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, ]); } diff --git a/blocksuite/affine/foundation/src/view.ts b/blocksuite/affine/foundation/src/view.ts index 55ff5bf47..56d2cdae6 100644 --- a/blocksuite/affine/foundation/src/view.ts +++ b/blocksuite/affine/foundation/src/view.ts @@ -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)) { diff --git a/blocksuite/affine/inlines/footnote/src/footnote-node/footnote-node.ts b/blocksuite/affine/inlines/footnote/src/footnote-node/footnote-node.ts index 9fb53b1ff..2d8495146 100644 --- a/blocksuite/affine/inlines/footnote/src/footnote-node/footnote-node.ts +++ b/blocksuite/affine/inlines/footnote/src/footnote-node/footnote-node.ts @@ -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``; }; diff --git a/blocksuite/affine/inlines/footnote/src/footnote-node/footnote-popup.ts b/blocksuite/affine/inlines/footnote/src/footnote-node/footnote-popup.ts index 07b775780..4efa9c937 100644 --- a/blocksuite/affine/inlines/footnote/src/footnote-node/footnote-popup.ts +++ b/blocksuite/affine/inlines/footnote/src/footnote-node/footnote-popup.ts @@ -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 = () => {}; } diff --git a/blocksuite/affine/model/src/utils/types.ts b/blocksuite/affine/model/src/utils/types.ts index 720021b4e..a4586c7a8 100644 --- a/blocksuite/affine/model/src/utils/types.ts +++ b/blocksuite/affine/model/src/utils/types.ts @@ -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; export type Connectable = Exclude< GfxModel, diff --git a/blocksuite/affine/shared/package.json b/blocksuite/affine/shared/package.json index 7cb12e66c..6a636a90c 100644 --- a/blocksuite/affine/shared/package.json +++ b/blocksuite/affine/shared/package.json @@ -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", diff --git a/blocksuite/affine/shared/src/services/feature-flag-service.ts b/blocksuite/affine/shared/src/services/feature-flag-service.ts index b7b0a8f23..77fad1c5e 100644 --- a/blocksuite/affine/shared/src/services/feature-flag-service.ts +++ b/blocksuite/affine/shared/src/services/feature-flag-service.ts @@ -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) { diff --git a/blocksuite/affine/shared/src/services/index.ts b/blocksuite/affine/shared/src/services/index.ts index f15160447..85aa2dc46 100644 --- a/blocksuite/affine/shared/src/services/index.ts +++ b/blocksuite/affine/shared/src/services/index.ts @@ -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'; diff --git a/blocksuite/affine/shared/src/services/link-preview-service/index.ts b/blocksuite/affine/shared/src/services/link-preview-service/index.ts new file mode 100644 index 000000000..ec070d847 --- /dev/null +++ b/blocksuite/affine/shared/src/services/link-preview-service/index.ts @@ -0,0 +1,3 @@ +export * from './link-preview-cache'; +export * from './link-preview-service'; +export * from './link-preview-storage'; diff --git a/blocksuite/affine/shared/src/services/link-preview-service/link-preview-cache.ts b/blocksuite/affine/shared/src/services/link-preview-service/link-preview-cache.ts new file mode 100644 index 000000000..e7c388212 --- /dev/null +++ b/blocksuite/affine/shared/src/services/link-preview-service/link-preview-cache.ts @@ -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 | 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): 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> | 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> + ): 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('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>; + /** + * The pending requests for the link preview + * The promise will be resolved when the data is fetched + */ + private readonly pendingRequests: Map< + string, + Promise> + >; + /** + * 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 | undefined { + return this.memoryCache.get(url); + } + + set(url: string, data: Partial): void { + this.memoryCache.set(url, data); + this._saveToStorage(); + } + + getPendingRequest( + url: string + ): Promise> | undefined { + return this.pendingRequests.get(url); + } + + setPendingRequest( + url: string, + promise: Promise> + ): 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) + ); + }, + }; +}; diff --git a/blocksuite/affine/shared/src/services/link-preview-service/link-preview-service.ts b/blocksuite/affine/shared/src/services/link-preview-service/link-preview-service.ts new file mode 100644 index 000000000..a775f9123 --- /dev/null +++ b/blocksuite/affine/shared/src/services/link-preview-service/link-preview-service.ts @@ -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>; + /** + * Set the endpoint for link preview + */ + setEndpoint: (endpoint: string) => void; + + /** + * Get the endpoint for link preview + */ + endpoint: string; +} + +export const LinkPreviewServiceIdentifier = + createIdentifier('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> => { + 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> => { + 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> => { + 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> => { + 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; + }; +} diff --git a/blocksuite/affine/shared/src/services/link-preview-service/link-preview-storage.ts b/blocksuite/affine/shared/src/services/link-preview-service/link-preview-storage.ts new file mode 100644 index 000000000..3bd0c560f --- /dev/null +++ b/blocksuite/affine/shared/src/services/link-preview-service/link-preview-storage.ts @@ -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; + +/** + * 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); + } +} diff --git a/blocksuite/affine/shared/src/services/link-previewer-service.ts b/blocksuite/affine/shared/src/services/link-previewer-service.ts deleted file mode 100644 index bef4fc1df..000000000 --- a/blocksuite/affine/shared/src/services/link-previewer-service.ts +++ /dev/null @@ -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> => { - 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; - }; -} diff --git a/packages/frontend/core/src/blocksuite/ai/blocks/ai-chat-block/ai-chat-block.ts b/packages/frontend/core/src/blocksuite/ai/blocks/ai-chat-block/ai-chat-block.ts index fbe710a2c..dc89139c2 100644 --- a/packages/frontend/core/src/blocksuite/ai/blocks/ai-chat-block/ai-chat-block.ts +++ b/packages/frontend/core/src/blocksuite/ai/blocks/ai-chat-block/ai-chat-block.ts @@ -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 { 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 { } }); + 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`
@@ -52,6 +61,10 @@ export class AIChatBlockComponent extends BlockComponent {
`; } + + get previewExtensions() { + return this.std.get(ViewExtensionManagerIdentifier).get('preview-page'); + } } declare global { diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-panel-messages.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-panel-messages.ts index c57853760..6f4edd22c 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-panel-messages.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-panel-messages.ts @@ -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()} >`; diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/content/rich-text.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/content/rich-text.ts index 13d807524..0204d4ccb 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/content/rich-text.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/content/rich-text.ts @@ -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)}`; } } diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts index 0e4abc86c..931976a50 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/index.ts @@ -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} > Promise; @@ -93,6 +97,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) { .text=${item.content} .state=${state} .extensions=${this.extensions} + .affineFeatureFlagService=${this.affineFeatureFlagService} > ${shouldRenderError ? AIChatErrorRenderer(host, error) : nothing} ${this.renderEditorActions()} diff --git a/packages/frontend/core/src/blocksuite/ai/components/text-renderer.ts b/packages/frontend/core/src/blocksuite/ai/components/text-renderer.ts index f5331815d..e193f2d48 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/text-renderer.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/text-renderer.ts @@ -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) diff --git a/packages/frontend/core/src/blocksuite/ai/peek-view/chat-block-peek-view.ts b/packages/frontend/core/src/blocksuite/ai/peek-view/chat-block-peek-view.ts index b37e27a22..765b0542b 100644 --- a/packages/frontend/core/src/blocksuite/ai/peek-view/chat-block-peek-view.ts +++ b/packages/frontend/core/src/blocksuite/ai/peek-view/chat-block-peek-view.ts @@ -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``; }; diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.tsx b/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.tsx index 1583649f8..a7b9d86f7 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.tsx +++ b/packages/frontend/core/src/blocksuite/block-suite-editor/bi-directional-link-panel.tsx @@ -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]); diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/blocksuite-editor.tsx b/packages/frontend/core/src/blocksuite/block-suite-editor/blocksuite-editor.tsx index 1ae88cbde..e01fd2f88 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/blocksuite-editor.tsx +++ b/packages/frontend/core/src/blocksuite/block-suite-editor/blocksuite-editor.tsx @@ -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 diff --git a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx index cb9759152..ec5396607 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/blocksuite/block-suite-editor/lit-adaper.tsx @@ -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') { diff --git a/packages/frontend/core/src/blocksuite/extensions/link-preview-service/index.ts b/packages/frontend/core/src/blocksuite/extensions/link-preview-service/index.ts new file mode 100644 index 000000000..b02e8899b --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/link-preview-service/index.ts @@ -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; + +export class AffineLinkPreviewExtension extends ViewExtensionProvider { + 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)); + } +} diff --git a/packages/frontend/core/src/blocksuite/extensions/link-preview-service/link-preview-service.ts b/packages/frontend/core/src/blocksuite/extensions/link-preview-service/link-preview-service.ts new file mode 100644 index 000000000..15739b8ee --- /dev/null +++ b/packages/frontend/core/src/blocksuite/extensions/link-preview-service/link-preview-service.ts @@ -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) + ); + }); + }, + }; +} diff --git a/packages/frontend/core/src/blocksuite/manager/migrating-view.ts b/packages/frontend/core/src/blocksuite/manager/migrating-view.ts index b339717b0..e8a2e6fd9 100644 --- a/packages/frontend/core/src/blocksuite/manager/migrating-view.ts +++ b/packages/frontend/core/src/blocksuite/manager/migrating-view.ts @@ -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() { diff --git a/packages/frontend/core/src/blocksuite/utils/markdown-utils.ts b/packages/frontend/core/src/blocksuite/utils/markdown-utils.ts index 1338e2560..086774e2b 100644 --- a/packages/frontend/core/src/blocksuite/utils/markdown-utils.ts +++ b/packages/frontend/core/src/blocksuite/utils/markdown-utils.ts @@ -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({ diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx index d2da110d7..7645ffeb7 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx @@ -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 { diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx index 26a5b11cb..46f1449d8 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx @@ -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); diff --git a/packages/frontend/core/src/modules/feature-flag/constant.ts b/packages/frontend/core/src/modules/feature-flag/constant.ts index 2cc698d47..547990d40 100644 --- a/packages/frontend/core/src/modules/feature-flag/constant.ts +++ b/packages/frontend/core/src/modules/feature-flag/constant.ts @@ -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', diff --git a/packages/frontend/core/src/modules/peek-view/view/ai-chat-block-peek-view/index.tsx b/packages/frontend/core/src/modules/peek-view/view/ai-chat-block-peek-view/index.tsx index 667289b27..209ac972d 100644 --- a/packages/frontend/core/src/modules/peek-view/view/ai-chat-block-peek-view/index.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/ai-chat-block-peek-view/index.tsx @@ -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, ]); }; diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index e15dcdce8..9130d14c5 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -14,7 +14,7 @@ "it-IT": 96, "it": 1, "ja": 96, - "ko": 56, + "ko": 55, "pl": 96, "pt-BR": 96, "ru": 96, diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 94dd07d0d..3f5b743ed 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -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` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 59688baa6..cfbf4422b 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 1f40ddf4a..5037621b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"