linco-connect 1.0.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.
@@ -0,0 +1,258 @@
1
+ const crypto = require('crypto');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { ensureDir } = require('./config');
5
+ const { sendError, sendSystem } = require('./protocol');
6
+
7
+ const RESERVED_WINDOWS_NAMES = new Set(['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9']);
8
+ const SUPPORTED_IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
9
+ const IMAGE_EXTENSION_MEDIA_TYPES = new Map([
10
+ ['.png', 'image/png'],
11
+ ['.jpg', 'image/jpeg'],
12
+ ['.jpeg', 'image/jpeg'],
13
+ ['.gif', 'image/gif'],
14
+ ['.webp', 'image/webp'],
15
+ ]);
16
+
17
+ function handleMessageWithAttachments(msg, ws, session, config, executeAgentQuery) {
18
+ const text = String(msg.text || '').trim();
19
+ const attachments = Array.isArray(msg.attachments) ? msg.attachments : [];
20
+
21
+ if (!text && attachments.length === 0) return;
22
+
23
+ let savedAttachments = [];
24
+ try {
25
+ savedAttachments = saveAttachments(session, attachments, config);
26
+ } catch (err) {
27
+ sendError(ws, `❌ 附件处理失败: ${err.message}`);
28
+ return;
29
+ }
30
+
31
+ const stats = summarizeAttachments(savedAttachments);
32
+ if (savedAttachments.length > 0) {
33
+ sendSystem(ws, buildAttachmentStatus(stats));
34
+ }
35
+
36
+ executeAgentQuery(buildContentWithAttachments(text, savedAttachments), ws, session, config);
37
+ }
38
+
39
+ function handleLegacyImageMessage(msg, ws, session, config, executeAgentQuery) {
40
+ const attachments = [{
41
+ name: `image.${extensionFromMime(msg.mimeType || 'image/png').slice(1)}`,
42
+ mimeType: msg.mimeType || 'image/png',
43
+ base64: msg.base64,
44
+ }];
45
+ handleMessageWithAttachments({ text: msg.text || '', attachments }, ws, session, config, executeAgentQuery);
46
+ }
47
+
48
+ function saveAttachments(session, attachments, config) {
49
+ if (attachments.length > config.maxAttachmentCount) {
50
+ throw new Error(`单次最多上传 ${config.maxAttachmentCount} 个附件`);
51
+ }
52
+
53
+ const attachmentsDir = session.attachmentsDir;
54
+ ensureDir(attachmentsDir);
55
+
56
+ let totalSize = 0;
57
+ const saved = [];
58
+
59
+ for (const attachment of attachments) {
60
+ validateAttachmentShape(attachment);
61
+ const ext = extensionFromNameOrMime(attachment.name, attachment.mimeType);
62
+ validateType(ext, attachment.name, config);
63
+
64
+ const buffer = decodeBase64(attachment.base64);
65
+ if (buffer.length === 0) throw new Error(`${attachment.name || '附件'} 为空`);
66
+ if (buffer.length > config.maxAttachmentBytes) {
67
+ throw new Error(`${attachment.name || '附件'} 超过单文件大小限制 ${(config.maxAttachmentBytes / 1024 / 1024).toFixed(0)}MB`);
68
+ }
69
+
70
+ totalSize += buffer.length;
71
+ if (totalSize > config.maxTotalAttachmentBytes) {
72
+ throw new Error(`附件总大小超过限制 ${(config.maxTotalAttachmentBytes / 1024 / 1024).toFixed(0)}MB`);
73
+ }
74
+
75
+ const safeName = sanitizeFilename(attachment.name || `attachment${ext || ''}`, ext);
76
+ const fileName = `${Date.now()}_${crypto.randomBytes(4).toString('hex')}_${safeName}`;
77
+ const filePath = path.resolve(attachmentsDir, fileName);
78
+
79
+ if (!isInside(filePath, attachmentsDir)) {
80
+ throw new Error('附件路径非法');
81
+ }
82
+
83
+ const mediaType = mediaTypeForImageAttachment(attachment, ext);
84
+
85
+ fs.writeFileSync(filePath, buffer);
86
+ saved.push({
87
+ originalName: attachment.name || safeName,
88
+ name: fileName,
89
+ mimeType: attachment.mimeType || '',
90
+ size: buffer.length,
91
+ path: filePath,
92
+ kind: mediaType ? 'image' : 'file',
93
+ mediaType,
94
+ base64: mediaType ? buffer.toString('base64') : undefined,
95
+ });
96
+ }
97
+
98
+ return saved;
99
+ }
100
+
101
+ function buildPromptWithAttachmentRefs(text, savedAttachments) {
102
+ const fileAttachments = savedAttachments.filter(file => file.kind !== 'image');
103
+ if (fileAttachments.length === 0) return text;
104
+
105
+ const prompt = text || '请分析这些附件。';
106
+ const refs = fileAttachments.map(file => `- ${file.path}`).join('\n');
107
+ return `${prompt}\n\n附件已保存到以下本地路径,请按需要读取:\n${refs}`;
108
+ }
109
+
110
+ function buildContentWithAttachments(text, savedAttachments) {
111
+ const imageAttachments = savedAttachments.filter(file => file.kind === 'image');
112
+ if (imageAttachments.length === 0) {
113
+ return buildPromptWithAttachmentRefs(text, savedAttachments);
114
+ }
115
+
116
+ const fileAttachments = savedAttachments.filter(file => file.kind !== 'image');
117
+ const content = [{
118
+ type: 'text',
119
+ text: text || '请描述这些图片的内容,并根据图片给出后续建议。',
120
+ }];
121
+
122
+ for (const image of imageAttachments) {
123
+ content.push({
124
+ type: 'text',
125
+ text: `图片附件:${image.originalName}(${image.mediaType},${formatSize(image.size)})`,
126
+ });
127
+ content.push({
128
+ type: 'image',
129
+ path: image.path,
130
+ source: {
131
+ type: 'base64',
132
+ media_type: image.mediaType,
133
+ data: image.base64,
134
+ },
135
+ });
136
+ }
137
+
138
+ if (fileAttachments.length > 0) {
139
+ const refs = fileAttachments.map(file => `- ${file.path}`).join('\n');
140
+ content.push({
141
+ type: 'text',
142
+ text: `普通附件已保存到以下本地路径,请按需要读取:\n${refs}`,
143
+ });
144
+ }
145
+
146
+ return content;
147
+ }
148
+
149
+ function summarizeAttachments(savedAttachments) {
150
+ return savedAttachments.reduce((stats, file) => {
151
+ if (file.kind === 'image') stats.images += 1;
152
+ else stats.files += 1;
153
+ return stats;
154
+ }, { images: 0, files: 0 });
155
+ }
156
+
157
+ function buildAttachmentStatus(stats) {
158
+ const parts = [];
159
+ if (stats.images > 0) parts.push(`${stats.images} 张图片将直接发送给当前 Agent 识别`);
160
+ if (stats.files > 0) parts.push(`${stats.files} 个文件已保存为路径引用`);
161
+ return `📎 已处理 ${stats.images + stats.files} 个附件:${parts.join(',')}`;
162
+ }
163
+
164
+ function mediaTypeForImageAttachment(attachment, ext) {
165
+ const mimeType = normalizeMimeType(attachment.mimeType || '');
166
+ if (SUPPORTED_IMAGE_MEDIA_TYPES.has(mimeType)) return mimeType;
167
+ if (mimeType && mimeType !== 'application/octet-stream') return '';
168
+ return IMAGE_EXTENSION_MEDIA_TYPES.get(ext) || '';
169
+ }
170
+
171
+ function normalizeMimeType(mimeType) {
172
+ return String(mimeType || '').split(';')[0].trim().toLowerCase();
173
+ }
174
+
175
+ function formatSize(bytes) {
176
+ if (bytes < 1024) return `${bytes} B`;
177
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
178
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
179
+ }
180
+
181
+ function validateAttachmentShape(attachment) {
182
+ if (!attachment || typeof attachment !== 'object') throw new Error('附件格式错误');
183
+ if (typeof attachment.base64 !== 'string' || !attachment.base64) throw new Error(`${attachment.name || '附件'} 缺少内容`);
184
+ }
185
+
186
+ function validateType(ext, name, config) {
187
+ if (!ext) return;
188
+ if (config.allowUnsafeAttachments) return;
189
+
190
+ const unsafe = new Set(config.unsafeAttachmentExtensions || []);
191
+ if (unsafe.has(ext.toLowerCase())) {
192
+ throw new Error(`出于安全原因,默认不允许上传 ${ext} 文件: ${name || '附件'}`);
193
+ }
194
+ }
195
+
196
+ function decodeBase64(base64) {
197
+ if (!/^[A-Za-z0-9+/=\r\n]+$/.test(base64)) throw new Error('附件不是有效的 base64');
198
+ return Buffer.from(base64, 'base64');
199
+ }
200
+
201
+ function sanitizeFilename(originalName, fallbackExt) {
202
+ const parsed = path.parse(path.basename(originalName || `attachment${fallbackExt || ''}`));
203
+ let base = parsed.name
204
+ .replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
205
+ .replace(/[. ]+$/g, '')
206
+ .replace(/^[. ]+/g, '')
207
+ .slice(0, 80);
208
+
209
+ if (!base) base = 'attachment';
210
+ if (RESERVED_WINDOWS_NAMES.has(base.toUpperCase())) base = `_${base}`;
211
+
212
+ const ext = normalizeExtension(parsed.ext || fallbackExt || '');
213
+ return `${base}${ext}`;
214
+ }
215
+
216
+ function extensionFromNameOrMime(name, mimeType) {
217
+ const ext = normalizeExtension(path.extname(path.basename(name || '')));
218
+ if (ext) return ext;
219
+ return extensionFromMime(mimeType || '');
220
+ }
221
+
222
+ function extensionFromMime(mimeType) {
223
+ switch (mimeType) {
224
+ case 'image/png': return '.png';
225
+ case 'image/jpeg': return '.jpg';
226
+ case 'image/gif': return '.gif';
227
+ case 'image/webp': return '.webp';
228
+ case 'text/plain': return '.txt';
229
+ case 'text/markdown': return '.md';
230
+ case 'text/csv': return '.csv';
231
+ case 'application/sql': return '.sql';
232
+ case 'application/pdf': return '.pdf';
233
+ case 'application/msword': return '.doc';
234
+ case 'application/vnd.ms-excel': return '.xls';
235
+ case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': return '.docx';
236
+ case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': return '.xlsx';
237
+ default: return '';
238
+ }
239
+ }
240
+
241
+ function normalizeExtension(ext) {
242
+ if (!ext) return '';
243
+ return ext.toLowerCase().replace(/[^.a-z0-9_-]/g, '').slice(0, 32);
244
+ }
245
+
246
+ function isInside(filePath, dir) {
247
+ const relative = path.relative(dir, filePath);
248
+ return relative && !relative.startsWith('..') && !path.isAbsolute(relative);
249
+ }
250
+
251
+ module.exports = {
252
+ buildContentWithAttachments,
253
+ buildPromptWithAttachmentRefs,
254
+ extensionFromMime,
255
+ handleLegacyImageMessage,
256
+ handleMessageWithAttachments,
257
+ saveAttachments,
258
+ };