@nextclaw/ui 0.3.11 → 0.3.13

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