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:
@@ -9,7 +9,7 @@ import type {
|
|||||||
import { ImageProxyService } from '@blocksuite/affine-shared/adapters';
|
import { ImageProxyService } from '@blocksuite/affine-shared/adapters';
|
||||||
import {
|
import {
|
||||||
DocModeProvider,
|
DocModeProvider,
|
||||||
LinkPreviewerService,
|
LinkPreviewServiceIdentifier,
|
||||||
} from '@blocksuite/affine-shared/services';
|
} from '@blocksuite/affine-shared/services';
|
||||||
import { BlockSelection } from '@blocksuite/std';
|
import { BlockSelection } from '@blocksuite/std';
|
||||||
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
|
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
|
||||||
@@ -72,8 +72,8 @@ export class BookmarkBlockComponent extends CaptionedBlockComponent<BookmarkBloc
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = false;
|
this.error = false;
|
||||||
|
|
||||||
this.std.store
|
this.std
|
||||||
.get(LinkPreviewerService)
|
.get(LinkPreviewServiceIdentifier)
|
||||||
.query(this.model.props.url, this._fetchAbortController.signal)
|
.query(this.model.props.url, this._fetchAbortController.signal)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
this._localLinkPreview$.value = {
|
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 { isAbortError } from '@blocksuite/affine-shared/utils';
|
||||||
|
|
||||||
import type { BookmarkBlockComponent } from './bookmark-block.js';
|
import type { BookmarkBlockComponent } from './bookmark-block.js';
|
||||||
@@ -15,7 +15,7 @@ export async function refreshBookmarkUrlData(
|
|||||||
try {
|
try {
|
||||||
bookmarkElement.loading = true;
|
bookmarkElement.loading = true;
|
||||||
|
|
||||||
const linkPreviewer = bookmarkElement.store.get(LinkPreviewerService);
|
const linkPreviewer = bookmarkElement.std.get(LinkPreviewServiceIdentifier);
|
||||||
const bookmarkUrlData = await linkPreviewer.query(
|
const bookmarkUrlData = await linkPreviewer.query(
|
||||||
bookmarkElement.model.props.url,
|
bookmarkElement.model.props.url,
|
||||||
signal
|
signal
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from '@blocksuite/affine-model';
|
} from '@blocksuite/affine-model';
|
||||||
import {
|
import {
|
||||||
EmbedOptionConfig,
|
EmbedOptionConfig,
|
||||||
LinkPreviewerService,
|
LinkPreviewServiceIdentifier,
|
||||||
} from '@blocksuite/affine-shared/services';
|
} from '@blocksuite/affine-shared/services';
|
||||||
import { BlockService } from '@blocksuite/std';
|
import { BlockService } from '@blocksuite/std';
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export class EmbedGithubBlockService extends BlockService {
|
|||||||
queryUrlData = (embedGithubModel: EmbedGithubModel, signal?: AbortSignal) => {
|
queryUrlData = (embedGithubModel: EmbedGithubModel, signal?: AbortSignal) => {
|
||||||
return queryEmbedGithubData(
|
return queryEmbedGithubData(
|
||||||
embedGithubModel,
|
embedGithubModel,
|
||||||
this.doc.get(LinkPreviewerService),
|
this.std.get(LinkPreviewServiceIdentifier),
|
||||||
signal
|
signal
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type {
|
|||||||
EmbedGithubBlockUrlData,
|
EmbedGithubBlockUrlData,
|
||||||
EmbedGithubModel,
|
EmbedGithubModel,
|
||||||
} from '@blocksuite/affine-model';
|
} 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 { isAbortError } from '@blocksuite/affine-shared/utils';
|
||||||
import { nothing } from 'lit';
|
import { nothing } from 'lit';
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
|
|
||||||
export async function queryEmbedGithubData(
|
export async function queryEmbedGithubData(
|
||||||
embedGithubModel: EmbedGithubModel,
|
embedGithubModel: EmbedGithubModel,
|
||||||
linkPreviewer: LinkPreviewerService,
|
linkPreviewer: LinkPreviewProvider,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<Partial<EmbedGithubBlockUrlData>> {
|
): Promise<Partial<EmbedGithubBlockUrlData>> {
|
||||||
const [githubApiData, openGraphData] = await Promise.all([
|
const [githubApiData, openGraphData] = await Promise.all([
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type EmbedIframeData,
|
type EmbedIframeData,
|
||||||
EmbedIframeService,
|
EmbedIframeService,
|
||||||
type IframeOptions,
|
type IframeOptions,
|
||||||
LinkPreviewerService,
|
LinkPreviewServiceIdentifier,
|
||||||
NotificationProvider,
|
NotificationProvider,
|
||||||
} from '@blocksuite/affine-shared/services';
|
} from '@blocksuite/affine-shared/services';
|
||||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||||
@@ -94,7 +94,7 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
|||||||
}
|
}
|
||||||
|
|
||||||
get linkPreviewService() {
|
get linkPreviewService() {
|
||||||
return this.std.get(LinkPreviewerService);
|
return this.std.get(LinkPreviewServiceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
get notificationService() {
|
get notificationService() {
|
||||||
@@ -156,7 +156,7 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
|||||||
if (!embedIframeService || !linkPreviewService) {
|
if (!embedIframeService || !linkPreviewService) {
|
||||||
throw new BlockSuiteError(
|
throw new BlockSuiteError(
|
||||||
ErrorCode.ValueNotExists,
|
ErrorCode.ValueNotExists,
|
||||||
'EmbedIframeService or LinkPreviewerService not found'
|
'EmbedIframeService or LinkPreviewService not found'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from '@blocksuite/affine-model';
|
} from '@blocksuite/affine-model';
|
||||||
import {
|
import {
|
||||||
EmbedOptionConfig,
|
EmbedOptionConfig,
|
||||||
LinkPreviewerService,
|
LinkPreviewServiceIdentifier,
|
||||||
} from '@blocksuite/affine-shared/services';
|
} from '@blocksuite/affine-shared/services';
|
||||||
import { BlockService } from '@blocksuite/std';
|
import { BlockService } from '@blocksuite/std';
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ export class EmbedYoutubeBlockService extends BlockService {
|
|||||||
) => {
|
) => {
|
||||||
return queryEmbedYoutubeData(
|
return queryEmbedYoutubeData(
|
||||||
embedYoutubeModel,
|
embedYoutubeModel,
|
||||||
this.doc.get(LinkPreviewerService),
|
this.std.get(LinkPreviewServiceIdentifier),
|
||||||
signal
|
signal
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import type {
|
|||||||
EmbedYoutubeBlockUrlData,
|
EmbedYoutubeBlockUrlData,
|
||||||
EmbedYoutubeModel,
|
EmbedYoutubeModel,
|
||||||
} from '@blocksuite/affine-model';
|
} 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 { isAbortError } from '@blocksuite/affine-shared/utils';
|
||||||
|
|
||||||
import type { EmbedYoutubeBlockComponent } from './embed-youtube-block.js';
|
import type { EmbedYoutubeBlockComponent } from './embed-youtube-block.js';
|
||||||
|
|
||||||
export async function queryEmbedYoutubeData(
|
export async function queryEmbedYoutubeData(
|
||||||
embedYoutubeModel: EmbedYoutubeModel,
|
embedYoutubeModel: EmbedYoutubeModel,
|
||||||
linkPreviewer: LinkPreviewerService,
|
linkPreviewer: LinkPreviewProvider,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<Partial<EmbedYoutubeBlockUrlData>> {
|
): Promise<Partial<EmbedYoutubeBlockUrlData>> {
|
||||||
const url = embedYoutubeModel.props.url;
|
const url = embedYoutubeModel.props.url;
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { HighlightSelectionExtension } from '@blocksuite/affine-shared/selection
|
|||||||
import {
|
import {
|
||||||
BlockMetaService,
|
BlockMetaService,
|
||||||
FeatureFlagService,
|
FeatureFlagService,
|
||||||
LinkPreviewerService,
|
|
||||||
} from '@blocksuite/affine-shared/services';
|
} from '@blocksuite/affine-shared/services';
|
||||||
import {
|
import {
|
||||||
BlockSelectionExtension,
|
BlockSelectionExtension,
|
||||||
@@ -49,7 +48,6 @@ export class FoundationStoreExtension extends StoreExtensionProvider {
|
|||||||
FeatureFlagService,
|
FeatureFlagService,
|
||||||
BlockMetaService,
|
BlockMetaService,
|
||||||
// TODO(@mirone): maybe merge these services into a file setting service
|
// TODO(@mirone): maybe merge these services into a file setting service
|
||||||
LinkPreviewerService,
|
|
||||||
ImageProxyService,
|
ImageProxyService,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
EmbedOptionService,
|
EmbedOptionService,
|
||||||
FileSizeLimitService,
|
FileSizeLimitService,
|
||||||
FontLoaderService,
|
FontLoaderService,
|
||||||
|
LinkPreviewCache,
|
||||||
|
LinkPreviewService,
|
||||||
PageViewportServiceExtension,
|
PageViewportServiceExtension,
|
||||||
ThemeService,
|
ThemeService,
|
||||||
ToolbarRegistryExtension,
|
ToolbarRegistryExtension,
|
||||||
@@ -47,6 +49,8 @@ export class FoundationViewExtension extends ViewExtensionProvider {
|
|||||||
ToolbarRegistryExtension,
|
ToolbarRegistryExtension,
|
||||||
AutoClearSelectionService,
|
AutoClearSelectionService,
|
||||||
FileSizeLimitService,
|
FileSizeLimitService,
|
||||||
|
LinkPreviewCache,
|
||||||
|
LinkPreviewService,
|
||||||
]);
|
]);
|
||||||
context.register(clipboardConfigs);
|
context.register(clipboardConfigs);
|
||||||
if (this.isEdgeless(context.scope)) {
|
if (this.isEdgeless(context.scope)) {
|
||||||
|
|||||||
@@ -150,6 +150,20 @@ export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) {
|
|||||||
window.open(url, '_blank');
|
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) => {
|
private readonly _FootNoteDefaultContent = (footnote: FootNote) => {
|
||||||
return html`<span
|
return html`<span
|
||||||
class="footnote-content-default"
|
class="footnote-content-default"
|
||||||
@@ -169,6 +183,7 @@ export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) {
|
|||||||
.std=${this.std}
|
.std=${this.std}
|
||||||
.abortController=${abortController}
|
.abortController=${abortController}
|
||||||
.onPopupClick=${this.onPopupClick ?? this.onFootnoteClick}
|
.onPopupClick=${this.onPopupClick ?? this.onFootnoteClick}
|
||||||
|
.updateFootnoteAttributes=${this._updateFootnoteAttributes}
|
||||||
></footnote-popup>`;
|
></footnote-popup>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { FootNote } from '@blocksuite/affine-model';
|
|||||||
import { ImageProxyService } from '@blocksuite/affine-shared/adapters';
|
import { ImageProxyService } from '@blocksuite/affine-shared/adapters';
|
||||||
import {
|
import {
|
||||||
DocDisplayMetaProvider,
|
DocDisplayMetaProvider,
|
||||||
LinkPreviewerService,
|
LinkPreviewServiceIdentifier,
|
||||||
ThemeProvider,
|
ThemeProvider,
|
||||||
} from '@blocksuite/affine-shared/services';
|
} from '@blocksuite/affine-shared/services';
|
||||||
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||||
@@ -121,7 +121,7 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) {
|
|||||||
if (referenceType === 'url') {
|
if (referenceType === 'url') {
|
||||||
const title = this._linkPreview$.value?.title;
|
const title = this._linkPreview$.value?.title;
|
||||||
const url = this.footnote.reference.url;
|
const url = this.footnote.reference.url;
|
||||||
return [title, url].filter(Boolean).join(' ') || '';
|
return [title, url].filter(Boolean).join('\n') || '';
|
||||||
}
|
}
|
||||||
return this._popupLabel$.value;
|
return this._popupLabel$.value;
|
||||||
});
|
});
|
||||||
@@ -160,15 +160,29 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) {
|
|||||||
isTitleAndDescriptionEmpty
|
isTitleAndDescriptionEmpty
|
||||||
) {
|
) {
|
||||||
this._isLoading$.value = true;
|
this._isLoading$.value = true;
|
||||||
this.std.store
|
this.std
|
||||||
.get(LinkPreviewerService)
|
.get(LinkPreviewServiceIdentifier)
|
||||||
.query(this.footnote.reference.url)
|
.query(this.footnote.reference.url)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
// update the local link preview data
|
||||||
this._linkPreview$.value = {
|
this._linkPreview$.value = {
|
||||||
favicon: data.icon ?? undefined,
|
favicon: data.icon ?? undefined,
|
||||||
title: data.title ?? undefined,
|
title: data.title ?? undefined,
|
||||||
description: data.description ?? 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)
|
.catch(console.error)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -209,4 +223,7 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) {
|
|||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor onPopupClick: FootNotePopupClickHandler | (() => void) = () => {};
|
accessor onPopupClick: FootNotePopupClickHandler | (() => void) = () => {};
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor updateFootnoteAttributes: (footnote: FootNote) => void = () => {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { GfxModel } from '@blocksuite/std/gfx';
|
import type { GfxModel } from '@blocksuite/std/gfx';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
BrushElementModel,
|
BrushElementModel,
|
||||||
@@ -20,12 +21,14 @@ export type EmbedCardStyle =
|
|||||||
| 'pdf'
|
| 'pdf'
|
||||||
| 'citation';
|
| 'citation';
|
||||||
|
|
||||||
export type LinkPreviewData = {
|
export const LinkPreviewDataSchema = z.object({
|
||||||
description: string | null;
|
description: z.string().nullable(),
|
||||||
icon: string | null;
|
icon: z.string().nullable(),
|
||||||
image: string | null;
|
image: z.string().nullable(),
|
||||||
title: string | null;
|
title: z.string().nullable(),
|
||||||
};
|
});
|
||||||
|
|
||||||
|
export type LinkPreviewData = z.infer<typeof LinkPreviewDataSchema>;
|
||||||
|
|
||||||
export type Connectable = Exclude<
|
export type Connectable = Exclude<
|
||||||
GfxModel,
|
GfxModel,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"micromark-extension-gfm-task-list-item": "^2.1.0",
|
"micromark-extension-gfm-task-list-item": "^2.1.0",
|
||||||
"micromark-util-combine-extensions": "^2.0.0",
|
"micromark-util-combine-extensions": "^2.0.0",
|
||||||
"minimatch": "^10.0.1",
|
"minimatch": "^10.0.1",
|
||||||
|
"quick-lru": "^7.0.1",
|
||||||
"rehype-parse": "^9.0.0",
|
"rehype-parse": "^9.0.0",
|
||||||
"rehype-stringify": "^10.0.0",
|
"rehype-stringify": "^10.0.0",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface BlockSuiteFlags {
|
|||||||
enable_embed_doc_with_alias: boolean;
|
enable_embed_doc_with_alias: boolean;
|
||||||
enable_turbo_renderer: boolean;
|
enable_turbo_renderer: boolean;
|
||||||
enable_citation: boolean;
|
enable_citation: boolean;
|
||||||
|
enable_link_preview_cache: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FeatureFlagService extends StoreExtension {
|
export class FeatureFlagService extends StoreExtension {
|
||||||
@@ -48,6 +49,7 @@ export class FeatureFlagService extends StoreExtension {
|
|||||||
enable_embed_doc_with_alias: false,
|
enable_embed_doc_with_alias: false,
|
||||||
enable_turbo_renderer: false,
|
enable_turbo_renderer: false,
|
||||||
enable_citation: false,
|
enable_citation: false,
|
||||||
|
enable_link_preview_cache: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
|
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export * from './feature-flag-service';
|
|||||||
export * from './file-size-limit-service';
|
export * from './file-size-limit-service';
|
||||||
export * from './font-loader';
|
export * from './font-loader';
|
||||||
export * from './generate-url-service';
|
export * from './generate-url-service';
|
||||||
export * from './link-previewer-service';
|
export * from './link-preview-service';
|
||||||
export * from './native-clipboard-service';
|
export * from './native-clipboard-service';
|
||||||
export * from './notification-service';
|
export * from './notification-service';
|
||||||
export * from './open-doc-config';
|
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 { Peekable } from '@blocksuite/affine/components/peek';
|
||||||
|
import { ViewExtensionManagerIdentifier } from '@blocksuite/affine/ext-loader';
|
||||||
import { BlockComponent } from '@blocksuite/affine/std';
|
import { BlockComponent } from '@blocksuite/affine/std';
|
||||||
import { computed } from '@preact/signals-core';
|
import { computed } from '@preact/signals-core';
|
||||||
import { html } from 'lit';
|
import { html } from 'lit';
|
||||||
|
|
||||||
import { ChatMessagesSchema } from '../../components/ai-chat-messages';
|
import { ChatMessagesSchema } from '../../components/ai-chat-messages';
|
||||||
|
import type { TextRendererOptions } from '../../components/text-renderer';
|
||||||
import { ChatWithAIIcon } from './components/icon';
|
import { ChatWithAIIcon } from './components/icon';
|
||||||
import { type AIChatBlockModel } from './model';
|
import { type AIChatBlockModel } from './model';
|
||||||
import { AIChatBlockStyles } from './styles';
|
import { AIChatBlockStyles } from './styles';
|
||||||
@@ -17,6 +19,8 @@ import { AIChatBlockStyles } from './styles';
|
|||||||
export class AIChatBlockComponent extends BlockComponent<AIChatBlockModel> {
|
export class AIChatBlockComponent extends BlockComponent<AIChatBlockModel> {
|
||||||
static override styles = AIChatBlockStyles;
|
static override styles = AIChatBlockStyles;
|
||||||
|
|
||||||
|
private _textRendererOptions: TextRendererOptions = {};
|
||||||
|
|
||||||
// Deserialize messages from JSON string and verify the type using zod
|
// Deserialize messages from JSON string and verify the type using zod
|
||||||
private readonly _deserializeChatMessages = computed(() => {
|
private readonly _deserializeChatMessages = computed(() => {
|
||||||
const messages = this.model.props.messages$.value;
|
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() {
|
override renderBlock() {
|
||||||
const messages = this._deserializeChatMessages.value.slice(-2);
|
const messages = this._deserializeChatMessages.value.slice(-2);
|
||||||
const textRendererOptions = {
|
|
||||||
customHeading: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
return html`<div class="affine-ai-chat-block-container">
|
return html`<div class="affine-ai-chat-block-container">
|
||||||
<div class="ai-chat-messages-container">
|
<div class="ai-chat-messages-container">
|
||||||
<ai-chat-messages
|
<ai-chat-messages
|
||||||
.host=${this.host}
|
.host=${this.host}
|
||||||
.messages=${messages}
|
.messages=${messages}
|
||||||
.textRendererOptions=${textRendererOptions}
|
.textRendererOptions=${this._textRendererOptions}
|
||||||
.withMask=${true}
|
.withMask=${true}
|
||||||
></ai-chat-messages>
|
></ai-chat-messages>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,6 +61,10 @@ export class AIChatBlockComponent extends BlockComponent<AIChatBlockModel> {
|
|||||||
</div>
|
</div>
|
||||||
</div> `;
|
</div> `;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get previewExtensions() {
|
||||||
|
return this.std.get(ViewExtensionManagerIdentifier).get('preview-page');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -158,6 +158,9 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor extensions!: ExtensionType[];
|
accessor extensions!: ExtensionType[];
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||||
|
|
||||||
@query('.chat-panel-messages-container')
|
@query('.chat-panel-messages-container')
|
||||||
accessor messagesContainer: HTMLDivElement | null = null;
|
accessor messagesContainer: HTMLDivElement | null = null;
|
||||||
|
|
||||||
@@ -271,6 +274,7 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
|
|||||||
.status=${isLast ? status : 'idle'}
|
.status=${isLast ? status : 'idle'}
|
||||||
.error=${isLast ? error : null}
|
.error=${isLast ? error : null}
|
||||||
.extensions=${this.extensions}
|
.extensions=${this.extensions}
|
||||||
|
.affineFeatureFlagService=${this.affineFeatureFlagService}
|
||||||
.getSessionId=${this.getSessionId}
|
.getSessionId=${this.getSessionId}
|
||||||
.retry=${() => this.retry()}
|
.retry=${() => this.retry()}
|
||||||
></chat-message-assistant>`;
|
></chat-message-assistant>`;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||||
import type { EditorHost } from '@blocksuite/affine/std';
|
import type { EditorHost } from '@blocksuite/affine/std';
|
||||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||||
@@ -20,11 +21,15 @@ export class ChatContentRichText extends WithDisposable(ShadowlessElement) {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor extensions!: ExtensionType[];
|
accessor extensions!: ExtensionType[];
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||||
|
|
||||||
protected override render() {
|
protected override render() {
|
||||||
const { text, host } = this;
|
const { text, host } = this;
|
||||||
return html`${createTextRenderer(host, {
|
return html`${createTextRenderer(host, {
|
||||||
customHeading: true,
|
customHeading: true,
|
||||||
extensions: this.extensions,
|
extensions: this.extensions,
|
||||||
|
affineFeatureFlagService: this.affineFeatureFlagService,
|
||||||
})(text, this.state)}`;
|
})(text, this.state)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import './chat-panel-messages';
|
import './chat-panel-messages';
|
||||||
|
|
||||||
|
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||||
import type { ContextEmbedStatus } from '@affine/graphql';
|
import type { ContextEmbedStatus } from '@affine/graphql';
|
||||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||||
import type { EditorHost } from '@blocksuite/affine/std';
|
import type { EditorHost } from '@blocksuite/affine/std';
|
||||||
@@ -205,6 +206,9 @@ export class ChatPanel extends SignalWatcher(
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor extensions!: ExtensionType[];
|
accessor extensions!: ExtensionType[];
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor isLoading = false;
|
accessor isLoading = false;
|
||||||
|
|
||||||
@@ -399,6 +403,7 @@ export class ChatPanel extends SignalWatcher(
|
|||||||
.host=${this.host}
|
.host=${this.host}
|
||||||
.isLoading=${this.isLoading}
|
.isLoading=${this.isLoading}
|
||||||
.extensions=${this.extensions}
|
.extensions=${this.extensions}
|
||||||
|
.affineFeatureFlagService=${this.affineFeatureFlagService}
|
||||||
></chat-panel-messages>
|
></chat-panel-messages>
|
||||||
<ai-chat-composer
|
<ai-chat-composer
|
||||||
.host=${this.host}
|
.host=${this.host}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import '../content/assistant-avatar';
|
import '../content/assistant-avatar';
|
||||||
import '../content/rich-text';
|
import '../content/rich-text';
|
||||||
|
|
||||||
|
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||||
import { isInsidePageEditor } from '@blocksuite/affine/shared/utils';
|
import { isInsidePageEditor } from '@blocksuite/affine/shared/utils';
|
||||||
import type { EditorHost } from '@blocksuite/affine/std';
|
import type { EditorHost } from '@blocksuite/affine/std';
|
||||||
@@ -48,6 +49,9 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor extensions!: ExtensionType[];
|
accessor extensions!: ExtensionType[];
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor getSessionId!: () => Promise<string | undefined>;
|
accessor getSessionId!: () => Promise<string | undefined>;
|
||||||
|
|
||||||
@@ -93,6 +97,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
|
|||||||
.text=${item.content}
|
.text=${item.content}
|
||||||
.state=${state}
|
.state=${state}
|
||||||
.extensions=${this.extensions}
|
.extensions=${this.extensions}
|
||||||
|
.affineFeatureFlagService=${this.affineFeatureFlagService}
|
||||||
></chat-content-rich-text>
|
></chat-content-rich-text>
|
||||||
${shouldRenderError ? AIChatErrorRenderer(host, error) : nothing}
|
${shouldRenderError ? AIChatErrorRenderer(host, error) : nothing}
|
||||||
${this.renderEditorActions()}
|
${this.renderEditorActions()}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createReactComponentFromLit } from '@affine/component';
|
import { createReactComponentFromLit } from '@affine/component';
|
||||||
|
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||||
import { Container, type ServiceProvider } from '@blocksuite/affine/global/di';
|
import { Container, type ServiceProvider } from '@blocksuite/affine/global/di';
|
||||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||||
import {
|
import {
|
||||||
@@ -6,10 +7,7 @@ import {
|
|||||||
defaultImageProxyMiddleware,
|
defaultImageProxyMiddleware,
|
||||||
ImageProxyService,
|
ImageProxyService,
|
||||||
} from '@blocksuite/affine/shared/adapters';
|
} from '@blocksuite/affine/shared/adapters';
|
||||||
import {
|
import { ThemeProvider } from '@blocksuite/affine/shared/services';
|
||||||
LinkPreviewerService,
|
|
||||||
ThemeProvider,
|
|
||||||
} from '@blocksuite/affine/shared/services';
|
|
||||||
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
||||||
import {
|
import {
|
||||||
BlockStdScope,
|
BlockStdScope,
|
||||||
@@ -104,6 +102,7 @@ export type TextRendererOptions = {
|
|||||||
extensions?: ExtensionType[];
|
extensions?: ExtensionType[];
|
||||||
additionalMiddlewares?: TransformerMiddleware[];
|
additionalMiddlewares?: TransformerMiddleware[];
|
||||||
testId?: string;
|
testId?: string;
|
||||||
|
affineFeatureFlagService?: FeatureFlagService;
|
||||||
};
|
};
|
||||||
|
|
||||||
// todo: refactor it for more general purpose usage instead of AI only?
|
// todo: refactor it for more general purpose usage instead of AI only?
|
||||||
@@ -266,7 +265,14 @@ export class TextRenderer extends WithDisposable(ShadowlessElement) {
|
|||||||
codeBlockWrapMiddleware(true),
|
codeBlockWrapMiddleware(true),
|
||||||
...(this.options.additionalMiddlewares ?? []),
|
...(this.options.additionalMiddlewares ?? []),
|
||||||
];
|
];
|
||||||
markDownToDoc(provider, schema, latestAnswer, middlewares)
|
const affineFeatureFlagService = this.options.affineFeatureFlagService;
|
||||||
|
markDownToDoc(
|
||||||
|
provider,
|
||||||
|
schema,
|
||||||
|
latestAnswer,
|
||||||
|
middlewares,
|
||||||
|
affineFeatureFlagService
|
||||||
|
)
|
||||||
.then(doc => {
|
.then(doc => {
|
||||||
this.disposeDoc();
|
this.disposeDoc();
|
||||||
this._doc = doc.doc.getStore({
|
this._doc = doc.doc.getStore({
|
||||||
@@ -278,16 +284,10 @@ export class TextRenderer extends WithDisposable(ShadowlessElement) {
|
|||||||
this._doc.readonly = true;
|
this._doc.readonly = true;
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
if (this.state !== 'generating') {
|
if (this.state !== 'generating') {
|
||||||
// LinkPreviewerService & ImageProxyService config should read from host settings
|
this._doc.load();
|
||||||
const linkPreviewerService =
|
// LinkPreviewService & ImageProxyService config should read from host settings
|
||||||
this.host?.std.store.get(LinkPreviewerService);
|
|
||||||
const imageProxyService =
|
const imageProxyService =
|
||||||
this.host?.std.store.get(ImageProxyService);
|
this.host?.std.store.get(ImageProxyService);
|
||||||
if (linkPreviewerService) {
|
|
||||||
this._doc
|
|
||||||
?.get(LinkPreviewerService)
|
|
||||||
.setEndpoint(linkPreviewerService.endpoint);
|
|
||||||
}
|
|
||||||
if (imageProxyService) {
|
if (imageProxyService) {
|
||||||
this._doc
|
this._doc
|
||||||
?.get(ImageProxyService)
|
?.get(ImageProxyService)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||||
import type { ContextEmbedStatus } from '@affine/graphql';
|
import type { ContextEmbedStatus } from '@affine/graphql';
|
||||||
import {
|
import {
|
||||||
CanvasElementType,
|
CanvasElementType,
|
||||||
@@ -445,7 +446,10 @@ export class AIChatBlockPeekView extends LitElement {
|
|||||||
.get(ViewExtensionManagerIdentifier)
|
.get(ViewExtensionManagerIdentifier)
|
||||||
.get('preview-page');
|
.get('preview-page');
|
||||||
|
|
||||||
this._textRendererOptions = { extensions };
|
this._textRendererOptions = {
|
||||||
|
extensions,
|
||||||
|
affineFeatureFlagService: this.affineFeatureFlagService,
|
||||||
|
};
|
||||||
this._historyMessages = this._deserializeHistoryChatMessages(
|
this._historyMessages = this._deserializeHistoryChatMessages(
|
||||||
this.historyMessagesString
|
this.historyMessagesString
|
||||||
);
|
);
|
||||||
@@ -556,6 +560,9 @@ export class AIChatBlockPeekView extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor searchMenuConfig!: SearchMenuConfig;
|
accessor searchMenuConfig!: SearchMenuConfig;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor _historyMessages: ChatMessage[] = [];
|
accessor _historyMessages: ChatMessage[] = [];
|
||||||
|
|
||||||
@@ -584,7 +591,8 @@ export const AIChatBlockPeekViewTemplate = (
|
|||||||
docDisplayConfig: DocDisplayConfig,
|
docDisplayConfig: DocDisplayConfig,
|
||||||
searchMenuConfig: SearchMenuConfig,
|
searchMenuConfig: SearchMenuConfig,
|
||||||
networkSearchConfig: AINetworkSearchConfig,
|
networkSearchConfig: AINetworkSearchConfig,
|
||||||
reasoningConfig: AIReasoningConfig
|
reasoningConfig: AIReasoningConfig,
|
||||||
|
affineFeatureFlagService: FeatureFlagService
|
||||||
) => {
|
) => {
|
||||||
return html`<ai-chat-block-peek-view
|
return html`<ai-chat-block-peek-view
|
||||||
.blockModel=${blockModel}
|
.blockModel=${blockModel}
|
||||||
@@ -593,5 +601,6 @@ export const AIChatBlockPeekViewTemplate = (
|
|||||||
.docDisplayConfig=${docDisplayConfig}
|
.docDisplayConfig=${docDisplayConfig}
|
||||||
.searchMenuConfig=${searchMenuConfig}
|
.searchMenuConfig=${searchMenuConfig}
|
||||||
.reasoningConfig=${reasoningConfig}
|
.reasoningConfig=${reasoningConfig}
|
||||||
|
.affineFeatureFlagService=${affineFeatureFlagService}
|
||||||
></ai-chat-block-peek-view>`;
|
></ai-chat-block-peek-view>`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -170,7 +170,8 @@ const usePreviewExtensions = () => {
|
|||||||
.theme(framework)
|
.theme(framework)
|
||||||
.database(framework)
|
.database(framework)
|
||||||
.linkedDoc(framework)
|
.linkedDoc(framework)
|
||||||
.paragraph(enableAI).value;
|
.paragraph(enableAI)
|
||||||
|
.linkPreview(framework).value;
|
||||||
const specs = manager.get('preview-page');
|
const specs = manager.get('preview-page');
|
||||||
return [...specs, patchReferenceRenderer(reactToLit, referenceRenderer)];
|
return [...specs, patchReferenceRenderer(reactToLit, referenceRenderer)];
|
||||||
}, [reactToLit, referenceRenderer, framework, enableAI]);
|
}, [reactToLit, referenceRenderer, framework, enableAI]);
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
ImageProxyService,
|
ImageProxyService,
|
||||||
} from '@blocksuite/affine/shared/adapters';
|
} from '@blocksuite/affine/shared/adapters';
|
||||||
import { focusBlockEnd } from '@blocksuite/affine/shared/commands';
|
import { focusBlockEnd } from '@blocksuite/affine/shared/commands';
|
||||||
import { LinkPreviewerService } from '@blocksuite/affine/shared/services';
|
|
||||||
import { getLastNoteBlock } from '@blocksuite/affine/shared/utils';
|
import { getLastNoteBlock } from '@blocksuite/affine/shared/utils';
|
||||||
import type { BlockStdScope, EditorHost } from '@blocksuite/affine/std';
|
import type { BlockStdScope, EditorHost } from '@blocksuite/affine/std';
|
||||||
import type { Store } from '@blocksuite/affine/store';
|
import type { Store } from '@blocksuite/affine/store';
|
||||||
@@ -212,13 +211,7 @@ const BlockSuiteEditorImpl = ({
|
|||||||
server.baseUrl
|
server.baseUrl
|
||||||
).toString();
|
).toString();
|
||||||
|
|
||||||
const linkPreviewUrl = new URL(
|
|
||||||
BUILD_CONFIG.linkPreviewUrl,
|
|
||||||
server.baseUrl
|
|
||||||
).toString();
|
|
||||||
|
|
||||||
editor.std.clipboard.use(customImageProxyMiddleware(imageProxyUrl));
|
editor.std.clipboard.use(customImageProxyMiddleware(imageProxyUrl));
|
||||||
page.get(LinkPreviewerService).setEndpoint(linkPreviewUrl);
|
|
||||||
page.get(ImageProxyService).setImageProxyURL(imageProxyUrl);
|
page.get(ImageProxyService).setImageProxyURL(imageProxyUrl);
|
||||||
|
|
||||||
editor.updateComplete
|
editor.updateComplete
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ const usePatchSpecs = (mode: DocMode) => {
|
|||||||
.linkedDoc(framework)
|
.linkedDoc(framework)
|
||||||
.paragraph(enableAI)
|
.paragraph(enableAI)
|
||||||
.mobile(framework)
|
.mobile(framework)
|
||||||
.electron(framework).value;
|
.electron(framework)
|
||||||
|
.linkPreview(framework).value;
|
||||||
|
|
||||||
if (BUILD_CONFIG.isMobileEdition) {
|
if (BUILD_CONFIG.isMobileEdition) {
|
||||||
if (mode === 'page') {
|
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 { createDatabaseOptionsConfig } from '@affine/core/blocksuite/extensions/editor-config/database';
|
||||||
import { createLinkedWidgetConfig } from '@affine/core/blocksuite/extensions/editor-config/linked';
|
import { createLinkedWidgetConfig } from '@affine/core/blocksuite/extensions/editor-config/linked';
|
||||||
import { ElectronViewExtension } from '@affine/core/blocksuite/extensions/electron';
|
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 { MobileViewExtension } from '@affine/core/blocksuite/extensions/mobile';
|
||||||
import { PdfViewExtension } from '@affine/core/blocksuite/extensions/pdf';
|
import { PdfViewExtension } from '@affine/core/blocksuite/extensions/pdf';
|
||||||
import { AffineThemeViewExtension } from '@affine/core/blocksuite/extensions/theme';
|
import { AffineThemeViewExtension } from '@affine/core/blocksuite/extensions/theme';
|
||||||
@@ -44,6 +45,7 @@ type Configure = {
|
|||||||
mobile: (framework?: FrameworkProvider) => Configure;
|
mobile: (framework?: FrameworkProvider) => Configure;
|
||||||
ai: (enable?: boolean, framework?: FrameworkProvider) => Configure;
|
ai: (enable?: boolean, framework?: FrameworkProvider) => Configure;
|
||||||
electron: (framework?: FrameworkProvider) => Configure;
|
electron: (framework?: FrameworkProvider) => Configure;
|
||||||
|
linkPreview: (framework?: FrameworkProvider) => Configure;
|
||||||
|
|
||||||
value: ViewExtensionManager;
|
value: ViewExtensionManager;
|
||||||
};
|
};
|
||||||
@@ -75,6 +77,7 @@ class ViewProvider {
|
|||||||
MobileViewExtension,
|
MobileViewExtension,
|
||||||
AIViewExtension,
|
AIViewExtension,
|
||||||
ElectronViewExtension,
|
ElectronViewExtension,
|
||||||
|
AffineLinkPreviewExtension,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +102,7 @@ class ViewProvider {
|
|||||||
mobile: this._configureMobile,
|
mobile: this._configureMobile,
|
||||||
ai: this._configureAI,
|
ai: this._configureAI,
|
||||||
electron: this._configureElectron,
|
electron: this._configureElectron,
|
||||||
|
linkPreview: this._configureLinkPreview,
|
||||||
value: this._manager,
|
value: this._manager,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -118,7 +122,8 @@ class ViewProvider {
|
|||||||
.pdf()
|
.pdf()
|
||||||
.mobile()
|
.mobile()
|
||||||
.ai()
|
.ai()
|
||||||
.electron();
|
.electron()
|
||||||
|
.linkPreview();
|
||||||
|
|
||||||
return this.config;
|
return this.config;
|
||||||
};
|
};
|
||||||
@@ -256,6 +261,11 @@ class ViewProvider {
|
|||||||
this._manager.configure(ElectronViewExtension, { framework });
|
this._manager.configure(ElectronViewExtension, { framework });
|
||||||
return this.config;
|
return this.config;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private readonly _configureLinkPreview = (framework?: FrameworkProvider) => {
|
||||||
|
this._manager.configure(AffineLinkPreviewExtension, { framework });
|
||||||
|
return this.config;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getViewManager() {
|
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 { WorkspaceImpl } from '@affine/core/modules/workspace/impls/workspace';
|
||||||
import type { ServiceProvider } from '@blocksuite/affine/global/di';
|
import type { ServiceProvider } from '@blocksuite/affine/global/di';
|
||||||
import {
|
import {
|
||||||
@@ -161,11 +162,13 @@ export async function markDownToDoc(
|
|||||||
provider: ServiceProvider,
|
provider: ServiceProvider,
|
||||||
schema: Schema,
|
schema: Schema,
|
||||||
answer: string,
|
answer: string,
|
||||||
middlewares?: TransformerMiddleware[]
|
middlewares?: TransformerMiddleware[],
|
||||||
|
affineFeatureFlagService?: FeatureFlagService
|
||||||
) {
|
) {
|
||||||
// Should not create a new doc in the original collection
|
// Should not create a new doc in the original collection
|
||||||
const collection = new WorkspaceImpl({
|
const collection = new WorkspaceImpl({
|
||||||
rootDoc: new YDoc({ guid: 'markdownToDoc' }),
|
rootDoc: new YDoc({ guid: 'markdownToDoc' }),
|
||||||
|
featureFlagService: affineFeatureFlagService,
|
||||||
});
|
});
|
||||||
collection.meta.initialize();
|
collection.meta.initialize();
|
||||||
const transformer = new Transformer({
|
const transformer = new Transformer({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ChatPanel } from '@affine/core/blocksuite/ai';
|
import { ChatPanel } from '@affine/core/blocksuite/ai';
|
||||||
import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor';
|
import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor';
|
||||||
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
|
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 { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
import { ViewExtensionManagerIdentifier } from '@blocksuite/affine/ext-loader';
|
import { ViewExtensionManagerIdentifier } from '@blocksuite/affine/ext-loader';
|
||||||
import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference';
|
import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference';
|
||||||
@@ -75,6 +76,8 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
|
|||||||
chatPanelRef.current.extensions = editor.host.std
|
chatPanelRef.current.extensions = editor.host.std
|
||||||
.get(ViewExtensionManagerIdentifier)
|
.get(ViewExtensionManagerIdentifier)
|
||||||
.get('preview-page');
|
.get('preview-page');
|
||||||
|
chatPanelRef.current.affineFeatureFlagService =
|
||||||
|
framework.get(FeatureFlagService);
|
||||||
|
|
||||||
containerRef.current?.append(chatPanelRef.current);
|
containerRef.current?.append(chatPanelRef.current);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
customImageProxyMiddleware,
|
customImageProxyMiddleware,
|
||||||
ImageProxyService,
|
ImageProxyService,
|
||||||
} from '@blocksuite/affine/shared/adapters';
|
} from '@blocksuite/affine/shared/adapters';
|
||||||
import { LinkPreviewerService } from '@blocksuite/affine/shared/services';
|
|
||||||
import {
|
import {
|
||||||
FrameworkScope,
|
FrameworkScope,
|
||||||
useLiveData,
|
useLiveData,
|
||||||
@@ -139,11 +138,6 @@ const DetailPageImpl = () => {
|
|||||||
server.baseUrl
|
server.baseUrl
|
||||||
).toString();
|
).toString();
|
||||||
|
|
||||||
const linkPreviewUrl = new URL(
|
|
||||||
BUILD_CONFIG.linkPreviewUrl,
|
|
||||||
server.baseUrl
|
|
||||||
).toString();
|
|
||||||
|
|
||||||
editorContainer.std.clipboard.use(
|
editorContainer.std.clipboard.use(
|
||||||
customImageProxyMiddleware(imageProxyUrl)
|
customImageProxyMiddleware(imageProxyUrl)
|
||||||
);
|
);
|
||||||
@@ -151,9 +145,6 @@ const DetailPageImpl = () => {
|
|||||||
.get(ImageProxyService)
|
.get(ImageProxyService)
|
||||||
.setImageProxyURL(imageProxyUrl);
|
.setImageProxyURL(imageProxyUrl);
|
||||||
|
|
||||||
// provide link preview endpoint to blocksuite
|
|
||||||
editorContainer.doc.get(LinkPreviewerService).setEndpoint(linkPreviewUrl);
|
|
||||||
|
|
||||||
// provide page mode and updated date to blocksuite
|
// provide page mode and updated date to blocksuite
|
||||||
const refNodeService =
|
const refNodeService =
|
||||||
editorContainer.std.getOptional(RefNodeSlotsProvider);
|
editorContainer.std.getOptional(RefNodeSlotsProvider);
|
||||||
|
|||||||
@@ -106,6 +106,16 @@ export const AFFINE_FLAGS = {
|
|||||||
configurable: isCanaryBuild,
|
configurable: isCanaryBuild,
|
||||||
defaultState: 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: {
|
enable_emoji_folder_icon: {
|
||||||
category: 'affine',
|
category: 'affine',
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { toReactNode } from '@affine/component';
|
|||||||
import { AIChatBlockPeekViewTemplate } from '@affine/core/blocksuite/ai';
|
import { AIChatBlockPeekViewTemplate } from '@affine/core/blocksuite/ai';
|
||||||
import type { AIChatBlockModel } from '@affine/core/blocksuite/ai/blocks/ai-chat-block/model/ai-chat-model';
|
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 { 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 type { EditorHost } from '@blocksuite/affine/std';
|
||||||
|
import { useFramework } from '@toeverything/infra';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
export type AIChatBlockPeekViewProps = {
|
export type AIChatBlockPeekViewProps = {
|
||||||
@@ -21,6 +23,9 @@ export const AIChatBlockPeekView = ({
|
|||||||
reasoningConfig,
|
reasoningConfig,
|
||||||
} = useAIChatConfig();
|
} = useAIChatConfig();
|
||||||
|
|
||||||
|
const framework = useFramework();
|
||||||
|
const affineFeatureFlagService = framework.get(FeatureFlagService);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const template = AIChatBlockPeekViewTemplate(
|
const template = AIChatBlockPeekViewTemplate(
|
||||||
model,
|
model,
|
||||||
@@ -28,7 +33,8 @@ export const AIChatBlockPeekView = ({
|
|||||||
docDisplayConfig,
|
docDisplayConfig,
|
||||||
searchMenuConfig,
|
searchMenuConfig,
|
||||||
networkSearchConfig,
|
networkSearchConfig,
|
||||||
reasoningConfig
|
reasoningConfig,
|
||||||
|
affineFeatureFlagService
|
||||||
);
|
);
|
||||||
return toReactNode(template);
|
return toReactNode(template);
|
||||||
}, [
|
}, [
|
||||||
@@ -38,5 +44,6 @@ export const AIChatBlockPeekView = ({
|
|||||||
searchMenuConfig,
|
searchMenuConfig,
|
||||||
networkSearchConfig,
|
networkSearchConfig,
|
||||||
reasoningConfig,
|
reasoningConfig,
|
||||||
|
affineFeatureFlagService,
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"it-IT": 96,
|
"it-IT": 96,
|
||||||
"it": 1,
|
"it": 1,
|
||||||
"ja": 96,
|
"ja": 96,
|
||||||
"ko": 56,
|
"ko": 55,
|
||||||
"pl": 96,
|
"pl": 96,
|
||||||
"pt-BR": 96,
|
"pt-BR": 96,
|
||||||
"ru": 96,
|
"ru": 96,
|
||||||
|
|||||||
@@ -5722,6 +5722,14 @@ export function useAFFiNEI18N(): {
|
|||||||
* `Enable citation feature.`
|
* `Enable citation feature.`
|
||||||
*/
|
*/
|
||||||
["com.affine.settings.workspace.experimental-features.enable-citation.description"](): string;
|
["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`
|
* `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-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.name": "Citation",
|
||||||
"com.affine.settings.workspace.experimental-features.enable-citation.description": "Enable citation feature.",
|
"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.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-embed-iframe-block.description": "Enables Embed Iframe Block.",
|
||||||
"com.affine.settings.workspace.experimental-features.enable-emoji-folder-icon.name": "Emoji Folder Icon",
|
"com.affine.settings.workspace.experimental-features.enable-emoji-folder-icon.name": "Emoji Folder Icon",
|
||||||
|
|||||||
@@ -3707,6 +3707,7 @@ __metadata:
|
|||||||
micromark-extension-gfm-task-list-item: "npm:^2.1.0"
|
micromark-extension-gfm-task-list-item: "npm:^2.1.0"
|
||||||
micromark-util-combine-extensions: "npm:^2.0.0"
|
micromark-util-combine-extensions: "npm:^2.0.0"
|
||||||
minimatch: "npm:^10.0.1"
|
minimatch: "npm:^10.0.1"
|
||||||
|
quick-lru: "npm:^7.0.1"
|
||||||
rehype-parse: "npm:^9.0.0"
|
rehype-parse: "npm:^9.0.0"
|
||||||
rehype-stringify: "npm:^10.0.0"
|
rehype-stringify: "npm:^10.0.0"
|
||||||
remark-math: "npm:^6.0.0"
|
remark-math: "npm:^6.0.0"
|
||||||
@@ -29524,6 +29525,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"raf-schd@npm:^4.0.3":
|
||||||
version: 4.0.3
|
version: 4.0.3
|
||||||
resolution: "raf-schd@npm:4.0.3"
|
resolution: "raf-schd@npm:4.0.3"
|
||||||
|
|||||||
Reference in New Issue
Block a user