@bolloon/bolloon-agent 0.1.30 → 0.1.33

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.
@@ -10,6 +10,8 @@ import { documentReader } from '../documents/reader.js';
10
10
  import { initMinimax, getMinimax } from '../constraints/index.js';
11
11
  import { createAgentSession } from '../agents/pi-sdk.js';
12
12
  import { llmConfigStore } from '../llm/config-store.js';
13
+ import { videoConfigStore } from '../llm/video-config-store.js';
14
+ import { audioConfigStore } from '../llm/audio-config-store.js';
13
15
  import { irohTransport } from '../network/iroh-transport.js';
14
16
  import { createAgentDelegateApp } from './agent-delegate-server.js';
15
17
  import { createIrohDelegateTransport } from './iroh-delegate-transport.js';
@@ -326,6 +328,114 @@ function isSharedWith(ch, peerPublicKey) {
326
328
  const shared = Array.isArray(ch.shared_with_peers) ? ch.shared_with_peers : [];
327
329
  return shared.includes(peerPublicKey);
328
330
  }
331
+ /**
332
+ * v3 新增: 解析 LLM 回复里的 @-mentions, 把消息发到目标 channel.
333
+ *
334
+ * 语法: "@渠道名 消息内容" — 渠道名匹配 local channels by name, 或 remote channels by name.
335
+ * - 本地 channel: 直接 push 到 session
336
+ * - 远端 channel: 通过 P2P RPC 转发到对端
337
+ *
338
+ * 返回: 解析到的 mention 列表, 供 SSE 广播
339
+ */
340
+ async function routeMentionsInReply(originChannelId, replyText, localChannels, remoteChannels) {
341
+ const results = [];
342
+ // 解析: 匹配 @渠道名 后面跟一段文字 (到下一个 @ 或 行尾)
343
+ // 渠道名: 中文/英文/数字/下划线/连字符, 1-30 字符
344
+ const regex = /@([一-龥A-Za-z0-9_\-]{1,30})\s+([^\n@]+?)(?=(?:\s*@[一-龥A-Za-z0-9_\-]{1,30}\s)|$)/g;
345
+ const matches = [...replyText.matchAll(regex)];
346
+ if (matches.length === 0)
347
+ return results;
348
+ // 找当前 channel 的 name (用于日志)
349
+ let originChannelName = originChannelId;
350
+ try {
351
+ const chs = await loadChannels();
352
+ const oc = chs.find(c => c.id === originChannelId);
353
+ if (oc)
354
+ originChannelName = oc.name;
355
+ }
356
+ catch { }
357
+ console.log(`[v3-cross] (${originChannelName}) 解析到 ${matches.length} 个 @-mention`);
358
+ for (const m of matches) {
359
+ const targetName = m[1].trim();
360
+ const text = m[2].trim();
361
+ if (!text)
362
+ continue;
363
+ // 优先本地 (本地 channel 不能有 ownerPublicKey)
364
+ const localTarget = localChannels.find(c => c.name === targetName);
365
+ const remoteTarget = !localTarget ? remoteChannels.find(c => c.name === targetName) : null;
366
+ if (localTarget) {
367
+ // 本地: 直接 push 到 session
368
+ try {
369
+ const existing = await loadSession(localTarget.id, 'default');
370
+ const session = existing || {
371
+ channelId: localTarget.id, sessionId: 'default', messages: [], lastUpdated: new Date().toISOString()
372
+ };
373
+ session.messages.push({
374
+ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
375
+ type: 'ai',
376
+ content: text,
377
+ timestamp: new Date().toISOString(),
378
+ source: 'ai-mention', // v3: 标记是其他 channel 的 AI @-mention 进来的
379
+ originChannelId, // 谁 @ 过来的
380
+ originChannelName // 渠道名 (方便显示)
381
+ });
382
+ session.lastUpdated = new Date().toISOString();
383
+ await saveSession(session);
384
+ console.log(`[v3-cross] (${originChannelName}) @${targetName} → 本地 channel ${localTarget.id}, 存了 ${text.length} chars`);
385
+ // 推 SSE 让本地 UI 知道有 AI 跨渠道消息
386
+ broadcast({
387
+ type: 'cross-mention-received',
388
+ originChannelId, originChannelName,
389
+ targetChannelId: localTarget.id, targetChannelName: localTarget.name,
390
+ text, source: 'ai-mention'
391
+ }, 'broadcast');
392
+ results.push({ targetName, targetId: localTarget.id, source: 'local', text, status: 'sent' });
393
+ }
394
+ catch (err) {
395
+ console.error(`[v3-cross] @${targetName} 本地存失败:`, err.message);
396
+ results.push({ targetName, targetId: localTarget.id, source: 'local', text, status: 'failed' });
397
+ }
398
+ }
399
+ else if (remoteTarget) {
400
+ // 远端: 通过 P2P RPC 转发
401
+ const ownerPk = remoteTarget._ownerPublicKey;
402
+ if (!v3P2PRef) {
403
+ console.warn(`[v3-cross] P2PDirect 未启动, 跳过远端 @${targetName}`);
404
+ results.push({ targetName, targetId: remoteTarget.id, source: 'remote', text, status: 'failed' });
405
+ continue;
406
+ }
407
+ try {
408
+ const rpc = JSON.stringify({
409
+ v: 3, op: 'agent.cross.post',
410
+ payload: {
411
+ targetChannelId: remoteTarget.id,
412
+ targetChannelName: remoteTarget.name,
413
+ originChannelId,
414
+ originChannelName,
415
+ text,
416
+ fromPublicKey: v3P2PRef.getPublicKey()
417
+ }
418
+ });
419
+ const ok = v3P2PRef.sendTo(ownerPk, rpc);
420
+ if (ok) {
421
+ console.log(`[v3-cross] (${originChannelName}) @${targetName} → 远端 peer ${ownerPk.substring(0, 12)}... (channelId=${remoteTarget.id})`);
422
+ results.push({ targetName, targetId: remoteTarget.id, source: 'remote', text, status: 'sent' });
423
+ }
424
+ else {
425
+ results.push({ targetName, targetId: remoteTarget.id, source: 'remote', text, status: 'failed' });
426
+ }
427
+ }
428
+ catch (err) {
429
+ console.error(`[v3-cross] @${targetName} 远端 RPC 失败:`, err.message);
430
+ results.push({ targetName, targetId: remoteTarget.id, source: 'remote', text, status: 'failed' });
431
+ }
432
+ }
433
+ else {
434
+ console.warn(`[v3-cross] @${targetName} 找不到匹配 channel (本地 ${localChannels.length} 个, 远端 ${remoteChannels.length} 个)`);
435
+ }
436
+ }
437
+ return results;
438
+ }
329
439
  /**
330
440
  * v3: 处理 Hyperswarm 通道收到的 v3 RPC 消息
331
441
  * 设计: 用 HyperswarmCommunicator (DHT topic 自动发现) 取代 iroh 直接 connect
@@ -435,11 +545,13 @@ async function handleV3P2PMessage(parsed, conn, comm) {
435
545
  id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
436
546
  type: 'user',
437
547
  content: text,
438
- timestamp: new Date().toISOString()
548
+ timestamp: new Date().toISOString(),
549
+ source: 'remote', // v3: 标记远端访客
550
+ fromPublicKey: senderKey // v3: 记录对方 publicKey
439
551
  });
440
552
  session.lastUpdated = new Date().toISOString();
441
553
  await saveSession(session);
442
- console.log(`[v3] (${channelId}) 存 user 消息 (${text.length} chars) 到 A 的 session`);
554
+ console.log(`[v3] (${channelId}) 存 user 消息 (${text.length} chars) 到 A 的 session (来自 ${senderKey.substring(0, 12)}...)`);
443
555
  }
444
556
  catch (saveErr) {
445
557
  console.warn(`[v3] 存 user 消息失败 (不影响 chat):`, saveErr.message);
@@ -465,7 +577,30 @@ async function handleV3P2PMessage(parsed, conn, comm) {
465
577
  // 2. 跑 LLM (复用 Phase 1 的 buildJudgmentHint — 注入 channel 的 judgment)
466
578
  const { getMinimax } = await import('../constraints/index.js');
467
579
  const llm = getMinimax();
468
- const fullPrompt = `${judgmentHint}${text}`;
580
+ // v3 新增: 在 prompt 头部标记"这是远端访客", 让 AI 知道对方不是自己 owner
581
+ const visitorHint = `[系统上下文] 消息来源: 远端访客 (P2P 连接, publicKey=${senderKey.substring(0, 12)}...). 对方不是你 owner, 是通过 P2P 网络访问你这个 channel 的合作者. 称呼对方时可用 "远端访客" / "朋友" / "合作者", 不要叫 "主人".\n\n`;
582
+ // v3 新增: 也注入 channel 目录给 LLM (B 的 channel 也可以 @-mention 其他)
583
+ let dirHint = '';
584
+ const localChannels = (await loadChannels()).filter(c => c.id !== channelId);
585
+ const remoteChannels = [];
586
+ for (const [peerPk, list] of remoteChannelCache.entries()) {
587
+ if (peerPk === senderKey)
588
+ continue; // 跳过发起方
589
+ for (const ch of list) {
590
+ remoteChannels.push({ ...ch, _ownerPublicKey: peerPk });
591
+ }
592
+ }
593
+ if (localChannels.length > 0 || remoteChannels.length > 0) {
594
+ dirHint += '[系统上下文] 可用渠道 (你可以用 @渠道名 消息内容 给它们发消息):\n';
595
+ for (const c of localChannels) {
596
+ dirHint += ` - [本地] @${c.name} (id=${c.id})\n`;
597
+ }
598
+ for (const c of remoteChannels) {
599
+ dirHint += ` - [远端, owner=${(c._ownerPublicKey || '').substring(0, 8)}…] @${c.name} (id=${c.id})\n`;
600
+ }
601
+ dirHint += '语法: 在回复中写 "@渠道名 我要说的话" 即可. 消息会持久化到目标 channel 的 session.\n\n';
602
+ }
603
+ const fullPrompt = `${visitorHint}${dirHint}${judgmentHint}${text}`;
469
604
  let fullResponse = '';
470
605
  // v3 新增: 流式 token 节流推给 B — 让 B 看到过程
471
606
  let lastFlushAt = 0;
@@ -588,6 +723,53 @@ async function handleV3P2PMessage(parsed, conn, comm) {
588
723
  }
589
724
  return;
590
725
  }
726
+ // v3 新增: 收到远端发来的 @-mention 跨渠道消息, 存到本地 target channel
727
+ if (op === 'agent.cross.post') {
728
+ const { targetChannelId, targetChannelName, originChannelId, originChannelName, text, fromPublicKey } = parsed.payload || {};
729
+ if (!targetChannelId || !text) {
730
+ console.warn(`[v3-cross] agent.cross.post 缺少 targetChannelId/text`);
731
+ return;
732
+ }
733
+ try {
734
+ // 找 channel — 必须存在于本节点
735
+ const channels = await loadChannels();
736
+ const ch = channels.find(c => c.id === targetChannelId);
737
+ if (!ch) {
738
+ console.warn(`[v3-cross] agent.cross.post: 本节点无 channel ${targetChannelId}, 忽略`);
739
+ return;
740
+ }
741
+ // 存到 session — 这是一条来自其他节点的 LLM @-mention
742
+ const existing = await loadSession(targetChannelId, 'default');
743
+ const session = existing || {
744
+ channelId: targetChannelId, sessionId: 'default', messages: [], lastUpdated: new Date().toISOString()
745
+ };
746
+ session.messages.push({
747
+ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
748
+ type: 'ai',
749
+ content: text,
750
+ timestamp: new Date().toISOString(),
751
+ source: 'ai-mention-remote', // v3: 来自其他节点的 AI @-mention
752
+ originChannelId, // 哪个 channel 触发的
753
+ originChannelName,
754
+ fromPublicKey // 哪个节点来的
755
+ });
756
+ session.lastUpdated = new Date().toISOString();
757
+ await saveSession(session);
758
+ console.log(`[v3-cross] 收到远端 @-mention: ${originChannelName} → 本地 ${targetChannelName} (${text.length} chars)`);
759
+ // 推 SSE 让本地 UI 知道有跨渠道消息到达
760
+ broadcast({
761
+ type: 'cross-mention-received',
762
+ originChannelId, originChannelName,
763
+ targetChannelId, targetChannelName: ch.name,
764
+ text, source: 'ai-mention-remote',
765
+ fromPublicKey
766
+ }, 'broadcast');
767
+ }
768
+ catch (err) {
769
+ console.error(`[v3-cross] 处理 agent.cross.post 失败:`, err.message);
770
+ }
771
+ return;
772
+ }
591
773
  console.log(`[v3] 收到未知 op: ${op}`);
592
774
  }
593
775
  async function buildJudgmentHint(channel, channelIdForLog) {
@@ -1058,6 +1240,8 @@ export async function createWebServer(port = 3000, options = {}) {
1058
1240
  let contextHint = '';
1059
1241
  if (realChannelDid)
1060
1242
  contextHint += `[系统上下文] 当前频道名称: ${realChannelName}, 你的真实 DID: ${realChannelDid}\n`;
1243
+ // v3 新增: 标识发送方 — 让 AI 分清内部 owner vs 远端访客
1244
+ contextHint += `[系统上下文] 消息来源: 本地 (channel 内部 owner / 此机器上的用户). 称呼对方时用 "你" 或 "主人" 即可.\n`;
1061
1245
  if (boundWalletAddress) {
1062
1246
  contextHint += `[系统上下文] 已绑定的加密钱包地址: ${boundWalletAddress}。当用户授权或启用自动工具调用时, 可使用该地址发起链上操作。\n`;
1063
1247
  }
@@ -1072,15 +1256,38 @@ export async function createWebServer(port = 3000, options = {}) {
1072
1256
  const judgmentHint = await buildJudgmentHint(channelForJudgment, channelId);
1073
1257
  if (judgmentHint)
1074
1258
  contextHint += judgmentHint;
1259
+ // v3 新增: 注入"可用渠道"目录, 让 LLM 知道可以 @-mention 哪些 channel
1260
+ // - 本地 channels (除了自己)
1261
+ // - 远端 channels (remoteChannelCache 缓存的)
1262
+ const localChannels = (await loadChannels()).filter(c => c.id !== channelId);
1263
+ const remoteChannels = [];
1264
+ for (const [peerPk, list] of remoteChannelCache.entries()) {
1265
+ for (const ch of list) {
1266
+ remoteChannels.push({ ...ch, _ownerPublicKey: peerPk });
1267
+ }
1268
+ }
1269
+ if (localChannels.length > 0 || remoteChannels.length > 0) {
1270
+ contextHint += '[系统上下文] 可用渠道 (你可以用 @渠道名 消息内容 给它们发消息):\n';
1271
+ for (const c of localChannels) {
1272
+ contextHint += ` - [本地] @${c.name} (id=${c.id})\n`;
1273
+ }
1274
+ for (const c of remoteChannels) {
1275
+ contextHint += ` - [远端, owner=${(c._ownerPublicKey || '').substring(0, 8)}…] @${c.name} (id=${c.id})\n`;
1276
+ }
1277
+ contextHint += '语法: 当你想给其他渠道发消息, 在回复中写 "@渠道名 我要说的话" 即可. 消息会持久化到目标 channel 的 session, 你之后能看到"自己"在那里说的话.\n\n';
1278
+ }
1075
1279
  if (contextHint)
1076
1280
  contextHint += '\n';
1077
1281
  fullResponse = await agent.promptStream(contextHint + text, streamCallback);
1282
+ // v3 新增: 解析 LLM 回复里的 @-mentions, 转发到目标 channel
1283
+ await routeMentionsInReply(channelId, fullResponse, localChannels, remoteChannels);
1078
1284
  broadcast({ type: 'ai', content: fullResponse }, channelId);
1079
1285
  const existingSession = await loadSession(channelId, currentSessionId);
1080
1286
  const session = existingSession || { channelId, sessionId: currentSessionId, messages: [], lastUpdated: new Date().toISOString() };
1081
1287
  session.sessionId = currentSessionId;
1082
- session.messages.push({ id: crypto.randomUUID(), type: 'user', content: text, timestamp: new Date().toISOString() });
1083
- session.messages.push({ id: crypto.randomUUID(), type: 'ai', content: fullResponse, timestamp: new Date().toISOString() });
1288
+ // v3: 加 source 标记 (local = 内部 owner, remote = 远端访客)
1289
+ session.messages.push({ id: crypto.randomUUID(), type: 'user', content: text, timestamp: new Date().toISOString(), source: 'local' });
1290
+ session.messages.push({ id: crypto.randomUUID(), type: 'ai', content: fullResponse, timestamp: new Date().toISOString(), source: 'local' });
1084
1291
  session.lastUpdated = new Date().toISOString();
1085
1292
  await saveSession(session);
1086
1293
  const channels = await loadChannels();
@@ -1952,6 +2159,11 @@ export async function createWebServer(port = 3000, options = {}) {
1952
2159
  if (!provider || !config) {
1953
2160
  return res.status(400).json({ error: 'provider and config required' });
1954
2161
  }
2162
+ // 如果前端发的是掩码(***xxx),从当前配置里取真实 key
2163
+ const currentConfig = await llmConfigStore.getProvider(provider);
2164
+ if (currentConfig && config.apiKey && config.apiKey.startsWith('***')) {
2165
+ config.apiKey = currentConfig.apiKey;
2166
+ }
1955
2167
  await llmConfigStore.updateProvider(provider, config);
1956
2168
  // 如果是活跃供应商,重新初始化 Pi SDK
1957
2169
  const currentActive = await llmConfigStore.getActiveProvider();
@@ -2010,6 +2222,113 @@ export async function createWebServer(port = 3000, options = {}) {
2010
2222
  res.status(500).json({ error: err.message });
2011
2223
  }
2012
2224
  });
2225
+ // ==================== 视频生成配置 (Seedance 等) ====================
2226
+ // 获取视频生成配置
2227
+ app.get('/api/video-config', async (req, res) => {
2228
+ try {
2229
+ const config = await videoConfigStore.getConfig();
2230
+ const providerInfo = videoConfigStore.getAllProviderInfo();
2231
+ // 脱敏:不返回 apiKey 明文
2232
+ const masked = Object.fromEntries(Object.entries(config.providers).map(([key, val]) => [
2233
+ key,
2234
+ { ...val, apiKey: val.apiKey ? '***' + val.apiKey.slice(-4) : '' }
2235
+ ]));
2236
+ res.json({
2237
+ activeProvider: config.activeProvider,
2238
+ providers: masked,
2239
+ providerInfo
2240
+ });
2241
+ }
2242
+ catch (err) {
2243
+ res.status(500).json({ error: err.message });
2244
+ }
2245
+ });
2246
+ // 更新视频供应商配置
2247
+ app.post('/api/video-config', async (req, res) => {
2248
+ try {
2249
+ const { provider, config } = req.body;
2250
+ if (!provider || !config) {
2251
+ return res.status(400).json({ error: 'provider and config required' });
2252
+ }
2253
+ // 如果前端发的是掩码(***xxx),从当前配置里取真实 key
2254
+ const currentConfig = await videoConfigStore.getProvider(provider);
2255
+ if (currentConfig && config.apiKey && config.apiKey.startsWith('***')) {
2256
+ config.apiKey = currentConfig.apiKey;
2257
+ }
2258
+ await videoConfigStore.updateProvider(provider, config);
2259
+ res.json({ ok: true });
2260
+ }
2261
+ catch (err) {
2262
+ res.status(500).json({ error: err.message });
2263
+ }
2264
+ });
2265
+ // 测试视频供应商连接
2266
+ app.post('/api/video-test', async (req, res) => {
2267
+ try {
2268
+ const { provider } = req.body;
2269
+ if (!provider) {
2270
+ return res.status(400).json({ error: 'provider required' });
2271
+ }
2272
+ const result = await videoConfigStore.testProvider(provider);
2273
+ res.json(result);
2274
+ }
2275
+ catch (err) {
2276
+ res.status(500).json({ error: err.message });
2277
+ }
2278
+ });
2279
+ // ==================== 音频生成配置 (TTS / Music) ====================
2280
+ // 获取音频配置
2281
+ app.get('/api/audio-config', async (req, res) => {
2282
+ try {
2283
+ const config = await audioConfigStore.getConfig();
2284
+ const providerInfo = audioConfigStore.getAllProviderInfo();
2285
+ const masked = Object.fromEntries(Object.entries(config.providers).map(([key, val]) => [
2286
+ key,
2287
+ { ...val, apiKey: val.apiKey ? '***' + val.apiKey.slice(-4) : '' }
2288
+ ]));
2289
+ res.json({
2290
+ activeProvider: config.activeProvider,
2291
+ providers: masked,
2292
+ providerInfo
2293
+ });
2294
+ }
2295
+ catch (err) {
2296
+ res.status(500).json({ error: err.message });
2297
+ }
2298
+ });
2299
+ // 更新音频供应商配置
2300
+ app.post('/api/audio-config', async (req, res) => {
2301
+ try {
2302
+ const { provider, config } = req.body;
2303
+ if (!provider || !config) {
2304
+ return res.status(400).json({ error: 'provider and config required' });
2305
+ }
2306
+ // 掩码回写真实 key
2307
+ const currentConfig = await audioConfigStore.getProvider(provider);
2308
+ if (currentConfig && config.apiKey && config.apiKey.startsWith('***')) {
2309
+ config.apiKey = currentConfig.apiKey;
2310
+ }
2311
+ await audioConfigStore.updateProvider(provider, config);
2312
+ res.json({ ok: true });
2313
+ }
2314
+ catch (err) {
2315
+ res.status(500).json({ error: err.message });
2316
+ }
2317
+ });
2318
+ // 测试音频供应商连接
2319
+ app.post('/api/audio-test', async (req, res) => {
2320
+ try {
2321
+ const { provider } = req.body;
2322
+ if (!provider) {
2323
+ return res.status(400).json({ error: 'provider required' });
2324
+ }
2325
+ const result = await audioConfigStore.testProvider(provider);
2326
+ res.json(result);
2327
+ }
2328
+ catch (err) {
2329
+ res.status(500).json({ error: err.message });
2330
+ }
2331
+ });
2013
2332
  // 统一 AI 解析入口:CLI / 接收方节点 调这里完成 LLM + judgment + harness
2014
2333
  // 入参: { text, mimeType, fileName, fromNodeId, source }
2015
2334
  // 出参: { summary, qualityScore, judgmentId?, gateArtifact? }
@@ -2989,6 +2989,89 @@ body {
2989
2989
  max-width: 900px;
2990
2990
  margin: 0 auto;
2991
2991
  padding: 24px;
2992
+ min-height: 100vh;
2993
+ }
2994
+
2995
+ /* Standalone api-config page: enable page-level scrolling.
2996
+ Default body has overflow:hidden (for app shell with sidebar). */
2997
+ body:has(> .api-config-page) {
2998
+ height: auto;
2999
+ min-height: 100vh;
3000
+ overflow-y: auto;
3001
+ }
3002
+
3003
+ /* Tab switcher */
3004
+ .api-tabs {
3005
+ display: flex;
3006
+ gap: 4px;
3007
+ border-bottom: 1px solid var(--border);
3008
+ margin-bottom: 24px;
3009
+ }
3010
+
3011
+ .api-tab {
3012
+ display: flex;
3013
+ align-items: center;
3014
+ gap: 8px;
3015
+ padding: 12px 20px;
3016
+ background: transparent;
3017
+ border: none;
3018
+ border-bottom: 2px solid transparent;
3019
+ color: var(--text-muted);
3020
+ font-size: 14px;
3021
+ font-weight: 500;
3022
+ cursor: pointer;
3023
+ transition: all 0.2s;
3024
+ }
3025
+
3026
+ .api-tab:hover {
3027
+ color: var(--text);
3028
+ }
3029
+
3030
+ .api-tab.active {
3031
+ color: var(--accent);
3032
+ border-bottom-color: var(--accent);
3033
+ }
3034
+
3035
+ .api-tab-icon {
3036
+ font-size: 16px;
3037
+ }
3038
+
3039
+ .api-panel {
3040
+ animation: fadeIn 0.2s ease;
3041
+ }
3042
+
3043
+ @keyframes fadeIn {
3044
+ from { opacity: 0; transform: translateY(4px); }
3045
+ to { opacity: 1; transform: translateY(0); }
3046
+ }
3047
+
3048
+ .video-intro {
3049
+ background: var(--bg-sidebar);
3050
+ border: 1px solid var(--border);
3051
+ border-radius: var(--radius);
3052
+ padding: 12px 16px;
3053
+ margin-bottom: 16px;
3054
+ color: var(--text-muted);
3055
+ font-size: 13px;
3056
+ line-height: 1.6;
3057
+ }
3058
+
3059
+ .video-intro p {
3060
+ margin: 0;
3061
+ }
3062
+
3063
+ .provider-docs {
3064
+ display: inline-block;
3065
+ margin-top: 4px;
3066
+ font-size: 12px;
3067
+ color: var(--accent);
3068
+ text-decoration: none;
3069
+ opacity: 0.8;
3070
+ }
3071
+
3072
+ .provider-docs:hover {
3073
+ opacity: 1;
3074
+ text-decoration: underline;
2992
3075
  }
