@bolloon/bolloon-agent 0.1.11 → 0.1.13
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/agents/p2p-chat-tools.js +321 -0
- package/dist/agents/p2p-document-tools.js +121 -1
- package/dist/agents/workflow-pivot-loop.js +4 -4
- package/dist/cli-entry.js +1 -1
- package/dist/documents/reader.js +5 -0
- package/dist/documents/store.js +1 -1
- package/dist/llm/pi-ai.js +6 -5
- package/dist/network/iroh-discovery.js +2 -1
- package/dist/network/iroh-transport.js +15 -2
- package/dist/network/p2p.js +9 -8
- package/dist/network/storage/adapters/json-adapter.js +16 -1
- package/dist/network/storage/index.js +2 -1
- package/dist/pi-ecosystem-judgment/index.js +43 -115
- package/dist/social/channels/channel-heartbeat-agent.js +1 -1
- package/dist/utils/auto-update.js +15 -1
- package/dist/web/components/p2p/index.js +226 -264
- package/dist/web/index.html +12 -0
- package/package.json +3 -1
- package/scripts/build-web.ts +1 -1
- package/scripts/postinstall.js +1 -1
- package/src/agents/p2p-chat-tools.ts +383 -0
- package/src/agents/p2p-document-tools.ts +151 -1
- package/src/agents/workflow-pivot-loop.ts +13 -12
- package/src/bollharness-integration/channel-judgment-engine.ts +1 -1
- package/src/cli-entry.ts +1 -1
- package/src/documents/reader.ts +5 -0
- package/src/documents/store.ts +1 -1
- package/src/llm/pi-ai.ts +6 -5
- package/src/network/iroh-discovery.ts +2 -1
- package/src/network/iroh-transport.ts +15 -2
- package/src/network/p2p.ts +9 -8
- package/src/network/storage/adapters/json-adapter.ts +17 -2
- package/src/network/storage/index.ts +19 -3
- package/src/social/channels/channel-heartbeat-agent.ts +1 -1
- package/src/utils/auto-update.ts +17 -1
- package/src/web/server.ts +149 -0
- package/tsconfig.electron.json +1 -1
- package/tsconfig.json +1 -1
- package/dist/web/components/p2p/P2PModal.js +0 -188
- package/dist/web/components/p2p/p2p-modal.js +0 -657
- package/dist/web/components/p2p/p2p-tools.js +0 -248
- package/dist/web/server.js +0 -1890
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* p2p-chat-tools — 异步 chat 通道 + 持久化 inbox + 判断力外包 (draft)
|
|
3
|
+
*
|
|
4
|
+
* 解决痛点: 两个节点的智能体代替各自人类主人做异步判断
|
|
5
|
+
*
|
|
6
|
+
* 流程:
|
|
7
|
+
* 1. A 节点 (人类在线) 通过 sendChat(peerDID, text) 发消息 → iroh 'agent_chat'
|
|
8
|
+
* 2. B 节点 (人类可能离线):
|
|
9
|
+
* - onMessage('agent_chat') → 落 ~/.bolloon/inbox/<peerDID>.jsonl
|
|
10
|
+
* - 状态 = 'received' (未处理)
|
|
11
|
+
* 3. B 节点 wake-up (processPendingInbox) → 扫描 status='received' 的消息
|
|
12
|
+
* - 调 LLM 生成 draft (注入主人历史判断 + ValueProfile)
|
|
13
|
+
* - 写 status='drafted', draft 落盘
|
|
14
|
+
* - 通过 'agent_chat' 消息类型回送 draft (前缀 [DRAFT] 表明是代回)
|
|
15
|
+
* 4. A 节点收到 draft → 写到 A 的 outbox (inbox 中 from=B 的那条)
|
|
16
|
+
* 5. B 人类上线 → getInbox() 看到 'drafted' 状态的条目, approveAndSend / dismissDraft
|
|
17
|
+
*
|
|
18
|
+
* 镜像 p2p-document-tools.ts 风格: iroh 消息 + 可注入 transport (支持多实例测试)
|
|
19
|
+
*/
|
|
20
|
+
import * as crypto from 'crypto';
|
|
21
|
+
import * as fs from 'fs/promises';
|
|
22
|
+
import * as path from 'path';
|
|
23
|
+
import * as os from 'os';
|
|
24
|
+
import { EventEmitter } from 'events';
|
|
25
|
+
import { irohTransport as defaultIrohTransport } from '../network/iroh-transport.js';
|
|
26
|
+
import { getRelevantValues, getValueProfile, loadAllJudgments } from '../pi-ecosystem-judgment/human-value-store.js';
|
|
27
|
+
function homeDir() {
|
|
28
|
+
return process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
29
|
+
}
|
|
30
|
+
class ChatEventBus extends EventEmitter {
|
|
31
|
+
}
|
|
32
|
+
export const chatEventBus = new ChatEventBus();
|
|
33
|
+
const states = new WeakMap();
|
|
34
|
+
function getState(transport) {
|
|
35
|
+
let s = states.get(transport);
|
|
36
|
+
if (!s) {
|
|
37
|
+
s = { handlers: new Set(), listenerInstalled: false };
|
|
38
|
+
states.set(transport, s);
|
|
39
|
+
}
|
|
40
|
+
return s;
|
|
41
|
+
}
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// Inbox 存储
|
|
44
|
+
// ============================================================================
|
|
45
|
+
function inboxDir() {
|
|
46
|
+
return path.join(homeDir(), '.bolloon', 'inbox');
|
|
47
|
+
}
|
|
48
|
+
function inboxPath(peerDID) {
|
|
49
|
+
const safe = peerDID.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
50
|
+
return path.join(inboxDir(), `${safe}.jsonl`);
|
|
51
|
+
}
|
|
52
|
+
function outboxPath() {
|
|
53
|
+
return path.join(inboxDir(), '_outbox.jsonl');
|
|
54
|
+
}
|
|
55
|
+
async function appendInbox(peerDID, entry) {
|
|
56
|
+
await fs.mkdir(inboxDir(), { recursive: true });
|
|
57
|
+
await fs.appendFile(inboxPath(peerDID), JSON.stringify(entry) + '\n', 'utf-8');
|
|
58
|
+
}
|
|
59
|
+
async function readInbox(peerDID) {
|
|
60
|
+
try {
|
|
61
|
+
const content = await fs.readFile(inboxPath(peerDID), 'utf-8');
|
|
62
|
+
return content.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function rewriteInbox(peerDID, entries) {
|
|
69
|
+
await fs.mkdir(inboxDir(), { recursive: true });
|
|
70
|
+
await fs.writeFile(inboxPath(peerDID), entries.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
|
|
71
|
+
}
|
|
72
|
+
async function updateInboxEntry(peerDID, id, patch) {
|
|
73
|
+
const entries = await readInbox(peerDID);
|
|
74
|
+
const idx = entries.findIndex((e) => e.id === id);
|
|
75
|
+
if (idx < 0)
|
|
76
|
+
return null;
|
|
77
|
+
entries[idx] = { ...entries[idx], ...patch };
|
|
78
|
+
await rewriteInbox(peerDID, entries);
|
|
79
|
+
return entries[idx];
|
|
80
|
+
}
|
|
81
|
+
async function markOutboundDraft(originalId, draft, at) {
|
|
82
|
+
try {
|
|
83
|
+
const content = await fs.readFile(outboxPath(), 'utf-8');
|
|
84
|
+
const entries = content.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
|
|
85
|
+
const idx = entries.findIndex((e) => e.id === originalId);
|
|
86
|
+
if (idx >= 0) {
|
|
87
|
+
entries[idx].inboundDraft = draft;
|
|
88
|
+
entries[idx].inboundDraftAt = at;
|
|
89
|
+
await fs.writeFile(outboxPath(), entries.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch { /* no outbox */ }
|
|
93
|
+
}
|
|
94
|
+
// ============================================================================
|
|
95
|
+
// Listener 安装 (每个 transport 各装一次)
|
|
96
|
+
// ============================================================================
|
|
97
|
+
function ensureListener(transport) {
|
|
98
|
+
const s = getState(transport);
|
|
99
|
+
if (s.listenerInstalled)
|
|
100
|
+
return;
|
|
101
|
+
s.listenerInstalled = true;
|
|
102
|
+
transport.onMessage('agent_chat', (msg) => {
|
|
103
|
+
try {
|
|
104
|
+
const data = JSON.parse(new TextDecoder().decode(msg.payload));
|
|
105
|
+
void handleIncomingChat(transport, msg.from, data);
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
console.error('[ChatReceiver] parse err:', e.message);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async function handleIncomingChat(transport, fromNodeId, data) {
|
|
113
|
+
const peerDID = fromNodeId;
|
|
114
|
+
if (data.kind === 'user') {
|
|
115
|
+
const entry = {
|
|
116
|
+
id: data.id, peerDID, fromNodeId, text: data.text,
|
|
117
|
+
timestamp: Date.now(), receivedAt: Date.now(), status: 'received',
|
|
118
|
+
};
|
|
119
|
+
await appendInbox(peerDID, entry);
|
|
120
|
+
const preview = data.text.slice(0, 60);
|
|
121
|
+
const peerShort = peerDID.slice(0, 12);
|
|
122
|
+
console.log('[ChatReceiver] received from ' + peerShort + ': ' + preview);
|
|
123
|
+
chatEventBus.emit('chat', { kind: 'inbox_new', peerDID, entry });
|
|
124
|
+
for (const h of getState(transport).handlers) {
|
|
125
|
+
try {
|
|
126
|
+
h(entry, fromNodeId);
|
|
127
|
+
}
|
|
128
|
+
catch { }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else if (data.kind === 'draft' && data.originalId) {
|
|
132
|
+
await markOutboundDraft(data.originalId, data.text, Date.now());
|
|
133
|
+
console.log('[ChatReceiver] inbound draft for ' + data.originalId);
|
|
134
|
+
chatEventBus.emit('chat', { kind: 'outbox_draft_in', peerDID, originalId: data.originalId, draft: data.text });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// ============================================================================
|
|
138
|
+
// 公共 API (可注入 transport)
|
|
139
|
+
// ============================================================================
|
|
140
|
+
export function onChatMessage(handler, transport = defaultIrohTransport) {
|
|
141
|
+
getState(transport).handlers.add(handler);
|
|
142
|
+
ensureListener(transport);
|
|
143
|
+
}
|
|
144
|
+
export async function initChatReceiver(transport = defaultIrohTransport) {
|
|
145
|
+
ensureListener(transport);
|
|
146
|
+
await fs.mkdir(inboxDir(), { recursive: true });
|
|
147
|
+
console.log('[ChatReceiver] Initialized at', inboxDir(), 'transport=', transport === defaultIrohTransport ? 'singleton' : 'instance');
|
|
148
|
+
}
|
|
149
|
+
export async function sendChat(peerDID, text, transport = defaultIrohTransport) {
|
|
150
|
+
const id = crypto.randomUUID();
|
|
151
|
+
const ok = await transport.sendMessage(peerDID, 'agent_chat', new TextEncoder().encode(JSON.stringify({ kind: 'user', id, text })));
|
|
152
|
+
if (ok) {
|
|
153
|
+
const entry = {
|
|
154
|
+
id, peerDID,
|
|
155
|
+
fromNodeId: transport.getNodeId() || '',
|
|
156
|
+
text, timestamp: Date.now(), receivedAt: Date.now(),
|
|
157
|
+
status: 'sent', sentText: text, sentAt: Date.now(),
|
|
158
|
+
};
|
|
159
|
+
await fs.mkdir(inboxDir(), { recursive: true });
|
|
160
|
+
await fs.appendFile(outboxPath(), JSON.stringify(entry) + '\n', 'utf-8');
|
|
161
|
+
chatEventBus.emit('chat', { kind: 'outbox_updated', peerDID, entry });
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
console.warn(`[ChatReceiver] send to ${peerDID.slice(0, 12)}... failed`);
|
|
165
|
+
}
|
|
166
|
+
return id;
|
|
167
|
+
}
|
|
168
|
+
export async function getInbox(peerDID) {
|
|
169
|
+
if (peerDID)
|
|
170
|
+
return readInbox(peerDID);
|
|
171
|
+
try {
|
|
172
|
+
const files = await fs.readdir(inboxDir());
|
|
173
|
+
const all = [];
|
|
174
|
+
for (const f of files) {
|
|
175
|
+
if (!f.endsWith('.jsonl') || f.startsWith('_'))
|
|
176
|
+
continue;
|
|
177
|
+
const peerDID = f.replace('.jsonl', '').replace(/_/g, ':');
|
|
178
|
+
all.push(...await readInbox(peerDID));
|
|
179
|
+
}
|
|
180
|
+
return all.sort((a, b) => b.receivedAt - a.receivedAt);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// ============================================================================
|
|
187
|
+
// Draft 引擎
|
|
188
|
+
// ============================================================================
|
|
189
|
+
async function buildValueHint(text) {
|
|
190
|
+
try {
|
|
191
|
+
const allJudgments = await loadAllJudgments();
|
|
192
|
+
const relevant = await getRelevantValues(text, undefined);
|
|
193
|
+
const profile = await getValueProfile('me');
|
|
194
|
+
const profileHint = [
|
|
195
|
+
`quality_focus=${profile.quality_focus.toFixed(2)}`,
|
|
196
|
+
`efficiency_focus=${profile.efficiency_focus.toFixed(2)}`,
|
|
197
|
+
`safety_focus=${profile.safety_focus.toFixed(2)}`,
|
|
198
|
+
`collaboration_focus=${profile.collaboration_focus.toFixed(2)}`,
|
|
199
|
+
`learning_focus=${profile.learning_focus.toFixed(2)}`,
|
|
200
|
+
].join(', ');
|
|
201
|
+
const reasons = allJudgments
|
|
202
|
+
.filter((j) => (j.reasons || []).length > 0)
|
|
203
|
+
.slice(-10)
|
|
204
|
+
.flatMap((j) => j.reasons || [])
|
|
205
|
+
.slice(0, 20);
|
|
206
|
+
return `\n[主人历史判断 (style 参考, 不可外泄)]\n关注维度: ${profileHint}\n关键理由示例: ${reasons.join(' | ').slice(0, 400) || '(无)'}\n`;
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
return '';
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
export async function generateDraft(messageId, peerDID, transport = defaultIrohTransport) {
|
|
213
|
+
const entries = await readInbox(peerDID);
|
|
214
|
+
const entry = entries.find((e) => e.id === messageId);
|
|
215
|
+
if (!entry)
|
|
216
|
+
return null;
|
|
217
|
+
if (entry.status !== 'received')
|
|
218
|
+
return entry;
|
|
219
|
+
const valueHint = await buildValueHint(entry.text);
|
|
220
|
+
const promptForDraft = `你是主人的代理. 对方发来这条消息: "${entry.text.slice(0, 1500)}"\n${valueHint}\n请基于主人的历史判断, 用 1-2 句话代主人拟一个回复草案. 草案要保留主人的语气和立场, 开头标注 [DRAFT]. 直接给草案文本, 不要解释.`;
|
|
221
|
+
let draftText = '';
|
|
222
|
+
let confidence = 0.5;
|
|
223
|
+
try {
|
|
224
|
+
const openaiKey = process.env.OPENAI_API_KEY;
|
|
225
|
+
if (!openaiKey)
|
|
226
|
+
throw new Error('OPENAI_API_KEY not set');
|
|
227
|
+
const openaiBase = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1';
|
|
228
|
+
const openaiModel = process.env.OPENAI_MODEL || 'gpt-4';
|
|
229
|
+
const r = await fetch(`${openaiBase}/chat/completions`, {
|
|
230
|
+
method: 'POST',
|
|
231
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${openaiKey}` },
|
|
232
|
+
body: JSON.stringify({
|
|
233
|
+
model: openaiModel,
|
|
234
|
+
messages: [
|
|
235
|
+
{ role: 'system', content: '你是主人的代理. 你的输出会被主人审阅后才发出. 请谨慎.' },
|
|
236
|
+
{ role: 'user', content: promptForDraft },
|
|
237
|
+
],
|
|
238
|
+
temperature: 0.4,
|
|
239
|
+
max_tokens: 300,
|
|
240
|
+
}),
|
|
241
|
+
signal: AbortSignal.timeout(15000),
|
|
242
|
+
});
|
|
243
|
+
if (r.ok) {
|
|
244
|
+
const data = await r.json();
|
|
245
|
+
draftText = (data.choices?.[0]?.message?.content || '').trim();
|
|
246
|
+
confidence = 0.7;
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
console.warn(`[DraftEngine] LLM ${r.status}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
console.warn(`[DraftEngine] LLM call failed: ${e.message}`);
|
|
254
|
+
}
|
|
255
|
+
if (!draftText) {
|
|
256
|
+
draftText = `[DRAFT] 已收到. (本地 draft 引擎未配置 LLM, 主人上线后请手写回复)`;
|
|
257
|
+
confidence = 0.1;
|
|
258
|
+
}
|
|
259
|
+
const updated = await updateInboxEntry(peerDID, messageId, {
|
|
260
|
+
status: 'drafted', draft: draftText, draftConfidence: confidence,
|
|
261
|
+
draftReasoning: valueHint.slice(0, 200), draftAt: Date.now(),
|
|
262
|
+
});
|
|
263
|
+
if (updated) {
|
|
264
|
+
const sent = await transport.sendMessage(peerDID, 'agent_chat', new TextEncoder().encode(JSON.stringify({ kind: 'draft', id: crypto.randomUUID(), text: draftText, originalId: messageId })));
|
|
265
|
+
if (sent)
|
|
266
|
+
console.log('[DraftEngine] draft sent to ' + peerDID.slice(0, 12) + ' for ' + messageId.slice(0, 8));
|
|
267
|
+
else
|
|
268
|
+
console.warn('[DraftEngine] failed to send draft');
|
|
269
|
+
chatEventBus.emit('chat', { kind: 'draft_ready', peerDID, entry: updated });
|
|
270
|
+
chatEventBus.emit('chat', { kind: 'outbox_updated', peerDID, entry: updated });
|
|
271
|
+
}
|
|
272
|
+
return updated;
|
|
273
|
+
}
|
|
274
|
+
export async function processPendingInbox(transport = defaultIrohTransport) {
|
|
275
|
+
await fs.mkdir(inboxDir(), { recursive: true });
|
|
276
|
+
const files = await fs.readdir(inboxDir());
|
|
277
|
+
let processed = 0, skipped = 0;
|
|
278
|
+
for (const f of files) {
|
|
279
|
+
if (!f.endsWith('.jsonl') || f.startsWith('_'))
|
|
280
|
+
continue;
|
|
281
|
+
const peerDID = f.replace('.jsonl', '').replace(/_/g, ':');
|
|
282
|
+
const entries = await readInbox(peerDID);
|
|
283
|
+
for (const e of entries) {
|
|
284
|
+
if (e.status === 'received') {
|
|
285
|
+
const r = await generateDraft(e.id, peerDID, transport);
|
|
286
|
+
if (r)
|
|
287
|
+
processed++;
|
|
288
|
+
else
|
|
289
|
+
skipped++;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return { processed, skipped };
|
|
294
|
+
}
|
|
295
|
+
export async function approveAndSend(messageId, peerDID, finalText, transport = defaultIrohTransport) {
|
|
296
|
+
const entries = await readInbox(peerDID);
|
|
297
|
+
const entry = entries.find((e) => e.id === messageId);
|
|
298
|
+
if (!entry || entry.status !== 'drafted')
|
|
299
|
+
return false;
|
|
300
|
+
const text = finalText || entry.draft || '';
|
|
301
|
+
const ok = await transport.sendMessage(peerDID, 'agent_chat', new TextEncoder().encode(JSON.stringify({ kind: 'user', id: crypto.randomUUID(), text })));
|
|
302
|
+
if (ok) {
|
|
303
|
+
const updated = await updateInboxEntry(peerDID, messageId, { status: 'sent', sentText: text, sentAt: Date.now() });
|
|
304
|
+
if (updated)
|
|
305
|
+
chatEventBus.emit('chat', { kind: 'inbox_sent', peerDID, entry: updated });
|
|
306
|
+
}
|
|
307
|
+
return ok;
|
|
308
|
+
}
|
|
309
|
+
export async function dismissDraft(messageId, peerDID) {
|
|
310
|
+
const entries = await readInbox(peerDID);
|
|
311
|
+
const entry = entries.find((e) => e.id === messageId);
|
|
312
|
+
if (!entry)
|
|
313
|
+
return false;
|
|
314
|
+
const updated = await updateInboxEntry(peerDID, messageId, { status: 'dismissed' });
|
|
315
|
+
if (updated)
|
|
316
|
+
chatEventBus.emit('chat', { kind: 'inbox_dismissed', peerDID, entry: updated });
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
export const p2pChatTools = {
|
|
320
|
+
sendChat, getInbox, processPendingInbox, generateDraft, approveAndSend, dismissDraft, onChatMessage, initChatReceiver,
|
|
321
|
+
};
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import * as crypto from 'crypto';
|
|
5
5
|
import * as fs from 'fs/promises';
|
|
6
|
+
import * as path from 'path';
|
|
6
7
|
import { irohTransport } from '../network/iroh-transport.js';
|
|
7
8
|
import { documentReader } from '../documents/reader.js';
|
|
8
9
|
import { documentStore } from '../documents/store.js';
|
|
@@ -15,6 +16,11 @@ function getMimeType(filename) {
|
|
|
15
16
|
const mimeTypes = {
|
|
16
17
|
'txt': 'text/plain',
|
|
17
18
|
'md': 'text/markdown',
|
|
19
|
+
'html': 'text/html',
|
|
20
|
+
'htm': 'text/html',
|
|
21
|
+
'yaml': 'application/yaml',
|
|
22
|
+
'yml': 'application/yaml',
|
|
23
|
+
'json': 'application/json',
|
|
18
24
|
'pdf': 'application/pdf',
|
|
19
25
|
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
20
26
|
};
|
|
@@ -22,8 +28,10 @@ function getMimeType(filename) {
|
|
|
22
28
|
}
|
|
23
29
|
export async function initDocumentReceiver() {
|
|
24
30
|
await documentStore.initialize();
|
|
25
|
-
documentStore.onDocumentReceived((doc) => {
|
|
31
|
+
documentStore.onDocumentReceived(async (doc) => {
|
|
26
32
|
console.log(`[DocumentReceiver] Document received: ${doc.fileName} from ${doc.fromNodeIdShort}`);
|
|
33
|
+
// 异步调用 LLM 解析(不阻塞接收流程)
|
|
34
|
+
void parseDocumentWithLLM(doc);
|
|
27
35
|
});
|
|
28
36
|
irohTransport.onMessage('document_chunk', async (msg) => {
|
|
29
37
|
try {
|
|
@@ -39,6 +47,118 @@ export async function initDocumentReceiver() {
|
|
|
39
47
|
});
|
|
40
48
|
console.log('[DocumentReceiver] Initialized and listening for document chunks');
|
|
41
49
|
}
|
|
50
|
+
const feedbackHandlers = new Set();
|
|
51
|
+
/** 注册 AI 解析反馈监听器(发送方调用) */
|
|
52
|
+
export function onAIFeedback(handler) {
|
|
53
|
+
feedbackHandlers.add(handler);
|
|
54
|
+
// iroh listener 只挂一次
|
|
55
|
+
ensureFeedbackListenerInstalled();
|
|
56
|
+
}
|
|
57
|
+
let feedbackListenerInstalled = false;
|
|
58
|
+
function ensureFeedbackListenerInstalled() {
|
|
59
|
+
if (feedbackListenerInstalled)
|
|
60
|
+
return;
|
|
61
|
+
feedbackListenerInstalled = true;
|
|
62
|
+
irohTransport.onMessage('ai_feedback', (msg) => {
|
|
63
|
+
try {
|
|
64
|
+
const fb = JSON.parse(new TextDecoder().decode(msg.payload));
|
|
65
|
+
for (const h of feedbackHandlers) {
|
|
66
|
+
try {
|
|
67
|
+
h(fb, msg.from);
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
console.error('[AIFeedback] handler error:', e);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
console.error('[AIFeedback] failed to parse message:', e);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** 文档解析服务:优先走 web 统一入口 (含 judgment + harness), fallback 到本地 LLM */
|
|
80
|
+
async function callAIParseService(text, fileName, mimeType, fromNodeId) {
|
|
81
|
+
// 1) 优先: 调 web 端 POST /api/ai-parse (统一入口, 含 judgment + harness)
|
|
82
|
+
const webBase = process.env.BOLLOON_WEB_URL || process.env.PORTAL_URL || 'http://127.0.0.1:54188';
|
|
83
|
+
try {
|
|
84
|
+
const r = await fetch(`${webBase}/api/ai-parse`, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'Content-Type': 'application/json' },
|
|
87
|
+
body: JSON.stringify({ text, mimeType, fileName, fromNodeId, source: 'p2p-document' }),
|
|
88
|
+
// 短超时, fallback 友好
|
|
89
|
+
signal: AbortSignal.timeout(8000),
|
|
90
|
+
});
|
|
91
|
+
if (r.ok) {
|
|
92
|
+
const data = await r.json();
|
|
93
|
+
return {
|
|
94
|
+
summary: data.summary,
|
|
95
|
+
qualityScore: data.qualityScore,
|
|
96
|
+
source: 'web',
|
|
97
|
+
judgmentId: data.judgmentId,
|
|
98
|
+
gateArtifact: data.gateArtifact,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
console.warn(`[AIParse] web returned ${r.status}, fallback to local`);
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
console.warn(`[AIParse] web ${webBase} unreachable (${e.message}), fallback to local`);
|
|
105
|
+
}
|
|
106
|
+
// 2) Fallback: 直接调本地 LLM (不调 judgment/harness, 仅在 web 不可用时使用)
|
|
107
|
+
const apiKey = process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.MINIMAX_API_KEY || process.env.OPENROUTER_API_KEY || process.env.GEMINI_API_KEY;
|
|
108
|
+
if (!apiKey) {
|
|
109
|
+
throw new Error('No web endpoint and no LLM API key configured. Set BOLLOON_WEB_URL or OPENAI_API_KEY.');
|
|
110
|
+
}
|
|
111
|
+
const { getMinimax } = await import('../constraints/index.js');
|
|
112
|
+
const llm = getMinimax();
|
|
113
|
+
const truncated = text.length > 6000 ? text.substring(0, 6000) + '...[截断]' : text;
|
|
114
|
+
const prompt = `请分析以下 ${mimeType} 文档,并给出 (1) 一句话中文摘要 (2) 关键要点列表 (3) 文档质量评分(0-1)。\n\n文件名: ${fileName}\n\n内容:\n${truncated}`;
|
|
115
|
+
const r = await llm.summarize(prompt);
|
|
116
|
+
return { summary: r.summary, qualityScore: r.qualityScore, source: 'local' };
|
|
117
|
+
}
|
|
118
|
+
async function parseDocumentWithLLM(doc) {
|
|
119
|
+
try {
|
|
120
|
+
const filePath = path.join(process.env.HOME || '/tmp', '.bolloon', 'documents', 'received', doc.id, doc.fileName);
|
|
121
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
122
|
+
const r = await callAIParseService(content, doc.fileName, doc.mimeType, doc.fromNodeId);
|
|
123
|
+
const sidecar = {
|
|
124
|
+
filename: doc.fileName,
|
|
125
|
+
mimeType: doc.mimeType,
|
|
126
|
+
fromNodeId: doc.fromNodeId,
|
|
127
|
+
receivedAt: doc.receivedAt,
|
|
128
|
+
summary: r.summary,
|
|
129
|
+
qualityScore: r.qualityScore,
|
|
130
|
+
parseSource: r.source,
|
|
131
|
+
judgmentId: r.judgmentId,
|
|
132
|
+
gateArtifact: r.gateArtifact,
|
|
133
|
+
analyzedAt: Date.now(),
|
|
134
|
+
};
|
|
135
|
+
const sidecarPath = path.join(process.env.HOME || '/tmp', '.bolloon', 'documents', 'received', doc.id, 'ai-analysis.json');
|
|
136
|
+
await fs.writeFile(sidecarPath, JSON.stringify(sidecar, null, 2));
|
|
137
|
+
console.log(`[DocumentReceiver] AI parsed ${doc.fileName} via ${r.source} (score=${r.qualityScore.toFixed(2)})${r.judgmentId ? ` judgment=${r.judgmentId}` : ''}${r.gateArtifact ? ` gate=${r.gateArtifact}` : ''}`);
|
|
138
|
+
// 把 AI 解析结果回送给发送方 (doc.fromNodeId),形成 "接收 → 解析 → 反馈" 闭环
|
|
139
|
+
if (doc.fromNodeId) {
|
|
140
|
+
const feedback = {
|
|
141
|
+
docId: doc.id,
|
|
142
|
+
fileName: doc.fileName,
|
|
143
|
+
mimeType: doc.mimeType,
|
|
144
|
+
summary: r.summary,
|
|
145
|
+
qualityScore: r.qualityScore,
|
|
146
|
+
feedbackAt: Date.now(),
|
|
147
|
+
fromNodeId: irohTransport.getNodeId() || '',
|
|
148
|
+
};
|
|
149
|
+
const sent = await irohTransport.sendMessage(doc.fromNodeId, 'ai_feedback', new TextEncoder().encode(JSON.stringify(feedback)));
|
|
150
|
+
if (sent) {
|
|
151
|
+
console.log(`[DocumentReceiver] Feedback sent to ${doc.fromNodeIdShort}`);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
console.warn(`[DocumentReceiver] Failed to send feedback to ${doc.fromNodeIdShort}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
console.error(`[DocumentReceiver] AI parse failed for ${doc.fileName}:`, e);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
42
162
|
export const p2pDocumentTools = [
|
|
43
163
|
{
|
|
44
164
|
name: 'list_online_peers',
|
|
@@ -220,7 +220,7 @@ export class WorkflowPivotLoop {
|
|
|
220
220
|
tool: toolCall.name
|
|
221
221
|
});
|
|
222
222
|
try {
|
|
223
|
-
const result = await tool.execute(toolCall.args);
|
|
223
|
+
const result = await tool.execute(toolCall.args ?? {});
|
|
224
224
|
this.emit({
|
|
225
225
|
type: result.success ? 'status' : 'error',
|
|
226
226
|
content: result.success
|
|
@@ -313,7 +313,7 @@ export class WorkflowPivotLoop {
|
|
|
313
313
|
const argsStr = match[2];
|
|
314
314
|
const args = this.parseArgs(argsStr);
|
|
315
315
|
if (this.tools.has(name)) {
|
|
316
|
-
pending.push({ name, args });
|
|
316
|
+
pending.push({ name, args, description: '', parameters: {} });
|
|
317
317
|
}
|
|
318
318
|
}
|
|
319
319
|
// Pattern 2: tool_name(args) format
|
|
@@ -327,7 +327,7 @@ export class WorkflowPivotLoop {
|
|
|
327
327
|
if (!this.tools.has(name))
|
|
328
328
|
continue;
|
|
329
329
|
const args = this.parseArgs(argsStr);
|
|
330
|
-
pending.push({ name, args });
|
|
330
|
+
pending.push({ name, args, description: '', parameters: {} });
|
|
331
331
|
}
|
|
332
332
|
// Pattern 3: JSON format tool calls
|
|
333
333
|
try {
|
|
@@ -337,7 +337,7 @@ export class WorkflowPivotLoop {
|
|
|
337
337
|
if (Array.isArray(parsed.tool_calls)) {
|
|
338
338
|
for (const tc of parsed.tool_calls) {
|
|
339
339
|
if (this.tools.has(tc.name)) {
|
|
340
|
-
pending.push({ name: tc.name, args: tc.args || {} });
|
|
340
|
+
pending.push({ name: tc.name, args: tc.args || {}, description: '', parameters: {} });
|
|
341
341
|
}
|
|
342
342
|
}
|
|
343
343
|
}
|
package/dist/cli-entry.js
CHANGED
package/dist/documents/reader.js
CHANGED
package/dist/documents/store.js
CHANGED
|
@@ -139,10 +139,10 @@ export class DocumentStore {
|
|
|
139
139
|
async readDocument(docId) {
|
|
140
140
|
const docDir = path.join(this.baseDir, docId);
|
|
141
141
|
const manifestPath = path.join(docDir, 'manifest.json');
|
|
142
|
-
const filePath = path.join(docDir);
|
|
143
142
|
try {
|
|
144
143
|
const manifestData = await fs.readFile(manifestPath, 'utf-8');
|
|
145
144
|
const manifest = JSON.parse(manifestData);
|
|
145
|
+
const filePath = path.join(docDir, manifest.fileName);
|
|
146
146
|
const fileContent = await fs.readFile(filePath, 'utf-8');
|
|
147
147
|
return {
|
|
148
148
|
content: fileContent,
|
package/dist/llm/pi-ai.js
CHANGED
|
@@ -102,25 +102,26 @@ export class PiAIModel {
|
|
|
102
102
|
if (this.config.baseUrl) {
|
|
103
103
|
return this.config.baseUrl;
|
|
104
104
|
}
|
|
105
|
+
// 允许通过 OPENAI_BASE_URL 等环境变量覆盖默认 base URL
|
|
105
106
|
const baseUrls = {
|
|
106
|
-
openai: 'https://api.openai.com/v1',
|
|
107
|
+
openai: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1',
|
|
107
108
|
anthropic: 'https://api.anthropic.com/v1',
|
|
108
109
|
ollama: process.env.OLLAMA_BASE_URL || 'http://localhost:11434',
|
|
109
|
-
openrouter: 'https://openrouter.ai/api/v1',
|
|
110
|
+
openrouter: process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1',
|
|
110
111
|
gemini: 'https://generativelanguage.googleapis.com/v1beta',
|
|
111
|
-
minimax: 'https://api.minimaxi.com/v1',
|
|
112
|
+
minimax: process.env.MINIMAX_BASE_URL || 'https://api.minimaxi.com/v1',
|
|
112
113
|
local: 'http://localhost:11434'
|
|
113
114
|
};
|
|
114
115
|
return baseUrls[this.provider];
|
|
115
116
|
}
|
|
116
117
|
mapModel() {
|
|
117
118
|
const modelMap = {
|
|
118
|
-
openai: this.config.model || 'gpt-4',
|
|
119
|
+
openai: this.config.model || process.env.OPENAI_MODEL || 'gpt-4',
|
|
119
120
|
anthropic: this.config.model || 'claude-3-5-sonnet-20241022',
|
|
120
121
|
ollama: this.config.model || 'llama3.2',
|
|
121
122
|
openrouter: this.config.model || 'anthropic/claude-3.5-sonnet',
|
|
122
123
|
gemini: this.config.model || 'gemini-2.0-flash',
|
|
123
|
-
minimax: this.config.model || 'MiniMax-M2.7',
|
|
124
|
+
minimax: this.config.model || process.env.MINIMAX_MODEL || 'MiniMax-M2.7',
|
|
124
125
|
local: this.config.model || 'llama3.2'
|
|
125
126
|
};
|
|
126
127
|
return modelMap[this.provider];
|
|
@@ -54,9 +54,10 @@ export class IrohDiscoveryService {
|
|
|
54
54
|
}, this.config.refreshIntervalMs);
|
|
55
55
|
}
|
|
56
56
|
startDiscoveryLoop() {
|
|
57
|
+
const interval = this.config.discoveryIntervalMs ?? 30000;
|
|
57
58
|
this.discoveryTimer = setInterval(async () => {
|
|
58
59
|
await this.discoverPeers();
|
|
59
|
-
},
|
|
60
|
+
}, interval);
|
|
60
61
|
setTimeout(() => this.discoverPeers(), 2000);
|
|
61
62
|
}
|
|
62
63
|
async discoverPeers() {
|
|
@@ -100,6 +100,9 @@ export class IrohTransport {
|
|
|
100
100
|
count += queue.length;
|
|
101
101
|
return count;
|
|
102
102
|
},
|
|
103
|
+
async getAllOfflineTargets() {
|
|
104
|
+
return Array.from(offlineQueues.keys());
|
|
105
|
+
},
|
|
103
106
|
async savePendingResponse(req) {
|
|
104
107
|
const id = crypto.randomUUID();
|
|
105
108
|
const pending = { ...req, id };
|
|
@@ -123,8 +126,13 @@ export class IrohTransport {
|
|
|
123
126
|
if (!this.messageStore)
|
|
124
127
|
return;
|
|
125
128
|
this.offlineDeliveryInterval = setInterval(async () => {
|
|
129
|
+
// 遍历所有有离线消息的目标节点(不仅是"已连接"的)
|
|
130
|
+
// 这样目标节点一旦在线(accept 连接)就能拿到离线消息
|
|
131
|
+
const allTargets = await this.messageStore.getAllOfflineTargets();
|
|
126
132
|
const connectedPeers = this.getConnectedPeers();
|
|
127
|
-
|
|
133
|
+
// 合并:已连接 + 有离线消息但未连接(也会去尝试 connect)
|
|
134
|
+
const targets = new Set([...connectedPeers, ...allTargets]);
|
|
135
|
+
for (const peerId of targets) {
|
|
128
136
|
const offlineMsgs = await this.messageStore.getOfflineMessages(peerId);
|
|
129
137
|
for (const msg of offlineMsgs) {
|
|
130
138
|
if (msg.retryCount >= 10) {
|
|
@@ -140,6 +148,9 @@ export class IrohTransport {
|
|
|
140
148
|
await this.messageStore.dequeueOfflineMessage(msg.id);
|
|
141
149
|
console.log(`[IrohTransport] Delivered offline message to ${peerId.substring(0, 12)}...`);
|
|
142
150
|
}
|
|
151
|
+
else {
|
|
152
|
+
await this.messageStore.incrementOfflineRetry(msg.id);
|
|
153
|
+
}
|
|
143
154
|
}
|
|
144
155
|
catch {
|
|
145
156
|
await this.messageStore.incrementOfflineRetry(msg.id);
|
|
@@ -356,8 +367,10 @@ export class IrohTransport {
|
|
|
356
367
|
await send.writeAll(Buffer.from(requestMsg));
|
|
357
368
|
await send.finish();
|
|
358
369
|
// 等待响应,带超时
|
|
370
|
+
// 注意: server sendResponse 后会关闭连接,导致 readToEnd 以 "connection lost" 错误 reject
|
|
371
|
+
// 这里我们把 readToEnd 的错误吞掉(视为流结束),只有超时才视为失败
|
|
359
372
|
const response = await Promise.race([
|
|
360
|
-
recv.readToEnd(64 * 1024),
|
|
373
|
+
recv.readToEnd(64 * 1024).catch(() => new Uint8Array(0)),
|
|
361
374
|
new Promise((_, rejectTimeout) => setTimeout(() => rejectTimeout(new Error('timeout')), timeout)),
|
|
362
375
|
]);
|
|
363
376
|
conn.close();
|
package/dist/network/p2p.js
CHANGED
|
@@ -437,8 +437,8 @@ export class P2PNetwork {
|
|
|
437
437
|
const colonIdx = messageStr.indexOf(':');
|
|
438
438
|
const didMarker = 'DID:';
|
|
439
439
|
let did;
|
|
440
|
-
let type;
|
|
441
|
-
let payload;
|
|
440
|
+
let type = 'message';
|
|
441
|
+
let payload = '';
|
|
442
442
|
let requestId = undefined;
|
|
443
443
|
if (messageStr.startsWith(didMarker)) {
|
|
444
444
|
const didEndIdx = messageStr.indexOf('|');
|
|
@@ -651,7 +651,11 @@ export class P2PNetwork {
|
|
|
651
651
|
* Register a handler for responses (used by the receiving side)
|
|
652
652
|
*/
|
|
653
653
|
onResponse(type, handler) {
|
|
654
|
-
|
|
654
|
+
// Store as pendingResponseHandlers-shaped wrapper. Extra args (did, requestId) are not
|
|
655
|
+
// available in pendingResponseHandlers signature, so ignore them when invoked.
|
|
656
|
+
this.pendingResponseHandlers.set(type, (responseData, from) => {
|
|
657
|
+
handler(responseData, from, undefined, undefined);
|
|
658
|
+
});
|
|
655
659
|
}
|
|
656
660
|
/**
|
|
657
661
|
* Send a response back to a peer
|
|
@@ -675,11 +679,8 @@ export class P2PNetwork {
|
|
|
675
679
|
handleRequest(type, payload, requestId, fromPeerId, did) {
|
|
676
680
|
const handler = this.messageHandlers.get(type);
|
|
677
681
|
if (handler) {
|
|
678
|
-
//
|
|
679
|
-
|
|
680
|
-
handler = (msg, from, didParam) => {
|
|
681
|
-
originalHandler(msg, from, didParam);
|
|
682
|
-
};
|
|
682
|
+
// Forward raw payload; callers register with onMessage() and adapt as needed.
|
|
683
|
+
handler(new TextEncoder().encode(payload), fromPeerId, did);
|
|
683
684
|
}
|
|
684
685
|
// Check if there's a response handler registered
|
|
685
686
|
const responseHandler = this.pendingResponseHandlers.get(type);
|