feat: support plugin state and settings

This commit is contained in:
arvinxx
2023-10-24 21:26:48 +08:00
parent 98860a8fb9
commit 10829a47d8
19 changed files with 377 additions and 101 deletions
@@ -1,6 +1,6 @@
import { RenderMessage } from '@lobehub/ui';
import isEqual from 'fast-deep-equal';
import { memo } from 'react';
import { memo, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useSessionStore } from '@/store/session';
@@ -14,11 +14,21 @@ export const FunctionMessage: RenderMessage = memo(({ id, content, plugin, name
chatSelectors.getFunctionMessageProps({ content, id, plugin }),
isEqual,
);
const [showRender, setShow] = useState(true);
return (
<Flexbox gap={12} id={id}>
<Inspector {...fcProps} />
<PluginRender content={content} loading={fcProps.loading} name={name} type={fcProps.type} />
<Inspector showRender={showRender} {...fcProps} setShow={setShow} />
{showRender && (
<PluginRender
content={content}
id={id}
loading={fcProps.loading}
name={name}
payload={fcProps.command}
type={fcProps.type}
/>
)}
</Flexbox>
);
});
@@ -1,15 +1,16 @@
import { LoadingOutlined } from '@ant-design/icons';
import { Avatar, Highlighter, Icon } from '@lobehub/ui';
import { LobePluginType } from '@lobehub/chat-plugin-sdk';
import { ActionIcon, Avatar, Highlighter, Icon } from '@lobehub/ui';
import { Tabs } from 'antd';
import { LucideChevronDown, LucideChevronUp, LucideToyBrick } from 'lucide-react';
import { LucideChevronDown, LucideChevronUp, LucideOrbit, LucideToyBrick } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { pluginHelpers, pluginSelectors, usePluginStore } from '@/store/plugin';
import { useStyles } from '../style';
import PluginResult from './PluginResultJSON';
import { useStyles } from './style';
export interface InspectorProps {
arguments?: string;
@@ -17,10 +18,22 @@ export interface InspectorProps {
content: string;
id?: string;
loading?: boolean;
setShow?: (showRender: boolean) => void;
showRender?: boolean;
type?: LobePluginType;
}
const Inspector = memo<InspectorProps>(
({ arguments: requestArgs = '{}', command, loading, content, id = 'unknown' }) => {
({
arguments: requestArgs = '{}',
command,
showRender,
loading,
setShow,
content,
id = 'unknown',
type,
}) => {
const { t } = useTranslation('plugin');
const { styles } = useStyles();
const [open, setOpen] = useState(false);
@@ -41,24 +54,37 @@ const Inspector = memo<InspectorProps>(
return (
<Flexbox gap={8}>
<Flexbox
align={'center'}
className={styles.container}
gap={8}
horizontal
onClick={() => {
setOpen(!open);
}}
>
{loading ? (
<div>
<LoadingOutlined />
</div>
) : (
avatar
)}
{pluginTitle ?? t('plugins.unknown')}
<Icon icon={open ? LucideChevronUp : LucideChevronDown} />
<Flexbox align={'center'} distribution={'space-between'} gap={24} horizontal>
<Flexbox
align={'center'}
className={styles.container}
gap={8}
horizontal
onClick={() => {
setOpen(!open);
}}
>
{loading ? (
<div>
<LoadingOutlined />
</div>
) : (
avatar
)}
{pluginTitle ?? t('plugins.unknown')}
<Icon icon={open ? LucideChevronUp : LucideChevronDown} />
</Flexbox>
<Flexbox horizontal>
{type === 'standalone' && <ActionIcon icon={LucideOrbit} />}
{setShow && (
<ActionIcon
icon={showRender ? LucideChevronUp : LucideChevronDown}
onClick={() => {
setShow(!showRender);
}}
/>
)}
</Flexbox>
</Flexbox>
{open && (
<Tabs
@@ -1,29 +0,0 @@
import { useEffect } from 'react';
import { onPluginFetchMessage, onPluginReady } from './utils';
export const useOnPluginReady = (onReady: () => void) => {
useEffect(() => {
const fn = (e: MessageEvent) => {
onPluginReady(e, onReady);
};
window.addEventListener('message', fn);
return () => {
window.removeEventListener('message', fn);
};
}, []);
};
export const useOnPluginFetchMessage = (onRequest: (data: any) => void, deps: any[] = []) => {
useEffect(() => {
const fn = (e: MessageEvent) => {
onPluginFetchMessage(e, onRequest);
};
window.addEventListener('message', fn);
return () => {
window.removeEventListener('message', fn);
};
}, deps);
};
@@ -1,9 +1,9 @@
import { PluginRenderProps } from '@lobehub/chat-plugin-sdk/client';
import { Skeleton } from 'antd';
import { memo, useEffect, useRef, useState } from 'react';
import { memo, useRef, useState } from 'react';
import { useOnPluginFetchMessage, useOnPluginReady } from './hooks';
import { sendMessageToPlugin } from './utils';
import { useOnPluginReadyForInteraction } from '../../utils/iframeOnReady';
import { sendMessageToPlugin } from '../../utils/postMessage';
interface IFrameRenderProps extends PluginRenderProps {
height?: number;
@@ -12,24 +12,13 @@ interface IFrameRenderProps extends PluginRenderProps {
}
const IFrameRender = memo<IFrameRenderProps>(({ url, width = 800, height = 300, ...props }) => {
const [loading, setLoading] = useState(true);
const [readyForRender, setReady] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
useOnPluginReady(() => setReady(true));
const [loading, setLoading] = useState(true);
// 当 props 发生变化时,主动向 iframe 发送数据
useEffect(() => {
useOnPluginReadyForInteraction(() => {
const iframeWin = iframeRef.current?.contentWindow;
if (iframeWin && readyForRender) {
sendMessageToPlugin(iframeWin, props);
}
}, [readyForRender, props]);
// 当接收到来自 iframe 的请求时,触发发送数据
useOnPluginFetchMessage(() => {
const iframeWin = iframeRef.current?.contentWindow;
if (iframeWin) {
sendMessageToPlugin(iframeWin, props);
}
@@ -1,17 +0,0 @@
import { PluginChannel } from '@lobehub/chat-plugin-sdk/client';
export const onPluginReady = (e: MessageEvent, onReady: () => void) => {
if (e.data.type === PluginChannel.pluginReadyForRender) {
onReady();
}
};
export const onPluginFetchMessage = (e: MessageEvent, onRequest: (data: any) => void) => {
if (e.data.type === PluginChannel.fetchPluginMessage) {
onRequest(e.data);
}
};
export const sendMessageToPlugin = (window: Window, props: any) => {
window.postMessage({ props, type: PluginChannel.renderPlugin }, '*');
};
@@ -1,17 +1,114 @@
import { PluginRequestPayload } from '@lobehub/chat-plugin-sdk';
import { Skeleton } from 'antd';
import { memo, useRef, useState } from 'react';
import { useOnPluginSettingsUpdate } from '@/app/chat/features/Conversation/ChatList/Plugins/Render/utils/pluginSettings';
import { useOnPluginStateUpdate } from '@/app/chat/features/Conversation/ChatList/Plugins/Render/utils/pluginState';
import { pluginSelectors, usePluginStore } from '@/store/plugin';
import { useSessionStore } from '@/store/session';
import { chatSelectors } from '@/store/session/selectors';
import { useOnPluginReadyForInteraction } from '../utils/iframeOnReady';
import {
useOnPluginFetchMessage,
useOnPluginFetchPluginSettings,
useOnPluginFetchPluginState,
useOnPluginFillContent,
} from '../utils/listenToPlugin';
import {
sendMessageToPlugin,
sendPayloadToPlugin,
sendPluginSettingsToPlugin,
sendPluginStateToPlugin,
} from '../utils/postMessage';
interface IFrameRenderProps {
height?: number;
id: string;
payload?: PluginRequestPayload;
url: string;
width?: number;
}
const IFrameRender = memo<IFrameRenderProps>(({ url, width = 600, height = 300 }) => {
const IFrameRender = memo<IFrameRenderProps>(({ url, id, payload, width = 600, height = 300 }) => {
const [loading, setLoading] = useState(true);
const iframeRef = useRef<HTMLIFrameElement>(null);
// when payload changesend content to plugin
useOnPluginReadyForInteraction(() => {
const iframeWin = iframeRef.current?.contentWindow;
if (iframeWin && payload) {
sendPayloadToPlugin(iframeWin, payload);
}
}, [payload]);
// when plugin wants to get message content, send it to plugin
useOnPluginFetchMessage(() => {
const iframeWin = iframeRef.current?.contentWindow;
if (iframeWin) {
const message = chatSelectors.getMessageById(id)(useSessionStore.getState());
if (!message) return;
const props = { content: '' };
try {
props.content = JSON.parse(message.content || '{}');
} catch {
props.content = message.content || '';
}
sendMessageToPlugin(iframeWin, props);
}
}, []);
// when plugin try to send back message, we should fill it to the message content
const fillPluginContent = useSessionStore((s) => s.fillPluginMessageContent);
useOnPluginFillContent((content) => {
fillPluginContent(id, content);
});
// when plugin wants to get plugin state, send it to plugin
useOnPluginFetchPluginState((key) => {
const iframeWin = iframeRef.current?.contentWindow;
if (iframeWin) {
const message = chatSelectors.getMessageById(id)(useSessionStore.getState());
if (!message) return;
sendPluginStateToPlugin(iframeWin, key, message.pluginState?.[key]);
}
});
// when plugin update state, we should update it to the message pluginState key
const updatePluginState = useSessionStore((s) => s.updatePluginState);
useOnPluginStateUpdate((key, value) => {
updatePluginState(id, key, value);
});
// when plugin wants to get plugin settings, send it to plugin
useOnPluginFetchPluginSettings(() => {
const iframeWin = iframeRef.current?.contentWindow;
if (iframeWin) {
if (!payload?.identifier) return;
const settings = pluginSelectors.getPluginSettingsById(payload?.identifier)(
usePluginStore.getState(),
);
sendPluginSettingsToPlugin(iframeWin, settings);
}
});
// when plugin update settings, we should update it to the plugin settings
const updatePluginSettings = usePluginStore((s) => s.updatePluginSettings);
useOnPluginSettingsUpdate((value) => {
if (!payload?.identifier) return;
updatePluginSettings(payload?.identifier, value);
});
return (
<>
{loading && <Skeleton active style={{ width }} />}
@@ -1,3 +1,4 @@
import { PluginRequestPayload } from '@lobehub/chat-plugin-sdk';
import { memo } from 'react';
import { usePluginStore } from '@/store/plugin';
@@ -5,10 +6,12 @@ import { usePluginStore } from '@/store/plugin';
import IFrameRender from './Iframe';
export interface PluginStandaloneTypeProps {
id: string;
name?: string;
payload?: PluginRequestPayload;
}
const PluginDefaultType = memo<PluginStandaloneTypeProps>(({ name = 'unknown' }) => {
const PluginDefaultType = memo<PluginStandaloneTypeProps>(({ payload, id, name = 'unknown' }) => {
const manifest = usePluginStore((s) => s.pluginManifestMap[name]);
if (!manifest?.ui) return;
@@ -17,7 +20,9 @@ const PluginDefaultType = memo<PluginStandaloneTypeProps>(({ name = 'unknown' })
if (!ui.url) return;
return <IFrameRender height={ui.height} url={ui.url} width={ui.width} />;
return (
<IFrameRender height={ui.height} id={id} payload={payload} url={ui.url} width={ui.width} />
);
});
export default PluginDefaultType;
@@ -1,4 +1,4 @@
import { LobePluginType } from '@lobehub/chat-plugin-sdk';
import { LobePluginType, PluginRequestPayload } from '@lobehub/chat-plugin-sdk';
import { memo } from 'react';
import DefaultType from './DefaultType';
@@ -6,15 +6,17 @@ import Standalone from './StandaloneType';
export interface PluginRenderProps {
content: string;
id: string;
loading?: boolean;
name?: string;
payload?: PluginRequestPayload;
type?: LobePluginType;
}
const PluginRender = memo<PluginRenderProps>(({ content, name, type }) => {
const PluginRender = memo<PluginRenderProps>(({ content, id, payload, name, type }) => {
switch (type) {
case 'standalone': {
return <Standalone name={name} />;
return <Standalone id={id} name={name} payload={payload} />;
}
default: {
@@ -0,0 +1,23 @@
import { PluginChannel } from '@lobehub/chat-plugin-sdk/client';
import { useEffect, useState } from 'react';
export const useOnPluginReadyForInteraction = (onReady: () => void, deps: any[] = []) => {
const [readyForRender, setReady] = useState(false);
useEffect(() => {
const fn = (e: MessageEvent) => {
if (e.data.type === PluginChannel.pluginReadyForRender) {
setReady(true);
}
};
window.addEventListener('message', fn);
return () => {
window.removeEventListener('message', fn);
};
}, []);
useEffect(() => {
if (readyForRender) {
onReady();
}
}, [readyForRender, ...deps]);
};
@@ -0,0 +1,65 @@
import { PluginChannel } from '@lobehub/chat-plugin-sdk/client';
import { useEffect } from 'react';
export const useOnPluginFetchMessage = (onRequest: (data: any) => void, deps: any[] = []) => {
useEffect(() => {
const fn = (e: MessageEvent) => {
if (e.data.type === PluginChannel.fetchPluginMessage) {
onRequest(e.data);
}
};
window.addEventListener('message', fn);
return () => {
window.removeEventListener('message', fn);
};
}, deps);
};
export const useOnPluginFetchPluginState = (onRequest: (key: string) => void) => {
useEffect(() => {
const fn = (e: MessageEvent) => {
if (e.data.type === PluginChannel.fetchPluginState) {
onRequest(e.data.key);
}
};
window.addEventListener('message', fn);
return () => {
window.removeEventListener('message', fn);
};
}, []);
};
export const useOnPluginFillContent = (callback: (content: string) => void) => {
useEffect(() => {
const fn = (e: MessageEvent) => {
if (e.data.type === PluginChannel.fillStandalonePluginContent) {
const data = e.data.content;
const content = typeof data !== 'string' ? JSON.stringify(data) : data;
callback(content);
}
};
window.addEventListener('message', fn);
return () => {
window.removeEventListener('message', fn);
};
}, []);
};
export const useOnPluginFetchPluginSettings = (onRequest: () => void) => {
useEffect(() => {
const fn = (e: MessageEvent) => {
if (e.data.type === PluginChannel.fetchPluginSettings) {
onRequest();
}
};
window.addEventListener('message', fn);
return () => {
window.removeEventListener('message', fn);
};
}, []);
};
@@ -0,0 +1,17 @@
import { PluginChannel } from '@lobehub/chat-plugin-sdk/client';
import { useEffect } from 'react';
export const useOnPluginSettingsUpdate = (callback: (settings: any) => void) => {
useEffect(() => {
const fn = (e: MessageEvent) => {
if (e.data.type === PluginChannel.updatePluginSettings) {
callback(e.data.value);
}
};
window.addEventListener('message', fn);
return () => {
window.removeEventListener('message', fn);
};
}, []);
};
@@ -0,0 +1,20 @@
import { PluginChannel } from '@lobehub/chat-plugin-sdk/client';
import { useEffect } from 'react';
export const useOnPluginStateUpdate = (callback: (key: string, value: any) => void) => {
useEffect(() => {
const fn = (e: MessageEvent) => {
if (e.data.type === PluginChannel.updatePluginState) {
const key = e.data.key;
const value = e.data.value;
callback(key, value);
}
};
window.addEventListener('message', fn);
return () => {
window.removeEventListener('message', fn);
};
}, []);
};
@@ -0,0 +1,17 @@
import { PluginChannel } from '@lobehub/chat-plugin-sdk/client';
export const sendMessageToPlugin = (window: Window, props: any) => {
window.postMessage({ props, type: PluginChannel.renderPlugin }, '*');
};
export const sendPayloadToPlugin = (window: Window, props: any) => {
window.postMessage({ props, type: PluginChannel.initStandalonePlugin }, '*');
};
export const sendPluginStateToPlugin = (window: Window, key: string, value: any) => {
window.postMessage({ key, type: PluginChannel.renderPluginState, value }, '*');
};
export const sendPluginSettingsToPlugin = (window: Window, settings: any) => {
window.postMessage({ type: PluginChannel.renderPluginState, value: settings }, '*');
};
@@ -16,9 +16,10 @@ const t = setNamespace('chat/plugin');
* 插件方法
*/
export interface ChatPluginAction {
fillPluginMessageContent: (id: string, content: string) => Promise<void>;
runPluginDefaultType: (id: string, payload: any) => Promise<void>;
runPluginStandaloneType: (id: string, payload: any) => Promise<void>;
triggerFunctionCall: (id: string) => Promise<void>;
updatePluginState: (id: string, key: string, value: any) => void;
}
export const chatPlugin: StateCreator<
@@ -27,6 +28,14 @@ export const chatPlugin: StateCreator<
[],
ChatPluginAction
> = (set, get) => ({
fillPluginMessageContent: async (id, content) => {
const { dispatchMessage, coreProcessMessage } = get();
dispatchMessage({ id, key: 'content', type: 'updateMessage', value: content });
const chats = chatSelectors.currentChats(get());
await coreProcessMessage(chats, id);
},
runPluginDefaultType: async (id, payload) => {
const { dispatchMessage, coreProcessMessage, toggleChatLoading } = get();
let data: string;
@@ -47,11 +56,8 @@ export const chatPlugin: StateCreator<
const chats = chatSelectors.currentChats(get());
await coreProcessMessage(chats, id);
},
runPluginStandaloneType: async (id, payload) => {
console.log('触发standalone', id, payload);
},
triggerFunctionCall: async (id) => {
const { dispatchMessage, runPluginDefaultType, runPluginStandaloneType } = get();
const { dispatchMessage, runPluginDefaultType } = get();
const session = sessionSelectors.currentSession(get());
if (!session) return;
@@ -91,7 +97,13 @@ export const chatPlugin: StateCreator<
dispatchMessage({ id, key: 'name', type: 'updateMessage', value: payload.identifier });
dispatchMessage({ id, key: 'plugin', type: 'updateMessage', value: payload });
if (payload.type === 'standalone') runPluginStandaloneType(id, payload);
else runPluginDefaultType(id, payload);
if (payload.type === 'standalone') {
// nothing to do
} else runPluginDefaultType(id, payload);
},
updatePluginState: (id, key, value) => {
const { dispatchMessage } = get();
dispatchMessage({ id, key, type: 'updatePluginState', value });
},
});
@@ -1,8 +1,10 @@
import isEqual from 'fast-deep-equal';
import { produce } from 'immer';
import { ChatMessage, ChatMessageMap } from '@/types/chatMessage';
import { LLMRoleType } from '@/types/llm';
import { MetaData } from '@/types/meta';
import { merge } from '@/utils/merge';
import { nanoid } from '@/utils/uuid';
interface AddMessage {
@@ -38,12 +40,20 @@ interface UpdateMessageExtra {
value: any;
}
interface UpdatePluginState {
id: string;
key: string;
type: 'updatePluginState';
value: any;
}
export type MessageDispatch =
| AddMessage
| DeleteMessage
| ResetMessages
| UpdateMessage
| UpdateMessageExtra;
| UpdateMessageExtra
| UpdatePluginState;
export const messagesReducer = (
state: ChatMessageMap,
@@ -101,6 +111,26 @@ export const messagesReducer = (
});
}
case 'updatePluginState': {
return produce(state, (draftState) => {
const { id, key, value } = payload;
const message = draftState[id];
if (!message) return;
let newState;
if (!message.pluginState) {
newState = { [key]: value } as any;
} else {
newState = merge(message.pluginState, { [key]: value });
}
if (isEqual(message.pluginState, newState)) return;
message.pluginState = newState;
message.updateAt = Date.now();
});
}
case 'resetMessages': {
return produce(state, (draftState) => {
const { topicId } = payload;
@@ -102,3 +102,9 @@ export const getFunctionMessageProps =
loading: id === s.chatLoadingId,
type: plugin?.type as LobePluginType,
});
export const getMessageById = (id: string) => (s: SessionStore) => {
for (const e of Object.values(s.sessions)) {
if (e.chats[id]) return e.chats[id];
}
};
@@ -5,6 +5,7 @@ import {
currentChatsWithHistoryConfig,
getChatsById,
getFunctionMessageProps,
getMessageById,
} from './chat';
import { currentTopics, getTopicMessages } from './topic';
@@ -15,6 +16,7 @@ export const chatSelectors = {
currentChatsWithHistoryConfig,
getChatsById,
getFunctionMessageProps,
getMessageById,
};
export const topicSelectors = {
+1
View File
@@ -46,6 +46,7 @@ export interface ChatMessage extends BaseDataModel {
parentId?: string;
plugin?: PluginRequestPayload;
pluginState?: any;
// 引用
quotaId?: string;