@nextclaw/ui 0.9.15 → 0.9.16

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +7 -4
  2. package/dist/assets/ChannelsList-DhM0gvDV.js +1 -0
  3. package/dist/assets/{ChatPage-Dmpau_7n.js → ChatPage-4niJBFCu.js} +14 -14
  4. package/dist/assets/{DocBrowser-C3ijFxFF.js → DocBrowser-DpXDQNhb.js} +1 -1
  5. package/dist/assets/{LogoBadge-BgjXmBcw.js → LogoBadge-nqabOtgk.js} +1 -1
  6. package/dist/assets/MarketplacePage-CrkTftqZ.js +49 -0
  7. package/dist/assets/{McpMarketplacePage-DPtH1xcY.js → McpMarketplacePage-DH1qKJqo.js} +1 -1
  8. package/dist/assets/ModelConfig-CrrxPK_y.js +1 -0
  9. package/dist/assets/{ProvidersList-DnWsJqMQ.js → ProvidersList-BG36JlSJ.js} +1 -1
  10. package/dist/assets/{RemoteAccessPage-BrXq-x0-.js → RemoteAccessPage-Dcj2Pzpt.js} +1 -1
  11. package/dist/assets/{RuntimeConfig-UE9VaFO7.js → RuntimeConfig-BrxgUzjJ.js} +1 -1
  12. package/dist/assets/{SearchConfig-CP-RM3V3.js → SearchConfig-D-NLwowp.js} +1 -1
  13. package/dist/assets/{SecretsConfig-CfN_bazs.js → SecretsConfig-DjNqBB05.js} +1 -1
  14. package/dist/assets/{SessionsConfig-CgkKzKGv.js → SessionsConfig-DdlsWXQc.js} +1 -1
  15. package/dist/assets/{chat-message-CGL3sMsS.js → chat-message-B7THd1Mh.js} +1 -1
  16. package/dist/assets/index-CgqD0Jfg.js +8 -0
  17. package/dist/assets/index-UC08nscf.css +1 -0
  18. package/dist/assets/{label-CbOSodIL.js → label-B-TkPZRF.js} +1 -1
  19. package/dist/assets/{page-layout-BtDnyNLf.js → page-layout-BTVBRo6H.js} +1 -1
  20. package/dist/assets/{popover-DGlUjPQc.js → popover-DBZvpGcL.js} +1 -1
  21. package/dist/assets/{security-config-D6Bs1yoK.js → security-config-DotxwVFR.js} +1 -1
  22. package/dist/assets/skeleton-DGtduHZV.js +1 -0
  23. package/dist/assets/{status-dot-C8vM3IN1.js → status-dot-BCUTVN2R.js} +1 -1
  24. package/dist/assets/{switch-AuwUiga3.js → switch-Bp2mda29.js} +1 -1
  25. package/dist/assets/{tabs-custom-CTS7SaFG.js → tabs-custom-BE8yZ2kE.js} +1 -1
  26. package/dist/assets/{useConfirmDialog-DrMAdNfN.js → useConfirmDialog-DCy-eYnV.js} +1 -1
  27. package/dist/assets/{vendor-TJ2hy_Lv.js → vendor-DJt0Azq5.js} +90 -80
  28. package/dist/index.html +3 -3
  29. package/package.json +4 -4
  30. package/src/api/channel-auth.ts +35 -0
  31. package/src/api/channel-auth.types.ts +28 -0
  32. package/src/api/config.ts +2 -4
  33. package/src/api/types.ts +7 -26
  34. package/src/components/chat/ncp/ncp-session-adapter.ts +0 -1
  35. package/src/components/config/ChannelForm.tsx +41 -128
  36. package/src/components/config/ChannelsList.test.tsx +71 -10
  37. package/src/components/config/ModelConfig.test.tsx +78 -0
  38. package/src/components/config/ModelConfig.tsx +4 -1
  39. package/src/components/config/channel-form-fields-section.tsx +155 -0
  40. package/src/components/config/weixin-channel-auth-section.tsx +242 -0
  41. package/src/hooks/use-channel-auth.ts +16 -0
  42. package/src/lib/i18n.channel-auth.ts +37 -0
  43. package/src/lib/i18n.ts +2 -4
  44. package/src/transport/app-client.ts +22 -6
  45. package/dist/assets/ChannelsList-Cu_hLbps.js +0 -1
  46. package/dist/assets/MarketplacePage-CAIdEiw8.js +0 -49
  47. package/dist/assets/ModelConfig-D-pqArCg.js +0 -1
  48. package/dist/assets/index-D4alkESd.js +0 -8
  49. package/dist/assets/index-SGSkQCPi.css +0 -1
  50. package/dist/assets/skeleton-BLV99JbX.js +0 -1
