@nextclaw/ui 0.12.0 → 0.12.2

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 (96) hide show
  1. package/CHANGELOG.md +57 -2
  2. package/dist/assets/ChannelsList-uKmkpD25.js +8 -0
  3. package/dist/assets/ChatPage-CslhBPfT.js +43 -0
  4. package/dist/assets/{DocBrowser-DxdSujSc.js → DocBrowser-C7-1sXqo.js} +1 -1
  5. package/dist/assets/DocBrowser-DQjtSsY3.js +1 -0
  6. package/dist/assets/{DocBrowserContext-CQ-8jMha.js → DocBrowserContext-DN5tjUoS.js} +1 -1
  7. package/dist/assets/{LogoBadge-D-KQIN4U.js → LogoBadge-DDS1sU_U.js} +1 -1
  8. package/dist/assets/MarketplacePage-BZQW70ti.js +1 -0
  9. package/dist/assets/{MarketplacePage-CRNvxtvx.js → MarketplacePage-DE0QjYVv.js} +1 -1
  10. package/dist/assets/{McpMarketplacePage-Cu7GmCcc.js → McpMarketplacePage-CeLvv1xy.js} +1 -1
  11. package/dist/assets/ModelConfig-D1JtGtQv.js +1 -0
  12. package/dist/assets/ProviderScopedModelInput-SAJH6nkC.js +1 -0
  13. package/dist/assets/{ProvidersList-BWbUb7-2.js → ProvidersList-1rKi3aQT.js} +1 -1
  14. package/dist/assets/{RemoteAccessPage-NsawrZb0.js → RemoteAccessPage-bIAKxDky.js} +1 -1
  15. package/dist/assets/RuntimeConfig-BTk9319O.js +1 -0
  16. package/dist/assets/SearchConfig-EjeszXbv.js +1 -0
  17. package/dist/assets/{SecretsConfig-CgDZOd3w.js → SecretsConfig-cnAXvREZ.js} +1 -1
  18. package/dist/assets/{SessionsConfig-Dd-KM7F7.js → SessionsConfig-BIXiDaK2.js} +1 -1
  19. package/dist/assets/{book-open-FnK2xCQd.js → book-open-DvWqOode.js} +1 -1
  20. package/dist/assets/chat-session-display-D4bYa0b8.js +1 -0
  21. package/dist/assets/{chunk-JZWAC4HX-B5l0hr_u.js → chunk-JZWAC4HX-CxfKRD7X.js} +1 -1
  22. package/dist/assets/{config-JKmXfZ3q.js → config-BeGwf2Ao.js} +1 -1
  23. package/dist/assets/{createLucideIcon-o1WWhwhd.js → createLucideIcon-C7MmdIX3.js} +1 -1
  24. package/dist/assets/{dist-C_moWYv7.js → dist-B6VMuIQN.js} +1 -1
  25. package/dist/assets/{dist-DazA6Wd_.js → dist-RWNFhxvR.js} +1 -1
  26. package/dist/assets/{external-link-BKje3SiD.js → external-link-U86Acd1t.js} +1 -1
  27. package/dist/assets/{hash-DfW4DT8O.js → hash-D-OVfV3Z.js} +1 -1
  28. package/dist/assets/i18n-hM3v-3YG.js +1 -0
  29. package/dist/assets/{index-BZaB1TqM.js → index-8XNPYwJu.js} +3 -3
  30. package/dist/assets/index-CpxuJa9o.css +1 -0
  31. package/dist/assets/{label-BzDWmdOe.js → label-CHJ1ATds.js} +1 -1
  32. package/dist/assets/loader-circle-C8cpaL0w.js +1 -0
  33. package/dist/assets/{logos-CTLlde_T.js → logos-U1_qDA3U.js} +1 -1
  34. package/dist/assets/{page-layout-BagR3t59.js → page-layout-Z1klaUFW.js} +1 -1
  35. package/dist/assets/plus-CrkO1kob.js +1 -0
  36. package/dist/assets/{popover-5DWhNfd4.js → popover-xWbqMnIN.js} +1 -1
  37. package/dist/assets/{react-C3yu5yge.js → react-3YE87-lE.js} +1 -1
  38. package/dist/assets/{refresh-ccw-BAJf-h7w.js → refresh-ccw-JQh1lwq-.js} +1 -1
  39. package/dist/assets/{save-aa6z4GJL.js → save-4VRlzkii.js} +1 -1
  40. package/dist/assets/search-EX-Papzl.js +1 -0
  41. package/dist/assets/{security-config-DRDxrApx.js → security-config-CGazBahs.js} +1 -1
  42. package/dist/assets/{select-BHJPiJWt.js → select-DF-AUoie.js} +1 -1
  43. package/dist/assets/skeleton-B0mmt1vo.js +1 -0
  44. package/dist/assets/{status-dot-DUwsTIdv.js → status-dot-Bq_8Ojvv.js} +1 -1
  45. package/dist/assets/{switch-B6nCfcOB.js → switch-D7JF_RZ-.js} +1 -1
  46. package/dist/assets/{tabs-custom-B57SMElx.js → tabs-custom-CLksZ2bO.js} +1 -1
  47. package/dist/assets/{trash-2-CrjYH5ok.js → trash-2-VV8jvziy.js} +1 -1
  48. package/dist/assets/{useConfirmDialog-DsxnXB1B.js → useConfirmDialog-D6HxybcM.js} +1 -1
  49. package/dist/assets/{useMutation-oTTWXgLG.js → useMutation-DBTWPbTg.js} +1 -1
  50. package/dist/assets/x-B4sxJkGY.js +1 -0
  51. package/dist/index.html +18 -18
  52. package/package.json +6 -6
  53. package/src/api/agents.ts +9 -1
  54. package/src/api/types.ts +25 -4
  55. package/src/components/agents/AgentDialogs.tsx +400 -0
  56. package/src/components/agents/AgentsPage.test.tsx +112 -1
  57. package/src/components/agents/AgentsPage.tsx +104 -112
  58. package/src/components/chat/ChatConversationPanel.test.tsx +31 -0
  59. package/src/components/chat/ChatConversationPanel.tsx +7 -6
  60. package/src/components/chat/ChatSidebar.test.tsx +41 -1
  61. package/src/components/chat/ChatWelcome.test.tsx +7 -2
  62. package/src/components/chat/ChatWelcome.tsx +38 -35
  63. package/src/components/chat/chat-page-runtime.test.ts +30 -0
  64. package/src/components/chat/chat-sidebar-session-item.tsx +6 -2
  65. package/src/components/chat/ncp/NcpChatPage.tsx +23 -1
  66. package/src/components/chat/ncp/ncp-session-adapter.ts +6 -1
  67. package/src/components/chat/useChatSessionTypeState.ts +1 -1
  68. package/src/components/common/ProviderScopedModelInput.tsx +149 -0
  69. package/src/components/config/ChannelForm.test.tsx +60 -0
  70. package/src/components/config/ChannelForm.tsx +52 -12
  71. package/src/components/config/ModelConfig.test.tsx +61 -0
  72. package/src/components/config/ModelConfig.tsx +15 -90
  73. package/src/components/config/RuntimeConfig.tsx +3 -24
  74. package/src/components/config/SearchConfig.test.tsx +150 -0
  75. package/src/components/config/SearchConfig.tsx +257 -71
  76. package/src/components/config/runtime-config-agent.utils.ts +5 -4
  77. package/src/hooks/agents/useAgents.ts +18 -1
  78. package/src/lib/i18n.agents.ts +21 -2
  79. package/src/lib/i18n.search.ts +37 -0
  80. package/src/lib/i18n.ts +6 -28
  81. package/dist/assets/ChannelsList-NKNKsf1J.js +0 -8
  82. package/dist/assets/ChatPage-p23OnnEI.js +0 -43
  83. package/dist/assets/DocBrowser-C8b2uPgL.js +0 -1
  84. package/dist/assets/MarketplacePage-GGkEXowp.js +0 -1
  85. package/dist/assets/ModelConfig-CEpx9fro.js +0 -1
  86. package/dist/assets/RuntimeConfig-BJHBsVTd.js +0 -1
  87. package/dist/assets/SearchConfig-BsaX_WYy.js +0 -1
  88. package/dist/assets/chat-session-display-BD_AN71I.js +0 -1
  89. package/dist/assets/i18n-BK1w-oBy.js +0 -1
  90. package/dist/assets/index-DaR9igPC.css +0 -1
  91. package/dist/assets/loader-circle-DdZPxBUz.js +0 -1
  92. package/dist/assets/plus-DP2PSCPO.js +0 -1
  93. package/dist/assets/provider-models-DJ29qHuA.js +0 -1
  94. package/dist/assets/search-pD6ZwQYF.js +0 -1
  95. package/dist/assets/skeleton-D6kCk9Y6.js +0 -1
  96. package/dist/assets/x-CTIQHUuD.js +0 -1
