@bolloon/bolloon-agent 0.3.1 → 0.3.3
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/judgeness/auto-add.js +145 -0
- package/dist/judgeness/protocol.js +214 -0
- package/dist/judgeness/rank.js +78 -0
- package/dist/judgeness/reflect.js +93 -0
- package/dist/judgeness/store.js +481 -0
- package/dist/judgeness/types.js +19 -0
- package/dist/judgeness/visibility.js +118 -0
- package/dist/scripts/dedup-session-messages.js +68 -0
- package/dist/web/client-hearth.js +67 -0
- package/dist/web/client.js +309 -41
- package/dist/web/routes-hearth.js +371 -0
- package/dist/web/server.js +356 -10
- package/dist/web/ui/message-renderer.js +13 -2
- package/dist/web/util/dual-mode.js +87 -0
- package/package.json +2 -2
package/dist/web/server.js
CHANGED
|
@@ -12,6 +12,7 @@ import { segmentChatReply } from '../agents/chat-segmenter.js';
|
|
|
12
12
|
import { registerJudgmentsRoutes } from './routes-judgments.js';
|
|
13
13
|
import { registerLlmConfigRoutes } from './routes-llm-config.js';
|
|
14
14
|
import { registerTaskRoutes } from './routes-tasks.js';
|
|
15
|
+
import { registerHearthRoutes } from './routes-hearth.js';
|
|
15
16
|
// 2026-07-06: 类型抽到 ./server-types.ts (channel / session / task / sse client / iroh info / paths)
|
|
16
17
|
import { SESSION_CACHE_PATH, IPFS_ENDPOINT, } from './server-types.js';
|
|
17
18
|
// 同时也 re-export 出去 (其它地方可能从 './server.js' 引用)
|
|
@@ -1824,7 +1825,11 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1824
1825
|
const app = express();
|
|
1825
1826
|
const server = createServer(app);
|
|
1826
1827
|
await ensureSessionDirs();
|
|
1827
|
-
|
|
1828
|
+
// 2026-07-15 修 Bug 3 续: attachment 路由需要单独大 limit body parser.
|
|
1829
|
+
// 关键: 必须挂在主 app.use(express.json()) 之前, 否则 4MB attachment 在主 100KB parser 阶段就被拒.
|
|
1830
|
+
// path-prefix 让它只对 /api/attachments/* 生效, 不污染其他端点的限制.
|
|
1831
|
+
app.use('/api/attachments', express.json({ limit: '15mb' }));
|
|
1832
|
+
app.use(express.json({ limit: '100kb' }));
|
|
1828
1833
|
app.use((req, res, next) => {
|
|
1829
1834
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
1830
1835
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
@@ -1924,6 +1929,125 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1924
1929
|
res.status(400).json({ ok: false, reason: e?.message ?? 'parse error' });
|
|
1925
1930
|
}
|
|
1926
1931
|
});
|
|
1932
|
+
// 2026-07-15 修 Bug 3: 拖拽文件上传
|
|
1933
|
+
// - POST /api/attachments/upload body: { filename, mimeType, content: base64 }
|
|
1934
|
+
// - 文件落到 ~/.bolloon/attachments/<YYYY-MM>/<uuid>__<safeName>
|
|
1935
|
+
// - 返回 { ok, attachmentId, url, size, mimeType, filename }
|
|
1936
|
+
// - 前端拿到 attachmentId 后, 在消息文本里插一个 [attachment:id] 标记
|
|
1937
|
+
// (这条消息发出去时 server 端 /message 把它解析成 contextHint + 行内下载链接)
|
|
1938
|
+
// 没用 multer/formidable — 复用 base64 JSON 简化, 跟 existing /api/judgments/import 同样的传输方式
|
|
1939
|
+
// body parser 在上面 path-prefix 中间件已挂, 这里直接 handler 即可.
|
|
1940
|
+
const ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024; // 10MB 单文件硬上限
|
|
1941
|
+
app.post('/api/attachments/upload', async (req, res) => {
|
|
1942
|
+
try {
|
|
1943
|
+
const body = req.body ?? {};
|
|
1944
|
+
const filename = String(body.filename || '').trim();
|
|
1945
|
+
const mimeType = String(body.mimeType || 'application/octet-stream');
|
|
1946
|
+
const content = String(body.content || '');
|
|
1947
|
+
if (!filename || !content) {
|
|
1948
|
+
return res.status(400).json({ ok: false, error: 'filename 与 content 必填' });
|
|
1949
|
+
}
|
|
1950
|
+
// base64 → bytes
|
|
1951
|
+
const buf = Buffer.from(content, 'base64');
|
|
1952
|
+
if (buf.length === 0) {
|
|
1953
|
+
return res.status(400).json({ ok: false, error: '文件为空' });
|
|
1954
|
+
}
|
|
1955
|
+
if (buf.length > ATTACHMENT_MAX_BYTES) {
|
|
1956
|
+
return res.status(413).json({
|
|
1957
|
+
ok: false,
|
|
1958
|
+
error: `文件超过 ${ATTACHMENT_MAX_BYTES / 1024 / 1024}MB 上限`,
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1961
|
+
// safeName: 跟 safeChannelName 同样规则, 防路径穿越 + Windows 非法字符
|
|
1962
|
+
// 注意: 把 . 也加进替换 (不然 "../../" 被转成 "..__" 还残留 .. 序列), 整个文件 base 安全
|
|
1963
|
+
const safeName = filename
|
|
1964
|
+
.replace(/[\\/:*?"<>|\x00-\x1f.]/g, '_')
|
|
1965
|
+
.replace(/^_+|_+$/g, '')
|
|
1966
|
+
.slice(0, 120) || 'unnamed';
|
|
1967
|
+
if (safeName !== filename) {
|
|
1968
|
+
console.log(`[attachments] safeName 转换: "${filename}" → "${safeName}"`);
|
|
1969
|
+
}
|
|
1970
|
+
const month = new Date().toISOString().slice(0, 7); // YYYY-MM
|
|
1971
|
+
const attachmentsDir = path.join(process.env.HOME || '/tmp', '.bolloon', 'attachments', month);
|
|
1972
|
+
await fs.mkdir(attachmentsDir, { recursive: true });
|
|
1973
|
+
const attachmentId = `${Date.now().toString(36)}_${crypto.randomBytes(6).toString('hex')}`;
|
|
1974
|
+
const storedFilename = `${attachmentId}__${safeName}`;
|
|
1975
|
+
const fullPath = path.join(attachmentsDir, storedFilename);
|
|
1976
|
+
await fs.writeFile(fullPath, buf);
|
|
1977
|
+
const urlPath = `/api/attachments/${attachmentId}`;
|
|
1978
|
+
console.log(`[attachments] 上传 ${filename} (${buf.length}B, ${mimeType}) → ${fullPath}`);
|
|
1979
|
+
res.json({
|
|
1980
|
+
ok: true,
|
|
1981
|
+
attachmentId,
|
|
1982
|
+
url: urlPath,
|
|
1983
|
+
filename,
|
|
1984
|
+
storedFilename,
|
|
1985
|
+
size: buf.length,
|
|
1986
|
+
mimeType,
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
catch (e) {
|
|
1990
|
+
console.error('[attachments] upload failed:', e?.message || e);
|
|
1991
|
+
res.status(500).json({ ok: false, error: e?.message || '上传失败' });
|
|
1992
|
+
}
|
|
1993
|
+
});
|
|
1994
|
+
// GET /api/attachments/:id — 下载 (按 attachmentId 找当月目录; 月份遍历回退)
|
|
1995
|
+
app.get('/api/attachments/:id', async (req, res) => {
|
|
1996
|
+
try {
|
|
1997
|
+
const id = String(req.params.id || '').replace(/[^a-zA-Z0-9_]/g, '');
|
|
1998
|
+
if (!id)
|
|
1999
|
+
return res.status(400).type('text/plain').send('invalid id');
|
|
2000
|
+
const attachmentsRoot = path.join(process.env.HOME || '/tmp', '.bolloon', 'attachments');
|
|
2001
|
+
// 先按当前月 → 前一月 → …→ 全部月份列表 (3 个月够历史用)
|
|
2002
|
+
const candidates = [];
|
|
2003
|
+
const now = new Date();
|
|
2004
|
+
for (let i = 0; i < 6; i++) {
|
|
2005
|
+
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
|
2006
|
+
const month = d.toISOString().slice(0, 7);
|
|
2007
|
+
try {
|
|
2008
|
+
const files = await fs.readdir(path.join(attachmentsRoot, month));
|
|
2009
|
+
for (const f of files) {
|
|
2010
|
+
if (f.startsWith(id + '__'))
|
|
2011
|
+
candidates.push(path.join(attachmentsRoot, month, f));
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
catch { /* month dir 可能不存在, 跳过 */ }
|
|
2015
|
+
if (candidates.length > 0)
|
|
2016
|
+
break;
|
|
2017
|
+
}
|
|
2018
|
+
if (candidates.length === 0) {
|
|
2019
|
+
return res.status(404).type('text/plain').send('attachment not found');
|
|
2020
|
+
}
|
|
2021
|
+
const fullPath = candidates[0];
|
|
2022
|
+
const stat = await fs.stat(fullPath).catch(() => null);
|
|
2023
|
+
if (!stat || !stat.isFile()) {
|
|
2024
|
+
return res.status(404).type('text/plain').send('attachment missing');
|
|
2025
|
+
}
|
|
2026
|
+
// 从文件名还原原 mimeType: 按扩展名猜
|
|
2027
|
+
const fileBase = path.basename(fullPath);
|
|
2028
|
+
const origName = fileBase.substring(id.length + 2); // 跳过 "<id>__"
|
|
2029
|
+
const ext = path.extname(origName).toLowerCase();
|
|
2030
|
+
const mimeMap = {
|
|
2031
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
2032
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
|
2033
|
+
'.pdf': 'application/pdf',
|
|
2034
|
+
'.txt': 'text/plain', '.md': 'text/markdown', '.json': 'application/json',
|
|
2035
|
+
'.csv': 'text/csv', '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
|
|
2036
|
+
'.zip': 'application/zip',
|
|
2037
|
+
'.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.webm': 'video/webm',
|
|
2038
|
+
};
|
|
2039
|
+
const mime = mimeMap[ext] || 'application/octet-stream';
|
|
2040
|
+
const buf = await fs.readFile(fullPath);
|
|
2041
|
+
res.setHeader('Content-Length', String(buf.length));
|
|
2042
|
+
res.setHeader('Content-Type', mime);
|
|
2043
|
+
res.setHeader('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(origName)}`);
|
|
2044
|
+
res.send(buf);
|
|
2045
|
+
}
|
|
2046
|
+
catch (e) {
|
|
2047
|
+
console.error('[attachments] download failed:', e?.message || e);
|
|
2048
|
+
res.status(500).type('text/plain').send('download error: ' + (e?.message || ''));
|
|
2049
|
+
}
|
|
2050
|
+
});
|
|
1927
2051
|
// 全局兜底: 任何 next(err) 走到这里, 给出结构化 4xx/5xx 而不是默认 HTML
|
|
1928
2052
|
app.use((err, req, res, _next) => {
|
|
1929
2053
|
console.error('[server] unhandled error on', req.method, req.path, '-', err?.message || err);
|
|
@@ -1955,10 +2079,29 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1955
2079
|
});
|
|
1956
2080
|
});
|
|
1957
2081
|
app.post('/message', async (req, res) => {
|
|
1958
|
-
const { text, channelId, channelDid } = req.body;
|
|
2082
|
+
const { text, channelId, channelDid, attachments } = req.body;
|
|
1959
2083
|
if (!text) {
|
|
1960
2084
|
return res.status(400).json({ error: 'No text provided' });
|
|
1961
2085
|
}
|
|
2086
|
+
// 2026-07-15 修 Bug 3: 拖拽附件 — LLM 在 contextHint 里看到文件清单, 用户文本保持可读
|
|
2087
|
+
// 替代方案: 把 [attachment:id] 标记塞 text 里, 这里解析回 attachments 数组
|
|
2088
|
+
let parsedAttachments = [];
|
|
2089
|
+
if (Array.isArray(attachments) && attachments.length > 0) {
|
|
2090
|
+
parsedAttachments = attachments.filter((a) => a && typeof a.attachmentId === 'string' && a.attachmentId.length > 0);
|
|
2091
|
+
}
|
|
2092
|
+
else {
|
|
2093
|
+
// 兼容老前端: 从 text 里抽 [attachment:<id>] 标记
|
|
2094
|
+
const rx = /\[attachment:([a-zA-Z0-9_]+)\]/g;
|
|
2095
|
+
let m;
|
|
2096
|
+
while ((m = rx.exec(String(text))) !== null) {
|
|
2097
|
+
parsedAttachments.push({ attachmentId: m[1] });
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
const attachmentContext = parsedAttachments.length > 0
|
|
2101
|
+
? `[系统上下文] 用户上传了 ${parsedAttachments.length} 个附件: ` +
|
|
2102
|
+
parsedAttachments.map(a => `${a.filename || a.attachmentId} (id=${a.attachmentId}, mime=${a.mimeType || '?'}, size=${a.size ?? '?'}B, URL=/api/attachments/${a.attachmentId})`).join('; ') +
|
|
2103
|
+
`\n你需要时可以调用文件读工具 (curl /api/attachments/<id>) 拉取真实内容。\n\n`
|
|
2104
|
+
: '';
|
|
1962
2105
|
if (!channelId) {
|
|
1963
2106
|
return res.status(400).json({ error: 'No channelId provided' });
|
|
1964
2107
|
}
|
|
@@ -1990,9 +2133,17 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1990
2133
|
// per-channel queue 检查: 已在跑就入队, 等当前跑完自动接上
|
|
1991
2134
|
const runState = getOrCreateRunState(channelId);
|
|
1992
2135
|
if (runState.running) {
|
|
1993
|
-
|
|
2136
|
+
// 2026-07-15 修 Bug 8: 入队时保留 attachments + channelDid, 否则下一轮执行时会丢附件
|
|
2137
|
+
runState.queue.push({
|
|
2138
|
+
channelId,
|
|
2139
|
+
text,
|
|
2140
|
+
boundWalletAddress,
|
|
2141
|
+
autoToolsEnabled,
|
|
2142
|
+
attachments: parsedAttachments,
|
|
2143
|
+
channelDid,
|
|
2144
|
+
});
|
|
1994
2145
|
broadcastQueueUpdate(channelId);
|
|
1995
|
-
console.log(`[queue] /message 入队 channel=${channelId}, queue len=${runState.queue.length}`);
|
|
2146
|
+
console.log(`[queue] /message 入队 channel=${channelId}, queue len=${runState.queue.length}, attach=${parsedAttachments.length}`);
|
|
1996
2147
|
return;
|
|
1997
2148
|
}
|
|
1998
2149
|
runState.running = true;
|
|
@@ -2267,7 +2418,8 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
2267
2418
|
extraHint = hint + '\n\n';
|
|
2268
2419
|
nextPromptHints.delete(channelId);
|
|
2269
2420
|
}
|
|
2270
|
-
|
|
2421
|
+
// 2026-07-15 修 Bug 3: 拖拽附件 — attachmentContext 提到 contextHint 最前, LLM 第一眼看到文件清单
|
|
2422
|
+
const markedPrompt = `${extraHint}【本轮用户请求】\n${text}\n【请求结束】\n\n${attachmentContext}${contextHint}`;
|
|
2271
2423
|
fullResponse = await agent.promptStream(markedPrompt, streamCallback, runState.abortController?.signal, channelId);
|
|
2272
2424
|
}
|
|
2273
2425
|
catch (err) {
|
|
@@ -2310,7 +2462,15 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
2310
2462
|
const session = existingSession || { channelId, sessionId: currentSessionId, messages: [], lastUpdated: new Date().toISOString() };
|
|
2311
2463
|
session.sessionId = currentSessionId;
|
|
2312
2464
|
// v3: 加 source 标记 (local = 内部 owner, remote = 远端访客)
|
|
2313
|
-
|
|
2465
|
+
// 2026-07-15 修 Bug 4: client.ts sendMessage 已经通过 persistLastMessageToServer PATCH /sessions/.../...
|
|
2466
|
+
// 把 user msg 落盘一次 (立即落, 切走再切回不丢). 这里再 push 一次 → session.messages 出现两条相同的 user msg,
|
|
2467
|
+
// loadSession 重渲染时两条都上屏, 表现"每条 user 气泡重复两次".
|
|
2468
|
+
// 修法: 持久化以 client PATCH 为准, /message 这边只 push ai 消息. 同时去重检查上次的 user 避免极端竞态 (并行 PATCH).
|
|
2469
|
+
const lastMsg = session.messages[session.messages.length - 1];
|
|
2470
|
+
const userAlreadyPushed = lastMsg && lastMsg.type === 'user' && lastMsg.content === text;
|
|
2471
|
+
if (!userAlreadyPushed) {
|
|
2472
|
+
session.messages.push({ id: crypto.randomUUID(), type: 'user', content: text, timestamp: new Date().toISOString(), source: 'local' });
|
|
2473
|
+
}
|
|
2314
2474
|
session.messages.push({
|
|
2315
2475
|
id: crypto.randomUUID(),
|
|
2316
2476
|
type: 'ai',
|
|
@@ -2499,12 +2659,26 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
2499
2659
|
// 能直接拿到引用.
|
|
2500
2660
|
clearTimeout(forceTimeout);
|
|
2501
2661
|
// queue dequeue: 跑完或失败都要清状态
|
|
2502
|
-
// 当前实现: 自动接下一条需要把 ~200 行 try 块抽函数, 暂不抽.
|
|
2503
|
-
// 替代: 用户点 [队列 +N] 按钮时, 客户端发起一个特殊的 HTTP 请求触发下一条
|
|
2504
|
-
// (在 client.js 实现). 这里只清状态 + 广播.
|
|
2505
2662
|
runState.running = false;
|
|
2506
2663
|
runState.abortController = null;
|
|
2507
2664
|
broadcastQueueUpdate(channelId);
|
|
2665
|
+
// 2026-07-15 修 Bug 8: 队列自动 drain — 之前只清状态不抽下一条, 用户连续发的所有
|
|
2666
|
+
// 第二轮起全卡在 queue 里出不来, 表现"第二轮没反应".
|
|
2667
|
+
// 修复: finally 里查 queue, 非空就异步 fire-and-forget 起下一轮.
|
|
2668
|
+
// 实现要点:
|
|
2669
|
+
// 1. 用 setImmediate / Promise.resolve() 让 res.headersSent 干净
|
|
2670
|
+
// 2. 不能 await (否则阻塞 finally 后面的 saveSession; 而且这就是 fire-and-forget)
|
|
2671
|
+
// 3. 重新 build contextHint, 走相同 LLM 路径
|
|
2672
|
+
if (runState.queue.length > 0) {
|
|
2673
|
+
const next = runState.queue.shift();
|
|
2674
|
+
console.log(`[queue-drain] channel=${next.channelId} text="${next.text.slice(0, 30)}" attach=${(next.attachments?.length ?? 0)}`);
|
|
2675
|
+
// 异步跑下一条 (fire-and-forget)
|
|
2676
|
+
setImmediate(() => {
|
|
2677
|
+
void runMessageFromQueue(next).catch((e) => {
|
|
2678
|
+
console.error('[queue-drain] error:', e?.message?.slice(0, 200));
|
|
2679
|
+
});
|
|
2680
|
+
});
|
|
2681
|
+
}
|
|
2508
2682
|
// 2026-07-01 (v0.2.5): 持久化当前 messageHistory — 让 web 用户跨刷新保留对话.
|
|
2509
2683
|
// saveCurrentSession 失败静默, 不阻塞 channel 状态清理.
|
|
2510
2684
|
// saveCurrentSession 内部走 SessionStore (默认 ~/.bolloon/sessions/cache/<sessionKey>.json).
|
|
@@ -2514,7 +2688,7 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
2514
2688
|
await agent.saveCurrentSession(sessionKey);
|
|
2515
2689
|
}
|
|
2516
2690
|
catch (saveErr) {
|
|
2517
|
-
console.warn(`[web] saveCurrentSession failed (non-fatal): ${saveErr
|
|
2691
|
+
console.warn(`[web] saveCurrentSession failed (non-fatal): ${saveErr.message?.slice(0, 100)}`);
|
|
2518
2692
|
}
|
|
2519
2693
|
}
|
|
2520
2694
|
}
|
|
@@ -2535,6 +2709,115 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
2535
2709
|
}
|
|
2536
2710
|
return s;
|
|
2537
2711
|
}
|
|
2712
|
+
/** 抽离 attachment contextHint 出来 — 给 queue-drain 用; /message 主路径有 inline 等价版避免重复 hoist 错误 */
|
|
2713
|
+
function buildAttachmentContextForQueue(parsedAttachments) {
|
|
2714
|
+
if (!parsedAttachments || parsedAttachments.length === 0)
|
|
2715
|
+
return '';
|
|
2716
|
+
return `[系统上下文] 用户上传了 ${parsedAttachments.length} 个附件: ` +
|
|
2717
|
+
parsedAttachments.map((a) => `${a.filename || a.attachmentId} (id=${a.attachmentId}, mime=${a.mimeType || '?'}, size=${a.size ?? '?'}B, URL=/api/attachments/${a.attachmentId})`).join('; ') +
|
|
2718
|
+
`\n你可以调用文件读工具 (curl /api/attachments/<id>) 拉取真实内容。\n\n`;
|
|
2719
|
+
}
|
|
2720
|
+
/**
|
|
2721
|
+
* 2026-07-15 修 Bug 8: 队列消息的执行器 — finally 排空 queue 时调这里
|
|
2722
|
+
* 跑下一条 queued 消息.
|
|
2723
|
+
*
|
|
2724
|
+
* 设计: 这是个 fire-and-forget wrapper, 复用 /message 主路径的 broadcast / save / etc.
|
|
2725
|
+
* 简化路径 (跟主路径 500 行 try 块相比):
|
|
2726
|
+
* - 不重新建载 judgment hint / persona / context — server.ts 这次明确把这些容
|
|
2727
|
+
* 易"廉价放"在 /message 主路径, queue 路径只用基本标识
|
|
2728
|
+
* - 仍然是合法: agent.promptStream → broadcast(type:user) → broadcast(type:ai) → done
|
|
2729
|
+
* - 处理 attachments: 跟主路径一样
|
|
2730
|
+
*
|
|
2731
|
+
* 如果要 1:1 复刻主路径的所有 hooks (judgment hint / persona / manifest / etc),
|
|
2732
|
+
* 后续可以把 /message 主路径的 try 块抽成 runPromptChannel 共享.
|
|
2733
|
+
*/
|
|
2734
|
+
async function runMessageFromQueue(queued) {
|
|
2735
|
+
const { channelId, text, attachments, channelDid: reqChannelDid, boundWalletAddress, autoToolsEnabled } = queued;
|
|
2736
|
+
const runState = getOrCreateRunState(channelId);
|
|
2737
|
+
if (runState.running)
|
|
2738
|
+
return; // 防重入 — queue 已经并发去重
|
|
2739
|
+
runState.running = true;
|
|
2740
|
+
runState.abortController = new AbortController();
|
|
2741
|
+
const currentSessionId = (await loadChannels()).find(c => c.id === channelId)?.currentSessionId || 'default';
|
|
2742
|
+
const realChannelDid = reqChannelDid || (await loadChannels()).find(c => c.id === channelId)?.did || '';
|
|
2743
|
+
const parsedAttachments = Array.isArray(attachments) ? attachments : [];
|
|
2744
|
+
const attachmentContext = buildAttachmentContextForQueue(parsedAttachments);
|
|
2745
|
+
const sessionKey = `${channelId}:${currentSessionId}`;
|
|
2746
|
+
// 防 LLM hang 安全网
|
|
2747
|
+
const PIVOT_FORCE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
2748
|
+
const forceTimeout = setTimeout(() => {
|
|
2749
|
+
console.warn(`[server] queue-drain pivot 强制 timeout, aborting`);
|
|
2750
|
+
runState.abortController?.abort();
|
|
2751
|
+
}, PIVOT_FORCE_TIMEOUT_MS);
|
|
2752
|
+
let agent = null;
|
|
2753
|
+
try {
|
|
2754
|
+
// 1) broadcast user 给前端 (跟主路径一致)
|
|
2755
|
+
broadcast({ type: 'user', content: text }, channelId);
|
|
2756
|
+
// 2) 取 agent + session
|
|
2757
|
+
agent = await getAgentForChannel(channelId, currentSessionId).catch(() => null);
|
|
2758
|
+
if (!agent) {
|
|
2759
|
+
throw new Error(`No agent for channel=${channelId}`);
|
|
2760
|
+
}
|
|
2761
|
+
// 3) 重 build contextHint (基本版, 不全 500 行 hook)
|
|
2762
|
+
// 原因: queue-drain 是常见调试路径, 全量 hooks 性能大. 关键是 attachments 带上.
|
|
2763
|
+
const contextHint = attachmentContext + `[系统上下文] 队列消息 (auto-drain)\n`;
|
|
2764
|
+
// 4) promptStream
|
|
2765
|
+
const markedPrompt = `【本轮用户请求】\n${text}\n【请求结束】\n\n${contextHint}`;
|
|
2766
|
+
const fullResponse = await agent.promptStream(markedPrompt, () => { }, runState.abortController?.signal, channelId);
|
|
2767
|
+
if (!fullResponse.trim()) {
|
|
2768
|
+
broadcast({ type: 'error', content: '⚠️ AI 未返回内容' }, channelId);
|
|
2769
|
+
}
|
|
2770
|
+
else {
|
|
2771
|
+
broadcast({ type: 'ai', content: fullResponse }, channelId);
|
|
2772
|
+
// 落 session
|
|
2773
|
+
try {
|
|
2774
|
+
const existing = await loadSession(channelId, currentSessionId);
|
|
2775
|
+
const session = existing || { channelId, sessionId: currentSessionId, messages: [], lastUpdated: new Date().toISOString() };
|
|
2776
|
+
session.sessionId = currentSessionId;
|
|
2777
|
+
// 跟主路径相同: 不重复 push user (主路径已 broadcast/push), 只 push ai
|
|
2778
|
+
session.messages.push({
|
|
2779
|
+
id: crypto.randomUUID(),
|
|
2780
|
+
type: 'ai',
|
|
2781
|
+
content: fullResponse,
|
|
2782
|
+
timestamp: new Date().toISOString(),
|
|
2783
|
+
source: 'local',
|
|
2784
|
+
});
|
|
2785
|
+
session.lastUpdated = new Date().toISOString();
|
|
2786
|
+
await saveSession(session);
|
|
2787
|
+
}
|
|
2788
|
+
catch (e) {
|
|
2789
|
+
console.warn('[queue-drain] saveSession failed:', e?.message?.slice(0, 100));
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
broadcast({ type: 'done' }, channelId);
|
|
2793
|
+
}
|
|
2794
|
+
catch (err) {
|
|
2795
|
+
console.warn('[queue-drain] failed:', err?.message?.slice(0, 200));
|
|
2796
|
+
broadcast({ type: 'error', content: 'queue-drain: ' + (err?.message || 'failed') }, channelId);
|
|
2797
|
+
}
|
|
2798
|
+
finally {
|
|
2799
|
+
clearTimeout(forceTimeout);
|
|
2800
|
+
runState.running = false;
|
|
2801
|
+
runState.abortController = null;
|
|
2802
|
+
broadcastQueueUpdate(channelId);
|
|
2803
|
+
// 递归 drain — 同 /message 主路径 finally 行为保持一致
|
|
2804
|
+
if (runState.queue.length > 0) {
|
|
2805
|
+
const next = runState.queue.shift();
|
|
2806
|
+
console.log(`[queue-drain-recursive] channel=${next.channelId} text="${next.text.slice(0, 30)}"`);
|
|
2807
|
+
setImmediate(() => {
|
|
2808
|
+
void runMessageFromQueue(next).catch((e) => {
|
|
2809
|
+
console.error('[queue-drain-recursive] error:', e?.message?.slice(0, 200));
|
|
2810
|
+
});
|
|
2811
|
+
});
|
|
2812
|
+
}
|
|
2813
|
+
if (agent) {
|
|
2814
|
+
try {
|
|
2815
|
+
await agent.saveCurrentSession(sessionKey);
|
|
2816
|
+
}
|
|
2817
|
+
catch { }
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2538
2821
|
function broadcastQueueUpdate(channelId) {
|
|
2539
2822
|
const s = channelRunState.get(channelId);
|
|
2540
2823
|
const queueLength = s ? s.queue.length : 0;
|
|
@@ -2855,6 +3138,41 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
2855
3138
|
channels.push(channel);
|
|
2856
3139
|
await saveChannels(channels);
|
|
2857
3140
|
await saveSession({ channelId: id, sessionId: 'default', messages: [], lastUpdated: new Date().toISOString() });
|
|
3141
|
+
// 2026-07-15 修 Bug 6: 同步把 agent 定义写进 ~/.bolloon/agents/agents.json
|
|
3142
|
+
// 之前 channel 只落 channels.json, 不会出现在 agents.json 里.
|
|
3143
|
+
// 重新启动 server 时 loadLocalSubAgents 只读 agents.json — 用户以为"智能体没保存".
|
|
3144
|
+
// 修法: 直接读 + append (idempotent) 写 agents.json, 用 channel.agentId 作为主键 (跟 channels.json 引用对齐).
|
|
3145
|
+
// 不走 SubAgentManager.registerAgent 因为它会自己生成新 id, 跟 channel.agentId 对不上.
|
|
3146
|
+
try {
|
|
3147
|
+
const agentsPath = path.join(process.env.HOME || '/tmp', '.bolloon', 'agents', 'agents.json');
|
|
3148
|
+
await fs.mkdir(path.dirname(agentsPath), { recursive: true });
|
|
3149
|
+
let arr = [];
|
|
3150
|
+
try {
|
|
3151
|
+
arr = JSON.parse(await fs.readFile(agentsPath, 'utf-8'));
|
|
3152
|
+
}
|
|
3153
|
+
catch { }
|
|
3154
|
+
if (!Array.isArray(arr))
|
|
3155
|
+
arr = [];
|
|
3156
|
+
const exists = arr.some(a => a && a.id === agentId);
|
|
3157
|
+
if (!exists) {
|
|
3158
|
+
arr.push({
|
|
3159
|
+
id: agentId,
|
|
3160
|
+
name,
|
|
3161
|
+
did: `did:local:${id}`,
|
|
3162
|
+
description: `Agent ${name} (auto-registered from channel ${id})`,
|
|
3163
|
+
capabilities: Array.isArray(channelPersona?.capabilities) ? channelPersona.capabilities : [],
|
|
3164
|
+
status: 'active',
|
|
3165
|
+
createdAt: new Date().toISOString(),
|
|
3166
|
+
lastActive: new Date().toISOString(),
|
|
3167
|
+
channelId: id,
|
|
3168
|
+
});
|
|
3169
|
+
await fs.writeFile(agentsPath, JSON.stringify(arr, null, 2), 'utf-8');
|
|
3170
|
+
console.log(`[创建频道] agent 写进 agents.json: name=${name} id=${agentId}`);
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
catch (e) {
|
|
3174
|
+
console.warn('[创建频道] 写 agents.json 失败 (非致命):', e?.message?.slice(0, 120));
|
|
3175
|
+
}
|
|
2858
3176
|
res.json(channel);
|
|
2859
3177
|
// 后台生成 DID — 用统一的修复队列, 避免每个 POST 都启动独立 setTimeout
|
|
2860
3178
|
console.log(`[创建频道] 加入 DID 修复队列...`);
|
|
@@ -2993,6 +3311,32 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
2993
3311
|
}
|
|
2994
3312
|
catch { }
|
|
2995
3313
|
}
|
|
3314
|
+
// 2026-07-15 修 Bug 7: 同步清理 agents.json 里挂在这个 channel 下的 agent 定义
|
|
3315
|
+
// 之前 v0.3.6 (Bug 6) 创建频道时同步往 agents.json append, 删频道却没删回来
|
|
3316
|
+
// → agents.json 里残留孤儿 agent, 重启后 loadLocalSubAgents 还能读到这些 — 看起来像"删不掉"
|
|
3317
|
+
// 修法: 用 channel.agentId 找, 同步从 agents.json 删一条
|
|
3318
|
+
try {
|
|
3319
|
+
const agentsPath = path.join(process.env.HOME || '/tmp', '.bolloon', 'agents', 'agents.json');
|
|
3320
|
+
const raw = await fs.readFile(agentsPath, 'utf-8').catch(() => '');
|
|
3321
|
+
if (raw) {
|
|
3322
|
+
let arr = [];
|
|
3323
|
+
try {
|
|
3324
|
+
arr = JSON.parse(raw);
|
|
3325
|
+
}
|
|
3326
|
+
catch { }
|
|
3327
|
+
if (!Array.isArray(arr))
|
|
3328
|
+
arr = [];
|
|
3329
|
+
const before = arr.length;
|
|
3330
|
+
arr = arr.filter(a => !(a && (a.id === channel.agentId || a.channelId === channelId)));
|
|
3331
|
+
if (arr.length !== before) {
|
|
3332
|
+
await fs.writeFile(agentsPath, JSON.stringify(arr, null, 2), 'utf-8');
|
|
3333
|
+
console.log(`[删除频道] agents.json 清掉 ${before - arr.length} 条 orphan agent (channel=${channelId})`);
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
catch (e) {
|
|
3338
|
+
console.warn('[删除频道] 清理 agents.json 失败 (非致命):', e?.message?.slice(0, 120));
|
|
3339
|
+
}
|
|
2996
3340
|
res.json({ ok: true });
|
|
2997
3341
|
}
|
|
2998
3342
|
catch (err) {
|
|
@@ -4665,6 +5009,8 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
4665
5009
|
});
|
|
4666
5010
|
// 2026-07-06: judgments / self-improve / permission-mode 路由抽到 ./routes-judgments.ts
|
|
4667
5011
|
registerJudgmentsRoutes(app);
|
|
5012
|
+
// 2026-07-15: judgeness · hearth 主路由 + peer 4 类写 API
|
|
5013
|
+
registerHearthRoutes(app);
|
|
4668
5014
|
// ==================== Self-Improve 端点 ====================
|
|
4669
5015
|
// 查看当前策略 (白名单 / 黑名单)
|
|
4670
5016
|
app.get('/api/self-improve/policy', async (_req, res) => {
|
|
@@ -60,7 +60,7 @@ function escapeHtml(s) {
|
|
|
60
60
|
"'": "'"
|
|
61
61
|
})[c]);
|
|
62
62
|
}
|
|
63
|
-
function addMessage(content, type, save = true, container, usedJudgmentIds = [], ctx = { messagesEl: null, messagesContainers: /* @__PURE__ */ new Map(), currentChannelId: null }) {
|
|
63
|
+
function addMessage(content, type, save = true, container, usedJudgmentIds = [], ctx = { messagesEl: null, messagesContainers: /* @__PURE__ */ new Map(), currentChannelId: null }, timestamp) {
|
|
64
64
|
const messagesEl = ctx.messagesEl || (typeof document !== "undefined" ? document.getElementById("messages") : null);
|
|
65
65
|
const messagesContainers = ctx.messagesContainers || /* @__PURE__ */ new Map();
|
|
66
66
|
const currentChannelId = ctx.currentChannelId;
|
|
@@ -129,9 +129,20 @@ function addMessage(content, type, save = true, container, usedJudgmentIds = [],
|
|
|
129
129
|
return;
|
|
130
130
|
}
|
|
131
131
|
const rawContent = segments.filter((s) => s.type === "text" || s.type === "final").map((s) => s.content || "").join("\n");
|
|
132
|
+
let timeLabel = "";
|
|
133
|
+
try {
|
|
134
|
+
if (timestamp !== void 0 && timestamp !== null) {
|
|
135
|
+
const d = timestamp instanceof Date ? timestamp : new Date(timestamp);
|
|
136
|
+
if (!isNaN(d.getTime())) {
|
|
137
|
+
timeLabel = d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
}
|
|
142
|
+
if (!timeLabel) timeLabel = (/* @__PURE__ */ new Date()).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
|
132
143
|
const time = document.createElement("div");
|
|
133
144
|
time.className = "time";
|
|
134
|
-
time.textContent =
|
|
145
|
+
time.textContent = timeLabel;
|
|
135
146
|
if (type === "ai") {
|
|
136
147
|
div.appendChild(buildMessageActions(div, rawContent, ctx));
|
|
137
148
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* judgeness / dual-mode.ts
|
|
3
|
+
*
|
|
4
|
+
* Accept 协商 + JSON-LD 生成.
|
|
5
|
+
* - 默认 `application/ld+json` 给 agent (头等公民)
|
|
6
|
+
* - `text/html` 或 `?view=human` 给人类
|
|
7
|
+
*
|
|
8
|
+
* 复用 bolloon 现有 src/web/util/safe-name.ts (兜底防 undefined/null/NaN 渲染).
|
|
9
|
+
*/
|
|
10
|
+
const HEARTH_LD_CONTEXT = 'https://judgeness.bolloon.com/schema/v1';
|
|
11
|
+
/** 最简 UA 检测: 已知 agent 名 / 字符串里含 'bot'/'agent' 视为 agent.
|
|
12
|
+
* 防御期 placeholder — 反攻期可接 allowlist UA detection. */
|
|
13
|
+
function isLikelyAgentUA(ua) {
|
|
14
|
+
if (!ua)
|
|
15
|
+
return false;
|
|
16
|
+
const s = ua.toLowerCase();
|
|
17
|
+
return /bot|agent|crawler|spider/.test(s);
|
|
18
|
+
}
|
|
19
|
+
export function negotiateAudience(input) {
|
|
20
|
+
// ?view=human 显式
|
|
21
|
+
const v = input.query?.['view'];
|
|
22
|
+
if (typeof v === 'string' && v.toLowerCase() === 'human')
|
|
23
|
+
return 'human';
|
|
24
|
+
const accept = (input.accept ?? '').toLowerCase();
|
|
25
|
+
// text/html 优先
|
|
26
|
+
if (accept.includes('text/html') && !accept.includes('application/ld+json'))
|
|
27
|
+
return 'human';
|
|
28
|
+
// UA 启发式
|
|
29
|
+
if (isLikelyAgentUA(input.userAgent) && !accept.includes('text/html'))
|
|
30
|
+
return 'agent';
|
|
31
|
+
// 缺省: agent (头等公民)
|
|
32
|
+
return 'agent';
|
|
33
|
+
}
|
|
34
|
+
export function descriptionToJsonLd(d) {
|
|
35
|
+
return {
|
|
36
|
+
'@context': HEARTH_LD_CONTEXT,
|
|
37
|
+
'@type': 'JudgenessDescription',
|
|
38
|
+
'@id': `urn:judgeness:description:${d.descriptionId}`,
|
|
39
|
+
name: `Description ${d.descriptionId}`,
|
|
40
|
+
description: `Judgment ref: ${d.judgmentRef}`,
|
|
41
|
+
facets: d.facets,
|
|
42
|
+
scope: d.scope,
|
|
43
|
+
visibility: d.visibility,
|
|
44
|
+
openState: d.openState,
|
|
45
|
+
by: d.by,
|
|
46
|
+
createdAt: d.createdAt,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// HTML 渲染 (人类视图, 极简, 防 XSS escape)
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
function escapeHtml(s) {
|
|
53
|
+
return s
|
|
54
|
+
.replace(/&/g, '&')
|
|
55
|
+
.replace(/</g, '<')
|
|
56
|
+
.replace(/>/g, '>')
|
|
57
|
+
.replace(/"/g, '"')
|
|
58
|
+
.replace(/'/g, ''');
|
|
59
|
+
}
|
|
60
|
+
export function renderHumanHtml(title, body) {
|
|
61
|
+
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(title)}</title></head><body><h1>${escapeHtml(title)}</h1>${body}</body></html>`;
|
|
62
|
+
}
|
|
63
|
+
export function descriptionToHumanHtml(d) {
|
|
64
|
+
const facetsLines = d.facets
|
|
65
|
+
? Object.entries(d.facets)
|
|
66
|
+
.filter(([, v]) => v !== undefined)
|
|
67
|
+
.map(([k, v]) => `<li>${escapeHtml(k)}: ${escapeHtml(String(v))}</li>`)
|
|
68
|
+
.join('')
|
|
69
|
+
: '<li>(no facets visible at your access level)</li>';
|
|
70
|
+
const body = `<section><h2>Description ${escapeHtml(d.descriptionId)}</h2><p>Judgment ref: <code>${escapeHtml(d.judgmentRef)}</code></p><ul>${facetsLines}</ul><p>visibility: <strong>${escapeHtml(d.visibility)}</strong> · openState: <strong>${escapeHtml(d.openState)}</strong></p></section>`;
|
|
71
|
+
return renderHumanHtml(`Hearth · ${d.descriptionId}`, body);
|
|
72
|
+
}
|
|
73
|
+
export function dualRender(input, humanHtml, jsonLdProducer) {
|
|
74
|
+
const aud = negotiateAudience(input);
|
|
75
|
+
if (aud === 'human') {
|
|
76
|
+
return { status: 200, contentType: 'text/html; charset=utf-8', body: humanHtml() };
|
|
77
|
+
}
|
|
78
|
+
const obj = jsonLdProducer();
|
|
79
|
+
return {
|
|
80
|
+
status: 200,
|
|
81
|
+
contentType: 'application/ld+json; charset=utf-8',
|
|
82
|
+
body: JSON.stringify(obj),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export function dualRenderList(input, humanHtmlProducer, jsonLdProducer) {
|
|
86
|
+
return dualRender(input, humanHtmlProducer, () => jsonLdProducer());
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bolloon/bolloon-agent",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
|
|
6
6
|
"main": "dist/cli-entry.js",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"src/constraint-runtime"
|
|
49
49
|
],
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@bolloon/bolloon-agent": "^0.3.
|
|
51
|
+
"@bolloon/bolloon-agent": "^0.3.3",
|
|
52
52
|
"@bolloon/constraint-runtime": "0.1.0",
|
|
53
53
|
"@capacitor/core": "^8.4.1",
|
|
54
54
|
"@capacitor/ios": "^8.4.1",
|