package/dist/index.html CHANGED
@@ -6,9 +6,9 @@
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-D4alkESd.js"></script>
10
- <link rel="modulepreload" crossorigin href="/assets/vendor-TJ2hy_Lv.js">
11
- <link rel="stylesheet" crossorigin href="/assets/index-SGSkQCPi.css">
9
+ <script type="module" crossorigin src="/assets/index-CgqD0Jfg.js"></script>
10
+ <link rel="modulepreload" crossorigin href="/assets/vendor-DJt0Azq5.js">
11
+ <link rel="stylesheet" crossorigin href="/assets/index-UC08nscf.css">
12
12
  </head>
13
13
 
14
14
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/ui",
3
- "version": "0.9.15",
3
+ "version": "0.9.16",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,10 +28,10 @@
28
28
  "zod": "^3.23.8",
29
29
  "zustand": "^5.0.2",
30
30
  "@nextclaw/agent-chat": "0.1.1",
31
- "@nextclaw/agent-chat-ui": "0.2.1",
32
31
  "@nextclaw/ncp": "0.3.1",
33
- "@nextclaw/ncp-http-agent-client": "0.3.1",
34
- "@nextclaw/ncp-react": "0.3.2"
32
+ "@nextclaw/agent-chat-ui": "0.2.1",
33
+ "@nextclaw/ncp-react": "0.3.2",
34
+ "@nextclaw/ncp-http-agent-client": "0.3.1"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@testing-library/react": "^16.3.0",
@@ -0,0 +1,35 @@
1
+ import { api } from './client';
2
+ import type {
3
+ ChannelAuthPollRequest,
4
+ ChannelAuthPollResult,
5
+ ChannelAuthStartRequest,
6
+ ChannelAuthStartResult
7
+ } from './channel-auth.types';
8
+
9
+ export async function startChannelAuth(
10
+ channel: string,
11
+ data: ChannelAuthStartRequest = {}
12
+ ): Promise<ChannelAuthStartResult> {
13
+ const response = await api.post<ChannelAuthStartResult>(
14
+ `/api/config/channels/${channel}/auth/start`,
15
+ data
16
+ );
17
+ if (!response.ok) {
18
+ throw new Error(response.error.message);
19
+ }
20
+ return response.data;
21
+ }
22
+
23
+ export async function pollChannelAuth(
24
+ channel: string,
25
+ data: ChannelAuthPollRequest
26
+ ): Promise<ChannelAuthPollResult> {
27
+ const response = await api.post<ChannelAuthPollResult>(
28
+ `/api/config/channels/${channel}/auth/poll`,
29
+ data
30
+ );
31
+ if (!response.ok) {
32
+ throw new Error(response.error.message);
33
+ }
34
+ return response.data;
35
+ }
@@ -0,0 +1,28 @@
1
+ export type ChannelAuthStartRequest = {
2
+ accountId?: string;
3
+ baseUrl?: string;
4
+ };
5
+
6
+ export type ChannelAuthStartResult = {
7
+ channel: string;
8
+ kind: "qr_code";
9
+ sessionId: string;
10
+ qrCode: string;
11
+ qrCodeUrl: string;
12
+ expiresAt: string;
13
+ intervalMs: number;
14
+ note?: string;
15
+ };
16
+
17
+ export type ChannelAuthPollRequest = {
18
+ sessionId: string;
19
+ };
20
+
21
+ export type ChannelAuthPollResult = {
22
+ channel: string;
23
+ status: "pending" | "scanned" | "authorized" | "expired" | "error";
24
+ message?: string;
25
+ nextPollMs?: number;
26
+ accountId?: string | null;
27
+ notes?: string[];
28
+ };
package/src/api/config.ts CHANGED
@@ -143,10 +143,8 @@ export async function fetchConfigSchema(): Promise<ConfigSchemaResponse> {
143
143
  }
