💄 style: refactor the plugin render style (#6390)

* wip

* refactor the args design

* update

* refactor tools

* try improve suspense

* update i18n

* fix lint

* fix api key issue

* improve code

* improve suspense

* improve suspense

* fix topic duplicate issue
This commit is contained in:
Arvin Xu
2025-02-22 00:29:05 +08:00
committed by GitHub
parent 7e6aaa0344
commit 3ecdba1a1b
42 changed files with 773 additions and 434 deletions
+4
View File
@@ -118,6 +118,10 @@
"reinstallError": "فشل تحديث الإضافة {{name}}",
"urlError": "الرابط لا يعيد محتوى بتنسيق JSON، يرجى التأكد من صحة الرابط"
},
"inspector": {
"args": "عرض قائمة المعلمات",
"pluginRender": "عرض واجهة المكون الإضافي"
},
"list": {
"item": {
"deprecated.title": "مهجور",
+4
View File
@@ -118,6 +118,10 @@
"reinstallError": "Неуспешно опресняване на плъгина {{name}}",
"urlError": "Връзката не върна съдържание във формат JSON. Моля, уверете се, че е валидна връзка."
},
"inspector": {
"args": "Преглед на списъка с параметри",
"pluginRender": "Преглед на интерфейса на плъгина"
},
"list": {
"item": {
"deprecated.title": "Изтрит",
+4
View File
@@ -118,6 +118,10 @@
"reinstallError": "Fehler beim Aktualisieren des Plugins {{name}}.",
"urlError": "Der Link hat keine JSON-Format-Inhalte zurückgegeben. Stellen Sie sicher, dass der Link gültig ist."
},
"inspector": {
"args": "Parameterliste anzeigen",
"pluginRender": "Plugin-Oberfläche anzeigen"
},
"list": {
"item": {
"deprecated.title": "Veraltet",
+4
View File
@@ -118,6 +118,10 @@
"reinstallError": "Failed to refresh plugin {{name}}",
"urlError": "The link did not return content in JSON format. Please ensure it is a valid link."
},
"inspector": {
"args": "View parameter list",
"pluginRender": "View plugin interface"
},
"list": {
"item": {
"deprecated.title": "Deleted",
+4
View File
@@ -118,6 +118,10 @@
"reinstallError": "Error al volver a instalar el complemento {{name}}.",
"urlError": "El enlace no devuelve contenido en formato JSON. Asegúrese de que sea un enlace válido."
},
"inspector": {
"args": "Ver lista de parámetros",
"pluginRender": "Ver interfaz del plugin"
},
"list": {
"item": {
"deprecated.title": "Obsoleto",
+4
View File
@@ -118,6 +118,10 @@
"reinstallError": "بروزرسانی افزونه {{name}} ناموفق بود.",
"urlError": "این لینک محتوای JSON بازنگرداند، لطفاً از معتبر بودن لینک اطمینان حاصل کنید."
},
"inspector": {
"args": "مشاهده لیست پارامترها",
"pluginRender": "مشاهده رابط کاربری پلاگین"
},
"list": {
"item": {
"deprecated.title": "حذف شده",
+4
View File
@@ -118,6 +118,10 @@
"reinstallError": "Échec de la mise à jour du plugin {{name}}",
"urlError": "Ce lien ne renvoie pas de contenu au format JSON. Veuillez vous assurer qu'il s'agit d'un lien valide."
},
"inspector": {
"args": "Voir la liste des paramètres",
"pluginRender": "Voir l'interface du plugin"
},
"list": {
"item": {
"deprecated.title": "Obsolète",
+4
View File
@@ -118,6 +118,10 @@
"reinstallError": "Ricaricamento del plugin {{name}} fallito",
"urlError": "Il collegamento non restituisce contenuti nel formato JSON. Assicurati che il collegamento sia valido"
},
"inspector": {
"args": "Visualizza l'elenco dei parametri",
"pluginRender": "Visualizza l'interfaccia del plugin"
},
"list": {
"item": {
"deprecated.title": "Deprecato",
+4
View File
@@ -118,6 +118,10 @@
"reinstallError": "プラグイン{{name}}の再インストールに失敗しました",
"urlError": "このリンクはJSON形式のコンテンツを返していません。有効なリンクであることを確認してください"
},
"inspector": {
"args": "パラメーターリストを表示",
"pluginRender": "プラグインインターフェースを表示"
},
"list": {
"item": {
"deprecated.title": "削除済み",
+4
View File
@@ -118,6 +118,10 @@
"reinstallError": "플러그인 {{name}} 다시 설치 중 오류가 발생했습니다.",
"urlError": "이 링크는 JSON 형식의 내용을 반환하지 않습니다. 유효한 링크인지 확인하세요."
},
"inspector": {
"args": "매개변수 목록 보기",
"pluginRender": "플러그인 인터페이스 보기"
},
"list": {
"item": {
"deprecated.title": "삭제됨",
+4
View File
@@ -118,6 +118,10 @@
"reinstallError": "Vernieuwen van de plugin {{name}} is mislukt.",
"urlError": "De link retourneert geen JSON-indeling. Zorg ervoor dat het een geldige link is."
},
"inspector": {
"args": "Bekijk parameterlijst",
"pluginRender": "Bekijk plugininterface"
},
"list": {
"item": {
"deprecated.title": "Verouderd",
+4
View File
@@ -118,6 +118,10 @@
"reinstallError": "Nie udało się odświeżyć wtyczki {{name}}",
"urlError": "Link nie zwrócił treści w formacie JSON. Upewnij się, że jest to poprawny link."
},
"inspector": {
"args": "Zobacz listę parametrów",
"pluginRender": "Zobacz interfejs wtyczki"
},
"list": {
"item": {
"deprecated.title": "Usunięte",
+4
View File
@@ -118,6 +118,10 @@
"reinstallError": "Falha ao atualizar o plugin {{name}}",
"urlError": "O link não retornou conteúdo no formato JSON. Certifique-se de que o link é válido."
},
"inspector": {
"args": "Ver parâmetros",
"pluginRender": "Ver interface do plugin"
},
"list": {
"item": {
"deprecated.title": "Obsoleto",
+4
View File
@@ -118,6 +118,10 @@
"reinstallError": "Ошибка при обновлении плагина {{name}}",
"urlError": "Ссылка не возвращает данные в формате JSON. Проверьте правильность ссылки"
},
"inspector": {
"args": "Просмотреть список параметров",
"pluginRender": "Просмотреть интерфейс плагина"
},
"list": {
"item": {
"deprecated.title": "Устарел",
+4
View File
@@ -118,6 +118,10 @@
"reinstallError": "{{name}} eklentisi yenilenemedi",
"urlError": "Bağlantı JSON formatında içerik döndürmedi. Lütfen geçerli bir bağlantı olduğundan emin olun"
},
"inspector": {
"args": "Parametre listesini görüntüle",
"pluginRender": "Eklenti arayüzünü görüntüle"
},
"list": {
"item": {
"deprecated.title": "Eski",
+4
View File
@@ -118,6 +118,10 @@
"reinstallError": "Làm mới plugin {{name}} thất bại",
"urlError": "Liên kết này không trả về nội dung dạng JSON, vui lòng đảm bảo rằng đó là một liên kết hợp lệ"
},
"inspector": {
"args": "Xem danh sách tham số",
"pluginRender": "Xem giao diện plugin"
},
"list": {
"item": {
"deprecated.title": "Đã loại bỏ",
+5 -1
View File
@@ -118,6 +118,10 @@
"reinstallError": "插件 {{name}} 刷新失败",
"urlError": "该链接没有返回 JSON 格式的内容, 请确保是有效的链接"
},
"inspector": {
"args": "查看参数列表",
"pluginRender": "查看插件界面"
},
"list": {
"item": {
"deprecated.title": "已删除",
@@ -163,4 +167,4 @@
"title": "插件商店"
},
"unknownPlugin": "未知插件"
}
}
+4
View File
@@ -118,6 +118,10 @@
"reinstallError": "插件 {{name}} 刷新失敗",
"urlError": "該連結沒有返回 JSON 格式的內容, 請確保是有效的連結"
},
"inspector": {
"args": "查看參數列表",
"pluginRender": "查看插件介面"
},
"list": {
"item": {
"deprecated.title": "已刪除",
@@ -14,10 +14,10 @@ const leftActions = [
'model',
'fileUpload',
'knowledgeBase',
'params',
'history',
'stt',
'tools',
'params',
'mainToken',
] as ActionKeys[];
+22 -9
View File
@@ -115,21 +115,34 @@ export class AiProviderModel {
const encrypt = encryptor ?? defaultSerialize;
const keyVaults = await encrypt(JSON.stringify(value.keyVaults));
const commonFields = {
checkModel: value.checkModel,
fetchOnClient: value.fetchOnClient,
keyVaults,
};
return this.db
.update(aiProviders)
.set({ ...value, keyVaults, updatedAt: new Date() })
.where(and(eq(aiProviders.id, id), eq(aiProviders.userId, this.userId)));
.insert(aiProviders)
.values({
...commonFields,
id,
source: this.getProviderSource(id),
updatedAt: new Date(),
userId: this.userId,
})
.onConflictDoUpdate({
set: { ...commonFields, updatedAt: new Date() },
target: [aiProviders.id, aiProviders.userId],
});
};
toggleProviderEnabled = async (id: string, enabled: boolean) => {
const isBuiltin = Object.values(ModelProvider).includes(id as any);
return this.db
.insert(aiProviders)
.values({
enabled,
id,
source: isBuiltin ? 'builtin' : 'custom',
source: this.getProviderSource(id),
updatedAt: new Date(),
userId: this.userId,
})
@@ -142,15 +155,13 @@ export class AiProviderModel {
updateOrder = async (sortMap: { id: string; sort: number }[]) => {
await this.db.transaction(async (tx) => {
const updates = sortMap.map(({ id, sort }) => {
const isBuiltin = Object.values(ModelProvider).includes(id as any);
return tx
.insert(aiProviders)
.values({
enabled: true,
id,
sort,
source: isBuiltin ? 'builtin' : 'custom',
source: this.getProviderSource(id),
updatedAt: new Date(),
userId: this.userId,
})
@@ -238,4 +249,6 @@ export class AiProviderModel {
};
private isBuiltInProvider = (id: string) => Object.values(ModelProvider).includes(id as any);
private getProviderSource = (id: string) => (this.isBuiltInProvider(id) ? 'builtin' : 'custom');
}
+2
View File
@@ -224,6 +224,7 @@ export class TopicModel {
.insert(topics)
.values({
...originalTopic,
clientId: null,
id: this.genId(),
title: newTitle || originalTopic?.title,
})
@@ -242,6 +243,7 @@ export class TopicModel {
.insert(messages)
.values({
...message,
clientId: null,
id: idGenerator('messages'),
topicId: duplicatedTopic.id,
})
@@ -0,0 +1,43 @@
import { Highlighter } from '@lobehub/ui';
import { Tabs } from 'antd';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
interface DebugProps {
payload: object;
requestArgs?: string;
}
const Debug = memo<DebugProps>(({ payload, requestArgs }) => {
const { t } = useTranslation('plugin');
let params;
try {
params = JSON.stringify(JSON.parse(requestArgs || ''), null, 2);
} catch {
params = '';
}
return (
<Tabs
items={[
{
children: <Highlighter language={'json'}>{params}</Highlighter>,
key: 'arguments',
label: t('debug.arguments'),
},
{
children: <Highlighter language={'json'}>{JSON.stringify(payload, null, 2)}</Highlighter>,
key: 'function_call',
label: t('debug.function_call'),
},
// {
// children: <PluginResult content={content} />,
// key: 'response',
// label: t('debug.response'),
// },
]}
style={{ display: 'grid', maxWidth: 800, minWidth: 400 }}
/>
);
});
export default Debug;
@@ -0,0 +1,58 @@
import { createStyles } from 'antd-style';
export const useStyles = createStyles(({ css, token }, borderWidth: number = 2.5) => ({
background: css`
position: absolute;
inset: 0;
aspect-ratio: 1;
width: 100%;
border-radius: 50%;
background: ${token.colorFill};
mask: radial-gradient(farthest-side, #0000 calc(100% - ${borderWidth}px), #000 0);
`,
container: css`
position: relative;
width: 13px;
height: 13px;
`,
loader: css`
position: absolute;
inset: 0;
aspect-ratio: 1;
width: 100%;
border-radius: 50%;
background:
radial-gradient(farthest-side, ${token.colorTextSecondary} 94%, #0000) top/ ${borderWidth}px
${borderWidth}px no-repeat,
conic-gradient(#0000 50%, ${token.colorTextSecondary});
mask: radial-gradient(farthest-side, #0000 calc(100% - ${borderWidth}px), #000 0);
animation: small-loader-anim 1s infinite linear;
@keyframes small-loader-anim {
100% {
transform: rotate(1turn);
}
}
`,
}));
const Loader = () => {
const { styles } = useStyles();
return (
<div className={styles.container}>
<div className={styles.loader} />
<div className={styles.background} />
</div>
);
};
export default Loader;
@@ -0,0 +1,151 @@
import { ActionIcon, Icon } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import isEqual from 'fast-deep-equal';
import {
ChevronDown,
ChevronRight,
LayoutPanelTop,
LogsIcon,
LucideBug,
LucideBugOff,
} from 'lucide-react';
import { CSSProperties, memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import PluginAvatar from '@/features/PluginAvatar';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { pluginHelpers, useToolStore } from '@/store/tool';
import { toolSelectors } from '@/store/tool/selectors';
import { shinyTextStylish } from '@/styles/loading';
import Debug from './Debug';
import Loader from './Loader';
import Settings from './Settings';
export const useStyles = createStyles(({ css, token }) => ({
apiName: css`
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
font-family: ${token.fontFamilyCode};
font-size: 12px;
text-overflow: ellipsis;
`,
container: css`
cursor: pointer;
width: fit-content;
padding-block: 2px;
border-radius: 6px;
color: ${token.colorTextTertiary};
&:hover {
background: ${token.colorFillTertiary};
}
`,
plugin: css`
display: flex;
gap: 4px;
align-items: center;
width: fit-content;
`,
shinyText: shinyTextStylish(token),
}));
interface InspectorProps {
apiName: string;
arguments?: string;
id: string;
identifier: string;
index: number;
messageId: string;
payload: object;
setShowPluginRender: (show: boolean) => void;
setShowRender: (show: boolean) => void;
showPluginRender: boolean;
showPortal?: boolean;
showRender: boolean;
style?: CSSProperties;
}
const Inspectors = memo<InspectorProps>(
({
messageId,
index,
identifier,
apiName,
arguments: requestArgs,
showRender,
payload,
setShowRender,
showPluginRender,
setShowPluginRender,
}) => {
const { t } = useTranslation('plugin');
const { styles } = useStyles();
const [showDebug, setShowDebug] = useState(false);
const loading = useChatStore(chatSelectors.isToolCallStreaming(messageId, index));
const pluginMeta = useToolStore(toolSelectors.getMetaById(identifier), isEqual);
const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? t('unknownPlugin');
return (
<Flexbox gap={4}>
<Flexbox align={'center'} distribution={'space-between'} gap={8} horizontal>
<Flexbox
align={'center'}
className={styles.container}
gap={8}
horizontal
onClick={() => {
setShowRender(!showRender);
}}
paddingInline={4}
>
<Flexbox
align={'center'}
className={loading ? styles.shinyText : ''}
gap={4}
horizontal
>
{loading ? <Loader /> : <PluginAvatar identifier={identifier} size={20} />}
<div>{pluginTitle}</div>/<span className={styles.apiName}>{apiName}</span>
</Flexbox>
<Icon icon={showRender ? ChevronDown : ChevronRight} />
</Flexbox>
<Flexbox horizontal>
{showRender && (
<ActionIcon
icon={showPluginRender ? LogsIcon : LayoutPanelTop}
onClick={() => {
setShowPluginRender(!showPluginRender);
}}
size={'small'}
title={showPluginRender ? t('inspector.args') : t('inspector.pluginRender')}
/>
)}
<ActionIcon
icon={showDebug ? LucideBugOff : LucideBug}
onClick={() => {
setShowDebug(!showDebug);
}}
size={'small'}
title={t(showDebug ? 'debug.off' : 'debug.on')}
/>
<Settings id={identifier} />
</Flexbox>
</Flexbox>
{showDebug && <Debug payload={payload} requestArgs={requestArgs} />}
</Flexbox>
);
},
);
export default Inspectors;
@@ -0,0 +1,165 @@
import { Highlighter, copyToClipboard } from '@lobehub/ui';
import { App } from 'antd';
import { createStyles } from 'antd-style';
import { parse } from 'partial-json';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useYamlArguments } from '@/hooks/useYamlArguments';
import { shinyTextStylish } from '@/styles/loading';
const useStyles = createStyles(({ css, token }) => ({
arrayRow: css`
&:not(:first-child) {
border-block-start: 1px dotted ${token.colorBorderSecondary};
}
`,
colon: css`
color: ${token.colorTextTertiary};
`,
container: css`
padding-block: 4px;
padding-inline: 12px;
border-radius: ${token.borderRadiusLG}px;
font-family: ${token.fontFamilyCode};
font-size: 13px;
line-height: 1.5;
background: ${token.colorFillQuaternary};
`,
copyable: css`
cursor: pointer;
width: 100%;
margin-block: 2px;
padding: 4px;
&:hover {
border-radius: 6px;
background: ${token.colorFillTertiary};
}
`,
key: css`
color: ${token.colorTextTertiary};
`,
row: css`
display: flex;
align-items: baseline;
&:not(:first-child) {
border-block-start: 1px dotted ${token.colorBorderSecondary};
}
`,
shineText: shinyTextStylish(token),
value: css`
color: ${token.colorTextSecondary};
`,
}));
interface ObjectDisplayProps {
data: Record<string, any>;
shine?: boolean;
}
const ObjectDisplay = memo(({ data, shine }: ObjectDisplayProps) => {
const { styles, cx } = useStyles();
const { t } = useTranslation('common');
const { message } = App.useApp();
const isMobile = useIsMobile();
const formatValue = (value: any): string | string[] => {
if (Array.isArray(value)) {
return value.map((v) => (typeof v === 'object' ? JSON.stringify(v) : v));
}
if (typeof value === 'object' && value !== null) {
return Object.entries(value)
.map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`)
.join(', ');
}
return String(value);
};
const hasMinWidth = Object.keys(data).length > 1;
if (Object.keys(data).length === 0) return null;
return (
<div className={styles.container}>
{Object.entries(data).map(([key, value]) => {
const formatedValue = formatValue(value);
return (
<div className={styles.row} key={key}>
<span
className={styles.key}
style={{ minWidth: hasMinWidth ? (isMobile ? 60 : 80) : undefined }}
>
{key}
</span>
<span className={styles.colon}>:</span>
<div className={cx(shine ? styles.shineText : styles.value)} style={{ width: '100%' }}>
{typeof formatedValue === 'string' ? (
<div
className={styles.copyable}
onClick={async () => {
await copyToClipboard(formatedValue);
message.success(t('copySuccess'));
}}
>
{formatedValue}
</div>
) : (
formatedValue.map((v, i) => (
<div
className={styles.arrayRow}
key={i}
onClick={async () => {
await copyToClipboard(v);
message.success(t('copySuccess'));
}}
>
<div className={styles.copyable}>{v}</div>
</div>
))
)}
</div>
</div>
);
})}
</div>
);
});
export interface ArgumentsProps {
arguments?: string;
shine?: boolean;
}
const Arguments = memo<ArgumentsProps>(({ arguments: args = '', shine }) => {
const requestArgs = useMemo(() => {
try {
const obj = parse(args);
if (Object.keys(obj).length === 0) return {};
return obj;
} catch {
return args;
}
}, [args]);
const yaml = useYamlArguments(args);
return typeof requestArgs === 'string' ? (
!!yaml && (
<Highlighter language={'yaml'} showLanguage={false}>
{yaml}
</Highlighter>
)
) : (
<ObjectDisplay data={requestArgs} shine={shine} />
);
});
export default Arguments;
@@ -2,7 +2,7 @@ import { Icon } from '@lobehub/ui';
import { ConfigProvider, Empty } from 'antd';
import { useTheme } from 'antd-style';
import { LucideSquareArrowLeft, LucideSquareArrowRight } from 'lucide-react';
import { memo, useContext, useState } from 'react';
import { memo, useContext, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
@@ -11,14 +11,15 @@ import { useChatStore } from '@/store/chat';
import { chatPortalSelectors, chatSelectors } from '@/store/chat/selectors';
import { ChatMessage } from '@/types/message';
import Arguments from '../../components/Arguments';
import Inspector from './Inspector';
import Arguments from './Arguments';
const Tool = memo<
const CustomRender = memo<
ChatMessage & {
showPortal?: boolean;
requestArgs?: string;
setShowPluginRender: (show: boolean) => void;
showPluginRender: boolean;
}
>(({ id, content, pluginState, plugin, showPortal }) => {
>(({ id, content, pluginState, plugin, requestArgs, showPluginRender, setShowPluginRender }) => {
const [loading, isMessageToolUIOpen] = useChatStore((s) => [
chatSelectors.isPluginApiInvoking(id)(s),
chatPortalSelectors.isPluginUIOpen(id)(s),
@@ -27,36 +28,34 @@ const Tool = memo<
const { t } = useTranslation('plugin');
const theme = useTheme();
const [showRender, setShow] = useState(plugin?.type !== 'default');
useEffect(() => {
if (!plugin?.type) return;
setShowPluginRender(plugin?.type !== 'default');
}, [plugin?.type]);
if (isMessageToolUIOpen)
return (
<Center paddingBlock={8} style={{ background: theme.colorFillQuaternary, borderRadius: 4 }}>
<Empty
description={t('showInPortal')}
image={
<Icon
color={theme.colorTextQuaternary}
icon={direction === 'rtl' ? LucideSquareArrowLeft : LucideSquareArrowRight}
size={'large'}
/>
}
styles={{
image: { height: 24 },
}}
/>
</Center>
);
return (
<Flexbox gap={12} id={id} width={'100%'}>
<Inspector
arguments={plugin?.arguments}
content={content}
id={id}
identifier={plugin?.identifier}
loading={loading}
payload={plugin}
setShow={setShow}
showPortal={showPortal}
showRender={showRender}
/>
{isMessageToolUIOpen ? (
<Center paddingBlock={8} style={{ background: theme.colorFillQuaternary, borderRadius: 4 }}>
<Empty
description={t('showInPortal')}
image={
<Icon
color={theme.colorTextQuaternary}
icon={direction === 'rtl' ? LucideSquareArrowLeft : LucideSquareArrowRight}
size={'large'}
/>
}
imageStyle={{ height: 24 }}
/>
</Center>
) : showRender || loading ? (
{showPluginRender ? (
<PluginRender
arguments={plugin?.arguments}
content={content}
@@ -68,10 +67,10 @@ const Tool = memo<
type={plugin?.type}
/>
) : (
<Arguments arguments={plugin?.arguments} />
<Arguments arguments={requestArgs} />
)}
</Flexbox>
);
});
export default Tool;
export default CustomRender;
@@ -0,0 +1,39 @@
import { Suspense, memo } from 'react';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import Arguments from './Arguments';
import CustomRender from './CustomRender';
interface RenderProps {
messageId: string;
requestArgs?: string;
setShowPluginRender: (show: boolean) => void;
showPluginRender: boolean;
toolCallId: string;
toolIndex: number;
}
const Render = memo<RenderProps>(
({ toolCallId, toolIndex, messageId, requestArgs, showPluginRender, setShowPluginRender }) => {
const loading = useChatStore(chatSelectors.isToolCallStreaming(messageId, toolIndex));
const toolMessage = useChatStore(chatSelectors.getMessageByToolCallId(toolCallId));
// 如果处于 loading 或者找不到 toolMessage 则展示 Arguments
if (loading || !toolMessage) return <Arguments arguments={requestArgs} />;
if (!!toolMessage)
return (
<Suspense fallback={<Arguments arguments={requestArgs} shine />}>
<CustomRender
{...toolMessage}
requestArgs={requestArgs}
setShowPluginRender={setShowPluginRender}
showPluginRender={showPluginRender}
/>
</Suspense>
);
},
);
export default Render;
@@ -0,0 +1,70 @@
import { AnimatePresence, motion } from 'framer-motion';
import { CSSProperties, memo, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import Inspectors from './Inspector';
import Render from './Render';
export interface InspectorProps {
apiName: string;
arguments?: string;
id: string;
identifier: string;
index: number;
messageId: string;
payload: object;
style?: CSSProperties;
}
const Tool = memo<InspectorProps>(
({ arguments: requestArgs, apiName, messageId, id, index, identifier, style, payload }) => {
const [showRender, setShowRender] = useState(true);
const [showPluginRender, setShowPluginRender] = useState(false);
return (
<Flexbox gap={8} style={style}>
<Inspectors
apiName={apiName}
arguments={requestArgs}
id={id}
identifier={identifier}
index={index}
messageId={messageId}
payload={payload}
setShowPluginRender={setShowPluginRender}
setShowRender={setShowRender}
showPluginRender={showPluginRender}
showRender={showRender}
/>
<AnimatePresence initial={false}>
{showRender && (
<motion.div
animate="open"
exit="collapsed"
initial="collapsed"
transition={{
duration: 0.1,
ease: [0.4, 0, 0.2, 1], // 使用 ease-out 缓动函数
}}
variants={{
collapsed: { height: 0, opacity: 0, width: 0 },
open: { height: 'auto', opacity: 1, width: 'auto' },
}}
>
<Render
messageId={messageId}
requestArgs={requestArgs}
setShowPluginRender={setShowPluginRender}
showPluginRender={showPluginRender}
toolCallId={id}
toolIndex={index}
/>
</motion.div>
)}
</AnimatePresence>
</Flexbox>
);
},
);
export default Tool;
@@ -1,166 +0,0 @@
import { Loading3QuartersOutlined } from '@ant-design/icons';
import { ActionIcon, Highlighter, Icon, Tag } from '@lobehub/ui';
import { Tabs, Typography } from 'antd';
import isEqual from 'fast-deep-equal';
import {
BetweenVerticalStart,
LucideBug,
LucideBugOff,
LucideChevronDown,
LucideChevronRight,
} from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { DESKTOP_HEADER_ICON_SIZE } from '@/const/layoutTokens';
import PluginAvatar from '@/features/PluginAvatar';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors } from '@/store/chat/selectors';
import { pluginHelpers, useToolStore } from '@/store/tool';
import { toolSelectors } from '@/store/tool/selectors';
import { ChatPluginPayload } from '@/types/message';
import PluginResult from './PluginResultJSON';
import Settings from './Settings';
import { useStyles } from './style';
export interface InspectorProps {
arguments?: string;
content: string;
id: string;
identifier?: string;
loading?: boolean;
payload?: ChatPluginPayload;
setShow?: (showRender: boolean) => void;
showPortal?: boolean;
showRender?: boolean;
}
const Inspector = memo<InspectorProps>(
({
arguments: requestArgs = '{}',
payload,
showRender,
loading,
setShow,
content,
identifier = 'unknown',
id,
showPortal = true,
}) => {
const { t } = useTranslation(['plugin', 'portal']);
const { styles } = useStyles();
const [open, setOpen] = useState(false);
const [isMessageToolUIOpen, openToolUI, togglePortal] = useChatStore((s) => [
chatPortalSelectors.isPluginUIOpen(id)(s),
s.openToolUI,
s.togglePortal,
]);
const isMobile = useIsMobile();
const pluginMeta = useToolStore(toolSelectors.getMetaById(identifier), isEqual);
const showRightAction = useToolStore(toolSelectors.isToolHasUI(identifier));
const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? t('unknownPlugin');
let args, params;
try {
args = JSON.stringify(payload, null, 2);
params = JSON.stringify(JSON.parse(requestArgs), null, 2);
} catch {
args = '';
params = '';
}
return (
<Flexbox gap={8}>
<Flexbox align={'center'} distribution={'space-between'} gap={24} horizontal>
<Flexbox
align={'center'}
className={styles.container}
gap={isMobile ? 16 : 8}
horizontal
onClick={() => {
setShow?.(!showRender);
}}
>
<Flexbox align={'center'} gap={8} horizontal>
{loading ? (
<div>
<Loading3QuartersOutlined spin />
</div>
) : (
<PluginAvatar identifier={identifier} size={isMobile ? 36 : undefined} />
)}
{isMobile ? (
<Flexbox>
<div>{pluginTitle}</div>
<Typography.Text className={styles.apiName} type={'secondary'}>
{payload?.apiName}
</Typography.Text>
</Flexbox>
) : (
<>
<div>{pluginTitle}</div>
<Tag>{payload?.apiName}</Tag>
</>
)}
</Flexbox>
{showRightAction && <Icon icon={showRender ? LucideChevronDown : LucideChevronRight} />}
</Flexbox>
<Flexbox horizontal>
{!isMobile && showRightAction && showPortal && (
<ActionIcon
icon={BetweenVerticalStart}
onClick={() => {
if (!isMessageToolUIOpen) openToolUI(id, identifier);
else {
togglePortal(false);
}
}}
size={DESKTOP_HEADER_ICON_SIZE}
title={t('title', { ns: 'portal' })}
/>
)}
<ActionIcon
icon={open ? LucideBugOff : LucideBug}
onClick={() => {
setOpen(!open);
}}
title={t(open ? 'debug.off' : 'debug.on')}
/>
<Settings id={identifier} />
</Flexbox>
</Flexbox>
{open && (
<Tabs
items={[
{
children: <Highlighter language={'json'}>{args}</Highlighter>,
key: 'function_call',
label: t('debug.function_call'),
},
{
children: <Highlighter language={'json'}>{params}</Highlighter>,
key: 'arguments',
label: t('debug.arguments'),
},
{
children: <PluginResult content={content} />,
key: 'response',
label: t('debug.response'),
},
]}
style={{ display: 'grid', maxWidth: 800, minWidth: 400 }}
/>
)}
</Flexbox>
);
},
);
export default Inspector;
@@ -1,35 +0,0 @@
import { createStyles } from 'antd-style';
export const useStyles = createStyles(({ css, token }) => ({
apiName: css`
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
font-size: 12px;
text-overflow: ellipsis;
`,
container: css`
cursor: pointer;
width: fit-content;
padding-block: 6px;
padding-inline: 8px;
padding-inline-end: 12px;
border: 1px solid ${token.colorBorder};
border-radius: 8px;
color: ${token.colorText};
&:hover {
background: ${token.colorFillTertiary};
}
`,
plugin: css`
display: flex;
gap: 4px;
align-items: center;
width: fit-content;
`,
}));
@@ -1,89 +0,0 @@
import { Icon, Tag } from '@lobehub/ui';
import { Typography } from 'antd';
import isEqual from 'fast-deep-equal';
import { Loader2 } from 'lucide-react';
import { CSSProperties, memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import PluginAvatar from '@/features/PluginAvatar';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { pluginHelpers, useToolStore } from '@/store/tool';
import { toolSelectors } from '@/store/tool/selectors';
import Arguments from '../../components/Arguments';
import ToolMessage from './Tool';
import { useStyles } from './style';
export interface InspectorProps {
apiName: string;
arguments?: string;
id: string;
identifier: string;
index: number;
messageId: string;
showPortal?: boolean;
style?: CSSProperties;
}
const CallItem = memo<InspectorProps>(
({ arguments: requestArgs, apiName, messageId, id, index, identifier, style, showPortal }) => {
const { t } = useTranslation('plugin');
const { styles } = useStyles();
const [open, setOpen] = useState(false);
const loading = useChatStore(chatSelectors.isToolCallStreaming(messageId, index));
const toolMessage = useChatStore(chatSelectors.getMessageByToolCallId(id));
const isMobile = useIsMobile();
const pluginMeta = useToolStore(toolSelectors.getMetaById(identifier), isEqual);
const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? t('unknownPlugin');
// when tool calling stop streaming, we should show the tool message
return !loading && toolMessage ? (
<ToolMessage {...toolMessage} showPortal={showPortal} />
) : (
<Flexbox gap={8} style={style}>
<Flexbox
align={'center'}
className={styles.container}
distribution={'space-between'}
gap={8}
horizontal
onClick={() => {
setOpen(!open);
}}
>
<Flexbox align={'center'} gap={8} horizontal>
{loading ? (
<div>
<Icon icon={Loader2} spin />
</div>
) : (
<PluginAvatar identifier={identifier} size={isMobile ? 36 : undefined} />
)}
{isMobile ? (
<Flexbox>
<div>{pluginTitle}</div>
<Typography.Text className={styles.apiName} type={'secondary'}>
{apiName}
</Typography.Text>
</Flexbox>
) : (
<>
<div>{pluginTitle}</div>
<Tag>{apiName}</Tag>
</>
)}
</Flexbox>
</Flexbox>
{loading && <Arguments arguments={requestArgs} />}
</Flexbox>
);
},
);
export default CallItem;
@@ -1,35 +0,0 @@
import { createStyles } from 'antd-style';
export const useStyles = createStyles(({ css, token }) => ({
apiName: css`
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
font-size: 12px;
text-overflow: ellipsis;
`,
container: css`
cursor: pointer;
width: fit-content;
padding-block: 6px;
padding-inline: 8px;
padding-inline-end: 12px;
border: 1px solid ${token.colorBorder};
border-radius: 8px;
color: ${token.colorText};
&:hover {
background: ${token.colorFillTertiary};
}
`,
plugin: css`
display: flex;
gap: 4px;
align-items: center;
width: fit-content;
`,
}));
@@ -1,18 +1,15 @@
import { Skeleton } from 'antd';
import { ReactNode, Suspense, memo, useContext } from 'react';
import { ReactNode, memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { LOADING_FLAT } from '@/const/message';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { aiChatSelectors } from '@/store/chat/slices/aiChat/selectors';
import { aiChatSelectors, chatSelectors } from '@/store/chat/selectors';
import { ChatMessage } from '@/types/message';
import { InPortalThreadContext } from '../../components/ChatItem/InPortalThreadContext';
import { DefaultMessage } from '../Default';
import FileChunks from './FileChunks';
import Thinking from './Reasoning';
import ToolCall from './ToolCallItem';
import Reasoning from './Reasoning';
import Tool from './Tool';
export const AssistantMessage = memo<
ChatMessage & {
@@ -22,7 +19,6 @@ export const AssistantMessage = memo<
const editing = useChatStore(chatSelectors.isMessageEditing(id));
const generating = useChatStore(chatSelectors.isMessageGenerating(id));
const inThread = useContext(InPortalThreadContext);
const isToolCallGenerating = generating && (content === LOADING_FLAT || !content) && !!tools;
const isReasoning = useChatStore(aiChatSelectors.isMessageInReasoning(id));
@@ -43,7 +39,7 @@ export const AssistantMessage = memo<
) : (
<Flexbox gap={8} id={id}>
{!!chunksList && chunksList.length > 0 && <FileChunks data={chunksList} />}
{showReasoning && <Thinking {...props.reasoning} id={id} />}
{showReasoning && <Reasoning {...props.reasoning} id={id} />}
{content && (
<DefaultMessage
addIdOnDOM={false}
@@ -54,24 +50,20 @@ export const AssistantMessage = memo<
/>
)}
{tools && (
<Suspense
fallback={<Skeleton.Button active style={{ height: 46, minWidth: 200, width: '100%' }} />}
>
<Flexbox gap={8}>
{tools.map((toolCall, index) => (
<ToolCall
apiName={toolCall.apiName}
arguments={toolCall.arguments}
id={toolCall.id}
identifier={toolCall.identifier}
index={index}
key={toolCall.id}
messageId={id}
showPortal={!inThread}
/>
))}
</Flexbox>
</Suspense>
<Flexbox gap={8}>
{tools.map((toolCall, index) => (
<Tool
apiName={toolCall.apiName}
arguments={toolCall.arguments}
id={toolCall.id}
identifier={toolCall.identifier}
index={index}
key={toolCall.id}
messageId={id}
payload={toolCall}
/>
))}
</Flexbox>
)}
</Flexbox>
);
@@ -1,22 +0,0 @@
import { Highlighter } from '@lobehub/ui';
import { memo } from 'react';
import { useYamlArguments } from '@/hooks/useYamlArguments';
export interface ArgumentsProps {
arguments?: string;
}
const Arguments = memo<ArgumentsProps>(({ arguments: args = '' }) => {
const yaml = useYamlArguments(args);
return (
!!yaml && (
<Highlighter language={'yaml'} showLanguage={false}>
{yaml}
</Highlighter>
)
);
});
export default Arguments;
+2 -2
View File
@@ -17,9 +17,9 @@ const PGliteIcon: IconType = forwardRef(({ size = '1em', style }, ref) => {
>
<title>PGlite</title>
<path
clip-rule="evenodd"
clipRule="evenodd"
d="M941.581 335.737v460.806c0 15.926-12.913 28.836-28.832 28.818l-115.283-.137c-15.243-.018-27.706-11.88-28.703-26.877.011-.569.018-1.138.018-1.711l-.004-172.904c0-47.745-38.736-86.451-86.454-86.451-46.245 0-84.052-36.359-86.342-82.068V191.496l201.708.149c79.484.058 143.892 64.553 143.892 144.092zm-576-144.281v201.818c0 47.746 38.682 86.456 86.4 86.456h86.4v-5.796c0 66.816 54.13 120.98 120.902 120.98 28.617 0 51.815 23.213 51.815 51.848v149.644c0 .688.011 1.372.025 2.057-.943 15.065-13.453 26.992-28.746 26.992l-144.982-.007.986-201.586c.079-15.915-12.755-28.88-28.66-28.959-15.904-.079-28.861 12.763-28.94 28.678l-.986 201.741v.118l-172.174-.01V623.722c0-15.915-12.895-28.819-28.8-28.819-15.906 0-28.8 12.904-28.8 28.819v201.704l-143.642-.007c-15.905-.004-28.798-12.904-28.798-28.819V335.547c0-79.58 64.471-144.093 144.001-144.092l143.999.001zm446.544 173.693c0-23.874-19.343-43.228-43.2-43.228-23.861 0-43.2 19.354-43.2 43.228 0 23.875 19.339 43.226 43.2 43.226 23.857 0 43.2-19.351 43.2-43.226z"
fill-rule="evenodd"
fillRule="evenodd"
/>
</svg>
);
+2 -11
View File
@@ -1,21 +1,12 @@
import { PluginRequestPayload } from '@lobehub/chat-plugin-sdk';
import { Skeleton } from 'antd';
import dynamic from 'next/dynamic';
import { memo } from 'react';
import { LobeToolRenderType } from '@/types/tool';
import BuiltinType from './BuiltinType';
import DefaultType from './DefaultType';
import Markdown from './MarkdownType';
const loading = () => (
<Skeleton.Node active style={{ width: '100%' }}>
{' '}
</Skeleton.Node>
);
const Standalone = dynamic(() => import('./StandaloneType'), { loading });
const BuiltinType = dynamic(() => import('./BuiltinType'), { loading });
import Standalone from './StandaloneType';
export interface PluginRenderProps {
arguments?: string;
+4
View File
@@ -119,6 +119,10 @@ export default {
reinstallError: '插件 {{name}} 刷新失败',
urlError: '该链接没有返回 JSON 格式的内容, 请确保是有效的链接',
},
inspector: {
args: '查看参数列表',
pluginRender: '查看插件界面',
},
list: {
item: {
'deprecated.title': '已删除',
+27
View File
@@ -1,4 +1,6 @@
import { css } from 'antd-style';
import type { FullToken } from 'antd-style/lib/types';
import { rgba } from 'polished';
export const dotLoading = css`
&::after {
@@ -26,3 +28,28 @@ export const dotLoading = css`
}
}
`;
export const shinyTextStylish = (token: FullToken) => css`
color: ${rgba(token.colorText, 0.45)};
background: linear-gradient(
120deg,
${rgba(token.colorTextBase, 0)} 40%,
${token.colorTextSecondary} 50%,
${rgba(token.colorTextBase, 0)} 60%
);
background-clip: text;
background-size: 200% 100%;
animation: shine 1.5s linear infinite;
@keyframes shine {
0% {
background-position: 100%;
}
100% {
background-position: -100%;
}
}
`;
+60
View File
@@ -0,0 +1,60 @@
import { useResponsive } from 'antd-style';
import { ReactNode, memo, useMemo } from 'react';
import { Flexbox } from 'react-layout-kit';
import Grid from '@/components/GalleyGrid/Grid';
const MAX_SIZE_DESKTOP = 640;
const MAX_SIZE_MOBILE = 280;
interface GalleyGridProps<T = any> {
items: T[];
renderItem: (props: T) => ReactNode;
}
const GalleyGrid = memo<GalleyGridProps>(({ items, renderItem: Render }) => {
const { mobile } = useResponsive();
const { firstRow, lastRow } = useMemo(() => {
if (items.length === 4) {
return {
firstRow: items.slice(0, 2),
lastRow: items.slice(2, 4),
};
}
const firstCol = items.length % 3 === 0 ? 3 : items.length % 3;
return {
firstRow: items.slice(0, firstCol),
lastRow: items.slice(firstCol, items.length),
};
}, [items]);
const { gap, max } = useMemo(
() => ({
gap: mobile ? 4 : 6,
max: (mobile ? MAX_SIZE_MOBILE : MAX_SIZE_DESKTOP) * firstRow.length,
}),
[mobile],
);
return (
<Flexbox gap={gap}>
<Grid col={firstRow.length} gap={gap} max={max}>
{firstRow.map((i, index) => (
<Render {...i} index={index} key={index} />
))}
</Grid>
{lastRow.length > 0 && (
<Grid col={lastRow.length > 2 ? 3 : lastRow.length} gap={gap} max={max}>
{lastRow.map((i, index) => (
<Render {...i} index={index} key={index} />
))}
</Grid>
)}
</Flexbox>
);
});
export default GalleyGrid;
+1 -1
View File
@@ -3,11 +3,11 @@ import { Download } from 'lucide-react';
import { memo, useRef } from 'react';
import { Flexbox } from 'react-layout-kit';
import GalleyGrid from '@/components/GalleyGrid';
import { fileService } from '@/services/file';
import { BuiltinRenderProps } from '@/types/tool';
import { DallEImageItem } from '@/types/tool/dalle';
import GalleyGrid from './GalleyGrid';
import ImageItem from './Item';
const DallE = memo<BuiltinRenderProps<DallEImageItem[]>>(({ content, messageId }) => {