@lobehub/chat 1.84.1 → 1.84.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.env.desktop +1 -0
  2. package/CHANGELOG.md +52 -0
  3. package/apps/desktop/electron-builder.js +8 -4
  4. package/apps/desktop/package.json +1 -0
  5. package/apps/desktop/src/main/index.ts +3 -0
  6. package/changelog/v1.json +14 -0
  7. package/locales/ar/models.json +6 -6
  8. package/locales/ar/plugin.json +10 -1
  9. package/locales/bg-BG/models.json +6 -6
  10. package/locales/bg-BG/plugin.json +10 -1
  11. package/locales/de-DE/models.json +6 -6
  12. package/locales/de-DE/plugin.json +10 -1
  13. package/locales/en-US/models.json +6 -6
  14. package/locales/en-US/plugin.json +10 -1
  15. package/locales/es-ES/models.json +6 -6
  16. package/locales/es-ES/plugin.json +10 -1
  17. package/locales/fa-IR/models.json +6 -6
  18. package/locales/fa-IR/plugin.json +10 -1
  19. package/locales/fr-FR/models.json +6 -6
  20. package/locales/fr-FR/plugin.json +10 -1
  21. package/locales/it-IT/models.json +6 -6
  22. package/locales/it-IT/plugin.json +10 -1
  23. package/locales/ja-JP/models.json +6 -6
  24. package/locales/ja-JP/plugin.json +10 -1
  25. package/locales/ko-KR/models.json +6 -6
  26. package/locales/ko-KR/plugin.json +10 -1
  27. package/locales/nl-NL/models.json +6 -6
  28. package/locales/nl-NL/plugin.json +10 -1
  29. package/locales/pl-PL/models.json +6 -6
  30. package/locales/pl-PL/plugin.json +10 -1
  31. package/locales/pt-BR/models.json +6 -6
  32. package/locales/pt-BR/plugin.json +10 -1
  33. package/locales/ru-RU/models.json +6 -6
  34. package/locales/ru-RU/plugin.json +10 -1
  35. package/locales/tr-TR/models.json +6 -6
  36. package/locales/tr-TR/plugin.json +10 -1
  37. package/locales/vi-VN/models.json +6 -6
  38. package/locales/vi-VN/plugin.json +10 -1
  39. package/locales/zh-CN/models.json +6 -6
  40. package/locales/zh-CN/plugin.json +10 -1
  41. package/locales/zh-TW/models.json +6 -6
  42. package/locales/zh-TW/plugin.json +10 -1
  43. package/package.json +2 -2
  44. package/src/app/[variants]/(main)/_layout/Desktop/SideBar/index.tsx +15 -5
  45. package/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/Mobile/InputArea/index.tsx +3 -2
  46. package/src/app/[variants]/(main)/chat/_layout/Desktop/RegisterHotkeys.tsx +6 -3
  47. package/src/app/[variants]/(main)/chat/settings/page.tsx +4 -33
  48. package/src/app/[variants]/(main)/settings/_layout/Desktop/Header.tsx +7 -2
  49. package/src/app/[variants]/(main)/settings/_layout/Mobile/Header.tsx +9 -1
  50. package/src/app/[variants]/(main)/settings/agent/_layout/Desktop.tsx +1 -1
  51. package/src/app/[variants]/(main)/settings/agent/_layout/Mobile.tsx +1 -8
  52. package/src/app/[variants]/(main)/settings/agent/index.tsx +34 -14
  53. package/src/app/[variants]/(main)/settings/common/features/Common.tsx +1 -0
  54. package/src/app/[variants]/(main)/settings/provider/features/ModelList/index.tsx +11 -1
  55. package/src/app/[variants]/(main)/settings/storage/Advanced.tsx +3 -0
  56. package/src/features/ChatInput/ActionBar/Params/ParamsControls.tsx +17 -7
  57. package/src/features/ModelSwitchPanel/index.tsx +6 -0
  58. package/src/features/PluginDevModal/MCPManifestForm/EnvEditor.tsx +227 -0
  59. package/src/features/PluginDevModal/MCPManifestForm/index.tsx +14 -0
  60. package/src/hooks/useHotkeys/chatScope.ts +0 -2
  61. package/src/hooks/useHotkeys/filesScope.ts +0 -2
  62. package/src/libs/agent-runtime/openai/index.ts +11 -0
  63. package/src/libs/mcp/client.ts +9 -1
  64. package/src/libs/mcp/types.ts +1 -0
  65. package/src/locales/default/plugin.ts +10 -1
  66. package/src/server/globalConfig/index.ts +3 -0
