@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.
- package/dist/agents/pi-sdk.js +10 -1
- package/dist/index.js +13 -1
- package/dist/llm/audio-config-store.js +199 -0
- package/dist/llm/config-store.js +80 -6
- package/dist/llm/pi-ai.js +30 -2
- package/dist/llm/video-config-store.js +201 -0
- package/dist/pi-ecosystem/index.js +1 -1
- package/dist/web/api-config.html +308 -53
- package/dist/web/client.js +375 -8
- package/dist/web/server.js +324 -5
- package/dist/web/style.css +83 -0
- package/package.json +1 -1
- package/src/agents/pi-sdk.ts +9 -1
- package/src/index.ts +13 -1
- package/src/llm/audio-config-store.ts +246 -0
- package/src/llm/config-store.ts +21 -11
- package/src/llm/pi-ai.ts +22 -2
- package/src/llm/video-config-store.ts +257 -0
- package/src/web/api-config.html +308 -53
- package/src/web/client.js +375 -8
- package/src/web/server.ts +354 -5
- package/src/web/style.css +83 -0
package/src/web/server.ts
CHANGED
|
@@ -18,6 +18,8 @@ import { documentReader } from '../documents/reader.js';
|
|
|
18
18
|
import { initMinimax, getMinimax } from '../constraints/index.js';
|
|
19
19
|
import { createAgentSession, type AgentSession, type StreamCallback, type StreamEvent } from '../agents/pi-sdk.js';
|
|
20
20
|
import { llmConfigStore, type ModelProvider, PROVIDER_INFO } from '../llm/config-store.js';
|
|
21
|
+
import { videoConfigStore, type VideoProvider } from '../llm/video-config-store.js';
|
|
22
|
+
import { audioConfigStore, type AudioProvider } from '../llm/audio-config-store.js';
|
|
21
23
|
import { irohTransport } from '../network/iroh-transport.js';
|
|
22
24
|
import { createAgentDelegateApp } from './agent-delegate-server.js';
|
|
23
25
|
import { createIrohDelegateTransport } from './iroh-delegate-transport.js';
|
|
@@ -113,6 +115,13 @@ interface SessionMessage {
|
|
|
113
115
|
type: 'user' | 'ai';
|
|
114
116
|
content: string;
|
|
115
117
|
timestamp: string;
|
|
118
|
+
/** v3: 'local' = channel 内部 owner 发的, 'remote' = 远端访客通过 P2P 发的, 'ai-mention' = 同节点其他 channel 的 AI @-mention, 'ai-mention-remote' = 远端节点的 AI @-mention */
|
|
119
|
+
source?: 'local' | 'remote' | 'ai-mention' | 'ai-mention-remote';
|
|
120
|
+
/** v3: 当 source='remote' 或 'ai-mention-remote' 时记录对方 publicKey */
|
|
121
|
+
fromPublicKey?: string;
|
|
122
|
+
/** v3: 当 source 是 ai-mention* 时, 是哪个 channel 触发的 */
|
|
123
|
+
originChannelId?: string;
|
|
124
|
+
originChannelName?: string;
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
interface Session {
|
|
@@ -464,6 +473,118 @@ function isSharedWith(ch: Channel, peerPublicKey: string): boolean {
|
|
|
464
473
|
return shared.includes(peerPublicKey);
|
|
465
474
|
}
|
|
466
475
|
|
|
476
|
+
/**
|
|
477
|
+
* v3 新增: 解析 LLM 回复里的 @-mentions, 把消息发到目标 channel.
|
|
478
|
+
*
|
|
479
|
+
* 语法: "@渠道名 消息内容" — 渠道名匹配 local channels by name, 或 remote channels by name.
|
|
480
|
+
* - 本地 channel: 直接 push 到 session
|
|
481
|
+
* - 远端 channel: 通过 P2P RPC 转发到对端
|
|
482
|
+
*
|
|
483
|
+
* 返回: 解析到的 mention 列表, 供 SSE 广播
|
|
484
|
+
*/
|
|
485
|
+
async function routeMentionsInReply(
|
|
486
|
+
originChannelId: string,
|
|
487
|
+
replyText: string,
|
|
488
|
+
localChannels: any[],
|
|
489
|
+
remoteChannels: any[]
|
|
490
|
+
): Promise<Array<{ targetName: string; targetId: string; source: 'local' | 'remote'; text: string; status: 'sent' | 'failed' }>> {
|
|
491
|
+
const results: any[] = [];
|
|
492
|
+
// 解析: 匹配 @渠道名 后面跟一段文字 (到下一个 @ 或 行尾)
|
|
493
|
+
// 渠道名: 中文/英文/数字/下划线/连字符, 1-30 字符
|
|
494
|
+
const regex = /@([一-龥A-Za-z0-9_\-]{1,30})\s+([^\n@]+?)(?=(?:\s*@[一-龥A-Za-z0-9_\-]{1,30}\s)|$)/g;
|
|
495
|
+
const matches = [...replyText.matchAll(regex)];
|
|
496
|
+
|
|
497
|
+
if (matches.length === 0) return results;
|
|
498
|
+
|
|
499
|
+
// 找当前 channel 的 name (用于日志)
|
|
500
|
+
let originChannelName = originChannelId;
|
|
501
|
+
try {
|
|
502
|
+
const chs = await loadChannels();
|
|
503
|
+
const oc = chs.find(c => c.id === originChannelId);
|
|
504
|
+
if (oc) originChannelName = oc.name;
|
|
505
|
+
} catch {}
|
|
506
|
+
|
|
507
|
+
console.log(`[v3-cross] (${originChannelName}) 解析到 ${matches.length} 个 @-mention`);
|
|
508
|
+
|
|
509
|
+
for (const m of matches) {
|
|
510
|
+
const targetName = m[1].trim();
|
|
511
|
+
const text = m[2].trim();
|
|
512
|
+
if (!text) continue;
|
|
513
|
+
|
|
514
|
+
// 优先本地 (本地 channel 不能有 ownerPublicKey)
|
|
515
|
+
const localTarget = localChannels.find(c => c.name === targetName);
|
|
516
|
+
const remoteTarget = !localTarget ? remoteChannels.find(c => c.name === targetName) : null;
|
|
517
|
+
|
|
518
|
+
if (localTarget) {
|
|
519
|
+
// 本地: 直接 push 到 session
|
|
520
|
+
try {
|
|
521
|
+
const existing = await loadSession(localTarget.id, 'default');
|
|
522
|
+
const session: Session = existing || {
|
|
523
|
+
channelId: localTarget.id, sessionId: 'default', messages: [], lastUpdated: new Date().toISOString()
|
|
524
|
+
};
|
|
525
|
+
session.messages.push({
|
|
526
|
+
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
527
|
+
type: 'ai' as const,
|
|
528
|
+
content: text,
|
|
529
|
+
timestamp: new Date().toISOString(),
|
|
530
|
+
source: 'ai-mention' as any, // v3: 标记是其他 channel 的 AI @-mention 进来的
|
|
531
|
+
originChannelId, // 谁 @ 过来的
|
|
532
|
+
originChannelName // 渠道名 (方便显示)
|
|
533
|
+
});
|
|
534
|
+
session.lastUpdated = new Date().toISOString();
|
|
535
|
+
await saveSession(session);
|
|
536
|
+
console.log(`[v3-cross] (${originChannelName}) @${targetName} → 本地 channel ${localTarget.id}, 存了 ${text.length} chars`);
|
|
537
|
+
// 推 SSE 让本地 UI 知道有 AI 跨渠道消息
|
|
538
|
+
broadcast({
|
|
539
|
+
type: 'cross-mention-received',
|
|
540
|
+
originChannelId, originChannelName,
|
|
541
|
+
targetChannelId: localTarget.id, targetChannelName: localTarget.name,
|
|
542
|
+
text, source: 'ai-mention'
|
|
543
|
+
}, 'broadcast');
|
|
544
|
+
results.push({ targetName, targetId: localTarget.id, source: 'local', text, status: 'sent' });
|
|
545
|
+
} catch (err) {
|
|
546
|
+
console.error(`[v3-cross] @${targetName} 本地存失败:`, (err as Error).message);
|
|
547
|
+
results.push({ targetName, targetId: localTarget.id, source: 'local', text, status: 'failed' });
|
|
548
|
+
}
|
|
549
|
+
} else if (remoteTarget) {
|
|
550
|
+
// 远端: 通过 P2P RPC 转发
|
|
551
|
+
const ownerPk = remoteTarget._ownerPublicKey;
|
|
552
|
+
if (!v3P2PRef) {
|
|
553
|
+
console.warn(`[v3-cross] P2PDirect 未启动, 跳过远端 @${targetName}`);
|
|
554
|
+
results.push({ targetName, targetId: remoteTarget.id, source: 'remote', text, status: 'failed' });
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
const rpc = JSON.stringify({
|
|
559
|
+
v: 3, op: 'agent.cross.post',
|
|
560
|
+
payload: {
|
|
561
|
+
targetChannelId: remoteTarget.id,
|
|
562
|
+
targetChannelName: remoteTarget.name,
|
|
563
|
+
originChannelId,
|
|
564
|
+
originChannelName,
|
|
565
|
+
text,
|
|
566
|
+
fromPublicKey: v3P2PRef.getPublicKey()
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
const ok = v3P2PRef.sendTo(ownerPk, rpc);
|
|
570
|
+
if (ok) {
|
|
571
|
+
console.log(`[v3-cross] (${originChannelName}) @${targetName} → 远端 peer ${ownerPk.substring(0,12)}... (channelId=${remoteTarget.id})`);
|
|
572
|
+
results.push({ targetName, targetId: remoteTarget.id, source: 'remote', text, status: 'sent' });
|
|
573
|
+
} else {
|
|
574
|
+
results.push({ targetName, targetId: remoteTarget.id, source: 'remote', text, status: 'failed' });
|
|
575
|
+
}
|
|
576
|
+
} catch (err) {
|
|
577
|
+
console.error(`[v3-cross] @${targetName} 远端 RPC 失败:`, (err as Error).message);
|
|
578
|
+
results.push({ targetName, targetId: remoteTarget.id, source: 'remote', text, status: 'failed' });
|
|
579
|
+
}
|
|
580
|
+
} else {
|
|
581
|
+
console.warn(`[v3-cross] @${targetName} 找不到匹配 channel (本地 ${localChannels.length} 个, 远端 ${remoteChannels.length} 个)`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return results;
|
|
586
|
+
}
|
|
587
|
+
|
|
467
588
|
/**
|
|
468
589
|
* v3: 处理 Hyperswarm 通道收到的 v3 RPC 消息
|
|
469
590
|
* 设计: 用 HyperswarmCommunicator (DHT topic 自动发现) 取代 iroh 直接 connect
|
|
@@ -574,11 +695,13 @@ async function handleV3P2PMessage(parsed: any, conn: P2PConnection, comm: Hypers
|
|
|
574
695
|
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
575
696
|
type: 'user',
|
|
576
697
|
content: text,
|
|
577
|
-
timestamp: new Date().toISOString()
|
|
698
|
+
timestamp: new Date().toISOString(),
|
|
699
|
+
source: 'remote', // v3: 标记远端访客
|
|
700
|
+
fromPublicKey: senderKey // v3: 记录对方 publicKey
|
|
578
701
|
});
|
|
579
702
|
session.lastUpdated = new Date().toISOString();
|
|
580
703
|
await saveSession(session);
|
|
581
|
-
console.log(`[v3] (${channelId}) 存 user 消息 (${text.length} chars) 到 A 的 session`);
|
|
704
|
+
console.log(`[v3] (${channelId}) 存 user 消息 (${text.length} chars) 到 A 的 session (来自 ${senderKey.substring(0,12)}...)`);
|
|
582
705
|
} catch (saveErr) {
|
|
583
706
|
console.warn(`[v3] 存 user 消息失败 (不影响 chat):`, (saveErr as Error).message);
|
|
584
707
|
}
|
|
@@ -604,7 +727,29 @@ async function handleV3P2PMessage(parsed: any, conn: P2PConnection, comm: Hypers
|
|
|
604
727
|
// 2. 跑 LLM (复用 Phase 1 的 buildJudgmentHint — 注入 channel 的 judgment)
|
|
605
728
|
const { getMinimax } = await import('../constraints/index.js');
|
|
606
729
|
const llm = getMinimax();
|
|
607
|
-
|
|
730
|
+
// v3 新增: 在 prompt 头部标记"这是远端访客", 让 AI 知道对方不是自己 owner
|
|
731
|
+
const visitorHint = `[系统上下文] 消息来源: 远端访客 (P2P 连接, publicKey=${senderKey.substring(0, 12)}...). 对方不是你 owner, 是通过 P2P 网络访问你这个 channel 的合作者. 称呼对方时可用 "远端访客" / "朋友" / "合作者", 不要叫 "主人".\n\n`;
|
|
732
|
+
// v3 新增: 也注入 channel 目录给 LLM (B 的 channel 也可以 @-mention 其他)
|
|
733
|
+
let dirHint = '';
|
|
734
|
+
const localChannels = (await loadChannels()).filter(c => c.id !== channelId);
|
|
735
|
+
const remoteChannels: any[] = [];
|
|
736
|
+
for (const [peerPk, list] of remoteChannelCache.entries()) {
|
|
737
|
+
if (peerPk === senderKey) continue; // 跳过发起方
|
|
738
|
+
for (const ch of list) {
|
|
739
|
+
remoteChannels.push({ ...ch, _ownerPublicKey: peerPk });
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (localChannels.length > 0 || remoteChannels.length > 0) {
|
|
743
|
+
dirHint += '[系统上下文] 可用渠道 (你可以用 @渠道名 消息内容 给它们发消息):\n';
|
|
744
|
+
for (const c of localChannels) {
|
|
745
|
+
dirHint += ` - [本地] @${c.name} (id=${c.id})\n`;
|
|
746
|
+
}
|
|
747
|
+
for (const c of remoteChannels) {
|
|
748
|
+
dirHint += ` - [远端, owner=${(c._ownerPublicKey || '').substring(0,8)}…] @${c.name} (id=${c.id})\n`;
|
|
749
|
+
}
|
|
750
|
+
dirHint += '语法: 在回复中写 "@渠道名 我要说的话" 即可. 消息会持久化到目标 channel 的 session.\n\n';
|
|
751
|
+
}
|
|
752
|
+
const fullPrompt = `${visitorHint}${dirHint}${judgmentHint}${text}`;
|
|
608
753
|
let fullResponse = '';
|
|
609
754
|
// v3 新增: 流式 token 节流推给 B — 让 B 看到过程
|
|
610
755
|
let lastFlushAt = 0;
|
|
@@ -726,6 +871,53 @@ async function handleV3P2PMessage(parsed: any, conn: P2PConnection, comm: Hypers
|
|
|
726
871
|
return;
|
|
727
872
|
}
|
|
728
873
|
|
|
874
|
+
// v3 新增: 收到远端发来的 @-mention 跨渠道消息, 存到本地 target channel
|
|
875
|
+
if (op === 'agent.cross.post') {
|
|
876
|
+
const { targetChannelId, targetChannelName, originChannelId, originChannelName, text, fromPublicKey } = parsed.payload || {};
|
|
877
|
+
if (!targetChannelId || !text) {
|
|
878
|
+
console.warn(`[v3-cross] agent.cross.post 缺少 targetChannelId/text`);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
try {
|
|
882
|
+
// 找 channel — 必须存在于本节点
|
|
883
|
+
const channels = await loadChannels();
|
|
884
|
+
const ch = channels.find(c => c.id === targetChannelId);
|
|
885
|
+
if (!ch) {
|
|
886
|
+
console.warn(`[v3-cross] agent.cross.post: 本节点无 channel ${targetChannelId}, 忽略`);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
// 存到 session — 这是一条来自其他节点的 LLM @-mention
|
|
890
|
+
const existing = await loadSession(targetChannelId, 'default');
|
|
891
|
+
const session: Session = existing || {
|
|
892
|
+
channelId: targetChannelId, sessionId: 'default', messages: [], lastUpdated: new Date().toISOString()
|
|
893
|
+
};
|
|
894
|
+
session.messages.push({
|
|
895
|
+
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
896
|
+
type: 'ai' as const,
|
|
897
|
+
content: text,
|
|
898
|
+
timestamp: new Date().toISOString(),
|
|
899
|
+
source: 'ai-mention-remote' as any, // v3: 来自其他节点的 AI @-mention
|
|
900
|
+
originChannelId, // 哪个 channel 触发的
|
|
901
|
+
originChannelName,
|
|
902
|
+
fromPublicKey // 哪个节点来的
|
|
903
|
+
});
|
|
904
|
+
session.lastUpdated = new Date().toISOString();
|
|
905
|
+
await saveSession(session);
|
|
906
|
+
console.log(`[v3-cross] 收到远端 @-mention: ${originChannelName} → 本地 ${targetChannelName} (${text.length} chars)`);
|
|
907
|
+
// 推 SSE 让本地 UI 知道有跨渠道消息到达
|
|
908
|
+
broadcast({
|
|
909
|
+
type: 'cross-mention-received',
|
|
910
|
+
originChannelId, originChannelName,
|
|
911
|
+
targetChannelId, targetChannelName: ch.name,
|
|
912
|
+
text, source: 'ai-mention-remote',
|
|
913
|
+
fromPublicKey
|
|
914
|
+
}, 'broadcast');
|
|
915
|
+
} catch (err) {
|
|
916
|
+
console.error(`[v3-cross] 处理 agent.cross.post 失败:`, (err as Error).message);
|
|
917
|
+
}
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
|
|
729
921
|
console.log(`[v3] 收到未知 op: ${op}`);
|
|
730
922
|
}
|
|
731
923
|
|
|
@@ -1255,6 +1447,8 @@ export async function createWebServer(port: number = 3000, options: CreateWebSer
|
|
|
1255
1447
|
// 将真实 DID 作为上下文前缀,让 AI 使用真实的 DID 而不是自己编造的
|
|
1256
1448
|
let contextHint = '';
|
|
1257
1449
|
if (realChannelDid) contextHint += `[系统上下文] 当前频道名称: ${realChannelName}, 你的真实 DID: ${realChannelDid}\n`;
|
|
1450
|
+
// v3 新增: 标识发送方 — 让 AI 分清内部 owner vs 远端访客
|
|
1451
|
+
contextHint += `[系统上下文] 消息来源: 本地 (channel 内部 owner / 此机器上的用户). 称呼对方时用 "你" 或 "主人" 即可.\n`;
|
|
1258
1452
|
if (boundWalletAddress) {
|
|
1259
1453
|
contextHint += `[系统上下文] 已绑定的加密钱包地址: ${boundWalletAddress}。当用户授权或启用自动工具调用时, 可使用该地址发起链上操作。\n`;
|
|
1260
1454
|
}
|
|
@@ -1269,16 +1463,41 @@ export async function createWebServer(port: number = 3000, options: CreateWebSer
|
|
|
1269
1463
|
const judgmentHint = await buildJudgmentHint(channelForJudgment, channelId);
|
|
1270
1464
|
if (judgmentHint) contextHint += judgmentHint;
|
|
1271
1465
|
|
|
1466
|
+
// v3 新增: 注入"可用渠道"目录, 让 LLM 知道可以 @-mention 哪些 channel
|
|
1467
|
+
// - 本地 channels (除了自己)
|
|
1468
|
+
// - 远端 channels (remoteChannelCache 缓存的)
|
|
1469
|
+
const localChannels = (await loadChannels()).filter(c => c.id !== channelId);
|
|
1470
|
+
const remoteChannels: any[] = [];
|
|
1471
|
+
for (const [peerPk, list] of remoteChannelCache.entries()) {
|
|
1472
|
+
for (const ch of list) {
|
|
1473
|
+
remoteChannels.push({ ...ch, _ownerPublicKey: peerPk });
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
if (localChannels.length > 0 || remoteChannels.length > 0) {
|
|
1477
|
+
contextHint += '[系统上下文] 可用渠道 (你可以用 @渠道名 消息内容 给它们发消息):\n';
|
|
1478
|
+
for (const c of localChannels) {
|
|
1479
|
+
contextHint += ` - [本地] @${c.name} (id=${c.id})\n`;
|
|
1480
|
+
}
|
|
1481
|
+
for (const c of remoteChannels) {
|
|
1482
|
+
contextHint += ` - [远端, owner=${(c._ownerPublicKey || '').substring(0,8)}…] @${c.name} (id=${c.id})\n`;
|
|
1483
|
+
}
|
|
1484
|
+
contextHint += '语法: 当你想给其他渠道发消息, 在回复中写 "@渠道名 我要说的话" 即可. 消息会持久化到目标 channel 的 session, 你之后能看到"自己"在那里说的话.\n\n';
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1272
1487
|
if (contextHint) contextHint += '\n';
|
|
1273
1488
|
fullResponse = await agent.promptStream(contextHint + text, streamCallback);
|
|
1274
1489
|
|
|
1490
|
+
// v3 新增: 解析 LLM 回复里的 @-mentions, 转发到目标 channel
|
|
1491
|
+
await routeMentionsInReply(channelId, fullResponse, localChannels, remoteChannels);
|
|
1492
|
+
|
|
1275
1493
|
broadcast({ type: 'ai', content: fullResponse }, channelId);
|
|
1276
1494
|
|
|
1277
1495
|
const existingSession = await loadSession(channelId, currentSessionId);
|
|
1278
1496
|
const session: Session = existingSession || { channelId, sessionId: currentSessionId, messages: [], lastUpdated: new Date().toISOString() };
|
|
1279
1497
|
session.sessionId = currentSessionId;
|
|
1280
|
-
|
|
1281
|
-
session.messages.push({ id: crypto.randomUUID(), type: '
|
|
1498
|
+
// v3: 加 source 标记 (local = 内部 owner, remote = 远端访客)
|
|
1499
|
+
session.messages.push({ id: crypto.randomUUID(), type: 'user' as const, content: text, timestamp: new Date().toISOString(), source: 'local' as any });
|
|
1500
|
+
session.messages.push({ id: crypto.randomUUID(), type: 'ai' as const, content: fullResponse, timestamp: new Date().toISOString(), source: 'local' as any });
|
|
1282
1501
|
session.lastUpdated = new Date().toISOString();
|
|
1283
1502
|
await saveSession(session);
|
|
1284
1503
|
|
|
@@ -2210,6 +2429,12 @@ app.get('/channels', async (_req, res) => {
|
|
|
2210
2429
|
return res.status(400).json({ error: 'provider and config required' });
|
|
2211
2430
|
}
|
|
2212
2431
|
|
|
2432
|
+
// 如果前端发的是掩码(***xxx),从当前配置里取真实 key
|
|
2433
|
+
const currentConfig = await llmConfigStore.getProvider(provider as ModelProvider);
|
|
2434
|
+
if (currentConfig && config.apiKey && config.apiKey.startsWith('***')) {
|
|
2435
|
+
config.apiKey = currentConfig.apiKey;
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2213
2438
|
await llmConfigStore.updateProvider(provider, config);
|
|
2214
2439
|
|
|
2215
2440
|
// 如果是活跃供应商,重新初始化 Pi SDK
|
|
@@ -2276,6 +2501,130 @@ app.get('/channels', async (_req, res) => {
|
|
|
2276
2501
|
}
|
|
2277
2502
|
});
|
|
2278
2503
|
|
|
2504
|
+
// ==================== 视频生成配置 (Seedance 等) ====================
|
|
2505
|
+
|
|
2506
|
+
// 获取视频生成配置
|
|
2507
|
+
app.get('/api/video-config', async (req, res) => {
|
|
2508
|
+
try {
|
|
2509
|
+
const config = await videoConfigStore.getConfig();
|
|
2510
|
+
const providerInfo = videoConfigStore.getAllProviderInfo();
|
|
2511
|
+
|
|
2512
|
+
// 脱敏:不返回 apiKey 明文
|
|
2513
|
+
const masked = Object.fromEntries(
|
|
2514
|
+
Object.entries(config.providers).map(([key, val]) => [
|
|
2515
|
+
key,
|
|
2516
|
+
{ ...val, apiKey: val.apiKey ? '***' + val.apiKey.slice(-4) : '' }
|
|
2517
|
+
])
|
|
2518
|
+
);
|
|
2519
|
+
|
|
2520
|
+
res.json({
|
|
2521
|
+
activeProvider: config.activeProvider,
|
|
2522
|
+
providers: masked,
|
|
2523
|
+
providerInfo
|
|
2524
|
+
});
|
|
2525
|
+
} catch (err: any) {
|
|
2526
|
+
res.status(500).json({ error: err.message });
|
|
2527
|
+
}
|
|
2528
|
+
});
|
|
2529
|
+
|
|
2530
|
+
// 更新视频供应商配置
|
|
2531
|
+
app.post('/api/video-config', async (req, res) => {
|
|
2532
|
+
try {
|
|
2533
|
+
const { provider, config } = req.body;
|
|
2534
|
+
|
|
2535
|
+
if (!provider || !config) {
|
|
2536
|
+
return res.status(400).json({ error: 'provider and config required' });
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
// 如果前端发的是掩码(***xxx),从当前配置里取真实 key
|
|
2540
|
+
const currentConfig = await videoConfigStore.getProvider(provider as VideoProvider);
|
|
2541
|
+
if (currentConfig && config.apiKey && config.apiKey.startsWith('***')) {
|
|
2542
|
+
config.apiKey = currentConfig.apiKey;
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
await videoConfigStore.updateProvider(provider as VideoProvider, config);
|
|
2546
|
+
res.json({ ok: true });
|
|
2547
|
+
} catch (err: any) {
|
|
2548
|
+
res.status(500).json({ error: err.message });
|
|
2549
|
+
}
|
|
2550
|
+
});
|
|
2551
|
+
|
|
2552
|
+
// 测试视频供应商连接
|
|
2553
|
+
app.post('/api/video-test', async (req, res) => {
|
|
2554
|
+
try {
|
|
2555
|
+
const { provider } = req.body;
|
|
2556
|
+
|
|
2557
|
+
if (!provider) {
|
|
2558
|
+
return res.status(400).json({ error: 'provider required' });
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
const result = await videoConfigStore.testProvider(provider as VideoProvider);
|
|
2562
|
+
res.json(result);
|
|
2563
|
+
} catch (err: any) {
|
|
2564
|
+
res.status(500).json({ error: err.message });
|
|
2565
|
+
}
|
|
2566
|
+
});
|
|
2567
|
+
|
|
2568
|
+
// ==================== 音频生成配置 (TTS / Music) ====================
|
|
2569
|
+
|
|
2570
|
+
// 获取音频配置
|
|
2571
|
+
app.get('/api/audio-config', async (req, res) => {
|
|
2572
|
+
try {
|
|
2573
|
+
const config = await audioConfigStore.getConfig();
|
|
2574
|
+
const providerInfo = audioConfigStore.getAllProviderInfo();
|
|
2575
|
+
|
|
2576
|
+
const masked = Object.fromEntries(
|
|
2577
|
+
Object.entries(config.providers).map(([key, val]) => [
|
|
2578
|
+
key,
|
|
2579
|
+
{ ...val, apiKey: val.apiKey ? '***' + val.apiKey.slice(-4) : '' }
|
|
2580
|
+
])
|
|
2581
|
+
);
|
|
2582
|
+
|
|
2583
|
+
res.json({
|
|
2584
|
+
activeProvider: config.activeProvider,
|
|
2585
|
+
providers: masked,
|
|
2586
|
+
providerInfo
|
|
2587
|
+
});
|
|
2588
|
+
} catch (err: any) {
|
|
2589
|
+
res.status(500).json({ error: err.message });
|
|
2590
|
+
}
|
|
2591
|
+
});
|
|
2592
|
+
|
|
2593
|
+
// 更新音频供应商配置
|
|
2594
|
+
app.post('/api/audio-config', async (req, res) => {
|
|
2595
|
+
try {
|
|
2596
|
+
const { provider, config } = req.body;
|
|
2597
|
+
if (!provider || !config) {
|
|
2598
|
+
return res.status(400).json({ error: 'provider and config required' });
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
// 掩码回写真实 key
|
|
2602
|
+
const currentConfig = await audioConfigStore.getProvider(provider as AudioProvider);
|
|
2603
|
+
if (currentConfig && config.apiKey && config.apiKey.startsWith('***')) {
|
|
2604
|
+
config.apiKey = currentConfig.apiKey;
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
await audioConfigStore.updateProvider(provider as AudioProvider, config);
|
|
2608
|
+
res.json({ ok: true });
|
|
2609
|
+
} catch (err: any) {
|
|
2610
|
+
res.status(500).json({ error: err.message });
|
|
2611
|
+
}
|
|
2612
|
+
});
|
|
2613
|
+
|
|
2614
|
+
// 测试音频供应商连接
|
|
2615
|
+
app.post('/api/audio-test', async (req, res) => {
|
|
2616
|
+
try {
|
|
2617
|
+
const { provider } = req.body;
|
|
2618
|
+
if (!provider) {
|
|
2619
|
+
return res.status(400).json({ error: 'provider required' });
|
|
2620
|
+
}
|
|
2621
|
+
const result = await audioConfigStore.testProvider(provider as AudioProvider);
|
|
2622
|
+
res.json(result);
|
|
2623
|
+
} catch (err: any) {
|
|
2624
|
+
res.status(500).json({ error: err.message });
|
|
2625
|
+
}
|
|
2626
|
+
});
|
|
2627
|
+
|
|
2279
2628
|
// 统一 AI 解析入口:CLI / 接收方节点 调这里完成 LLM + judgment + harness
|
|
2280
2629
|
// 入参: { text, mimeType, fileName, fromNodeId, source }
|
|
2281
2630
|
// 出参: { summary, qualityScore, judgmentId?, gateArtifact? }
|
package/src/web/style.css
CHANGED
|
@@ -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 {
|