@@ -0,0 +1,150 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { SearchConfig } from '@/components/config/SearchConfig';
5
+ import { setLanguage } from '@/lib/i18n';
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ mutate: vi.fn(),
9
+ useConfigData: {
10
+ data: {
11
+ search: {
12
+ provider: 'tavily',
13
+ enabledProviders: ['bocha', 'tavily'],
14
+ defaults: {
15
+ maxResults: 8
16
+ },
17
+ providers: {
18
+ bocha: {
19
+ enabled: true,
20
+ apiKeySet: false,
21
+ baseUrl: 'https://api.bocha.cn/v1/web-search',
22
+ docsUrl: 'https://open.bocha.cn',
23
+ summary: true,
24
+ freshness: 'noLimit'
25
+ },
26
+ tavily: {
27
+ enabled: true,
28
+ apiKeySet: true,
29
+ apiKeyMasked: 'tv****1234',
30
+ baseUrl: 'https://api.tavily.com/search',
31
+ searchDepth: 'advanced',
32
+ includeAnswer: true
33
+ },
34
+ brave: {
35
+ enabled: false,
36
+ apiKeySet: false,
37
+ baseUrl: 'https://api.search.brave.com/res/v1/web/search'
38
+ }
39
+ }
40
+ }
41
+ }
42
+ },
43
+ useConfigMetaData: {
44
+ data: {
45
+ search: [
46
+ {
47
+ name: 'bocha',
48
+ displayName: 'Bocha Search',
49
+ description: 'China-friendly web search with AI-ready summaries.',
50
+ docsUrl: 'https://open.bocha.cn',
51
+ isDefault: true,
52
+ supportsSummary: true
53
+ },
54
+ {
55
+ name: 'tavily',
56
+ displayName: 'Tavily Search',
57
+ description: 'Research-focused web search with optional synthesized answers.',
58
+ docsUrl: 'https://docs.tavily.com/documentation/api-reference/endpoint/search',
59
+ supportsSummary: true
60
+ },
61
+ {
62
+ name: 'brave',
63
+ displayName: 'Brave Search',
64
+ description: 'Brave web search API kept as an optional provider.',
65
+ supportsSummary: false
66
+ }
67
+ ]
68
+ }
69
+ }
70
+ }));
71
+
72
+ vi.mock('@/hooks/useConfig', () => ({
73
+ useConfig: () => mocks.useConfigData,
74
+ useConfigMeta: () => mocks.useConfigMetaData,
75
+ useUpdateSearch: () => ({
76
+ mutate: mocks.mutate,
77
+ isPending: false
78
+ })
79
+ }));
80
+
81
+ describe('SearchConfig', () => {
82
+ beforeEach(() => {
83
+ setLanguage('zh');
84
+ mocks.mutate.mockReset();
85
+ if (!HTMLElement.prototype.hasPointerCapture) {
86
+ HTMLElement.prototype.hasPointerCapture = () => false;
87
+ }
88
+ if (!HTMLElement.prototype.setPointerCapture) {
89
+ HTMLElement.prototype.setPointerCapture = () => {};
90
+ }
91
+ if (!HTMLElement.prototype.releasePointerCapture) {
92
+ HTMLElement.prototype.releasePointerCapture = () => {};
93
+ }
94
+ });
95
+
96
+ it('renders Tavily-specific controls and submits Tavily config through updateSearch', async () => {
97
+ const user = userEvent.setup();
98
+
99
+ render(<SearchConfig />);
100
+
101
+ expect(screen.getByRole('heading', { name: 'Tavily Search' })).toBeTruthy();
102
+ expect(screen.getByText('搜索深度')).toBeTruthy();
103
+ expect(screen.getByText('包含回答')).toBeTruthy();
104
+ expect(screen.getByDisplayValue('https://api.tavily.com/search')).toBeTruthy();
105
+ expect(screen.queryByText('结果摘要')).toBeNull();
106
+
107
+ const searchDepthSection = screen.getByText('搜索深度').parentElement;
108
+ const includeAnswerSection = screen.getByText('包含回答').parentElement;
109
+ const searchDepthTrigger = searchDepthSection?.querySelector('[role="combobox"]');
110
+ const includeAnswerTrigger = includeAnswerSection?.querySelector('[role="combobox"]');
111
+
112
+ expect(searchDepthTrigger).toBeTruthy();
113
+ expect(includeAnswerTrigger).toBeTruthy();
114
+
115
+ await user.click(searchDepthTrigger as HTMLElement);
116
+ await user.click(screen.getByRole('option', { name: '高级' }));
117
+ await user.click(includeAnswerTrigger as HTMLElement);
118
+ await user.click(screen.getByRole('option', { name: '启用' }));
119
+
120
+ await user.click(screen.getByRole('button', { name: '保存变更' }));
121
+
122
+ expect(mocks.mutate).toHaveBeenCalledWith({
123
+ data: {
124
+ provider: 'tavily',
125
+ enabledProviders: ['bocha', 'tavily'],
126
+ defaults: {
127
+ maxResults: 8
128
+ },
129
+ providers: {
130
+ bocha: {
131
+ apiKey: undefined,
132
+ baseUrl: 'https://api.bocha.cn/v1/web-search',
133
+ summary: true,
134
+ freshness: 'noLimit'
135
+ },
136
+ tavily: {
137
+ apiKey: undefined,
138
+ baseUrl: 'https://api.tavily.com/search',
139
+ searchDepth: 'advanced',
140
+ includeAnswer: true
141
+ },
142
+ brave: {
143
+ apiKey: undefined,
144
+ baseUrl: 'https://api.search.brave.com/res/v1/web/search'
145
+ }
146
+ }
147
+ }
148
+ });
149
+ });
150
+ });
@@ -9,7 +9,7 @@ import { useConfig, useConfigMeta, useUpdateSearch } from '@/hooks/useConfig';
9
9
  import { t } from '@/lib/i18n';
