@bolloon/bolloon-agent 0.1.24 → 0.1.26
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/web/client.js +295 -6
- package/dist/web/index.html +32 -2
- package/dist/web/server.js +221 -6
- package/dist/web/style.css +35 -0
- package/package.json +1 -1
- package/src/web/client.js +295 -6
- package/src/web/index.html +32 -2
- package/src/web/server.ts +240 -6
- package/src/web/style.css +35 -0
package/src/web/server.ts
CHANGED
|
@@ -72,6 +72,8 @@ interface Channel {
|
|
|
72
72
|
updatedAt: string;
|
|
73
73
|
currentSessionId?: string;
|
|
74
74
|
sessions?: SessionSummary[];
|
|
75
|
+
/** 用户在盾牌里手动绑定的判断力 (LLM 跑 channel 时会注入). 默认 []. */
|
|
76
|
+
bound_judgment_ids?: string[];
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
interface SessionSummary {
|
|
@@ -380,9 +382,93 @@ interface SSEClient {
|
|
|
380
382
|
}
|
|
381
383
|
|
|
382
384
|
let sseClients: Set<SSEClient> = new Set();
|
|
385
|
+
// v3: 远端 channel UI 元数据缓存 — key: peerId, value: sanitize 过的 channel 列表
|
|
386
|
+
// in-memory only, 进程重启清空 (judgment 内容永远不在这里)
|
|
387
|
+
let remoteChannelCache: Map<string, Array<Record<string, unknown>>> = new Map();
|
|
383
388
|
let channelSessions: Map<string, AgentSession> = new Map(); // key: channelId
|
|
384
389
|
let sessionMessages: Map<string, any[]> = new Map(); // key: channelId + sessionId
|
|
385
390
|
|
|
391
|
+
/**
|
|
392
|
+
* v3 重做: 构造 channel 的两路 judgment prompt 片段
|
|
393
|
+
* 路 1: 用户在盾牌里手动绑定的 judgment (channel.bound_judgment_ids)
|
|
394
|
+
* 路 2: 全局 judgment 列表 (供 LLM 在主调用中按需挑选, 写入回复)
|
|
395
|
+
* 返回 "" 表示完全没数据; 否则返回完整 "[系统上下文] ..." 块 (含尾部换行)
|
|
396
|
+
* 失败非致命 — 任何异常都返回空串, 保证 LLM 调用不被阻塞
|
|
397
|
+
*/
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* v3: 过滤 channel 元数据, 只返回对远端 peer 安全的字段.
|
|
401
|
+
* 关键: bound_judgment_ids / walletBinding / autoInvokeTools 内部状态不外传.
|
|
402
|
+
* judgment 内容永远不会出现在 RPC 响应里 (judgment 始终在 A 节点内存, 由 A 跑 LLM).
|
|
403
|
+
*/
|
|
404
|
+
function sanitizeChannelForPeer(ch: Channel): Record<string, unknown> {
|
|
405
|
+
return {
|
|
406
|
+
id: ch.id,
|
|
407
|
+
name: ch.name,
|
|
408
|
+
did: ch.did,
|
|
409
|
+
publicKey: ch.publicKey,
|
|
410
|
+
createdAt: ch.createdAt,
|
|
411
|
+
updatedAt: ch.updatedAt,
|
|
412
|
+
hasWallet: !!ch.walletAddress, // 只告诉 B "有没有钱包", 不传地址
|
|
413
|
+
boundJudgmentCount: Array.isArray(ch.bound_judgment_ids) ? ch.bound_judgment_ids.length : 0,
|
|
414
|
+
// 🔒 不返回: bound_judgment_ids, walletAddress, walletBinding, autoInvokeTools, sessions
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function buildJudgmentHint(
|
|
419
|
+
channel: Channel | undefined | null,
|
|
420
|
+
channelIdForLog: string
|
|
421
|
+
): Promise<string> {
|
|
422
|
+
try {
|
|
423
|
+
const { loadAllJudgments, initializeValueStore } = await import(
|
|
424
|
+
'../pi-ecosystem-judgment/human-value-store.js'
|
|
425
|
+
);
|
|
426
|
+
await initializeValueStore();
|
|
427
|
+
const allJudgments = await loadAllJudgments();
|
|
428
|
+
if (allJudgments.length === 0) return '';
|
|
429
|
+
|
|
430
|
+
const boundIds = new Set(
|
|
431
|
+
channel && Array.isArray(channel.bound_judgment_ids) ? channel.bound_judgment_ids : []
|
|
432
|
+
);
|
|
433
|
+
const bound = allJudgments.filter(j => j.id !== undefined && boundIds.has(j.id));
|
|
434
|
+
const others = allJudgments.filter(j => j.id !== undefined && !boundIds.has(j.id));
|
|
435
|
+
|
|
436
|
+
let hint = '';
|
|
437
|
+
|
|
438
|
+
// 路 1: 用户手动绑定的 judgment — 硬约束, 必须遵循
|
|
439
|
+
if (bound.length > 0) {
|
|
440
|
+
hint += `[系统上下文] 此 channel 用户绑定了 ${bound.length} 条判断力, 必须严格遵循:\n`;
|
|
441
|
+
for (const j of bound) {
|
|
442
|
+
const decision = (j.decision || '').toString().slice(0, 200);
|
|
443
|
+
const reasonList = Array.isArray(j.reasons) ? j.reasons : [];
|
|
444
|
+
const reasonText = reasonList.length > 0
|
|
445
|
+
? ` (理由: ${reasonList.join('; ').slice(0, 100)})`
|
|
446
|
+
: '';
|
|
447
|
+
hint += `- ${decision}${reasonText}\n`;
|
|
448
|
+
}
|
|
449
|
+
hint += '\n';
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// 路 2: 全局 judgment 候选池 — 软参考, LLM 自己挑
|
|
453
|
+
if (others.length > 0) {
|
|
454
|
+
hint += `[系统上下文] 候选判断力 (用户未明确绑定, 你可以按相关性自主选择参考):\n`;
|
|
455
|
+
for (const j of others) {
|
|
456
|
+
const decision = (j.decision || '').toString().slice(0, 120);
|
|
457
|
+
hint += `- [id=${j.id}] ${decision}\n`;
|
|
458
|
+
}
|
|
459
|
+
hint += `\n[系统上下文] 如果你的回复参考了某条候选判断力, 请在回复中自然提及 "我参考了你的判断: <decision 简述>" 即可, 无需复述 id.\n\n`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
console.log(
|
|
463
|
+
`[v3] channel ${channelIdForLog} 注入: 绑定 ${bound.length} 条, 候选 ${others.length} 条`
|
|
464
|
+
);
|
|
465
|
+
return hint;
|
|
466
|
+
} catch (err) {
|
|
467
|
+
console.error(`[v3] 加载判断力失败 (非致命):`, (err as Error).message);
|
|
468
|
+
return '';
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
386
472
|
async function getAgentForChannel(
|
|
387
473
|
channelId: string,
|
|
388
474
|
channelDid?: string,
|
|
@@ -640,6 +726,8 @@ export async function createWebServer(port: number = 3000, options: CreateWebSer
|
|
|
640
726
|
// (line ~638) 与这里外层的 const channel 形成 shadowing 让 TS 误报"使用前未声明"
|
|
641
727
|
const boundWalletAddress = channel?.walletAddress;
|
|
642
728
|
const autoToolsEnabled = channel?.autoInvokeTools !== false; // 默认开启
|
|
729
|
+
// 捕获外层 channel 到独立变量, 避免被 try 块内 (line 740+) 的 const channel 遮蔽
|
|
730
|
+
const channelForJudgment = channel;
|
|
643
731
|
|
|
644
732
|
try {
|
|
645
733
|
const agent = await getAgentForChannel(channelId, realChannelDid, realChannelName, realChannelDidDoc);
|
|
@@ -675,6 +763,12 @@ export async function createWebServer(port: number = 3000, options: CreateWebSer
|
|
|
675
763
|
} else {
|
|
676
764
|
contextHint += `[系统上下文] 自动工具调用已关闭: 每次执行工具前必须先与用户确认。\n`;
|
|
677
765
|
}
|
|
766
|
+
|
|
767
|
+
// v3: 注入 channel 绑定的判断力 (judgment_ids)
|
|
768
|
+
// 这是 v3 的核心 — channel 跑 LLM 时, 它的判断力 = 绑定的 judgment 列表
|
|
769
|
+
const judgmentHint = await buildJudgmentHint(channelForJudgment, channelId);
|
|
770
|
+
if (judgmentHint) contextHint += judgmentHint;
|
|
771
|
+
|
|
678
772
|
if (contextHint) contextHint += '\n';
|
|
679
773
|
fullResponse = await agent.promptStream(contextHint + text, streamCallback);
|
|
680
774
|
|
|
@@ -833,10 +927,47 @@ app.get('/channels', async (_req, res) => {
|
|
|
833
927
|
}
|
|
834
928
|
});
|
|
835
929
|
|
|
930
|
+
// v3: 列出本节点缓存的远端 channel (按 peerId 分组)
|
|
931
|
+
app.get('/api/remote-channels', async (_req, res) => {
|
|
932
|
+
try {
|
|
933
|
+
const out: Array<{ peerId: string; channels: Array<Record<string, unknown>> }> = [];
|
|
934
|
+
for (const [peerId, list] of remoteChannelCache.entries()) {
|
|
935
|
+
out.push({ peerId, channels: list });
|
|
936
|
+
}
|
|
937
|
+
res.json({ count: out.length, peers: out });
|
|
938
|
+
} catch (err: any) {
|
|
939
|
+
res.status(500).json({ error: err.message });
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
// v3: 主动向所有已连接 P2P peer 拉 channel 列表
|
|
944
|
+
// 用法: B 端用户点 "刷新远端智能体" → 触发本 endpoint
|
|
945
|
+
app.post('/api/remote-channels/refresh', async (_req, res) => {
|
|
946
|
+
try {
|
|
947
|
+
const peers = irohTransport.getPeers ? irohTransport.getPeers() : [];
|
|
948
|
+
const peerIds = Array.isArray(peers) ? peers.map((p: any) => p.nodeId || p) : [];
|
|
949
|
+
if (peerIds.length === 0) {
|
|
950
|
+
return res.json({ ok: true, sent: 0, note: 'no connected peers' });
|
|
951
|
+
}
|
|
952
|
+
let sent = 0;
|
|
953
|
+
for (const peerId of peerIds) {
|
|
954
|
+
const ok = await irohTransport.sendMessage(
|
|
955
|
+
peerId,
|
|
956
|
+
'agent.meta.list',
|
|
957
|
+
new TextEncoder().encode('{}')
|
|
958
|
+
);
|
|
959
|
+
if (ok) sent++;
|
|
960
|
+
}
|
|
961
|
+
res.json({ ok: true, sent, total: peerIds.length });
|
|
962
|
+
} catch (err: any) {
|
|
963
|
+
res.status(500).json({ error: err.message });
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
|
|
836
967
|
app.post('/channels', async (req, res) => {
|
|
837
968
|
try {
|
|
838
|
-
const { name, agentId, walletAddress, autoInvokeTools } = req.body;
|
|
839
|
-
console.log(`[创建频道] 收到请求: name=${name}, agentId=${agentId}, wallet=${walletAddress ? 'yes' : 'no'}`);
|
|
969
|
+
const { name, agentId, walletAddress, autoInvokeTools, bound_judgment_ids } = req.body;
|
|
970
|
+
console.log(`[创建频道] 收到请求: name=${name}, agentId=${agentId}, wallet=${walletAddress ? 'yes' : 'no'}, boundJudgments=${Array.isArray(bound_judgment_ids) ? bound_judgment_ids.length : 0}`);
|
|
840
971
|
if (!name || !agentId) {
|
|
841
972
|
return res.status(400).json({ error: 'name and agentId required' });
|
|
842
973
|
}
|
|
@@ -846,6 +977,11 @@ app.get('/channels', async (_req, res) => {
|
|
|
846
977
|
// 校验钱包地址格式 (粗校验: 0x + 40 hex / Solana base58 / Sui 0x+64)
|
|
847
978
|
const validWallet = isValidWalletAddress(walletAddress);
|
|
848
979
|
|
|
980
|
+
// 过滤 bound_judgment_ids: 只保留 string
|
|
981
|
+
const safeBoundIds = Array.isArray(bound_judgment_ids)
|
|
982
|
+
? bound_judgment_ids.filter((x: unknown) => typeof x === 'string' && (x as string).length > 0)
|
|
983
|
+
: [];
|
|
984
|
+
|
|
849
985
|
// 先创建频道(不阻塞等待 DID 生成)
|
|
850
986
|
const channel: Channel = {
|
|
851
987
|
id,
|
|
@@ -857,6 +993,7 @@ app.get('/channels', async (_req, res) => {
|
|
|
857
993
|
walletAddress: validWallet || undefined,
|
|
858
994
|
walletRegisteredAt: validWallet ? new Date().toISOString() : undefined,
|
|
859
995
|
autoInvokeTools: autoInvokeTools !== false, // 默认 true
|
|
996
|
+
bound_judgment_ids: safeBoundIds,
|
|
860
997
|
sessions: [{
|
|
861
998
|
id: `sess_${Date.now()}`,
|
|
862
999
|
createdAt: new Date().toISOString(),
|
|
@@ -1035,7 +1172,7 @@ app.get('/channels', async (_req, res) => {
|
|
|
1035
1172
|
app.patch('/channels/:channelId', async (req, res) => {
|
|
1036
1173
|
try {
|
|
1037
1174
|
const { channelId } = req.params;
|
|
1038
|
-
const { name, walletAddress, autoInvokeTools } = req.body;
|
|
1175
|
+
const { name, walletAddress, autoInvokeTools, bound_judgment_ids } = req.body;
|
|
1039
1176
|
const channels = await loadChannels();
|
|
1040
1177
|
const channel = channels.find(c => c.id === channelId);
|
|
1041
1178
|
if (!channel) {
|
|
@@ -1061,6 +1198,19 @@ app.get('/channels', async (_req, res) => {
|
|
|
1061
1198
|
if (typeof autoInvokeTools === 'boolean') {
|
|
1062
1199
|
channel.autoInvokeTools = autoInvokeTools;
|
|
1063
1200
|
}
|
|
1201
|
+
// bound_judgment_ids: 允许数组(替换)/null(清空)/undefined(不改)
|
|
1202
|
+
if (bound_judgment_ids !== undefined) {
|
|
1203
|
+
if (bound_judgment_ids === null) {
|
|
1204
|
+
channel.bound_judgment_ids = [];
|
|
1205
|
+
} else if (Array.isArray(bound_judgment_ids)) {
|
|
1206
|
+
channel.bound_judgment_ids = bound_judgment_ids.filter(
|
|
1207
|
+
(x: unknown) => typeof x === 'string' && (x as string).length > 0
|
|
1208
|
+
);
|
|
1209
|
+
} else {
|
|
1210
|
+
return res.status(400).json({ error: 'bound_judgment_ids must be array or null' });
|
|
1211
|
+
}
|
|
1212
|
+
console.log(`[Channel ${channelId}] 绑定判断力: ${channel.bound_judgment_ids.length} 条`);
|
|
1213
|
+
}
|
|
1064
1214
|
channel.updatedAt = new Date().toISOString();
|
|
1065
1215
|
await saveChannels(channels);
|
|
1066
1216
|
res.json(channel);
|
|
@@ -1225,8 +1375,9 @@ app.get('/channels', async (_req, res) => {
|
|
|
1225
1375
|
}
|
|
1226
1376
|
};
|
|
1227
1377
|
|
|
1228
|
-
// 重新生成时只发送用户消息
|
|
1229
|
-
|
|
1378
|
+
// 重新生成时只发送用户消息 (v3: 同时注入 channel 绑定的判断力)
|
|
1379
|
+
const regenHint = await buildJudgmentHint(channel, channelId);
|
|
1380
|
+
fullResponse = await agent.promptStream(regenHint + userMessage, streamCallback);
|
|
1230
1381
|
|
|
1231
1382
|
broadcast({ type: 'ai', content: fullResponse }, channelId);
|
|
1232
1383
|
|
|
@@ -1767,6 +1918,88 @@ app.get('/channels', async (_req, res) => {
|
|
|
1767
1918
|
}, 'p2p-global');
|
|
1768
1919
|
});
|
|
1769
1920
|
|
|
1921
|
+
// ============ v3: 跨用户 channel 元数据 RPC ============
|
|
1922
|
+
// 设计原则: judgment / bound_judgment_ids / wallet 等敏感字段绝不出现在 RPC 响应里.
|
|
1923
|
+
// 收到 'agent.meta.list' → 返回本节点所有 channel 的 UI 元数据 (无 judgment)
|
|
1924
|
+
// 收到 'agent.meta.get' + channelId → 返回单条 channel 的 UI 元数据
|
|
1925
|
+
// B 节点收到响应 → 存到远端 cache → 渲染到 "远端智能体" 区域
|
|
1926
|
+
|
|
1927
|
+
// B 侧: 收到对端的 list/get 回复 → 更新远端 cache → SSE 推给前端
|
|
1928
|
+
irohTransport.onMessage('agent.meta.list.reply', (msg) => {
|
|
1929
|
+
try {
|
|
1930
|
+
const data = JSON.parse(new TextDecoder().decode(msg.payload));
|
|
1931
|
+
if (!data.ok) return;
|
|
1932
|
+
const peerId = msg.from;
|
|
1933
|
+
const list = Array.isArray(data.channels) ? data.channels : [];
|
|
1934
|
+
remoteChannelCache.set(peerId, list);
|
|
1935
|
+
console.log(`[v3] 缓存远端 peer ${peerId.substring(0, 12)}... 的 ${list.length} 个 channel`);
|
|
1936
|
+
broadcast({
|
|
1937
|
+
type: 'remote-channel-update',
|
|
1938
|
+
peerId,
|
|
1939
|
+
channels: list
|
|
1940
|
+
}, 'p2p-global');
|
|
1941
|
+
} catch (err) {
|
|
1942
|
+
console.error('[v3] 处理 agent.meta.list.reply 失败:', err);
|
|
1943
|
+
}
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
irohTransport.onMessage('agent.meta.get.reply', (msg) => {
|
|
1947
|
+
try {
|
|
1948
|
+
const data = JSON.parse(new TextDecoder().decode(msg.payload));
|
|
1949
|
+
if (!data.ok || !data.channel) return;
|
|
1950
|
+
const peerId = msg.from;
|
|
1951
|
+
const ch = data.channel;
|
|
1952
|
+
const list = remoteChannelCache.get(peerId) || [];
|
|
1953
|
+
const idx = list.findIndex(c => c.id === ch.id);
|
|
1954
|
+
if (idx >= 0) list[idx] = ch;
|
|
1955
|
+
else list.push(ch);
|
|
1956
|
+
remoteChannelCache.set(peerId, list);
|
|
1957
|
+
broadcast({
|
|
1958
|
+
type: 'remote-channel-update',
|
|
1959
|
+
peerId,
|
|
1960
|
+
channels: list
|
|
1961
|
+
}, 'p2p-global');
|
|
1962
|
+
} catch (err) {
|
|
1963
|
+
console.error('[v3] 处理 agent.meta.get.reply 失败:', err);
|
|
1964
|
+
}
|
|
1965
|
+
});
|
|
1966
|
+
|
|
1967
|
+
// A 侧: 收到对端的 list/get 请求
|
|
1968
|
+
irohTransport.onMessage('agent.meta.list', async (msg) => {
|
|
1969
|
+
console.log(`[v3] 收到 agent.meta.list from ${msg.from.substring(0, 12)}...`);
|
|
1970
|
+
try {
|
|
1971
|
+
const channels = await loadChannels();
|
|
1972
|
+
const publicMeta = channels.map(sanitizeChannelForPeer);
|
|
1973
|
+
const response = JSON.stringify({ ok: true, channels: publicMeta });
|
|
1974
|
+
const encoded = new TextEncoder().encode(response);
|
|
1975
|
+
// 沿用 msg.from 路由回去
|
|
1976
|
+
irohTransport.sendMessage(msg.from, 'agent.meta.list.reply', encoded).catch(err => {
|
|
1977
|
+
console.error('[v3] 发送 agent.meta.list.reply 失败:', err);
|
|
1978
|
+
});
|
|
1979
|
+
} catch (err) {
|
|
1980
|
+
console.error('[v3] 处理 agent.meta.list 失败:', err);
|
|
1981
|
+
}
|
|
1982
|
+
});
|
|
1983
|
+
|
|
1984
|
+
irohTransport.onMessage('agent.meta.get', async (msg) => {
|
|
1985
|
+
try {
|
|
1986
|
+
const req = JSON.parse(new TextDecoder().decode(msg.payload));
|
|
1987
|
+
const channelId = req.channelId;
|
|
1988
|
+
console.log(`[v3] 收到 agent.meta.get for ${channelId} from ${msg.from.substring(0, 12)}...`);
|
|
1989
|
+
const channels = await loadChannels();
|
|
1990
|
+
const ch = channels.find(c => c.id === channelId);
|
|
1991
|
+
if (!ch) {
|
|
1992
|
+
const response = JSON.stringify({ ok: false, error: 'channel not found' });
|
|
1993
|
+
irohTransport.sendMessage(msg.from, 'agent.meta.get.reply', new TextEncoder().encode(response));
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
const response = JSON.stringify({ ok: true, channel: sanitizeChannelForPeer(ch) });
|
|
1997
|
+
irohTransport.sendMessage(msg.from, 'agent.meta.get.reply', new TextEncoder().encode(response));
|
|
1998
|
+
} catch (err) {
|
|
1999
|
+
console.error('[v3] 处理 agent.meta.get 失败:', err);
|
|
2000
|
+
}
|
|
2001
|
+
});
|
|
2002
|
+
|
|
1770
2003
|
irohTransport.onMessage('ai-dialogue', (msg) => {
|
|
1771
2004
|
const content = new TextDecoder().decode(msg.payload);
|
|
1772
2005
|
console.log(`[iroh] 收到 AI 对话 from ${msg.from.substring(0, 12)}...`);
|
|
@@ -2451,7 +2684,8 @@ app.get('/channels', async (_req, res) => {
|
|
|
2451
2684
|
try {
|
|
2452
2685
|
const { createHealthMonitor, createWatchdog } = await import('../heartbeat/index.js');
|
|
2453
2686
|
healthMonitor = createHealthMonitor();
|
|
2454
|
-
watchdog
|
|
2687
|
+
// 把 watchdog 静默阈值拉到 30 分钟, 避免开发期 / 用户空闲时被误杀
|
|
2688
|
+
watchdog = createWatchdog({ silentThresholdMs: 30 * 60 * 1000 });
|
|
2455
2689
|
|
|
2456
2690
|
console.log('[24h] Heartbeat modules loaded');
|
|
2457
2691
|
} catch (err) {
|
package/src/web/style.css
CHANGED
|
@@ -269,6 +269,41 @@ body {
|
|
|
269
269
|
padding: 16px;
|
|
270
270
|
position: relative;
|
|
271
271
|
}
|
|
272
|
+
|
|
273
|
+
/* v3: 本地/远端 flex 比例由 CSS variable 驱动, 拖拽分隔线时改这两个值 */
|
|
274
|
+
.sidebar-section.local-flex { flex: var(--local-flex, 1) 1 0; }
|
|
275
|
+
.sidebar-section.remote-flex { flex: var(--remote-flex, 1) 1 0; transition: flex 0.15s; }
|
|
276
|
+
|
|
277
|
+
/* v3: 可拖拽分隔线 */
|
|
278
|
+
#sidebar-split-handle {
|
|
279
|
+
flex: 0 0 auto;
|
|
280
|
+
height: 6px;
|
|
281
|
+
cursor: ns-resize;
|
|
282
|
+
display: flex;
|
|
283
|
+
align-items: center;
|
|
284
|
+
justify-content: center;
|
|
285
|
+
position: relative;
|
|
286
|
+
background: transparent;
|
|
287
|
+
user-select: none;
|
|
288
|
+
}
|
|
289
|
+
#sidebar-split-handle:hover { background: var(--bg-hover); }
|
|
290
|
+
#sidebar-split-handle.dragging { background: var(--accent-glow); }
|
|
291
|
+
#sidebar-split-handle .split-handle-grip {
|
|
292
|
+
width: 32px;
|
|
293
|
+
height: 3px;
|
|
294
|
+
border-radius: 2px;
|
|
295
|
+
background: var(--border-light);
|
|
296
|
+
pointer-events: none;
|
|
297
|
+
}
|
|
298
|
+
#sidebar-split-handle:hover .split-handle-grip,
|
|
299
|
+
#sidebar-split-handle.dragging .split-handle-grip { background: var(--accent); }
|
|
300
|
+
|
|
301
|
+
/* v3: 远端区域折叠状态 */
|
|
302
|
+
#remote-agents-section.collapsed .channel-list { display: none; }
|
|
303
|
+
#remote-agents-section.collapsed { flex: 0 0 auto; padding-top: 0; padding-bottom: 0; }
|
|
304
|
+
#remote-agents-section.collapsed .section-header { margin-bottom: 0; }
|
|
305
|
+
#remote-agents-section.collapsed #remote-agents-toggle { transform: rotate(-90deg); }
|
|
306
|
+
|
|
272
307
|
.section-header {
|
|
273
308
|
display: flex;
|
|
274
309
|
align-items: center;
|