@nextclaw/ui 0.20.3 → 0.21.0

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 (95) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/assets/{api-iMkU_o5P.js → api-DNLQPHfQ.js} +5 -5
  3. package/dist/assets/{appearance-settings-page-CU5k4b-M.js → appearance-settings-page-BlAFMOGV.js} +1 -1
  4. package/dist/assets/{book-open-nD8gdMhA.js → book-open-BryfPTTa.js} +1 -1
  5. package/dist/assets/{channels-list-page-Drlt62kY.js → channels-list-page-Cfbgubyv.js} +2 -2
  6. package/dist/assets/{config-split-page-BtJZp1E5.js → config-split-page-Dd8xG0Jk.js} +1 -1
  7. package/dist/assets/{confirm-dialog-k2-Jq_x3.js → confirm-dialog-CZIWjnJ6.js} +2 -2
  8. package/dist/assets/{createLucideIcon-DxlwKLr-.js → createLucideIcon-BxwVe8ZD.js} +1 -1
  9. package/dist/assets/desktop-capabilities-page-C6YxsHeV.js +1 -0
  10. package/dist/assets/desktop-update-config-BVN4QPJn.js +1 -0
  11. package/dist/assets/{dist-m08DUswB.js → dist-BRloKr3S.js} +1 -1
  12. package/dist/assets/{dist-Cy_e4kNb.js → dist-Cd8Nm3BW.js} +1 -1
  13. package/dist/assets/{doc-browser-npOvWtgf.js → doc-browser-Bzgqmmiu.js} +1 -1
  14. package/dist/assets/doc-browser-D0qI0Z_R.js +1 -0
  15. package/dist/assets/{doc-browser-context-jiy-O4F8.js → doc-browser-context-3JGufiMA.js} +1 -1
  16. package/dist/assets/{extensions-CIb17__o.js → extensions-Dedd2oDI.js} +1 -1
  17. package/dist/assets/{form-actions-DGf5zUg_.js → form-actions-FWCEof3V.js} +1 -1
  18. package/dist/assets/index-CXkHcESm.css +1 -0
  19. package/dist/assets/index-D25tRg1p.js +109 -0
  20. package/dist/assets/loader-circle-BoyCoFps.js +1 -0
  21. package/dist/assets/mcp-marketplace-page-Cn-9CDh3.js +1 -0
  22. package/dist/assets/mcp-marketplace-page-e9Rmfz-d.js +10 -0
  23. package/dist/assets/model-config-page-C173Ssil.js +1 -0
  24. package/dist/assets/{navigation-link-DAfwFCj4.js → navigation-link-DTPOvGGL.js} +1 -1
  25. package/dist/assets/plus-D1R4Vipq.js +1 -0
  26. package/dist/assets/privacy-settings-page-CRserBVF.js +1 -0
  27. package/dist/assets/providers-config-page-C8jUtnZb.js +1 -0
  28. package/dist/assets/react-BAzO6LZS.js +8 -0
  29. package/dist/assets/{refresh-cw-D_Eibznw.js → refresh-cw-BCj-yjde.js} +1 -1
  30. package/dist/assets/remote-6BHWP5zi.js +1 -0
  31. package/dist/assets/{rotate-cw-QQjmoWuq.js → rotate-cw-BPnW4qUl.js} +1 -1
  32. package/dist/assets/runtime-config-page-aeQ1Dryz.js +1 -0
  33. package/dist/assets/{save-W2xthJJ2.js → save-KT0MumfJ.js} +1 -1
  34. package/dist/assets/{search-Vih1R2BD.js → search-Cv1Hx_nx.js} +1 -1
  35. package/dist/assets/{search-config-page-CtBcL2uc.js → search-config-page-b283V-fo.js} +1 -1
  36. package/dist/assets/{secrets-config-page-Q6XfIj0h.js → secrets-config-page-ss7x3BOo.js} +2 -2
  37. package/dist/assets/security-config-HHz62oxH.js +1 -0
  38. package/dist/assets/{select-lNdozU4o.js → select-BVgD4EpG.js} +1 -1
  39. package/dist/assets/skeleton-ChUmcyys.js +1 -0
  40. package/dist/assets/{tag-chip-gdaU9bE7.js → tag-chip-DZ4ircUB.js} +1 -1
  41. package/dist/assets/x-BWDRfVWL.js +1 -0
  42. package/dist/index.html +21 -21
  43. package/package.json +7 -7
  44. package/src/app/components/layout/sidebar.tsx +5 -1
  45. package/src/app/configs/app-navigation.config.ts +17 -2
  46. package/src/app/hooks/use-feature-controls.ts +11 -0
  47. package/src/app/index.tsx +10 -0
  48. package/src/features/chat/components/layout/chat-sidebar.tsx +1 -0
  49. package/src/features/chat/features/session/components/__tests__/chat-sidebar-session-item.test.tsx +12 -0
  50. package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +3 -0
  51. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +14 -1
  52. package/src/features/chat/features/session/utils/chat-session-deletion.utils.ts +25 -0
  53. package/src/features/chat/managers/__tests__/chat-thread-deletion.manager.test.ts +125 -0
  54. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +0 -37
  55. package/src/features/chat/managers/chat-thread.manager.ts +17 -28
  56. package/src/features/desktop-capabilities/components/desktop-authorization-dialog.test.tsx +58 -0
  57. package/src/features/desktop-capabilities/components/desktop-authorization-dialog.tsx +161 -0
  58. package/src/features/desktop-capabilities/hooks/use-desktop-capabilities.test.ts +10 -0
  59. package/src/features/desktop-capabilities/hooks/use-desktop-capabilities.ts +55 -0
  60. package/src/features/desktop-capabilities/index.ts +1 -0
  61. package/src/features/desktop-capabilities/managers/desktop-capability.manager.ts +25 -0
  62. package/src/features/desktop-capabilities/pages/desktop-capabilities-page.test.tsx +44 -0
  63. package/src/features/desktop-capabilities/pages/desktop-capabilities-page.tsx +171 -0
  64. package/src/features/desktop-capabilities/stores/desktop-authorization.store.ts +21 -0
  65. package/src/features/marketplace/components/mcp/__tests__/mcp-connection-dialog.test.tsx +23 -0
  66. package/src/features/marketplace/components/mcp/mcp-connection-dialog.tsx +35 -0
  67. package/src/features/marketplace/components/mcp/mcp-marketplace-page.tsx +20 -2
  68. package/src/platforms/mobile/components/mobile-settings-shell.tsx +5 -1
  69. package/src/shared/components/ui/__tests__/confirm-dialog.test.tsx +31 -0
  70. package/src/shared/components/ui/confirm-dialog.tsx +11 -1
  71. package/src/shared/lib/api/index.ts +1 -0
  72. package/src/shared/lib/api/utils/mcp-connection.utils.ts +10 -0
  73. package/src/shared/lib/i18n/locales/en-US/chat.json +2 -0
  74. package/src/shared/lib/i18n/locales/en-US/core.json +43 -0
  75. package/src/shared/lib/i18n/locales/en-US/marketplace.json +29 -1
  76. package/src/shared/lib/i18n/locales/zh-CN/chat.json +2 -0
  77. package/src/shared/lib/i18n/locales/zh-CN/core.json +43 -0
  78. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +29 -1
  79. package/dist/assets/desktop-update-config-BiZmy7FC.js +0 -1
  80. package/dist/assets/doc-browser-CPsPHh12.js +0 -1
  81. package/dist/assets/index-C86hxgv5.css +0 -1
  82. package/dist/assets/index-CYBXR7zN.js +0 -109
  83. package/dist/assets/loader-circle-BlLuRFVW.js +0 -1
  84. package/dist/assets/mcp-marketplace-page-B12JNh7Q.js +0 -1
  85. package/dist/assets/mcp-marketplace-page-kLWW6kzt.js +0 -9
  86. package/dist/assets/model-config-page-DSeAjVT_.js +0 -1
  87. package/dist/assets/plus-D4LtWtTk.js +0 -1
  88. package/dist/assets/privacy-settings-page-tN0Mt15x.js +0 -1
  89. package/dist/assets/providers-config-page-CgPrXR1u.js +0 -1
  90. package/dist/assets/react-8iBFECZE.js +0 -8
  91. package/dist/assets/remote-Derr_AQp.js +0 -1
  92. package/dist/assets/runtime-config-page-Crq96Jjq.js +0 -1
  93. package/dist/assets/security-config-CE9rkQvq.js +0 -1
  94. package/dist/assets/skeleton-B__aTcly.js +0 -1
  95. package/dist/assets/x-CWdWBu_U.js +0 -1