10
10
  import { cn } from '@/lib/utils';
11
11
  import { CONFIG_DETAIL_CARD_CLASS, CONFIG_SIDEBAR_CARD_CLASS, CONFIG_SPLIT_GRID_CLASS } from './config-layout';
12
- import type { SearchConfigUpdate, SearchProviderName } from '@/api/types';
12
+ import type { SearchConfigUpdate, SearchProviderName, TavilySearchDepthValue } from '@/api/types';
13
13
 
14
14
  const FRESHNESS_OPTIONS = [
15
15
  { value: 'noLimit', label: 'searchFreshnessNoLimit' },
@@ -19,11 +19,219 @@ const FRESHNESS_OPTIONS = [
19
19
  { value: 'oneYear', label: 'searchFreshnessOneYear' }
20
20
  ] as const;
21
21
 
22
+ const SEARCH_DEPTH_OPTIONS = [
23
+ { value: 'basic', label: 'searchDepthBasic' },
24
+ { value: 'advanced', label: 'searchDepthAdvanced' }
25
+ ] as const;
26
+
27
+ const SEARCH_PROVIDER_DESCRIPTION_KEYS: Record<SearchProviderName, string> = {
28
+ bocha: 'searchProviderBochaDescription',
29
+ tavily: 'searchProviderTavilyDescription',
30
+ brave: 'searchProviderBraveDescription'
31
+ };
32
+
33
+ type BochaProviderFieldsProps = {
34
+ apiKey: string;
35
+ apiKeyMasked?: string;
36
+ baseUrl: string;
37
+ summary: boolean;
38
+ freshness: string;
39
+ docsUrl?: string;
40
+ onApiKeyChange: (value: string) => void;
41
+ onBaseUrlChange: (value: string) => void;
42
+ onSummaryChange: (value: boolean) => void;
43
+ onFreshnessChange: (value: string) => void;
44
+ };
45
+
46
+ function BochaProviderFields({
47
+ apiKey,
48
+ apiKeyMasked,
49
+ baseUrl,
50
+ summary,
51
+ freshness,
52
+ docsUrl,
53
+ onApiKeyChange,
54
+ onBaseUrlChange,
55
+ onSummaryChange,
56
+ onFreshnessChange
57
+ }: BochaProviderFieldsProps) {
58
+ return (
59
+ <div className="space-y-4">
60
+ <div className="space-y-2">
61
+ <Label>{t('apiKey')}</Label>
62
+ <Input
63
+ type="password"
64
+ value={apiKey}
65
+ onChange={(event) => onApiKeyChange(event.target.value)}
66
+ placeholder={apiKeyMasked || t('enterApiKey')}
67
+ className="rounded-xl"
68
+ />
69
+ </div>
70
+ <div className="space-y-2">
71
+ <Label>{t('searchProviderBaseUrl')}</Label>
72
+ <Input value={baseUrl} onChange={(event) => onBaseUrlChange(event.target.value)} className="rounded-xl" />
73
+ </div>
74
+ <div className="grid gap-4 md:grid-cols-2">
75
+ <div className="space-y-2">
76
+ <Label>{t('searchProviderSummary')}</Label>
77
+ <Select value={summary ? 'true' : 'false'} onValueChange={(value) => onSummaryChange(value === 'true')}>
78
+ <SelectTrigger className="rounded-xl">
79
+ <SelectValue />
80
+ </SelectTrigger>
81
+ <SelectContent>
82
+ <SelectItem value="true">{t('enabled')}</SelectItem>
83
+ <SelectItem value="false">{t('disabled')}</SelectItem>
84
+ </SelectContent>
85
+ </Select>
86
+ </div>
87
+ <div className="space-y-2">
88
+ <Label>{t('searchProviderFreshness')}</Label>
89
+ <Select value={freshness} onValueChange={onFreshnessChange}>
90
+ <SelectTrigger className="rounded-xl">
91
+ <SelectValue />
92
+ </SelectTrigger>
93
+ <SelectContent>
94
+ {FRESHNESS_OPTIONS.map((option) => (
95
+ <SelectItem key={option.value} value={option.value}>{t(option.label)}</SelectItem>
96
+ ))}
97
+ </SelectContent>
98
+ </Select>
99
+ </div>
100
+ </div>
101
+ <div className="space-y-2">
102
+ <a href={docsUrl ?? 'https://open.bocha.cn'} target="_blank" rel="noreferrer">
103
+ <Button type="button" variant="outline" className="rounded-xl">
104
+ <ExternalLink className="mr-2 h-4 w-4" />
105
+ {t('searchProviderOpenDocs')}
106
+ </Button>
107
+ </a>
108
+ </div>
109
+ </div>
110
+ );
111
+ }
112
+
113
+ type TavilyProviderFieldsProps = {
114
+ apiKey: string;
115
+ apiKeyMasked?: string;
116
+ baseUrl: string;
117
+ searchDepth: TavilySearchDepthValue;
118
+ includeAnswer: boolean;
119
+ docsUrl?: string;
120
+ onApiKeyChange: (value: string) => void;
121
+ onBaseUrlChange: (value: string) => void;
122
+ onSearchDepthChange: (value: TavilySearchDepthValue) => void;
123
+ onIncludeAnswerChange: (value: boolean) => void;
124
+ };
125
+
126
+ function TavilyProviderFields({
127
+ apiKey,
128
+ apiKeyMasked,
129
+ baseUrl,
130
+ searchDepth,
131
+ includeAnswer,
132
+ docsUrl,
133
+ onApiKeyChange,
134
+ onBaseUrlChange,
135
+ onSearchDepthChange,
136
+ onIncludeAnswerChange
137
+ }: TavilyProviderFieldsProps) {
138
+ return (
139
+ <div className="space-y-4">
140
+ <div className="space-y-2">
141
+ <Label>{t('apiKey')}</Label>
142
+ <Input
143
+ type="password"
144
+ value={apiKey}
145
+ onChange={(event) => onApiKeyChange(event.target.value)}
146
+ placeholder={apiKeyMasked || t('enterApiKey')}
147
+ className="rounded-xl"
148
+ />
149
+ </div>
150
+ <div className="space-y-2">
151
+ <Label>{t('searchProviderBaseUrl')}</Label>
152
+ <Input value={baseUrl} onChange={(event) => onBaseUrlChange(event.target.value)} className="rounded-xl" />
153
+ </div>
154
+ <div className="grid gap-4 md:grid-cols-2">
155
+ <div className="space-y-2">
156
+ <Label>{t('searchProviderSearchDepth')}</Label>
157
+ <Select value={searchDepth} onValueChange={(value) => onSearchDepthChange(value as TavilySearchDepthValue)}>
158
+ <SelectTrigger className="rounded-xl">
159
+ <SelectValue />
160
+ </SelectTrigger>
161
+ <SelectContent>
162
+ {SEARCH_DEPTH_OPTIONS.map((option) => (
163
+ <SelectItem key={option.value} value={option.value}>{t(option.label)}</SelectItem>
164
+ ))}
165
+ </SelectContent>
166
+ </Select>
167
+ </div>
168
+ <div className="space-y-2">
169
+ <Label>{t('searchProviderIncludeAnswer')}</Label>
170
+ <Select value={includeAnswer ? 'true' : 'false'} onValueChange={(value) => onIncludeAnswerChange(value === 'true')}>
171
+ <SelectTrigger className="rounded-xl">
172
+ <SelectValue />
173
+ </SelectTrigger>
174
+ <SelectContent>
175
+ <SelectItem value="true">{t('enabled')}</SelectItem>
176
+ <SelectItem value="false">{t('disabled')}</SelectItem>
177
+ </SelectContent>
178
+ </Select>
179
+ </div>
180
+ </div>
181
+ {docsUrl ? (
182
+ <div className="space-y-2">
183
+ <a href={docsUrl} target="_blank" rel="noreferrer">
184
+ <Button type="button" variant="outline" className="rounded-xl">
185
+ <ExternalLink className="mr-2 h-4 w-4" />
186
+ {t('searchProviderOpenDocs')}
187
+ </Button>
188
+ </a>
189
+ </div>
190
+ ) : null}
191
+ </div>
192
+ );
193
+ }
194
+
195
+ type BraveProviderFieldsProps = {
196
+ apiKey: string;
197
+ apiKeyMasked?: string;
198
+ baseUrl: string;
199
+ onApiKeyChange: (value: string) => void;
200
+ onBaseUrlChange: (value: string) => void;
201
+ };
202
+
203
+ function BraveProviderFields({
204
+ apiKey,
205
+ apiKeyMasked,
206
+ baseUrl,
207
+ onApiKeyChange,
208
+ onBaseUrlChange
209
+ }: BraveProviderFieldsProps) {
210
+ return (
211
+ <div className="space-y-4">
212
+ <div className="space-y-2">
213
+ <Label>{t('apiKey')}</Label>
214
+ <Input
215
+ type="password"
216
+ value={apiKey}
217
+ onChange={(event) => onApiKeyChange(event.target.value)}
218
+ placeholder={apiKeyMasked || t('enterApiKey')}
219
+ className="rounded-xl"
220
+ />
221
+ </div>
222
+ <div className="space-y-2">
223
+ <Label>{t('searchProviderBaseUrl')}</Label>
224
+ <Input value={baseUrl} onChange={(event) => onBaseUrlChange(event.target.value)} className="rounded-xl" />
225
+ </div>
226
+ </div>
227
+ );
228
+ }
229
+
22
230
  export function SearchConfig() {
23
231
  const { data: config } = useConfig();
24
232
  const { data: meta } = useConfigMeta();
25
233
  const updateSearch = useUpdateSearch();
26
- const providers = meta?.search ?? [];
234
+ const providers = useMemo(() => meta?.search ?? [], [meta]);
27
235
  const search = config?.search;
28
236
 
29
237
  const [selectedProvider, setSelectedProvider] = useState<SearchProviderName>('bocha');
@@ -34,6 +242,10 @@ export function SearchConfig() {
34
242
  const [bochaBaseUrl, setBochaBaseUrl] = useState('https://api.bocha.cn/v1/web-search');
35
243
  const [bochaSummary, setBochaSummary] = useState(true);
36
244
  const [bochaFreshness, setBochaFreshness] = useState('noLimit');
245
+ const [tavilyApiKey, setTavilyApiKey] = useState('');
246
+ const [tavilyBaseUrl, setTavilyBaseUrl] = useState('https://api.tavily.com/search');
247
+ const [tavilySearchDepth, setTavilySearchDepth] = useState<TavilySearchDepthValue>('basic');
248
+ const [tavilyIncludeAnswer, setTavilyIncludeAnswer] = useState(false);
37
249
  const [braveApiKey, setBraveApiKey] = useState('');
38
250
  const [braveBaseUrl, setBraveBaseUrl] = useState('https://api.search.brave.com/res/v1/web/search');
39
251
 
@@ -48,6 +260,9 @@ export function SearchConfig() {
48
260
  setBochaBaseUrl(search.providers.bocha.baseUrl);
49
261
  setBochaSummary(Boolean(search.providers.bocha.summary));
50
262
  setBochaFreshness(search.providers.bocha.freshness ?? 'noLimit');
263
+ setTavilyBaseUrl(search.providers.tavily.baseUrl);
264
+ setTavilySearchDepth(search.providers.tavily.searchDepth ?? 'basic');
265
+ setTavilyIncludeAnswer(Boolean(search.providers.tavily.includeAnswer));
51
266
  setBraveBaseUrl(search.providers.brave.baseUrl);
52
267
  }, [search]);
53
268
 
@@ -57,7 +272,7 @@ export function SearchConfig() {
57
272
  );
58
273
  const selectedView = search?.providers[selectedProvider];
59
274
  const selectedEnabled = enabledProviders.includes(selectedProvider);
60
- const bochaDocsUrl = search?.providers.bocha.docsUrl ?? meta?.search.find((provider) => provider.name === 'bocha')?.docsUrl ?? 'https://open.bocha.cn';
275
+ const selectedDocsUrl = selectedView?.docsUrl ?? selectedMeta?.docsUrl;
61
276
  const activationButtonLabel = selectedEnabled
62
277
  ? t('searchProviderDeactivate')
63
278
  : t('searchProviderActivate');
@@ -78,6 +293,12 @@ export function SearchConfig() {
78
293
  summary: bochaSummary,
79
294
  freshness: bochaFreshness
80
295
  },
296
+ tavily: {
297
+ apiKey: tavilyApiKey || undefined,
298
+ baseUrl: tavilyBaseUrl,
299
+ searchDepth: tavilySearchDepth,
300
+ includeAnswer: tavilyIncludeAnswer
301
+ },
81
302
  brave: {
82
303
  apiKey: braveApiKey || undefined,
83
304
  baseUrl: braveBaseUrl
@@ -140,7 +361,7 @@ export function SearchConfig() {
140
361
  <div className="min-w-0">
141
362
  <p className="truncate text-sm font-semibold text-gray-900">{provider.displayName}</p>
142
363
  <p className="line-clamp-2 text-[11px] text-gray-500">
143
- {provider.name === 'bocha' ? t('searchProviderBochaDescription') : t('searchProviderBraveDescription')}
364
+ {t(SEARCH_PROVIDER_DESCRIPTION_KEYS[provider.name])}
144
365
  </p>
145
366
  </div>
146
367
  <div className="flex flex-col items-end gap-1">
@@ -212,74 +433,39 @@ export function SearchConfig() {
212
433
  </div>
213
434
 
214
435
  {selectedProvider === 'bocha' ? (
215
- <div className="space-y-4">
216
- <div className="space-y-2">
217
- <Label>{t('apiKey')}</Label>
218
- <Input
219
- type="password"
220
- value={bochaApiKey}
221
- onChange={(event) => setBochaApiKey(event.target.value)}
222
- placeholder={search.providers.bocha.apiKeyMasked || t('enterApiKey')}
223
- className="rounded-xl"
224
- />
225
- </div>
226
- <div className="space-y-2">
227
- <Label>{t('searchProviderBaseUrl')}</Label>
228
- <Input value={bochaBaseUrl} onChange={(event) => setBochaBaseUrl(event.target.value)} className="rounded-xl" />
229
- </div>
230
- <div className="grid gap-4 md:grid-cols-2">
231
- <div className="space-y-2">
232
- <Label>{t('searchProviderSummary')}</Label>
233
- <Select value={bochaSummary ? 'true' : 'false'} onValueChange={(value) => setBochaSummary(value === 'true')}>
234
- <SelectTrigger className="rounded-xl">
235
- <SelectValue />
236
- </SelectTrigger>
237
- <SelectContent>
238
- <SelectItem value="true">{t('enabled')}</SelectItem>
239
- <SelectItem value="false">{t('disabled')}</SelectItem>
240
- </SelectContent>
241
- </Select>
242
- </div>
243
- <div className="space-y-2">
244
- <Label>{t('searchProviderFreshness')}</Label>
245
- <Select value={bochaFreshness} onValueChange={setBochaFreshness}>
246
- <SelectTrigger className="rounded-xl">
247
- <SelectValue />
248
- </SelectTrigger>
249
- <SelectContent>
250
- {FRESHNESS_OPTIONS.map((option) => (
251
- <SelectItem key={option.value} value={option.value}>{t(option.label)}</SelectItem>
252
- ))}
253
- </SelectContent>
254
- </Select>
255
- </div>
256
- </div>
257
- <div className="space-y-2">
258
- <a href={bochaDocsUrl} target="_blank" rel="noreferrer">
259
- <Button type="button" variant="outline" className="rounded-xl">
260
- <ExternalLink className="mr-2 h-4 w-4" />
261
- {t('searchProviderOpenDocs')}
262
- </Button>
263
- </a>
264
- </div>
265
- </div>
436
+ <BochaProviderFields
437
+ apiKey={bochaApiKey}
438
+ apiKeyMasked={search.providers.bocha.apiKeyMasked}
439
+ baseUrl={bochaBaseUrl}
440
+ summary={bochaSummary}
441
+ freshness={bochaFreshness}
442
+ docsUrl={selectedDocsUrl}
443
+ onApiKeyChange={setBochaApiKey}
444
+ onBaseUrlChange={setBochaBaseUrl}
445
+ onSummaryChange={setBochaSummary}
446
+ onFreshnessChange={setBochaFreshness}
447
+ />
448
+ ) : selectedProvider === 'tavily' ? (
449
+ <TavilyProviderFields
450
+ apiKey={tavilyApiKey}
451
+ apiKeyMasked={search.providers.tavily.apiKeyMasked}
452
+ baseUrl={tavilyBaseUrl}
453
+ searchDepth={tavilySearchDepth}
454
+ includeAnswer={tavilyIncludeAnswer}
455
+ docsUrl={selectedDocsUrl}
456
+ onApiKeyChange={setTavilyApiKey}
457
+ onBaseUrlChange={setTavilyBaseUrl}
458
+ onSearchDepthChange={setTavilySearchDepth}
459
+ onIncludeAnswerChange={setTavilyIncludeAnswer}
460
+ />
266
461
  ) : (
267
- <div className="space-y-4">
268
- <div className="space-y-2">
269
- <Label>{t('apiKey')}</Label>
270
- <Input
271
- type="password"
272
- value={braveApiKey}
273
- onChange={(event) => setBraveApiKey(event.target.value)}
274
- placeholder={search.providers.brave.apiKeyMasked || t('enterApiKey')}
275
- className="rounded-xl"
276
- />
277
- </div>
278
- <div className="space-y-2">
279
- <Label>{t('searchProviderBaseUrl')}</Label>
280
- <Input value={braveBaseUrl} onChange={(event) => setBraveBaseUrl(event.target.value)} className="rounded-xl" />
281
- </div>
282
- </div>
462
+ <BraveProviderFields
463
+ apiKey={braveApiKey}
464
+ apiKeyMasked={search.providers.brave.apiKeyMasked}
465
+ baseUrl={braveBaseUrl}
466
+ onApiKeyChange={setBraveApiKey}
467
+ onBaseUrlChange={setBraveBaseUrl}
468
+ />
283
469
  )}
284
470
 
285
471
  <div className="flex justify-end">
@@ -6,7 +6,7 @@ export function createEmptyRuntimeAgent(): AgentProfileView {
6
6
  default: false,
7
7
  workspace: '',
8
8
  model: '',
9
- engine: '',
9
+ runtime: '',
10
10
  contextTokens: undefined,
11
11
  maxToolIterations: undefined
12
12
  };
@@ -31,7 +31,7 @@ export function hydrateRuntimeAgent(agent: AgentProfileView): AgentProfileView {
31
31
  avatar: agent.avatar ?? '',
32
32
  workspace: agent.workspace ?? '',
33
33
  model: agent.model ?? '',
34
- engine: agent.engine ?? '',
34
+ runtime: agent.runtime ?? agent.engine ?? '',
35
35
  contextTokens: agent.contextTokens,
36
36
  maxToolIterations: agent.maxToolIterations
37
37
  };
@@ -82,8 +82,9 @@ export function toPersistedRuntimeAgent(agent: AgentProfileView): AgentProfileVi
82
82
  if (agent.model?.trim()) {
83
83
  normalized.model = agent.model.trim();
84
84
  }
85
- if (agent.engine?.trim()) {
86
- normalized.engine = agent.engine.trim();
85
+ const runtime = agent.runtime?.trim() ?? agent.engine?.trim();
86
+ if (runtime) {
87
+ normalized.engine = runtime;
87
88
  }
88
89
  if (typeof agent.contextTokens === 'number') {
89
90
  normalized.contextTokens = Math.max(1000, agent.contextTokens);
@@ -1,5 +1,5 @@
1
1
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
- import { createAgent, deleteAgent, fetchAgents } from '@/api/agents';
2
+ import { createAgent, deleteAgent, fetchAgents, updateAgent } from '@/api/agents';
3
3
  import { toast } from 'sonner';
4
4
  import { t } from '@/lib/i18n';
5
5
 
@@ -27,6 +27,23 @@ export function useCreateAgent() {
27
27
  });
28
28
  }
29
29
 
30
+ export function useUpdateAgent() {
31
+ const queryClient = useQueryClient();
32
+
33
+ return useMutation({
34
+ mutationFn: ({ agentId, data }: { agentId: string; data: Parameters<typeof updateAgent>[1] }) =>
35
+ updateAgent(agentId, data),
36
+ onSuccess: () => {
37
+ queryClient.invalidateQueries({ queryKey: ['agents'] });
38
+ queryClient.invalidateQueries({ queryKey: ['config'] });
39
+ toast.success(t('configSavedApplied'));
40
+ },
41
+ onError: (error: Error) => {
42
+ toast.error(t('configSaveFailed') + ': ' + error.message);
43
+ }
44
+ });
45
+ }
46
+
30
47
  export function useDeleteAgent() {
31
48
  const queryClient = useQueryClient();
32
49
 
@@ -33,7 +33,25 @@ export const AGENT_LABELS: Record<string, { zh: string; en: string }> = {
33
33
  agentsFormDescriptionPlaceholder: { zh: '角色描述,可选', en: 'Role description, optional' },
34
34
  agentsFormAvatarPlaceholder: { zh: '头像 URL 或本地路径,可选', en: 'Avatar URL or local path, optional' },
35
35
  agentsFormHomePlaceholder: { zh: '主目录,可选', en: 'Home Directory, optional' },
36
+ agentsFormRuntimePlaceholder: { zh: 'Runtime(如 native 或 codex,可选)', en: 'Runtime (e.g. native or codex, optional)' },
37
+ agentsRuntimeSelectPlaceholder: { zh: '选择 Runtime', en: 'Select runtime' },
38
+ agentsRuntimeUnavailableHelp: {
39
+ zh: '当前 Runtime 已不在可用列表中;建议改选一个已安装且可用的 Runtime。',
40
+ en: 'This runtime is no longer available in the current list. Switch to an installed and available runtime.'
41
+ },
36
42
  agentsCreateAction: { zh: '创建 Agent', en: 'Create Agent' },
43
+ agentsEditAction: { zh: '编辑', en: 'Edit' },
44
+ agentsEditDialogTitle: { zh: '编辑 Agent 身份', en: 'Edit agent identity' },
45
+ agentsEditDialogDescription: {
46
+ zh: '更新名称、角色描述与头像。Agent ID 与主目录保持稳定,避免影响既有记忆、技能与会话。',
47
+ en: 'Update the name, role description, and avatar. The agent ID and home directory stay stable to protect existing memory, skills, and sessions.'
48
+ },
49
+ agentsEditHomeReadonly: { zh: '主目录保持不变', en: 'Home directory stays unchanged' },
50
+ agentsEditHomeReadonlyHint: {
51
+ zh: '如需迁移主目录,请走独立配置迁移流程,避免意外丢失 Agent 上下文。',
52
+ en: 'Use a dedicated config migration flow to move the home directory and avoid losing agent context unexpectedly.'
53
+ },
54
+ agentsEditSaveAction: { zh: '保存编辑', en: 'Save edits' },
37
55
  agentsRemoveAction: { zh: '移除', en: 'Remove' },
38
56
  agentsNewChat: { zh: '新建会话', en: 'New Chat' },
39
57
  agentsLoading: { zh: '正在加载 Agent...', en: 'Loading agents...' },
@@ -55,12 +73,13 @@ export const AGENT_LABELS: Record<string, { zh: string; en: string }> = {
55
73
  zh: '专属 Agent 身份,可沉淀自己的记忆、技能与角色风格。',
56
74
  en: 'A dedicated identity with its own memory, skills, and role style.'
57
75
  },
76
+ agentsCardRuntimeLabel: { zh: 'Runtime', en: 'Runtime' },
58
77
  agentsCardHomeLabel: { zh: '主目录', en: 'Home Directory' },
59
78
  agentsCardAvatarLabel: { zh: 'Avatar', en: 'Avatar' },
60
79
  agentsCardStartChat: { zh: '开始对话', en: 'Start Chat' },
61
80
  agentsCardBuiltInTag: { zh: '系统主', en: 'Main Agent' },
62
81
  agentsCardCustomTag: { zh: '专职', en: 'Specialist' },
63
- chatDraftAgentTitle: { zh: '本次会话将与谁对话?', en: 'Who should this draft chat talk to?' },
64
- chatDraftAgentDescription: { zh: '草稿态可以选择 Agent;会话创建后不可切换。', en: 'Choose the agent in draft state. It cannot be changed after the session is created.' },
82
+ chatDraftAgentTitle: { zh: '本次会话 Agent', en: 'Draft agent' },
83
+ chatDraftAgentDescription: { zh: '创建后不可切换', en: 'Locked after creation' },
65
84
  chatDraftAgentCurrent: { zh: '当前 Agent', en: 'Current Agent' }
66
85
  };