@nextclaw/ui 0.12.1 → 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 (89) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/assets/ChannelsList-uKmkpD25.js +8 -0
  3. package/dist/assets/ChatPage-CslhBPfT.js +43 -0
  4. package/dist/assets/{DocBrowser-DjcghYGO.js → DocBrowser-C7-1sXqo.js} +1 -1
  5. package/dist/assets/DocBrowser-DQjtSsY3.js +1 -0
  6. package/dist/assets/{DocBrowserContext-CLlq7rZQ.js → DocBrowserContext-DN5tjUoS.js} +1 -1
  7. package/dist/assets/{LogoBadge-D_dOy5U3.js → LogoBadge-DDS1sU_U.js} +1 -1
  8. package/dist/assets/MarketplacePage-BZQW70ti.js +1 -0
  9. package/dist/assets/{MarketplacePage-BlIeNn3x.js → MarketplacePage-DE0QjYVv.js} +1 -1
  10. package/dist/assets/{McpMarketplacePage-mz2_IX1O.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-B0RCb_Vg.js → ProvidersList-1rKi3aQT.js} +1 -1
  14. package/dist/assets/{RemoteAccessPage-CcfQjLtx.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-DbiS3txa.js → SecretsConfig-cnAXvREZ.js} +1 -1
  18. package/dist/assets/{SessionsConfig-CbIOcAp8.js → SessionsConfig-BIXiDaK2.js} +1 -1
  19. package/dist/assets/{book-open-BLxSL7Dk.js → book-open-DvWqOode.js} +1 -1
  20. package/dist/assets/chat-session-display-D4bYa0b8.js +1 -0
  21. package/dist/assets/{chunk-JZWAC4HX-Bp0t5xoO.js → chunk-JZWAC4HX-CxfKRD7X.js} +1 -1
  22. package/dist/assets/{config-C96FWufn.js → config-BeGwf2Ao.js} +1 -1
  23. package/dist/assets/{createLucideIcon-B_U7Nq4F.js → createLucideIcon-C7MmdIX3.js} +1 -1
  24. package/dist/assets/{dist-D9pHzW9z.js → dist-B6VMuIQN.js} +1 -1
  25. package/dist/assets/{dist-BFY-GyT4.js → dist-RWNFhxvR.js} +1 -1
  26. package/dist/assets/{external-link-BydIQTIH.js → external-link-U86Acd1t.js} +1 -1
  27. package/dist/assets/{hash-Djdf0x1C.js → hash-D-OVfV3Z.js} +1 -1
  28. package/dist/assets/i18n-hM3v-3YG.js +1 -0
  29. package/dist/assets/{index-DqSv8Azv.js → index-8XNPYwJu.js} +3 -3
  30. package/dist/assets/index-CpxuJa9o.css +1 -0
  31. package/dist/assets/{label-Bvv4Mrea.js → label-CHJ1ATds.js} +1 -1
  32. package/dist/assets/loader-circle-C8cpaL0w.js +1 -0
  33. package/dist/assets/{logos-CGJJRI5_.js → logos-U1_qDA3U.js} +1 -1
  34. package/dist/assets/{page-layout-6Nm4Cnvr.js → page-layout-Z1klaUFW.js} +1 -1
  35. package/dist/assets/plus-CrkO1kob.js +1 -0
  36. package/dist/assets/{popover-b9rSYI6X.js → popover-xWbqMnIN.js} +1 -1
  37. package/dist/assets/{react-CDZz_StC.js → react-3YE87-lE.js} +1 -1
  38. package/dist/assets/{refresh-ccw-BvSSnnCw.js → refresh-ccw-JQh1lwq-.js} +1 -1
  39. package/dist/assets/{save-CAf0_-b9.js → save-4VRlzkii.js} +1 -1
  40. package/dist/assets/search-EX-Papzl.js +1 -0
  41. package/dist/assets/{security-config-DF66-l25.js → security-config-CGazBahs.js} +1 -1
  42. package/dist/assets/{select-CEIMqc0H.js → select-DF-AUoie.js} +1 -1
  43. package/dist/assets/skeleton-B0mmt1vo.js +1 -0
  44. package/dist/assets/{status-dot-CmQI5Qq2.js → status-dot-Bq_8Ojvv.js} +1 -1
  45. package/dist/assets/{switch-B7SxDXyR.js → switch-D7JF_RZ-.js} +1 -1
  46. package/dist/assets/{tabs-custom-Dxt6EJJW.js → tabs-custom-CLksZ2bO.js} +1 -1
  47. package/dist/assets/{trash-2-BnQ1PDTw.js → trash-2-VV8jvziy.js} +1 -1
  48. package/dist/assets/{useConfirmDialog-B-vMOmhG.js → useConfirmDialog-D6HxybcM.js} +1 -1
  49. package/dist/assets/{useMutation-Bi39Z9_J.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 +5 -5
  53. package/src/api/agents.ts +9 -1
  54. package/src/api/types.ts +25 -1
  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/ncp/NcpChatPage.tsx +1 -0
  59. package/src/components/chat/ncp/ncp-session-adapter.ts +6 -1
  60. package/src/components/chat/useChatSessionTypeState.ts +1 -1
  61. package/src/components/common/ProviderScopedModelInput.tsx +149 -0
  62. package/src/components/config/ChannelForm.test.tsx +60 -0
  63. package/src/components/config/ChannelForm.tsx +52 -12
  64. package/src/components/config/ModelConfig.test.tsx +61 -0
  65. package/src/components/config/ModelConfig.tsx +15 -90
  66. package/src/components/config/RuntimeConfig.tsx +2 -2
  67. package/src/components/config/SearchConfig.test.tsx +150 -0
  68. package/src/components/config/SearchConfig.tsx +257 -71
  69. package/src/components/config/runtime-config-agent.utils.ts +5 -4
  70. package/src/hooks/agents/useAgents.ts +18 -1
  71. package/src/lib/i18n.agents.ts +19 -0
  72. package/src/lib/i18n.search.ts +37 -0
  73. package/src/lib/i18n.ts +6 -26
  74. package/dist/assets/ChannelsList-DekMP4a3.js +0 -8
  75. package/dist/assets/ChatPage-Dgw4vlDt.js +0 -43
  76. package/dist/assets/DocBrowser-CExjX5is.js +0 -1
  77. package/dist/assets/MarketplacePage-DGfzg1LG.js +0 -1
  78. package/dist/assets/ModelConfig-C_49_a9v.js +0 -1
  79. package/dist/assets/RuntimeConfig-DBWzwoY-.js +0 -1
  80. package/dist/assets/SearchConfig-jSdwlH4b.js +0 -1
  81. package/dist/assets/chat-session-display-8yW6-mtm.js +0 -1
  82. package/dist/assets/i18n-DAekxt_G.js +0 -1
  83. package/dist/assets/index-CHEgQIiO.css +0 -1
  84. package/dist/assets/loader-circle-CGXXikVG.js +0 -1
  85. package/dist/assets/plus-CrW9BJDy.js +0 -1
  86. package/dist/assets/provider-models-IJDA940D.js +0 -1
  87. package/dist/assets/search-DgoXxocn.js +0 -1
  88. package/dist/assets/skeleton-BiPUQkOD.js +0 -1
  89. package/dist/assets/x-PBSiWt3l.js +0 -1
@@ -1,6 +1,7 @@
1
1
  import { render, screen, waitFor } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { ModelConfig } from '@/components/config/ModelConfig';
4
+ import { setLanguage } from '@/lib/i18n';
4
5
 
5
6
  const mocks = vi.hoisted(() => ({
6
7
  mutate: vi.fn(),
@@ -56,6 +57,34 @@ vi.mock('@/hooks/useConfig', () => ({
56
57
  describe('ModelConfig', () => {
57
58
  beforeEach(() => {
58
59
  mocks.mutate.mockReset();
60
+ setLanguage('en');
61
+ mocks.configQuery.data = {
62
+ agents: {
63
+ defaults: {
64
+ model: 'openai/gpt-5.2',
65
+ workspace: '~/old-workspace'
66
+ }
67
+ },
68
+ providers: {
69
+ openai: {
70
+ enabled: true,
71
+ apiKeySet: true,
72
+ models: ['gpt-5.2']
73
+ }
74
+ }
75
+ };
76
+ mocks.metaQuery.data = {
77
+ providers: [
78
+ {
79
+ name: 'openai',
80
+ displayName: 'OpenAI',
81
+ modelPrefix: 'openai',
82
+ defaultModels: ['openai/gpt-5.2'],
83
+ keywords: [],
84
+ envKey: 'OPENAI_API_KEY'
85
+ }
86
+ ]
87
+ };
59
88
  });
60
89
 
61
90
  it('submits the workspace together with the selected model', async () => {
@@ -75,4 +104,36 @@ describe('ModelConfig', () => {
75
104
  });
76
105
  });
77
106
  });
107
+
108
+ it('shows a clear empty state and still allows manual model input when no providers are configured', async () => {
109
+ const user = userEvent.setup();
110
+ mocks.configQuery.data = {
111
+ agents: {
112
+ defaults: {
113
+ model: '',
114
+ workspace: '~/workspace'
115
+ }
116
+ },
117
+ providers: {}
118
+ } as typeof mocks.configQuery.data;
119
+ mocks.metaQuery.data = {
120
+ providers: []
121
+ } as typeof mocks.metaQuery.data;
122
+
123
+ render(<ModelConfig />);
124
+
125
+ expect(await screen.findByText('No providers configured')).toBeTruthy();
126
+ expect(screen.getByText('Add an AI provider to start using the platform.')).toBeTruthy();
127
+
128
+ const modelInput = screen.getByPlaceholderText('provider/model');
129
+ await user.type(modelInput, 'openai/gpt-5.1');
130
+ await user.click(screen.getByRole('button', { name: /save/i }));
131
+
132
+ await waitFor(() => {
133
+ expect(mocks.mutate).toHaveBeenCalledWith({
134
+ model: 'openai/gpt-5.1',
135
+ workspace: '~/workspace'
136
+ });
137
+ });
138
+ });
78
139
  });
@@ -3,30 +3,25 @@ import { Card } from '@/components/ui/card';
3
3
  import { Input } from '@/components/ui/input';
4
4
  import { Label } from '@/components/ui/label';
5
5
  import { Skeleton } from '@/components/ui/skeleton';
6
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
7
- import { SearchableModelInput } from '@/components/common/SearchableModelInput';
6
+ import { ProviderScopedModelInput } from '@/components/common/ProviderScopedModelInput';
8
7
  import { useConfig, useConfigMeta, useConfigSchema, useUpdateModel } from '@/hooks/useConfig';
9
8
  import { hintForPath } from '@/lib/config-hints';
10
9
  import { t } from '@/lib/i18n';
11
- import {
12
- buildProviderModelCatalog,
13
- composeProviderModel,
14
- findProviderByModel,
15
- toProviderLocalModel
16
- } from '@/lib/provider-models';
10
+ import { buildProviderModelCatalog } from '@/lib/provider-models';
17
11
  import { PageLayout, PageHeader } from '@/components/layout/page-layout';
18
12
  import { DOCS_DEFAULT_BASE_URL } from '@/components/doc-browser/DocBrowserContext';
19
13
  import { BookOpen, Folder, Loader2, Sparkles } from 'lucide-react';
20
14
  import { useEffect, useMemo, useState } from 'react';
21
15
 
16
+ const DEFAULT_MODEL_INPUT_PLACEHOLDER = 'provider/model';
17
+
22
18
  export function ModelConfig() {
23
19
  const { data: config, isLoading } = useConfig();
24
20
  const { data: meta } = useConfigMeta();
25
21
  const { data: schema } = useConfigSchema();
26
22
  const updateModel = useUpdateModel();
27
23
 
28
- const [providerName, setProviderName] = useState('');
29
- const [modelId, setModelId] = useState('');
24
+ const [model, setModel] = useState('');
30
25
  const [workspace, setWorkspace] = useState('');
31
26
  const uiHints = schema?.uiHints;
32
27
  const modelHint = hintForPath('agents.defaults.model', uiHints);
@@ -37,66 +32,20 @@ export function ModelConfig() {
37
32
  [config, meta]
38
33
  );
39
34
 
40
- const providerMap = useMemo(() => new Map(providerCatalog.map((provider) => [provider.name, provider])), [providerCatalog]);
41
- const selectedProvider = providerMap.get(providerName);
42
- const selectedProviderName = selectedProvider?.name ?? '';
43
- const selectedProviderAliases = useMemo(() => selectedProvider?.aliases ?? [], [selectedProvider]);
44
- const selectedProviderModels = useMemo(() => selectedProvider?.models ?? [], [selectedProvider]);
45
-
46
35
  useEffect(() => {
47
36
  if (!config?.agents?.defaults) {
48
37
  return;
49
38
  }
50
- const currentModel = (config.agents.defaults.model || '').trim();
51
- const matchedProvider = findProviderByModel(currentModel, providerCatalog);
52
- const effectiveProvider = matchedProvider ?? '';
53
- const aliases = providerMap.get(effectiveProvider)?.aliases ?? [];
54
- setProviderName(effectiveProvider);
55
- setModelId(effectiveProvider ? toProviderLocalModel(currentModel, aliases) : '');
39
+ setModel((config.agents.defaults.model || '').trim());
56
40
  setWorkspace(config.agents.defaults.workspace || '');
57
- }, [config, providerCatalog, providerMap]);
58
-
59
- const modelOptions = useMemo(() => {
60
- const deduped = new Set<string>();
61
- for (const modelName of selectedProviderModels) {
62
- const trimmed = modelName.trim();
63
- if (trimmed) {
64
- deduped.add(trimmed);
65
- }
66
- }
67
- return [...deduped];
68
- }, [selectedProviderModels]);
69
-
70
- const composedModel = useMemo(() => {
71
- if (!selectedProvider) {
72
- return '';
73
- }
74
- const normalizedModelId = toProviderLocalModel(modelId, selectedProviderAliases);
75
- if (!normalizedModelId) {
76
- return '';
77
- }
78
- return composeProviderModel(selectedProvider.prefix, normalizedModelId);
79
- }, [modelId, selectedProvider, selectedProviderAliases]);
41
+ }, [config]);
80
42
 
81
43
  const modelHelpText = t('modelIdentifierHelp') || modelHint?.help || '';
82
44
 
83
- const handleProviderChange = (nextProvider: string) => {
84
- setProviderName(nextProvider);
85
- setModelId('');
86
- };
87
-
88
- const handleModelChange = (nextModelId: string) => {
89
- if (!selectedProvider) {
90
- setModelId('');
91
- return;
92
- }
93
- setModelId(toProviderLocalModel(nextModelId, selectedProviderAliases));
94
- };
95
-
96
45
  const handleSubmit = (e: React.FormEvent) => {
97
46
  e.preventDefault();
98
47
  updateModel.mutate({
99
- model: composedModel,
48
+ model,
100
49
  workspace
101
50
  });
102
51
  };
@@ -146,38 +95,14 @@ export function ModelConfig() {
146
95
  <Label htmlFor="model" className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
147
96
  {modelHint?.label ?? 'Model Name'}
148
97
  </Label>
149
- <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
150
- <div className="sm:w-[38%] sm:min-w-[170px]">
151
- <Select value={selectedProviderName} onValueChange={handleProviderChange}>
152
- <SelectTrigger className="h-10 w-full rounded-xl">
153
- <SelectValue placeholder={t('providersSelectPlaceholder')} />
154
- </SelectTrigger>
155
- <SelectContent>
156
- {providerCatalog.map((provider) => (
157
- <SelectItem key={provider.name} value={provider.name}>
158
- {provider.displayName}
159
- </SelectItem>
160
- ))}
161
- </SelectContent>
162
- </Select>
163
- </div>
164
- <span className="hidden h-10 items-center justify-center leading-none text-lg font-semibold text-gray-300 sm:inline-flex">/</span>
165
- <SearchableModelInput
166
- key={selectedProviderName}
167
- id="model"
168
- value={modelId}
169
- onChange={handleModelChange}
170
- options={modelOptions}
171
- disabled={!selectedProviderName}
172
- placeholder={modelHint?.placeholder ?? 'gpt-5.1'}
173
- className="sm:flex-1"
174
- inputClassName="h-10 rounded-xl"
175
- emptyText={t('modelPickerNoOptions')}
176
- createText={t('modelPickerUseCustom')}
177
- />
178
- </div>
98
+ <ProviderScopedModelInput
99
+ id="model"
100
+ value={model}
101
+ onChange={setModel}
102
+ providerCatalog={providerCatalog}
103
+ modelPlaceholder={modelHint?.placeholder ?? DEFAULT_MODEL_INPUT_PLACEHOLDER}
104
+ />
179
105
  <p className="text-xs text-gray-400">{modelHelpText}</p>
180
- <p className="text-xs text-gray-500">{t('modelInputCustomHint')}</p>
181
106
  <a
182
107
  href={`${DOCS_DEFAULT_BASE_URL}/guide/model-selection`}
183
108
  className="inline-flex items-center gap-1.5 text-xs text-primary hover:text-primary-hover transition-colors"
@@ -245,8 +245,8 @@ export function RuntimeConfig() {
245
245
  placeholder={t('modelOverridePlaceholder')}
246
246
  />
247
247
  <Input
248
- value={agent.engine ?? ''}
249
- onChange={(event) => updateAgent(index, { engine: event.target.value })}
248
+ value={agent.runtime ?? agent.engine ?? ''}
249
+ onChange={(event) => updateAgent(index, { runtime: event.target.value })}
250
250
  placeholder={agentEngineHint?.label ?? t('engineOverridePlaceholder')}
251
251
  />
252
252
  <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
@@ -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
+ });