@nextclaw/ui 0.3.11 → 0.3.12

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.
@@ -0,0 +1,472 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { useConfig, useConfigSchema, useUpdateRuntime } from '@/hooks/useConfig';
3
+ import type { AgentBindingView, AgentProfileView } from '@/api/types';
4
+ import { Button } from '@/components/ui/button';
5
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
6
+ import { Input } from '@/components/ui/input';
7
+ import { Switch } from '@/components/ui/switch';
8
+ import { hintForPath } from '@/lib/config-hints';
9
+ import { Plus, Save, Trash2 } from 'lucide-react';
10
+ import { toast } from 'sonner';
11
+
12
+ type DmScope = 'main' | 'per-peer' | 'per-channel-peer' | 'per-account-channel-peer';
13
+ type PeerKind = '' | 'direct' | 'group' | 'channel';
14
+
15
+ const DM_SCOPE_OPTIONS: Array<{ value: DmScope; label: string }> = [
16
+ { value: 'main', label: 'main' },
17
+ { value: 'per-peer', label: 'per-peer' },
18
+ { value: 'per-channel-peer', label: 'per-channel-peer' },
19
+ { value: 'per-account-channel-peer', label: 'per-account-channel-peer' }
20
+ ];
21
+
22
+ function createEmptyAgent(): AgentProfileView {
23
+ return {
24
+ id: '',
25
+ default: false,
26
+ workspace: '',
27
+ model: '',
28
+ maxTokens: undefined,
29
+ maxToolIterations: undefined
30
+ };
31
+ }
32
+
33
+ function createEmptyBinding(): AgentBindingView {
34
+ return {
35
+ agentId: '',
36
+ match: {
37
+ channel: '',
38
+ accountId: ''
39
+ }
40
+ };
41
+ }
42
+
43
+ function parseOptionalInt(value: string): number | undefined {
44
+ const trimmed = value.trim();
45
+ if (!trimmed) {
46
+ return undefined;
47
+ }
48
+ const parsed = Number.parseInt(trimmed, 10);
49
+ return Number.isFinite(parsed) ? parsed : undefined;
50
+ }
51
+
52
+ export function RuntimeConfig() {
53
+ const { data: config, isLoading } = useConfig();
54
+ const { data: schema } = useConfigSchema();
55
+ const updateRuntime = useUpdateRuntime();
56
+
57
+ const [agents, setAgents] = useState<AgentProfileView[]>([]);
58
+ const [bindings, setBindings] = useState<AgentBindingView[]>([]);
59
+ const [dmScope, setDmScope] = useState<DmScope>('per-channel-peer');
60
+ const [maxPingPongTurns, setMaxPingPongTurns] = useState(0);
61
+
62
+ useEffect(() => {
63
+ if (!config) {
64
+ return;
65
+ }
66
+ setAgents(
67
+ (config.agents.list ?? []).map((agent) => ({
68
+ id: agent.id ?? '',
69
+ default: Boolean(agent.default),
70
+ workspace: agent.workspace ?? '',
71
+ model: agent.model ?? '',
72
+ maxTokens: agent.maxTokens,
73
+ maxToolIterations: agent.maxToolIterations
74
+ }))
75
+ );
76
+ setBindings(
77
+ (config.bindings ?? []).map((binding) => ({
78
+ agentId: binding.agentId ?? '',
79
+ match: {
80
+ channel: binding.match?.channel ?? '',
81
+ accountId: binding.match?.accountId ?? '',
82
+ peer: binding.match?.peer
83
+ ? {
84
+ kind: binding.match.peer.kind,
85
+ id: binding.match.peer.id
86
+ }
87
+ : undefined
88
+ }
89
+ }))
90
+ );
91
+ setDmScope((config.session?.dmScope as DmScope) ?? 'per-channel-peer');
92
+ setMaxPingPongTurns(config.session?.agentToAgent?.maxPingPongTurns ?? 0);
93
+ }, [config]);
94
+
95
+ const uiHints = schema?.uiHints;
96
+ const dmScopeHint = hintForPath('session.dmScope', uiHints);
97
+ const maxPingHint = hintForPath('session.agentToAgent.maxPingPongTurns', uiHints);
98
+ const agentsHint = hintForPath('agents.list', uiHints);
99
+ const bindingsHint = hintForPath('bindings', uiHints);
100
+
101
+ const knownAgentIds = useMemo(() => {
102
+ const ids = new Set<string>(['main']);
103
+ agents.forEach((agent) => {
104
+ const id = agent.id.trim();
105
+ if (id) {
106
+ ids.add(id);
107
+ }
108
+ });
109
+ return ids;
110
+ }, [agents]);
111
+
112
+ const updateAgent = (index: number, patch: Partial<AgentProfileView>) => {
113
+ setAgents((prev) => prev.map((agent, cursor) => (cursor === index ? { ...agent, ...patch } : agent)));
114
+ };
115
+
116
+ const updateBinding = (index: number, next: AgentBindingView) => {
117
+ setBindings((prev) => prev.map((binding, cursor) => (cursor === index ? next : binding)));
118
+ };
119
+
120
+ const handleSave = () => {
121
+ try {
122
+ const normalizedAgents = agents.map((agent, index) => {
123
+ const id = agent.id.trim();
124
+ if (!id) {
125
+ throw new Error(`agents.list[${index}].id is required`);
126
+ }
127
+ const normalized: AgentProfileView = { id };
128
+ if (agent.default) {
129
+ normalized.default = true;
130
+ }
131
+ if (agent.workspace?.trim()) {
132
+ normalized.workspace = agent.workspace.trim();
133
+ }
134
+ if (agent.model?.trim()) {
135
+ normalized.model = agent.model.trim();
136
+ }
137
+ if (typeof agent.maxTokens === 'number') {
138
+ normalized.maxTokens = agent.maxTokens;
139
+ }
140
+ if (typeof agent.maxToolIterations === 'number') {
141
+ normalized.maxToolIterations = agent.maxToolIterations;
142
+ }
143
+ return normalized;
144
+ });
145
+
146
+ const duplicates = normalizedAgents
147
+ .map((agent) => agent.id)
148
+ .filter((id, index, all) => all.indexOf(id) !== index);
149
+ if (duplicates.length > 0) {
150
+ toast.error(`Duplicate agent id: ${duplicates[0]}`);
151
+ return;
152
+ }
153
+
154
+ const normalizedBindings = bindings.map((binding, index) => {
155
+ const agentId = binding.agentId.trim();
156
+ const channel = binding.match.channel.trim();
157
+ const accountId = binding.match.accountId?.trim() ?? '';
158
+ const peerKind = binding.match.peer?.kind;
159
+ const peerId = binding.match.peer?.id?.trim() ?? '';
160
+
161
+ if (!agentId) {
162
+ throw new Error(`bindings[${index}].agentId is required`);
163
+ }
164
+ if (!knownAgentIds.has(agentId)) {
165
+ throw new Error(`bindings[${index}].agentId '${agentId}' not found in agents.list/main`);
166
+ }
167
+ if (!channel) {
168
+ throw new Error(`bindings[${index}].match.channel is required`);
169
+ }
170
+
171
+ const normalized: AgentBindingView = {
172
+ agentId,
173
+ match: {
174
+ channel
175
+ }
176
+ };
177
+
178
+ if (accountId) {
179
+ normalized.match.accountId = accountId;
180
+ }
181
+
182
+ if (peerKind) {
183
+ if (!peerId) {
184
+ throw new Error(`bindings[${index}].match.peer.id is required when peer.kind is set`);
185
+ }
186
+ normalized.match.peer = {
187
+ kind: peerKind,
188
+ id: peerId
189
+ };
190
+ }
191
+
192
+ return normalized;
193
+ });
194
+
195
+ updateRuntime.mutate({
196
+ data: {
197
+ agents: { list: normalizedAgents },
198
+ bindings: normalizedBindings,
199
+ session: {
200
+ dmScope,
201
+ agentToAgent: {
202
+ maxPingPongTurns: Math.min(5, Math.max(0, maxPingPongTurns))
203
+ }
204
+ }
205
+ }
206
+ });
207
+ } catch (error) {
208
+ const message = error instanceof Error ? error.message : String(error);
209
+ toast.error(message);
210
+ }
211
+ };
212
+
213
+ if (isLoading || !config) {
214
+ return <div className="p-8 text-gray-400">Loading runtime settings...</div>;
215
+ }
216
+
217
+ return (
218
+ <div className="space-y-6 pb-20 animate-fade-in">
219
+ <div>
220
+ <h2 className="text-2xl font-bold text-gray-900">Routing & Runtime</h2>
221
+ <p className="text-sm text-gray-500 mt-1">
222
+ Align multi-agent routing with OpenClaw: bindings, agent pool, and DM scope.
223
+ </p>
224
+ </div>
225
+
226
+ <Card>
227
+ <CardHeader>
228
+ <CardTitle>{dmScopeHint?.label ?? 'DM Scope'}</CardTitle>
229
+ <CardDescription>{dmScopeHint?.help ?? 'Control how direct-message sessions are isolated.'}</CardDescription>
230
+ </CardHeader>
231
+ <CardContent className="space-y-4">
232
+ <div className="space-y-2">
233
+ <label className="text-sm font-medium text-gray-800">{dmScopeHint?.label ?? 'DM Scope'}</label>
234
+ <select
235
+ value={dmScope}
236
+ onChange={(event) => setDmScope(event.target.value as DmScope)}
237
+ className="flex h-10 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm"
238
+ >
239
+ {DM_SCOPE_OPTIONS.map((option) => (
240
+ <option key={option.value} value={option.value}>
241
+ {option.label}
242
+ </option>
243
+ ))}
244
+ </select>
245
+ </div>
246
+ <div className="space-y-2">
247
+ <label className="text-sm font-medium text-gray-800">
248
+ {maxPingHint?.label ?? 'Max Ping-Pong Turns'}
249
+ </label>
250
+ <Input
251
+ type="number"
252
+ min={0}
253
+ max={5}
254
+ value={maxPingPongTurns}
255
+ onChange={(event) => setMaxPingPongTurns(Math.max(0, Number.parseInt(event.target.value, 10) || 0))}
256
+ />
257
+ <p className="text-xs text-gray-500">
258
+ {maxPingHint?.help ?? 'Set to 0 to block automatic agent-to-agent ping-pong loops.'}
259
+ </p>
260
+ </div>
261
+ </CardContent>
262
+ </Card>
263
+
264
+ <Card>
265
+ <CardHeader>
266
+ <CardTitle>{agentsHint?.label ?? 'Agent List'}</CardTitle>
267
+ <CardDescription>{agentsHint?.help ?? 'Run multiple fixed-role agents in one gateway process.'}</CardDescription>
268
+ </CardHeader>
269
+ <CardContent className="space-y-3">
270
+ {agents.map((agent, index) => (
271
+ <div key={`${index}-${agent.id}`} className="rounded-xl border border-gray-200 p-3 space-y-3">
272
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
273
+ <Input
274
+ value={agent.id}
275
+ onChange={(event) => updateAgent(index, { id: event.target.value })}
276
+ placeholder="Agent ID (e.g. engineer)"
277
+ />
278
+ <Input
279
+ value={agent.workspace ?? ''}
280
+ onChange={(event) => updateAgent(index, { workspace: event.target.value })}
281
+ placeholder="Workspace override (optional)"
282
+ />
283
+ <Input
284
+ value={agent.model ?? ''}
285
+ onChange={(event) => updateAgent(index, { model: event.target.value })}
286
+ placeholder="Model override (optional)"
287
+ />
288
+ <div className="grid grid-cols-2 gap-2">
289
+ <Input
290
+ type="number"
291
+ min={1}
292
+ value={agent.maxTokens ?? ''}
293
+ onChange={(event) =>
294
+ updateAgent(index, {
295
+ maxTokens: parseOptionalInt(event.target.value)
296
+ })
297
+ }
298
+ placeholder="Max tokens"
299
+ />
300
+ <Input
301
+ type="number"
302
+ min={1}
303
+ value={agent.maxToolIterations ?? ''}
304
+ onChange={(event) =>
305
+ updateAgent(index, {
306
+ maxToolIterations: parseOptionalInt(event.target.value)
307
+ })
308
+ }
309
+ placeholder="Max tools"
310
+ />
311
+ </div>
312
+ </div>
313
+ <div className="flex items-center justify-between">
314
+ <div className="flex items-center gap-2 text-sm text-gray-600">
315
+ <Switch
316
+ checked={Boolean(agent.default)}
317
+ onCheckedChange={(checked) => {
318
+ if (!checked) {
319
+ updateAgent(index, { default: false });
320
+ return;
321
+ }
322
+ setAgents((prev) =>
323
+ prev.map((entry, cursor) => ({
324
+ ...entry,
325
+ default: cursor === index
326
+ }))
327
+ );
328
+ }}
329
+ />
330
+ <span>Default agent</span>
331
+ </div>
332
+ <Button type="button" variant="outline" size="sm" onClick={() => setAgents((prev) => prev.filter((_, cursor) => cursor !== index))}>
333
+ <Trash2 className="h-4 w-4 mr-1" />
334
+ Remove
335
+ </Button>
336
+ </div>
337
+ </div>
338
+ ))}
339
+
340
+ <Button type="button" variant="outline" onClick={() => setAgents((prev) => [...prev, createEmptyAgent()])}>
341
+ <Plus className="h-4 w-4 mr-2" />
342
+ Add Agent
343
+ </Button>
344
+ </CardContent>
345
+ </Card>
346
+
347
+ <Card>
348
+ <CardHeader>
349
+ <CardTitle>{bindingsHint?.label ?? 'Bindings'}</CardTitle>
350
+ <CardDescription>
351
+ {bindingsHint?.help ?? 'Route inbound message by channel + account + peer to target agent.'}
352
+ </CardDescription>
353
+ </CardHeader>
354
+ <CardContent className="space-y-3">
355
+ {bindings.map((binding, index) => {
356
+ const peerKind = (binding.match.peer?.kind ?? '') as PeerKind;
357
+ return (
358
+ <div key={`${index}-${binding.agentId}`} className="rounded-xl border border-gray-200 p-3 space-y-3">
359
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
360
+ <Input
361
+ value={binding.agentId}
362
+ onChange={(event) => updateBinding(index, { ...binding, agentId: event.target.value })}
363
+ placeholder="Target agent ID"
364
+ />
365
+ <Input
366
+ value={binding.match.channel}
367
+ onChange={(event) =>
368
+ updateBinding(index, {
369
+ ...binding,
370
+ match: {
371
+ ...binding.match,
372
+ channel: event.target.value
373
+ }
374
+ })
375
+ }
376
+ placeholder="Channel (e.g. discord)"
377
+ />
378
+ <Input
379
+ value={binding.match.accountId ?? ''}
380
+ onChange={(event) =>
381
+ updateBinding(index, {
382
+ ...binding,
383
+ match: {
384
+ ...binding.match,
385
+ accountId: event.target.value
386
+ }
387
+ })
388
+ }
389
+ placeholder="Account ID (optional)"
390
+ />
391
+ <select
392
+ value={peerKind}
393
+ onChange={(event) => {
394
+ const nextKind = event.target.value as PeerKind;
395
+ if (!nextKind) {
396
+ updateBinding(index, {
397
+ ...binding,
398
+ match: {
399
+ ...binding.match,
400
+ peer: undefined
401
+ }
402
+ });
403
+ return;
404
+ }
405
+ updateBinding(index, {
406
+ ...binding,
407
+ match: {
408
+ ...binding.match,
409
+ peer: {
410
+ kind: nextKind,
411
+ id: binding.match.peer?.id ?? ''
412
+ }
413
+ }
414
+ });
415
+ }}
416
+ className="flex h-10 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm"
417
+ >
418
+ <option value="">Peer kind (optional)</option>
419
+ <option value="direct">direct</option>
420
+ <option value="group">group</option>
421
+ <option value="channel">channel</option>
422
+ </select>
423
+ <Input
424
+ value={binding.match.peer?.id ?? ''}
425
+ onChange={(event) =>
426
+ updateBinding(index, {
427
+ ...binding,
428
+ match: {
429
+ ...binding.match,
430
+ peer: peerKind
431
+ ? {
432
+ kind: peerKind,
433
+ id: event.target.value
434
+ }
435
+ : undefined
436
+ }
437
+ })
438
+ }
439
+ placeholder="Peer ID (requires peer kind)"
440
+ />
441
+ </div>
442
+ <div className="flex justify-end">
443
+ <Button
444
+ type="button"
445
+ variant="outline"
446
+ size="sm"
447
+ onClick={() => setBindings((prev) => prev.filter((_, cursor) => cursor !== index))}
448
+ >
449
+ <Trash2 className="h-4 w-4 mr-1" />
450
+ Remove
451
+ </Button>
452
+ </div>
453
+ </div>
454
+ );
455
+ })}
456
+
457
+ <Button type="button" variant="outline" onClick={() => setBindings((prev) => [...prev, createEmptyBinding()])}>
458
+ <Plus className="h-4 w-4 mr-2" />
459
+ Add Binding
460
+ </Button>
461
+ </CardContent>
462
+ </Card>
463
+
464
+ <div className="flex justify-end">
465
+ <Button type="button" onClick={handleSave} disabled={updateRuntime.isPending}>
466
+ <Save className="h-4 w-4 mr-2" />
467
+ {updateRuntime.isPending ? 'Saving...' : 'Save Runtime Settings'}
468
+ </Button>
469
+ </div>
470
+ </div>
471
+ );
472
+ }
@@ -1,6 +1,6 @@
1
1
  import { useUiStore } from '@/stores/ui.store';
