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,73 @@
1
+ const { send } = require('./protocol');
2
+
3
+ const STREAM_FLUSH_INTERVAL = 100;
4
+ const STREAM_FLUSH_TEXT_THRESHOLD = 24;
5
+
6
+ function createTextStreamBuffer({ onStart } = {}) {
7
+ return {
8
+ assistantStarted: false,
9
+ pendingText: '',
10
+ flushTimer: null,
11
+ lastFlushAt: 0,
12
+ onStart,
13
+ };
14
+ }
15
+
16
+ function appendTextStream(text, ws, state) {
17
+ if (!state) return;
18
+
19
+ if (!state.assistantStarted) {
20
+ state.onStart?.(ws);
21
+ state.assistantStarted = true;
22
+ }
23
+
24
+ state.pendingText += text;
25
+
26
+ if (state.pendingText.length >= STREAM_FLUSH_TEXT_THRESHOLD) {
27
+ flushTextStream(ws, state);
28
+ return;
29
+ }
30
+
31
+ if (!state.flushTimer) {
32
+ state.flushTimer = setTimeout(() => {
33
+ state.flushTimer = null;
34
+ flushTextStream(ws, state);
35
+ }, STREAM_FLUSH_INTERVAL);
36
+ }
37
+ }
38
+
39
+ function flushTextStream(ws, state) {
40
+ if (!state) return;
41
+
42
+ if (state.flushTimer) {
43
+ clearTimeout(state.flushTimer);
44
+ state.flushTimer = null;
45
+ }
46
+
47
+ if (!state.pendingText) return;
48
+ if (ws) send(ws, 'assistant_chunk', { text: state.pendingText });
49
+ state.pendingText = '';
50
+ state.lastFlushAt = Date.now();
51
+ }
52
+
53
+ function resetTextStream(state) {
54
+ if (!state) return;
55
+
56
+ if (state.flushTimer) {
57
+ clearTimeout(state.flushTimer);
58
+ }
59
+
60
+ state.assistantStarted = false;
61
+ state.pendingText = '';
62
+ state.flushTimer = null;
63
+ state.lastFlushAt = 0;
64
+ }
65
+
66
+ module.exports = {
67
+ STREAM_FLUSH_INTERVAL,
68
+ STREAM_FLUSH_TEXT_THRESHOLD,
69
+ appendTextStream,
70
+ createTextStreamBuffer,
71
+ flushTextStream,
72
+ resetTextStream,
73
+ };
@@ -0,0 +1,293 @@
1
+ const { WebSocketServer } = require('ws');
2
+ const { executeAgentQuery, resolvePendingDanger, resolvePendingPermission } = require('./agentRunner');
3
+ const { handleLegacyImageMessage, handleMessageWithAttachments } = require('./attachmentHandler');
4
+ const { startOutboxWatcher } = require('./outgoingAttachmentHandler');
5
+ const { isLocalRequestAuthorized } = require('./localAuth');
6
+ const { send, sendError, sendSystem } = require('./protocol');
7
+ const { cleanupSession, createSession } = require('./session');
8
+ const { handleSlashCommand } = require('./slashCommands');
9
+ const { isLincoMessage, toInternal, createLincoAdapter } = require('./lincoProtocol');
10
+
11
+ function attachWebSocketServer(server, config) {
12
+ const log = config.logger;
13
+ const activeSessions = config.activeSessions || new Map();
14
+ config.activeSessions = activeSessions;
15
+ const wss = new WebSocketServer({ server, maxPayload: config.maxWsPayloadBytes });
16
+
17
+ wss.on('connection', (ws, request) => {
18
+ const url = new URL(request?.url || '/', 'http://localhost');
19
+ if (!isLocalRequestAuthorized(request, config, url)) {
20
+ log?.warn('websocket authorization failed', { path: url.pathname });
21
+ ws.close(1008, 'Unauthorized local test access');
22
+ return;
23
+ }
24
+
25
+ let session;
26
+ try {
27
+ session = createSession(config, { externalSessionId: parseExternalSessionId(request), agentType: parseAgentType(request, config) });
28
+ } catch (err) {
29
+ log?.error('session initialization failed', { error: err.message });
30
+ sendError(ws, `❌ 会话初始化失败: ${err.message}`);
31
+ ws.close(1008, 'Invalid session_id');
32
+ return;
33
+ }
34
+
35
+ if (!registerActiveSession(activeSessions, session)) {
36
+ log?.warn('duplicate websocket session rejected', { sessionId: session.id });
37
+ cleanupSession(session);
38
+ sendError(ws, '❌ 该 session_id 已有活动连接');
39
+ ws.close(1008, 'Session already active');
40
+ return;
41
+ }
42
+
43
+ // Detect Linco mode from URL parameter
44
+ const lincoMode = url.searchParams.get('linco') !== null;
45
+ let effectiveWs = ws;
46
+ if (lincoMode) {
47
+ session.linco = {
48
+ accountId: 'main',
49
+ agentId: 'main',
50
+ chatType: 'direct',
51
+ streamId: `linco-stream-${Date.now()}`,
52
+ fullText: '',
53
+ };
54
+ effectiveWs = createLincoAdapter(ws, session, config);
55
+ session._lincoAdapterActive = true;
56
+ config.logger?.info('linco protocol activated on connect', { sessionId: session.id });
57
+ }
58
+
59
+ session.ws = effectiveWs;
60
+ startOutboxWatcher(effectiveWs, session, config);
61
+
62
+ log?.info('websocket session opened', { sessionId: session.id, workspace: session.workspace });
63
+ sendSessionInfo(effectiveWs, session, config);
64
+ sendSystem(effectiveWs, `👋 已连接到 Linco Agent\n📂 工作目录: ${session.workspace}\n📋 输入 /help 查看可用命令`);
65
+
66
+ ws.on('message', (data) => {
67
+ handleMessage(data, ws, session, config);
68
+ });
69
+
70
+ ws.on('close', (code, reason) => {
71
+ log?.info('websocket session closed', {
72
+ sessionId: session.id,
73
+ code,
74
+ reason: reason?.toString(),
75
+ });
76
+ if (activeSessions.get(session.activeKey) === session) {
77
+ activeSessions.delete(session.activeKey);
78
+ }
79
+ cleanupSession(session);
80
+ });
81
+
82
+ ws.on('error', (err) => {
83
+ log?.error('websocket error', { sessionId: session.id, error: err.message });
84
+ });
85
+ });
86
+
87
+ return wss;
88
+ }
89
+
90
+ function parseExternalSessionId(request) {
91
+ const rawUrl = request?.url || '/';
92
+ const url = new URL(rawUrl, 'http://localhost');
93
+ if (!url.searchParams.has('session_id') && !url.searchParams.has('sessionId')) return undefined;
94
+
95
+ const sessionId = url.searchParams.get('session_id') ?? url.searchParams.get('sessionId');
96
+ if (!String(sessionId || '').trim()) {
97
+ throw new Error('session_id 不能为空');
98
+ }
99
+ return sessionId;
100
+ }
101
+
102
+ function parseAgentType(request, config) {
103
+ const rawUrl = request?.url || '/';
104
+ const url = new URL(rawUrl, 'http://localhost');
105
+ const requested = String(url.searchParams.get('agentType') || '').trim().toLowerCase();
106
+ if (requested && config.agents?.[requested]) return requested;
107
+ return config.defaultLocalAgent || 'claude';
108
+ }
109
+
110
+ function registerActiveSession(activeSessions, session) {
111
+ if (activeSessions.has(session.activeKey)) return false;
112
+ activeSessions.set(session.activeKey, session);
113
+ return true;
114
+ }
115
+
116
+ function sendSessionInfo(ws, session, config) {
117
+ send(ws, 'session_info', {
118
+ sessionId: session.id,
119
+ sessionIdSource: session.idSource,
120
+ storageId: session.storageId,
121
+ agentType: session.agentType,
122
+ agentSessionId: session.agentSessionId,
123
+ workspace: session.workspace,
124
+ runtime: {
125
+ dir: session.runtimeDir,
126
+ attachmentsDir: session.attachmentsDir,
127
+ outboxDir: session.outboxDir,
128
+ },
129
+ upload: {
130
+ maxCount: config.maxAttachmentCount,
131
+ maxFileBytes: config.maxAttachmentBytes,
132
+ maxTotalBytes: config.maxTotalAttachmentBytes,
133
+ blockedExtensions: config.allowUnsafeAttachments ? [] : config.unsafeAttachmentExtensions,
134
+ },
135
+ capabilities: {
136
+ incomingAttachments: true,
137
+ multimodalImages: session.agentType === 'claude',
138
+ outgoingAttachments: true,
139
+ },
140
+ });
141
+ }
142
+
143
+ function handleMessage(data, ws, session, config) {
144
+ let msg;
145
+ try {
146
+ msg = JSON.parse(data.toString());
147
+ } catch (err) {
148
+ config.logger?.warn('invalid websocket message json', { sessionId: session.id, error: err.message });
149
+ sendError(ws, '❌ 消息格式错误');
150
+ return;
151
+ }
152
+
153
+ // --- Linco protocol handling ---
154
+ if (isLincoMessage(msg)) {
155
+ if (msg.type === 'ping') {
156
+ ws.send(JSON.stringify({ type: 'pong', from: 'linco', ts: Date.now() }));
157
+ return;
158
+ }
159
+ if (msg.type === 'pong') return;
160
+
161
+ if (msg.type === 'inbound_message') {
162
+ handleLincoInboundMessage(msg, ws, session, config);
163
+ return;
164
+ }
165
+
166
+ // danger_confirm / permission_response in Linco format
167
+ if (msg.type === 'danger_confirm') {
168
+ config.logger?.info('danger confirmation received (linco)', { sessionKey: msg.sessionKey, approved: !!msg.approved });
169
+ if (!resolvePendingDanger(!!msg.approved, session.ws, session, config)) {
170
+ sendError(session.ws, '❌ 没有待确认的危险操作');
171
+ }
172
+ return;
173
+ }
174
+ if (msg.type === 'permission_response') {
175
+ config.logger?.info('permission response received (linco)', { sessionKey: msg.sessionKey, approved: !!msg.approved });
176
+ if (!resolvePendingPermission(!!msg.approved, session.ws, session, config)) {
177
+ sendError(session.ws, '❌ 没有待确认的工具权限请求');
178
+ }
179
+ return;
180
+ }
181
+ return;
182
+ }
183
+
184
+ // --- Internal protocol handling ---
185
+ if (msg.type === 'danger_confirm') {
186
+ config.logger?.info('danger confirmation received', { sessionId: session.id, approved: !!msg.approved });
187
+ if (!resolvePendingDanger(!!msg.approved, ws, session, config)) {
188
+ sendError(ws, '❌ 没有待确认的危险操作');
189
+ }
190
+ return;
191
+ }
192
+
193
+ if (msg.type === 'permission_response') {
194
+ config.logger?.info('permission response received', { sessionId: session.id, approved: !!msg.approved });
195
+ if (!resolvePendingPermission(!!msg.approved, ws, session, config)) {
196
+ sendError(ws, '❌ 没有待确认的工具权限请求');
197
+ }
198
+ return;
199
+ }
200
+
201
+ if (msg.type === 'message') {
202
+ const rawText = String(msg.text || '').trim();
203
+ config.logger?.info('user message received', {
204
+ sessionId: session.id,
205
+ type: msg.type,
206
+ chars: rawText.length,
207
+ attachments: Array.isArray(msg.attachments) ? msg.attachments.length : 0,
208
+ });
209
+ if (rawText.startsWith('/') && (!Array.isArray(msg.attachments) || msg.attachments.length === 0)) {
210
+ if (handleSlashCommand(rawText, ws, session, config)) return;
211
+ }
212
+
213
+ handleMessageWithAttachments(msg, ws, session, config, executeAgentQuery);
214
+ return;
215
+ }
216
+
217
+ if (msg.type === 'image') {
218
+ config.logger?.info('legacy image message received', { sessionId: session.id });
219
+ handleLegacyImageMessage(msg, ws, session, config, executeAgentQuery);
220
+ return;
221
+ }
222
+
223
+ if (!msg.type && typeof msg.text === 'string') {
224
+ const rawText = msg.text.trim();
225
+ config.logger?.info('legacy text message received', { sessionId: session.id, chars: rawText.length });
226
+
227
+ if (rawText.startsWith('/') && handleSlashCommand(rawText, ws, session, config)) {
228
+ return;
229
+ }
230
+
231
+ executeAgentQuery(rawText, ws, session, config);
232
+ return;
233
+ }
234
+
235
+ config.logger?.warn('unknown websocket message type', { sessionId: session.id, type: msg.type || '(empty)' });
236
+ sendError(ws, `❌ 未知消息类型: ${msg.type || '(空)'}`);
237
+ }
238
+
239
+ function handleLincoInboundMessage(msg, rawWs, session, config) {
240
+ // Activate Linco adapter on first inbound message (if not already activated via URL param)
241
+ if (!session._lincoAdapterActive) {
242
+ // Update session ID from Linco sessionKey if available
243
+ if (msg.sessionKey) {
244
+ session.id = msg.sessionKey;
245
+ session.activeKey = `${session.agentType}:${msg.sessionKey}`;
246
+ if (config.activeSessions) {
247
+ config.activeSessions.set(session.activeKey, session);
248
+ }
249
+ }
250
+
251
+ // Initialize linco metadata
252
+ session.linco = {
253
+ accountId: msg.accountId || 'main',
254
+ agentId: msg.agentId || 'main',
255
+ chatType: msg.chatType || 'direct',
256
+ userId: msg.userId,
257
+ messageId: msg.messageId,
258
+ streamId: msg.streamId || `linco-stream-${msg.messageId || Date.now()}`,
259
+ fullText: '',
260
+ };
261
+
262
+ // Wrap ws with Linco adapter for outbound conversion
263
+ const adapterWs = createLincoAdapter(rawWs, session, config);
264
+ session.ws = adapterWs;
265
+
266
+ // Update outbox watcher to use the wrapped ws
267
+ startOutboxWatcher(adapterWs, session, config);
268
+
269
+ session._lincoAdapterActive = true;
270
+ config.logger?.info('linco protocol activated', { sessionId: session.id });
271
+ }
272
+
273
+ // Convert to internal format and route
274
+ const internal = toInternal(msg);
275
+ const rawText = internal.text || '';
276
+ const attachments = internal.attachments || [];
277
+
278
+ config.logger?.info('linco inbound message', {
279
+ sessionId: session.id,
280
+ chars: rawText.length,
281
+ attachments: attachments.length,
282
+ });
283
+
284
+ if (rawText.startsWith('/') && attachments.length === 0) {
285
+ if (handleSlashCommand(rawText, session.ws, session, config)) return;
286
+ }
287
+
288
+ handleMessageWithAttachments(internal, session.ws, session, config, executeAgentQuery);
289
+ }
290
+
291
+ module.exports = {
292
+ attachWebSocketServer,
293
+ };