feat: load prompts from database (#17)

* feat: load prompts from database

* chore: bump editor version

* fix: dropdown disable state

* fix: revert dropdown menu changes

* chore: bump ai chat

* fix: duplicated fields not immediately usable

* chore: bump ai chat version

* chore: move hook usage upwards

* chore: export type
This commit is contained in:
Richard Shiue
2025-07-04 13:39:58 +08:00
committed by GitHub
parent 7ddbd094ec
commit d951ac32c2
13 changed files with 626 additions and 90 deletions

View File

@@ -7,9 +7,11 @@ import {
DatabaseRelations,
GenerateAISummaryRowPayload,
GenerateAITranslateRowPayload,
LoadDatabasePrompts,
LoadView,
LoadViewMeta,
RowId,
TestDatabasePromptConfig,
UpdatePagePayload,
View,
YDatabase,
@@ -48,6 +50,8 @@ export interface DatabaseContextState {
loadViews?: () => Promise<View[]>;
uploadFile?: (file: File) => Promise<string>;
createOrphanedView?: (payload: { document_id: string }) => Promise<void>;
loadDatabasePrompts?: LoadDatabasePrompts;
testDatabasePromptConfig?: TestDatabasePromptConfig;
requestInstance?: AxiosInstance | null;
}

View File

@@ -1,3 +1,4 @@
import { PromptDatabaseConfiguration } from '@appflowyinc/ai-chat';
import { AxiosInstance } from 'axios';
import * as Y from 'yjs';
@@ -1068,6 +1069,14 @@ export interface ViewComponentProps {
createFolderView?: (payload: CreateFolderViewPayload) => Promise<string>;
generateAISummaryForRow?: (payload: GenerateAISummaryRowPayload) => Promise<string>;
generateAITranslateForRow?: (payload: GenerateAITranslateRowPayload) => Promise<string>;
loadDatabasePrompts?: (config: PromptDatabaseConfiguration) => Promise<{
rawDatabasePrompts: DatabasePrompt[];
fields: DatabasePromptField[];
}>;
testDatabasePromptConfig?: (viewId: string) => Promise<{
config: PromptDatabaseConfiguration;
fields: DatabasePromptField[];
}>;
}
export interface CreatePagePayload {
@@ -1142,3 +1151,35 @@ export interface GenerateAITranslateRowPayload {
include_header?: boolean
}
export type LoadDatabasePrompts = (config: PromptDatabaseConfiguration) => Promise<{
rawDatabasePrompts: DatabasePrompt[];
fields: DatabasePromptField[];
}>;
export type TestDatabasePromptConfig = (viewId: string) => Promise<{
config: PromptDatabaseConfiguration;
fields: DatabasePromptField[];
}>;
export interface DatabasePrompt {
id: string;
name: string;
content: string;
example: string;
category: string;
}
export interface DatabasePromptField {
id: string;
name: string;
isPrimary: boolean;
isSelect: boolean;
}
export interface DatabasePromptRow {
id: string;
data: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[fieldId: string]: any;
};
}

View File

@@ -17,7 +17,7 @@ export function AIChat({ chatId, onRendered }: { chatId: string; onRendered?: ()
const isMobile = getPlatform().isMobile;
const [openMobilePrompt, setOpenMobilePrompt] = React.useState(isMobile);
const { refreshOutline, updatePage } = useAppHandlers();
const { refreshOutline, updatePage, loadDatabasePrompts, testDatabasePromptConfig } = useAppHandlers();
const {
selectionMode,
@@ -77,7 +77,7 @@ export function AIChat({ chatId, onRendered }: { chatId: string; onRendered?: ()
}
}, [onRendered]);
if (!requestInstance) return null;
if (!requestInstance || !workspaceId) return null;
return (
<div
@@ -88,6 +88,7 @@ export function AIChat({ chatId, onRendered }: { chatId: string; onRendered?: ()
>
<div className={'w-[952px] max-w-full px-24 max-sm:px-6'}>
<Chat
workspaceId={workspaceId}
requestInstance={requestInstance}
chatId={chatId}
currentUser={
@@ -106,6 +107,8 @@ export function AIChat({ chatId, onRendered }: { chatId: string; onRendered?: ()
openingViewId={(drawerOpen && openViewId) || undefined}
onCloseView={onCloseView}
onOpenView={onOpenView}
loadDatabasePrompts={loadDatabasePrompts}
testDatabasePromptConfig={testDatabasePromptConfig}
/>
</div>

View File

@@ -13,12 +13,17 @@ import {
CreatePagePayload,
CreateRowDoc,
CreateSpacePayload,
DatabasePrompt,
DatabasePromptField,
DatabasePromptRow,
DatabaseRelations,
GenerateAISummaryRowPayload,
GenerateAITranslateRowPayload,
LoadDatabasePrompts,
LoadView,
LoadViewMeta,
Subscription,
TestDatabasePromptConfig,
TextCount,
Types,
UIVariant,
@@ -27,6 +32,7 @@ import {
UserWorkspaceInfo,
View,
ViewLayout,
YDatabase,
YjsDatabaseKey,
YjsEditorKey,
YSharedRoot,
@@ -36,6 +42,9 @@ import { AppOverlayProvider } from '@/components/app/app-overlay/AppOverlayProvi
import RequestAccess from '@/components/app/landing-pages/RequestAccess';
import { AFConfigContext, useService } from '@/components/main/app.hooks';
import { findAncestors, findView, findViewByLayout } from '@/components/_shared/outline/utils';
import { PromptDatabaseConfiguration } from '@appflowyinc/ai-chat';
import { FieldType } from '@/application/database-yjs';
import { getCellDataText } from '@/application/database-yjs/cell.parse';
const ViewModal = React.lazy(() => import('@/components/app/ViewModal'));
@@ -84,6 +93,8 @@ export interface AppContextType {
generateAITranslateForRow?: (payload: GenerateAITranslateRowPayload) => Promise<string>;
loadDatabaseRelations?: () => Promise<DatabaseRelations | undefined>;
createOrphanedView?: (payload: { document_id: string }) => Promise<void>;
loadDatabasePrompts?: LoadDatabasePrompts;
testDatabasePromptConfig?: TestDatabasePromptConfig;
eventEmitter?: EventEmitter;
}
@@ -832,6 +843,203 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
[currentWorkspaceId, service]
);
const rowDocsRef = useRef<Map<string, DatabasePromptRow>>(new Map());
const getRows = useCallback(
async (viewId: string) => {
if (!currentWorkspaceId) return [];
const doc = await loadView(viewId);
const database = doc?.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database) as YDatabase;
const view = database.get(YjsDatabaseKey.views).get(viewId);
const rowOrders = view?.get(YjsDatabaseKey.row_orders) || [];
const rowPromises = rowOrders
.map(async (row: { id: string }) => {
if (rowDocsRef.current.has(row.id)) {
return rowDocsRef.current.get(row.id);
}
if (!createRowDoc) return;
const rowKey = `${doc.guid}_rows_${row.id}`;
const rowDoc = await createRowDoc(rowKey);
const rowSharedRoot = rowDoc?.getMap(YjsEditorKey.data_section);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = rowSharedRoot?.get(YjsEditorKey.database_row) as { [fieldId: string]: any };
const databaseRow = {
id: row.id,
data,
};
rowDocsRef.current.set(row.id, databaseRow);
return databaseRow;
})
.filter((p): p is Promise<DatabasePromptRow> => p !== undefined);
return Promise.all(rowPromises);
},
[createRowDoc, currentWorkspaceId, loadView]
);
const getFields = useCallback(
async (viewId: string) => {
if (!currentWorkspaceId) return [];
const doc = await loadView(viewId);
const database = doc?.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database) as YDatabase;
const fields = database.get(YjsDatabaseKey.fields);
return Array.from(fields.entries())
.map(([id, field]) => {
const isPrimary = field.get(YjsDatabaseKey.is_primary) || false;
const name = field.get(YjsDatabaseKey.name) || '';
const fieldType = Number(field.get(YjsDatabaseKey.type));
const isSelect = fieldType === FieldType.SingleSelect || fieldType === FieldType.MultiSelect;
return {
id,
name,
fieldType,
isPrimary,
isSelect,
data: field,
};
})
.filter((f) => [FieldType.RichText, FieldType.SingleSelect, FieldType.MultiSelect].includes(f.fieldType));
},
[currentWorkspaceId, loadView]
);
const loadDatabasePromptsWithFields = useCallback(
async (
config: PromptDatabaseConfiguration,
fields: {
id: string;
name: string;
isPrimary: boolean;
fieldType: FieldType;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any;
}[]
) => {
const titleField = fields.find((field) => field.id === config.titleFieldId);
if (!titleField) {
throw new Error('Cannot find title field');
}
const contentField = fields.find((field) => field.id === config.contentFieldId);
if (!contentField) {
throw new Error('Cannot find content field');
}
const exampleField = config.exampleFieldId
? fields.find((field) => field.id === config.exampleFieldId)
: undefined;
const categoryField = config.categoryFieldId
? fields.find((field) => field.id === config.categoryFieldId)
: undefined;
const rows = await getRows(config.databaseViewId);
return rows
.map((row) => {
const cells = row?.data.get(YjsDatabaseKey.cells);
const nameCell = cells?.get(titleField.id);
const name = nameCell && titleField ? getCellDataText(nameCell, titleField.data) : '';
const contentCell = cells?.get(contentField.id);
const content = contentCell && contentField ? getCellDataText(contentCell, contentField.data) : '';
if (!name || !content) return null;
const exampleCell = exampleField ? cells?.get(exampleField.id) : null;
const example = exampleCell && exampleField ? getCellDataText(exampleCell, exampleField.data) : '';
const categoryCell = categoryField ? cells?.get(categoryField.id) : null;
const category = categoryCell && categoryField ? getCellDataText(categoryCell, categoryField.data) : '';
return {
id: row.id,
name,
content,
example,
category,
};
})
.filter((prompt): prompt is DatabasePrompt => prompt !== null);
},
[getRows]
);
const loadDatabasePrompts = async (
config: PromptDatabaseConfiguration
): Promise<{
rawDatabasePrompts: DatabasePrompt[];
fields: DatabasePromptField[];
}> => {
const fields = await getFields(config.databaseViewId);
const rawDatabasePrompts = await loadDatabasePromptsWithFields(config, fields);
return {
rawDatabasePrompts,
fields: fields.map((field) => ({
id: field.id,
name: field.name,
isPrimary: field.isPrimary,
isSelect: field.fieldType === FieldType.SingleSelect || field.fieldType === FieldType.MultiSelect,
})),
};
};
const testDatabasePromptConfig = useCallback(
async (viewId: string) => {
const fields = await getFields(viewId);
const titleField = fields.find((field) => field.isPrimary);
if (!titleField) {
throw new Error('Cannot find primary field');
}
const contentField = fields.find(
(field) =>
!field.isPrimary &&
((field.name.toLowerCase() === 'content' && field.fieldType === FieldType.RichText) ||
field.fieldType === FieldType.RichText)
);
if (!contentField) {
throw new Error('Cannot find content field');
}
const exampleField = fields.find(
(field) => field.name.toLowerCase() === 'example' && field.fieldType === FieldType.RichText
);
const categoryField = fields.find(
(field) => field.name.toLowerCase() === 'category' && field.fieldType === FieldType.RichText
);
const config: PromptDatabaseConfiguration = {
databaseViewId: viewId,
titleFieldId: titleField.id,
contentFieldId: contentField.id,
exampleFieldId: exampleField?.id || null,
categoryFieldId: categoryField?.id || null,
};
return { config, fields };
},
[getFields]
);
return (
<AppContext.Provider
value={{
@@ -879,6 +1087,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
generateAITranslateForRow,
loadDatabaseRelations,
createOrphanedView,
loadDatabasePrompts,
testDatabasePromptConfig,
eventEmitter: eventEmitterRef.current,
}}
>
@@ -1035,6 +1245,8 @@ export function useAppHandlers() {
generateAITranslateForRow: context.generateAITranslateForRow,
loadDatabaseRelations: context.loadDatabaseRelations,
createOrphanedView: context.createOrphanedView,
loadDatabasePrompts: context.loadDatabasePrompts,
testDatabasePromptConfig: context.testDatabasePromptConfig,
eventEmitter: context.eventEmitter,
};
}

View File

@@ -7,7 +7,9 @@ import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup, DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
@@ -15,15 +17,17 @@ import { useTranslation } from 'react-i18next';
import MoreActionsContent from './MoreActionsContent';
import { ReactComponent as AddToPageIcon } from '@/assets/icons/add_to_page.svg';
import { useService } from '@/components/main/app.hooks';
import { Tooltip, TooltipContent } from '@/components/ui/tooltip';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
function MoreActions ({ viewId, onDeleted, menuContentProps }:
{
viewId: string;
onDeleted?: () => void;
menuContentProps?: React.ComponentProps<typeof DropdownMenuContent>
} & React.ComponentProps<typeof DropdownMenu>,
) {
function MoreActions({
viewId,
onDeleted,
menuContentProps,
}: {
viewId: string;
onDeleted?: () => void;
menuContentProps?: React.ComponentProps<typeof DropdownMenuContent>;
} & React.ComponentProps<typeof DropdownMenu>) {
const workspaceId = useCurrentWorkspaceId();
const service = useService();
const { selectionMode, onOpenSelectionMode } = useAIChatContext();
@@ -59,21 +63,21 @@ function MoreActions ({ viewId, onDeleted, menuContentProps }:
return view?.layout === ViewLayout.AIChat ? (
<>
<Tooltip>
<div>
<Button
disabled={!hasMessages}
<TooltipTrigger asChild>
<DropdownMenuItem
onClick={() => {
onOpenSelectionMode();
handleClose();
if (hasMessages) {
onOpenSelectionMode();
handleClose();
}
}}
className={hasMessages ? '' : '!cursor-default !text-text-tertiary hover:!bg-fill-content'}
>
<AddToPageIcon />
{t('web.addMessagesToPage')}
</Button>
</div>
<TooltipContent>
{hasMessages ? '' : t('web.addMessagesToPageDisabled')}
</TooltipContent>
</DropdownMenuItem>
</TooltipTrigger>
{!hasMessages && <TooltipContent>{t('web.addMessagesToPageDisabled')}</TooltipContent>}
</Tooltip>
<DropdownMenuSeparator />
</>
@@ -85,23 +89,14 @@ function MoreActions ({ viewId, onDeleted, menuContentProps }:
}
return (
<DropdownMenu
open={open}
onOpenChange={setOpen}
>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
size={'icon'}
variant={'ghost'}
className={'text-icon-secondary'}
>
<Button size={'icon'} variant={'ghost'} className={'text-icon-secondary'}>
<MoreIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent {...menuContentProps}>
<DropdownMenuGroup>
{ChatOptions}
</DropdownMenuGroup>
<DropdownMenuGroup>{ChatOptions}</DropdownMenuGroup>
<MoreActionsContent
itemClicked={() => {
@@ -113,7 +108,6 @@ function MoreActions ({ viewId, onDeleted, menuContentProps }:
<DropdownMenuSeparator />
<DocumentInfo viewId={viewId} />
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -26,6 +26,7 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => {
const context = useDatabaseContext();
const database = useDatabase();
const row = useRowData(rowId);
const getCellData = useCallback(
(cell: YDatabaseCell, field: YDatabaseField) => {
const type = Number(field?.get(YjsDatabaseKey.type));

View File

@@ -13,9 +13,12 @@ import { clipboardFormatKey } from '@/components/editor/plugins/withCopy';
const defaultInitialValue: Descendant[] = [];
function CollaborativeEditor({ doc, onEditorConnected }: {
doc: Y.Doc,
onEditorConnected?: (editor: YjsEditor) => void
function CollaborativeEditor({
doc,
onEditorConnected,
}: {
doc: Y.Doc;
onEditorConnected?: (editor: YjsEditor) => void;
}) {
const context = useEditorContext();
const readSummary = context.readSummary;
@@ -25,13 +28,16 @@ function CollaborativeEditor({ doc, onEditorConnected }: {
const viewId = context.viewId;
const onWordCountChange = context.onWordCountChange;
const [, setClock] = useState(0);
const onContentChange = useCallback((content: Descendant[]) => {
const wordCount = getTextCount(content);
const onContentChange = useCallback(
(content: Descendant[]) => {
const wordCount = getTextCount(content);
onWordCountChange?.(viewId, wordCount);
setClock((prev) => prev + 1);
onRendered?.();
}, [onWordCountChange, viewId, onRendered]);
onWordCountChange?.(viewId, wordCount);
setClock((prev) => prev + 1);
onRendered?.();
},
[onWordCountChange, viewId, onRendered]
);
const editor = useMemo(
() =>
@@ -46,18 +52,18 @@ function CollaborativeEditor({ doc, onEditorConnected }: {
onContentChange,
uploadFile,
id: viewId,
}),
})
),
clipboardFormatKey,
),
clipboardFormatKey
)
) as YjsEditor),
// eslint-disable-next-line react-hooks/exhaustive-deps
[viewId, doc],
[viewId, doc]
);
const [, setIsConnected] = useState(false);
useEffect(() => {
if(!editor) return;
if (!editor) return;
editor.connect();
setIsConnected(true);
@@ -71,13 +77,9 @@ function CollaborativeEditor({ doc, onEditorConnected }: {
}, [editor]);
return (
<Slate
editor={editor}
initialValue={defaultInitialValue}
>
<Slate editor={editor} initialValue={defaultInitialValue}>
<EditorEditable />
</Slate>
);
}

View File

@@ -24,10 +24,7 @@ export const Editor = memo(({ doc, onEditorConnected, layoutStyle = defaultLayou
addCodeGrammars={handleAddCodeGrammars}
layoutStyle={layoutStyle}
>
<CollaborativeEditor
doc={doc}
onEditorConnected={onEditorConnected}
/>
<CollaborativeEditor doc={doc} onEditorConnected={onEditorConnected} />
</EditorContextProvider>
);
});

View File

@@ -3,7 +3,13 @@ import {
FontLayout,
LineHeightLayout,
LoadView,
LoadViewMeta, UIVariant, View, CreatePagePayload, TextCount,
LoadViewMeta,
UIVariant,
View,
CreatePagePayload,
TextCount,
LoadDatabasePrompts,
TestDatabasePromptConfig,
} from '@/application/types';
import { AxiosInstance } from 'axios';
import EventEmitter from 'events';
@@ -57,6 +63,8 @@ export interface EditorContextState {
uploadFile?: (file: File) => Promise<string>;
requestInstance?: AxiosInstance | null;
getMoreAIContext?: () => string;
loadDatabasePrompts?: LoadDatabasePrompts;
testDatabasePromptConfig?: TestDatabasePromptConfig;
eventEmitter?: EventEmitter;
}
@@ -76,7 +84,7 @@ export const EditorContextProvider = ({ children, ...props }: EditorContextState
setDecorateState((prev) => {
const oldValue = prev[type];
if(oldValue && Range.equals(oldValue.range, range) && oldValue.class_name === class_name) {
if (oldValue && Range.equals(oldValue.range, range) && oldValue.class_name === class_name) {
return prev;
}
@@ -92,7 +100,7 @@ export const EditorContextProvider = ({ children, ...props }: EditorContextState
const removeDecorate = useCallback((type: string) => {
setDecorateState((prev) => {
if(prev[type] === undefined) {
if (prev[type] === undefined) {
return prev;
}
@@ -103,16 +111,20 @@ export const EditorContextProvider = ({ children, ...props }: EditorContextState
});
}, []);
return <EditorContext.Provider
value={{
...props,
decorateState,
addDecorate,
removeDecorate,
setSelectedBlockIds,
selectedBlockIds,
}}
>{children}</EditorContext.Provider>;
return (
<EditorContext.Provider
value={{
...props,
decorateState,
addDecorate,
removeDecorate,
setSelectedBlockIds,
selectedBlockIds,
}}
>
{children}
</EditorContext.Provider>
);
};
export function useEditorContext() {

View File

@@ -23,7 +23,7 @@ import Panels from './components/panels';
import Toolbars from './components/toolbar';
function EditorOverlay({ viewId, workspaceId }: { viewId: string; workspaceId: string }) {
const { requestInstance } = useEditorContext();
const { requestInstance, loadDatabasePrompts, testDatabasePromptConfig } = useEditorContext();
const editor = useSlate() as YjsEditor;
const selection = editor.selection;
const isRange = selection ? Range.isExpanded(selection) : false;
@@ -172,7 +172,11 @@ function EditorOverlay({ viewId, workspaceId }: { viewId: string; workspaceId: s
return (
<ErrorBoundary fallbackRender={() => null}>
<PromptModalProvider>
<PromptModalProvider
workspaceId={workspaceId}
loadDatabasePrompts={loadDatabasePrompts}
testDatabasePromptConfig={testDatabasePromptConfig}
>
<AIAssistantProvider
isGlobalDocument={!isRange}
onInsertBelow={handleInsertBelow}

View File

@@ -94,6 +94,7 @@ const dropdownMenuItemVariants = cva(
const DropdownMenuItem = forwardRef<
HTMLDivElement,
React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
className?: string;
inset?: boolean;
variant?: 'default' | 'destructive';
}