2993
3076
 
2994
3077
  .loading-state {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.1.30",
3
+ "version": "0.1.33",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli.js",
@@ -1178,6 +1178,7 @@ ${toolDefs}
1178
1178
  const reply = response.reply.trim();
1179
1179
 
1180
1180
  console.log(`[PiAgent] LLM 回复长度: ${reply.length}, 内容预览: "${reply.substring(0, 80)}..."`);
1181
+ console.log(`[PiAgent] LLM 完整回复:\n${reply}`);
1181
1182
 
1182
1183
  // 通知前端:收到 LLM 回复
1183
1184
  if (onStream) {
@@ -1450,7 +1451,14 @@ Workspace root folder: ${this.cwd}
1450
1451
  const marker = '<final gen>';
1451
1452
  const markerIndex = content.indexOf(marker);
1452
1453
  if (markerIndex !== -1) {
1453
- content = content.substring(markerIndex + marker.length).trim();
1454
+ const after = content.substring(markerIndex + marker.length).trim();
1455
+ // v3 修复: 如果 <final gen> 之后是空, fallback 用 marker 之前的内容 (去掉 marker)
1456
+ // 否则 LLM 写了 <final gen> 在末尾时, 用户看到空回复 + error
1457
+ if (after) {
1458
+ content = after;
1459
+ } else {
1460
+ content = content.substring(0, markerIndex).trim();
1461
+ }
1454
1462
  }
1455
1463
  // 移除任何 tool call 标记
1456
1464
  let cleaned = content
package/src/index.ts CHANGED
@@ -1499,6 +1499,10 @@ function printHelp(): void {
1499
1499
 
1500
1500
  环境变量:
1501
1501
  MINIMAX_API_KEY MiniMax API 密钥
1502
+ DEEPSEEK_API_KEY DeepSeek API 密钥
1503
+ KIMI_API_KEY / MOONSHOT_API_KEY Kimi/Moonshot API 密钥
1504
+ GLM_API_KEY / ZHIPU_API_KEY 智谱 GLM API 密钥
1505
+ QWEN_API_KEY / DASHSCOPE_API_KEY 通义千问 API 密钥
1502
1506
  OPENAI_API_KEY OpenAI API 密钥(Pi SDK)
1503
1507
  ANTHROPIC_API_KEY Anthropic API 密钥(Pi SDK)
1504
1508
  PORT Web 服务端口(默认 54188)
@@ -1563,6 +1567,10 @@ async function main() {
1563
1567
  const hasOpenAI = !!process.env.OPENAI_API_KEY;
1564
1568
  const hasAnthropic = !!process.env.ANTHROPIC_API_KEY;
1565
1569
  const hasMinimax = !!process.env.MINIMAX_API_KEY;
1570
+ const hasDeepSeek = !!process.env.DEEPSEEK_API_KEY;
1571
+ const hasKimi = !!(process.env.KIMI_API_KEY || process.env.MOONSHOT_API_KEY);
1572
+ const hasGlm = !!(process.env.GLM_API_KEY || process.env.ZHIPU_API_KEY);
1573
+ const hasQwen = !!(process.env.QWEN_API_KEY || process.env.DASHSCOPE_API_KEY);
1566
1574
  const hasOpenRouter = !!process.env.OPENROUTER_API_KEY;
1567
1575
  const hasGemini = !!process.env.GEMINI_API_KEY;
1568
1576
  const hasOllama = !!process.env.OLLAMA_BASE_URL;
@@ -1572,7 +1580,11 @@ async function main() {
1572
1580
  hasOpenRouter ? 'OpenRouter' :
1573
1581
  hasGemini ? 'Gemini' :
1574
1582
  hasOllama ? 'Ollama' :
1575
- hasMinimax ? 'MiniMax' : null;
1583
+ hasMinimax ? 'MiniMax' :
1584
+ hasDeepSeek ? 'DeepSeek' :
1585
+ hasKimi ? 'Kimi' :
1586
+ hasGlm ? 'GLM' :
1587
+ hasQwen ? 'Qwen' : null;
1576
1588
 
1577
1589
  if (llmProvider) {
1578
1590
  s.step(0, 4, `LLM: ${llmProvider}`, 'ok');