@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.
- package/CHANGELOG.md +38 -0
- package/dist/assets/ChannelsList-uKmkpD25.js +8 -0
- package/dist/assets/ChatPage-CslhBPfT.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-BZQW70ti.js +1 -0
- package/dist/assets/{MarketplacePage-BlIeNn3x.js → MarketplacePage-DE0QjYVv.js} +1 -1
- package/dist/assets/{McpMarketplacePage-mz2_IX1O.js → McpMarketplacePage-CeLvv1xy.js} +1 -1
- package/dist/assets/ModelConfig-D1JtGtQv.js +1 -0
- package/dist/assets/ProviderScopedModelInput-SAJH6nkC.js +1 -0
- package/dist/assets/{ProvidersList-B0RCb_Vg.js → ProvidersList-1rKi3aQT.js} +1 -1
- package/dist/assets/{RemoteAccessPage-CcfQjLtx.js → RemoteAccessPage-bIAKxDky.js} +1 -1
- package/dist/assets/RuntimeConfig-BTk9319O.js +1 -0
- package/dist/assets/SearchConfig-EjeszXbv.js +1 -0
- package/dist/assets/{SecretsConfig-DbiS3txa.js → SecretsConfig-cnAXvREZ.js} +1 -1
- package/dist/assets/{SessionsConfig-CbIOcAp8.js → SessionsConfig-BIXiDaK2.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-DqSv8Azv.js → index-8XNPYwJu.js} +3 -3
- package/dist/assets/index-CpxuJa9o.css +1 -0
- 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-CGazBahs.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-D6HxybcM.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 +5 -5
- 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 +112 -1
- package/src/components/agents/AgentsPage.tsx +104 -112
- package/src/components/chat/ncp/NcpChatPage.tsx +1 -0
- package/src/components/chat/ncp/ncp-session-adapter.ts +6 -1
- package/src/components/chat/useChatSessionTypeState.ts +1 -1
- 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,39 +1,29 @@
|
|
|
1
1
|
import { useMemo, useState } from 'react';
|
|
2
2
|
import { useNavigate } from 'react-router-dom';
|
|
3
|
-
import { useCreateAgent, useDeleteAgent, useAgents } from '@/hooks/agents/useAgents';
|
|
3
|
+
import { useCreateAgent, useDeleteAgent, useAgents, useUpdateAgent } from '@/hooks/agents/useAgents';
|
|
4
|
+
import { useConfig, useConfigMeta } from '@/hooks/useConfig';
|
|
5
|
+
import type { AgentProfileView } from '@/api/types';
|
|
6
|
+
import {
|
|
7
|
+
AgentCreateDialog,
|
|
8
|
+
AgentEditDialog,
|
|
9
|
+
type AgentCreateFormState,
|
|
10
|
+
type AgentEditFormState
|
|
11
|
+
} from '@/components/agents/AgentDialogs';
|
|
12
|
+
import {
|
|
13
|
+
buildSessionTypeOptions,
|
|
14
|
+
normalizeSessionType,
|
|
15
|
+
resolveSessionTypeLabel
|
|
16
|
+
} from '@/components/chat/useChatSessionTypeState';
|
|
4
17
|
import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
|
|
5
18
|
import { AgentAvatar } from '@/components/common/AgentAvatar';
|
|
6
19
|
import { Button } from '@/components/ui/button';
|
|
7
20
|
import { Card, CardContent } from '@/components/ui/card';
|
|
8
|
-
import {
|
|
9
|
-
Dialog,
|
|
10
|
-
DialogContent,
|
|
11
|
-
DialogDescription,
|
|
12
|
-
DialogFooter,
|
|
13
|
-
DialogHeader,
|
|
14
|
-
DialogTitle
|
|
15
|
-
} from '@/components/ui/dialog';
|
|
16
|
-
import { Input } from '@/components/ui/input';
|
|
21
|
+
import { useNcpChatSessionTypes } from '@/hooks/use-ncp-chat-session-types';
|
|
17
22
|
import { PageLayout } from '@/components/layout/page-layout';
|
|
18
23
|
import { t } from '@/lib/i18n';
|
|
24
|
+
import { buildProviderModelCatalog } from '@/lib/provider-models';
|
|
19
25
|
import { cn } from '@/lib/utils';
|
|
20
|
-
import { Bot, House, MessageCircle, Plus, ShieldCheck, Sparkles, Trash2 } from 'lucide-react';
|
|
21
|
-
|
|
22
|
-
type CreateFormState = {
|
|
23
|
-
id: string;
|
|
24
|
-
displayName: string;
|
|
25
|
-
description: string;
|
|
26
|
-
avatar: string;
|
|
27
|
-
home: string;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
const EMPTY_FORM: CreateFormState = {
|
|
31
|
-
id: '',
|
|
32
|
-
displayName: '',
|
|
33
|
-
description: '',
|
|
34
|
-
avatar: '',
|
|
35
|
-
home: ''
|
|
36
|
-
};
|
|
26
|
+
import { Bot, House, MessageCircle, Pencil, Plus, ShieldCheck, Sparkles, Trash2 } from 'lucide-react';
|
|
37
27
|
|
|
38
28
|
const CARD_TONES = [
|
|
39
29
|
{
|
|
@@ -63,10 +53,14 @@ function resolveAgentTone(index: number, builtIn: boolean) {
|
|
|
63
53
|
export function AgentsPage() {
|
|
64
54
|
const navigate = useNavigate();
|
|
65
55
|
const agentsQuery = useAgents();
|
|
56
|
+
const configQuery = useConfig();
|
|
57
|
+
const configMetaQuery = useConfigMeta();
|
|
58
|
+
const sessionTypesQuery = useNcpChatSessionTypes();
|
|
66
59
|
const createAgent = useCreateAgent();
|
|
60
|
+
const updateAgent = useUpdateAgent();
|
|
67
61
|
const deleteAgent = useDeleteAgent();
|
|
68
62
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
|
69
|
-
const [
|
|
63
|
+
const [editingAgent, setEditingAgent] = useState<AgentProfileView | null>(null);
|
|
70
64
|
const setSessionListSnapshot = useChatSessionListStore((state) => state.setSnapshot);
|
|
71
65
|
|
|
72
66
|
const agents = useMemo(() => agentsQuery.data?.agents ?? [], [agentsQuery.data?.agents]);
|
|
@@ -79,21 +73,56 @@ export function AgentsPage() {
|
|
|
79
73
|
),
|
|
80
74
|
[agents]
|
|
81
75
|
);
|
|
76
|
+
const providerCatalog = useMemo(
|
|
77
|
+
() => buildProviderModelCatalog({ config: configQuery.data, meta: configMetaQuery.data, onlyConfigured: true }),
|
|
78
|
+
[configMetaQuery.data, configQuery.data]
|
|
79
|
+
);
|
|
80
|
+
const runtimeOptions = useMemo(
|
|
81
|
+
() => buildSessionTypeOptions(sessionTypesQuery.data?.options ?? []),
|
|
82
|
+
[sessionTypesQuery.data?.options]
|
|
83
|
+
);
|
|
84
|
+
const defaultRuntime = useMemo(
|
|
85
|
+
() => normalizeSessionType(sessionTypesQuery.data?.defaultType ?? 'native'),
|
|
86
|
+
[sessionTypesQuery.data?.defaultType]
|
|
87
|
+
);
|
|
88
|
+
const defaultRuntimeLabel = useMemo(
|
|
89
|
+
() => runtimeOptions.find((option) => option.value === defaultRuntime)?.label ?? resolveSessionTypeLabel(defaultRuntime),
|
|
90
|
+
[defaultRuntime, runtimeOptions]
|
|
91
|
+
);
|
|
82
92
|
|
|
83
|
-
const handleCreate = async () => {
|
|
93
|
+
const handleCreate = async (form: AgentCreateFormState) => {
|
|
84
94
|
await createAgent.mutateAsync({
|
|
85
95
|
data: {
|
|
86
96
|
id: form.id,
|
|
87
97
|
...(form.displayName.trim() ? { displayName: form.displayName.trim() } : {}),
|
|
88
98
|
...(form.description.trim() ? { description: form.description.trim() } : {}),
|
|
89
99
|
...(form.avatar.trim() ? { avatar: form.avatar.trim() } : {}),
|
|
90
|
-
...(form.home.trim() ? { home: form.home.trim() } : {})
|
|
100
|
+
...(form.home.trim() ? { home: form.home.trim() } : {}),
|
|
101
|
+
...(form.model.trim() ? { model: form.model.trim() } : {}),
|
|
102
|
+
...(form.runtime.trim() ? { runtime: form.runtime.trim() } : {})
|
|
91
103
|
}
|
|
92
104
|
});
|
|
93
|
-
setForm(EMPTY_FORM);
|
|
94
105
|
setIsCreateDialogOpen(false);
|
|
95
106
|
};
|
|
96
107
|
|
|
108
|
+
const handleStartEdit = (agent: AgentProfileView) => {
|
|
109
|
+
setEditingAgent(agent);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const handleUpdate = async (agentId: string, form: AgentEditFormState) => {
|
|
113
|
+
await updateAgent.mutateAsync({
|
|
114
|
+
agentId,
|
|
115
|
+
data: {
|
|
116
|
+
displayName: form.displayName,
|
|
117
|
+
description: form.description,
|
|
118
|
+
avatar: form.avatar,
|
|
119
|
+
model: form.model,
|
|
120
|
+
...(form.runtime.trim() ? { runtime: form.runtime.trim() } : { runtime: "" })
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
setEditingAgent(null);
|
|
124
|
+
};
|
|
125
|
+
|
|
97
126
|
const startChatWithAgent = (agentId: string) => {
|
|
98
127
|
setSessionListSnapshot({
|
|
99
128
|
selectedAgentId: agentId,
|
|
@@ -178,6 +207,11 @@ export function AgentsPage() {
|
|
|
178
207
|
) : (
|
|
179
208
|
sortedAgents.map((agent, index) => {
|
|
180
209
|
const tone = resolveAgentTone(index, Boolean(agent.builtIn));
|
|
210
|
+
const runtimeValue = agent.runtime?.trim() || agent.engine?.trim() || '';
|
|
211
|
+
const runtimeLabel = runtimeValue
|
|
212
|
+
? runtimeOptions.find((option) => option.value === normalizeSessionType(runtimeValue))?.label ??
|
|
213
|
+
resolveSessionTypeLabel(runtimeValue)
|
|
214
|
+
: defaultRuntimeLabel;
|
|
181
215
|
return (
|
|
182
216
|
<Card
|
|
183
217
|
key={agent.id}
|
|
@@ -223,6 +257,16 @@ export function AgentsPage() {
|
|
|
223
257
|
</p>
|
|
224
258
|
|
|
225
259
|
<div className="mt-auto flex flex-col gap-4">
|
|
260
|
+
<div>
|
|
261
|
+
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#94a3b8]">
|
|
262
|
+
<Sparkles className="h-3.5 w-3.5" />
|
|
263
|
+
{t('agentsCardRuntimeLabel')}
|
|
264
|
+
</div>
|
|
265
|
+
<div className="mt-1.5 text-sm leading-6 text-[#475569]">
|
|
266
|
+
{runtimeLabel}
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
226
270
|
<div className="border-t border-gray-100 pt-3">
|
|
227
271
|
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#94a3b8]">
|
|
228
272
|
<House className="h-3.5 w-3.5" />
|
|
@@ -242,6 +286,16 @@ export function AgentsPage() {
|
|
|
242
286
|
<MessageCircle className="mr-2 h-4 w-4" />
|
|
243
287
|
{t('agentsCardStartChat')}
|
|
244
288
|
</Button>
|
|
289
|
+
<Button
|
|
290
|
+
type="button"
|
|
291
|
+
variant="ghost"
|
|
292
|
+
className="h-8 rounded-xl px-3 text-xs text-[#7b8794] hover:bg-[#f3f4f6] hover:text-[#475569]"
|
|
293
|
+
onClick={() => handleStartEdit(agent)}
|
|
294
|
+
disabled={updateAgent.isPending}
|
|
295
|
+
>
|
|
296
|
+
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
|
297
|
+
{t('agentsEditAction')}
|
|
298
|
+
</Button>
|
|
245
299
|
{!agent.builtIn ? (
|
|
246
300
|
<Button
|
|
247
301
|
type="button"
|
|
@@ -263,91 +317,29 @@ export function AgentsPage() {
|
|
|
263
317
|
)}
|
|
264
318
|
</div>
|
|
265
319
|
|
|
266
|
-
<
|
|
320
|
+
<AgentCreateDialog
|
|
267
321
|
open={isCreateDialogOpen}
|
|
322
|
+
pending={createAgent.isPending}
|
|
323
|
+
providerCatalog={providerCatalog}
|
|
324
|
+
runtimeOptions={runtimeOptions}
|
|
325
|
+
defaultRuntime={defaultRuntime}
|
|
326
|
+
onOpenChange={setIsCreateDialogOpen}
|
|
327
|
+
onSubmit={handleCreate}
|
|
328
|
+
/>
|
|
329
|
+
|
|
330
|
+
<AgentEditDialog
|
|
331
|
+
agent={editingAgent}
|
|
332
|
+
pending={updateAgent.isPending}
|
|
333
|
+
providerCatalog={providerCatalog}
|
|
334
|
+
runtimeOptions={runtimeOptions}
|
|
335
|
+
defaultRuntime={defaultRuntime}
|
|
268
336
|
onOpenChange={(open) => {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
setForm(EMPTY_FORM);
|
|
337
|
+
if (!open && !updateAgent.isPending) {
|
|
338
|
+
setEditingAgent(null);
|
|
272
339
|
}
|
|
273
340
|
}}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
<div className="border-b border-[#f0e2c8] px-6 py-6">
|
|
277
|
-
<DialogHeader className="text-left">
|
|
278
|
-
<DialogTitle>{t('agentsCreateDialogTitle')}</DialogTitle>
|
|
279
|
-
<DialogDescription>{t('agentsCreateDialogDescription')}</DialogDescription>
|
|
280
|
-
</DialogHeader>
|
|
281
|
-
</div>
|
|
282
|
-
<div className="space-y-4 px-6 py-6">
|
|
283
|
-
<div className="grid gap-4 md:grid-cols-2">
|
|
284
|
-
<Input
|
|
285
|
-
value={form.id}
|
|
286
|
-
onChange={(event) =>
|
|
287
|
-
setForm((prev) => ({ ...prev, id: event.target.value }))
|
|
288
|
-
}
|
|
289
|
-
placeholder={t('agentsFormIdPlaceholder')}
|
|
290
|
-
/>
|
|
291
|
-
<Input
|
|
292
|
-
value={form.displayName}
|
|
293
|
-
onChange={(event) =>
|
|
294
|
-
setForm((prev) => ({
|
|
295
|
-
...prev,
|
|
296
|
-
displayName: event.target.value
|
|
297
|
-
}))
|
|
298
|
-
}
|
|
299
|
-
placeholder={t('agentsFormNamePlaceholder')}
|
|
300
|
-
/>
|
|
301
|
-
<Input
|
|
302
|
-
value={form.description}
|
|
303
|
-
onChange={(event) =>
|
|
304
|
-
setForm((prev) => ({
|
|
305
|
-
...prev,
|
|
306
|
-
description: event.target.value
|
|
307
|
-
}))
|
|
308
|
-
}
|
|
309
|
-
placeholder={t('agentsFormDescriptionPlaceholder')}
|
|
310
|
-
/>
|
|
311
|
-
<Input
|
|
312
|
-
value={form.avatar}
|
|
313
|
-
onChange={(event) =>
|
|
314
|
-
setForm((prev) => ({ ...prev, avatar: event.target.value }))
|
|
315
|
-
}
|
|
316
|
-
placeholder={t('agentsFormAvatarPlaceholder')}
|
|
317
|
-
/>
|
|
318
|
-
<Input
|
|
319
|
-
value={form.home}
|
|
320
|
-
onChange={(event) =>
|
|
321
|
-
setForm((prev) => ({ ...prev, home: event.target.value }))
|
|
322
|
-
}
|
|
323
|
-
placeholder={t('agentsFormHomePlaceholder')}
|
|
324
|
-
/>
|
|
325
|
-
</div>
|
|
326
|
-
<div className="rounded-2xl border border-[#efe3ca] bg-[#fff9ef] px-4 py-3 text-xs leading-6 text-[#7a6246]">
|
|
327
|
-
{t('agentsCreateDialogHint')}
|
|
328
|
-
</div>
|
|
329
|
-
</div>
|
|
330
|
-
<DialogFooter className="border-t border-[#f1e7d4] px-6 py-5">
|
|
331
|
-
<Button
|
|
332
|
-
type="button"
|
|
333
|
-
variant="ghost"
|
|
334
|
-
onClick={() => setIsCreateDialogOpen(false)}
|
|
335
|
-
disabled={createAgent.isPending}
|
|
336
|
-
>
|
|
337
|
-
{t('cancel')}
|
|
338
|
-
</Button>
|
|
339
|
-
<Button
|
|
340
|
-
type="button"
|
|
341
|
-
className="rounded-2xl bg-[#1f5c4d] px-5 text-white hover:bg-[#184d40]"
|
|
342
|
-
onClick={() => void handleCreate()}
|
|
343
|
-
disabled={createAgent.isPending || form.id.trim().length === 0}
|
|
344
|
-
>
|
|
345
|
-
<Plus className="mr-2 h-4 w-4" />
|
|
346
|
-
{t('agentsCreateAction')}
|
|
347
|
-
</Button>
|
|
348
|
-
</DialogFooter>
|
|
349
|
-
</DialogContent>
|
|
350
|
-
</Dialog>
|
|
341
|
+
onSubmit={handleUpdate}
|
|
342
|
+
/>
|
|
351
343
|
</PageLayout>
|
|
352
344
|
);
|
|
353
345
|
}
|
|
@@ -57,6 +57,7 @@ export function buildNcpSendMetadata(payload: {
|
|
|
57
57
|
}
|
|
58
58
|
if (payload.sessionType?.trim()) {
|
|
59
59
|
metadata.session_type = payload.sessionType.trim();
|
|
60
|
+
metadata.runtime = payload.sessionType.trim();
|
|
60
61
|
}
|
|
61
62
|
if (payload.agentId?.trim()) {
|
|
62
63
|
metadata.agent_id = payload.agentId.trim();
|
|
@@ -85,7 +85,12 @@ function readNcpSessionType(summary: NcpSessionSummaryView): string {
|
|
|
85
85
|
if (!metadata) {
|
|
86
86
|
return 'native';
|
|
87
87
|
}
|
|
88
|
-
return
|
|
88
|
+
return (
|
|
89
|
+
readOptionalString(metadata.runtime) ??
|
|
90
|
+
readOptionalString(metadata.session_type) ??
|
|
91
|
+
readOptionalString(metadata.sessionType) ??
|
|
92
|
+
'native'
|
|
93
|
+
);
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
function readNcpParentSessionId(summary: NcpSessionSummaryView): string | null {
|
|
@@ -55,7 +55,7 @@ export function resolveSessionTypeLabel(sessionType: string, fallbackLabel?: str
|
|
|
55
55
|
.join(' ') || sessionType;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
function buildSessionTypeOptions(
|
|
58
|
+
export function buildSessionTypeOptions(
|
|
59
59
|
options: ChatSessionTypeOptionView[]
|
|
60
60
|
): ChatSessionTypeOption[] {
|
|
61
61
|
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) => {
|