@nextclaw/ui 0.12.1 → 0.12.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.
- package/CHANGELOG.md +44 -0
- package/dist/assets/ChannelsList-DZWam3Ob.js +8 -0
- package/dist/assets/ChatPage-YBL7iJ1X.js +43 -0
- package/dist/assets/{DocBrowser-DjcghYGO.js → DocBrowser-C7-1sXqo.js} +1 -1
- package/dist/assets/DocBrowser-DQjtSsY3.js +1 -0
- package/dist/assets/{DocBrowserContext-CLlq7rZQ.js → DocBrowserContext-DN5tjUoS.js} +1 -1
- package/dist/assets/{LogoBadge-D_dOy5U3.js → LogoBadge-DDS1sU_U.js} +1 -1
- package/dist/assets/{MarketplacePage-BlIeNn3x.js → MarketplacePage-2tWWgwAb.js} +1 -1
- package/dist/assets/MarketplacePage-BorWJftJ.js +1 -0
- package/dist/assets/{McpMarketplacePage-mz2_IX1O.js → McpMarketplacePage-N-fB4HID.js} +1 -1
- package/dist/assets/ModelConfig-DvsBTUiE.js +1 -0
- package/dist/assets/ProviderScopedModelInput-D9woCARc.js +1 -0
- package/dist/assets/{ProvidersList-B0RCb_Vg.js → ProvidersList-D-qPGgC4.js} +1 -1
- package/dist/assets/{RemoteAccessPage-CcfQjLtx.js → RemoteAccessPage-COnjm8_x.js} +1 -1
- package/dist/assets/RuntimeConfig-BHpqcaHm.js +1 -0
- package/dist/assets/SearchConfig-DIT6M65Q.js +1 -0
- package/dist/assets/{SecretsConfig-DbiS3txa.js → SecretsConfig-Cefg1LFJ.js} +1 -1
- package/dist/assets/{SessionsConfig-CbIOcAp8.js → SessionsConfig-BZnmVTIu.js} +1 -1
- package/dist/assets/{book-open-BLxSL7Dk.js → book-open-DvWqOode.js} +1 -1
- package/dist/assets/chat-session-display-D4bYa0b8.js +1 -0
- package/dist/assets/{chunk-JZWAC4HX-Bp0t5xoO.js → chunk-JZWAC4HX-CxfKRD7X.js} +1 -1
- package/dist/assets/{config-C96FWufn.js → config-BeGwf2Ao.js} +1 -1
- package/dist/assets/{createLucideIcon-B_U7Nq4F.js → createLucideIcon-C7MmdIX3.js} +1 -1
- package/dist/assets/{dist-D9pHzW9z.js → dist-B6VMuIQN.js} +1 -1
- package/dist/assets/{dist-BFY-GyT4.js → dist-RWNFhxvR.js} +1 -1
- package/dist/assets/{external-link-BydIQTIH.js → external-link-U86Acd1t.js} +1 -1
- package/dist/assets/{hash-Djdf0x1C.js → hash-D-OVfV3Z.js} +1 -1
- package/dist/assets/i18n-hM3v-3YG.js +1 -0
- package/dist/assets/index-CpxuJa9o.css +1 -0
- package/dist/assets/{index-DqSv8Azv.js → index-DHmCjcxq.js} +3 -3
- package/dist/assets/{label-Bvv4Mrea.js → label-CHJ1ATds.js} +1 -1
- package/dist/assets/loader-circle-C8cpaL0w.js +1 -0
- package/dist/assets/{logos-CGJJRI5_.js → logos-U1_qDA3U.js} +1 -1
- package/dist/assets/{page-layout-6Nm4Cnvr.js → page-layout-Z1klaUFW.js} +1 -1
- package/dist/assets/plus-CrkO1kob.js +1 -0
- package/dist/assets/{popover-b9rSYI6X.js → popover-xWbqMnIN.js} +1 -1
- package/dist/assets/{react-CDZz_StC.js → react-3YE87-lE.js} +1 -1
- package/dist/assets/{refresh-ccw-BvSSnnCw.js → refresh-ccw-JQh1lwq-.js} +1 -1
- package/dist/assets/{save-CAf0_-b9.js → save-4VRlzkii.js} +1 -1
- package/dist/assets/search-EX-Papzl.js +1 -0
- package/dist/assets/{security-config-DF66-l25.js → security-config-DEgOD4VX.js} +1 -1
- package/dist/assets/{select-CEIMqc0H.js → select-DF-AUoie.js} +1 -1
- package/dist/assets/skeleton-B0mmt1vo.js +1 -0
- package/dist/assets/{status-dot-CmQI5Qq2.js → status-dot-Bq_8Ojvv.js} +1 -1
- package/dist/assets/{switch-B7SxDXyR.js → switch-D7JF_RZ-.js} +1 -1
- package/dist/assets/{tabs-custom-Dxt6EJJW.js → tabs-custom-CLksZ2bO.js} +1 -1
- package/dist/assets/{trash-2-BnQ1PDTw.js → trash-2-VV8jvziy.js} +1 -1
- package/dist/assets/{useConfirmDialog-B-vMOmhG.js → useConfirmDialog-CuQqiPx7.js} +1 -1
- package/dist/assets/{useMutation-Bi39Z9_J.js → useMutation-DBTWPbTg.js} +1 -1
- package/dist/assets/x-B4sxJkGY.js +1 -0
- package/dist/index.html +18 -18
- package/package.json +4 -4
- package/src/api/agents.ts +9 -1
- package/src/api/types.ts +25 -1
- package/src/components/agents/AgentDialogs.tsx +400 -0
- package/src/components/agents/AgentsPage.test.tsx +148 -1
- package/src/components/agents/AgentsPage.tsx +114 -115
- package/src/components/chat/ChatConversationPanel.test.tsx +69 -3
- package/src/components/chat/ChatConversationPanel.tsx +24 -3
- package/src/components/chat/managers/chat-session-list.manager.test.ts +34 -3
- package/src/components/chat/managers/chat-session-list.manager.ts +17 -4
- package/src/components/chat/ncp/NcpChatPage.tsx +1 -0
- package/src/components/chat/ncp/ncp-chat.presenter.ts +5 -1
- package/src/components/chat/ncp/ncp-session-adapter.ts +6 -1
- package/src/components/chat/stores/chat-session-list.store.ts +6 -1
- package/src/components/chat/useChatSessionTypeState.ts +10 -2
- package/src/components/common/ProviderScopedModelInput.tsx +149 -0
- package/src/components/config/ChannelForm.test.tsx +60 -0
- package/src/components/config/ChannelForm.tsx +52 -12
- package/src/components/config/ModelConfig.test.tsx +61 -0
- package/src/components/config/ModelConfig.tsx +15 -90
- package/src/components/config/RuntimeConfig.tsx +2 -2
- package/src/components/config/SearchConfig.test.tsx +150 -0
- package/src/components/config/SearchConfig.tsx +257 -71
- package/src/components/config/runtime-config-agent.utils.ts +5 -4
- package/src/hooks/agents/useAgents.ts +18 -1
- package/src/lib/i18n.agents.ts +19 -0
- package/src/lib/i18n.search.ts +37 -0
- package/src/lib/i18n.ts +6 -26
- package/dist/assets/ChannelsList-DekMP4a3.js +0 -8
- package/dist/assets/ChatPage-Dgw4vlDt.js +0 -43
- package/dist/assets/DocBrowser-CExjX5is.js +0 -1
- package/dist/assets/MarketplacePage-DGfzg1LG.js +0 -1
- package/dist/assets/ModelConfig-C_49_a9v.js +0 -1
- package/dist/assets/RuntimeConfig-DBWzwoY-.js +0 -1
- package/dist/assets/SearchConfig-jSdwlH4b.js +0 -1
- package/dist/assets/chat-session-display-8yW6-mtm.js +0 -1
- package/dist/assets/i18n-DAekxt_G.js +0 -1
- package/dist/assets/index-CHEgQIiO.css +0 -1
- package/dist/assets/loader-circle-CGXXikVG.js +0 -1
- package/dist/assets/plus-CrW9BJDy.js +0 -1
- package/dist/assets/provider-models-IJDA940D.js +0 -1
- package/dist/assets/search-DgoXxocn.js +0 -1
- package/dist/assets/skeleton-BiPUQkOD.js +0 -1
- package/dist/assets/x-PBSiWt3l.js +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef } from 'react';
|
|
2
2
|
import type { Dispatch, SetStateAction } from 'react';
|
|
3
|
-
import type { ChatSessionTypeOptionView, SessionEntryView } from '@/api/types';
|
|
3
|
+
import type { AgentProfileView, ChatSessionTypeOptionView, SessionEntryView } from '@/api/types';
|
|
4
4
|
import { t } from '@/lib/i18n';
|
|
5
5
|
|
|
6
6
|
export const DEFAULT_SESSION_TYPE = 'native';
|
|
@@ -39,6 +39,14 @@ export function normalizeSessionType(value: unknown): string {
|
|
|
39
39
|
return normalized || DEFAULT_SESSION_TYPE;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export function resolveAgentRuntimeSessionType(
|
|
43
|
+
agent: Pick<AgentProfileView, 'runtime' | 'engine'> | null | undefined,
|
|
44
|
+
fallbackSessionType: string = DEFAULT_SESSION_TYPE
|
|
45
|
+
): string {
|
|
46
|
+
const runtime = agent?.runtime?.trim() || agent?.engine?.trim() || fallbackSessionType;
|
|
47
|
+
return normalizeSessionType(runtime);
|
|
48
|
+
}
|
|
49
|
+
|
|
42
50
|
export function resolveSessionTypeLabel(sessionType: string, fallbackLabel?: string): string {
|
|
43
51
|
if (sessionType === 'native') {
|
|
44
52
|
return t('chatSessionTypeNative');
|
|
@@ -55,7 +63,7 @@ export function resolveSessionTypeLabel(sessionType: string, fallbackLabel?: str
|
|
|
55
63
|
.join(' ') || sessionType;
|
|
56
64
|
}
|
|
57
65
|
|
|
58
|
-
function buildSessionTypeOptions(
|
|
66
|
+
export function buildSessionTypeOptions(
|
|
59
67
|
options: ChatSessionTypeOptionView[]
|
|
60
68
|
): ChatSessionTypeOption[] {
|
|
61
69
|
const deduped = new Map<string, ChatSessionTypeOption>();
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
3
|
+
import { SearchableModelInput } from '@/components/common/SearchableModelInput';
|
|
4
|
+
import { Input } from '@/components/ui/input';
|
|
5
|
+
import type { ProviderModelCatalogItem } from '@/lib/provider-models';
|
|
6
|
+
import { composeProviderModel, findProviderByModel, toProviderLocalModel } from '@/lib/provider-models';
|
|
7
|
+
import { t } from '@/lib/i18n';
|
|
8
|
+
|
|
9
|
+
type ProviderScopedModelInputProps = {
|
|
10
|
+
id?: string;
|
|
11
|
+
value: string;
|
|
12
|
+
onChange: (value: string) => void;
|
|
13
|
+
providerCatalog: ProviderModelCatalogItem[];
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
providerPlaceholder?: string;
|
|
16
|
+
modelPlaceholder?: string;
|
|
17
|
+
className?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const DEFAULT_MODEL_INPUT_PLACEHOLDER = 'provider/model';
|
|
21
|
+
|
|
22
|
+
function normalizeModelOptions(options: string[]): string[] {
|
|
23
|
+
const deduped = new Set<string>();
|
|
24
|
+
for (const option of options) {
|
|
25
|
+
const trimmed = option.trim();
|
|
26
|
+
if (trimmed) {
|
|
27
|
+
deduped.add(trimmed);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return [...deduped];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function ProviderScopedModelInput({
|
|
34
|
+
id,
|
|
35
|
+
value,
|
|
36
|
+
onChange,
|
|
37
|
+
providerCatalog,
|
|
38
|
+
disabled = false,
|
|
39
|
+
providerPlaceholder,
|
|
40
|
+
modelPlaceholder,
|
|
41
|
+
className
|
|
42
|
+
}: ProviderScopedModelInputProps) {
|
|
43
|
+
const [providerName, setProviderName] = useState('');
|
|
44
|
+
const [modelId, setModelId] = useState('');
|
|
45
|
+
const hasProviders = providerCatalog.length > 0;
|
|
46
|
+
const effectiveModelPlaceholder = modelPlaceholder ?? DEFAULT_MODEL_INPUT_PLACEHOLDER;
|
|
47
|
+
|
|
48
|
+
const providerMap = useMemo(
|
|
49
|
+
() => new Map(providerCatalog.map((provider) => [provider.name, provider])),
|
|
50
|
+
[providerCatalog]
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const selectedProvider = providerMap.get(providerName);
|
|
54
|
+
const selectedProviderAliases = useMemo(() => selectedProvider?.aliases ?? [], [selectedProvider]);
|
|
55
|
+
const selectedProviderModels = useMemo(
|
|
56
|
+
() => normalizeModelOptions(selectedProvider?.models ?? []),
|
|
57
|
+
[selectedProvider]
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const currentModel = value.trim();
|
|
62
|
+
if (!hasProviders) {
|
|
63
|
+
setProviderName('');
|
|
64
|
+
setModelId(currentModel);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const matchedProvider = findProviderByModel(currentModel, providerCatalog);
|
|
68
|
+
const effectiveProvider = matchedProvider ?? '';
|
|
69
|
+
const aliases = providerMap.get(effectiveProvider)?.aliases ?? [];
|
|
70
|
+
setProviderName(effectiveProvider);
|
|
71
|
+
setModelId(effectiveProvider ? toProviderLocalModel(currentModel, aliases) : currentModel);
|
|
72
|
+
}, [hasProviders, providerCatalog, providerMap, value]);
|
|
73
|
+
|
|
74
|
+
const handleProviderChange = (nextProvider: string) => {
|
|
75
|
+
setProviderName(nextProvider);
|
|
76
|
+
setModelId('');
|
|
77
|
+
onChange('');
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const handleModelChange = (nextModelId: string) => {
|
|
81
|
+
if (!selectedProvider) {
|
|
82
|
+
const trimmed = nextModelId.trim();
|
|
83
|
+
setModelId(trimmed);
|
|
84
|
+
onChange(trimmed);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const normalizedLocalModel = toProviderLocalModel(nextModelId, selectedProviderAliases);
|
|
88
|
+
setModelId(normalizedLocalModel);
|
|
89
|
+
onChange(normalizedLocalModel ? composeProviderModel(selectedProvider.prefix, normalizedLocalModel) : '');
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (!hasProviders) {
|
|
93
|
+
return (
|
|
94
|
+
<div className={className}>
|
|
95
|
+
<div className="rounded-2xl border border-amber-200 bg-amber-50/70 px-4 py-3">
|
|
96
|
+
<p className="text-sm font-semibold text-amber-950">{t('providersEmptyTitle')}</p>
|
|
97
|
+
<p className="mt-1 text-xs leading-5 text-amber-900">{t('providersEmptyDescription')}</p>
|
|
98
|
+
</div>
|
|
99
|
+
<Input
|
|
100
|
+
id={id}
|
|
101
|
+
value={value}
|
|
102
|
+
disabled={disabled}
|
|
103
|
+
onChange={(event) => onChange(event.target.value)}
|
|
104
|
+
placeholder={effectiveModelPlaceholder}
|
|
105
|
+
className="mt-3 h-10 rounded-xl"
|
|
106
|
+
/>
|
|
107
|
+
<p className="mt-2 text-xs text-gray-500">{t('modelInputCustomHint')}</p>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className={className}>
|
|
114
|
+
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
115
|
+
<div className="sm:w-[38%] sm:min-w-[170px]">
|
|
116
|
+
<Select value={providerName} onValueChange={handleProviderChange} disabled={disabled}>
|
|
117
|
+
<SelectTrigger className="h-10 w-full rounded-xl">
|
|
118
|
+
<SelectValue placeholder={providerPlaceholder ?? t('providersSelectPlaceholder')} />
|
|
119
|
+
</SelectTrigger>
|
|
120
|
+
<SelectContent>
|
|
121
|
+
{providerCatalog.map((provider) => (
|
|
122
|
+
<SelectItem key={provider.name} value={provider.name}>
|
|
123
|
+
{provider.displayName}
|
|
124
|
+
</SelectItem>
|
|
125
|
+
))}
|
|
126
|
+
</SelectContent>
|
|
127
|
+
</Select>
|
|
128
|
+
</div>
|
|
129
|
+
<span className="hidden h-10 items-center justify-center leading-none text-lg font-semibold text-gray-300 sm:inline-flex">
|
|
130
|
+
/
|
|
131
|
+
</span>
|
|
132
|
+
<SearchableModelInput
|
|
133
|
+
key={providerName}
|
|
134
|
+
id={id}
|
|
135
|
+
value={modelId}
|
|
136
|
+
onChange={handleModelChange}
|
|
137
|
+
options={selectedProviderModels}
|
|
138
|
+
disabled={disabled || !providerName}
|
|
139
|
+
placeholder={effectiveModelPlaceholder}
|
|
140
|
+
className="sm:flex-1"
|
|
141
|
+
inputClassName="h-10 rounded-xl"
|
|
142
|
+
emptyText={t('modelPickerNoOptions')}
|
|
143
|
+
createText={t('modelPickerUseCustom')}
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
<p className="mt-2 text-xs text-gray-500">{t('modelInputCustomHint')}</p>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { ChannelForm } from './ChannelForm';
|
|
3
|
+
|
|
4
|
+
vi.mock('@/hooks/useConfig', () => ({
|
|
5
|
+
useConfig: () => ({
|
|
6
|
+
data: {
|
|
7
|
+
channels: {
|
|
8
|
+
weixin: {
|
|
9
|
+
enabled: false
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}),
|
|
14
|
+
useConfigMeta: () => ({
|
|
15
|
+
data: {
|
|
16
|
+
channels: [
|
|
17
|
+
{
|
|
18
|
+
name: 'weixin',
|
|
19
|
+
displayName: 'Weixin',
|
|
20
|
+
enabled: false
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
}),
|
|
25
|
+
useConfigSchema: () => ({
|
|
26
|
+
data: {
|
|
27
|
+
uiHints: {},
|
|
28
|
+
actions: []
|
|
29
|
+
}
|
|
30
|
+
}),
|
|
31
|
+
useUpdateChannel: () => ({
|
|
32
|
+
mutate: vi.fn(),
|
|
33
|
+
mutateAsync: vi.fn(),
|
|
34
|
+
isPending: false
|
|
35
|
+
}),
|
|
36
|
+
useExecuteConfigAction: () => ({
|
|
37
|
+
mutateAsync: vi.fn(),
|
|
38
|
+
isPending: false
|
|
39
|
+
})
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
describe('ChannelForm', () => {
|
|
43
|
+
it('renders the empty selection state without entering a render loop', async () => {
|
|
44
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
45
|
+
|
|
46
|
+
render(<ChannelForm />);
|
|
47
|
+
|
|
48
|
+
expect(await screen.findByText('Select a channel from the left to configure')).toBeTruthy();
|
|
49
|
+
|
|
50
|
+
await waitFor(() => {
|
|
51
|
+
expect(
|
|
52
|
+
consoleErrorSpy.mock.calls.some((call) =>
|
|
53
|
+
call.some((entry) => typeof entry === 'string' && entry.includes('Maximum update depth exceeded'))
|
|
54
|
+
)
|
|
55
|
+
).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
consoleErrorSpy.mockRestore();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo, useState } from 'react';
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { useConfig, useConfigMeta, useConfigSchema, useUpdateChannel, useExecuteConfigAction } from '@/hooks/useConfig';
|
|
3
3
|
import { Button } from '@/components/ui/button';
|
|
4
4
|
import { StatusDot } from '@/components/ui/status-dot';
|
|
@@ -20,6 +20,9 @@ type ChannelFormProps = {
|
|
|
20
20
|
channelName?: string;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
const EMPTY_CHANNEL_FIELDS: ChannelField[] = [];
|
|
24
|
+
const DEFAULT_CHANNEL_LAYOUT_BLOCKS: ChannelFormBlock[] = [{ type: 'fields', section: 'all' }];
|
|
25
|
+
|
|
23
26
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
24
27
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
25
28
|
}
|
|
@@ -60,6 +63,31 @@ function resolveFieldsForSection(fields: ChannelField[], section: ChannelFormFie
|
|
|
60
63
|
return fields.filter((field) => field.section !== 'primary');
|
|
61
64
|
}
|
|
62
65
|
|
|
66
|
+
function buildJsonDrafts(channelConfig: Record<string, unknown>, fields: ChannelField[]): Record<string, string> {
|
|
67
|
+
const nextDrafts: Record<string, string> = {};
|
|
68
|
+
fields
|
|
69
|
+
.filter((field) => field.type === 'json')
|
|
70
|
+
.forEach((field) => {
|
|
71
|
+
nextDrafts[field.name] = JSON.stringify(channelConfig[field.name] ?? {}, null, 2);
|
|
72
|
+
});
|
|
73
|
+
return nextDrafts;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildChannelFormHydrationKey(
|
|
77
|
+
channelName: string | undefined,
|
|
78
|
+
channelConfig: Record<string, unknown> | null | undefined,
|
|
79
|
+
fields: ChannelField[]
|
|
80
|
+
): string {
|
|
81
|
+
if (!channelName || !channelConfig) {
|
|
82
|
+
return `empty:${channelName ?? ''}`;
|
|
83
|
+
}
|
|
84
|
+
return JSON.stringify({
|
|
85
|
+
channelName,
|
|
86
|
+
channelConfig,
|
|
87
|
+
jsonFields: fields.filter((field) => field.type === 'json').map((field) => field.name)
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
63
91
|
export function ChannelForm({ channelName }: ChannelFormProps) {
|
|
64
92
|
const { data: config } = useConfig();
|
|
65
93
|
const { data: meta } = useConfigMeta();
|
|
@@ -70,12 +98,13 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
|
|
|
70
98
|
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
|
71
99
|
const [jsonDrafts, setJsonDrafts] = useState<Record<string, string>>({});
|
|
72
100
|
const [runningActionId, setRunningActionId] = useState<string | null>(null);
|
|
101
|
+
const lastHydrationKeyRef = useRef<string | null>(null);
|
|
73
102
|
|
|
74
103
|
const channelConfig = channelName ? config?.channels[channelName] : null;
|
|
75
104
|
const channelDefinitions = useMemo(() => buildChannelFormDefinitions(), []);
|
|
76
105
|
const channelDefinition = channelName ? channelDefinitions[channelName] : undefined;
|
|
77
|
-
const fields = channelDefinition?.fields ??
|
|
78
|
-
const layoutBlocks = channelDefinition?.layout ??
|
|
106
|
+
const fields = channelDefinition?.fields ?? EMPTY_CHANNEL_FIELDS;
|
|
107
|
+
const layoutBlocks = channelDefinition?.layout ?? DEFAULT_CHANNEL_LAYOUT_BLOCKS;
|
|
79
108
|
const uiHints = schema?.uiHints;
|
|
80
109
|
const scope = channelName ? `channels.${channelName}` : null;
|
|
81
110
|
const actions = schema?.actions?.filter((action) => action.scope === scope) ?? [];
|
|
@@ -84,23 +113,22 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
|
|
|
84
113
|
: channelName;
|
|
85
114
|
const channelMeta = meta?.channels.find((item) => item.name === channelName);
|
|
86
115
|
const tutorialUrl = channelMeta ? resolveChannelTutorialUrl(channelMeta) : undefined;
|
|
116
|
+
const hydrationKey = buildChannelFormHydrationKey(channelName, channelConfig, fields);
|
|
87
117
|
|
|
88
118
|
useEffect(() => {
|
|
119
|
+
if (lastHydrationKeyRef.current === hydrationKey) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
lastHydrationKeyRef.current = hydrationKey;
|
|
123
|
+
|
|
89
124
|
if (channelConfig) {
|
|
90
125
|
setFormData({ ...channelConfig });
|
|
91
|
-
|
|
92
|
-
fields
|
|
93
|
-
.filter((field) => field.type === 'json')
|
|
94
|
-
.forEach((field) => {
|
|
95
|
-
const value = channelConfig[field.name];
|
|
96
|
-
nextDrafts[field.name] = JSON.stringify(value ?? {}, null, 2);
|
|
97
|
-
});
|
|
98
|
-
setJsonDrafts(nextDrafts);
|
|
126
|
+
setJsonDrafts(buildJsonDrafts(channelConfig, fields));
|
|
99
127
|
} else {
|
|
100
128
|
setFormData({});
|
|
101
129
|
setJsonDrafts({});
|
|
102
130
|
}
|
|
103
|
-
}, [channelConfig, fields]);
|
|
131
|
+
}, [channelConfig, fields, hydrationKey]);
|
|
104
132
|
|
|
105
133
|
const updateField = (name: string, value: unknown) => {
|
|
106
134
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
|
@@ -150,6 +178,18 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
|
|
|
150
178
|
return;
|
|
151
179
|
}
|
|
152
180
|
setFormData((prev) => deepMergeRecords(prev, channelPatch));
|
|
181
|
+
setJsonDrafts((prev) => {
|
|
182
|
+
let changed = false;
|
|
183
|
+
const nextDrafts = { ...prev };
|
|
184
|
+
for (const field of fields) {
|
|
185
|
+
if (field.type !== 'json' || !Object.prototype.hasOwnProperty.call(channelPatch, field.name)) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
nextDrafts[field.name] = JSON.stringify(channelPatch[field.name] ?? {}, null, 2);
|
|
189
|
+
changed = true;
|
|
190
|
+
}
|
|
191
|
+
return changed ? nextDrafts : prev;
|
|
192
|
+
});
|
|
153
193
|
};
|
|
154
194
|
|
|
155
195
|
const handleManualAction = async (action: ConfigActionManifest) => {
|
|
@@ -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 {
|
|
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 [
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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, {
|
|
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">
|