mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-22 20:46:38 +08:00
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 {
|
||||
DocModeProvider,
|
||||
LinkPreviewerService,
|
||||
LinkPreviewServiceIdentifier,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { BlockSelection } from '@blocksuite/std';
|
||||
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
|
||||
@@ -72,8 +72,8 @@ export class BookmarkBlockComponent extends CaptionedBlockComponent<BookmarkBloc
|
||||
this.loading = true;
|
||||
this.error = false;
|
||||
|
||||
this.std.store
|
||||
.get(LinkPreviewerService)
|
||||
this.std
|
||||
.get(LinkPreviewServiceIdentifier)
|
||||
.query(this.model.props.url, this._fetchAbortController.signal)
|
||||
.then(data => {
|
||||
this._localLinkPreview$.value = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LinkPreviewerService } from '@blocksuite/affine-shared/services';
|
||||
import { LinkPreviewServiceIdentifier } from '@blocksuite/affine-shared/services';
|
||||
import { isAbortError } from '@blocksuite/affine-shared/utils';
|
||||
|
||||
import type { BookmarkBlockComponent } from './bookmark-block.js';
|
||||
@@ -15,7 +15,7 @@ export async function refreshBookmarkUrlData(
|
||||
try {
|
||||
bookmarkElement.loading = true;
|
||||
|
||||
const linkPreviewer = bookmarkElement.store.get(LinkPreviewerService);
|
||||
const linkPreviewer = bookmarkElement.std.get(LinkPreviewServiceIdentifier);
|
||||
const bookmarkUrlData = await linkPreviewer.query(
|
||||
bookmarkElement.model.props.url,
|
||||
signal
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EmbedOptionConfig,
|
||||
LinkPreviewerService,
|
||||
LinkPreviewServiceIdentifier,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { BlockService } from '@blocksuite/std';
|
||||
|
||||
@@ -22,7 +22,7 @@ export class EmbedGithubBlockService extends BlockService {
|
||||
queryUrlData = (embedGithubModel: EmbedGithubModel, signal?: AbortSignal) => {
|
||||
return queryEmbedGithubData(
|
||||
embedGithubModel,
|
||||
this.doc.get(LinkPreviewerService),
|
||||
this.std.get(LinkPreviewServiceIdentifier),
|
||||
signal
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
EmbedGithubBlockUrlData,
|
||||
EmbedGithubModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type { LinkPreviewerService } from '@blocksuite/affine-shared/services';
|
||||
import type { LinkPreviewProvider } from '@blocksuite/affine-shared/services';
|
||||
import { isAbortError } from '@blocksuite/affine-shared/utils';
|
||||
import { nothing } from 'lit';
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
|
||||
export async function queryEmbedGithubData(
|
||||
embedGithubModel: EmbedGithubModel,
|
||||
linkPreviewer: LinkPreviewerService,
|
||||
linkPreviewer: LinkPreviewProvider,
|
||||
signal?: AbortSignal
|
||||
): Promise<Partial<EmbedGithubBlockUrlData>> {
|
||||
const [githubApiData, openGraphData] = await Promise.all([
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type EmbedIframeData,
|
||||
EmbedIframeService,
|
||||
type IframeOptions,
|
||||
LinkPreviewerService,
|
||||
LinkPreviewServiceIdentifier,
|
||||
NotificationProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
@@ -94,7 +94,7 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
}
|
||||
|
||||
get linkPreviewService() {
|
||||
return this.std.get(LinkPreviewerService);
|
||||
return this.std.get(LinkPreviewServiceIdentifier);
|
||||
}
|
||||
|
||||
get notificationService() {
|
||||
@@ -156,7 +156,7 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
if (!embedIframeService || !linkPreviewService) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'EmbedIframeService or LinkPreviewerService not found'
|
||||
'EmbedIframeService or LinkPreviewService not found'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EmbedOptionConfig,
|
||||
LinkPreviewerService,
|
||||
LinkPreviewServiceIdentifier,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { BlockService } from '@blocksuite/std';
|
||||
|
||||
@@ -21,7 +21,7 @@ export class EmbedYoutubeBlockService extends BlockService {
|
||||
) => {
|
||||
return queryEmbedYoutubeData(
|
||||
embedYoutubeModel,
|
||||
this.doc.get(LinkPreviewerService),
|
||||
this.std.get(LinkPreviewServiceIdentifier),
|
||||
signal
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,14 +2,14 @@ import type {
|
||||
EmbedYoutubeBlockUrlData,
|
||||
EmbedYoutubeModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type { LinkPreviewerService } from '@blocksuite/affine-shared/services';
|
||||
import type { LinkPreviewProvider } from '@blocksuite/affine-shared/services';
|
||||
import { isAbortError } from '@blocksuite/affine-shared/utils';
|
||||
|
||||
import type { EmbedYoutubeBlockComponent } from './embed-youtube-block.js';
|
||||
|
||||
export async function queryEmbedYoutubeData(
|
||||
embedYoutubeModel: EmbedYoutubeModel,
|
||||
linkPreviewer: LinkPreviewerService,
|
||||
linkPreviewer: LinkPreviewProvider,
|
||||
signal?: AbortSignal
|
||||
): Promise<Partial<EmbedYoutubeBlockUrlData>> {
|
||||
const url = embedYoutubeModel.props.url;
|
||||
|
||||
@@ -15,7 +15,6 @@ import { HighlightSelectionExtension } from '@blocksuite/affine-shared/selection
|
||||
import {
|
||||
BlockMetaService,
|
||||
FeatureFlagService,
|
||||
LinkPreviewerService,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
BlockSelectionExtension,
|
||||
@@ -49,7 +48,6 @@ export class FoundationStoreExtension extends StoreExtensionProvider {
|
||||
FeatureFlagService,
|
||||
BlockMetaService,
|
||||
// TODO(@mirone): maybe merge these services into a file setting service
|
||||
LinkPreviewerService,
|
||||
ImageProxyService,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
EmbedOptionService,
|
||||
FileSizeLimitService,
|
||||
FontLoaderService,
|
||||
LinkPreviewCache,
|
||||
LinkPreviewService,
|
||||
PageViewportServiceExtension,
|
||||
ThemeService,
|
||||
ToolbarRegistryExtension,
|
||||
@@ -47,6 +49,8 @@ export class FoundationViewExtension extends ViewExtensionProvider {
|
||||
ToolbarRegistryExtension,
|
||||
AutoClearSelectionService,
|
||||
FileSizeLimitService,
|
||||
LinkPreviewCache,
|
||||
LinkPreviewService,
|
||||
]);
|
||||
context.register(clipboardConfigs);
|
||||
if (this.isEdgeless(context.scope)) {
|
||||
|
||||
@@ -150,6 +150,20 @@ export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) {
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
private readonly _updateFootnoteAttributes = (footnote: FootNote) => {
|
||||
if (!this.footnote || this.readonly) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.inlineEditor || !this.selfInlineRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inlineEditor.formatText(this.selfInlineRange, {
|
||||
footnote: footnote,
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _FootNoteDefaultContent = (footnote: FootNote) => {
|
||||
return html`<span
|
||||
class="footnote-content-default"
|
||||
@@ -169,6 +183,7 @@ export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) {
|
||||
.std=${this.std}
|
||||
.abortController=${abortController}
|
||||
.onPopupClick=${this.onPopupClick ?? this.onFootnoteClick}
|
||||
.updateFootnoteAttributes=${this._updateFootnoteAttributes}
|
||||
></footnote-popup>`;
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { FootNote } from '@blocksuite/affine-model';
|
||||
import { ImageProxyService } from '@blocksuite/affine-shared/adapters';
|
||||
import {
|
||||
DocDisplayMetaProvider,
|
||||
LinkPreviewerService,
|
||||
LinkPreviewServiceIdentifier,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
@@ -121,7 +121,7 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
if (referenceType === 'url') {
|
||||
const title = this._linkPreview$.value?.title;
|
||||
const url = this.footnote.reference.url;
|
||||
return [title, url].filter(Boolean).join(' ') || '';
|
||||
return [title, url].filter(Boolean).join('\n') || '';
|
||||
}
|
||||
return this._popupLabel$.value;
|
||||
});
|
||||
@@ -160,15 +160,29 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
isTitleAndDescriptionEmpty
|
||||
) {
|
||||
this._isLoading$.value = true;
|
||||
this.std.store
|
||||
.get(LinkPreviewerService)
|
||||
this.std
|
||||
.get(LinkPreviewServiceIdentifier)
|
||||
.query(this.footnote.reference.url)
|
||||
.then(data => {
|
||||
// update the local link preview data
|
||||
this._linkPreview$.value = {
|
||||
favicon: data.icon ?? undefined,
|
||||
title: data.title ?? undefined,
|
||||
description: data.description ?? undefined,
|
||||
};
|
||||
|
||||
// update the footnote attributes in the node with the link preview data
|
||||
// to avoid fetching the same data multiple times
|
||||
const footnote: FootNote = {
|
||||
...this.footnote,
|
||||
reference: {
|
||||
...this.footnote.reference,
|
||||
...(data.icon && { favicon: data.icon }),
|
||||
...(data.title && { title: data.title }),
|
||||
...(data.description && { description: data.description }),
|
||||
},
|
||||
};
|
||||
this.updateFootnoteAttributes(footnote);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
@@ -209,4 +223,7 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onPopupClick: FootNotePopupClickHandler | (() => void) = () => {};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor updateFootnoteAttributes: (footnote: FootNote) => void = () => {};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GfxModel } from '@blocksuite/std/gfx';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type {
|
||||
BrushElementModel,
|
||||
@@ -20,12 +21,14 @@ export type EmbedCardStyle =
|
||||
| 'pdf'
|
||||
| 'citation';
|
||||
|
||||
export type LinkPreviewData = {
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
image: string | null;
|
||||
title: string | null;
|
||||
};
|
||||
export const LinkPreviewDataSchema = z.object({
|
||||
description: z.string().nullable(),
|
||||
icon: z.string().nullable(),
|
||||
image: z.string().nullable(),
|
||||
title: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type LinkPreviewData = z.infer<typeof LinkPreviewDataSchema>;
|
||||
|
||||
export type Connectable = Exclude<
|
||||
GfxModel,
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"micromark-extension-gfm-task-list-item": "^2.1.0",
|
||||
"micromark-util-combine-extensions": "^2.0.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"quick-lru": "^7.0.1",
|
||||
"rehype-parse": "^9.0.0",
|
||||
"rehype-stringify": "^10.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface BlockSuiteFlags {
|
||||
enable_embed_doc_with_alias: boolean;
|
||||
enable_turbo_renderer: boolean;
|
||||
enable_citation: boolean;
|
||||
enable_link_preview_cache: boolean;
|
||||
}
|
||||
|
||||
export class FeatureFlagService extends StoreExtension {
|
||||
@@ -48,6 +49,7 @@ export class FeatureFlagService extends StoreExtension {
|
||||
enable_embed_doc_with_alias: false,
|
||||
enable_turbo_renderer: false,
|
||||
enable_citation: false,
|
||||
enable_link_preview_cache: false,
|
||||
});
|
||||
|
||||
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
|
||||
|
||||
@@ -11,7 +11,7 @@ export * from './feature-flag-service';
|
||||
export * from './file-size-limit-service';
|
||||
export * from './font-loader';
|
||||
export * from './generate-url-service';
|
||||
export * from './link-previewer-service';
|
||||
export * from './link-preview-service';
|
||||
export * from './native-clipboard-service';
|
||||
export * from './notification-service';
|
||||
export * from './open-doc-config';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './link-preview-cache';
|
||||
export * from './link-preview-service';
|
||||
export * from './link-preview-storage';
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { LinkPreviewData } from '@blocksuite/affine-model';
|
||||
import { type Container, createIdentifier } from '@blocksuite/global/di';
|
||||
import { Extension, type ExtensionType } from '@blocksuite/store';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
import QuickLRU from 'quick-lru';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LinkPreviewStorage } from './link-preview-storage';
|
||||
|
||||
export const LinkPreviewCacheConfigSchema = z.object({
|
||||
/**
|
||||
* The maximum number of items in the cache
|
||||
*/
|
||||
cacheSize: z.number(),
|
||||
/**
|
||||
* The time to live for the memory cache
|
||||
*/
|
||||
memoryTTL: z.number(),
|
||||
/**
|
||||
* The time to live for the local storage cache
|
||||
*/
|
||||
localStorageTTL: z.number(),
|
||||
});
|
||||
|
||||
export type LinkPreviewCacheConfig = z.infer<
|
||||
typeof LinkPreviewCacheConfigSchema
|
||||
>;
|
||||
|
||||
const DEFAULT_LINK_PREVIEW_CACHE_CONFIG: LinkPreviewCacheConfig = {
|
||||
cacheSize: 50,
|
||||
memoryTTL: 1000 * 60 * 60, // 60 minutes
|
||||
localStorageTTL: 1000 * 60 * 60 * 6, // 6 hours
|
||||
};
|
||||
|
||||
/**
|
||||
* It's used to debounce the save to local storage to avoid frequent writes
|
||||
*/
|
||||
const DEBOUNCE_TIME = 1000;
|
||||
|
||||
/**
|
||||
* The interface for the link preview cache provider
|
||||
*/
|
||||
export interface LinkPreviewCacheProvider {
|
||||
/**
|
||||
* Get the link preview data for a given URL
|
||||
* @param url The URL to get the link preview data for
|
||||
* @returns The link preview data for the given URL
|
||||
*/
|
||||
get(url: string): Partial<LinkPreviewData> | undefined;
|
||||
/**
|
||||
* Set the link preview data for a given URL
|
||||
* @param url The URL to set the link preview data for
|
||||
* @param data The link preview data to set
|
||||
*/
|
||||
set(url: string, data: Partial<LinkPreviewData>): void;
|
||||
/**
|
||||
* Get the pending request for a given URL
|
||||
* @param url The URL to get the pending request for
|
||||
* @returns The pending request for the given URL
|
||||
*/
|
||||
getPendingRequest(url: string): Promise<Partial<LinkPreviewData>> | undefined;
|
||||
/**
|
||||
* Set the pending request for a given URL
|
||||
* @param url The URL to set the pending request for
|
||||
* @param promise The promise to set for the given URL
|
||||
*/
|
||||
setPendingRequest(
|
||||
url: string,
|
||||
promise: Promise<Partial<LinkPreviewData>>
|
||||
): void;
|
||||
/**
|
||||
* Delete the pending request for a given URL
|
||||
* @param url The URL to delete the pending request for
|
||||
*/
|
||||
deletePendingRequest(url: string): void;
|
||||
/**
|
||||
* Clear the cache
|
||||
*/
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export const LinkPreviewCacheIdentifier =
|
||||
createIdentifier<LinkPreviewCacheProvider>('AffineLinkPreviewCache');
|
||||
|
||||
/**
|
||||
* The link preview cache, it will cache the link preview data in the memory and local storage
|
||||
*/
|
||||
export class LinkPreviewCache
|
||||
extends Extension
|
||||
implements LinkPreviewCacheProvider
|
||||
{
|
||||
/**
|
||||
* The singleton instance of the link preview cache
|
||||
*/
|
||||
private static instance: LinkPreviewCache | null = null;
|
||||
|
||||
/**
|
||||
* The memory cache for the link preview
|
||||
*/
|
||||
private readonly memoryCache: QuickLRU<string, Partial<LinkPreviewData>>;
|
||||
/**
|
||||
* The pending requests for the link preview
|
||||
* The promise will be resolved when the data is fetched
|
||||
*/
|
||||
private readonly pendingRequests: Map<
|
||||
string,
|
||||
Promise<Partial<LinkPreviewData>>
|
||||
>;
|
||||
/**
|
||||
* The local storage manager for the link preview
|
||||
*/
|
||||
private readonly storage: LinkPreviewStorage;
|
||||
|
||||
constructor(
|
||||
private readonly config: LinkPreviewCacheConfig = DEFAULT_LINK_PREVIEW_CACHE_CONFIG
|
||||
) {
|
||||
super();
|
||||
this.storage = new LinkPreviewStorage();
|
||||
this.memoryCache = new QuickLRU({
|
||||
maxSize: this.config.cacheSize,
|
||||
maxAge: this.config.memoryTTL,
|
||||
onEviction: key => {
|
||||
this._clearItemFromStorage(key);
|
||||
},
|
||||
});
|
||||
this.pendingRequests = new Map();
|
||||
this._loadFromStorage();
|
||||
}
|
||||
|
||||
static getInstance(config?: LinkPreviewCacheConfig): LinkPreviewCache {
|
||||
if (!LinkPreviewCache.instance) {
|
||||
LinkPreviewCache.instance = new LinkPreviewCache(config);
|
||||
}
|
||||
return LinkPreviewCache.instance;
|
||||
}
|
||||
|
||||
get(url: string): Partial<LinkPreviewData> | undefined {
|
||||
return this.memoryCache.get(url);
|
||||
}
|
||||
|
||||
set(url: string, data: Partial<LinkPreviewData>): void {
|
||||
this.memoryCache.set(url, data);
|
||||
this._saveToStorage();
|
||||
}
|
||||
|
||||
getPendingRequest(
|
||||
url: string
|
||||
): Promise<Partial<LinkPreviewData>> | undefined {
|
||||
return this.pendingRequests.get(url);
|
||||
}
|
||||
|
||||
setPendingRequest(
|
||||
url: string,
|
||||
promise: Promise<Partial<LinkPreviewData>>
|
||||
): void {
|
||||
this.pendingRequests.set(url, promise);
|
||||
}
|
||||
|
||||
deletePendingRequest(url: string): void {
|
||||
this.pendingRequests.delete(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the cache from local storage
|
||||
*/
|
||||
private readonly _loadFromStorage = (): void => {
|
||||
const data = this.storage.load();
|
||||
|
||||
// Check if the data is expired
|
||||
const localDataExpires = data.expires;
|
||||
// If the data is expired, clear the data
|
||||
if (localDataExpires && localDataExpires < Date.now()) {
|
||||
this.storage.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// load the data to the memory cache
|
||||
Object.entries(data.data).forEach(([url, item]) => {
|
||||
this.memoryCache.set(url, item);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Save the cache to local storage
|
||||
* Debounce the save to local storage to avoid frequent writes
|
||||
*/
|
||||
private readonly _saveToStorage = debounce(() => {
|
||||
const entries = Array.from(this.memoryCache.entriesDescending());
|
||||
const linkPreviewData = Object.fromEntries(
|
||||
entries.slice(0, this.memoryCache.size).map(([url, data]) => [url, data])
|
||||
);
|
||||
const data = {
|
||||
data: linkPreviewData,
|
||||
expires: Date.now() + this.config.localStorageTTL,
|
||||
};
|
||||
this.storage.save(data);
|
||||
}, DEBOUNCE_TIME);
|
||||
|
||||
/**
|
||||
* Clear a link preview record from local storage with specific URL
|
||||
* Called when the item is evicted from the memory cache
|
||||
* @param {string} url The URL key to remove from storage
|
||||
* @returns {boolean} Whether the item was successfully removed
|
||||
*/
|
||||
private readonly _clearItemFromStorage = (url: string): void => {
|
||||
this.storage.clearItem(url);
|
||||
};
|
||||
|
||||
clear(): void {
|
||||
this.memoryCache.clear();
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
|
||||
clearLocalStorage(): void {
|
||||
this.storage.clear();
|
||||
}
|
||||
|
||||
static override setup(di: Container) {
|
||||
di.addImpl(LinkPreviewCacheIdentifier, () =>
|
||||
LinkPreviewCache.getInstance()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The extension for the link preview cache, it will override the link preview cache instance
|
||||
* @param config - The configuration for the link preview cache
|
||||
* @returns The extension for the link preview cache
|
||||
*/
|
||||
export const LinkPreviewCacheExtension = (
|
||||
config?: LinkPreviewCacheConfig
|
||||
): ExtensionType => {
|
||||
return {
|
||||
setup: (di: Container) => {
|
||||
di.override(LinkPreviewCacheIdentifier, () =>
|
||||
LinkPreviewCache.getInstance(config)
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,225 @@
|
||||
import { type LinkPreviewData } from '@blocksuite/affine-model';
|
||||
import { type Container, createIdentifier } from '@blocksuite/global/di';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { type BlockStdScope, StdIdentifier } from '@blocksuite/std';
|
||||
import { Extension } from '@blocksuite/store';
|
||||
|
||||
import { DEFAULT_LINK_PREVIEW_ENDPOINT } from '../../consts';
|
||||
import { isAbortError } from '../../utils/is-abort-error';
|
||||
import { FeatureFlagService } from '../feature-flag-service';
|
||||
import {
|
||||
LinkPreviewCacheIdentifier,
|
||||
type LinkPreviewCacheProvider,
|
||||
} from './link-preview-cache';
|
||||
|
||||
export type LinkPreviewResponseData = {
|
||||
url: string;
|
||||
title?: string;
|
||||
siteName?: string;
|
||||
description?: string;
|
||||
images?: string[];
|
||||
mediaType?: string;
|
||||
contentType?: string;
|
||||
charset?: string;
|
||||
videos?: string[];
|
||||
favicons?: string[];
|
||||
};
|
||||
|
||||
export interface LinkPreviewProvider {
|
||||
/**
|
||||
* Query link preview data for a given URL
|
||||
*/
|
||||
query: (
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
) => Promise<Partial<LinkPreviewData>>;
|
||||
/**
|
||||
* Set the endpoint for link preview
|
||||
*/
|
||||
setEndpoint: (endpoint: string) => void;
|
||||
|
||||
/**
|
||||
* Get the endpoint for link preview
|
||||
*/
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
export const LinkPreviewServiceIdentifier =
|
||||
createIdentifier<LinkPreviewProvider>('AffineLinkPreviewService');
|
||||
|
||||
export class LinkPreviewService
|
||||
extends Extension
|
||||
implements LinkPreviewProvider
|
||||
{
|
||||
static override setup(di: Container) {
|
||||
di.addImpl(LinkPreviewServiceIdentifier, LinkPreviewService, [
|
||||
StdIdentifier,
|
||||
LinkPreviewCacheIdentifier,
|
||||
]);
|
||||
}
|
||||
|
||||
private _endpoint: string = DEFAULT_LINK_PREVIEW_ENDPOINT;
|
||||
|
||||
constructor(
|
||||
private readonly _std: BlockStdScope,
|
||||
private readonly _cache: LinkPreviewCacheProvider
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get endpoint() {
|
||||
return this._endpoint;
|
||||
}
|
||||
|
||||
setEndpoint = (endpoint: string) => {
|
||||
this._endpoint = endpoint;
|
||||
};
|
||||
|
||||
private readonly _fetchTwitterPreview = async (
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Partial<LinkPreviewData>> => {
|
||||
try {
|
||||
const match = /\/status\/(\d+)/.exec(url);
|
||||
if (!match) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.DefaultRuntimeError,
|
||||
`Invalid tweet URL: ${url}`
|
||||
);
|
||||
}
|
||||
const apiUrl = `https://api.fxtwitter.com/status/${match[1]}`;
|
||||
|
||||
const response = await fetch(apiUrl, { signal }).then(res => res.json());
|
||||
const tweet = response?.tweet;
|
||||
if (!tweet) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.DefaultRuntimeError,
|
||||
`Invalid tweet response: ${url}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
title: tweet.author?.name ?? null,
|
||||
icon: tweet.author?.avatar_url ?? null,
|
||||
description: tweet.text ?? null,
|
||||
image:
|
||||
tweet.media?.photos?.[0]?.url || tweet.author?.banner_url || null,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`Failed to fetch tweet: ${url}`);
|
||||
console.error(e);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _fetchStandardPreview = async (
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Partial<LinkPreviewData>> => {
|
||||
const response = await fetch(this.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
signal,
|
||||
})
|
||||
.then(r => {
|
||||
if (!r || !r.ok) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.DefaultRuntimeError,
|
||||
`Failed to fetch link preview: ${url}`
|
||||
);
|
||||
}
|
||||
return r;
|
||||
})
|
||||
.catch(err => {
|
||||
if (isAbortError(err)) return null;
|
||||
console.error(`Failed to fetch link preview: ${url}`);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!response) return {};
|
||||
|
||||
const data: LinkPreviewResponseData = await response.json();
|
||||
return {
|
||||
title: data.title ?? null,
|
||||
description: data.description ?? null,
|
||||
icon: data.favicons?.[0],
|
||||
image: data.images?.[0],
|
||||
};
|
||||
};
|
||||
|
||||
private readonly _isTwitterUrl = (url: string): boolean => {
|
||||
const twitterDomains = [
|
||||
'https://x.com/',
|
||||
'https://www.x.com/',
|
||||
'https://www.twitter.com/',
|
||||
'https://twitter.com/',
|
||||
];
|
||||
return (
|
||||
twitterDomains.some(domain => url.startsWith(domain)) &&
|
||||
url.includes('/status/')
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _fetchPreview = async (
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Partial<LinkPreviewData>> => {
|
||||
if (this._isTwitterUrl(url)) {
|
||||
return this._fetchTwitterPreview(url, signal);
|
||||
}
|
||||
return this._fetchStandardPreview(url, signal);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch link preview data for a given URL
|
||||
*/
|
||||
query = async (
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Partial<LinkPreviewData>> => {
|
||||
const featureFlagService = this._std.store.get(FeatureFlagService);
|
||||
const cacheEnabled = featureFlagService.getFlag(
|
||||
'enable_link_preview_cache'
|
||||
);
|
||||
// If the cache is not enabled, fetch the preview directly
|
||||
if (!cacheEnabled) {
|
||||
return this._fetchPreview(url, signal);
|
||||
}
|
||||
|
||||
// Check memory cache, if hit, return the cached data
|
||||
const cached = this._cache.get(url);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Check pending requests, if there is a pending request, return the promise
|
||||
const pendingRequest = this._cache.getPendingRequest(url);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
// Fetch new data
|
||||
const promise = (async () => {
|
||||
try {
|
||||
// Fetch new data
|
||||
const data = await this._fetchPreview(url, signal);
|
||||
// If the data is not empty, set the data to the cache
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
this._cache.set(url, data);
|
||||
}
|
||||
return data;
|
||||
} finally {
|
||||
// Delete the pending request regardless of success or failure
|
||||
this._cache.deletePendingRequest(url);
|
||||
}
|
||||
})();
|
||||
|
||||
// Set the promise to the cache
|
||||
this._cache.setPendingRequest(url, promise);
|
||||
return promise;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { LinkPreviewDataSchema } from '@blocksuite/affine-model';
|
||||
import { z } from 'zod';
|
||||
|
||||
const _StorageSchema = z.object({
|
||||
data: z.record(LinkPreviewDataSchema.partial()),
|
||||
expires: z.number().optional(),
|
||||
});
|
||||
|
||||
type StorageData = z.infer<typeof _StorageSchema>;
|
||||
|
||||
/**
|
||||
* The local storage manager for the link preview cache data
|
||||
*/
|
||||
export class LinkPreviewStorage {
|
||||
/**
|
||||
* The storage key for the link preview
|
||||
*/
|
||||
storageKey = 'blocksuite:link-preview-cache';
|
||||
|
||||
/**
|
||||
* Load the cache from local storage
|
||||
* @returns StorageData
|
||||
*/
|
||||
load(): StorageData {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.storageKey);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
const safe = _StorageSchema.safeParse(parsed);
|
||||
if (safe.success) {
|
||||
return safe.data;
|
||||
}
|
||||
// if the data is invalid, clear the data
|
||||
this.clear();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load cache from storage:', e);
|
||||
}
|
||||
return { data: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the cache to local storage
|
||||
* @param {StorageData} data
|
||||
*/
|
||||
save(data: StorageData): void {
|
||||
try {
|
||||
const serialized = JSON.stringify(data);
|
||||
localStorage.setItem(this.storageKey, serialized);
|
||||
} catch (e) {
|
||||
console.error('Failed to save cache to storage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a link preview record from local storage with specific URL
|
||||
* @param {string} url The URL key to remove from storage
|
||||
* @returns {boolean} Whether the item was successfully removed
|
||||
*/
|
||||
clearItem(url: string): boolean {
|
||||
try {
|
||||
const data = this.load();
|
||||
if (!(url in data.data)) {
|
||||
return false;
|
||||
}
|
||||
delete data.data[url];
|
||||
this.save(data);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to clear item from storage:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all records from local storage
|
||||
*/
|
||||
clear(): void {
|
||||
localStorage.removeItem(this.storageKey);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { LinkPreviewData } from '@blocksuite/affine-model';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { StoreExtension } from '@blocksuite/store';
|
||||
|
||||
import { DEFAULT_LINK_PREVIEW_ENDPOINT } from '../consts';
|
||||
import { isAbortError } from '../utils/is-abort-error';
|
||||
|
||||
export type LinkPreviewResponseData = {
|
||||
url: string;
|
||||
title?: string;
|
||||
siteName?: string;
|
||||
description?: string;
|
||||
images?: string[];
|
||||
mediaType?: string;
|
||||
contentType?: string;
|
||||
charset?: string;
|
||||
videos?: string[];
|
||||
favicons?: string[];
|
||||
};
|
||||
|
||||
export class LinkPreviewerService extends StoreExtension {
|
||||
static override key = 'link-previewer';
|
||||
|
||||
private _endpoint = DEFAULT_LINK_PREVIEW_ENDPOINT;
|
||||
|
||||
query = async (
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Partial<LinkPreviewData>> => {
|
||||
if (
|
||||
(url.startsWith('https://x.com/') ||
|
||||
url.startsWith('https://www.x.com/') ||
|
||||
url.startsWith('https://www.twitter.com/') ||
|
||||
url.startsWith('https://twitter.com/')) &&
|
||||
url.includes('/status/')
|
||||
) {
|
||||
// use api.fxtwitter.com
|
||||
url =
|
||||
'https://api.fxtwitter.com/status/' + /\/status\/(.*)/.exec(url)?.[1];
|
||||
try {
|
||||
const { tweet } = await fetch(url, { signal }).then(res => res.json());
|
||||
return {
|
||||
title: tweet.author.name,
|
||||
icon: tweet.author.avatar_url,
|
||||
description: tweet.text,
|
||||
image: tweet.media?.photos?.[0].url || tweet.author.banner_url,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`Failed to fetch tweet: ${url}`);
|
||||
console.error(e);
|
||||
return {};
|
||||
}
|
||||
} else {
|
||||
const response = await fetch(this._endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
}),
|
||||
signal,
|
||||
})
|
||||
.then(r => {
|
||||
if (!r || !r.ok) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.DefaultRuntimeError,
|
||||
`Failed to fetch link preview: ${url}`
|
||||
);
|
||||
}
|
||||
return r;
|
||||
})
|
||||
.catch(err => {
|
||||
if (isAbortError(err)) return null;
|
||||
console.error(`Failed to fetch link preview: ${url}`);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!response) return {};
|
||||
|
||||
const data: LinkPreviewResponseData = await response.json();
|
||||
return {
|
||||
title: data.title ?? null,
|
||||
description: data.description ?? null,
|
||||
icon: data.favicons?.[0],
|
||||
image: data.images?.[0],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
get endpoint() {
|
||||
return this._endpoint;
|
||||
}
|
||||
|
||||
setEndpoint = (endpoint: string) => {
|
||||
this._endpoint = endpoint;
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Peekable } from '@blocksuite/affine/components/peek';
|
||||
import { ViewExtensionManagerIdentifier } from '@blocksuite/affine/ext-loader';
|
||||
import { BlockComponent } from '@blocksuite/affine/std';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
|
||||
import { ChatMessagesSchema } from '../../components/ai-chat-messages';
|
||||
import type { TextRendererOptions } from '../../components/text-renderer';
|
||||
import { ChatWithAIIcon } from './components/icon';
|
||||
import { type AIChatBlockModel } from './model';
|
||||
import { AIChatBlockStyles } from './styles';
|
||||
@@ -17,6 +19,8 @@ import { AIChatBlockStyles } from './styles';
|
||||
export class AIChatBlockComponent extends BlockComponent<AIChatBlockModel> {
|
||||
static override styles = AIChatBlockStyles;
|
||||
|
||||
private _textRendererOptions: TextRendererOptions = {};
|
||||
|
||||
// Deserialize messages from JSON string and verify the type using zod
|
||||
private readonly _deserializeChatMessages = computed(() => {
|
||||
const messages = this.model.props.messages$.value;
|
||||
@@ -32,18 +36,23 @@ export class AIChatBlockComponent extends BlockComponent<AIChatBlockModel> {
|
||||
}
|
||||
});
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._textRendererOptions = {
|
||||
customHeading: true,
|
||||
extensions: this.previewExtensions,
|
||||
};
|
||||
}
|
||||
|
||||
override renderBlock() {
|
||||
const messages = this._deserializeChatMessages.value.slice(-2);
|
||||
const textRendererOptions = {
|
||||
customHeading: true,
|
||||
};
|
||||
|
||||
return html`<div class="affine-ai-chat-block-container">
|
||||
<div class="ai-chat-messages-container">
|
||||
<ai-chat-messages
|
||||
.host=${this.host}
|
||||
.messages=${messages}
|
||||
.textRendererOptions=${textRendererOptions}
|
||||
.textRendererOptions=${this._textRendererOptions}
|
||||
.withMask=${true}
|
||||
></ai-chat-messages>
|
||||
</div>
|
||||
@@ -52,6 +61,10 @@ export class AIChatBlockComponent extends BlockComponent<AIChatBlockModel> {
|
||||
</div>
|
||||
</div> `;
|
||||
}
|
||||
|
||||
get previewExtensions() {
|
||||
return this.std.get(ViewExtensionManagerIdentifier).get('preview-page');
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -158,6 +158,9 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
|
||||
@property({ attribute: false })
|
||||
accessor extensions!: ExtensionType[];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||
|
||||
@query('.chat-panel-messages-container')
|
||||
accessor messagesContainer: HTMLDivElement | null = null;
|
||||
|
||||
@@ -271,6 +274,7 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
|
||||
.status=${isLast ? status : 'idle'}
|
||||
.error=${isLast ? error : null}
|
||||
.extensions=${this.extensions}
|
||||
.affineFeatureFlagService=${this.affineFeatureFlagService}
|
||||
.getSessionId=${this.getSessionId}
|
||||
.retry=${() => this.retry()}
|
||||
></chat-message-assistant>`;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
@@ -20,11 +21,15 @@ export class ChatContentRichText extends WithDisposable(ShadowlessElement) {
|
||||
@property({ attribute: false })
|
||||
accessor extensions!: ExtensionType[];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||
|
||||
protected override render() {
|
||||
const { text, host } = this;
|
||||
return html`${createTextRenderer(host, {
|
||||
customHeading: true,
|
||||
extensions: this.extensions,
|
||||
affineFeatureFlagService: this.affineFeatureFlagService,
|
||||
})(text, this.state)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import './chat-panel-messages';
|
||||
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import type { ContextEmbedStatus } from '@affine/graphql';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
@@ -205,6 +206,9 @@ export class ChatPanel extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor extensions!: ExtensionType[];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||
|
||||
@state()
|
||||
accessor isLoading = false;
|
||||
|
||||
@@ -399,6 +403,7 @@ export class ChatPanel extends SignalWatcher(
|
||||
.host=${this.host}
|
||||
.isLoading=${this.isLoading}
|
||||
.extensions=${this.extensions}
|
||||
.affineFeatureFlagService=${this.affineFeatureFlagService}
|
||||
></chat-panel-messages>
|
||||
<ai-chat-composer
|
||||
.host=${this.host}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import '../content/assistant-avatar';
|
||||
import '../content/rich-text';
|
||||
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { isInsidePageEditor } from '@blocksuite/affine/shared/utils';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
@@ -48,6 +49,9 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
|
||||
@property({ attribute: false })
|
||||
accessor extensions!: ExtensionType[];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor getSessionId!: () => Promise<string | undefined>;
|
||||
|
||||
@@ -93,6 +97,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
|
||||
.text=${item.content}
|
||||
.state=${state}
|
||||
.extensions=${this.extensions}
|
||||
.affineFeatureFlagService=${this.affineFeatureFlagService}
|
||||
></chat-content-rich-text>
|
||||
${shouldRenderError ? AIChatErrorRenderer(host, error) : nothing}
|
||||
${this.renderEditorActions()}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createReactComponentFromLit } from '@affine/component';
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { Container, type ServiceProvider } from '@blocksuite/affine/global/di';
|
||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import {
|
||||
@@ -6,10 +7,7 @@ import {
|
||||
defaultImageProxyMiddleware,
|
||||
ImageProxyService,
|
||||
} from '@blocksuite/affine/shared/adapters';
|
||||
import {
|
||||
LinkPreviewerService,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine/shared/services';
|
||||
import { ThemeProvider } from '@blocksuite/affine/shared/services';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
||||
import {
|
||||
BlockStdScope,
|
||||
@@ -104,6 +102,7 @@ export type TextRendererOptions = {
|
||||
extensions?: ExtensionType[];
|
||||
additionalMiddlewares?: TransformerMiddleware[];
|
||||
testId?: string;
|
||||
affineFeatureFlagService?: FeatureFlagService;
|
||||
};
|
||||
|
||||
// todo: refactor it for more general purpose usage instead of AI only?
|
||||
@@ -266,7 +265,14 @@ export class TextRenderer extends WithDisposable(ShadowlessElement) {
|
||||
codeBlockWrapMiddleware(true),
|
||||
...(this.options.additionalMiddlewares ?? []),
|
||||
];
|
||||
markDownToDoc(provider, schema, latestAnswer, middlewares)
|
||||
const affineFeatureFlagService = this.options.affineFeatureFlagService;
|
||||
markDownToDoc(
|
||||
provider,
|
||||
schema,
|
||||
latestAnswer,
|
||||
middlewares,
|
||||
affineFeatureFlagService
|
||||
)
|
||||
.then(doc => {
|
||||
this.disposeDoc();
|
||||
this._doc = doc.doc.getStore({
|
||||
@@ -278,16 +284,10 @@ export class TextRenderer extends WithDisposable(ShadowlessElement) {
|
||||
this._doc.readonly = true;
|
||||
this.requestUpdate();
|
||||
if (this.state !== 'generating') {
|
||||
// LinkPreviewerService & ImageProxyService config should read from host settings
|
||||
const linkPreviewerService =
|
||||
this.host?.std.store.get(LinkPreviewerService);
|
||||
this._doc.load();
|
||||
// LinkPreviewService & ImageProxyService config should read from host settings
|
||||
const imageProxyService =
|
||||
this.host?.std.store.get(ImageProxyService);
|
||||
if (linkPreviewerService) {
|
||||
this._doc
|
||||
?.get(LinkPreviewerService)
|
||||
.setEndpoint(linkPreviewerService.endpoint);
|
||||
}
|
||||
if (imageProxyService) {
|
||||
this._doc
|
||||
?.get(ImageProxyService)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import type { ContextEmbedStatus } from '@affine/graphql';
|
||||
import {
|
||||
CanvasElementType,
|
||||
@@ -445,7 +446,10 @@ export class AIChatBlockPeekView extends LitElement {
|
||||
.get(ViewExtensionManagerIdentifier)
|
||||
.get('preview-page');
|
||||
|
||||
this._textRendererOptions = { extensions };
|
||||
this._textRendererOptions = {
|
||||
extensions,
|
||||
affineFeatureFlagService: this.affineFeatureFlagService,
|
||||
};
|
||||
this._historyMessages = this._deserializeHistoryChatMessages(
|
||||
this.historyMessagesString
|
||||
);
|
||||
@@ -556,6 +560,9 @@ export class AIChatBlockPeekView extends LitElement {
|
||||
@property({ attribute: false })
|
||||
accessor searchMenuConfig!: SearchMenuConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor affineFeatureFlagService!: FeatureFlagService;
|
||||
|
||||
@state()
|
||||
accessor _historyMessages: ChatMessage[] = [];
|
||||
|
||||
@@ -584,7 +591,8 @@ export const AIChatBlockPeekViewTemplate = (
|
||||
docDisplayConfig: DocDisplayConfig,
|
||||
searchMenuConfig: SearchMenuConfig,
|
||||
networkSearchConfig: AINetworkSearchConfig,
|
||||
reasoningConfig: AIReasoningConfig
|
||||
reasoningConfig: AIReasoningConfig,
|
||||
affineFeatureFlagService: FeatureFlagService
|
||||
) => {
|
||||
return html`<ai-chat-block-peek-view
|
||||
.blockModel=${blockModel}
|
||||
@@ -593,5 +601,6 @@ export const AIChatBlockPeekViewTemplate = (
|
||||
.docDisplayConfig=${docDisplayConfig}
|
||||
.searchMenuConfig=${searchMenuConfig}
|
||||
.reasoningConfig=${reasoningConfig}
|
||||
.affineFeatureFlagService=${affineFeatureFlagService}
|
||||
></ai-chat-block-peek-view>`;
|
||||
};
|
||||
|
||||
+2
-1
@@ -170,7 +170,8 @@ const usePreviewExtensions = () => {
|
||||
.theme(framework)
|
||||
.database(framework)
|
||||
.linkedDoc(framework)
|
||||
.paragraph(enableAI).value;
|
||||
.paragraph(enableAI)
|
||||
.linkPreview(framework).value;
|
||||
const specs = manager.get('preview-page');
|
||||
return [...specs, patchReferenceRenderer(reactToLit, referenceRenderer)];
|
||||
}, [reactToLit, referenceRenderer, framework, enableAI]);
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
ImageProxyService,
|
||||
} from '@blocksuite/affine/shared/adapters';
|
||||
import { focusBlockEnd } from '@blocksuite/affine/shared/commands';
|
||||
import { LinkPreviewerService } from '@blocksuite/affine/shared/services';
|
||||
import { getLastNoteBlock } from '@blocksuite/affine/shared/utils';
|
||||
import type { BlockStdScope, EditorHost } from '@blocksuite/affine/std';
|
||||
import type { Store } from '@blocksuite/affine/store';
|
||||
@@ -212,13 +211,7 @@ const BlockSuiteEditorImpl = ({
|
||||
server.baseUrl
|
||||
).toString();
|
||||
|
||||
const linkPreviewUrl = new URL(
|
||||
BUILD_CONFIG.linkPreviewUrl,
|
||||
server.baseUrl
|
||||
).toString();
|
||||
|
||||
editor.std.clipboard.use(customImageProxyMiddleware(imageProxyUrl));
|
||||
page.get(LinkPreviewerService).setEndpoint(linkPreviewUrl);
|
||||
page.get(ImageProxyService).setImageProxyURL(imageProxyUrl);
|
||||
|
||||
editor.updateComplete
|
||||
|
||||
@@ -101,7 +101,8 @@ const usePatchSpecs = (mode: DocMode) => {
|
||||
.linkedDoc(framework)
|
||||
.paragraph(enableAI)
|
||||
.mobile(framework)
|
||||
.electron(framework).value;
|
||||
.electron(framework)
|
||||
.linkPreview(framework).value;
|
||||
|
||||
if (BUILD_CONFIG.isMobileEdition) {
|
||||
if (mode === 'page') {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
type ViewExtensionContext,
|
||||
ViewExtensionProvider,
|
||||
} from '@blocksuite/affine/ext-loader';
|
||||
import { FrameworkProvider } from '@toeverything/infra';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { patchLinkPreviewService } from './link-preview-service';
|
||||
|
||||
const optionsSchema = z.object({
|
||||
framework: z.instanceof(FrameworkProvider).optional(),
|
||||
});
|
||||
|
||||
type AffineLinkPreviewViewOptions = z.infer<typeof optionsSchema>;
|
||||
|
||||
export class AffineLinkPreviewExtension extends ViewExtensionProvider<AffineLinkPreviewViewOptions> {
|
||||
override name = 'affine-link-preview-extension';
|
||||
|
||||
override schema = optionsSchema;
|
||||
|
||||
override setup(
|
||||
context: ViewExtensionContext,
|
||||
options?: AffineLinkPreviewViewOptions
|
||||
) {
|
||||
super.setup(context, options);
|
||||
if (!options?.framework) {
|
||||
return;
|
||||
}
|
||||
const { framework } = options;
|
||||
context.register(patchLinkPreviewService(framework));
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { DEFAULT_LINK_PREVIEW_ENDPOINT } from '@blocksuite/affine/shared/consts';
|
||||
import {
|
||||
LinkPreviewCacheIdentifier,
|
||||
type LinkPreviewCacheProvider,
|
||||
LinkPreviewService,
|
||||
LinkPreviewServiceIdentifier,
|
||||
} from '@blocksuite/affine/shared/services';
|
||||
import { type BlockStdScope, StdIdentifier } from '@blocksuite/affine/std';
|
||||
import { type ExtensionType } from '@blocksuite/affine/store';
|
||||
import type { Container } from '@blocksuite/global/di';
|
||||
import type { FrameworkProvider } from '@toeverything/infra';
|
||||
|
||||
import { ServerService } from '../../../modules/cloud/services/server';
|
||||
|
||||
class AffineLinkPreviewService extends LinkPreviewService {
|
||||
constructor(
|
||||
endpoint: string,
|
||||
std: BlockStdScope,
|
||||
cache: LinkPreviewCacheProvider
|
||||
) {
|
||||
super(std, cache);
|
||||
this.setEndpoint(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the link preview service, set the endpoint and cache
|
||||
* @param framework
|
||||
* @returns
|
||||
*/
|
||||
export function patchLinkPreviewService(
|
||||
framework: FrameworkProvider
|
||||
): ExtensionType {
|
||||
// get link preview service endpoint from server and BUILD_CONFIG
|
||||
let linkPreviewUrl: string;
|
||||
try {
|
||||
const server = framework.get(ServerService).server;
|
||||
linkPreviewUrl = new URL(
|
||||
BUILD_CONFIG.linkPreviewUrl || '/',
|
||||
server.baseUrl
|
||||
).toString();
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'Invalid BUILD_CONFIG.linkPreviewUrl, falling back to default',
|
||||
err
|
||||
);
|
||||
linkPreviewUrl = DEFAULT_LINK_PREVIEW_ENDPOINT;
|
||||
}
|
||||
|
||||
return {
|
||||
setup: (di: Container) => {
|
||||
di.override(LinkPreviewServiceIdentifier, provider => {
|
||||
return new AffineLinkPreviewService(
|
||||
linkPreviewUrl,
|
||||
provider.get(StdIdentifier),
|
||||
provider.get(LinkPreviewCacheIdentifier)
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { AffineEditorConfigViewExtension } from '@affine/core/blocksuite/extensi
|
||||
import { createDatabaseOptionsConfig } from '@affine/core/blocksuite/extensions/editor-config/database';
|
||||
import { createLinkedWidgetConfig } from '@affine/core/blocksuite/extensions/editor-config/linked';
|
||||
import { ElectronViewExtension } from '@affine/core/blocksuite/extensions/electron';
|
||||
import { AffineLinkPreviewExtension } from '@affine/core/blocksuite/extensions/link-preview-service';
|
||||
import { MobileViewExtension } from '@affine/core/blocksuite/extensions/mobile';
|
||||
import { PdfViewExtension } from '@affine/core/blocksuite/extensions/pdf';
|
||||
import { AffineThemeViewExtension } from '@affine/core/blocksuite/extensions/theme';
|
||||
@@ -44,6 +45,7 @@ type Configure = {
|
||||
mobile: (framework?: FrameworkProvider) => Configure;
|
||||
ai: (enable?: boolean, framework?: FrameworkProvider) => Configure;
|
||||
electron: (framework?: FrameworkProvider) => Configure;
|
||||
linkPreview: (framework?: FrameworkProvider) => Configure;
|
||||
|
||||
value: ViewExtensionManager;
|
||||
};
|
||||
@@ -75,6 +77,7 @@ class ViewProvider {
|
||||
MobileViewExtension,
|
||||
AIViewExtension,
|
||||
ElectronViewExtension,
|
||||
AffineLinkPreviewExtension,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -99,6 +102,7 @@ class ViewProvider {
|
||||
mobile: this._configureMobile,
|
||||
ai: this._configureAI,
|
||||
electron: this._configureElectron,
|
||||
linkPreview: this._configureLinkPreview,
|
||||
value: this._manager,
|
||||
};
|
||||
}
|
||||
@@ -118,7 +122,8 @@ class ViewProvider {
|
||||
.pdf()
|
||||
.mobile()
|
||||
.ai()
|
||||
.electron();
|
||||
.electron()
|
||||
.linkPreview();
|
||||
|
||||
return this.config;
|
||||
};
|
||||
@@ -256,6 +261,11 @@ class ViewProvider {
|
||||
this._manager.configure(ElectronViewExtension, { framework });
|
||||
return this.config;
|
||||
};
|
||||
|
||||
private readonly _configureLinkPreview = (framework?: FrameworkProvider) => {
|
||||
this._manager.configure(AffineLinkPreviewExtension, { framework });
|
||||
return this.config;
|
||||
};
|
||||
}
|
||||
|
||||
export function getViewManager() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { WorkspaceImpl } from '@affine/core/modules/workspace/impls/workspace';
|
||||
import type { ServiceProvider } from '@blocksuite/affine/global/di';
|
||||
import {
|
||||
@@ -161,11 +162,13 @@ export async function markDownToDoc(
|
||||
provider: ServiceProvider,
|
||||
schema: Schema,
|
||||
answer: string,
|
||||
middlewares?: TransformerMiddleware[]
|
||||
middlewares?: TransformerMiddleware[],
|
||||
affineFeatureFlagService?: FeatureFlagService
|
||||
) {
|
||||
// Should not create a new doc in the original collection
|
||||
const collection = new WorkspaceImpl({
|
||||
rootDoc: new YDoc({ guid: 'markdownToDoc' }),
|
||||
featureFlagService: affineFeatureFlagService,
|
||||
});
|
||||
collection.meta.initialize();
|
||||
const transformer = new Transformer({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChatPanel } from '@affine/core/blocksuite/ai';
|
||||
import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor';
|
||||
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { ViewExtensionManagerIdentifier } from '@blocksuite/affine/ext-loader';
|
||||
import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference';
|
||||
@@ -75,6 +76,8 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
|
||||
chatPanelRef.current.extensions = editor.host.std
|
||||
.get(ViewExtensionManagerIdentifier)
|
||||
.get('preview-page');
|
||||
chatPanelRef.current.affineFeatureFlagService =
|
||||
framework.get(FeatureFlagService);
|
||||
|
||||
containerRef.current?.append(chatPanelRef.current);
|
||||
} else {
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
customImageProxyMiddleware,
|
||||
ImageProxyService,
|
||||
} from '@blocksuite/affine/shared/adapters';
|
||||
import { LinkPreviewerService } from '@blocksuite/affine/shared/services';
|
||||
import {
|
||||
FrameworkScope,
|
||||
useLiveData,
|
||||
@@ -139,11 +138,6 @@ const DetailPageImpl = () => {
|
||||
server.baseUrl
|
||||
).toString();
|
||||
|
||||
const linkPreviewUrl = new URL(
|
||||
BUILD_CONFIG.linkPreviewUrl,
|
||||
server.baseUrl
|
||||
).toString();
|
||||
|
||||
editorContainer.std.clipboard.use(
|
||||
customImageProxyMiddleware(imageProxyUrl)
|
||||
);
|
||||
@@ -151,9 +145,6 @@ const DetailPageImpl = () => {
|
||||
.get(ImageProxyService)
|
||||
.setImageProxyURL(imageProxyUrl);
|
||||
|
||||
// provide link preview endpoint to blocksuite
|
||||
editorContainer.doc.get(LinkPreviewerService).setEndpoint(linkPreviewUrl);
|
||||
|
||||
// provide page mode and updated date to blocksuite
|
||||
const refNodeService =
|
||||
editorContainer.std.getOptional(RefNodeSlotsProvider);
|
||||
|
||||
@@ -106,6 +106,16 @@ export const AFFINE_FLAGS = {
|
||||
configurable: isCanaryBuild,
|
||||
defaultState: isCanaryBuild,
|
||||
},
|
||||
enable_link_preview_cache: {
|
||||
category: 'blocksuite',
|
||||
bsFlag: 'enable_link_preview_cache',
|
||||
displayName:
|
||||
'com.affine.settings.workspace.experimental-features.enable-link-preview-cache.name',
|
||||
description:
|
||||
'com.affine.settings.workspace.experimental-features.enable-link-preview-cache.description',
|
||||
configurable: isCanaryBuild,
|
||||
defaultState: isCanaryBuild,
|
||||
},
|
||||
|
||||
enable_emoji_folder_icon: {
|
||||
category: 'affine',
|
||||
|
||||
@@ -2,7 +2,9 @@ import { toReactNode } from '@affine/component';
|
||||
import { AIChatBlockPeekViewTemplate } from '@affine/core/blocksuite/ai';
|
||||
import type { AIChatBlockModel } from '@affine/core/blocksuite/ai/blocks/ai-chat-block/model/ai-chat-model';
|
||||
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
import { useFramework } from '@toeverything/infra';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export type AIChatBlockPeekViewProps = {
|
||||
@@ -21,6 +23,9 @@ export const AIChatBlockPeekView = ({
|
||||
reasoningConfig,
|
||||
} = useAIChatConfig();
|
||||
|
||||
const framework = useFramework();
|
||||
const affineFeatureFlagService = framework.get(FeatureFlagService);
|
||||
|
||||
return useMemo(() => {
|
||||
const template = AIChatBlockPeekViewTemplate(
|
||||
model,
|
||||
@@ -28,7 +33,8 @@ export const AIChatBlockPeekView = ({
|
||||
docDisplayConfig,
|
||||
searchMenuConfig,
|
||||
networkSearchConfig,
|
||||
reasoningConfig
|
||||
reasoningConfig,
|
||||
affineFeatureFlagService
|
||||
);
|
||||
return toReactNode(template);
|
||||
}, [
|
||||
@@ -38,5 +44,6 @@ export const AIChatBlockPeekView = ({
|
||||
searchMenuConfig,
|
||||
networkSearchConfig,
|
||||
reasoningConfig,
|
||||
affineFeatureFlagService,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"it-IT": 96,
|
||||
"it": 1,
|
||||
"ja": 96,
|
||||
"ko": 56,
|
||||
"ko": 55,
|
||||
"pl": 96,
|
||||
"pt-BR": 96,
|
||||
"ru": 96,
|
||||
|
||||
@@ -5722,6 +5722,14 @@ export function useAFFiNEI18N(): {
|
||||
* `Enable citation feature.`
|
||||
*/
|
||||
["com.affine.settings.workspace.experimental-features.enable-citation.description"](): string;
|
||||
/**
|
||||
* `Link Preview Cache`
|
||||
*/
|
||||
["com.affine.settings.workspace.experimental-features.enable-link-preview-cache.name"](): string;
|
||||
/**
|
||||
* `Once enabled, the link preview will be cached and cached data will be used when the same link is fetched again. Otherwise, the link preview will be fetched every time.`
|
||||
*/
|
||||
["com.affine.settings.workspace.experimental-features.enable-link-preview-cache.description"](): string;
|
||||
/**
|
||||
* `Embed Iframe Block`
|
||||
*/
|
||||
|
||||
@@ -1429,6 +1429,8 @@
|
||||
"com.affine.settings.workspace.experimental-features.enable-callout.description": "Let your words stand out. This also include the callout in the transcription block.",
|
||||
"com.affine.settings.workspace.experimental-features.enable-citation.name": "Citation",
|
||||
"com.affine.settings.workspace.experimental-features.enable-citation.description": "Enable citation feature.",
|
||||
"com.affine.settings.workspace.experimental-features.enable-link-preview-cache.name": "Link Preview Cache",
|
||||
"com.affine.settings.workspace.experimental-features.enable-link-preview-cache.description": "Once enabled, the link preview will be cached and cached data will be used when the same link is fetched again. Otherwise, the link preview will be fetched every time.",
|
||||
"com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.name": "Embed Iframe Block",
|
||||
"com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.description": "Enables Embed Iframe Block.",
|
||||
"com.affine.settings.workspace.experimental-features.enable-emoji-folder-icon.name": "Emoji Folder Icon",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user