@bolloon/bolloon-agent 0.1.12 → 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/web/components/p2p/index.js +226 -264
- package/dist/web/index.html +12 -0
- package/package.json +1 -1
- package/scripts/build-web.ts +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/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/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,383 @@
|
|
|
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
|
+
|
|
21
|
+
import * as crypto from 'crypto';
|
|
22
|
+
import * as fs from 'fs/promises';
|
|
23
|
+
import * as path from 'path';
|
|
24
|
+
import * as os from 'os';
|
|
25
|
+
import { EventEmitter } from 'events';
|
|
26
|
+
import { irohTransport as defaultIrohTransport, type IrohTransport } from '../network/iroh-transport.js';
|
|
27
|
+
import { getRelevantValues, getValueProfile, loadAllJudgments } from '../pi-ecosystem-judgment/human-value-store.js';
|
|
28
|
+
|
|
29
|
+
function homeDir(): string {
|
|
30
|
+
return process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// 事件总线 — 任何状态变化都 emit, web SSE/UI 可订阅
|
|
35
|
+
// ============================================================================
|
|
36
|
+
export type ChatEvent =
|
|
37
|
+
| { kind: 'inbox_new'; peerDID: string; entry: ChatMessage }
|
|
38
|
+
| { kind: 'draft_ready'; peerDID: string; entry: ChatMessage }
|
|
39
|
+
| { kind: 'outbox_updated'; peerDID: string; entry: ChatMessage }
|
|
40
|
+
| { kind: 'outbox_draft_in'; peerDID: string; originalId: string; draft: string }
|
|
41
|
+
| { kind: 'inbox_sent'; peerDID: string; entry: ChatMessage }
|
|
42
|
+
| { kind: 'inbox_dismissed'; peerDID: string; entry: ChatMessage };
|
|
43
|
+
|
|
44
|
+
class ChatEventBus extends EventEmitter {}
|
|
45
|
+
export const chatEventBus = new ChatEventBus();
|
|
46
|
+
|
|
47
|
+
// ============================================================================
|
|
48
|
+
// 类型
|
|
49
|
+
// ============================================================================
|
|
50
|
+
|
|
51
|
+
export type ChatMessageStatus = 'received' | 'drafted' | 'sent' | 'dismissed';
|
|
52
|
+
|
|
53
|
+
export interface ChatMessage {
|
|
54
|
+
id: string;
|
|
55
|
+
peerDID: string;
|
|
56
|
+
fromNodeId: string;
|
|
57
|
+
text: string;
|
|
58
|
+
timestamp: number;
|
|
59
|
+
receivedAt: number;
|
|
60
|
+
status: ChatMessageStatus;
|
|
61
|
+
draft?: string;
|
|
62
|
+
draftConfidence?: number;
|
|
63
|
+
draftReasoning?: string;
|
|
64
|
+
draftAt?: number;
|
|
65
|
+
sentText?: string;
|
|
66
|
+
sentAt?: number;
|
|
67
|
+
inboundDraft?: string;
|
|
68
|
+
inboundDraftAt?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type ChatMessageHandler = (msg: ChatMessage, from: string) => void;
|
|
72
|
+
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// 每 transport 一份状态 (支持多 IrohTransport 实例)
|
|
75
|
+
// ============================================================================
|
|
76
|
+
|
|
77
|
+
interface ChatModuleState {
|
|
78
|
+
handlers: Set<ChatMessageHandler>;
|
|
79
|
+
listenerInstalled: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const states = new WeakMap<IrohTransport, ChatModuleState>();
|
|
83
|
+
|
|
84
|
+
function getState(transport: IrohTransport): ChatModuleState {
|
|
85
|
+
let s = states.get(transport);
|
|
86
|
+
if (!s) {
|
|
87
|
+
s = { handlers: new Set(), listenerInstalled: false };
|
|
88
|
+
states.set(transport, s);
|
|
89
|
+
}
|
|
90
|
+
return s;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ============================================================================
|
|
94
|
+
// Inbox 存储
|
|
95
|
+
// ============================================================================
|
|
96
|
+
|
|
97
|
+
function inboxDir(): string {
|
|
98
|
+
return path.join(homeDir(), '.bolloon', 'inbox');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function inboxPath(peerDID: string): string {
|
|
102
|
+
const safe = peerDID.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
103
|
+
return path.join(inboxDir(), `${safe}.jsonl`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function outboxPath(): string {
|
|
107
|
+
return path.join(inboxDir(), '_outbox.jsonl');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function appendInbox(peerDID: string, entry: ChatMessage): Promise<void> {
|
|
111
|
+
await fs.mkdir(inboxDir(), { recursive: true });
|
|
112
|
+
await fs.appendFile(inboxPath(peerDID), JSON.stringify(entry) + '\n', 'utf-8');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function readInbox(peerDID: string): Promise<ChatMessage[]> {
|
|
116
|
+
try {
|
|
117
|
+
const content = await fs.readFile(inboxPath(peerDID), 'utf-8');
|
|
118
|
+
return content.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l) as ChatMessage);
|
|
119
|
+
} catch { return []; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function rewriteInbox(peerDID: string, entries: ChatMessage[]): Promise<void> {
|
|
123
|
+
await fs.mkdir(inboxDir(), { recursive: true });
|
|
124
|
+
await fs.writeFile(inboxPath(peerDID), entries.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function updateInboxEntry(peerDID: string, id: string, patch: Partial<ChatMessage>): Promise<ChatMessage | null> {
|
|
128
|
+
const entries = await readInbox(peerDID);
|
|
129
|
+
const idx = entries.findIndex((e) => e.id === id);
|
|
130
|
+
if (idx < 0) return null;
|
|
131
|
+
entries[idx] = { ...entries[idx], ...patch };
|
|
132
|
+
await rewriteInbox(peerDID, entries);
|
|
133
|
+
return entries[idx];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function markOutboundDraft(originalId: string, draft: string, at: number): Promise<void> {
|
|
137
|
+
try {
|
|
138
|
+
const content = await fs.readFile(outboxPath(), 'utf-8');
|
|
139
|
+
const entries: ChatMessage[] = content.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
|
|
140
|
+
const idx = entries.findIndex((e) => e.id === originalId);
|
|
141
|
+
if (idx >= 0) {
|
|
142
|
+
entries[idx].inboundDraft = draft;
|
|
143
|
+
entries[idx].inboundDraftAt = at;
|
|
144
|
+
await fs.writeFile(outboxPath(), entries.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
|
|
145
|
+
}
|
|
146
|
+
} catch { /* no outbox */ }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ============================================================================
|
|
150
|
+
// Listener 安装 (每个 transport 各装一次)
|
|
151
|
+
// ============================================================================
|
|
152
|
+
|
|
153
|
+
function ensureListener(transport: IrohTransport): void {
|
|
154
|
+
const s = getState(transport);
|
|
155
|
+
if (s.listenerInstalled) return;
|
|
156
|
+
s.listenerInstalled = true;
|
|
157
|
+
transport.onMessage('agent_chat', (msg) => {
|
|
158
|
+
try {
|
|
159
|
+
const data = JSON.parse(new TextDecoder().decode(msg.payload)) as { kind: string; id: string; text: string; originalId?: string };
|
|
160
|
+
void handleIncomingChat(transport, msg.from, data);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
console.error('[ChatReceiver] parse err:', (e as Error).message);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function handleIncomingChat(transport: IrohTransport, fromNodeId: string, data: { kind: string; id: string; text: string; originalId?: string }): Promise<void> {
|
|
168
|
+
const peerDID = fromNodeId;
|
|
169
|
+
if (data.kind === 'user') {
|
|
170
|
+
const entry: ChatMessage = {
|
|
171
|
+
id: data.id, peerDID, fromNodeId, text: data.text,
|
|
172
|
+
timestamp: Date.now(), receivedAt: Date.now(), status: 'received',
|
|
173
|
+
};
|
|
174
|
+
await appendInbox(peerDID, entry);
|
|
175
|
+
const preview = data.text.slice(0, 60);
|
|
176
|
+
const peerShort = peerDID.slice(0, 12);
|
|
177
|
+
console.log('[ChatReceiver] received from ' + peerShort + ': ' + preview);
|
|
178
|
+
chatEventBus.emit('chat', { kind: 'inbox_new', peerDID, entry } satisfies ChatEvent);
|
|
179
|
+
for (const h of getState(transport).handlers) {
|
|
180
|
+
try { h(entry, fromNodeId); } catch {}
|
|
181
|
+
}
|
|
182
|
+
} else if (data.kind === 'draft' && data.originalId) {
|
|
183
|
+
await markOutboundDraft(data.originalId, data.text, Date.now());
|
|
184
|
+
console.log('[ChatReceiver] inbound draft for ' + data.originalId);
|
|
185
|
+
chatEventBus.emit('chat', { kind: 'outbox_draft_in', peerDID, originalId: data.originalId, draft: data.text } satisfies ChatEvent);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ============================================================================
|
|
190
|
+
// 公共 API (可注入 transport)
|
|
191
|
+
// ============================================================================
|
|
192
|
+
|
|
193
|
+
export function onChatMessage(handler: ChatMessageHandler, transport: IrohTransport = defaultIrohTransport): void {
|
|
194
|
+
getState(transport).handlers.add(handler);
|
|
195
|
+
ensureListener(transport);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function initChatReceiver(transport: IrohTransport = defaultIrohTransport): Promise<void> {
|
|
199
|
+
ensureListener(transport);
|
|
200
|
+
await fs.mkdir(inboxDir(), { recursive: true });
|
|
201
|
+
console.log('[ChatReceiver] Initialized at', inboxDir(), 'transport=', (transport as any) === defaultIrohTransport ? 'singleton' : 'instance');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function sendChat(peerDID: string, text: string, transport: IrohTransport = defaultIrohTransport): Promise<string> {
|
|
205
|
+
const id = crypto.randomUUID();
|
|
206
|
+
const ok = await transport.sendMessage(
|
|
207
|
+
peerDID, 'agent_chat',
|
|
208
|
+
new TextEncoder().encode(JSON.stringify({ kind: 'user', id, text })),
|
|
209
|
+
);
|
|
210
|
+
if (ok) {
|
|
211
|
+
const entry: ChatMessage = {
|
|
212
|
+
id, peerDID,
|
|
213
|
+
fromNodeId: transport.getNodeId() || '',
|
|
214
|
+
text, timestamp: Date.now(), receivedAt: Date.now(),
|
|
215
|
+
status: 'sent', sentText: text, sentAt: Date.now(),
|
|
216
|
+
};
|
|
217
|
+
await fs.mkdir(inboxDir(), { recursive: true });
|
|
218
|
+
await fs.appendFile(outboxPath(), JSON.stringify(entry) + '\n', 'utf-8');
|
|
219
|
+
chatEventBus.emit('chat', { kind: 'outbox_updated', peerDID, entry } satisfies ChatEvent);
|
|
220
|
+
} else {
|
|
221
|
+
console.warn(`[ChatReceiver] send to ${peerDID.slice(0, 12)}... failed`);
|
|
222
|
+
}
|
|
223
|
+
return id;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function getInbox(peerDID?: string): Promise<ChatMessage[]> {
|
|
227
|
+
if (peerDID) return readInbox(peerDID);
|
|
228
|
+
try {
|
|
229
|
+
const files = await fs.readdir(inboxDir());
|
|
230
|
+
const all: ChatMessage[] = [];
|
|
231
|
+
for (const f of files) {
|
|
232
|
+
if (!f.endsWith('.jsonl') || f.startsWith('_')) continue;
|
|
233
|
+
const peerDID = f.replace('.jsonl', '').replace(/_/g, ':');
|
|
234
|
+
all.push(...await readInbox(peerDID));
|
|
235
|
+
}
|
|
236
|
+
return all.sort((a, b) => b.receivedAt - a.receivedAt);
|
|
237
|
+
} catch { return []; }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ============================================================================
|
|
241
|
+
// Draft 引擎
|
|
242
|
+
// ============================================================================
|
|
243
|
+
|
|
244
|
+
async function buildValueHint(text: string): Promise<string> {
|
|
245
|
+
try {
|
|
246
|
+
const allJudgments = await loadAllJudgments();
|
|
247
|
+
const relevant = await getRelevantValues(text, undefined);
|
|
248
|
+
const profile = await getValueProfile('me');
|
|
249
|
+
const profileHint = [
|
|
250
|
+
`quality_focus=${profile.quality_focus.toFixed(2)}`,
|
|
251
|
+
`efficiency_focus=${profile.efficiency_focus.toFixed(2)}`,
|
|
252
|
+
`safety_focus=${profile.safety_focus.toFixed(2)}`,
|
|
253
|
+
`collaboration_focus=${profile.collaboration_focus.toFixed(2)}`,
|
|
254
|
+
`learning_focus=${profile.learning_focus.toFixed(2)}`,
|
|
255
|
+
].join(', ');
|
|
256
|
+
const reasons = allJudgments
|
|
257
|
+
.filter((j) => (j.reasons || []).length > 0)
|
|
258
|
+
.slice(-10)
|
|
259
|
+
.flatMap((j) => j.reasons || [])
|
|
260
|
+
.slice(0, 20);
|
|
261
|
+
return `\n[主人历史判断 (style 参考, 不可外泄)]\n关注维度: ${profileHint}\n关键理由示例: ${reasons.join(' | ').slice(0, 400) || '(无)'}\n`;
|
|
262
|
+
} catch (e) {
|
|
263
|
+
return '';
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function generateDraft(
|
|
268
|
+
messageId: string,
|
|
269
|
+
peerDID: string,
|
|
270
|
+
transport: IrohTransport = defaultIrohTransport,
|
|
271
|
+
): Promise<ChatMessage | null> {
|
|
272
|
+
const entries = await readInbox(peerDID);
|
|
273
|
+
const entry = entries.find((e) => e.id === messageId);
|
|
274
|
+
if (!entry) return null;
|
|
275
|
+
if (entry.status !== 'received') return entry;
|
|
276
|
+
|
|
277
|
+
const valueHint = await buildValueHint(entry.text);
|
|
278
|
+
const promptForDraft = `你是主人的代理. 对方发来这条消息: "${entry.text.slice(0, 1500)}"\n${valueHint}\n请基于主人的历史判断, 用 1-2 句话代主人拟一个回复草案. 草案要保留主人的语气和立场, 开头标注 [DRAFT]. 直接给草案文本, 不要解释.`;
|
|
279
|
+
|
|
280
|
+
let draftText = '';
|
|
281
|
+
let confidence = 0.5;
|
|
282
|
+
try {
|
|
283
|
+
const openaiKey = process.env.OPENAI_API_KEY;
|
|
284
|
+
if (!openaiKey) throw new Error('OPENAI_API_KEY not set');
|
|
285
|
+
const openaiBase = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1';
|
|
286
|
+
const openaiModel = process.env.OPENAI_MODEL || 'gpt-4';
|
|
287
|
+
const r = await fetch(`${openaiBase}/chat/completions`, {
|
|
288
|
+
method: 'POST',
|
|
289
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${openaiKey}` },
|
|
290
|
+
body: JSON.stringify({
|
|
291
|
+
model: openaiModel,
|
|
292
|
+
messages: [
|
|
293
|
+
{ role: 'system', content: '你是主人的代理. 你的输出会被主人审阅后才发出. 请谨慎.' },
|
|
294
|
+
{ role: 'user', content: promptForDraft },
|
|
295
|
+
],
|
|
296
|
+
temperature: 0.4,
|
|
297
|
+
max_tokens: 300,
|
|
298
|
+
}),
|
|
299
|
+
signal: AbortSignal.timeout(15000),
|
|
300
|
+
});
|
|
301
|
+
if (r.ok) {
|
|
302
|
+
const data = await r.json() as any;
|
|
303
|
+
draftText = (data.choices?.[0]?.message?.content || '').trim();
|
|
304
|
+
confidence = 0.7;
|
|
305
|
+
} else {
|
|
306
|
+
console.warn(`[DraftEngine] LLM ${r.status}`);
|
|
307
|
+
}
|
|
308
|
+
} catch (e) {
|
|
309
|
+
console.warn(`[DraftEngine] LLM call failed: ${(e as Error).message}`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (!draftText) {
|
|
313
|
+
draftText = `[DRAFT] 已收到. (本地 draft 引擎未配置 LLM, 主人上线后请手写回复)`;
|
|
314
|
+
confidence = 0.1;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const updated = await updateInboxEntry(peerDID, messageId, {
|
|
318
|
+
status: 'drafted', draft: draftText, draftConfidence: confidence,
|
|
319
|
+
draftReasoning: valueHint.slice(0, 200), draftAt: Date.now(),
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
if (updated) {
|
|
323
|
+
const sent = await transport.sendMessage(
|
|
324
|
+
peerDID, 'agent_chat',
|
|
325
|
+
new TextEncoder().encode(JSON.stringify({ kind: 'draft', id: crypto.randomUUID(), text: draftText, originalId: messageId })),
|
|
326
|
+
);
|
|
327
|
+
if (sent) console.log('[DraftEngine] draft sent to ' + peerDID.slice(0, 12) + ' for ' + messageId.slice(0, 8));
|
|
328
|
+
else console.warn('[DraftEngine] failed to send draft');
|
|
329
|
+
chatEventBus.emit('chat', { kind: 'draft_ready', peerDID, entry: updated } satisfies ChatEvent);
|
|
330
|
+
chatEventBus.emit('chat', { kind: 'outbox_updated', peerDID, entry: updated } satisfies ChatEvent);
|
|
331
|
+
}
|
|
332
|
+
return updated;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export async function processPendingInbox(transport: IrohTransport = defaultIrohTransport): Promise<{ processed: number; skipped: number }> {
|
|
336
|
+
await fs.mkdir(inboxDir(), { recursive: true });
|
|
337
|
+
const files = await fs.readdir(inboxDir());
|
|
338
|
+
let processed = 0, skipped = 0;
|
|
339
|
+
for (const f of files) {
|
|
340
|
+
if (!f.endsWith('.jsonl') || f.startsWith('_')) continue;
|
|
341
|
+
const peerDID = f.replace('.jsonl', '').replace(/_/g, ':');
|
|
342
|
+
const entries = await readInbox(peerDID);
|
|
343
|
+
for (const e of entries) {
|
|
344
|
+
if (e.status === 'received') {
|
|
345
|
+
const r = await generateDraft(e.id, peerDID, transport);
|
|
346
|
+
if (r) processed++; else skipped++;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return { processed, skipped };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export async function approveAndSend(
|
|
354
|
+
messageId: string, peerDID: string, finalText?: string,
|
|
355
|
+
transport: IrohTransport = defaultIrohTransport,
|
|
356
|
+
): Promise<boolean> {
|
|
357
|
+
const entries = await readInbox(peerDID);
|
|
358
|
+
const entry = entries.find((e) => e.id === messageId);
|
|
359
|
+
if (!entry || entry.status !== 'drafted') return false;
|
|
360
|
+
const text = finalText || entry.draft || '';
|
|
361
|
+
const ok = await transport.sendMessage(
|
|
362
|
+
peerDID, 'agent_chat',
|
|
363
|
+
new TextEncoder().encode(JSON.stringify({ kind: 'user', id: crypto.randomUUID(), text })),
|
|
364
|
+
);
|
|
365
|
+
if (ok) {
|
|
366
|
+
const updated = await updateInboxEntry(peerDID, messageId, { status: 'sent', sentText: text, sentAt: Date.now() });
|
|
367
|
+
if (updated) chatEventBus.emit('chat', { kind: 'inbox_sent', peerDID, entry: updated } satisfies ChatEvent);
|
|
368
|
+
}
|
|
369
|
+
return ok;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function dismissDraft(messageId: string, peerDID: string): Promise<boolean> {
|
|
373
|
+
const entries = await readInbox(peerDID);
|
|
374
|
+
const entry = entries.find((e) => e.id === messageId);
|
|
375
|
+
if (!entry) return false;
|
|
376
|
+
const updated = await updateInboxEntry(peerDID, messageId, { status: 'dismissed' });
|
|
377
|
+
if (updated) chatEventBus.emit('chat', { kind: 'inbox_dismissed', peerDID, entry: updated } satisfies ChatEvent);
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export const p2pChatTools = {
|
|
382
|
+
sendChat, getInbox, processPendingInbox, generateDraft, approveAndSend, dismissDraft, onChatMessage, initChatReceiver,
|
|
383
|
+
};
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import * as crypto from 'crypto';
|
|
6
6
|
import * as fs from 'fs/promises';
|
|
7
|
+
import * as path from 'path';
|
|
7
8
|
import { irohTransport } from '../network/iroh-transport.js';
|
|
8
9
|
import { documentReader, type DocumentContent } from '../documents/reader.js';
|
|
9
10
|
import { documentStore, type DocumentChunk, type ReceivedDocument } from '../documents/store.js';
|
|
@@ -20,6 +21,11 @@ function getMimeType(filename: string): string {
|
|
|
20
21
|
const mimeTypes: Record<string, string> = {
|
|
21
22
|
'txt': 'text/plain',
|
|
22
23
|
'md': 'text/markdown',
|
|
24
|
+
'html': 'text/html',
|
|
25
|
+
'htm': 'text/html',
|
|
26
|
+
'yaml': 'application/yaml',
|
|
27
|
+
'yml': 'application/yaml',
|
|
28
|
+
'json': 'application/json',
|
|
23
29
|
'pdf': 'application/pdf',
|
|
24
30
|
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
25
31
|
};
|
|
@@ -29,8 +35,10 @@ function getMimeType(filename: string): string {
|
|
|
29
35
|
export async function initDocumentReceiver(): Promise<void> {
|
|
30
36
|
await documentStore.initialize();
|
|
31
37
|
|
|
32
|
-
documentStore.onDocumentReceived((doc) => {
|
|
38
|
+
documentStore.onDocumentReceived(async (doc) => {
|
|
33
39
|
console.log(`[DocumentReceiver] Document received: ${doc.fileName} from ${doc.fromNodeIdShort}`);
|
|
40
|
+
// 异步调用 LLM 解析(不阻塞接收流程)
|
|
41
|
+
void parseDocumentWithLLM(doc);
|
|
34
42
|
});
|
|
35
43
|
|
|
36
44
|
irohTransport.onMessage('document_chunk', async (msg) => {
|
|
@@ -48,6 +56,148 @@ export async function initDocumentReceiver(): Promise<void> {
|
|
|
48
56
|
console.log('[DocumentReceiver] Initialized and listening for document chunks');
|
|
49
57
|
}
|
|
50
58
|
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// AI 解析反馈:接收方解析完文档后把摘要回送给发送方
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
export interface AIFeedbackMessage {
|
|
64
|
+
docId: string;
|
|
65
|
+
fileName: string;
|
|
66
|
+
mimeType: string;
|
|
67
|
+
summary: string;
|
|
68
|
+
qualityScore: number;
|
|
69
|
+
feedbackAt: number;
|
|
70
|
+
fromNodeId: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type AIFeedbackHandler = (feedback: AIFeedbackMessage, fromNodeId: string) => void;
|
|
74
|
+
const feedbackHandlers: Set<AIFeedbackHandler> = new Set();
|
|
75
|
+
|
|
76
|
+
/** 注册 AI 解析反馈监听器(发送方调用) */
|
|
77
|
+
export function onAIFeedback(handler: AIFeedbackHandler): void {
|
|
78
|
+
feedbackHandlers.add(handler);
|
|
79
|
+
// iroh listener 只挂一次
|
|
80
|
+
ensureFeedbackListenerInstalled();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let feedbackListenerInstalled = false;
|
|
84
|
+
function ensureFeedbackListenerInstalled(): void {
|
|
85
|
+
if (feedbackListenerInstalled) return;
|
|
86
|
+
feedbackListenerInstalled = true;
|
|
87
|
+
irohTransport.onMessage('ai_feedback', (msg) => {
|
|
88
|
+
try {
|
|
89
|
+
const fb: AIFeedbackMessage = JSON.parse(new TextDecoder().decode(msg.payload));
|
|
90
|
+
for (const h of feedbackHandlers) {
|
|
91
|
+
try {
|
|
92
|
+
h(fb, msg.from);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
console.error('[AIFeedback] handler error:', e);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.error('[AIFeedback] failed to parse message:', e);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 文档解析服务:优先走 web 统一入口 (含 judgment + harness), fallback 到本地 LLM */
|
|
104
|
+
async function callAIParseService(text: string, fileName: string, mimeType: string, fromNodeId?: string): Promise<{ summary: string; qualityScore: number; source: 'web' | 'local'; judgmentId?: string; gateArtifact?: string }> {
|
|
105
|
+
// 1) 优先: 调 web 端 POST /api/ai-parse (统一入口, 含 judgment + harness)
|
|
106
|
+
const webBase = process.env.BOLLOON_WEB_URL || process.env.PORTAL_URL || 'http://127.0.0.1:54188';
|
|
107
|
+
try {
|
|
108
|
+
const r = await fetch(`${webBase}/api/ai-parse`, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: { 'Content-Type': 'application/json' },
|
|
111
|
+
body: JSON.stringify({ text, mimeType, fileName, fromNodeId, source: 'p2p-document' }),
|
|
112
|
+
// 短超时, fallback 友好
|
|
113
|
+
signal: AbortSignal.timeout(8000),
|
|
114
|
+
});
|
|
115
|
+
if (r.ok) {
|
|
116
|
+
const data = await r.json() as any;
|
|
117
|
+
return {
|
|
118
|
+
summary: data.summary,
|
|
119
|
+
qualityScore: data.qualityScore,
|
|
120
|
+
source: 'web',
|
|
121
|
+
judgmentId: data.judgmentId,
|
|
122
|
+
gateArtifact: data.gateArtifact,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
console.warn(`[AIParse] web returned ${r.status}, fallback to local`);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
console.warn(`[AIParse] web ${webBase} unreachable (${(e as Error).message}), fallback to local`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 2) Fallback: 直接调本地 LLM (不调 judgment/harness, 仅在 web 不可用时使用)
|
|
131
|
+
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;
|
|
132
|
+
if (!apiKey) {
|
|
133
|
+
throw new Error('No web endpoint and no LLM API key configured. Set BOLLOON_WEB_URL or OPENAI_API_KEY.');
|
|
134
|
+
}
|
|
135
|
+
const { getMinimax } = await import('../constraints/index.js');
|
|
136
|
+
const llm = getMinimax();
|
|
137
|
+
const truncated = text.length > 6000 ? text.substring(0, 6000) + '...[截断]' : text;
|
|
138
|
+
const prompt = `请分析以下 ${mimeType} 文档,并给出 (1) 一句话中文摘要 (2) 关键要点列表 (3) 文档质量评分(0-1)。\n\n文件名: ${fileName}\n\n内容:\n${truncated}`;
|
|
139
|
+
const r = await llm.summarize(prompt);
|
|
140
|
+
return { summary: r.summary, qualityScore: r.qualityScore, source: 'local' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function parseDocumentWithLLM(doc: ReceivedDocument): Promise<void> {
|
|
144
|
+
try {
|
|
145
|
+
const filePath = path.join(
|
|
146
|
+
process.env.HOME || '/tmp',
|
|
147
|
+
'.bolloon', 'documents', 'received',
|
|
148
|
+
doc.id, doc.fileName
|
|
149
|
+
);
|
|
150
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
151
|
+
|
|
152
|
+
const r = await callAIParseService(content, doc.fileName, doc.mimeType, doc.fromNodeId);
|
|
153
|
+
|
|
154
|
+
const sidecar = {
|
|
155
|
+
filename: doc.fileName,
|
|
156
|
+
mimeType: doc.mimeType,
|
|
157
|
+
fromNodeId: doc.fromNodeId,
|
|
158
|
+
receivedAt: doc.receivedAt,
|
|
159
|
+
summary: r.summary,
|
|
160
|
+
qualityScore: r.qualityScore,
|
|
161
|
+
parseSource: r.source,
|
|
162
|
+
judgmentId: r.judgmentId,
|
|
163
|
+
gateArtifact: r.gateArtifact,
|
|
164
|
+
analyzedAt: Date.now(),
|
|
165
|
+
};
|
|
166
|
+
const sidecarPath = path.join(
|
|
167
|
+
process.env.HOME || '/tmp',
|
|
168
|
+
'.bolloon', 'documents', 'received',
|
|
169
|
+
doc.id, 'ai-analysis.json'
|
|
170
|
+
);
|
|
171
|
+
await fs.writeFile(sidecarPath, JSON.stringify(sidecar, null, 2));
|
|
172
|
+
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}` : ''}`);
|
|
173
|
+
|
|
174
|
+
// 把 AI 解析结果回送给发送方 (doc.fromNodeId),形成 "接收 → 解析 → 反馈" 闭环
|
|
175
|
+
if (doc.fromNodeId) {
|
|
176
|
+
const feedback: AIFeedbackMessage = {
|
|
177
|
+
docId: doc.id,
|
|
178
|
+
fileName: doc.fileName,
|
|
179
|
+
mimeType: doc.mimeType,
|
|
180
|
+
summary: r.summary,
|
|
181
|
+
qualityScore: r.qualityScore,
|
|
182
|
+
feedbackAt: Date.now(),
|
|
183
|
+
fromNodeId: irohTransport.getNodeId() || '',
|
|
184
|
+
};
|
|
185
|
+
const sent = await irohTransport.sendMessage(
|
|
186
|
+
doc.fromNodeId,
|
|
187
|
+
'ai_feedback',
|
|
188
|
+
new TextEncoder().encode(JSON.stringify(feedback))
|
|
189
|
+
);
|
|
190
|
+
if (sent) {
|
|
191
|
+
console.log(`[DocumentReceiver] Feedback sent to ${doc.fromNodeIdShort}`);
|
|
192
|
+
} else {
|
|
193
|
+
console.warn(`[DocumentReceiver] Failed to send feedback to ${doc.fromNodeIdShort}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch (e) {
|
|
197
|
+
console.error(`[DocumentReceiver] AI parse failed for ${doc.fileName}:`, e);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
51
201
|
export const p2pDocumentTools: Tool[] = [
|
|
52
202
|
{
|
|
53
203
|
name: 'list_online_peers',
|
|
@@ -38,6 +38,7 @@ export interface ToolDefinition {
|
|
|
38
38
|
name: string;
|
|
39
39
|
description: string;
|
|
40
40
|
parameters: Record<string, string>;
|
|
41
|
+
args?: Record<string, string>;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export interface LoopResult {
|
|
@@ -137,7 +138,7 @@ export class WorkflowPivotLoop {
|
|
|
137
138
|
private config: Required<PivotLoopConfig>;
|
|
138
139
|
private state: PivotLoopState;
|
|
139
140
|
private tools: Map<string, Tool>;
|
|
140
|
-
private messageHistory: Array<{ role: string; content: string; toolCall?:
|
|
141
|
+
private messageHistory: Array<{ role: string; content: string; toolCall?: ToolDefinition; toolResult?: ToolResult }>;
|
|
141
142
|
private streamCallback?: StreamCallback;
|
|
142
143
|
|
|
143
144
|
constructor(config: PivotLoopConfig) {
|
|
@@ -318,7 +319,7 @@ export class WorkflowPivotLoop {
|
|
|
318
319
|
});
|
|
319
320
|
|
|
320
321
|
try {
|
|
321
|
-
const result = await tool.execute(toolCall.args);
|
|
322
|
+
const result = await tool.execute(toolCall.args ?? {});
|
|
322
323
|
|
|
323
324
|
this.emit({
|
|
324
325
|
type: result.success ? 'status' : 'error',
|
|
@@ -416,9 +417,9 @@ export class WorkflowPivotLoop {
|
|
|
416
417
|
/**
|
|
417
418
|
* Extract pending tool uses from LLM response
|
|
418
419
|
*/
|
|
419
|
-
private extractPendingToolUses(content: string):
|
|
420
|
-
const pending:
|
|
421
|
-
|
|
420
|
+
private extractPendingToolUses(content: string): ToolDefinition[] {
|
|
421
|
+
const pending: ToolDefinition[] = [];
|
|
422
|
+
|
|
422
423
|
// Pattern 1: Chinese format "调用工具: tool_name(args)"
|
|
423
424
|
const pattern1 = /调用工具[::]\s*(\w+)\s*\(([^)]*)\)/g;
|
|
424
425
|
let match;
|
|
@@ -427,10 +428,10 @@ export class WorkflowPivotLoop {
|
|
|
427
428
|
const argsStr = match[2];
|
|
428
429
|
const args = this.parseArgs(argsStr);
|
|
429
430
|
if (this.tools.has(name)) {
|
|
430
|
-
pending.push({ name, args });
|
|
431
|
+
pending.push({ name, args, description: '', parameters: {} });
|
|
431
432
|
}
|
|
432
433
|
}
|
|
433
|
-
|
|
434
|
+
|
|
434
435
|
// Pattern 2: tool_name(args) format
|
|
435
436
|
const pattern2 = /(\w+)\s*\(\s*([^)]*)\s*\)/g;
|
|
436
437
|
while ((match = pattern2.exec(content)) !== null) {
|
|
@@ -439,11 +440,11 @@ export class WorkflowPivotLoop {
|
|
|
439
440
|
// Skip if already matched or doesn't look like a tool call
|
|
440
441
|
if (pending.some(p => p.name === name)) continue;
|
|
441
442
|
if (!this.tools.has(name)) continue;
|
|
442
|
-
|
|
443
|
+
|
|
443
444
|
const args = this.parseArgs(argsStr);
|
|
444
|
-
pending.push({ name, args });
|
|
445
|
+
pending.push({ name, args, description: '', parameters: {} });
|
|
445
446
|
}
|
|
446
|
-
|
|
447
|
+
|
|
447
448
|
// Pattern 3: JSON format tool calls
|
|
448
449
|
try {
|
|
449
450
|
const jsonMatch = content.match(/\{[\s\S]*"tool_calls"[\s\S]*\}/);
|
|
@@ -452,7 +453,7 @@ export class WorkflowPivotLoop {
|
|
|
452
453
|
if (Array.isArray(parsed.tool_calls)) {
|
|
453
454
|
for (const tc of parsed.tool_calls) {
|
|
454
455
|
if (this.tools.has(tc.name)) {
|
|
455
|
-
pending.push({ name: tc.name, args: tc.args || {} });
|
|
456
|
+
pending.push({ name: tc.name, args: tc.args || {}, description: '', parameters: {} });
|
|
456
457
|
}
|
|
457
458
|
}
|
|
458
459
|
}
|
|
@@ -460,7 +461,7 @@ export class WorkflowPivotLoop {
|
|
|
460
461
|
} catch {
|
|
461
462
|
// JSON parsing failed, ignore
|
|
462
463
|
}
|
|
463
|
-
|
|
464
|
+
|
|
464
465
|
return pending;
|
|
465
466
|
}
|
|
466
467
|
|
|
@@ -505,7 +505,7 @@ export class ChannelJudgmentEngine {
|
|
|
505
505
|
* 确定 Skills(基于 Gate 和上下文)
|
|
506
506
|
*/
|
|
507
507
|
private determineSkills(gate: Gate, context: JudgmentContext): string[] {
|
|
508
|
-
const baseSkills = GATE_PROMPTS[gate]?.skills || ['arch'];
|
|
508
|
+
const baseSkills = (GATE_PROMPTS as Record<number, { skills: string[] }>)[gate]?.skills || ['arch'];
|
|
509
509
|
|
|
510
510
|
// 根据上下文调整 Skills
|
|
511
511
|
const { currentMessage } = context;
|
package/src/documents/reader.ts
CHANGED
package/src/documents/store.ts
CHANGED
|
@@ -205,11 +205,11 @@ export class DocumentStore {
|
|
|
205
205
|
async readDocument(docId: string): Promise<{ content: string; metadata: ReceivedDocument } | null> {
|
|
206
206
|
const docDir = path.join(this.baseDir, docId);
|
|
207
207
|
const manifestPath = path.join(docDir, 'manifest.json');
|
|
208
|
-
const filePath = path.join(docDir);
|
|
209
208
|
|
|
210
209
|
try {
|
|
211
210
|
const manifestData = await fs.readFile(manifestPath, 'utf-8');
|
|
212
211
|
const manifest = JSON.parse(manifestData);
|
|
212
|
+
const filePath = path.join(docDir, manifest.fileName);
|
|
213
213
|
const fileContent = await fs.readFile(filePath, 'utf-8');
|
|
214
214
|
|
|
215
215
|
return {
|