@nextclaw/ui 0.9.15 → 0.9.17

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 (56) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/assets/ChannelsList-D75wfbDS.js +8 -0
  3. package/dist/assets/{ChatPage-Dmpau_7n.js → ChatPage-gWZ3rDTy.js} +14 -14
  4. package/dist/assets/{DocBrowser-C3ijFxFF.js → DocBrowser-CebTdor0.js} +1 -1
  5. package/dist/assets/{LogoBadge-BgjXmBcw.js → LogoBadge-gdbraoaZ.js} +1 -1
  6. package/dist/assets/MarketplacePage-C-zz0lBT.js +49 -0
  7. package/dist/assets/{McpMarketplacePage-DPtH1xcY.js → McpMarketplacePage-tfpLh6Zz.js} +1 -1
  8. package/dist/assets/ModelConfig-j74dn-5k.js +1 -0
  9. package/dist/assets/{ProvidersList-DnWsJqMQ.js → ProvidersList-BGI9EgVV.js} +1 -1
  10. package/dist/assets/{RemoteAccessPage-BrXq-x0-.js → RemoteAccessPage-CusGQmZE.js} +1 -1
  11. package/dist/assets/{RuntimeConfig-UE9VaFO7.js → RuntimeConfig-pmhW8ifz.js} +1 -1
  12. package/dist/assets/{SearchConfig-CP-RM3V3.js → SearchConfig-rrD2_F5u.js} +1 -1
  13. package/dist/assets/{SecretsConfig-CfN_bazs.js → SecretsConfig-D7onb-hv.js} +1 -1
  14. package/dist/assets/{SessionsConfig-CgkKzKGv.js → SessionsConfig-m-6RSeja.js} +1 -1
  15. package/dist/assets/{chat-message-CGL3sMsS.js → chat-message-BO-s2mvl.js} +1 -1
  16. package/dist/assets/index-BsL1YIJ1.js +8 -0
  17. package/dist/assets/index-C63mHRbE.css +1 -0
  18. package/dist/assets/{label-CbOSodIL.js → label-CDSYExvV.js} +1 -1
  19. package/dist/assets/{page-layout-BtDnyNLf.js → page-layout-BMCVAnQM.js} +1 -1
  20. package/dist/assets/{popover-DGlUjPQc.js → popover-DfywyUDH.js} +1 -1
  21. package/dist/assets/{security-config-D6Bs1yoK.js → security-config-BU-K2EOM.js} +1 -1
  22. package/dist/assets/skeleton-Cg9CRkOt.js +1 -0
  23. package/dist/assets/{status-dot-C8vM3IN1.js → status-dot-2vau2Xtc.js} +1 -1
  24. package/dist/assets/{switch-AuwUiga3.js → switch-CJRPF2V6.js} +1 -1
  25. package/dist/assets/{tabs-custom-CTS7SaFG.js → tabs-custom-B-2uSCfW.js} +1 -1
  26. package/dist/assets/{useConfirmDialog-DrMAdNfN.js → useConfirmDialog-CfOpdypA.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 +7 -6
  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/ChatSidebar.test.tsx +1 -1
  35. package/src/components/chat/chat-sidebar-session-item.tsx +0 -1
  36. package/src/components/chat/ncp/ncp-session-adapter.ts +0 -1
  37. package/src/components/config/ChannelForm.tsx +41 -128
  38. package/src/components/config/ChannelsList.test.tsx +79 -10
  39. package/src/components/config/ModelConfig.test.tsx +78 -0
  40. package/src/components/config/ModelConfig.tsx +4 -1
  41. package/src/components/config/channel-form-fields-section.tsx +155 -0
  42. package/src/components/config/weixin-channel-auth-section.test.tsx +90 -0
  43. package/src/components/config/weixin-channel-auth-section.tsx +301 -0
  44. package/src/components/layout/Sidebar.tsx +128 -120
  45. package/src/components/layout/sidebar.layout.test.tsx +99 -0
  46. package/src/hooks/use-channel-auth.ts +16 -0
  47. package/src/lib/i18n.channel-auth.ts +37 -0
  48. package/src/lib/i18n.ts +2 -4
  49. package/src/qrcode.d.ts +10 -0
  50. package/src/transport/app-client.ts +22 -6
  51. package/dist/assets/ChannelsList-Cu_hLbps.js +0 -1
  52. package/dist/assets/MarketplacePage-CAIdEiw8.js +0 -49
  53. package/dist/assets/ModelConfig-D-pqArCg.js +0 -1
  54. package/dist/assets/index-D4alkESd.js +0 -8
  55. package/dist/assets/index-SGSkQCPi.css +0 -1
  56. 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-BsL1YIJ1.js"></script>
10
+ <link rel="modulepreload" crossorigin href="/assets/vendor-DJt0Azq5.js">
11
+ <link rel="stylesheet" crossorigin href="/assets/index-C63mHRbE.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.17",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -17,6 +17,7 @@
17
17
  "class-variance-authority": "^0.7.1",
18
18
  "clsx": "^2.1.1",
19
19
  "lucide-react": "^0.462.0",
20
+ "qrcode": "^1.5.4",
20
21
  "react": "^18.3.1",
21
22
  "react-dom": "^18.3.1",
22
23
  "react-hook-form": "^7.53.2",
@@ -27,11 +28,11 @@
27
28
  "tailwind-merge": "^2.5.4",
28
29
  "zod": "^3.23.8",
29
30
  "zustand": "^5.0.2",
30
- "@nextclaw/agent-chat": "0.1.1",
31
- "@nextclaw/agent-chat-ui": "0.2.1",
32
- "@nextclaw/ncp": "0.3.1",
33
- "@nextclaw/ncp-http-agent-client": "0.3.1",
34
- "@nextclaw/ncp-react": "0.3.2"
31
+ "@nextclaw/agent-chat": "0.1.2",
32
+ "@nextclaw/agent-chat-ui": "0.2.2",
33
+ "@nextclaw/ncp": "0.3.2",
34
+ "@nextclaw/ncp-http-agent-client": "0.3.2",
35
+ "@nextclaw/ncp-react": "0.3.3"
35
36
  },
