@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.
- package/CHANGELOG.md +9 -0
- package/dist/assets/index-D3arfjLX.css +1 -0
- package/dist/assets/index-DTd23uLj.js +240 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/App.tsx +3 -0
- package/src/api/config.ts +15 -0
- package/src/api/types.ts +41 -0
- package/src/components/config/ChannelForm.tsx +93 -7
- package/src/components/config/RuntimeConfig.tsx +472 -0
- package/src/components/layout/Sidebar.tsx +6 -1
- package/src/hooks/useConfig.ts +17 -0
- package/src/stores/ui.store.ts +1 -1
- package/dist/assets/index-CANDXRNv.js +0 -230
- package/dist/assets/index-DM9Q3WUX.css +0 -1
|
@@ -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
|
|
package/src/hooks/useConfig.ts
CHANGED
|
@@ -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 }) =>
|
package/src/stores/ui.store.ts
CHANGED
|
@@ -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
|