144
144
 
145
145
  // PUT /api/config/model
146
- export async function updateModel(data: {
147
- model: string;
148
- }): Promise<{ model: string }> {
149
- const response = await api.put<{ model: string }>('/api/config/model', data);
146
+ export async function updateModel(data: { model: string; workspace?: string }): Promise<{ model: string; workspace?: string }> {
147
+ const response = await api.put<{ model: string; workspace?: string }>('/api/config/model', data);
150
148
  if (!response.ok) {
151
149
  throw new Error(response.error.message);
152
150
  }
package/src/api/types.ts CHANGED
@@ -11,10 +11,7 @@ export type ApiResponse<T> =
11
11
  | { ok: true; data: T }
12
12
  | { ok: false; error: ApiError };
13
13
 
14
- export type AppMetaView = {
15
- name: string;
16
- productVersion: string;
17
- };
14
+ export type AppMetaView = { name: string; productVersion: string };
18
15
 
19
16
  export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "adaptive" | "xhigh";
20
17
 
@@ -47,15 +44,8 @@ export type ProviderConnectionTestRequest = ProviderConfigUpdate & {
47
44
 
48
45
  export type ProviderCreateRequest = ProviderConfigUpdate;
49
46
 
50
- export type ProviderCreateResult = {
51
- name: string;
52
- provider: ProviderConfigView;
53
- };
54
-
55
- export type ProviderDeleteResult = {
56
- deleted: boolean;
57
- provider: string;
58
- };
47
+ export type ProviderCreateResult = { name: string; provider: ProviderConfigView };
48
+ export type ProviderDeleteResult = { deleted: boolean; provider: string };
59
49
 
60
50
  export type ProviderConnectionTestErrorCode =
61
51
  | 'API_KEY_REQUIRED'
@@ -140,13 +130,8 @@ export type ProviderAuthStartResult = {
140
130
  note?: string;
141
131
  };
142
132
 
143
- export type ProviderAuthStartRequest = {
144
- methodId?: string;
145
- };
146
-
147
- export type ProviderAuthPollRequest = {
148
- sessionId: string;
149
- };
133
+ export type ProviderAuthStartRequest = { methodId?: string };
134
+ export type ProviderAuthPollRequest = { sessionId: string };
150
135
 
151
136
  export type ProviderAuthPollResult = {
152
137
  provider: string;
@@ -155,12 +140,7 @@ export type ProviderAuthPollResult = {
155
140
  nextPollMs?: number;
156
141
  };
157
142
 
158
- export type ProviderAuthImportResult = {
159
- provider: string;
160
- status: "imported";
161
- source: "cli";
162
- expiresAt?: string;
163
- };
143
+ export type ProviderAuthImportResult = { provider: string; status: "imported"; source: "cli"; expiresAt?: string };
164
144
 
165
145
  export type {
166
146
  AuthEnabledUpdateRequest,
@@ -169,6 +149,7 @@ export type {
169
149
  AuthSetupRequest,
170
150
  AuthStatusView
171
151
  } from './auth.types';
152
+ export type { ChannelAuthPollRequest, ChannelAuthPollResult, ChannelAuthStartRequest, ChannelAuthStartResult } from './channel-auth.types';
172
153
 
173
154
  export type {
174
155
  RemoteAccessView,
@@ -168,7 +168,6 @@ export function adaptNcpMessageToUiMessage(message: NcpMessageView): UIMessage {
168
168
  }
169
169
 
170
170
  export function adaptNcpMessagesToUiMessages(messages: readonly NcpMessageView[]): UIMessage[] {
171
- console.log('[adaptNcpMessagesToUiMessages]', { messages });
172
171
  return messages.map(adaptNcpMessageToUiMessage);
173
172
  }
174
173
 
@@ -1,47 +1,25 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { useConfig, useConfigMeta, useConfigSchema, useUpdateChannel, useExecuteConfigAction } from '@/hooks/useConfig';
3
3
  import { Button } from '@/components/ui/button';
4
- import { Input } from '@/components/ui/input';
5
- import { Label } from '@/components/ui/label';
6
- import { Switch } from '@/components/ui/switch';
7
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
8
- import { TagInput } from '@/components/common/TagInput';
9
4
  import { StatusDot } from '@/components/ui/status-dot';
10
5
  import { LogoBadge } from '@/components/common/LogoBadge';
11
6
  import { t } from '@/lib/i18n';
12
7
  import { hintForPath } from '@/lib/config-hints';
13
8
  import { cn } from '@/lib/utils';
14
9
  import { toast } from 'sonner';
15
- import { Settings, ToggleLeft, Hash, Mail, Globe, KeyRound, BookOpen } from 'lucide-react';
10
+ import { BookOpen, ChevronDown } from 'lucide-react';
16
11
  import type { ConfigActionManifest } from '@/api/types';
17
12
  import { resolveChannelTutorialUrl } from '@/lib/channel-tutorials';
18
13
  import { getChannelLogo } from '@/lib/logos';
19
14
  import { CONFIG_DETAIL_CARD_CLASS, CONFIG_EMPTY_DETAIL_CARD_CLASS } from './config-layout';
15
+ import { ChannelFormFieldsSection } from './channel-form-fields-section';
20
16
  import { buildChannelFields } from './channel-form-fields';
17
+ import { WeixinChannelAuthSection } from './weixin-channel-auth-section';
21
18
 
22
19
  type ChannelFormProps = {
23
20
  channelName?: string;
24
21
  };
25
22
 
26
- const getFieldIcon = (fieldName: string) => {
27
- if (fieldName.includes('token') || fieldName.includes('secret') || fieldName.includes('password')) {
28
- return <KeyRound className="h-3.5 w-3.5 text-gray-500" />;
29
- }
30
- if (fieldName.includes('url') || fieldName.includes('host')) {
31
- return <Globe className="h-3.5 w-3.5 text-gray-500" />;
32
- }
33
- if (fieldName.includes('email') || fieldName.includes('mail')) {
34
- return <Mail className="h-3.5 w-3.5 text-gray-500" />;
35
- }
36
- if (fieldName.includes('id') || fieldName.includes('from')) {
37
- return <Hash className="h-3.5 w-3.5 text-gray-500" />;
38
- }
39
- if (fieldName === 'enabled' || fieldName === 'consentGranted') {
40
- return <ToggleLeft className="h-3.5 w-3.5 text-gray-500" />;
41
- }
42
- return <Settings className="h-3.5 w-3.5 text-gray-500" />;
43
- };
44
-
45
23
  function isRecord(value: unknown): value is Record<string, unknown> {
46
24
  return typeof value === 'object' && value !== null && !Array.isArray(value);
47
25
  }
@@ -93,6 +71,7 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
93
71
  : channelName;
94
72
  const channelMeta = meta?.channels.find((item) => item.name === channelName);
95
73
  const tutorialUrl = channelMeta ? resolveChannelTutorialUrl(channelMeta) : undefined;
74
+ const isWeixinChannel = channelName === 'weixin';
96
75
 
97
76
  useEffect(() => {
98
77
  if (channelConfig) {
@@ -251,111 +230,45 @@ export function ChannelForm({ channelName }: ChannelFormProps) {
251
230
 
252
231
  <form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
253
232
  <div className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain px-6 py-5">
254
- {fields.map((field) => {
255
- const hint = channelName
256
- ? hintForPath(`channels.${channelName}.${field.name}`, uiHints)
257
- : undefined;
258
- const label = hint?.label ?? field.label;
259
- const placeholder = hint?.placeholder;
260
-
261
- return (
262
- <div key={field.name} className="space-y-2.5">
263
- <Label
264
- htmlFor={field.name}
265
- className="flex items-center gap-2 text-sm font-medium text-gray-900"
266
- >
267
- {getFieldIcon(field.name)}
268
- {label}
269
- </Label>
270
-
271
- {field.type === 'boolean' && (
272
- <div className="flex items-center justify-between rounded-xl bg-gray-50 p-3">
273
- <span className="text-sm text-gray-500">
274
- {(formData[field.name] as boolean) ? t('enabled') : t('disabled')}
275
- </span>
276
- <Switch
277
- id={field.name}
278
- checked={(formData[field.name] as boolean) || false}
279
- onCheckedChange={(checked) => updateField(field.name, checked)}
280
- className="data-[state=checked]:bg-emerald-500"
281
- />
233
+ {isWeixinChannel ? (
234
+ <>
235
+ <WeixinChannelAuthSection
236
+ channelConfig={channelConfig}
237
+ formData={formData}
238
+ disabled={updateChannel.isPending || Boolean(runningActionId)}
239
+ />
240
+ <details className="group rounded-2xl border border-gray-200/80 bg-white">
241
+ <summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-5 py-4 text-sm font-medium text-gray-900">
242
+ <div>
243
+ <p>{t('weixinAuthAdvancedTitle')}</p>
244
+ <p className="mt-1 text-xs font-normal text-gray-500">{t('weixinAuthAdvancedDescription')}</p>
282
245
  </div>
283
- )}
284
-
285
- {(field.type === 'text' || field.type === 'email') && (
286
- <Input
287
- id={field.name}
288
- type={field.type}
289
- value={(formData[field.name] as string) || ''}
290
- onChange={(e) => updateField(field.name, e.target.value)}
291
- placeholder={placeholder}
292
- className="rounded-xl"
293
- />
294
- )}
295
-
296
- {field.type === 'password' && (
297
- <Input
298
- id={field.name}
299
- type="password"
300
- value={(formData[field.name] as string) || ''}
301
- onChange={(e) => updateField(field.name, e.target.value)}
302
- placeholder={placeholder ?? t('leaveBlankToKeepUnchanged')}
303
- className="rounded-xl"
304
- />
305
- )}
306
-
307
- {field.type === 'number' && (
308
- <Input
309
- id={field.name}
310
- type="number"
311
- value={(formData[field.name] as number) || 0}
312
- onChange={(e) => updateField(field.name, parseInt(e.target.value, 10) || 0)}
313
- placeholder={placeholder}
314
- className="rounded-xl"
315
- />
316
- )}
317
-
318
- {field.type === 'tags' && (
319
- <TagInput
320
- value={(formData[field.name] as string[]) || []}
321
- onChange={(tags) => updateField(field.name, tags)}
246
+ <ChevronDown className="h-4 w-4 text-gray-400 transition-transform group-open:rotate-180" />
247
+ </summary>
248
+ <div className="space-y-6 border-t border-gray-100 px-5 py-5">
249
+ <ChannelFormFieldsSection
250
+ channelName={channelName}
251
+ fields={fields}
252
+ formData={formData}
253
+ jsonDrafts={jsonDrafts}
254
+ setJsonDrafts={setJsonDrafts}
255
+ updateField={updateField}
256
+ uiHints={uiHints}
322
257
  />
323
- )}
324
-
325
- {field.type === 'select' && (
326
- <Select
327
- value={(formData[field.name] as string) || ''}
328
- onValueChange={(v) => updateField(field.name, v)}
329
- >
330
- <SelectTrigger className="rounded-xl">
331
- <SelectValue />
332
- </SelectTrigger>
333
- <SelectContent>
334
- {(field.options ?? []).map((option) => (
335
- <SelectItem key={option.value} value={option.value}>
336
- {option.label}
337
- </SelectItem>
338
- ))}
339
- </SelectContent>
340
- </Select>
341
- )}
342
-
343
- {field.type === 'json' && (
344
- <textarea
345
- id={field.name}
346
- value={jsonDrafts[field.name] ?? '{}'}
347
- onChange={(event) =>
348
- setJsonDrafts((prev) => ({
349
- ...prev,
350
- [field.name]: event.target.value
351
- }))
352
- }
353
- className="min-h-[120px] w-full resize-none rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-mono"
354
- />
355
- )}
356
- </div>
357
- );
358
- })}
258
+ </div>
259
+ </details>
260
+ </>
261
+ ) : (
262
+ <ChannelFormFieldsSection
263
+ channelName={channelName}
264
+ fields={fields}
265
+ formData={formData}
266
+ jsonDrafts={jsonDrafts}
267
+ setJsonDrafts={setJsonDrafts}
268
+ updateField={updateField}
269
+ uiHints={uiHints}
270
+ />
271
+ )}
359
272
  </div>
360
273
 
361
274
  <div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-100 px-6 py-4">
@@ -3,8 +3,10 @@ import userEvent from '@testing-library/user-event';
3
3
  import { ChannelsList } from '@/components/config/ChannelsList';
4
4
 
5
5
  const mocks = vi.hoisted(() => ({
6
- mutate: vi.fn(),
7
- mutateAsync: vi.fn(),
6
+ updateChannelMutate: vi.fn(),
7
+ updateChannelMutateAsync: vi.fn(),
8
+ startChannelAuthMutateAsync: vi.fn(),
9
+ pollChannelAuthMutateAsync: vi.fn(),
8
10
  configQuery: {
9
11
  data: {
10
12
  channels: {
@@ -51,13 +53,23 @@ const mocks = vi.hoisted(() => ({
51
53
  }
52
54
  }));
53
55
 
56
+ vi.mock('@tanstack/react-query', async () => {
57
+ const actual = await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query');
58
+ return {
59
+ ...actual,
60
+ useQueryClient: () => ({
61
+ invalidateQueries: vi.fn().mockResolvedValue(undefined)
62
+ })
63
+ };
64
+ });
65
+
54
66
  vi.mock('@/hooks/useConfig', () => ({
55
67
  useConfig: () => mocks.configQuery,
56
68
  useConfigMeta: () => mocks.metaQuery,
57
69
  useConfigSchema: () => mocks.schemaQuery,
58
70
  useUpdateChannel: () => ({
59
- mutate: mocks.mutate,
60
- mutateAsync: mocks.mutateAsync,
71
+ mutate: mocks.updateChannelMutate,
72
+ mutateAsync: mocks.updateChannelMutateAsync,
61
73
  isPending: false
62
74
  }),
63
75
  useExecuteConfigAction: () => ({
@@ -66,27 +78,76 @@ vi.mock('@/hooks/useConfig', () => ({
66
78
  })
67
79
  }));
68
80
 
81
+ vi.mock('@/hooks/use-channel-auth', () => ({
82
+ useStartChannelAuth: () => ({
83
+ mutateAsync: mocks.startChannelAuthMutateAsync,
84
+ isPending: false
85
+ }),
86
+ usePollChannelAuth: () => ({
87
+ mutateAsync: mocks.pollChannelAuthMutateAsync,
88
+ isPending: false
89
+ })
90
+ }));
91
+
69
92
  describe('ChannelsList', () => {
70
93
  beforeEach(() => {
71
- mocks.mutate.mockReset();
72
- mocks.mutateAsync.mockReset();
94
+ mocks.updateChannelMutate.mockReset();
95
+ mocks.updateChannelMutateAsync.mockReset();
96
+ mocks.startChannelAuthMutateAsync.mockReset();
97
+ mocks.pollChannelAuthMutateAsync.mockReset();
73
98
  });
74
99
 
75
- it('renders weixin and submits weixin-specific config fields', async () => {
100
+ it('renders weixin qr auth card and starts channel auth', async () => {
76
101
  const user = userEvent.setup();
102
+ mocks.startChannelAuthMutateAsync.mockResolvedValue({
103
+ channel: 'weixin',
104
+ kind: 'qr_code',
105
+ sessionId: 'session-1',
106
+ qrCode: 'qr-token',
107
+ qrCodeUrl: 'https://example.com/weixin-qr.png',
108
+ expiresAt: '2026-03-23T10:00:00.000Z',
109
+ intervalMs: 60_000,
110
+ note: '请扫码'
111
+ });
77
112
 
78
113
  render(<ChannelsList />);
79
114
 
80
115
  await user.click(await screen.findByRole('button', { name: /All Channels/i }));
81
116
 
82
117
  expect((await screen.findAllByText('Weixin')).length).toBeGreaterThan(0);
83
- expect(await screen.findByLabelText('Default Account ID')).toBeTruthy();
118
+ expect(await screen.findByRole('button', { name: 'Reconnect with QR' })).toBeTruthy();
119
+ expect(screen.getByText('Weixin now uses QR login as the primary setup flow.')).toBeTruthy();
120
+
121
+ await user.click(screen.getByRole('button', { name: 'Reconnect with QR' }));
122
+
123
+ await waitFor(() => {
124
+ expect(mocks.startChannelAuthMutateAsync).toHaveBeenCalledWith({
125
+ channel: 'weixin',
126
+ data: expect.objectContaining({
127
+ accountId: '1344b2b24720@im.bot',
128
+ baseUrl: 'https://ilinkai.weixin.qq.com'
129
+ })
130
+ });
131
+ });
132
+ });
133
+
134
+ it('saves weixin advanced settings from the advanced section', async () => {
135
+ const user = userEvent.setup();
136
+
137
+ const { container } = render(<ChannelsList />);
138
+
139
+ await user.click(await screen.findByRole('button', { name: /All Channels/i }));
140
+ await user.click(await screen.findByText('Advanced settings'));
84
141
 
85
142
  const timeoutInput = await screen.findByLabelText('Long Poll Timeout (ms)');
86
143
  await user.clear(timeoutInput);
87
144
  await user.type(timeoutInput, '45000');
88
145
 
89
- const accountsJson = await screen.findByLabelText('Accounts JSON');
146
+ const accountsJson = container.querySelector('textarea#accounts') as HTMLTextAreaElement | null;
147
+ expect(accountsJson).toBeTruthy();
148
+ if (!accountsJson) {
149
+ throw new Error('accounts textarea not found');
150
+ }
90
151
  await user.clear(accountsJson);
91
152
  fireEvent.change(accountsJson, {
92
153
  target: {
@@ -106,7 +167,7 @@ describe('ChannelsList', () => {
106
167
  await user.click(screen.getByRole('button', { name: /save/i }));
107
168
 
108
169
  await waitFor(() => {
109
- expect(mocks.mutate).toHaveBeenCalledWith({
170
+ expect(mocks.updateChannelMutate).toHaveBeenCalledWith({
110
171
  channel: 'weixin',
111
172
  data: expect.objectContaining({
112
173
  enabled: false,
@@ -0,0 +1,78 @@
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { ModelConfig } from '@/components/config/ModelConfig';
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ mutate: vi.fn(),
7
+ configQuery: {
8
+ data: {
9
+ agents: {
10
+ defaults: {
11
+ model: 'openai/gpt-5.2',
12
+ workspace: '~/old-workspace'
13
+ }
14
+ },
15
+ providers: {
16
+ openai: {
17
+ enabled: true,
18
+ apiKeySet: true,
19
+ models: ['gpt-5.2']
20
+ }
21
+ }
22
+ },
23
+ isLoading: false
24
+ },
25
+ metaQuery: {
26
+ data: {
27
+ providers: [
28
+ {
29
+ name: 'openai',
30
+ displayName: 'OpenAI',
31
+ modelPrefix: 'openai',
32
+ defaultModels: ['openai/gpt-5.2'],
33
+ keywords: [],
34
+ envKey: 'OPENAI_API_KEY'
35
+ }
36
+ ]
37
+ }
38
+ },
39
+ schemaQuery: {
40
+ data: {
41
+ uiHints: {}
42
+ }
43
+ }
44
+ }));
45
+
46
+ vi.mock('@/hooks/useConfig', () => ({
47
+ useConfig: () => mocks.configQuery,
48
+ useConfigMeta: () => mocks.metaQuery,
49
+ useConfigSchema: () => mocks.schemaQuery,
50
+ useUpdateModel: () => ({
51
+ mutate: mocks.mutate,
52
+ isPending: false
53
+ })
54
+ }));
55
+
56
+ describe('ModelConfig', () => {
57
+ beforeEach(() => {
58
+ mocks.mutate.mockReset();
59
+ });
60
+
61
+ it('submits the workspace together with the selected model', async () => {
62
+ const user = userEvent.setup();
63
+
64
+ render(<ModelConfig />);
65
+
66
+ const workspaceInput = await screen.findByLabelText('Default Path');
67
+ await user.clear(workspaceInput);
68
+ await user.type(workspaceInput, '~/new-workspace');
69
+ await user.click(screen.getByRole('button', { name: /save/i }));
70
+
71
+ await waitFor(() => {
72
+ expect(mocks.mutate).toHaveBeenCalledWith({
73
+ model: 'openai/gpt-5.2',
74
+ workspace: '~/new-workspace'
75
+ });
76
+ });
77
+ });
78
+ });
@@ -95,7 +95,10 @@ export function ModelConfig() {
95
95
 
96
96
  const handleSubmit = (e: React.FormEvent) => {
97
97
  e.preventDefault();
98
- updateModel.mutate({ model: composedModel });
98
+ updateModel.mutate({
99
+ model: composedModel,
100
+ workspace
101
+ });
99
102
  };
100
103
 
101
104
  if (isLoading) {