@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.
- package/CHANGELOG.md +18 -0
- package/dist/assets/ChannelsList-D75wfbDS.js +8 -0
- package/dist/assets/{ChatPage-Dmpau_7n.js → ChatPage-gWZ3rDTy.js} +14 -14
- package/dist/assets/{DocBrowser-C3ijFxFF.js → DocBrowser-CebTdor0.js} +1 -1
- package/dist/assets/{LogoBadge-BgjXmBcw.js → LogoBadge-gdbraoaZ.js} +1 -1
- package/dist/assets/MarketplacePage-C-zz0lBT.js +49 -0
- package/dist/assets/{McpMarketplacePage-DPtH1xcY.js → McpMarketplacePage-tfpLh6Zz.js} +1 -1
- package/dist/assets/ModelConfig-j74dn-5k.js +1 -0
- package/dist/assets/{ProvidersList-DnWsJqMQ.js → ProvidersList-BGI9EgVV.js} +1 -1
- package/dist/assets/{RemoteAccessPage-BrXq-x0-.js → RemoteAccessPage-CusGQmZE.js} +1 -1
- package/dist/assets/{RuntimeConfig-UE9VaFO7.js → RuntimeConfig-pmhW8ifz.js} +1 -1
- package/dist/assets/{SearchConfig-CP-RM3V3.js → SearchConfig-rrD2_F5u.js} +1 -1
- package/dist/assets/{SecretsConfig-CfN_bazs.js → SecretsConfig-D7onb-hv.js} +1 -1
- package/dist/assets/{SessionsConfig-CgkKzKGv.js → SessionsConfig-m-6RSeja.js} +1 -1
- package/dist/assets/{chat-message-CGL3sMsS.js → chat-message-BO-s2mvl.js} +1 -1
- package/dist/assets/index-BsL1YIJ1.js +8 -0
- package/dist/assets/index-C63mHRbE.css +1 -0
- package/dist/assets/{label-CbOSodIL.js → label-CDSYExvV.js} +1 -1
- package/dist/assets/{page-layout-BtDnyNLf.js → page-layout-BMCVAnQM.js} +1 -1
- package/dist/assets/{popover-DGlUjPQc.js → popover-DfywyUDH.js} +1 -1
- package/dist/assets/{security-config-D6Bs1yoK.js → security-config-BU-K2EOM.js} +1 -1
- package/dist/assets/skeleton-Cg9CRkOt.js +1 -0
- package/dist/assets/{status-dot-C8vM3IN1.js → status-dot-2vau2Xtc.js} +1 -1
- package/dist/assets/{switch-AuwUiga3.js → switch-CJRPF2V6.js} +1 -1
- package/dist/assets/{tabs-custom-CTS7SaFG.js → tabs-custom-B-2uSCfW.js} +1 -1
- package/dist/assets/{useConfirmDialog-DrMAdNfN.js → useConfirmDialog-CfOpdypA.js} +1 -1
- package/dist/assets/{vendor-TJ2hy_Lv.js → vendor-DJt0Azq5.js} +90 -80
- package/dist/index.html +3 -3
- package/package.json +7 -6
- package/src/api/channel-auth.ts +35 -0
- package/src/api/channel-auth.types.ts +28 -0
- package/src/api/config.ts +2 -4
- package/src/api/types.ts +7 -26
- package/src/components/chat/ChatSidebar.test.tsx +1 -1
- package/src/components/chat/chat-sidebar-session-item.tsx +0 -1
- package/src/components/chat/ncp/ncp-session-adapter.ts +0 -1
- package/src/components/config/ChannelForm.tsx +41 -128
- package/src/components/config/ChannelsList.test.tsx +79 -10
- package/src/components/config/ModelConfig.test.tsx +78 -0
- package/src/components/config/ModelConfig.tsx +4 -1
- package/src/components/config/channel-form-fields-section.tsx +155 -0
- package/src/components/config/weixin-channel-auth-section.test.tsx +90 -0
- package/src/components/config/weixin-channel-auth-section.tsx +301 -0
- package/src/components/layout/Sidebar.tsx +128 -120
- package/src/components/layout/sidebar.layout.test.tsx +99 -0
- package/src/hooks/use-channel-auth.ts +16 -0
- package/src/lib/i18n.channel-auth.ts +37 -0
- package/src/lib/i18n.ts +2 -4
- package/src/qrcode.d.ts +10 -0
- package/src/transport/app-client.ts +22 -6
- package/dist/assets/ChannelsList-Cu_hLbps.js +0 -1
- package/dist/assets/MarketplacePage-CAIdEiw8.js +0 -49
- package/dist/assets/ModelConfig-D-pqArCg.js +0 -1
- package/dist/assets/index-D4alkESd.js +0 -8
- package/dist/assets/index-SGSkQCPi.css +0 -1
- 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-
|
|
10
|
-
<link rel="modulepreload" crossorigin href="/assets/vendor-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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.
|
|
31
|
-
"@nextclaw/agent-chat-ui": "0.2.
|
|
32
|
-
"@nextclaw/ncp": "0.3.
|
|
33
|
-
"@nextclaw/ncp-http-agent-client": "0.3.
|
|
34
|
-
"@nextclaw/ncp-react": "0.3.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
{
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
286
|
-
<
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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.
|
|
60
|
-
mutateAsync: mocks.
|
|
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.
|
|
72
|
-
mocks.
|
|
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
|
|
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.
|
|
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 =
|
|
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.
|
|
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({
|
|
98
|
+
updateModel.mutate({
|
|
99
|
+
model: composedModel,
|
|
100
|
+
workspace
|
|
101
|
+
});
|
|
99
102
|
};
|
|
100
103
|
|
|
101
104
|
if (isLoading) {
|