@nextclaw/ui 0.10.4 → 0.10.5
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 +12 -0
- package/dist/assets/{ChannelsList-ohaw9GpD.js → ChannelsList-Nu7Ig6_-.js} +1 -1
- package/dist/assets/{ChatPage-BqFTFaut.js → ChatPage-CBCFSk4e.js} +24 -24
- package/dist/assets/{DocBrowser-Cm8LqQ8S.js → DocBrowser-3CfKmJA6.js} +1 -1
- package/dist/assets/{LogoBadge-GTNIKJO9.js → LogoBadge-DdthDJOp.js} +1 -1
- package/dist/assets/{MarketplacePage-CfSXSQFi.js → MarketplacePage-inGGiv1T.js} +1 -1
- package/dist/assets/{McpMarketplacePage-BRJudD8Z.js → McpMarketplacePage-Dg8GSZh6.js} +1 -1
- package/dist/assets/{ModelConfig-B-P04UNK.js → ModelConfig-DyQ6cC92.js} +1 -1
- package/dist/assets/{ProvidersList-D9IocmDB.js → ProvidersList-B2T8Lc_i.js} +1 -1
- package/dist/assets/{RemoteAccessPage-58fYdCYK.js → RemoteAccessPage-C9LxgK-C.js} +1 -1
- package/dist/assets/{RuntimeConfig-C5NlEc57.js → RuntimeConfig-Ey4VIqTW.js} +1 -1
- package/dist/assets/{SearchConfig-Cw17ED0n.js → SearchConfig-R1BcCLWO.js} +1 -1
- package/dist/assets/{SecretsConfig-DliEIjCS.js → SecretsConfig-D-jZMHeY.js} +2 -2
- package/dist/assets/{SessionsConfig-BRqvuKqq.js → SessionsConfig-Cawoh4_2.js} +1 -1
- package/dist/assets/{chat-message-C6dxqsRj.js → chat-message-BbuIK4dQ.js} +1 -1
- package/dist/assets/index-BulnQWr6.js +8 -0
- package/dist/assets/{label-B3FlNUAA.js → label-C7yzBvzK.js} +1 -1
- package/dist/assets/{page-layout-HrT1hpq7.js → page-layout-DF0xpax2.js} +1 -1
- package/dist/assets/{popover-BEo9XeN-.js → popover-DjaScZDJ.js} +1 -1
- package/dist/assets/{security-config-BRE-9ipr.js → security-config-Bg2eriNx.js} +1 -1
- package/dist/assets/{skeleton-BeC_fgbu.js → skeleton-DycBJAJF.js} +1 -1
- package/dist/assets/{status-dot-D9tZgJWo.js → status-dot-B9opOZ22.js} +1 -1
- package/dist/assets/{switch-Cq7y46-d.js → switch-l1P0ev4D.js} +1 -1
- package/dist/assets/{tabs-custom-xrIDQNF2.js → tabs-custom-BG9y2JhC.js} +1 -1
- package/dist/assets/{useConfirmDialog-DCZbJzHW.js → useConfirmDialog-DTducNfn.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +6 -6
- package/src/api/ncp-attachments.ts +41 -0
- package/src/api/types.ts +13 -0
- package/src/components/chat/adapters/chat-message.adapter.test.ts +27 -0
- package/src/components/chat/adapters/chat-message.adapter.ts +5 -1
- package/src/components/chat/chat-composer-state.test.ts +33 -0
- package/src/components/chat/chat-composer-state.ts +3 -1
- package/src/components/chat/containers/chat-input-bar.container.tsx +9 -8
- package/src/components/chat/ncp/ncp-chat-input.manager.ts +3 -1
- package/src/components/chat/ncp/ncp-session-adapter.test.ts +3 -1
- package/src/components/chat/ncp/ncp-session-adapter.ts +15 -1
- package/src/lib/i18n.chat.ts +7 -7
- package/dist/assets/index-B3caHNCU.js +0 -8
|
@@ -1 +1 @@
|
|
|
1
|
-
import{j as e}from"./vendor-CNhxtHCf.js";import{as as m,c as s}from"./index-
|
|
1
|
+
import{j as e}from"./vendor-CNhxtHCf.js";import{as as m,c as s}from"./index-BulnQWr6.js";function c({tabs:a,activeTab:i,onChange:o,className:n}){return e.jsx("div",{className:s("flex items-center gap-6 border-b border-gray-200/60 mb-6",n),children:a.map(t=>{const r=i===t.id;return e.jsxs("button",{onClick:()=>o(t.id),className:s("relative pb-3 text-[14px] font-medium transition-all duration-fast flex items-center gap-1.5",r?"text-gray-900":"text-gray-600 hover:text-gray-900"),children:[t.label,t.count!==void 0&&e.jsx("span",{className:s("text-[11px] font-medium","text-gray-500"),children:m(t.count)}),r&&e.jsx("div",{className:"absolute bottom-0 left-0 right-0 h-[2px] bg-primary rounded-full"})]},t.id)})})}export{c as T};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{j as a,r as t}from"./vendor-CNhxtHCf.js";import{am as p,an as C,ao as h,ap as x,aq as g,ar as D,B as d,t as i}from"./index-
|
|
1
|
+
import{j as a,r as t}from"./vendor-CNhxtHCf.js";import{am as p,an as C,ao as h,ap as x,aq as g,ar as D,B as d,t as i}from"./index-BulnQWr6.js";const j=({open:l,onOpenChange:r,title:c,description:o,confirmLabel:s=i("confirm"),cancelLabel:e=i("cancel"),variant:n="default",onConfirm:u,onCancel:f})=>{const m=()=>{u(),r(!1)},v=()=>{f(),r(!1)};return a.jsx(p,{open:l,onOpenChange:r,children:a.jsxs(C,{className:"[&>:last-child]:hidden",onCloseAutoFocus:b=>b.preventDefault(),children:[a.jsxs(h,{children:[a.jsx(x,{children:c}),o?a.jsx(g,{children:o}):null]}),a.jsxs(D,{className:"gap-2 sm:gap-0",children:[a.jsx(d,{type:"button",variant:"outline",onClick:v,children:e}),a.jsx(d,{type:"button",variant:n==="destructive"?"destructive":"default",onClick:m,children:s})]})]})})},L={open:!1,title:"",description:"",confirmLabel:i("confirm"),cancelLabel:i("cancel"),variant:"default",resolve:null};function y(){const[l,r]=t.useState(L),c=t.useCallback(e=>new Promise(n=>{r({open:!0,title:e.title,description:e.description??"",confirmLabel:e.confirmLabel??i("confirm"),cancelLabel:e.cancelLabel??i("cancel"),variant:e.variant??"default",resolve:u=>{n(u),r(f=>({...f,open:!1,resolve:null}))}})}),[]),o=t.useCallback(e=>{r(n=>(!e&&n.resolve&&n.resolve(!1),{...n,open:e,resolve:e?n.resolve:null}))},[]),s=t.useCallback(()=>a.jsx(j,{open:l.open,onOpenChange:o,title:l.title,description:l.description||void 0,confirmLabel:l.confirmLabel,cancelLabel:l.cancelLabel,variant:l.variant,onConfirm:()=>{var e;return(e=l.resolve)==null?void 0:e.call(l,!0)},onCancel:()=>{var e;return(e=l.resolve)==null?void 0:e.call(l,!1)}}),[l,o]);return{confirm:c,ConfirmDialog:s}}export{y as u};
|
package/dist/index.html
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
|
7
7
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
8
8
|
<title>NextClaw</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-BulnQWr6.js"></script>
|
|
10
10
|
<link rel="modulepreload" crossorigin href="/assets/vendor-CNhxtHCf.js">
|
|
11
11
|
<link rel="stylesheet" crossorigin href="/assets/index-kaPUhd-8.css">
|
|
12
12
|
</head>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nextclaw/ui",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
"tailwind-merge": "^2.5.4",
|
|
29
29
|
"zod": "^3.23.8",
|
|
30
30
|
"zustand": "^5.0.2",
|
|
31
|
-
"@nextclaw/ncp
|
|
32
|
-
"@nextclaw/
|
|
33
|
-
"@nextclaw/
|
|
34
|
-
"@nextclaw/agent-
|
|
35
|
-
"@nextclaw/
|
|
31
|
+
"@nextclaw/ncp": "0.3.3",
|
|
32
|
+
"@nextclaw/agent-chat": "0.1.3",
|
|
33
|
+
"@nextclaw/agent-chat-ui": "0.2.5",
|
|
34
|
+
"@nextclaw/ncp-http-agent-client": "0.3.3",
|
|
35
|
+
"@nextclaw/ncp-react": "0.3.6"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@testing-library/react": "^16.3.0",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { NcpDraftAttachment } from "@nextclaw/ncp-react";
|
|
2
|
+
import { API_BASE } from "./api-base";
|
|
3
|
+
import type { ApiResponse, NcpAttachmentUploadView } from "./types";
|
|
4
|
+
|
|
5
|
+
function readErrorMessage(payload: unknown, fallback: string): string {
|
|
6
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
7
|
+
return fallback;
|
|
8
|
+
}
|
|
9
|
+
const error = (payload as { error?: unknown }).error;
|
|
10
|
+
if (!error || typeof error !== "object" || Array.isArray(error)) {
|
|
11
|
+
return fallback;
|
|
12
|
+
}
|
|
13
|
+
const message = (error as { message?: unknown }).message;
|
|
14
|
+
return typeof message === "string" && message.trim().length > 0 ? message : fallback;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function uploadNcpAttachments(files: File[]): Promise<NcpDraftAttachment[]> {
|
|
18
|
+
const formData = new FormData();
|
|
19
|
+
for (const file of files) {
|
|
20
|
+
formData.append("files", file);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const response = await fetch(`${API_BASE}/api/ncp/attachments`, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
body: formData,
|
|
26
|
+
credentials: "include",
|
|
27
|
+
});
|
|
28
|
+
const payload = (await response.json()) as ApiResponse<NcpAttachmentUploadView>;
|
|
29
|
+
if (!response.ok || !payload.ok) {
|
|
30
|
+
throw new Error(readErrorMessage(payload, "Failed to upload attachments."));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return payload.data.attachments.map((attachment) => ({
|
|
34
|
+
id: attachment.id,
|
|
35
|
+
name: attachment.name,
|
|
36
|
+
mimeType: attachment.mimeType,
|
|
37
|
+
sizeBytes: attachment.sizeBytes,
|
|
38
|
+
attachmentUri: attachment.attachmentUri,
|
|
39
|
+
url: attachment.url,
|
|
40
|
+
}));
|
|
41
|
+
}
|
package/src/api/types.ts
CHANGED
|
@@ -259,6 +259,19 @@ export type NcpSessionMessagesView = {
|
|
|
259
259
|
total: number;
|
|
260
260
|
};
|
|
261
261
|
|
|
262
|
+
export type NcpAttachmentView = {
|
|
263
|
+
id: string;
|
|
264
|
+
name: string;
|
|
265
|
+
mimeType: string;
|
|
266
|
+
sizeBytes: number;
|
|
267
|
+
attachmentUri: string;
|
|
268
|
+
url: string;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
export type NcpAttachmentUploadView = {
|
|
272
|
+
attachments: NcpAttachmentView[];
|
|
273
|
+
};
|
|
274
|
+
|
|
262
275
|
export type NcpSessionStatusView = NcpSessionStatus;
|
|
263
276
|
|
|
264
277
|
export type SessionPatchUpdate = {
|
|
@@ -164,3 +164,30 @@ it("maps file parts into previewable attachment view models", () => {
|
|
|
164
164
|
},
|
|
165
165
|
});
|
|
166
166
|
});
|
|
167
|
+
|
|
168
|
+
it("keeps named non-image files as downloadable attachments", () => {
|
|
169
|
+
const adapted = adapt([
|
|
170
|
+
{
|
|
171
|
+
id: "assistant-doc",
|
|
172
|
+
role: "assistant",
|
|
173
|
+
parts: [
|
|
174
|
+
{
|
|
175
|
+
type: "file",
|
|
176
|
+
name: "spec.pdf",
|
|
177
|
+
mimeType: "application/pdf",
|
|
178
|
+
data: "cGRm",
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
] as unknown as ChatMessageSource[]);
|
|
183
|
+
|
|
184
|
+
expect(adapted[0]?.parts[0]).toEqual({
|
|
185
|
+
type: "file",
|
|
186
|
+
file: {
|
|
187
|
+
label: "spec.pdf",
|
|
188
|
+
mimeType: "application/pdf",
|
|
189
|
+
dataUrl: "data:application/pdf;base64,cGRm",
|
|
190
|
+
isImage: false,
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -18,6 +18,7 @@ export type ChatMessagePartSource =
|
|
|
18
18
|
type: 'file';
|
|
19
19
|
mimeType: string;
|
|
20
20
|
data: string;
|
|
21
|
+
url?: string;
|
|
21
22
|
name?: string;
|
|
22
23
|
}
|
|
23
24
|
| {
|
|
@@ -212,7 +213,10 @@ export function adaptChatMessages(params: {
|
|
|
212
213
|
? params.texts.imageAttachmentLabel
|
|
213
214
|
: params.texts.fileAttachmentLabel,
|
|
214
215
|
mimeType: part.mimeType,
|
|
215
|
-
dataUrl:
|
|
216
|
+
dataUrl:
|
|
217
|
+
typeof part.url === 'string' && part.url.trim().length > 0
|
|
218
|
+
? part.url.trim()
|
|
219
|
+
: `data:${part.mimeType};base64,${part.data}`,
|
|
216
220
|
isImage
|
|
217
221
|
}
|
|
218
222
|
};
|
|
@@ -71,4 +71,37 @@ describe('deriveNcpMessagePartsFromComposer', () => {
|
|
|
71
71
|
}
|
|
72
72
|
]);
|
|
73
73
|
});
|
|
74
|
+
|
|
75
|
+
it('preserves uploaded attachment references when the attachment has a server uri', () => {
|
|
76
|
+
const parts = deriveNcpMessagePartsFromComposer(
|
|
77
|
+
[
|
|
78
|
+
createChatComposerTokenNode({
|
|
79
|
+
tokenKind: 'file',
|
|
80
|
+
tokenKey: 'config',
|
|
81
|
+
label: 'config.json'
|
|
82
|
+
})
|
|
83
|
+
],
|
|
84
|
+
[
|
|
85
|
+
{
|
|
86
|
+
id: 'config',
|
|
87
|
+
name: 'config.json',
|
|
88
|
+
mimeType: 'application/json',
|
|
89
|
+
sizeBytes: 18,
|
|
90
|
+
attachmentUri: 'attachment://local/2026/03/26/att_123',
|
|
91
|
+
url: '/api/ncp/attachments/content?uri=attachment%3A%2F%2Flocal%2F2026%2F03%2F26%2Fatt_123'
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(parts).toEqual([
|
|
97
|
+
{
|
|
98
|
+
type: 'file',
|
|
99
|
+
name: 'config.json',
|
|
100
|
+
mimeType: 'application/json',
|
|
101
|
+
attachmentUri: 'attachment://local/2026/03/26/att_123',
|
|
102
|
+
url: '/api/ncp/attachments/content?uri=attachment%3A%2F%2Flocal%2F2026%2F03%2F26%2Fatt_123',
|
|
103
|
+
sizeBytes: 18
|
|
104
|
+
}
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
74
107
|
});
|
|
@@ -108,7 +108,9 @@ export function deriveNcpMessagePartsFromComposer(
|
|
|
108
108
|
type: 'file',
|
|
109
109
|
name: attachment.name,
|
|
110
110
|
mimeType: attachment.mimeType,
|
|
111
|
-
|
|
111
|
+
...(attachment.attachmentUri ? { attachmentUri: attachment.attachmentUri } : {}),
|
|
112
|
+
...(attachment.url ? { url: attachment.url } : {}),
|
|
113
|
+
...(attachment.contentBase64 ? { contentBase64: attachment.contentBase64 } : {}),
|
|
112
114
|
sizeBytes: attachment.sizeBytes
|
|
113
115
|
});
|
|
114
116
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { ChatInputBar, type ChatInputBarHandle } from '@nextclaw/agent-chat-ui';
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
readFilesAsNcpDraftAttachments
|
|
4
|
+
DEFAULT_NCP_ATTACHMENT_MAX_BYTES,
|
|
5
|
+
uploadFilesAsNcpDraftAttachments
|
|
7
6
|
} from '@nextclaw/ncp-react';
|
|
7
|
+
import { uploadNcpAttachments } from '@/api/ncp-attachments';
|
|
8
8
|
import {
|
|
9
9
|
buildChatSlashItems,
|
|
10
10
|
buildModelStateHint,
|
|
@@ -133,23 +133,25 @@ export function ChatInputBarContainer() {
|
|
|
133
133
|
|
|
134
134
|
const showAttachmentError = useCallback((reason: 'unsupported-type' | 'too-large' | 'read-failed') => {
|
|
135
135
|
if (reason === 'unsupported-type') {
|
|
136
|
-
toast.error(t('
|
|
136
|
+
toast.error(t('chatInputAttachmentUnsupported'));
|
|
137
137
|
return;
|
|
138
138
|
}
|
|
139
139
|
if (reason === 'too-large') {
|
|
140
140
|
toast.error(
|
|
141
|
-
t('
|
|
141
|
+
t('chatInputAttachmentTooLarge').replace('{maxMb}', String(DEFAULT_NCP_ATTACHMENT_MAX_BYTES / (1024 * 1024)))
|
|
142
142
|
);
|
|
143
143
|
return;
|
|
144
144
|
}
|
|
145
|
-
toast.error(t('
|
|
145
|
+
toast.error(t('chatInputAttachmentReadFailed'));
|
|
146
146
|
}, []);
|
|
147
147
|
|
|
148
148
|
const handleFilesAdd = useCallback(async (files: File[]) => {
|
|
149
149
|
if (!attachmentSupported || files.length === 0) {
|
|
150
150
|
return;
|
|
151
151
|
}
|
|
152
|
-
const result = await
|
|
152
|
+
const result = await uploadFilesAsNcpDraftAttachments(files, {
|
|
153
|
+
uploadBatch: uploadNcpAttachments,
|
|
154
|
+
});
|
|
153
155
|
if (result.attachments.length > 0) {
|
|
154
156
|
const insertedAttachments = presenter.chatInputManager.addAttachments?.(result.attachments) ?? [];
|
|
155
157
|
if (insertedAttachments.length > 0) {
|
|
@@ -275,7 +277,6 @@ export function ChatInputBarContainer() {
|
|
|
275
277
|
<input
|
|
276
278
|
ref={fileInputRef}
|
|
277
279
|
type="file"
|
|
278
|
-
accept={DEFAULT_NCP_IMAGE_ATTACHMENT_ACCEPT}
|
|
279
280
|
multiple
|
|
280
281
|
className="hidden"
|
|
281
282
|
onChange={async (event) => {
|
|
@@ -27,10 +27,12 @@ export class NcpChatInputManager {
|
|
|
27
27
|
|
|
28
28
|
private buildAttachmentSignature = (attachment: NcpDraftAttachment): string =>
|
|
29
29
|
[
|
|
30
|
+
attachment.attachmentUri ?? '',
|
|
31
|
+
attachment.url ?? '',
|
|
30
32
|
attachment.name,
|
|
31
33
|
attachment.mimeType,
|
|
32
34
|
String(attachment.sizeBytes),
|
|
33
|
-
attachment.contentBase64,
|
|
35
|
+
attachment.contentBase64 ?? '',
|
|
34
36
|
].join(':');
|
|
35
37
|
|
|
36
38
|
constructor(
|
|
@@ -121,8 +121,22 @@ function toUiParts(parts: NcpMessagePart[]): UIMessage['parts'] {
|
|
|
121
121
|
if (part.type === 'file' && part.contentBase64) {
|
|
122
122
|
uiParts.push({
|
|
123
123
|
type: 'file',
|
|
124
|
+
...(part.name ? { name: part.name } : {}),
|
|
124
125
|
mimeType: part.mimeType ?? 'application/octet-stream',
|
|
125
|
-
data: part.contentBase64
|
|
126
|
+
data: part.contentBase64,
|
|
127
|
+
...(part.url ? { url: part.url } : {}),
|
|
128
|
+
...(typeof part.sizeBytes === 'number' ? { sizeBytes: part.sizeBytes } : {})
|
|
129
|
+
});
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (part.type === 'file' && part.url) {
|
|
133
|
+
uiParts.push({
|
|
134
|
+
type: 'file',
|
|
135
|
+
...(part.name ? { name: part.name } : {}),
|
|
136
|
+
mimeType: part.mimeType ?? 'application/octet-stream',
|
|
137
|
+
data: '',
|
|
138
|
+
url: part.url,
|
|
139
|
+
...(typeof part.sizeBytes === 'number' ? { sizeBytes: part.sizeBytes } : {})
|
|
126
140
|
});
|
|
127
141
|
continue;
|
|
128
142
|
}
|
package/src/lib/i18n.chat.ts
CHANGED
|
@@ -104,14 +104,14 @@ export const CHAT_LABELS: Record<string, { zh: string; en: string }> = {
|
|
|
104
104
|
chatSkillsPickerOfficial: { zh: '官方', en: 'Official' },
|
|
105
105
|
chatSkillsPickerManage: { zh: '管理技能', en: 'Manage Skills' },
|
|
106
106
|
chatInputAttach: { zh: '添加附件', en: 'Attach file' },
|
|
107
|
-
|
|
108
|
-
zh: '
|
|
109
|
-
en: '
|
|
107
|
+
chatInputAttachmentUnsupported: {
|
|
108
|
+
zh: '当前上传流程不支持该文件类型。',
|
|
109
|
+
en: 'This file type is not supported in the current upload flow.'
|
|
110
110
|
},
|
|
111
|
-
|
|
112
|
-
zh: '
|
|
113
|
-
en: '
|
|
111
|
+
chatInputAttachmentTooLarge: {
|
|
112
|
+
zh: '文件不能超过 {maxMb} MB。',
|
|
113
|
+
en: 'Files must be {maxMb} MB or smaller.'
|
|
114
114
|
},
|
|
115
|
-
|
|
115
|
+
chatInputAttachmentReadFailed: { zh: '读取文件失败,请重试。', en: 'Failed to read the file. Please try again.' },
|
|
116
116
|
chatInputAttachComingSoon: { zh: '即将支持', en: 'Coming soon' }
|
|
117
117
|
};
|