@@ -7,22 +7,20 @@ import { memo, useState } from 'react';
7
7
  import { useTranslation } from 'react-i18next';
8
8
 
9
9
  import PageTitle from '@/components/PageTitle';
10
- import { INBOX_SESSION_ID } from '@/const/session';
10
+ import { useCategory } from '@/features/AgentSetting/AgentCategory/useCategory';
11
+ import AgentSettings from '@/features/AgentSetting/AgentSettings';
11
12
  import { useInitAgentConfig } from '@/hooks/useInitAgentConfig';
12
13
  import { useAgentStore } from '@/store/agent';
13
14
  import { agentSelectors } from '@/store/agent/selectors';
14
15
  import { ChatSettingsTabs } from '@/store/global/initialState';
15
- import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
16
16
  import { useSessionStore } from '@/store/session';
17
17
  import { sessionMetaSelectors } from '@/store/session/selectors';
18
18
 
19
- import AgentSettings from '../../../../../features/AgentSetting/AgentSettings';
20
-
21
19
  const EditPage = memo(() => {
22
20
  const { t } = useTranslation('setting');
23
21
  const [tab, setTab] = useState(ChatSettingsTabs.Prompt);
24
22
  const theme = useTheme();
25
-
23
+ const cateItems = useCategory();
26
24
  const [id, updateAgentMeta, title] = useSessionStore((s) => [
27
25
  s.activeId,
28
26
  s.updateSessionMeta,
@@ -36,39 +34,12 @@ const EditPage = memo(() => {
36
34
 
37
35
  const { isLoading } = useInitAgentConfig();
38
36
 
39
- const { enablePlugins } = useServerConfigStore(featureFlagsSelectors);
40
-
41
37
  return (
42
38
  <>
43
39
  <PageTitle title={t('header.sessionWithName', { name: title })} />
44
40
  <Tabs
45
41
  compact
46
- items={[
47
- {
48
- key: ChatSettingsTabs.Prompt,
49
- label: t('settingAgent.prompt.title'),
50
- },
51
- (id !== INBOX_SESSION_ID && {
52
- key: ChatSettingsTabs.Meta,
53
- label: t('settingAgent.title'),
54
- }) as any,
55
- {
56
- key: ChatSettingsTabs.Chat,
57
- label: t('settingChat.title'),
58
- },
59
- {
60
- key: ChatSettingsTabs.Modal,
61
- label: t('settingModel.title'),
62
- },
63
- {
64
- key: ChatSettingsTabs.TTS,
65
- label: t('settingTTS.title'),
66
- },
67
- (enablePlugins && {
68
- key: ChatSettingsTabs.Plugin,
69
- label: t('settingPlugin.title'),
70
- }) as any,
71
- ]}
42
+ items={cateItems as any}
72
43
  onChange={(value) => setTab(value as ChatSettingsTabs)}
73
44
  style={{
74
45
  borderBottom: `1px solid ${theme.colorBorderSecondary}`,
@@ -15,7 +15,7 @@ const useStyles = createStyles(({ token, css }) => ({
15
15
  position: relative;
16
16
  flex: none;
17
17
  height: 54px;
18
- background: ${token.colorBgContainer};
18
+ background: ${token.colorBgLayout};
19
19
  `,
20
20
  title: css`
21
21
  font-size: 18px;
@@ -52,6 +52,11 @@ const Header = memo<HeaderProps>(({ children, getContainer, title }) => {
52
52
  }
53
53
  />
54
54
  }
55
+ styles={{
56
+ left: {
57
+ padding: 0,
58
+ },
59
+ }}
55
60
  />
56
61
  <Drawer
57
62
  getContainer={getContainer}
@@ -61,7 +66,7 @@ const Header = memo<HeaderProps>(({ children, getContainer, title }) => {
61
66
  placement={'left'}
62
67
  rootStyle={{ position: 'absolute' }}
63
68
  style={{
64
- background: theme.colorBgContainer,
69
+ background: theme.colorBgLayout,
65
70
  borderRight: `1px solid ${theme.colorSplit}`,
66
71
  }}
67
72
  styles={{
@@ -2,12 +2,14 @@
2
2
 
3
3
  import { Tag } from '@lobehub/ui';
4
4
  import { ChatHeader } from '@lobehub/ui/mobile';
5
+ import { usePathname } from 'next/navigation';
5
6
  import { memo } from 'react';
6
7
  import { useTranslation } from 'react-i18next';
7
8
  import { Flexbox } from 'react-layout-kit';
8
9
 
9
10
  import { enableAuth } from '@/const/auth';
10
11
  import { useActiveSettingsKey } from '@/hooks/useActiveTabKey';
12
+ import { useProviderName } from '@/hooks/useProviderName';
11
13
  import { useQueryRoute } from '@/hooks/useQueryRoute';
12
14
  import { useShowMobileWorkspace } from '@/hooks/useShowMobileWorkspace';
13
15
  import { SettingsTabs } from '@/store/global/initialState';
@@ -21,6 +23,9 @@ const Header = memo(() => {
21
23
  const showMobileWorkspace = useShowMobileWorkspace();
22
24
  const activeSettingsKey = useActiveSettingsKey();
23
25
  const isSessionActive = useSessionStore((s) => !!s.activeId);
26
+ const pathname = usePathname();
27
+ const isProvider = pathname.includes('/settings/provider/');
28
+ const providerName = useProviderName(activeSettingsKey);
24
29
 
25
30
  const handleBackClick = () => {
26
31
  if (isSessionActive && showMobileWorkspace) {
@@ -29,13 +34,16 @@ const Header = memo(() => {
29
34
  router.push(enableAuth ? '/me/settings' : '/me');
30
35
  }
31
36
  };
37
+
32
38
  return (
33
39
  <ChatHeader
34
40
  center={
35
41
  <ChatHeader.Title
36
42
  title={
37
43
  <Flexbox align={'center'} gap={8} horizontal>
38
- <span style={{ lineHeight: 1.2 }}> {t(`tab.${activeSettingsKey}`)}</span>
44
+ <span style={{ lineHeight: 1.2 }}>
45
+ {isProvider ? providerName : t(`tab.${activeSettingsKey}`)}
46
+ </span>
39
47
  {activeSettingsKey === SettingsTabs.Sync && (
40
48
  <Tag bordered={false} color={'warning'}>
41
49
  {t('tab.experiment')}
@@ -10,7 +10,7 @@ const Layout = ({ children }: PropsWithChildren) => {
10
10
  return (
11
11
  <>
12
12
  <NProgress />
13
- <Flexbox horizontal width={'100%'}>
13
+ <Flexbox height={'100%'} horizontal width={'100%'}>
14
14
  <AgentMenu />
15
15
  <SettingContainer>{children}</SettingContainer>
16
16
  </Flexbox>
@@ -1,14 +1,7 @@
1
- 'use client';
2
-
3
- import { usePathname } from 'next/navigation';
4
1
  import { PropsWithChildren } from 'react';
5
2
 
6
- import ProviderMenu from '../AgentMenu';
7
-
8
3
  const Layout = ({ children }: PropsWithChildren) => {
9
- const pathname = usePathname();
10
-
11
- return pathname === '/settings/agent' ? <ProviderMenu mobile /> : children;
4
+ return children;
12
5
  };
13
6
 
14
7
  export default Layout;
@@ -1,38 +1,58 @@
1
1
  'use client';
2
2
 
3
+ import { Tabs } from '@lobehub/ui';
4
+ import { useTheme } from 'antd-style';
3
5
  import isEqual from 'fast-deep-equal';
4
6
  import { useQueryState } from 'nuqs';
5
7
  import { memo } from 'react';
6
8
 
7
9
  import { INBOX_SESSION_ID } from '@/const/session';
8
10
  import { AgentSettings } from '@/features/AgentSetting';
11
+ import { useCategory } from '@/features/AgentSetting/AgentCategory/useCategory';
9
12
  import { ChatSettingsTabs } from '@/store/global/initialState';
13
+ import { useServerConfigStore } from '@/store/serverConfig';
10
14
  import { useUserStore } from '@/store/user';
11
15
  import { settingsSelectors } from '@/store/user/selectors';
12
16
 
13
17
  const Page = memo(() => {
14
- const [tab] = useQueryState('tab', {
18
+ const cateItems = useCategory();
19
+ const [tab, setTab] = useQueryState('tab', {
15
20
  defaultValue: ChatSettingsTabs.Prompt,
16
21
  });
17
22
  const config = useUserStore(settingsSelectors.defaultAgentConfig, isEqual);
18
23
  const meta = useUserStore(settingsSelectors.defaultAgentMeta, isEqual);
19
24
  const [updateAgent] = useUserStore((s) => [s.updateDefaultAgent]);
20
25
  const isUserStateInit = useUserStore((s) => s.isUserStateInit);
26
+ const theme = useTheme();
27
+ const mobile = useServerConfigStore((s) => s.isMobile);
21
28
 
22
29
  return (
23
- <AgentSettings
24
- config={config}
25
- id={INBOX_SESSION_ID}
26
- loading={!isUserStateInit}
27
- meta={meta}
28
- onConfigChange={(config) => {
29
- updateAgent({ config });
30
- }}
31
- onMetaChange={(meta) => {
32
- updateAgent({ meta });
33
- }}
34
- tab={tab as ChatSettingsTabs}
35
- />
30
+ <>
31
+ {mobile && (
32
+ <Tabs
33
+ activeKey={tab}
34
+ compact
35
+ items={cateItems as any}
36
+ onChange={(value) => setTab(value as ChatSettingsTabs)}
37
+ style={{
38
+ borderBottom: `1px solid ${theme.colorBorderSecondary}`,
39
+ }}
40
+ />
41
+ )}
42
+ <AgentSettings
43
+ config={config}
44
+ id={INBOX_SESSION_ID}
45
+ loading={!isUserStateInit}
46
+ meta={meta}
47
+ onConfigChange={(config) => {
48
+ updateAgent({ config });
49
+ }}
50
+ onMetaChange={(meta) => {
51
+ updateAgent({ meta });
52
+ }}
53
+ tab={tab as ChatSettingsTabs}
54
+ />
55
+ </>
36
56
  );
37
57
  });
38
58
 
@@ -60,6 +60,7 @@ const Common = memo(() => {
60
60
  ),
61
61
  desc: t('danger.reset.desc'),
62
62
  label: t('danger.reset.title'),
63
+ layout: 'horizontal',
63
64
  minWidth: undefined,
64
65
  },
65
66
  ],
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import { useTheme } from 'antd-style';
3
4
  import { Suspense, memo } from 'react';
4
5
  import { Flexbox } from 'react-layout-kit';
5
6
 
@@ -48,12 +49,21 @@ interface ModelListProps extends ProviderSettingsContextValue {
48
49
  const ModelList = memo<ModelListProps>(
49
50
  ({ id, showModelFetcher, sdkType, showAddNewModel, showDeployName, modelEditable = true }) => {
50
51
  const mobile = useIsMobile();
52
+ const theme = useTheme();
51
53
 
52
54
  return (
53
55
  <ProviderSettingsContext
54
56
  value={{ modelEditable, sdkType, showAddNewModel, showDeployName, showModelFetcher }}
55
57
  >
56
- <Flexbox gap={16} paddingInline={mobile ? 12 : 0}>
58
+ <Flexbox
59
+ gap={16}
60
+ paddingInline={mobile ? 12 : 0}
61
+ style={{
62
+ background: mobile ? theme.colorBgContainer : undefined,
63
+ paddingBottom: 16,
64
+ paddingTop: 8,
65
+ }}
66
+ >
57
67
  <ModelTitle
58
68
  provider={id}
59
69
  showAddNewModel={showAddNewModel}
@@ -64,6 +64,7 @@ const AdvancedActions = () => {
64
64
  </DataImporter>
65
65
  ),
66
66
  label: t('storage.actions.import.title'),
67
+ layout: 'horizontal',
67
68
  minWidth: undefined,
68
69
  },
69
70
  {
@@ -78,6 +79,7 @@ const AdvancedActions = () => {
78
79
  </Button>
79
80
  ),
80
81
  label: t('storage.actions.export.title'),
82
+ layout: 'horizontal',
81
83
  minWidth: undefined,
82
84
  },
83
85
  {
@@ -88,6 +90,7 @@ const AdvancedActions = () => {
88
90
  ),
89
91
  desc: t('danger.clear.desc'),
90
92
  label: t('danger.clear.title'),
93
+ layout: 'horizontal',
91
94
  minWidth: undefined,
92
95
  },
93
96
  ],
@@ -14,21 +14,21 @@ import {
14
14
  } from '@/features/ModelParamsControl';
15
15
  import { useAgentStore } from '@/store/agent';
16
16
  import { agentSelectors } from '@/store/agent/selectors';
17
+ import { useServerConfigStore } from '@/store/serverConfig';
17
18
 
18
19
  interface ParamsControlsProps {
19
20
  setUpdating: (updating: boolean) => void;
20
21
  }
21
22
  const ParamsControls = memo<ParamsControlsProps>(({ setUpdating }) => {
22
23
  const { t } = useTranslation('setting');
23
-
24
+ const mobile = useServerConfigStore((s) => s.isMobile);
24
25
  const updateAgentConfig = useAgentStore((s) => s.updateAgentConfig);
25
26
 
26
27
  const config = useAgentStore(agentSelectors.currentAgentConfig, isEqual);
27
28
 
28
- const items: FormItemProps[] = [
29
+ let items: FormItemProps[] = [
29
30
  {
30
31
  children: <Temperature />,
31
- desc: <Tag>temperature</Tag>,
32
32
  label: (
33
33
  <Flexbox align={'center'} gap={8} horizontal justify={'space-between'}>
34
34
  {t('settingModel.temperature.title')}
@@ -36,10 +36,10 @@ const ParamsControls = memo<ParamsControlsProps>(({ setUpdating }) => {
36
36
  </Flexbox>
37
37
  ),
38
38
  name: ['params', 'temperature'],
39
+ tag: 'temperature',
39
40
  },
40
41
  {
41
42
  children: <TopP />,
42
- desc: <Tag>top_p</Tag>,
43
43
  label: (
44
44
  <Flexbox gap={8} horizontal>
45
45
  {t('settingModel.topP.title')}
@@ -47,10 +47,10 @@ const ParamsControls = memo<ParamsControlsProps>(({ setUpdating }) => {
47
47
  </Flexbox>
48
48
  ),
49
49
  name: ['params', 'top_p'],
50
+ tag: 'top_p',
50
51
  },
51
52
  {
52
53
  children: <PresencePenalty />,
53
- desc: <Tag>presence_penalty</Tag>,
54
54
  label: (
55
55
  <Flexbox gap={8} horizontal>
56
56
  {t('settingModel.presencePenalty.title')}
@@ -58,10 +58,10 @@ const ParamsControls = memo<ParamsControlsProps>(({ setUpdating }) => {
58
58
  </Flexbox>
59
59
  ),
60
60
  name: ['params', 'presence_penalty'],
61
+ tag: 'presence_penalty',
61
62
  },
62
63
  {
63
64
  children: <FrequencyPenalty />,
64
- desc: <Tag>frequency_penalty</Tag>,
65
65
  label: (
66
66
  <Flexbox gap={8} horizontal>
67
67
  {t('settingModel.frequencyPenalty.title')}
@@ -69,6 +69,7 @@ const ParamsControls = memo<ParamsControlsProps>(({ setUpdating }) => {
69
69
  </Flexbox>
70
70
  ),
71
71
  name: ['params', 'frequency_penalty'],
72
+ tag: 'frequency_penalty',
72
73
  },
73
74
  ];
74
75
 
@@ -76,7 +77,9 @@ const ParamsControls = memo<ParamsControlsProps>(({ setUpdating }) => {
76
77
  <Form
77
78
  initialValues={config}
78
79
  itemMinWidth={200}
79
- items={items}
80
+ items={
81
+ mobile ? items : items.map(({ tag, ...item }) => ({ ...item, desc: <Tag>{tag}</Tag> }))
82
+ }
80
83
  itemsType={'flat'}
81
84
  onValuesChange={debounce(async (values) => {
82
85
  setUpdating(true);
@@ -84,6 +87,13 @@ const ParamsControls = memo<ParamsControlsProps>(({ setUpdating }) => {
84
87
  setUpdating(false);
85
88
  }, 500)}
86
89
  style={{ fontSize: 12 }}
90
+ styles={{
91
+ group: {
92
+ background: 'transparent',
93
+ paddingBottom: mobile ? 16 : 0,
94
+ paddingInline: 0,
95
+ },
96
+ }}
87
97
  variant={'borderless'}
88
98
  />
89
99
  );
@@ -135,6 +135,12 @@ const ModelSwitchPanel = memo<PropsWithChildren>(({ children }) => {
135
135
  activeKey: menuKey(provider, model),
136
136
  className: styles.menu,
137
137
  items,
138
+ // 不加限高就会导致面板超长,顶部的内容会被隐藏
139
+ // https://github.com/user-attachments/assets/9c043c47-42c5-46ef-b5c1-bee89376f042
140
+ style: {
141
+ maxHeight: 500,
142
+ overflowY: 'scroll',
143
+ },
138
144
  }}
139
145
  placement={'topLeft'}
140
146
  >
@@ -0,0 +1,227 @@
1
+ import { ActionIcon, Icon } from '@lobehub/ui';
2
+ import { Button } from 'antd';
3
+ import { createStyles } from 'antd-style';
4
+ import fastDeepEqual from 'fast-deep-equal';
5
+ import { LucidePlus, LucideTrash } from 'lucide-react';
6
+ import { memo, useEffect, useRef, useState } from 'react';
7
+ import { useTranslation } from 'react-i18next';
8
+ import { Flexbox } from 'react-layout-kit';
9
+ import { v4 as uuidv4 } from 'uuid';
10
+
11
+ import { FormInput } from '@/components/FormInput';
12
+
13
+ const useStyles = createStyles(({ css, token }) => ({
14
+ container: css`
15
+ position: relative;
16
+
17
+ width: 100%;
18
+ padding: 12px;
19
+ border: 1px solid ${token.colorBorderSecondary};
20
+ border-radius: ${token.borderRadiusLG}px;
21
+ `,
22
+ input: css`
23
+ font-family: ${token.fontFamilyCode};
24
+ font-size: 12px;
25
+ `,
26
+ row: css`
27
+ margin-block-end: 8px;
28
+
29
+ &:last-child {
30
+ margin-block-end: 0;
31
+ }
32
+ `,
33
+ title: css`
34
+ margin-block-end: 8px;
35
+ color: ${token.colorTextTertiary};
36
+ `,
37
+ }));
38
+
39
+ interface KeyValueItem {
40
+ id: string;
41
+ key: string;
42
+ value: string;
43
+ }
44
+
45
+ export interface EnvEditorProps {
46
+ onChange?: (value: Record<string, string>) => void;
47
+ value?: Record<string, string>;
48
+ }
49
+
50
+ const recordToLocalList = (
51
+ record: Record<string, string> | undefined | null = {},
52
+ ): KeyValueItem[] =>
53
+ Object.entries(record || {}).map(([key, val]) => ({
54
+ id: uuidv4(),
55
+ key,
56
+ value: typeof val === 'string' ? val : '',
57
+ }));
58
+
59
+ const localListToRecord = (
60
+ list: KeyValueItem[] | undefined | null = [],
61
+ ): Record<string, string> => {
62
+ const record: Record<string, string> = {};
63
+ const keys = new Set<string>();
64
+ (list || [])
65
+ .slice()
66
+ .reverse()
67
+ .forEach((item) => {
68
+ const trimmedKey = item.key.trim();
69
+ if (trimmedKey && !keys.has(trimmedKey)) {
70
+ record[trimmedKey] = typeof item.value === 'string' ? item.value : '';
71
+ keys.add(trimmedKey);
72
+ }
73
+ });
74
+ return Object.keys(record)
75
+ .reverse()
76
+ .reduce(
77
+ (acc, key) => {
78
+ acc[key] = record[key];
79
+ return acc;
80
+ },
81
+ {} as Record<string, string>,
82
+ );
83
+ };
84
+
85
+ const EnvEditor = memo<EnvEditorProps>(({ value, onChange }) => {
86
+ const { styles } = useStyles();
87
+ const { t } = useTranslation(['plugin', 'common']);
88
+ const [items, setItems] = useState<KeyValueItem[]>(() => recordToLocalList(value));
89
+ const prevValueRef = useRef<Record<string, string> | undefined>(undefined);
90
+
91
+ useEffect(() => {
92
+ const externalRecord = value || {};
93
+ if (!fastDeepEqual(externalRecord, prevValueRef.current)) {
94
+ setItems(recordToLocalList(externalRecord));
95
+ prevValueRef.current = externalRecord;
96
+ }
97
+ }, [value]);
98
+
99
+ const triggerChange = (newItems: KeyValueItem[]) => {
100
+ const keysCount: Record<string, number> = {};
101
+ newItems.forEach((item) => {
102
+ const trimmedKey = item.key.trim();
103
+ if (trimmedKey) {
104
+ keysCount[trimmedKey] = (keysCount[trimmedKey] || 0) + 1;
105
+ }
106
+ });
107
+ setItems(
108
+ newItems.map((item) => ({
109
+ ...item,
110
+ })),
111
+ );
112
+ onChange?.(localListToRecord(newItems));
113
+ };
114
+
115
+ const handleAdd = () => {
116
+ const newItems = [...items, { id: uuidv4(), key: '', value: '' }];
117
+ triggerChange(newItems);
118
+ };
119
+
120
+ const handleRemove = (id: string) => {
121
+ const newItems = items.filter((item) => item.id !== id);
122
+ triggerChange(newItems);
123
+ };
124
+
125
+ const handleKeyChange = (id: string, newKey: string) => {
126
+ const newItems = items.map((item) => (item.id === id ? { ...item, key: newKey } : item));
127
+ triggerChange(newItems);
128
+ };
129
+
130
+ const handleValueChange = (id: string, newValue: string) => {
131
+ const newItems = items.map((item) => (item.id === id ? { ...item, value: newValue } : item));
132
+ triggerChange(newItems);
133
+ };
134
+
135
+ const getDuplicateKeys = (currentItems: KeyValueItem[]): Set<string> => {
136
+ const keys = new Set<string>();
137
+ const duplicates = new Set<string>();
138
+ currentItems.forEach((item) => {
139
+ const trimmedKey = item.key.trim();
140
+ if (trimmedKey) {
141
+ if (keys.has(trimmedKey)) {
142
+ duplicates.add(trimmedKey);
143
+ } else {
144
+ keys.add(trimmedKey);
145
+ }
146
+ }
147
+ });
148
+ return duplicates;
149
+ };
150
+ const duplicateKeys = getDuplicateKeys(items);
151
+
152
+ return (
153
+ <div className={styles.container}>
154
+ <Flexbox className={styles.title} gap={8} horizontal>
155
+ <Flexbox flex={1}>key</Flexbox>
156
+ <Flexbox flex={2}>value</Flexbox>
157
+ <Flexbox style={{ width: 30 }} />
158
+ </Flexbox>
159
+ <Flexbox width={'100%'}>
160
+ {items.map((item) => {
161
+ const isDuplicate = item.key.trim() && duplicateKeys.has(item.key.trim());
162
+ return (
163
+ <Flexbox
164
+ align="flex-start"
165
+ className={styles.row}
166
+ gap={8}
167
+ horizontal
168
+ key={item.id}
169
+ width={'100%'}
170
+ >
171
+ <Flexbox flex={1} style={{ position: 'relative' }}>
172
+ <FormInput
173
+ className={styles.input}
174
+ onChange={(e) => handleKeyChange(item.id, e)}
175
+ placeholder={'key'}
176
+ status={isDuplicate ? 'error' : undefined}
177
+ value={item.key}
178
+ variant={'filled'}
179
+ />
180
+ {isDuplicate && (
181
+ <div
182
+ style={{
183
+ bottom: '-16px',
184
+ color: 'red',
185
+ fontSize: '12px',
186
+ position: 'absolute',
187
+ }}
188
+ >
189
+ {t('dev.mcp.env.duplicateKeyError')}
190
+ </div>
191
+ )}
192
+ </Flexbox>
193
+ <Flexbox flex={2}>
194
+ <FormInput
195
+ className={styles.input}
196
+ onChange={(value) => handleValueChange(item.id, value)}
197
+ placeholder={'value'}
198
+ value={item.value}
199
+ variant={'filled'}
200
+ />
201
+ </Flexbox>
202
+ <ActionIcon
203
+ icon={LucideTrash}
204
+ onClick={() => handleRemove(item.id)}
205
+ size={'small'}
206
+ style={{ marginTop: 4 }}
207
+ title={t('delete', { ns: 'common' })}
208
+ />
209
+ </Flexbox>
210
+ );
211
+ })}
212
+ <Button
213
+ block
214
+ icon={<Icon icon={LucidePlus} />}
215
+ onClick={handleAdd}
216
+ size={'small'}
217
+ style={{ marginTop: items.length > 0 ? 16 : 8 }}
218
+ type="dashed"
219
+ >
220
+ {t('dev.mcp.env.add')}
221
+ </Button>
222
+ </Flexbox>
223
+ </div>
224
+ );
225
+ });
226
+
227
+ export default EnvEditor;