@sybilion/uilib 1.3.12 → 1.3.15
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/dist/esm/components/ui/Chat/ChatChrome/ChatChrome.js +4 -1
- package/dist/esm/components/ui/Chat/ChatMessage/ChatMessage.js +7 -3
- package/dist/esm/components/ui/Chat/ChatMessage/UserTextFileAttachmentBubble.js +39 -0
- package/dist/esm/components/ui/Chat/ChatPrompt/ChatPromptAttachments.js +7 -1
- package/dist/esm/components/ui/Chat/ChatSheet/ChatSheet.js +4 -1
- package/dist/esm/components/ui/Chat/ChatSheet/useChatPanelChromeModel.js +6 -3
- package/dist/esm/components/ui/Chat/buildChatSendMessagePayload.js +70 -0
- package/dist/esm/components/ui/Chat/chatAttachmentAccept.js +20 -1
- package/dist/esm/components/ui/Chat/chatAttachmentExtract.js +11 -1
- package/dist/esm/components/ui/Chat/chatDocxExtract.js +17 -0
- package/dist/esm/components/ui/Chat/chatXlsxExtract.js +34 -0
- package/dist/esm/components/ui/Chat/sanitizeAttachmentFilename.js +14 -0
- package/dist/esm/components/ui/Chat/userTextFileAttachments.js +6 -0
- package/dist/esm/contexts/chat-context.js +8 -3
- package/dist/esm/index.js +2 -0
- package/dist/esm/types/src/components/ui/Chat/Chat.types.d.ts +8 -8
- package/dist/esm/types/src/components/ui/Chat/ChatMessage/ChatMessage.d.ts +1 -1
- package/dist/esm/types/src/components/ui/Chat/ChatMessage/UserTextFileAttachmentBubble.d.ts +4 -0
- package/dist/esm/types/src/components/ui/Chat/ChatSheet/ChatSheet.d.ts +1 -1
- package/dist/esm/types/src/components/ui/Chat/buildChatSendMessagePayload.d.ts +10 -0
- package/dist/esm/types/src/components/ui/Chat/buildChatSendMessagePayload.test.d.ts +1 -0
- package/dist/esm/types/src/components/ui/Chat/chatAttachmentAccept.d.ts +3 -1
- package/dist/esm/types/src/components/ui/Chat/chatAttachmentAccept.test.d.ts +1 -0
- package/dist/esm/types/src/components/ui/Chat/chatDocxExtract.d.ts +2 -0
- package/dist/esm/types/src/components/ui/Chat/chatDocxExtract.test.d.ts +1 -0
- package/dist/esm/types/src/components/ui/Chat/chatXlsxExtract.d.ts +2 -0
- package/dist/esm/types/src/components/ui/Chat/chatXlsxExtract.test.d.ts +1 -0
- package/dist/esm/types/src/components/ui/Chat/index.d.ts +3 -1
- package/dist/esm/types/src/components/ui/Chat/sanitizeAttachmentFilename.d.ts +2 -0
- package/dist/esm/types/src/components/ui/Chat/userTextFileAttachments.d.ts +3 -0
- package/dist/esm/types/src/contexts/chat-context.d.ts +3 -3
- package/dist/esm/types/tests/setup.d.ts +1 -0
- package/package.json +4 -2
- package/src/components/ui/Chat/Chat.types.ts +8 -8
- package/src/components/ui/Chat/ChatChrome/ChatChrome.tsx +4 -1
- package/src/components/ui/Chat/ChatMessage/ChatMessage.tsx +12 -5
- package/src/components/ui/Chat/ChatMessage/UserTextFileAttachmentBubble.tsx +50 -0
- package/src/components/ui/Chat/ChatPrompt/ChatPromptAttachments.tsx +9 -1
- package/src/components/ui/Chat/ChatSheet/ChatSheet.tsx +6 -0
- package/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.tsx +10 -3
- package/src/components/ui/Chat/buildChatSendMessagePayload.test.ts +116 -0
- package/src/components/ui/Chat/buildChatSendMessagePayload.ts +82 -0
- package/src/components/ui/Chat/chatAttachmentAccept.test.ts +78 -0
- package/src/components/ui/Chat/chatAttachmentAccept.ts +25 -0
- package/src/components/ui/Chat/chatAttachmentExtract.ts +13 -1
- package/src/components/ui/Chat/chatDocxExtract.test.ts +40 -0
- package/src/components/ui/Chat/chatDocxExtract.ts +19 -0
- package/src/components/ui/Chat/chatXlsxExtract.test.ts +72 -0
- package/src/components/ui/Chat/chatXlsxExtract.ts +43 -0
- package/src/components/ui/Chat/index.ts +7 -1
- package/src/components/ui/Chat/sanitizeAttachmentFilename.ts +15 -0
- package/src/components/ui/Chat/userTextFileAttachments.ts +8 -0
- package/src/contexts/chat-context.tsx +12 -7
- package/src/docs/pages/ChatAttachmentsDropzonePage.tsx +14 -20
- package/src/docs/pages/ChatUserCsvAttachmentPage.tsx +7 -5
- package/dist/esm/components/ui/Chat/ChatMessage/UserCsvAttachmentBubble.js +0 -10
- package/dist/esm/types/src/components/ui/Chat/ChatMessage/UserCsvAttachmentBubble.d.ts +0 -4
- package/src/components/ui/Chat/ChatMessage/UserCsvAttachmentBubble.tsx +0 -27
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as XLSX from 'xlsx';
|
|
2
|
+
|
|
3
|
+
import { extractXlsxFileToText } from './chatXlsxExtract';
|
|
4
|
+
|
|
5
|
+
function makeXlsxFile(
|
|
6
|
+
sheets: Record<string, (string | number)[][]>,
|
|
7
|
+
name = 'test.xlsx',
|
|
8
|
+
): File {
|
|
9
|
+
const workbook = XLSX.utils.book_new();
|
|
10
|
+
for (const [sheetName, rows] of Object.entries(sheets)) {
|
|
11
|
+
XLSX.utils.book_append_sheet(
|
|
12
|
+
workbook,
|
|
13
|
+
XLSX.utils.aoa_to_sheet(rows),
|
|
14
|
+
sheetName,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
const buffer = new Uint8Array(
|
|
18
|
+
XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }),
|
|
19
|
+
);
|
|
20
|
+
return new File([buffer], name, {
|
|
21
|
+
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('extractXlsxFileToText', () => {
|
|
26
|
+
it('extracts non-empty sheets as CSV with headings', async () => {
|
|
27
|
+
const file = makeXlsxFile({
|
|
28
|
+
Data: [
|
|
29
|
+
['name', 'value'],
|
|
30
|
+
['a', 1],
|
|
31
|
+
],
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const text = await extractXlsxFileToText(file);
|
|
35
|
+
expect(text).toContain('## Sheet Data');
|
|
36
|
+
expect(text).toContain('name,value');
|
|
37
|
+
expect(text).toContain('a,1');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('skips empty sheets', async () => {
|
|
41
|
+
const workbook = XLSX.utils.book_new();
|
|
42
|
+
XLSX.utils.book_append_sheet(
|
|
43
|
+
workbook,
|
|
44
|
+
XLSX.utils.aoa_to_sheet([]),
|
|
45
|
+
'Empty',
|
|
46
|
+
);
|
|
47
|
+
XLSX.utils.book_append_sheet(
|
|
48
|
+
workbook,
|
|
49
|
+
XLSX.utils.aoa_to_sheet([['x']]),
|
|
50
|
+
'Filled',
|
|
51
|
+
);
|
|
52
|
+
const buffer = new Uint8Array(
|
|
53
|
+
XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }),
|
|
54
|
+
);
|
|
55
|
+
const file = new File([buffer], 'mixed.xlsx', {
|
|
56
|
+
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const text = await extractXlsxFileToText(file);
|
|
60
|
+
expect(text).not.toContain('## Sheet Empty');
|
|
61
|
+
expect(text).toContain('## Sheet Filled');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('rejects files over the size limit', async () => {
|
|
65
|
+
const huge = new Uint8Array(10 * 1024 * 1024 + 1);
|
|
66
|
+
const file = new File([huge], 'huge.xlsx', {
|
|
67
|
+
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await expect(extractXlsxFileToText(file)).rejects.toThrow(/too large/i);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
2
|
+
const MAX_SHEETS = 20;
|
|
3
|
+
const MAX_CSV_CHARS_PER_SHEET = 500_000;
|
|
4
|
+
|
|
5
|
+
function truncateCsv(csv: string): string {
|
|
6
|
+
if (csv.length <= MAX_CSV_CHARS_PER_SHEET) return csv;
|
|
7
|
+
return `${csv.slice(0, MAX_CSV_CHARS_PER_SHEET).trimEnd()}…`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Best-effort plain text from XLSX; one CSV block per sheet (xlsx loaded on demand). */
|
|
11
|
+
export async function extractXlsxFileToText(file: File): Promise<string> {
|
|
12
|
+
const buffer = new Uint8Array(await file.arrayBuffer());
|
|
13
|
+
if (buffer.byteLength > MAX_FILE_BYTES) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`${file.name} is too large (max ${MAX_FILE_BYTES / (1024 * 1024)} MB)`,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const XLSX = await import('xlsx');
|
|
20
|
+
const workbook = XLSX.read(buffer, { type: 'array' });
|
|
21
|
+
const sheetNames = workbook.SheetNames.slice(0, MAX_SHEETS);
|
|
22
|
+
const sheetTexts: string[] = [];
|
|
23
|
+
|
|
24
|
+
for (const sheetName of sheetNames) {
|
|
25
|
+
const sheet = workbook.Sheets[sheetName];
|
|
26
|
+
if (!sheet) continue;
|
|
27
|
+
|
|
28
|
+
const csv = truncateCsv(
|
|
29
|
+
XLSX.utils.sheet_to_csv(sheet, { blankrows: false }).trim(),
|
|
30
|
+
);
|
|
31
|
+
if (csv) {
|
|
32
|
+
sheetTexts.push(`## Sheet ${sheetName}\n\n${csv}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (workbook.SheetNames.length > MAX_SHEETS) {
|
|
37
|
+
sheetTexts.push(
|
|
38
|
+
`_(Only the first ${MAX_SHEETS} of ${workbook.SheetNames.length} sheets were included.)_`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return sheetTexts.join('\n\n');
|
|
43
|
+
}
|
|
@@ -9,6 +9,12 @@ export {
|
|
|
9
9
|
TEXT_ATTACHMENT_ACCEPT_PARTS,
|
|
10
10
|
filterToTextAttachments,
|
|
11
11
|
} from './chatAttachmentAccept';
|
|
12
|
+
export {
|
|
13
|
+
buildChatSendMessagePayload,
|
|
14
|
+
displayTextFromSendPayload,
|
|
15
|
+
normalizeUserTextFileAttachments,
|
|
16
|
+
} from './buildChatSendMessagePayload';
|
|
17
|
+
export { sanitizeAttachmentFilename } from './sanitizeAttachmentFilename';
|
|
12
18
|
export { ChatSheet } from './ChatSheet/ChatSheet';
|
|
13
19
|
export { useChatPanelChromeModel } from './ChatSheet/useChatPanelChromeModel';
|
|
14
20
|
export type { ChatSheetActions, ChatSheetProps } from './ChatSheet/ChatSheet';
|
|
@@ -26,7 +32,7 @@ export type {
|
|
|
26
32
|
ChatProps,
|
|
27
33
|
ChatPreset as ChatPresetType,
|
|
28
34
|
Message,
|
|
29
|
-
|
|
35
|
+
UserTextFileAttachment,
|
|
30
36
|
} from './Chat.types';
|
|
31
37
|
export { MessageRole } from './Chat.types';
|
|
32
38
|
export { CsvIcon } from '../../icons/CsvIcon/CsvIcon';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Safe download filename from a display title or original file name. */
|
|
2
|
+
export function sanitizeAttachmentFilename(
|
|
3
|
+
displayTitle: string,
|
|
4
|
+
defaultExt = 'txt',
|
|
5
|
+
): string {
|
|
6
|
+
const cleaned = displayTitle
|
|
7
|
+
.replace(/[/\\?%*:|"<>]/g, '-')
|
|
8
|
+
.replace(/\s+/g, ' ')
|
|
9
|
+
.trim()
|
|
10
|
+
.slice(0, 120);
|
|
11
|
+
const base = cleaned.length > 0 ? cleaned : 'attachment';
|
|
12
|
+
const ext = defaultExt.startsWith('.') ? defaultExt : `.${defaultExt}`;
|
|
13
|
+
const pattern = new RegExp(`${ext.replace('.', '\\.')}$`, 'i');
|
|
14
|
+
return pattern.test(base) ? base : `${base}${ext}`;
|
|
15
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Message, UserTextFileAttachment } from './Chat.types';
|
|
2
|
+
|
|
3
|
+
/** File attachments on a stored message. */
|
|
4
|
+
export function userTextFileAttachmentsFromMessage(
|
|
5
|
+
message: Pick<Message, 'userTextFileAttachments'>,
|
|
6
|
+
): UserTextFileAttachment[] {
|
|
7
|
+
return message.userTextFileAttachments ?? [];
|
|
8
|
+
}
|
|
@@ -13,8 +13,9 @@ import {
|
|
|
13
13
|
type ChatSendMessagePayload,
|
|
14
14
|
type Message,
|
|
15
15
|
MessageRole,
|
|
16
|
-
type
|
|
16
|
+
type UserTextFileAttachment,
|
|
17
17
|
} from '#uilib/components/ui/Chat/Chat.types';
|
|
18
|
+
import { normalizeUserTextFileAttachments } from '#uilib/components/ui/Chat/buildChatSendMessagePayload';
|
|
18
19
|
import { stripJsonDashboardFences } from '#uilib/lib/dashboard-spec/stripJsonDashboardFences';
|
|
19
20
|
import type { ChatResponse } from '#uilib/types/chat-api.types';
|
|
20
21
|
import { LS } from '@homecode/ui';
|
|
@@ -26,14 +27,14 @@ export type SendChatMessageFn = (
|
|
|
26
27
|
|
|
27
28
|
export type {
|
|
28
29
|
ChatSendMessagePayload,
|
|
29
|
-
|
|
30
|
+
UserTextFileAttachment,
|
|
30
31
|
} from '#uilib/components/ui/Chat/Chat.types';
|
|
31
32
|
|
|
32
33
|
const CHATS_PREFIX = 'chats-';
|
|
33
34
|
const CHAT_SCOPE_IDS_REGISTRY_KEY = 'chat-scope-ids';
|
|
34
35
|
|
|
35
36
|
export type AddChatMessageOptions = {
|
|
36
|
-
|
|
37
|
+
userTextFileAttachments?: UserTextFileAttachment[];
|
|
37
38
|
};
|
|
38
39
|
|
|
39
40
|
export interface ChatContextType {
|
|
@@ -300,14 +301,18 @@ export function ChatProvider({
|
|
|
300
301
|
if (userSwitchKey === null) return undefined;
|
|
301
302
|
addScopeIdToRegistry(scopeId);
|
|
302
303
|
const storedText = stripJsonDashboardFences(text);
|
|
303
|
-
const
|
|
304
|
-
role === MessageRole.USER
|
|
304
|
+
const attachments =
|
|
305
|
+
role === MessageRole.USER
|
|
306
|
+
? options?.userTextFileAttachments
|
|
307
|
+
: undefined;
|
|
305
308
|
const newMessage: Message = {
|
|
306
309
|
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
307
310
|
role,
|
|
308
311
|
text: storedText,
|
|
309
312
|
timestamp: Date.now(),
|
|
310
|
-
...(
|
|
313
|
+
...(attachments?.length
|
|
314
|
+
? { userTextFileAttachments: attachments }
|
|
315
|
+
: {}),
|
|
311
316
|
};
|
|
312
317
|
|
|
313
318
|
setChats(prev => {
|
|
@@ -372,7 +377,7 @@ export function ChatProvider({
|
|
|
372
377
|
MessageRole.USER,
|
|
373
378
|
message.displayText,
|
|
374
379
|
{
|
|
375
|
-
|
|
380
|
+
userTextFileAttachments: normalizeUserTextFileAttachments(message),
|
|
376
381
|
},
|
|
377
382
|
);
|
|
378
383
|
}
|
|
@@ -97,16 +97,16 @@ export default function ChatAttachmentsDropzonePage() {
|
|
|
97
97
|
<AppPageHeader
|
|
98
98
|
breadcrumbs={[{ label: 'Chat' }, { label: 'Attachments dropzone' }]}
|
|
99
99
|
title="Chat — attachments dropzone"
|
|
100
|
-
subheader="Drop text files onto the chat shell; they appear on the prompt until you send."
|
|
100
|
+
subheader="Drop text, Office, or PDF files onto the chat shell; they appear on the prompt until you send."
|
|
101
101
|
actions={<DocsHeaderActions />}
|
|
102
102
|
/>
|
|
103
103
|
<PageContentSection>
|
|
104
104
|
<p style={{ marginBottom: 16, fontSize: 14, lineHeight: 1.5 }}>
|
|
105
|
-
Drop a <code>.txt</code>, <code>.csv</code>, <code>.md</code>,
|
|
106
|
-
<code>.
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
<code>allowPdfAttachments</code>.
|
|
105
|
+
Drop a <code>.txt</code>, <code>.csv</code>, <code>.md</code>,{' '}
|
|
106
|
+
<code>.docx</code>, <code>.xlsx</code>, or <code>.pdf</code> file
|
|
107
|
+
anywhere on the chat panel. The file shows above the composer; press
|
|
108
|
+
send to post it. Office and PDF parsers load on demand when you attach
|
|
109
|
+
those types; PDF also requires <code>allowPdfAttachments</code>.
|
|
110
110
|
</p>
|
|
111
111
|
<ChatChrome
|
|
112
112
|
showResizeHandle={false}
|
|
@@ -133,25 +133,19 @@ export default function ChatAttachmentsDropzonePage() {
|
|
|
133
133
|
effectiveScopeId="docs-chat-attachments-dropzone"
|
|
134
134
|
onPromptSubmit={onPromptSubmit}
|
|
135
135
|
onChatDeleted={() => {}}
|
|
136
|
-
allowedAttachments={
|
|
137
|
-
'text/plain',
|
|
138
|
-
'.txt',
|
|
139
|
-
'text/csv',
|
|
140
|
-
'.csv',
|
|
141
|
-
'text/markdown',
|
|
142
|
-
'.md',
|
|
143
|
-
'application/json',
|
|
144
|
-
'.json',
|
|
145
|
-
]}
|
|
136
|
+
allowedAttachments={TEXT_ATTACHMENT_ACCEPT_PARTS}
|
|
146
137
|
allowPdfAttachments
|
|
147
138
|
emptyState={{
|
|
148
|
-
title: 'Drop a text
|
|
139
|
+
title: 'Drop a text, Office, or PDF file',
|
|
149
140
|
description:
|
|
150
|
-
'Drag a file onto this panel, review it above the composer, then send.',
|
|
141
|
+
'Drag a file onto this panel, review it above the composer, then send. DOCX and XLSX are parsed in the browser.',
|
|
151
142
|
additionalContent: (
|
|
152
143
|
<p style={{ fontSize: 13, opacity: 0.85 }}>
|
|
153
|
-
|
|
154
|
-
{
|
|
144
|
+
Accepted types include <code>.txt</code>, <code>.csv</code>,{' '}
|
|
145
|
+
<code>.md</code>, <code>.json</code>, <code>.docx</code>,{' '}
|
|
146
|
+
<code>.xlsx</code>, and more (
|
|
147
|
+
{TEXT_ATTACHMENT_ACCEPT_PARTS.length} entries in the text
|
|
148
|
+
allowlist).
|
|
155
149
|
</p>
|
|
156
150
|
),
|
|
157
151
|
}}
|
|
@@ -42,11 +42,13 @@ function makeUserMessageWithCsv(
|
|
|
42
42
|
role: MessageRole.USER,
|
|
43
43
|
text: displayText,
|
|
44
44
|
timestamp: Date.now(),
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
userTextFileAttachments: [
|
|
46
|
+
{
|
|
47
|
+
displayName,
|
|
48
|
+
filename: 'docs-sample.csv',
|
|
49
|
+
content: SAMPLE_CSV,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
50
52
|
};
|
|
51
53
|
}
|
|
52
54
|
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { jsx } from 'react/jsx-runtime';
|
|
2
|
-
import { FileChip } from '../../FileChip/FileChip.js';
|
|
3
|
-
import { downloadTextFile } from '../../../../utils/downloadTextFile.js';
|
|
4
|
-
|
|
5
|
-
const CSV_DOWNLOAD_HINT = 'Download .CSV file';
|
|
6
|
-
function UserCsvAttachmentBubble({ attachment, }) {
|
|
7
|
-
return (jsx(FileChip, { name: attachment.displayName, format: "csv", hint: CSV_DOWNLOAD_HINT, onClick: () => downloadTextFile(attachment.content, attachment.filename, 'text/csv;charset=utf-8') }));
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export { UserCsvAttachmentBubble };
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { FileChip } from '#uilib/components/ui/FileChip';
|
|
2
|
-
import { downloadTextFile } from '#uilib/utils/downloadTextFile';
|
|
3
|
-
|
|
4
|
-
import type { UserCsvAttachment } from '../Chat.types';
|
|
5
|
-
|
|
6
|
-
const CSV_DOWNLOAD_HINT = 'Download .CSV file';
|
|
7
|
-
|
|
8
|
-
export function UserCsvAttachmentBubble({
|
|
9
|
-
attachment,
|
|
10
|
-
}: {
|
|
11
|
-
attachment: UserCsvAttachment;
|
|
12
|
-
}) {
|
|
13
|
-
return (
|
|
14
|
-
<FileChip
|
|
15
|
-
name={attachment.displayName}
|
|
16
|
-
format="csv"
|
|
17
|
-
hint={CSV_DOWNLOAD_HINT}
|
|
18
|
-
onClick={() =>
|
|
19
|
-
downloadTextFile(
|
|
20
|
-
attachment.content,
|
|
21
|
-
attachment.filename,
|
|
22
|
-
'text/csv;charset=utf-8',
|
|
23
|
-
)
|
|
24
|
-
}
|
|
25
|
-
/>
|
|
26
|
-
);
|
|
27
|
-
}
|