@nextclaw/ui 0.3.8 → 0.3.9

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,7 +6,7 @@
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>
9
+ <script type="module" crossorigin src="/assets/index-BN_YuYNC.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="/assets/index-DahcMyga.css">
11
11
  </head>
12
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/ui",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
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
@@ -96,14 +96,60 @@ export type ConfigUiHints = Record<string, ConfigUiHint>;
96
96
  export type ConfigSchemaResponse = {
97
97
  schema: Record<string, unknown>;
98
98
  uiHints: ConfigUiHints;
99
+ actions: ConfigActionManifest[];
99
100
  version: string;
100
101
  generatedAt: string;
101
102
  };
102
103
 
103
- export type FeishuProbeView = {
104
- appId: string;
105
- botName?: string | null;
106
- botOpenId?: string | null;
104
+ export type ConfigActionType = 'httpProbe' | 'oauthStart' | 'webhookVerify' | 'openUrl' | 'copyToken';
105
+
106
+ export type ConfigActionManifest = {
107
+ id: string;
108
+ version: string;
109
+ scope: string;
110
+ title: string;
111
+ description?: string;
112
+ type: ConfigActionType;
113
+ trigger: 'manual' | 'afterSave';
114
+ requires?: string[];
115
+ request: {
116
+ method: 'GET' | 'POST' | 'PUT';
117
+ path: string;
118
+ timeoutMs?: number;
119
+ };
120
+ success?: {
121
+ message?: string;
122
+ };
123
+ failure?: {
124
+ message?: string;
125
+ };
126
+ saveBeforeRun?: boolean;
127
+ savePatch?: Record<string, unknown>;
128
+ resultMap?: Record<string, string>;
129
+ policy?: {
130
+ roles?: string[];
131
+ rateLimitKey?: string;
132
+ cooldownMs?: number;
133
+ audit?: boolean;
134
+ };
135
+ };
136
+
137
+ export type ConfigActionExecuteRequest = {
138
+ scope?: string;
139
+ draftConfig?: Record<string, unknown>;
140
+ context?: {
141
+ actor?: string;
142
+ traceId?: string;
143
+ };
144
+ };
145
+
146
+ export type ConfigActionExecuteResult = {
147
+ ok: boolean;
148
+ status: 'success' | 'failed';
149
+ message: string;
150
+ data?: Record<string, unknown>;
151
+ patch?: Record<string, unknown>;
152
+ nextActions?: string[];
107
153
  };
108
154
 
109
155
  // 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
 
@@ -284,20 +355,23 @@ export function ChannelForm() {
284
355
  </Button>
285
356
  <Button
286
357
  type="submit"
287
- disabled={updateChannel.isPending || isConnecting}
358
+ disabled={updateChannel.isPending || Boolean(runningActionId)}
288
359
  >
289
360
  {updateChannel.isPending ? 'Saving...' : t('save')}
290
361
  </Button>
291
- {channelName === 'feishu' && (
362
+ {actions
363
+ .filter((action) => action.trigger === 'manual')
364
+ .map((action) => (
292
365
  <Button
366
+ key={action.id}
293
367
  type="button"
294
- onClick={handleVerifyConnect}
295
- disabled={updateChannel.isPending || isConnecting}
368
+ onClick={() => handleManualAction(action)}
369
+ disabled={updateChannel.isPending || Boolean(runningActionId)}
296
370
  variant="secondary"
297
371
  >
298
- {isConnecting ? t('feishuConnecting') : t('saveVerifyConnect')}
372
+ {runningActionId === action.id ? t('connecting') : action.title}
299
373
  </Button>
300
- )}
374
+ ))}
301
375
  </DialogFooter>
302
376
  </form>
303
377
  </div>
@@ -5,7 +5,8 @@ import {
5
5
  fetchConfigSchema,
6
6
  updateModel,
7
7
  updateProvider,
8
- updateChannel
8
+ updateChannel,
9
+ executeConfigAction
9
10
  } from '@/api/config';
10
11
  import { toast } from 'sonner';
11
12
  import { t } from '@/lib/i18n';
@@ -81,3 +82,13 @@ export function useUpdateChannel() {
81
82
  }
82
83
  });
83
84
  }
85
+
86
+ export function useExecuteConfigAction() {
87
+ return useMutation({
88
+ mutationFn: ({ actionId, data }: { actionId: string; data: unknown }) =>
89
+ executeConfigAction(actionId, data as Parameters<typeof executeConfigAction>[1]),
90
+ onError: (error: Error) => {
91
+ toast.error(t('error') + ': ' + error.message);
92
+ }
93
+ });
94
+ }