@@ -0,0 +1,171 @@
1
+ import type { CapabilityGrantView } from "@nextclaw/client-sdk";
2
+ import { Button } from "@/shared/components/ui/button";
3
+ import { SettingsPage } from "@/shared/components/settings/settings-page";
4
+ import {
5
+ SettingRow,
6
+ SettingsGroup,
7
+ SettingsSection,
8
+ } from "@/shared/components/settings/setting-row";
9
+ import { t } from "@/shared/lib/i18n";
10
+ import {
11
+ useDesktopCapabilityGrants,
12
+ useDesktopCapabilityStatus,
13
+ useOpenDesktopSystemSettings,
14
+ useRequestDesktopSystemPermission,
15
+ useRevokeDesktopGrant,
16
+ } from "../hooks/use-desktop-capabilities";
17
+
18
+ export function DesktopCapabilitiesPage() {
19
+ const statusQuery = useDesktopCapabilityStatus();
20
+ const grantsQuery = useDesktopCapabilityGrants();
21
+ const requestPermission = useRequestDesktopSystemPermission();
22
+ const openSettings = useOpenDesktopSystemSettings();
23
+ const revokeGrant = useRevokeDesktopGrant();
24
+ const status = statusQuery.data;
25
+ const busy = requestPermission.isPending || openSettings.isPending;
26
+
27
+ return (
28
+ <SettingsPage
29
+ title={t("desktopCapabilitiesPageTitle")}
30
+ description={t("desktopCapabilitiesPageDescription")}
31
+ >
32
+ <SettingsSection
33
+ title={t("desktopCapabilitiesSystemTitle")}
34
+ description={t("desktopCapabilitiesSystemDescription")}
35
+ >
36
+ <SettingsGroup>
37
+ <SettingRow
38
+ title={t("desktopCapabilitiesHostStatus")}
39
+ description={
40
+ statusQuery.isLoading
41
+ ? t("loading")
42
+ : status?.online
43
+ ? t("desktopCapabilitiesHostOnline")
44
+ : t("desktopCapabilitiesHostOffline")
45
+ }
46
+ control={(
47
+ <Button
48
+ type="button"
49
+ variant="outline"
50
+ size="sm"
51
+ disabled={statusQuery.isFetching}
52
+ onClick={() => void statusQuery.refetch()}
53
+ >
54
+ {t("desktopCapabilitiesRecheck")}
55
+ </Button>
56
+ )}
57
+ />
58
+ <SettingRow
59
+ title={t("desktopCapabilitiesAccessibility")}
60
+ description={formatPermission(status?.permissions.accessibility)}
61
+ control={(
62
+ <div className="flex flex-wrap justify-end gap-2">
63
+ <Button
64
+ type="button"
65
+ variant="outline"
66
+ size="sm"
67
+ disabled={busy}
68
+ onClick={() => openSettings.mutate()}
69
+ >
70
+ {t("desktopCapabilitiesOpenSettings")}
71
+ </Button>
72
+ <Button
73
+ type="button"
74
+ size="sm"
75
+ disabled={busy}
76
+ onClick={() => requestPermission.mutate()}
77
+ >
78
+ {t("desktopCapabilitiesRequestPermission")}
79
+ </Button>
80
+ </div>
81
+ )}
82
+ />
83
+ </SettingsGroup>
84
+ </SettingsSection>
85
+
86
+ <SettingsSection
87
+ title={t("desktopCapabilitiesGrantsTitle")}
88
+ description={t("desktopCapabilitiesGrantsDescription")}
89
+ >
90
+ <SettingsGroup>
91
+ {grantsQuery.isLoading ? (
92
+ <SettingRow title={t("loading")} />
93
+ ) : grantsQuery.data?.length ? (
94
+ grantsQuery.data.map((grant) => (
95
+ <DesktopGrantRow
96
+ key={grantKey(grant)}
97
+ grant={grant}
98
+ disabled={revokeGrant.isPending}
99
+ onRevoke={() => revokeGrant.mutate(grant)}
100
+ />
101
+ ))
102
+ ) : (
103
+ <SettingRow
104
+ title={t("desktopCapabilitiesNoGrants")}
105
+ description={t("desktopCapabilitiesNoGrantsDescription")}
106
+ />
107
+ )}
108
+ </SettingsGroup>
109
+ </SettingsSection>
110
+ </SettingsPage>
111
+ );
112
+ }
113
+
114
+ function DesktopGrantRow({
115
+ disabled,
116
+ grant,
117
+ onRevoke,
118
+ }: {
119
+ disabled: boolean;
120
+ grant: CapabilityGrantView;
121
+ onRevoke: () => void;
122
+ }) {
123
+ const target = readApplicationId(grant.resource.target);
124
+ const subject = grant.subject.type === "agent"
125
+ ? `${t("desktopCapabilitiesAgent")} · ${grant.subject.id}`
126
+ : `${t("desktopCapabilitiesExtension")} · ${grant.subject.id}`;
127
+ return (
128
+ <SettingRow
129
+ title={subject}
130
+ description={`${target} · ${grant.access.join(", ")}`}
131
+ control={(
132
+ <Button
133
+ type="button"
134
+ variant="destructive"
135
+ size="sm"
136
+ disabled={disabled}
137
+ onClick={onRevoke}
138
+ >
139
+ {t("desktopCapabilitiesRevoke")}
140
+ </Button>
141
+ )}
142
+ />
143
+ );
144
+ }
145
+
146
+ function readApplicationId(target: unknown): string {
147
+ if (!target || typeof target !== "object" || Array.isArray(target)) {
148
+ return t("desktopCapabilitiesUnknownApplication");
149
+ }
150
+ const { applicationId } = target as { applicationId?: unknown };
151
+ return typeof applicationId === "string" && applicationId.trim()
152
+ ? applicationId.trim()
153
+ : t("desktopCapabilitiesUnknownApplication");
154
+ }
155
+
156
+ function formatPermission(value: string | undefined): string {
157
+ switch (value) {
158
+ case "granted":
159
+ return t("desktopCapabilitiesPermissionGranted");
160
+ case "not_granted":
161
+ return t("desktopCapabilitiesPermissionNotGranted");
162
+ case "not_supported":
163
+ return t("desktopCapabilitiesPermissionNotSupported");
164
+ default:
165
+ return t("desktopCapabilitiesPermissionUnknown");
166
+ }
167
+ }
168
+
169
+ function grantKey(grant: CapabilityGrantView): string {
170
+ return `${grant.subject.type}:${grant.subject.id}:${JSON.stringify(grant.resource.target)}:${grant.access.join(",")}`;
171
+ }
@@ -0,0 +1,21 @@
1
+ import { create } from "zustand";
2
+ import type { CapabilityGrantRequestView } from "@nextclaw/client-sdk";
3
+
4
+ export type DesktopAuthorizationRequest = {
5
+ applicationId: string;
6
+ request: CapabilityGrantRequestView;
7
+ };
8
+
9
+ type DesktopAuthorizationState = {
10
+ pending: DesktopAuthorizationRequest | null;
11
+ present: (request: DesktopAuthorizationRequest) => void;
12
+ clear: () => void;
13
+ };
14
+
15
+ export const useDesktopAuthorizationStore = create<DesktopAuthorizationState>(
16
+ (set) => ({
17
+ pending: null,
18
+ present: (pending) => set({ pending }),
19
+ clear: () => set({ pending: null }),
20
+ }),
21
+ );
@@ -0,0 +1,23 @@
1
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { McpConnectionDialog } from "../mcp-connection-dialog";
4
+
5
+ describe("McpConnectionDialog", () => {
6
+ it("requires a successful test before saving a stdio connection", async () => {
7
+ const onTest = vi.fn(async () => ({ name: "local-tools", transport: "stdio" as const, accessible: true, toolCount: 2 }));
8
+ const onSave = vi.fn(async () => undefined);
9
+ render(<McpConnectionDialog open testing={false} saving={false} onOpenChange={vi.fn()} onTest={onTest} onSave={onSave} />);
10
+
11
+ const saveButton = screen.getByRole("button", { name: "Save connection" });
12
+ expect(saveButton.hasAttribute("disabled")).toBe(true);
13
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "local-tools" } });
14
+ fireEvent.change(screen.getByLabelText("Command"), { target: { value: "node" } });
15
+ fireEvent.click(screen.getByRole("button", { name: "Test connection" }));
16
+
17
+ await waitFor(() => expect(onTest).toHaveBeenCalledOnce());
18
+ expect(await screen.findByText("Connected. Found 2 tools.")).not.toBeNull();
19
+ expect(saveButton.hasAttribute("disabled")).toBe(false);
20
+ fireEvent.click(saveButton);
21
+ await waitFor(() => expect(onSave).toHaveBeenCalledOnce());
22
+ });
23
+ });
@@ -0,0 +1,35 @@
1
+ import { useMemo, useState, type ReactNode } from "react";
2
+ import type { McpConnectionRequest, McpConnectionTestResult } from "@nextclaw/client-sdk";
3
+ import { Button } from "@/shared/components/ui/button";
4
+ import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/shared/components/ui/overlays/sheet";
5
+ import { Input } from "@/shared/components/ui/input";
6
+ import { Textarea } from "@/shared/components/ui/textarea";
7
+ import { Switch } from "@/shared/components/ui/switch";
8
+ import { t } from "@/shared/lib/i18n";
9
+
10
+ type Transport = "stdio" | "http" | "sse";
11
+
12
+ export function McpConnectionDialog({ open, testing, saving, onOpenChange, onTest, onSave }: {
13
+ open: boolean; testing: boolean; saving: boolean; onOpenChange: (open: boolean) => void;
14
+ onTest: (request: McpConnectionRequest) => Promise<McpConnectionTestResult>; onSave: (request: McpConnectionRequest) => Promise<void>;
15
+ }) {
16
+ const [transport, setTransport] = useState<Transport>("stdio");
17
+ const [name, setName] = useState(""); const [command, setCommand] = useState(""); const [args, setArgs] = useState("");
18
+ const [cwd, setCwd] = useState(""); const [env, setEnv] = useState(""); const [url, setUrl] = useState("");
19
+ const [headers, setHeaders] = useState(""); const [timeout, setTimeout] = useState("15000"); const [verifyTls, setVerifyTls] = useState(true);
20
+ const [result, setResult] = useState<McpConnectionTestResult | null>(null); const [error, setError] = useState<string | null>(null);
21
+ const request = useMemo(() => buildRequest({ transport, name, command, args, cwd, env, url, headers, timeout, verifyTls }), [args, command, cwd, env, headers, name, timeout, transport, url, verifyTls]);
22
+ const dirty = () => { setResult(null); setError(null); };
23
+ const test = async () => { if (request instanceof Error) return setError(request.message); dirty(); try { const next = await onTest(request); setResult(next); if (!next.accessible) setError(next.error ?? t("mcpConnectionTestFailed")); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } };
24
+ const save = async () => { if (request instanceof Error || !result?.accessible) return; try { await onSave(request); onOpenChange(false); dirty(); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } };
25
+ return <Sheet open={open} onOpenChange={onOpenChange}><SheetContent side="right" closeLabel={t("cancel")} className="flex h-full w-full flex-col overflow-hidden p-0 sm:max-w-[560px]"><SheetHeader className="shrink-0 border-b border-border/60 px-5 pb-5 pt-6 pr-14 sm:px-7 sm:pt-7"><SheetTitle>{t("mcpConnectionTitle")}</SheetTitle><SheetDescription>{t("mcpConnectionDescription")}</SheetDescription></SheetHeader><div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-6 sm:px-7">
26
+ <Field label={t("mcpConnectionTransport")}><select className="flex h-10 w-full rounded-xl border border-border/75 bg-card px-3 text-sm" value={transport} onChange={(e) => { setTransport(e.target.value as Transport); dirty(); }}><option value="stdio">{t("mcpConnectionStdio")}</option><option value="http">HTTP</option><option value="sse">SSE</option></select></Field>
27
+ <Field label={t("mcpConnectionName")}><Input value={name} onChange={(e) => { setName(e.target.value); dirty(); }} placeholder="my-mcp" /></Field>
28
+ {transport === "stdio" ? <><Field label={t("mcpConnectionCommand")}><Input value={command} onChange={(e) => { setCommand(e.target.value); dirty(); }} placeholder="npx" /></Field><Field label={t("mcpConnectionArgs")} hint={t("mcpConnectionArgsHint")}><Input value={args} onChange={(e) => { setArgs(e.target.value); dirty(); }} placeholder="-y @example/mcp-server" /></Field><Field label={t("mcpConnectionCwd")}><Input value={cwd} onChange={(e) => { setCwd(e.target.value); dirty(); }} /></Field><Field label={t("mcpConnectionEnv")} hint={t("mcpConnectionPairsHint")}><Textarea className="min-h-20 font-mono" value={env} onChange={(e) => { setEnv(e.target.value); dirty(); }} placeholder="API_KEY=..." /></Field><p className="rounded-xl bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-100">{t("mcpConnectionLocalWarning")}</p></> : <><Field label={t("mcpConnectionUrl")}><Input type="url" value={url} onChange={(e) => { setUrl(e.target.value); dirty(); }} placeholder="https://example.com/mcp" /></Field><Field label={t("mcpConnectionHeaders")} hint={t("mcpConnectionPairsHint")}><Textarea className="min-h-20 font-mono" value={headers} onChange={(e) => { setHeaders(e.target.value); dirty(); }} placeholder="Authorization=Bearer ..." /></Field><Field label={t("mcpConnectionTimeout")}><Input type="number" min="1" value={timeout} onChange={(e) => { setTimeout(e.target.value); dirty(); }} /></Field><div className="flex items-center justify-between rounded-xl bg-muted/60 px-3 py-3"><span className="text-sm">{t("mcpConnectionVerifyTls")}</span><Switch checked={verifyTls} onCheckedChange={(value) => { setVerifyTls(value); dirty(); }} /></div><p className="rounded-xl bg-muted px-3 py-2 text-sm text-muted-foreground">{t("mcpConnectionRemoteWarning")}</p></>}
29
+ {result?.accessible ? <p className="rounded-xl bg-emerald-500/10 px-3 py-2 text-sm text-emerald-800 dark:text-emerald-100">{t("mcpConnectionTestSuccess").replace("{count}", String(result.toolCount))}</p> : null}{error ? <p role="alert" className="rounded-xl bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p> : null}
30
+ </div><SheetFooter className="shrink-0 border-t border-border/60 bg-background px-5 py-4 sm:px-7"><Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={testing || saving}>{t("cancel")}</Button><Button type="button" variant="outline" onClick={() => void test()} disabled={testing || saving}>{testing ? t("mcpConnectionTesting") : t("mcpConnectionTest")}</Button><Button type="button" onClick={() => void save()} disabled={!result?.accessible || testing || saving}>{saving ? t("saving") : t("mcpConnectionSave")}</Button></SheetFooter></SheetContent></Sheet>;
31
+ }
32
+
33
+ function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { return <label className="block space-y-1.5"><span className="text-sm font-medium">{label}</span>{hint ? <span className="block text-xs text-muted-foreground">{hint}</span> : null}{children}</label>; }
34
+ function buildRequest(input: { transport: Transport; name: string; command: string; args: string; cwd: string; env: string; url: string; headers: string; timeout: string; verifyTls: boolean }): McpConnectionRequest | Error { try { const name = input.name.trim(); if (!name) throw new Error(t("mcpConnectionNameRequired")); if (input.transport === "stdio") { const command = input.command.trim(); if (!command) throw new Error(t("mcpConnectionCommandRequired")); return { name, definition: { enabled: true, transport: { type: "stdio", command, args: input.args.trim() ? input.args.trim().split(/\s+/) : [], cwd: input.cwd.trim(), env: pairs(input.env), stderr: "pipe" }, scope: { allAgents: true, agents: [] }, policy: { trust: "explicit", start: "eager" } } }; } const url = input.url.trim(); if (!url) throw new Error(t("mcpConnectionUrlRequired")); const timeoutMs = Number(input.timeout); if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error(t("mcpConnectionTimeoutInvalid")); const common = { url, headers: pairs(input.headers), timeoutMs: Math.trunc(timeoutMs), verifyTls: input.verifyTls }; return { name, definition: { enabled: true, transport: input.transport === "http" ? { type: "http", ...common } : { type: "sse", ...common, reconnect: { enabled: true, initialDelayMs: 1000, maxDelayMs: 30000 } }, scope: { allAgents: true, agents: [] }, policy: { trust: "explicit", start: "eager" } } }; } catch (error) { return error instanceof Error ? error : new Error(String(error)); } }
35
+ function pairs(value: string): Record<string, string> { return Object.fromEntries(value.split("\n").filter(Boolean).map((line) => { const index = line.indexOf("="); if (index <= 0) throw new Error(t("mcpConnectionPairsInvalid")); return [line.slice(0, index).trim(), line.slice(index + 1)]; })); }
@@ -9,7 +9,9 @@ import type {
9
9
  } from "@/shared/lib/api";
10
10
  import {
11
11
  fetchMcpMarketplaceContent,
12
+ createMcpConnection,
12
13
  doctorMcpMarketplaceItem,
14
+ testMcpConnection,
13
15
  } from "@/shared/lib/api";
14
16
  import { SettingsPage } from "@/shared/components/settings/settings-page";
15
17
  import {
@@ -19,6 +21,7 @@ import {
19
21
  } from "@/features/marketplace/components/marketplace-page-parts";
20
22
  import { buildLocaleFallbacks } from "@/features/marketplace/components/marketplace-localization";
21
23
  import { McpMarketplaceCard } from "@/features/marketplace/components/mcp/mcp-marketplace-card";
24
+ import { McpConnectionDialog } from "@/features/marketplace/components/mcp/mcp-connection-dialog";
22
25
  import {
23
26
  buildInstalledRecordLookup,
24
27
  findInstalledRecordForItem,
@@ -46,6 +49,7 @@ import { t } from "@/shared/lib/i18n";
46
49
  import { useInfiniteScrollLoader } from "@/shared/hooks/use-infinite-scroll-loader";
47
50
  import { cn } from "@/shared/lib/utils";
48
51
  import { Sparkles, PackageCheck } from "lucide-react";
52
+ import { Button } from "@/shared/components/ui/button";
49
53
 
50
54
  type ScopeType = "catalog" | "installed";
51
55
 
@@ -61,6 +65,7 @@ export function McpMarketplacePage() {
61
65
  const [doctorTarget, setDoctorTarget] = useState<string | null>(null);
62
66
  const [doctorResult, setDoctorResult] =
63
67
  useState<MarketplaceMcpDoctorResult | null>(null);
68
+ const [connectionOpen, setConnectionOpen] = useState(false);
64
69
  const { language } = useI18n();
65
70
  const docBrowser = useDocBrowser();
66
71
  const { confirm, ConfirmDialog } = useConfirmDialog();
@@ -110,6 +115,8 @@ export function McpMarketplacePage() {
110
115
  setDoctorResult(result);
111
116
  },
112
117
  });
118
+ const testConnectionMutation = useMutation({ mutationFn: testMcpConnection });
119
+ const createConnectionMutation = useMutation({ mutationFn: createMcpConnection });
113
120
 
114
121
  const installedRecordLookup = useMemo(() => {
115
122
  return buildInstalledRecordLookup(installedQuery.data?.records ?? []);
@@ -325,6 +332,9 @@ export function McpMarketplacePage() {
325
332
  : (installedQuery.data?.total ?? 0)}
326
333
  </span>
327
334
  </h3>
335
+ <Button type="button" size="sm" onClick={() => setConnectionOpen(true)}>
336
+ {t("mcpConnectionOpen")}
337
+ </Button>
328
338
  </div>
329
339
 
330
340
  <div
@@ -441,13 +451,21 @@ export function McpMarketplacePage() {
441
451
  </div>
442
452
  </section>
443
453
 
444
- <InstallDialog
454
+ <InstallDialog
445
455
  item={installingItem}
446
456
  open={Boolean(installingItem)}
447
457
  pending={installMutation.isPending}
448
458
  onOpenChange={(open) => !open && setInstallingItem(null)}
449
459
  onSubmit={handleInstall}
450
- />
460
+ />
461
+ <McpConnectionDialog
462
+ open={connectionOpen}
463
+ testing={testConnectionMutation.isPending}
464
+ saving={createConnectionMutation.isPending}
465
+ onOpenChange={setConnectionOpen}
466
+ onTest={(request) => testConnectionMutation.mutateAsync(request)}
467
+ onSave={async (request) => { await createConnectionMutation.mutateAsync(request); }}
468
+ />
451
469
  <DoctorDialog
452
470
  open={Boolean(doctorTarget)}
453
471
  targetName={doctorTarget}
@@ -2,9 +2,13 @@ import { NavLink } from "react-router-dom";
2
2
  import { getSettingsNavItems } from "@/app/configs/app-navigation.config";
3
3
  import { t } from "@/shared/lib/i18n";
4
4
  import { cn } from "@/shared/lib/utils";
5
+ import { useDesktopCapabilityAvailability } from "@/features/desktop-capabilities/hooks/use-desktop-capabilities";
5
6
 
6
7
  export function MobileSettingsShell() {
7
- const settingsNavItems = getSettingsNavItems(t);
8
+ const desktopCapabilityAvailable = useDesktopCapabilityAvailability();
9
+ const settingsNavItems = getSettingsNavItems(t, {
10
+ includeDesktopCapabilities: desktopCapabilityAvailable,
11
+ });
8
12
 
9
13
  return (
10
14
  <div
@@ -0,0 +1,31 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { ConfirmDialog } from '@/shared/components/ui/confirm-dialog';
5
+
6
+ describe('ConfirmDialog', () => {
7
+ it('focuses the confirm action so Enter confirms the dialog', async () => {
8
+ const user = userEvent.setup();
9
+ const onConfirm = vi.fn();
10
+
11
+ render(
12
+ <ConfirmDialog
13
+ open
14
+ onOpenChange={vi.fn()}
15
+ title="Delete session"
16
+ description="This action cannot be undone."
17
+ confirmLabel="Delete"
18
+ cancelLabel="Cancel"
19
+ onConfirm={onConfirm}
20
+ onCancel={vi.fn()}
21
+ />,
22
+ );
23
+
24
+ const confirmButton = await screen.findByRole('button', { name: 'Delete' });
25
+ expect(document.activeElement).toBe(confirmButton);
26
+
27
+ await user.keyboard('{Enter}');
28
+
29
+ expect(onConfirm).toHaveBeenCalledOnce();
30
+ });
31
+ });
@@ -35,6 +35,8 @@ export const ConfirmDialog = ({
35
35
  onConfirm,
36
36
  onCancel
37
37
  }: ConfirmDialogProps) => {
38
+ const confirmButtonRef = React.useRef<HTMLButtonElement>(null);
39
+
38
40
  const handleConfirm = () => {
39
41
  onConfirm();
40
42
  onOpenChange(false);
@@ -45,7 +47,14 @@ export const ConfirmDialog = ({
45
47
  };
46
48
  return (
47
49
  <Dialog open={open} onOpenChange={onOpenChange}>
48
- <DialogContent className="[&>:last-child]:hidden" onCloseAutoFocus={(e) => e.preventDefault()}>
50
+ <DialogContent
51
+ className="[&>:last-child]:hidden"
52
+ onOpenAutoFocus={(event) => {
53
+ event.preventDefault();
54
+ confirmButtonRef.current?.focus();
55
+ }}
56
+ onCloseAutoFocus={(event) => event.preventDefault()}
57
+ >
49
58
  <DialogHeader>
50
59
  <DialogTitle>{title}</DialogTitle>
51
60
  {description ? <DialogDescription>{description}</DialogDescription> : null}
@@ -55,6 +64,7 @@ export const ConfirmDialog = ({
55
64
  {cancelLabel}
56
65
  </Button>
57
66
  <Button
67
+ ref={confirmButtonRef}
58
68
  type="button"
59
69
  variant={variant === 'destructive' ? 'destructive' : 'default'}
60
70
  onClick={handleConfirm}
@@ -8,6 +8,7 @@ export * from './managers/client.manager';
8
8
  export * from './utils/config.utils';
9
9
  export * from './utils/marketplace.utils';
10
10
  export * from './utils/mcp-marketplace.utils';
11
+ export * from './utils/mcp-connection.utils';
11
12
  export * from './utils/ncp-attachments.utils';
12
13
  export * from './utils/ncp-session.utils';
13
14
  export * from './projects/project.utils';
@@ -0,0 +1,10 @@
1
+ import type { McpConnectionCreateResult, McpConnectionRequest, McpConnectionTestResult } from "@nextclaw/client-sdk";
2
+ import { nextclawClient } from "@/shared/lib/api/managers/client.manager";
3
+
4
+ export async function testMcpConnection(request: McpConnectionRequest): Promise<McpConnectionTestResult> {
5
+ return await nextclawClient.mcp.testConnection(request);
6
+ }
7
+
8
+ export async function createMcpConnection(request: McpConnectionRequest): Promise<McpConnectionCreateResult> {
9
+ return await nextclawClient.mcp.createConnection(request);
10
+ }
@@ -476,6 +476,8 @@
476
476
  "chatSteeringPending": "Waiting for the next step",
477
477
  "chatDeleteSession": "Delete Session",
478
478
  "chatDeleteSessionConfirm": "Delete the current session?",
479
+ "chatDeleteSessionSucceeded": "Session deleted.",
480
+ "chatDeleteSessionFailed": "Could not delete session",
479
481
  "chatSessionMoreActions": "More actions",
480
482
  "chatSessionCopyId": "Copy session ID",
481
483
  "chatSessionCopyIdSuccess": "Session ID copied",
@@ -26,6 +26,49 @@
26
26
  "sidebarExpand": "Expand sidebar",
27
27
  "security": "Sign-in Management",
28
28
  "privacy": "Privacy & Analytics",
29
+ "desktopCapabilities": "Desktop Access",
30
+ "desktopCapabilitiesPageTitle": "Desktop Access",
31
+ "desktopCapabilitiesPageDescription": "Check NextClaw Desktop system permissions and manage which desktop apps AI agents and Extensions can access.",
32
+ "desktopCapabilitiesSystemTitle": "System permission",
33
+ "desktopCapabilitiesSystemDescription": "macOS only needs permission for NextClaw Desktop. Each AI agent or Extension still needs separate access to an app.",
34
+ "desktopCapabilitiesHostStatus": "Desktop Host",
35
+ "desktopCapabilitiesHostOnline": "Connected and ready to check desktop apps and system permissions.",
36
+ "desktopCapabilitiesHostOffline": "Not connected. Start NextClaw Desktop first.",
37
+ "desktopCapabilitiesRecheck": "Check again",
38
+ "desktopCapabilitiesAccessibility": "Accessibility",
39
+ "desktopCapabilitiesOpenSettings": "Open System Settings",
40
+ "desktopCapabilitiesRequestPermission": "Request permission",
41
+ "desktopCapabilitiesPermissionGranted": "NextClaw Desktop can control local apps.",
42
+ "desktopCapabilitiesPermissionNotGranted": "Not allowed yet. Open System Settings and enable Accessibility for NextClaw Desktop.",
43
+ "desktopCapabilitiesPermissionNotSupported": "Desktop access is not supported on this system.",
44
+ "desktopCapabilitiesPermissionUnknown": "The permission state is currently unavailable.",
45
+ "desktopCapabilitiesGrantsTitle": "App access",
46
+ "desktopCapabilitiesGrantsDescription": "These permissions decide which AI agent or Extension can read or operate an app. Revoking access stops related operations immediately.",
47
+ "desktopCapabilitiesNoGrants": "No app access yet",
48
+ "desktopCapabilitiesNoGrantsDescription": "NextClaw will ask for permission when an AI agent or Extension first accesses a desktop app.",
49
+ "desktopCapabilitiesAgent": "AI",
50
+ "desktopCapabilitiesExtension": "Extension",
51
+ "desktopCapabilitiesUnknownApplication": "Unknown app",
52
+ "desktopCapabilitiesRevoke": "Revoke access",
53
+ "desktopAuthorizationTitle": "Allow desktop access?",
54
+ "desktopAuthorizationDescription": "This action needs access to a local app. Check who is asking, which app, and what they can do.",
55
+ "desktopAuthorizationCaller": "Requested by",
56
+ "desktopAuthorizationApplication": "App",
57
+ "desktopAuthorizationAccess": "Access",
58
+ "desktopAuthorizationRead": "Read visible content",
59
+ "desktopAuthorizationObserve": "Watch visible changes",
60
+ "desktopAuthorizationWrite": "Write to the app",
61
+ "desktopAuthorizationScreenCapture": "Capture visible window content",
62
+ "desktopAuthorizationPointerInput": "Click inside the app window",
63
+ "desktopAuthorizationReadRisk": "Once allowed, this requester can read or watch currently visible content in this app until you revoke access in Desktop Access.",
64
+ "desktopAuthorizationWriteRisk": "Once allowed, this AI agent can write a draft into this app. This permission does not allow sending or confirming.",
65
+ "desktopAuthorizationScreenCaptureRisk": "Once allowed, this requester can capture the currently visible content of this app window for AI understanding until you revoke access in Desktop Access.",
66
+ "desktopAuthorizationPointerInputRisk": "Once allowed, this AI can click inside this app's authorized window. It cannot use keys, scroll, drag, send, or confirm.",
67
+ "desktopAuthorizationReject": "Don't allow",
68
+ "desktopAuthorizationAllow": "Allow",
69
+ "desktopAuthorizationAllowing": "Allowing…",
70
+ "desktopAuthorizationAllowed": "Access allowed. The AI can retry the previous action.",
71
+ "desktopAuthorizationFailed": "Desktop access could not be saved.",
29
72
  "backToMain": "Back to Main",
30
73
  "enabled": "Enabled",
31
74
  "disabled": "Disabled",
@@ -79,5 +79,33 @@
79
79
  "marketplaceMcpDoctorTools": "Tools",
80
80
  "marketplaceMcpRemove": "Remove",
81
81
  "marketplaceMcpRemoveTitle": "Remove MCP Server",
82
- "marketplaceMcpRemoveDescription": "This removes the MCP server config and hot-unplugs it from the tool source immediately."
82
+ "marketplaceMcpRemoveDescription": "This removes the MCP server config and hot-unplugs it from the tool source immediately.",
83
+ "mcpConnectionOpen": "Connect external MCP",
84
+ "mcpConnectionTitle": "Connect external MCP",
85
+ "mcpConnectionDescription": "Test the connection, then save it for your agents to use.",
86
+ "mcpConnectionTransport": "Connection type",
87
+ "mcpConnectionStdio": "Local command (stdio)",
88
+ "mcpConnectionName": "Name",
89
+ "mcpConnectionCommand": "Command",
90
+ "mcpConnectionArgs": "Command arguments",
91
+ "mcpConnectionArgsHint": "Separate arguments with spaces.",
92
+ "mcpConnectionCwd": "Working directory (optional)",
93
+ "mcpConnectionEnv": "Environment variables (optional)",
94
+ "mcpConnectionHeaders": "Request headers (optional)",
95
+ "mcpConnectionPairsHint": "One key=value pair per line.",
96
+ "mcpConnectionUrl": "Server URL",
97
+ "mcpConnectionTimeout": "Timeout (ms)",
98
+ "mcpConnectionVerifyTls": "Verify TLS certificate",
99
+ "mcpConnectionLocalWarning": "This MCP runs a command on this computer. Connect only to servers you trust.",
100
+ "mcpConnectionRemoteWarning": "NextClaw connects to this remote service. Confirm that its address and permissions are trusted.",
101
+ "mcpConnectionTest": "Test connection",
102
+ "mcpConnectionTesting": "Testing",
103
+ "mcpConnectionSave": "Save connection",
104
+ "mcpConnectionTestSuccess": "Connected. Found {count} tools.",
105
+ "mcpConnectionTestFailed": "Could not connect to the MCP server.",
106
+ "mcpConnectionNameRequired": "Enter an MCP name.",
107
+ "mcpConnectionCommandRequired": "Enter a command.",
108
+ "mcpConnectionUrlRequired": "Enter a server URL.",
109
+ "mcpConnectionTimeoutInvalid": "Timeout must be greater than 0.",
110
+ "mcpConnectionPairsInvalid": "Each line must use key=value."
83
111
  }
@@ -476,6 +476,8 @@
476
476
  "chatSteeringPending": "等待进入下一步",
477
477
  "chatDeleteSession": "删除会话",
478
478
  "chatDeleteSessionConfirm": "确认删除当前会话?",
479
+ "chatDeleteSessionSucceeded": "会话已删除。",
480
+ "chatDeleteSessionFailed": "无法删除会话",
479
481
  "chatSessionMoreActions": "更多操作",
480
482
  "chatSessionCopyId": "复制会话 ID",
481
483
  "chatSessionCopyIdSuccess": "会话 ID 已复制",
@@ -26,6 +26,49 @@
26
26
  "sidebarExpand": "展开侧边栏",
27
27
  "security": "登录管理",
28
28
  "privacy": "隐私与统计",
29
+ "desktopCapabilities": "桌面操作",
30
+ "desktopCapabilitiesPageTitle": "桌面操作",
31
+ "desktopCapabilitiesPageDescription": "查看 NextClaw Desktop 的系统权限,并管理 AI 和 Extension 可以访问的桌面应用。",
32
+ "desktopCapabilitiesSystemTitle": "系统权限",
33
+ "desktopCapabilitiesSystemDescription": "macOS 只需要授权 NextClaw Desktop;每个 AI 或 Extension 仍需单独获得应用访问许可。",
34
+ "desktopCapabilitiesHostStatus": "Desktop Host",
35
+ "desktopCapabilitiesHostOnline": "已连接,可以检查桌面应用和系统权限。",
36
+ "desktopCapabilitiesHostOffline": "未连接。请先启动 NextClaw Desktop。",
37
+ "desktopCapabilitiesRecheck": "重新检查",
38
+ "desktopCapabilitiesAccessibility": "辅助功能",
39
+ "desktopCapabilitiesOpenSettings": "打开系统设置",
40
+ "desktopCapabilitiesRequestPermission": "请求权限",
41
+ "desktopCapabilitiesPermissionGranted": "已允许 NextClaw Desktop 控制本机应用。",
42
+ "desktopCapabilitiesPermissionNotGranted": "尚未允许。打开系统设置后为 NextClaw Desktop 开启辅助功能。",
43
+ "desktopCapabilitiesPermissionNotSupported": "当前系统不支持这项桌面操作。",
44
+ "desktopCapabilitiesPermissionUnknown": "暂时无法读取权限状态。",
45
+ "desktopCapabilitiesGrantsTitle": "应用访问许可",
46
+ "desktopCapabilitiesGrantsDescription": "这里的许可决定哪个 AI 或 Extension 可以读取或操作哪个应用。撤销后,相关操作会立即停止。",
47
+ "desktopCapabilitiesNoGrants": "还没有应用访问许可",
48
+ "desktopCapabilitiesNoGrantsDescription": "AI 或 Extension 首次访问桌面应用时,NextClaw 会请求你的允许。",
49
+ "desktopCapabilitiesAgent": "AI",
50
+ "desktopCapabilitiesExtension": "Extension",
51
+ "desktopCapabilitiesUnknownApplication": "未知应用",
52
+ "desktopCapabilitiesRevoke": "撤销许可",
53
+ "desktopAuthorizationTitle": "允许桌面访问?",
54
+ "desktopAuthorizationDescription": "这项操作需要访问本机应用。请确认发起者、应用和操作范围。",
55
+ "desktopAuthorizationCaller": "发起者",
56
+ "desktopAuthorizationApplication": "应用",
57
+ "desktopAuthorizationAccess": "操作范围",
58
+ "desktopAuthorizationRead": "读取可见内容",
59
+ "desktopAuthorizationObserve": "持续观察可见变化",
60
+ "desktopAuthorizationWrite": "写入应用",
61
+ "desktopAuthorizationScreenCapture": "截取当前窗口可见内容",
62
+ "desktopAuthorizationPointerInput": "在应用窗口内点击",
63
+ "desktopAuthorizationReadRisk": "允许后,这个发起者可以读取或观察该应用中当前可见的内容,直到你在“桌面操作”中撤销许可。",
64
+ "desktopAuthorizationWriteRisk": "允许后,这个 AI 可以向该应用写入草稿。当前许可不包含发送或确认操作。",
65
+ "desktopAuthorizationScreenCaptureRisk": "允许后,这个发起者可以截取该应用当前窗口的可见内容并交给 AI 理解,直到你在“桌面操作”中撤销许可。",
66
+ "desktopAuthorizationPointerInputRisk": "允许后,这个 AI 可以在该应用已授权的窗口内点击,但不能按键、滚动、拖拽、发送或确认。",
67
+ "desktopAuthorizationReject": "不允许",
68
+ "desktopAuthorizationAllow": "允许",
69
+ "desktopAuthorizationAllowing": "正在允许…",
70
+ "desktopAuthorizationAllowed": "已允许。AI 可以重试刚才的操作。",
71
+ "desktopAuthorizationFailed": "未能保存桌面访问许可。",
29
72
  "backToMain": "返回主界面",
30
73
  "enabled": "启用",
31
74
  "disabled": "禁用",
@@ -79,5 +79,33 @@
79
79
  "marketplaceMcpDoctorTools": "工具数",
80
80
  "marketplaceMcpRemove": "移除",
81
81
  "marketplaceMcpRemoveTitle": "确认移除",
82
- "marketplaceMcpRemoveDescription": "该操作会删除 MCP server 配置,并通过热插拔立即从工具源中移除。"
82
+ "marketplaceMcpRemoveDescription": "该操作会删除 MCP server 配置,并通过热插拔立即从工具源中移除。",
83
+ "mcpConnectionOpen": "连接外部 MCP",
84
+ "mcpConnectionTitle": "连接外部 MCP",
85
+ "mcpConnectionDescription": "先测试连接,再保存并让 Agent 使用这个 MCP 服务。",
86
+ "mcpConnectionTransport": "连接方式",
87
+ "mcpConnectionStdio": "本地命令(stdio)",
88
+ "mcpConnectionName": "名称",
89
+ "mcpConnectionCommand": "启动命令",
90
+ "mcpConnectionArgs": "命令参数",
91
+ "mcpConnectionArgsHint": "用空格分隔参数。",
92
+ "mcpConnectionCwd": "工作目录(可选)",
93
+ "mcpConnectionEnv": "环境变量(可选)",
94
+ "mcpConnectionHeaders": "请求头(可选)",
95
+ "mcpConnectionPairsHint": "每行一个 key=value。",
96
+ "mcpConnectionUrl": "服务地址",
97
+ "mcpConnectionTimeout": "超时(毫秒)",
98
+ "mcpConnectionVerifyTls": "验证 TLS 证书",
99
+ "mcpConnectionLocalWarning": "此 MCP 会在本机执行命令。只连接你信任的服务。",
100
+ "mcpConnectionRemoteWarning": "NextClaw 会连接这个远程服务。请确认地址和权限可信。",
101
+ "mcpConnectionTest": "测试连接",
102
+ "mcpConnectionTesting": "正在测试",
103
+ "mcpConnectionSave": "保存连接",
104
+ "mcpConnectionTestSuccess": "连接成功,发现 {count} 个工具。",
105
+ "mcpConnectionTestFailed": "无法连接到 MCP 服务。",
106
+ "mcpConnectionNameRequired": "请填写 MCP 名称。",
107
+ "mcpConnectionCommandRequired": "请填写启动命令。",
108
+ "mcpConnectionUrlRequired": "请填写服务地址。",
109
+ "mcpConnectionTimeoutInvalid": "超时必须是大于 0 的数字。",
110
+ "mcpConnectionPairsInvalid": "每一行都必须是 key=value。"
83
111
  }