2
2
  import { cn } from '@/lib/utils';
3
- import { Cpu, MessageSquare, Sparkles } from 'lucide-react';
3
+ import { Cpu, GitBranch, MessageSquare, Sparkles } from 'lucide-react';
4
4
 
5
5
  const navItems = [
6
6
  {
@@ -17,6 +17,11 @@ const navItems = [
17
17
  id: 'channels' as const,
18
18
  label: 'Channels',
19
19
  icon: MessageSquare,
20
+ },
21
+ {
22
+ id: 'runtime' as const,
23
+ label: 'Routing & Runtime',
24
+ icon: GitBranch,
20
25
  }
21
26
  ];
22
27
 
@@ -6,6 +6,7 @@ import {
6
6
  updateModel,
7
7
  updateProvider,
8
8
  updateChannel,
9
+ updateRuntime,
9
10
  executeConfigAction
10
11
  } from '@/api/config';
11
12
  import { toast } from 'sonner';
@@ -83,6 +84,22 @@ export function useUpdateChannel() {
83
84
  });
84
85
  }
85
86
 
87
+ export function useUpdateRuntime() {
88
+ const queryClient = useQueryClient();
89
+
90
+ return useMutation({
91
+ mutationFn: ({ data }: { data: unknown }) =>
92
+ updateRuntime(data as Parameters<typeof updateRuntime>[0]),
93
+ onSuccess: () => {
94
+ queryClient.invalidateQueries({ queryKey: ['config'] });
95
+ toast.success(t('configSavedApplied'));
96
+ },
97
+ onError: (error: Error) => {
98
+ toast.error(t('configSaveFailed') + ': ' + error.message);
99
+ }
100
+ });
101
+ }
102
+
86
103
  export function useExecuteConfigAction() {
87
104
  return useMutation({
88
105
  mutationFn: ({ actionId, data }: { actionId: string; data: unknown }) =>
@@ -4,7 +4,7 @@ type ConnectionStatus = 'connected' | 'disconnected' | 'connecting';
4
4
 
5
5
  interface UiState {
6
6
  // Active configuration tab
7
- activeTab: 'model' | 'providers' | 'channels';
7
+ activeTab: 'model' | 'providers' | 'channels' | 'runtime';
8
8
  setActiveTab: (tab: UiState['activeTab']) => void;
9
9
 
10
10
  // Connection status