@nextclaw/ui 0.3.8 → 0.3.10

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/dist/index.html CHANGED
@@ -6,8 +6,8 @@
6
6
  <link rel="icon" type="image/svg+xml" href="/logo.svg" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
8
  <title>NextClaw - 系统配置</title>
9
- <script type="module" crossorigin src="/assets/index-DI6zQUcL.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-DahcMyga.css">
9
+ <script type="module" crossorigin src="/assets/index-DO3sh5Tk.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-DM9Q3WUX.css">
11
11
  </head>
12
12
 
13
13
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/ui",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/api/config.ts CHANGED
@@ -6,7 +6,8 @@ import type {
6
6
  ProviderConfigView,
7
7
  ChannelConfigUpdate,
8
8
  ProviderConfigUpdate,
9
- FeishuProbeView
9
+ ConfigActionExecuteRequest,
10
+ ConfigActionExecuteResult
10
11
  } from './types';
11
12
 
12
13
  // GET /api/config
@@ -77,9 +78,15 @@ export async function updateChannel(
77
78
  return response.data;
78
79
  }
79
80
 
80
- // POST /api/channels/feishu/probe
81
- export async function probeFeishu(): Promise<FeishuProbeView> {
82
- const response = await api.post<FeishuProbeView>('/api/channels/feishu/probe', {});
81
+ // POST /api/config/actions/:id/execute
82
+ export async function executeConfigAction(
83
+ actionId: string,
84
+ data: ConfigActionExecuteRequest
85
+ ): Promise<ConfigActionExecuteResult> {
86
+ const response = await api.post<ConfigActionExecuteResult>(
87
+ `/api/config/actions/${actionId}/execute`,
88
+ data
89
+ );
83
90
  if (!response.ok) {
84
91
  throw new Error(response.error.message);
85
92
  }
package/src/api/types.ts CHANGED
@@ -32,7 +32,6 @@ export type ConfigView = {
32
32
  model: string;
33
33
  workspace?: string;
34
34
  maxTokens?: number;
35
- temperature?: number;
36
35
  maxToolIterations?: number;
37
36
  };
38
37
  context?: {
@@ -96,14 +95,60 @@ export type ConfigUiHints = Record<string, ConfigUiHint>;
96
95
  export type ConfigSchemaResponse = {
97
96
  schema: Record<string, unknown>;
98
97
  uiHints: ConfigUiHints;
98
+ actions: ConfigActionManifest[];
99
99
  version: string;
100
100
  generatedAt: string;
101
101
  };
102
102
 
103
- export type FeishuProbeView = {
104
- appId: string;
105
- botName?: string | null;
106
- botOpenId?: string | null;
103
+ export type ConfigActionType = 'httpProbe' | 'oauthStart' | 'webhookVerify' | 'openUrl' | 'copyToken';
104
+
105
+ export type ConfigActionManifest = {
106
+ id: string;
107
+ version: string;
108
+ scope: string;
109
+ title: string;
110
+ description?: string;
111
+ type: ConfigActionType;
112
+ trigger: 'manual' | 'afterSave';
113
+ requires?: string[];
114
+ request: {
115
+ method: 'GET' | 'POST' | 'PUT';
116
+ path: string;
117
+ timeoutMs?: number;
118
+ };
119
+ success?: {
120
+ message?: string;
121
+ };
122
+ failure?: {
123
+ message?: string;
124
+ };
125
+ saveBeforeRun?: boolean;
126
+ savePatch?: Record<string, unknown>;
127
+ resultMap?: Record<string, string>;
128
+ policy?: {
129
+ roles?: string[];
130
+ rateLimitKey?: string;
131
+ cooldownMs?: number;
132
+ audit?: boolean;
133
+ };
134
+ };
135
+
136
+ export type ConfigActionExecuteRequest = {
137
+ scope?: string;
138
+ draftConfig?: Record<string, unknown>;
139
+ context?: {
140
+ actor?: string;
141
+ traceId?: string;
142
+ };
143
+ };
144
+
145
+ export type ConfigActionExecuteResult = {
146
+ ok: boolean;
147
+ status: 'success' | 'failed';
148
+ message: string;
149
+ data?: Record<string, unknown>;
150
+ patch?: Record<string, unknown>;
151
+ nextActions?: string[];
107
152
  };
108
153
 
109
154
  // WebSocket events
@@ -1,6 +1,5 @@
1
1
  import { useEffect, useState } from 'react';
2
- import { useConfig, useConfigSchema, useUpdateChannel } from '@/hooks/useConfig';
3
- import { probeFeishu } from '@/api/config';
2
+ import { useConfig, useConfigSchema, useUpdateChannel, useExecuteConfigAction } from '@/hooks/useConfig';
4
3
  import { useUiStore } from '@/stores/ui.store';
5
4
  import {
6
5
  Dialog,
@@ -19,6 +18,7 @@ import { t } from '@/lib/i18n';
19
18
  import { hintForPath } from '@/lib/config-hints';
20
19
  import { toast } from 'sonner';
21
20
  import { MessageCircle, Settings, ToggleLeft, Hash, Mail, Globe, KeyRound } from 'lucide-react';
21
+ import type { ConfigActionManifest } from '@/api/types';
22
22
 
23
23
  // Field icon mapping
24
24
  const getFieldIcon = (fieldName: string) => {
@@ -51,6 +51,7 @@ const CHANNEL_FIELDS: Record<string, Array<{ name: string; type: string; label:
51
51
  discord: [
52
52
  { name: 'enabled', type: 'boolean', label: t('enabled') },
53
53
  { name: 'token', type: 'password', label: t('botToken') },
54
+ { name: 'allowBots', type: 'boolean', label: 'Allow Bot Messages' },
54
55
  { name: 'allowFrom', type: 'tags', label: t('allowFrom') },
55
56
  { name: 'gatewayUrl', type: 'text', label: t('gatewayUrl') },
56
57
  { name: 'intents', type: 'number', label: t('intents') }
@@ -78,6 +79,7 @@ const CHANNEL_FIELDS: Record<string, Array<{ name: string; type: string; label:
78
79
  { name: 'enabled', type: 'boolean', label: t('enabled') },
79
80
  { name: 'mode', type: 'text', label: t('mode') },
80
81
  { name: 'webhookPath', type: 'text', label: t('webhookPath') },
82
+ { name: 'allowBots', type: 'boolean', label: 'Allow Bot Messages' },
81
83
  { name: 'botToken', type: 'password', label: t('botToken') },
82
84
  { name: 'appToken', type: 'password', label: t('appToken') }
83
85
  ],
@@ -120,19 +122,52 @@ const channelColors: Record<string, string> = {
120
122
  default: 'from-slate-400 to-gray-500'
121
123
  };
122
124
 
125
+ function isRecord(value: unknown): value is Record<string, unknown> {
126
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
127
+ }
128
+
129
+ function deepMergeRecords(base: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown> {
130
+ const next: Record<string, unknown> = { ...base };
131
+ for (const [key, value] of Object.entries(patch)) {
132
+ const prev = next[key];
133
+ if (isRecord(prev) && isRecord(value)) {
134
+ next[key] = deepMergeRecords(prev, value);
135
+ continue;
136
+ }
137
+ next[key] = value;
138
+ }
139
+ return next;
140
+ }
141
+
142
+ function buildScopeDraft(scope: string, value: Record<string, unknown>): Record<string, unknown> {
143
+ const segments = scope.split('.');
144
+ const output: Record<string, unknown> = {};
145
+ let cursor: Record<string, unknown> = output;
146
+ for (let index = 0; index < segments.length - 1; index += 1) {
147
+ const segment = segments[index];
148
+ cursor[segment] = {};
149
+ cursor = cursor[segment] as Record<string, unknown>;
150
+ }
151
+ cursor[segments[segments.length - 1]] = value;
152
+ return output;
153
+ }
154
+
123
155
  export function ChannelForm() {
124
156
  const { channelModal, closeChannelModal } = useUiStore();
125
157
  const { data: config } = useConfig();
126
158
  const { data: schema } = useConfigSchema();
127
159
  const updateChannel = useUpdateChannel();
160
+ const executeAction = useExecuteConfigAction();
128
161
 
129
162
  const [formData, setFormData] = useState<Record<string, unknown>>({});
130
- const [isConnecting, setIsConnecting] = useState(false);
163
+ const [runningActionId, setRunningActionId] = useState<string | null>(null);
131
164
 
132
165
  const channelName = channelModal.channel;
133
166
  const channelConfig = channelName ? config?.channels[channelName] : null;
134
167
  const fields = channelName ? CHANNEL_FIELDS[channelName] : [];
135
168
  const uiHints = schema?.uiHints;
169
+ const scope = channelName ? `channels.${channelName}` : null;
170
+ const actions = schema?.actions?.filter((action) => action.scope === scope) ?? [];
136
171
  const channelLabel = channelName
137
172
  ? hintForPath(`channels.${channelName}`, uiHints)?.label ?? channelName
138
173
  : channelName;
@@ -160,23 +195,59 @@ export function ChannelForm() {
160
195
  );
161
196
  };
162
197
 
163
- const handleVerifyConnect = async () => {
164
- if (!channelName || channelName !== 'feishu') return;
165
- setIsConnecting(true);
198
+ const applyActionPatchToForm = (patch?: Record<string, unknown>) => {
199
+ if (!patch || !channelName) {
200
+ return;
201
+ }
202
+ const channelsNode = patch.channels;
203
+ if (!isRecord(channelsNode)) {
204
+ return;
205
+ }
206
+ const channelPatch = channelsNode[channelName];
207
+ if (!isRecord(channelPatch)) {
208
+ return;
209
+ }
210
+ setFormData((prev) => deepMergeRecords(prev, channelPatch));
211
+ };
212
+
213
+ const handleManualAction = async (action: ConfigActionManifest) => {
214
+ if (!channelName || !scope) {
215
+ return;
216
+ }
217
+
218
+ setRunningActionId(action.id);
166
219
  try {
167
- const nextData = { ...formData, enabled: true };
168
- if (!formData.enabled) {
220
+ let nextData = { ...formData };
221
+
222
+ if (action.saveBeforeRun) {
223
+ nextData = {
224
+ ...nextData,
225
+ ...(action.savePatch ?? {})
226
+ };
169
227
  setFormData(nextData);
228
+ await updateChannel.mutateAsync({ channel: channelName, data: nextData });
229
+ }
230
+
231
+ const result = await executeAction.mutateAsync({
232
+ actionId: action.id,
233
+ data: {
234
+ scope,
235
+ draftConfig: buildScopeDraft(scope, nextData)
236
+ }
237
+ });
238
+
239
+ applyActionPatchToForm(result.patch);
240
+
241
+ if (result.ok) {
242
+ toast.success(result.message || t('success'));
243
+ } else {
244
+ toast.error(result.message || t('error'));
170
245
  }
171
- await updateChannel.mutateAsync({ channel: channelName, data: nextData });
172
- const probe = await probeFeishu();
173
- const botLabel = probe.botName ? ` (${probe.botName})` : '';
174
- toast.success(t('feishuVerifySuccess') + botLabel);
175
246
  } catch (error) {
176
247
  const message = error instanceof Error ? error.message : String(error);
177
- toast.error(`${t('feishuVerifyFailed')}: ${message}`);
248
+ toast.error(`${t('error')}: ${message}`);
178
249
  } finally {
179
- setIsConnecting(false);
250
+ setRunningActionId(null);
180
251
  }
181
252
  };
182
253
 
@@ -198,8 +269,8 @@ export function ChannelForm() {
198
269
  </div>
199
270
  </DialogHeader>
200
271
 
201
- <div className="flex-1 overflow-y-auto custom-scrollbar py-2">
202
- <form onSubmit={handleSubmit} className="space-y-5 pr-2">
272
+ <form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
273
+ <div className="flex-1 overflow-y-auto custom-scrollbar py-2 pr-2 space-y-5">
203
274
  {fields.map((field) => {
204
275
  const hint = channelName
205
276
  ? hintForPath(`channels.${channelName}.${field.name}`, uiHints)
@@ -209,98 +280,101 @@ export function ChannelForm() {
209
280
 
210
281
  return (
211
282
  <div key={field.name} className="space-y-2.5">
212
- <Label
213
- htmlFor={field.name}
214
- className="text-sm font-medium text-gray-900 flex items-center gap-2"
215
- >
216
- {getFieldIcon(field.name)}
217
- {label}
218
- </Label>
219
-
220
- {field.type === 'boolean' && (
221
- <div className="flex items-center justify-between p-3 rounded-xl bg-gray-50">
222
- <span className="text-sm text-gray-500">
223
- {(formData[field.name] as boolean) ? t('enabled') : t('disabled')}
224
- </span>
225
- <Switch
283
+ <Label
284
+ htmlFor={field.name}
285
+ className="text-sm font-medium text-gray-900 flex items-center gap-2"
286
+ >
287
+ {getFieldIcon(field.name)}
288
+ {label}
289
+ </Label>
290
+
291
+ {field.type === 'boolean' && (
292
+ <div className="flex items-center justify-between p-3 rounded-xl bg-gray-50">
293
+ <span className="text-sm text-gray-500">
294
+ {(formData[field.name] as boolean) ? t('enabled') : t('disabled')}
295
+ </span>
296
+ <Switch
297
+ id={field.name}
298
+ checked={(formData[field.name] as boolean) || false}
299
+ onCheckedChange={(checked) => updateField(field.name, checked)}
300
+ className="data-[state=checked]:bg-emerald-500"
301
+ />
302
+ </div>
303
+ )}
304
+
305
+ {(field.type === 'text' || field.type === 'email') && (
306
+ <Input
307
+ id={field.name}
308
+ type={field.type}
309
+ value={(formData[field.name] as string) || ''}
310
+ onChange={(e) => updateField(field.name, e.target.value)}
311
+ placeholder={placeholder}
312
+ className="rounded-xl"
313
+ />
314
+ )}
315
+
316
+ {field.type === 'password' && (
317
+ <Input
318
+ id={field.name}
319
+ type="password"
320
+ value={(formData[field.name] as string) || ''}
321
+ onChange={(e) => updateField(field.name, e.target.value)}
322
+ placeholder={placeholder ?? 'Leave blank to keep unchanged'}
323
+ className="rounded-xl"
324
+ />
325
+ )}
326
+
327
+ {field.type === 'number' && (
328
+ <Input
226
329
  id={field.name}
227
- checked={(formData[field.name] as boolean) || false}
228
- onCheckedChange={(checked) => updateField(field.name, checked)}
229
- className="data-[state=checked]:bg-emerald-500"
330
+ type="number"
331
+ value={(formData[field.name] as number) || 0}
332
+ onChange={(e) => updateField(field.name, parseInt(e.target.value) || 0)}
333
+ placeholder={placeholder}
334
+ className="rounded-xl"
335
+ />
336
+ )}
337
+
338
+ {field.type === 'tags' && (
339
+ <TagInput
340
+ value={(formData[field.name] as string[]) || []}
341
+ onChange={(tags) => updateField(field.name, tags)}
230
342
  />
231
- </div>
232
- )}
233
-
234
- {(field.type === 'text' || field.type === 'email') && (
235
- <Input
236
- id={field.name}
237
- type={field.type}
238
- value={(formData[field.name] as string) || ''}
239
- onChange={(e) => updateField(field.name, e.target.value)}
240
- placeholder={placeholder}
241
- className="rounded-xl"
242
- />
243
- )}
244
-
245
- {field.type === 'password' && (
246
- <Input
247
- id={field.name}
248
- type="password"
249
- value={(formData[field.name] as string) || ''}
250
- onChange={(e) => updateField(field.name, e.target.value)}
251
- placeholder={placeholder ?? 'Leave blank to keep unchanged'}
252
- className="rounded-xl"
253
- />
254
- )}
255
-
256
- {field.type === 'number' && (
257
- <Input
258
- id={field.name}
259
- type="number"
260
- value={(formData[field.name] as number) || 0}
261
- onChange={(e) => updateField(field.name, parseInt(e.target.value) || 0)}
262
- placeholder={placeholder}
263
- className="rounded-xl"
264
- />
265
- )}
266
-
267
- {field.type === 'tags' && (
268
- <TagInput
269
- value={(formData[field.name] as string[]) || []}
270
- onChange={(tags) => updateField(field.name, tags)}
271
- />
272
- )}
343
+ )}
273
344
  </div>
274
345
  );
275
346
  })}
347
+ </div>
276
348
 
277
- <DialogFooter className="pt-4">
278
- <Button
279
- type="button"
280
- variant="outline"
281
- onClick={closeChannelModal}
282
- >
283
- {t('cancel')}
284
- </Button>
285
- <Button
286
- type="submit"
287
- disabled={updateChannel.isPending || isConnecting}
288
- >
289
- {updateChannel.isPending ? 'Saving...' : t('save')}
290
- </Button>
291
- {channelName === 'feishu' && (
349
+ <DialogFooter className="pt-4 flex-shrink-0">
350
+ <Button
351
+ type="button"
352
+ variant="outline"
353
+ onClick={closeChannelModal}
354
+ >
355
+ {t('cancel')}
356
+ </Button>
357
+ <Button
358
+ type="submit"
359
+ disabled={updateChannel.isPending || Boolean(runningActionId)}
360
+ >
361
+ {updateChannel.isPending ? 'Saving...' : t('save')}
362
+ </Button>
363
+ {actions
364
+ .filter((action) => action.trigger === 'manual')
365
+ .map((action) => (
292
366
  <Button
367
+ key={action.id}
293
368
  type="button"
294
- onClick={handleVerifyConnect}
295
- disabled={updateChannel.isPending || isConnecting}
369
+ onClick={() => handleManualAction(action)}
370
+ disabled={updateChannel.isPending || Boolean(runningActionId)}
296
371
  variant="secondary"
297
372
  >
298
- {isConnecting ? t('feishuConnecting') : t('saveVerifyConnect')}
373
+ {runningActionId === action.id ? t('connecting') : action.title}
299
374
  </Button>
300
- )}
301
- </DialogFooter>
302
- </form>
303
- </div>
375
+ ))}
376
+ </DialogFooter>
377
+ </form>
304
378
  </DialogContent>
305
379
  </Dialog>
306
380
  );
@@ -16,19 +16,16 @@ export function ModelConfig() {
16
16
  const [model, setModel] = useState('');
17
17
  const [workspace, setWorkspace] = useState('');
18
18
  const [maxTokens, setMaxTokens] = useState(8192);
19
- const [temperature, setTemperature] = useState(0.7);
20
19
  const uiHints = schema?.uiHints;
21
20
  const modelHint = hintForPath('agents.defaults.model', uiHints);
22
21
  const workspaceHint = hintForPath('agents.defaults.workspace', uiHints);
23
22
  const maxTokensHint = hintForPath('agents.defaults.maxTokens', uiHints);
24
- const temperatureHint = hintForPath('agents.defaults.temperature', uiHints);
25
23
 
26
24
  useEffect(() => {
27
25
  if (config?.agents?.defaults) {
28
26
  setModel(config.agents.defaults.model || '');
29
27
  setWorkspace(config.agents.defaults.workspace || '');
30
28
  setMaxTokens(config.agents.defaults.maxTokens || 8192);
31
- setTemperature(config.agents.defaults.temperature || 0.7);
32
29
  }
33
30
  }, [config]);
34
31
 
@@ -63,10 +60,6 @@ export function ModelConfig() {
63
60
  <Skeleton className="h-4 w-28 mb-3" />
64
61
  <Skeleton className="h-2 w-full rounded-full" />
65
62
  </div>
66
- <div>
67
- <Skeleton className="h-4 w-32 mb-3" />
68
- <Skeleton className="h-2 w-full rounded-full" />
69
- </div>
70
63
  </div>
71
64
  </Card>
72
65
  </div>
@@ -77,7 +70,7 @@ export function ModelConfig() {
77
70
  <div className="max-w-4xl animate-fade-in pb-20">
78
71
  <div className="mb-10">
79
72
  <h2 className="text-2xl font-bold text-gray-900">Model Configuration</h2>
80
- <p className="text-sm text-gray-500 mt-1">Configure default AI model and behavior parameters</p>
73
+ <p className="text-sm text-gray-500 mt-1">Configure default AI model and runtime limits</p>
81
74
  </div>
82
75
 
83
76
  <form onSubmit={handleSubmit} className="space-y-8">
@@ -142,7 +135,7 @@ export function ModelConfig() {
142
135
  <h3 className="text-lg font-bold text-gray-900">Generation Parameters</h3>
143
136
  </div>
144
137
 
145
- <div className="grid grid-cols-1 md:grid-cols-2 gap-12">
138
+ <div className="grid grid-cols-1 gap-12">
146
139
  <div className="space-y-4">
147
140
  <div className="flex justify-between items-center mb-2">
148
141
  <Label className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
@@ -160,24 +153,6 @@ export function ModelConfig() {
160
153
  className="w-full h-1 bg-gray-200 rounded-full appearance-none cursor-pointer accent-primary"
161
154
  />
162
155
  </div>
163
-
164
- <div className="space-y-4">
165
- <div className="flex justify-between items-center mb-2">
166
- <Label className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
167
- {temperatureHint?.label ?? 'Temperature'}
168
- </Label>
169
- <span className="text-sm font-semibold text-gray-900">{temperature}</span>
170
- </div>
171
- <input
172
- type="range"
173
- min="0"
174
- max="2"
175
- step="0.1"
176
- value={temperature}
177
- onChange={(e) => setTemperature(parseFloat(e.target.value))}
178
- className="w-full h-1 bg-gray-200 rounded-full appearance-none cursor-pointer accent-primary"
179
- />
180
- </div>
181
156
  </div>
182
157
  </div>
183
158