@decido/kernel-bridge 1.0.0 → 4.0.2
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/README.md +31 -0
- package/dist/index.js +2593 -0
- package/dist/index.mjs +2518 -0
- package/package.json +13 -7
- package/.turbo/turbo-build.log +0 -13
- package/.turbo/turbo-lint.log +0 -30
- package/src/ai/components/PeerNetworkPanel.tsx +0 -219
- package/src/ai/components/TokenWalletPanel.tsx +0 -172
- package/src/ai/hooks/usePeerMesh.ts +0 -79
- package/src/ai/hooks/useTokenWallet.ts +0 -35
- package/src/ai/index.ts +0 -96
- package/src/ai/services/EmbeddingService.ts +0 -119
- package/src/ai/services/InferenceRouter.ts +0 -347
- package/src/ai/services/LocalAgentResponder.ts +0 -199
- package/src/ai/services/MLXBridge.ts +0 -278
- package/src/ai/services/OllamaService.ts +0 -326
- package/src/ai/services/PeerMesh.ts +0 -373
- package/src/ai/services/TokenWallet.ts +0 -237
- package/src/ai/services/providers/AnthropicProvider.ts +0 -229
- package/src/ai/services/providers/GeminiProvider.ts +0 -121
- package/src/ai/services/providers/LLMProvider.ts +0 -72
- package/src/ai/services/providers/OllamaProvider.ts +0 -84
- package/src/ai/services/providers/OpenAIProvider.ts +0 -178
- package/src/crypto.ts +0 -54
- package/src/index.ts +0 -4
- package/src/kernel.ts +0 -376
- package/src/rehydration.ts +0 -52
- package/tsconfig.json +0 -18
|
@@ -1,326 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OllamaService — Chat interface for DecidoOS Agent
|
|
3
|
-
*
|
|
4
|
-
* Manages conversation history, system prompt construction,
|
|
5
|
-
* and tool-call parsing. Chat requests are now routed through
|
|
6
|
-
* InferenceRouter to support multiple LLM providers.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { routeChat } from './InferenceRouter';
|
|
10
|
-
import type { ChatMessage } from './providers/LLMProvider';
|
|
11
|
-
|
|
12
|
-
// ─── Types ──────────────────────────────────────────────────
|
|
13
|
-
|
|
14
|
-
interface OllamaChatMessage {
|
|
15
|
-
role: 'system' | 'user' | 'assistant';
|
|
16
|
-
content: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface OllamaModelInfo {
|
|
20
|
-
name: string;
|
|
21
|
-
size: number;
|
|
22
|
-
modified_at: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// ─── Tool Call Types ────────────────────────────────────────
|
|
26
|
-
|
|
27
|
-
export interface ToolCallRequest {
|
|
28
|
-
name: string;
|
|
29
|
-
args: Record<string, unknown>;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface ChatWithToolsResult {
|
|
33
|
-
text: string;
|
|
34
|
-
toolCalls: ToolCallRequest[];
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// ─── Config ─────────────────────────────────────────────────
|
|
38
|
-
|
|
39
|
-
const OLLAMA_BASE_URL = 'http://localhost:11434';
|
|
40
|
-
const DEFAULT_MODEL = 'qwen2:latest';
|
|
41
|
-
|
|
42
|
-
// ─── System Prompt ──────────────────────────────────────────
|
|
43
|
-
|
|
44
|
-
function buildSystemPrompt(): string {
|
|
45
|
-
const liveContext = buildLiveContext();
|
|
46
|
-
const toolSchemas = ''; // Removed toolRegistry reference to fix dependencies, but kept variable for now
|
|
47
|
-
|
|
48
|
-
return `- Estás integrado en DecidoOS, una plataforma empresarial de escritorio
|
|
49
|
-
- Corres localmente en la máquina del usuario — todo es privado
|
|
50
|
-
- Puedes ejecutar herramientas del sistema para ayudar al usuario
|
|
51
|
-
|
|
52
|
-
${liveContext}
|
|
53
|
-
|
|
54
|
-
## Herramientas disponibles
|
|
55
|
-
|
|
56
|
-
${toolSchemas}
|
|
57
|
-
|
|
58
|
-
## Reglas de respuesta
|
|
59
|
-
1. Responde siempre en español a menos que el usuario hable en otro idioma
|
|
60
|
-
2. Sé directo — no des introducciones largas
|
|
61
|
-
3. Si puedes resolver algo con una herramienta, USA LA HERRAMIENTA nativa en vez de solo describirla
|
|
62
|
-
4. Mantén respuestas bajo 300 palabras
|
|
63
|
-
5. Si no sabes algo, dilo honestamente
|
|
64
|
-
6. NO inventes resultados de herramientas — espera el resultado real
|
|
65
|
-
7. Cuando el usuario pregunte sobre el estado del sistema, USA LOS DATOS del "Estado actual del sistema" que tienes arriba — esos son datos reales y recientes`;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// ─── Live Context Builder ───────────────────────────────────
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Builds a live system context section from watchdog metrics
|
|
72
|
-
* and the app's context snapshot. Called every time a message
|
|
73
|
-
* is sent to keep the LLM's awareness current.
|
|
74
|
-
*/
|
|
75
|
-
function buildLiveContext(): string {
|
|
76
|
-
const parts: string[] = ['## Estado actual del sistema'];
|
|
77
|
-
const now = new Date().toLocaleString('es-CO', { hour12: false });
|
|
78
|
-
parts.push(`Hora actual: ${now}`);
|
|
79
|
-
|
|
80
|
-
// Watchdog metrics
|
|
81
|
-
try {
|
|
82
|
-
// Dynamic import to avoid circular deps — we access synchronously via singleton
|
|
83
|
-
const watchdogModule = (globalThis as any).__systemWatchdog;
|
|
84
|
-
if (watchdogModule) {
|
|
85
|
-
const snapshot = watchdogModule.getLastSnapshot?.();
|
|
86
|
-
if (snapshot) {
|
|
87
|
-
const metrics: string[] = [];
|
|
88
|
-
if (snapshot.cpuPercent !== null) metrics.push(`CPU: ${snapshot.cpuPercent.toFixed(1)}%`);
|
|
89
|
-
if (snapshot.memoryPercent !== null) metrics.push(`Memoria: ${snapshot.memoryPercent.toFixed(1)}%`);
|
|
90
|
-
if (snapshot.diskFreeGB !== null) metrics.push(`Disco libre: ${snapshot.diskFreeGB.toFixed(1)} GB`);
|
|
91
|
-
if (snapshot.connectionCount !== null) metrics.push(`Conexiones de red: ${snapshot.connectionCount}`);
|
|
92
|
-
if (metrics.length > 0) {
|
|
93
|
-
parts.push(`Métricas del sistema: ${metrics.join(' | ')}`);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const alerts = watchdogModule.getAlerts?.()?.filter((a: any) => !a.dismissed).slice(-5) ?? [];
|
|
98
|
-
if (alerts.length > 0) {
|
|
99
|
-
parts.push('Alertas recientes:');
|
|
100
|
-
for (const alert of alerts) {
|
|
101
|
-
const emoji = alert.severity === 'critical' ? '🚨' : '⚠️';
|
|
102
|
-
parts.push(` ${emoji} ${alert.title}`);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
} catch {
|
|
107
|
-
// Watchdog not available yet
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Context snapshot from store (accessed via globalThis to avoid monolith coupling)
|
|
111
|
-
try {
|
|
112
|
-
const appStore = (globalThis as any).__appStore;
|
|
113
|
-
const ctx = appStore?.getState?.()?.contextSnapshot;
|
|
114
|
-
if (ctx) {
|
|
115
|
-
if (ctx.canvasNodeCount > 0) parts.push(`Canvas: ${ctx.canvasNodeCount} nodos`);
|
|
116
|
-
if (ctx.gitBranch) parts.push(`Git: rama ${ctx.gitBranch}${ctx.gitModifiedFiles ? ` (${ctx.gitModifiedFiles} archivos modificados)` : ''}`);
|
|
117
|
-
if (ctx.activeInsights > 0) parts.push(`Insights activos: ${ctx.activeInsights}`);
|
|
118
|
-
if (ctx.criticalInsightsSummary?.length > 0) {
|
|
119
|
-
parts.push('Insights críticos: ' + ctx.criticalInsightsSummary.slice(0, 3).join('; '));
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
} catch {
|
|
123
|
-
// Store not available
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Persistent memory (learned facts)
|
|
127
|
-
try {
|
|
128
|
-
const memoryModule = (globalThis as any).__agentMemory;
|
|
129
|
-
if (memoryModule) {
|
|
130
|
-
const memContext = memoryModule.buildMemoryContext?.();
|
|
131
|
-
if (memContext) {
|
|
132
|
-
parts.push('');
|
|
133
|
-
parts.push(memContext);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
} catch {
|
|
137
|
-
// Memory not available
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return parts.join('\n');
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// ─── Tool Call Parser ───────────────────────────────────────
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Parse Ollama native tool calls from the message object.
|
|
147
|
-
* Previously this used a Regex over the text response.
|
|
148
|
-
*/
|
|
149
|
-
export function parseToolCalls(message: any): ToolCallRequest[] {
|
|
150
|
-
const calls: ToolCallRequest[] = [];
|
|
151
|
-
|
|
152
|
-
if (message?.tool_calls && Array.isArray(message.tool_calls)) {
|
|
153
|
-
for (const tc of message.tool_calls) {
|
|
154
|
-
if (tc.function?.name) {
|
|
155
|
-
calls.push({
|
|
156
|
-
name: tc.function.name,
|
|
157
|
-
args: tc.function.arguments || {},
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return calls;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Strip tool_call blocks from text to get the clean message.
|
|
168
|
-
* Using native tools, this is usually a no-op as tools are not in the text,
|
|
169
|
-
* but kept for backwards compatibility with any leaked formatting.
|
|
170
|
-
*/
|
|
171
|
-
export function stripToolCalls(text: string): string {
|
|
172
|
-
if (!text) return '';
|
|
173
|
-
return text.replace(/<tool_call>[\s\S]*?<\/tool_call>/g, '').trim();
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// ─── Conversation History ───────────────────────────────────
|
|
177
|
-
|
|
178
|
-
let conversationHistory: OllamaChatMessage[] = [];
|
|
179
|
-
const MAX_HISTORY = 20;
|
|
180
|
-
|
|
181
|
-
function addToHistory(msg: OllamaChatMessage): void {
|
|
182
|
-
conversationHistory.push(msg);
|
|
183
|
-
if (conversationHistory.length > MAX_HISTORY) {
|
|
184
|
-
conversationHistory = conversationHistory.slice(-MAX_HISTORY);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export function clearConversationHistory(): void {
|
|
189
|
-
conversationHistory = [];
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// ─── API Methods ────────────────────────────────────────────
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Check if Ollama is running and accessible.
|
|
196
|
-
*/
|
|
197
|
-
export async function isOllamaAvailable(): Promise<boolean> {
|
|
198
|
-
try {
|
|
199
|
-
const res = await fetch(`${OLLAMA_BASE_URL}/api/tags`, {
|
|
200
|
-
signal: AbortSignal.timeout(2000)
|
|
201
|
-
});
|
|
202
|
-
return res.ok;
|
|
203
|
-
} catch {
|
|
204
|
-
return false;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* List available models from Ollama.
|
|
210
|
-
*/
|
|
211
|
-
export async function listModels(): Promise<string[]> {
|
|
212
|
-
try {
|
|
213
|
-
const res = await fetch(`${OLLAMA_BASE_URL}/api/tags`);
|
|
214
|
-
if (!res.ok) return [];
|
|
215
|
-
const data = await res.json();
|
|
216
|
-
return (data.models || []).map((m: OllamaModelInfo) => m.name);
|
|
217
|
-
} catch {
|
|
218
|
-
return [];
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Send a chat message and get a complete response (non-streaming).
|
|
224
|
-
* Routes through InferenceRouter to support multiple providers.
|
|
225
|
-
*/
|
|
226
|
-
export async function chat(
|
|
227
|
-
userMessage: string,
|
|
228
|
-
options?: { model?: string; temperature?: number }
|
|
229
|
-
): Promise<string> {
|
|
230
|
-
const userMsg: ChatMessage = { role: 'user', content: userMessage };
|
|
231
|
-
addToHistory(userMsg);
|
|
232
|
-
|
|
233
|
-
const messages: ChatMessage[] = [
|
|
234
|
-
{ role: 'system', content: buildSystemPrompt() },
|
|
235
|
-
...conversationHistory,
|
|
236
|
-
];
|
|
237
|
-
|
|
238
|
-
try {
|
|
239
|
-
const result = await routeChat(messages, {
|
|
240
|
-
temperature: options?.temperature ?? 0.7,
|
|
241
|
-
maxTokens: 512,
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
const assistantContent = result?.text || '';
|
|
245
|
-
addToHistory({ role: 'assistant', content: assistantContent });
|
|
246
|
-
|
|
247
|
-
if (result) {
|
|
248
|
-
console.log(`🧠[Chat] Response via ${result.backend} (${result.model}) in ${result.latencyMs} ms`);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
return assistantContent;
|
|
252
|
-
} catch (error) {
|
|
253
|
-
if (error instanceof DOMException && error.name === 'TimeoutError') {
|
|
254
|
-
return '⏳ La respuesta del modelo tardó demasiado. Intenta con una pregunta más corta.';
|
|
255
|
-
}
|
|
256
|
-
throw error;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* Send a chat message and stream the response token-by-token.
|
|
262
|
-
* NOTE: Streaming only works with Ollama directly (local provider).
|
|
263
|
-
* For cloud providers, this falls back to non-streaming.
|
|
264
|
-
*/
|
|
265
|
-
export async function* chatStream(
|
|
266
|
-
userMessage: string,
|
|
267
|
-
options?: { model?: string; temperature?: number }
|
|
268
|
-
): AsyncGenerator<string, void, unknown> {
|
|
269
|
-
const model = options?.model || DEFAULT_MODEL;
|
|
270
|
-
|
|
271
|
-
const userMsg: OllamaChatMessage = { role: 'user', content: userMessage };
|
|
272
|
-
addToHistory(userMsg);
|
|
273
|
-
|
|
274
|
-
const messages: OllamaChatMessage[] = [
|
|
275
|
-
{ role: 'system', content: buildSystemPrompt() },
|
|
276
|
-
...conversationHistory,
|
|
277
|
-
];
|
|
278
|
-
|
|
279
|
-
const res = await fetch(`${OLLAMA_BASE_URL}/api/chat`, {
|
|
280
|
-
method: 'POST',
|
|
281
|
-
headers: { 'Content-Type': 'application/json' },
|
|
282
|
-
body: JSON.stringify({
|
|
283
|
-
model,
|
|
284
|
-
messages,
|
|
285
|
-
stream: true,
|
|
286
|
-
options: {
|
|
287
|
-
temperature: options?.temperature ?? 0.7,
|
|
288
|
-
num_predict: 512,
|
|
289
|
-
},
|
|
290
|
-
}),
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
if (!res.ok || !res.body) {
|
|
294
|
-
throw new Error(`Ollama stream error: ${res.status}`);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const reader = res.body.getReader();
|
|
298
|
-
const decoder = new TextDecoder();
|
|
299
|
-
let fullContent = '';
|
|
300
|
-
|
|
301
|
-
try {
|
|
302
|
-
while (true) {
|
|
303
|
-
const { done, value } = await reader.read();
|
|
304
|
-
if (done) break;
|
|
305
|
-
|
|
306
|
-
const chunk = decoder.decode(value, { stream: true });
|
|
307
|
-
const lines = chunk.split('\n').filter(Boolean);
|
|
308
|
-
|
|
309
|
-
for (const line of lines) {
|
|
310
|
-
try {
|
|
311
|
-
const data = JSON.parse(line);
|
|
312
|
-
if (data.message?.content) {
|
|
313
|
-
fullContent += data.message.content;
|
|
314
|
-
yield data.message.content;
|
|
315
|
-
}
|
|
316
|
-
} catch {
|
|
317
|
-
// Skip malformed chunks
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
} finally {
|
|
322
|
-
reader.releaseLock();
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
addToHistory({ role: 'assistant', content: fullContent });
|
|
326
|
-
}
|
|
@@ -1,373 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PeerMesh — WebSocket-based P2P mesh for Decido OS instances
|
|
3
|
-
*
|
|
4
|
-
* Allows multiple Decido OS instances on the same network to form
|
|
5
|
-
* a group and share AI results/context. Each peer maintains its
|
|
6
|
-
* own provider keys (BYOK). Uses manual connection (IP:port) in
|
|
7
|
-
* this first iteration; future versions will use mDNS auto-discovery.
|
|
8
|
-
*
|
|
9
|
-
* Protocol:
|
|
10
|
-
* - Host starts a WebSocket server on a port (default 9876)
|
|
11
|
-
* - Clients connect to the host's IP:port
|
|
12
|
-
* - Messages: join, leave, share-result, share-context, heartbeat
|
|
13
|
-
* - Max 8 peers per group
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { v4 as uuid } from 'uuid';
|
|
17
|
-
|
|
18
|
-
// ─── Types ──────────────────────────────────────────────────
|
|
19
|
-
|
|
20
|
-
export interface PeerInfo {
|
|
21
|
-
id: string;
|
|
22
|
-
name: string;
|
|
23
|
-
address: string;
|
|
24
|
-
joinedAt: number;
|
|
25
|
-
lastSeen: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface SharedResult {
|
|
29
|
-
peerId: string;
|
|
30
|
-
provider: string;
|
|
31
|
-
model: string;
|
|
32
|
-
prompt: string;
|
|
33
|
-
response: string;
|
|
34
|
-
timestamp: number;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
interface PeerMessage {
|
|
38
|
-
type: 'join' | 'leave' | 'share-result' | 'share-context' | 'heartbeat' | 'peers-update';
|
|
39
|
-
peerId: string;
|
|
40
|
-
peerName: string;
|
|
41
|
-
payload?: unknown;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// ─── Constants ──────────────────────────────────────────────
|
|
45
|
-
|
|
46
|
-
const DEFAULT_PORT = 9876;
|
|
47
|
-
const HEARTBEAT_INTERVAL = 10_000; // 10s
|
|
48
|
-
const PEER_TIMEOUT = 30_000; // 30s without heartbeat = dead
|
|
49
|
-
const MAX_PEERS = 8;
|
|
50
|
-
|
|
51
|
-
// ─── Event Callbacks ────────────────────────────────────────
|
|
52
|
-
|
|
53
|
-
type PeersChangedCb = (peers: PeerInfo[]) => void;
|
|
54
|
-
type HostingChangedCb = (isHosting: boolean) => void;
|
|
55
|
-
type ConnectionChangedCb = (isConnected: boolean) => void;
|
|
56
|
-
type SharedResultCb = (result: SharedResult) => void;
|
|
57
|
-
|
|
58
|
-
// ─── PeerMesh Class ─────────────────────────────────────────
|
|
59
|
-
|
|
60
|
-
class PeerMeshImpl {
|
|
61
|
-
private myId = uuid();
|
|
62
|
-
private myName = this._getHostname();
|
|
63
|
-
private peers = new Map<string, PeerInfo>();
|
|
64
|
-
private connections = new Map<string, WebSocket>();
|
|
65
|
-
private isHostingFlag = false;
|
|
66
|
-
private isConnectedFlag = false;
|
|
67
|
-
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
68
|
-
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
69
|
-
|
|
70
|
-
// Event listeners
|
|
71
|
-
private peersListeners = new Set<PeersChangedCb>();
|
|
72
|
-
private hostingListeners = new Set<HostingChangedCb>();
|
|
73
|
-
private connectionListeners = new Set<ConnectionChangedCb>();
|
|
74
|
-
private resultListeners = new Set<SharedResultCb>();
|
|
75
|
-
|
|
76
|
-
// ── Host Mode ─────────────────────────────────────────
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Start hosting a peer group.
|
|
80
|
-
* Note: In browser WebSocket server is not available. This method
|
|
81
|
-
* is a placeholder for Tauri (Rust-side server). In browser mode,
|
|
82
|
-
* we simulate by acting as a "relay" through a shared connection.
|
|
83
|
-
*/
|
|
84
|
-
startHost(port = DEFAULT_PORT): void {
|
|
85
|
-
if (this.isHostingFlag) return;
|
|
86
|
-
this.isHostingFlag = true;
|
|
87
|
-
this._emitHosting(true);
|
|
88
|
-
this._startHeartbeat();
|
|
89
|
-
console.log(`🌐 [PeerMesh] Hosting on port ${port} (peer: ${this.myId.slice(0, 8)})`);
|
|
90
|
-
console.log(`🌐 [PeerMesh] ⚠️ Browser-mode: WebSocket server requires Tauri backend.`);
|
|
91
|
-
console.log(`🌐 [PeerMesh] Use connectToPeer() from another instance to connect.`);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
stopHost(): void {
|
|
95
|
-
if (!this.isHostingFlag) return;
|
|
96
|
-
this.isHostingFlag = false;
|
|
97
|
-
this._stopHeartbeat();
|
|
98
|
-
this._broadcast({ type: 'leave', peerId: this.myId, peerName: this.myName });
|
|
99
|
-
this._disconnectAll();
|
|
100
|
-
this._emitHosting(false);
|
|
101
|
-
console.log('🌐 [PeerMesh] Stopped hosting');
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ── Client Mode ───────────────────────────────────────
|
|
105
|
-
|
|
106
|
-
connectToPeer(address: string): void {
|
|
107
|
-
if (this.connections.size >= MAX_PEERS) {
|
|
108
|
-
console.warn(`🌐 [PeerMesh] Max peers (${MAX_PEERS}) reached`);
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const wsUrl = address.startsWith('ws') ? address : `ws://${address}`;
|
|
113
|
-
console.log(`🌐 [PeerMesh] Connecting to ${wsUrl}...`);
|
|
114
|
-
|
|
115
|
-
try {
|
|
116
|
-
const ws = new WebSocket(wsUrl);
|
|
117
|
-
|
|
118
|
-
ws.onopen = () => {
|
|
119
|
-
console.log(`🌐 [PeerMesh] ✅ Connected to ${address}`);
|
|
120
|
-
this.connections.set(address, ws);
|
|
121
|
-
this.isConnectedFlag = true;
|
|
122
|
-
this._emitConnection(true);
|
|
123
|
-
|
|
124
|
-
// Announce ourselves
|
|
125
|
-
this._send(ws, {
|
|
126
|
-
type: 'join',
|
|
127
|
-
peerId: this.myId,
|
|
128
|
-
peerName: this.myName,
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
this._startHeartbeat();
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
ws.onmessage = (event) => {
|
|
135
|
-
try {
|
|
136
|
-
const msg: PeerMessage = JSON.parse(event.data);
|
|
137
|
-
this._handleMessage(msg, address);
|
|
138
|
-
} catch { /* ignore malformed messages */ }
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
ws.onclose = () => {
|
|
142
|
-
console.log(`🌐 [PeerMesh] Disconnected from ${address}`);
|
|
143
|
-
this.connections.delete(address);
|
|
144
|
-
// Remove peer associated with this address
|
|
145
|
-
for (const [id, peer] of this.peers) {
|
|
146
|
-
if (peer.address === address) {
|
|
147
|
-
this.peers.delete(id);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
this._emitPeers();
|
|
151
|
-
if (this.connections.size === 0) {
|
|
152
|
-
this.isConnectedFlag = false;
|
|
153
|
-
this._emitConnection(false);
|
|
154
|
-
this._stopHeartbeat();
|
|
155
|
-
}
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
ws.onerror = (err) => {
|
|
159
|
-
console.error(`🌐 [PeerMesh] Connection error to ${address}:`, err);
|
|
160
|
-
};
|
|
161
|
-
} catch (err) {
|
|
162
|
-
console.error(`🌐 [PeerMesh] Failed to connect:`, err);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
disconnect(): void {
|
|
167
|
-
this._broadcast({ type: 'leave', peerId: this.myId, peerName: this.myName });
|
|
168
|
-
this._disconnectAll();
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ── Share Results ─────────────────────────────────────
|
|
172
|
-
|
|
173
|
-
shareResult(result: Omit<SharedResult, 'peerId' | 'timestamp'>): void {
|
|
174
|
-
const fullResult: SharedResult = {
|
|
175
|
-
...result,
|
|
176
|
-
peerId: this.myId,
|
|
177
|
-
timestamp: Date.now(),
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
this._broadcast({
|
|
181
|
-
type: 'share-result',
|
|
182
|
-
peerId: this.myId,
|
|
183
|
-
peerName: this.myName,
|
|
184
|
-
payload: fullResult,
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
console.log(`🌐 [PeerMesh] Shared result to ${this.connections.size} peers`);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// ── Event Subscriptions ───────────────────────────────
|
|
191
|
-
|
|
192
|
-
onPeersChanged(cb: PeersChangedCb): () => void {
|
|
193
|
-
this.peersListeners.add(cb);
|
|
194
|
-
return () => { this.peersListeners.delete(cb); };
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
onHostingChanged(cb: HostingChangedCb): () => void {
|
|
198
|
-
this.hostingListeners.add(cb);
|
|
199
|
-
return () => { this.hostingListeners.delete(cb); };
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
onConnectionChanged(cb: ConnectionChangedCb): () => void {
|
|
203
|
-
this.connectionListeners.add(cb);
|
|
204
|
-
return () => { this.connectionListeners.delete(cb); };
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
onSharedResult(cb: SharedResultCb): () => void {
|
|
208
|
-
this.resultListeners.add(cb);
|
|
209
|
-
return () => { this.resultListeners.delete(cb); };
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// ── Getters ───────────────────────────────────────────
|
|
213
|
-
|
|
214
|
-
getPeers(): PeerInfo[] {
|
|
215
|
-
return Array.from(this.peers.values());
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
getMyId(): string {
|
|
219
|
-
return this.myId;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
getMyName(): string {
|
|
223
|
-
return this.myName;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// ── Message Handling ──────────────────────────────────
|
|
227
|
-
|
|
228
|
-
private _handleMessage(msg: PeerMessage, fromAddress: string): void {
|
|
229
|
-
switch (msg.type) {
|
|
230
|
-
case 'join':
|
|
231
|
-
this.peers.set(msg.peerId, {
|
|
232
|
-
id: msg.peerId,
|
|
233
|
-
name: msg.peerName,
|
|
234
|
-
address: fromAddress,
|
|
235
|
-
joinedAt: Date.now(),
|
|
236
|
-
lastSeen: Date.now(),
|
|
237
|
-
});
|
|
238
|
-
this._emitPeers();
|
|
239
|
-
console.log(`🌐 [PeerMesh] Peer joined: ${msg.peerName} (${msg.peerId.slice(0, 8)})`);
|
|
240
|
-
break;
|
|
241
|
-
|
|
242
|
-
case 'leave':
|
|
243
|
-
this.peers.delete(msg.peerId);
|
|
244
|
-
this._emitPeers();
|
|
245
|
-
console.log(`🌐 [PeerMesh] Peer left: ${msg.peerName}`);
|
|
246
|
-
break;
|
|
247
|
-
|
|
248
|
-
case 'heartbeat':
|
|
249
|
-
if (this.peers.has(msg.peerId)) {
|
|
250
|
-
this.peers.get(msg.peerId)!.lastSeen = Date.now();
|
|
251
|
-
}
|
|
252
|
-
break;
|
|
253
|
-
|
|
254
|
-
case 'share-result':
|
|
255
|
-
if (msg.payload) {
|
|
256
|
-
const result = msg.payload as SharedResult;
|
|
257
|
-
for (const cb of this.resultListeners) {
|
|
258
|
-
try { cb(result); } catch { /* ignore */ }
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
break;
|
|
262
|
-
|
|
263
|
-
case 'peers-update':
|
|
264
|
-
// Host sends updated peer list
|
|
265
|
-
if (Array.isArray(msg.payload)) {
|
|
266
|
-
for (const p of msg.payload as PeerInfo[]) {
|
|
267
|
-
if (p.id !== this.myId && !this.peers.has(p.id)) {
|
|
268
|
-
this.peers.set(p.id, p);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
this._emitPeers();
|
|
272
|
-
}
|
|
273
|
-
break;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// ── Internal Helpers ──────────────────────────────────
|
|
278
|
-
|
|
279
|
-
private _send(ws: WebSocket, msg: PeerMessage): void {
|
|
280
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
281
|
-
ws.send(JSON.stringify(msg));
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
private _broadcast(msg: PeerMessage): void {
|
|
286
|
-
for (const ws of this.connections.values()) {
|
|
287
|
-
this._send(ws, msg);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
private _disconnectAll(): void {
|
|
292
|
-
for (const ws of this.connections.values()) {
|
|
293
|
-
try { ws.close(); } catch { /* ignore */ }
|
|
294
|
-
}
|
|
295
|
-
this.connections.clear();
|
|
296
|
-
this.peers.clear();
|
|
297
|
-
this.isConnectedFlag = false;
|
|
298
|
-
this._stopHeartbeat();
|
|
299
|
-
this._emitPeers();
|
|
300
|
-
this._emitConnection(false);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
private _startHeartbeat(): void {
|
|
304
|
-
if (this.heartbeatTimer) return;
|
|
305
|
-
|
|
306
|
-
this.heartbeatTimer = setInterval(() => {
|
|
307
|
-
this._broadcast({
|
|
308
|
-
type: 'heartbeat',
|
|
309
|
-
peerId: this.myId,
|
|
310
|
-
peerName: this.myName,
|
|
311
|
-
});
|
|
312
|
-
}, HEARTBEAT_INTERVAL);
|
|
313
|
-
|
|
314
|
-
this.cleanupTimer = setInterval(() => {
|
|
315
|
-
const now = Date.now();
|
|
316
|
-
let changed = false;
|
|
317
|
-
for (const [id, peer] of this.peers) {
|
|
318
|
-
if (now - peer.lastSeen > PEER_TIMEOUT) {
|
|
319
|
-
this.peers.delete(id);
|
|
320
|
-
changed = true;
|
|
321
|
-
console.log(`🌐 [PeerMesh] Peer timed out: ${peer.name}`);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
if (changed) this._emitPeers();
|
|
325
|
-
}, PEER_TIMEOUT);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
private _stopHeartbeat(): void {
|
|
329
|
-
if (this.heartbeatTimer) {
|
|
330
|
-
clearInterval(this.heartbeatTimer);
|
|
331
|
-
this.heartbeatTimer = null;
|
|
332
|
-
}
|
|
333
|
-
if (this.cleanupTimer) {
|
|
334
|
-
clearInterval(this.cleanupTimer);
|
|
335
|
-
this.cleanupTimer = null;
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// ── Event Emitters ────────────────────────────────────
|
|
340
|
-
|
|
341
|
-
private _emitPeers(): void {
|
|
342
|
-
const list = this.getPeers();
|
|
343
|
-
for (const cb of this.peersListeners) {
|
|
344
|
-
try { cb(list); } catch { /* ignore */ }
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
private _emitHosting(hosting: boolean): void {
|
|
349
|
-
for (const cb of this.hostingListeners) {
|
|
350
|
-
try { cb(hosting); } catch { /* ignore */ }
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
private _emitConnection(connected: boolean): void {
|
|
355
|
-
for (const cb of this.connectionListeners) {
|
|
356
|
-
try { cb(connected); } catch { /* ignore */ }
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// ── Hostname ──────────────────────────────────────────
|
|
361
|
-
|
|
362
|
-
private _getHostname(): string {
|
|
363
|
-
try {
|
|
364
|
-
return `user-${Math.random().toString(36).slice(2, 6)}`;
|
|
365
|
-
} catch {
|
|
366
|
-
return 'anonymous';
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// ─── Singleton Export ───────────────────────────────────────
|
|
372
|
-
|
|
373
|
-
export const peerMesh = new PeerMeshImpl();
|