36
37
  "devDependencies": {
37
38
  "@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,
@@ -179,7 +179,7 @@ describe('ChatSidebar', () => {
179
179
 
180
180
  expect(screen.getByText('Codex Task')).not.toBeNull();
181
181
  expect(screen.getByText('Codex')).not.toBeNull();
182
- expect(screen.getByText('session:codex-1')).not.toBeNull();
182
+ expect(screen.queryByText('session:codex-1')).toBeNull();
183
183
  });
184
184
 
185
185
  it('formats non-native session badges generically when the type is no longer in the available options', () => {
@@ -119,7 +119,6 @@ export function ChatSidebarSessionItem(props: ChatSidebarSessionItemProps) {
119
119
  {runStatus ? <SessionRunBadge status={runStatus} /> : null}
120
120
  </span>
121
121
  </div>
122
- <div className="mt-0.5 text-[11px] text-gray-400 truncate">{session.key}</div>
123
122
  <div className="mt-0.5 text-[11px] text-gray-400 truncate">
124
123
  {session.messageCount} · {formatDateTime(session.updatedAt)}
125
124
  </div>
@@ -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,27 @@ const mocks = vi.hoisted(() => ({
51
53
  }
52
54
  }));
53
55
 
56
+ vi.mock('qrcode', () => ({
57
+ toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,weixin-qr')
58
+ }));
59
+
60
+ vi.mock('@tanstack/react-query', async () => {
61
+ const actual = await vi.importActual<typeof import('@tanstack/react-query')>('@tanstack/react-query');
62
+ return {
63
+ ...actual,
64
+ useQueryClient: () => ({
65
+ invalidateQueries: vi.fn().mockResolvedValue(undefined)
66
+ })
67
+ };
68
+ });
69
+
54
70
  vi.mock('@/hooks/useConfig', () => ({
55
71
  useConfig: () => mocks.configQuery,
56
72
  useConfigMeta: () => mocks.metaQuery,
57
73
  useConfigSchema: () => mocks.schemaQuery,
58
74
  useUpdateChannel: () => ({
59
- mutate: mocks.mutate,
60
- mutateAsync: mocks.mutateAsync,
75
+ mutate: mocks.updateChannelMutate,
76
+ mutateAsync: mocks.updateChannelMutateAsync,
61
77
  isPending: false
62
78
  }),
63
79
  useExecuteConfigAction: () => ({
@@ -66,27 +82,80 @@ vi.mock('@/hooks/useConfig', () => ({
66
82
  })
67
83
  }));
68
84
 
85
+ vi.mock('@/hooks/use-channel-auth', () => ({
86
+ useStartChannelAuth: () => ({
87
+ mutateAsync: mocks.startChannelAuthMutateAsync,
88
+ isPending: false
89
+ }),
90
+ usePollChannelAuth: () => ({
91
+ mutateAsync: mocks.pollChannelAuthMutateAsync,
92
+ isPending: false
93
+ })
94
+ }));
95
+
69
96
  describe('ChannelsList', () => {
70
97
  beforeEach(() => {
71
- mocks.mutate.mockReset();
72
- mocks.mutateAsync.mockReset();
98
+ mocks.updateChannelMutate.mockReset();
99
+ mocks.updateChannelMutateAsync.mockReset();
100
+ mocks.startChannelAuthMutateAsync.mockReset();
101
+ mocks.pollChannelAuthMutateAsync.mockReset();
73
102
  });
74
103
 
75
- it('renders weixin and submits weixin-specific config fields', async () => {
104
+ it('renders weixin qr auth card and starts channel auth', async () => {
76
105
  const user = userEvent.setup();
106
+ mocks.startChannelAuthMutateAsync.mockResolvedValue({
107
+ channel: 'weixin',
108
+ kind: 'qr_code',
109
+ sessionId: 'session-1',
110
+ qrCode: 'qr-token',
111
+ qrCodeUrl: 'https://example.com/weixin-qr.png',
112
+ expiresAt: '2026-03-23T10:00:00.000Z',
113
+ intervalMs: 60_000,
114
+ note: '请扫码'
115
+ });
77
116
 
78
117
  render(<ChannelsList />);
79
118
 
80
119
  await user.click(await screen.findByRole('button', { name: /All Channels/i }));
81
120
 
82
121
  expect((await screen.findAllByText('Weixin')).length).toBeGreaterThan(0);
83
- expect(await screen.findByLabelText('Default Account ID')).toBeTruthy();
122
+ expect(await screen.findByRole('button', { name: 'Reconnect with QR' })).toBeTruthy();
123
+ expect(screen.getByText('Weixin now uses QR login as the primary setup flow.')).toBeTruthy();
124
+
125
+ await user.click(screen.getByRole('button', { name: 'Reconnect with QR' }));
126
+
127
+ await waitFor(() => {
128
+ expect(mocks.startChannelAuthMutateAsync).toHaveBeenCalledWith({
129
+ channel: 'weixin',
130
+ data: expect.objectContaining({
131
+ accountId: '1344b2b24720@im.bot',
132
+ baseUrl: 'https://ilinkai.weixin.qq.com'
133
+ })
134
+ });
135
+ });
136
+
137
+ await waitFor(() => {
138
+ expect(screen.getByAltText('Weixin login QR code').getAttribute('src')).toBe('data:image/png;base64,weixin-qr');
139
+ });
140
+ });
141
+
142
+ it('saves weixin advanced settings from the advanced section', async () => {
143
+ const user = userEvent.setup();
144
+
145
+ const { container } = render(<ChannelsList />);
146
+
147
+ await user.click(await screen.findByRole('button', { name: /All Channels/i }));
148
+ await user.click(await screen.findByText('Advanced settings'));
84
149
 
85
150
  const timeoutInput = await screen.findByLabelText('Long Poll Timeout (ms)');
86
151
  await user.clear(timeoutInput);
87
152
  await user.type(timeoutInput, '45000');
88
153
 
89
- const accountsJson = await screen.findByLabelText('Accounts JSON');
154
+ const accountsJson = container.querySelector('textarea#accounts') as HTMLTextAreaElement | null;
155
+ expect(accountsJson).toBeTruthy();
156
+ if (!accountsJson) {
157
+ throw new Error('accounts textarea not found');
158
+ }
90
159
  await user.clear(accountsJson);
91
160
  fireEvent.change(accountsJson, {
92
161
  target: {
@@ -106,7 +175,7 @@ describe('ChannelsList', () => {
106
175
  await user.click(screen.getByRole('button', { name: /save/i }));
107
176
 
108
177
  await waitFor(() => {
109
- expect(mocks.mutate).toHaveBeenCalledWith({
178
+ expect(mocks.updateChannelMutate).toHaveBeenCalledWith({
110
179
  channel: 'weixin',
111
180
  data: expect.objectContaining({
112
181
  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) {