Skip to content

Commit a0c8bbb

Browse files
committed
feat(ui): Implement agent emoji avatars and enhance UI components for better visibility and interaction
1 parent 08b8682 commit a0c8bbb

12 files changed

Lines changed: 380 additions & 142 deletions

conftest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
TEST_DB_PATH = os.path.join(os.path.dirname(__file__), "data", "bus_test.db")
1919
_SERVER_PROCESS = None
2020

21+
# Script-style checks that are intended to run manually against a dedicated server
22+
# should not be collected by pytest's normal test discovery.
23+
collect_ignore = ["test_image_paste.py", "test_token_exposure.py"]
24+
2125

2226
@pytest.fixture(scope="session", autouse=True)
2327
def server():

emoji.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Emoji 头像需求与实现计划
2+
3+
## 背景
4+
当前 UI 中,聊天区 agent 头像使用名称截取的 2-3 个字符,区分度不足;状态栏中的 `emoji` 字段实际用于状态图标(在线/离线),不等同于 agent 头像。
5+
6+
## 目标
7+
1. 聊天区中每个 agent 使用稳定的 emoji 头像。
8+
2. 状态栏中每个 agent 条目左侧展示对应 emoji 头像。
9+
3. 状态指示器(如 Active/Idle/Offline)移动到条目最右侧。
10+
4. 本阶段以需求分析和方案为主,不进行业务代码改动。
11+
12+
## 范围
13+
- In scope:
14+
- 头像显示策略与 UI 结构调整方案
15+
- 前端最小改动路径(Phase 1)
16+
- 可选后端持久化扩展(Phase 2)
17+
- Out of scope:
18+
- 本次不落地代码改造
19+
- 本次不做 DB 迁移
20+
21+
## 现状定位(代码点)
22+
- 聊天消息头像:
23+
- `src/static/index.html``appendBubble(m)` 使用 initials 渲染 `.msg-avatar`
24+
- `src/static/index.html``showTyping(agentId)` 也使用 initials。
25+
- 状态栏:
26+
- `src/static/js/shared-agents.js``updateStatusBar()` 中,`emoji = getStateEmoji(state)`
27+
- `src/static/js/components/acb-agent-status-item.js` 组件当前结构约为 `[emoji][text]`
28+
- 状态图标:
29+
- `src/static/js/shared-agent-status.js``getStateEmoji(state)` 负责状态指示符。
30+
31+
## 方案总览
32+
33+
### Phase 1(优先,低风险,仅前端)
34+
目标: 在不改后端协议的情况下,提供稳定且一致的 emoji 头像体验。
35+
36+
1. 引入统一映射函数(前端)
37+
- 新增 `getAgentEmoji(agent)`(或等价工具函数)。
38+
- 输入优先级建议:
39+
1) `agent.emoji`(若未来字段存在)
40+
2) `agent_id` / `id`
41+
3) `display_name` / `name`
42+
- 稳定映射策略:
43+
- 基于 `agent_id`(优先)或名称字符串计算哈希。
44+
- 从预定义 emoji 池中取模选择,保证“同一 agent -> 同一 emoji”。
45+
- emoji 池建议 50-100 个中性、高辨识度符号(动物/植物/物品优先)。
46+
- 排除与状态符号易混淆的图标(例如 `🟢/⚫/⏳/🌙` 一类)。
47+
- 默认回退: `🤖`
48+
49+
2. 特殊角色默认头像
50+
- `human` 默认 `👤`(或 `🧑`)。
51+
- `system` 默认 `⚙️`(或 `📢`)。
52+
- 普通 agent 命中映射池;若映射失败回退 `🤖`
53+
54+
3. 聊天区头像替换
55+
- `appendBubble(m)``.msg-avatar` 文本由 initials 改为 emoji。
56+
- `showTyping(agentId)` 的 typing 行头像同步改为 emoji。
57+
- 保留现有颜色背景逻辑(可提升可读性与辨识度)。
58+
59+
4. 状态栏结构调整
60+
- `acb-agent-status-item` 结构调整为:
61+
- 左: `agent emoji`
62+
- 中: agent 名称 + 状态文本
63+
- 右: `state indicator``getStateEmoji(state)`
64+
- 对长离线压缩模式(compact)保持兼容:
65+
- 左侧仍显示 agent emoji
66+
- 右侧显示状态指示或压缩符号(按最终 UI 决策)
67+
68+
5. 哈希实现建议
69+
- 可在 `src/static/js/shared-utils.js` 新增统一哈希工具(如简化 `djb2`)。
70+
- 聊天区与状态栏必须复用同一函数,避免同一 agent 出现不同 emoji。
71+
72+
6. 一致性要求
73+
- 聊天区、状态栏、tooltip 中同一 agent 必须显示同一个 emoji。
74+
- 页面刷新、线程切换后映射结果不变(在同一映射规则下)。
75+
76+
### Phase 2(可选增强,后端持久化)
77+
目标: 允许 agent 在注册/恢复时声明自定义 emoji,并持久化。
78+
79+
1. 数据模型扩展
80+
- `AgentInfo` 增加可选字段 `emoji`
81+
82+
2. API 扩展
83+
- `agent_register` / `agent_resume` 支持传入 `emoji`
84+
- agent 列表接口返回 `emoji`
85+
86+
3. 前端优先级更新
87+
- 若后端返回 `emoji`,优先使用该值;否则回退 Phase 1 映射策略。
88+
89+
## UI 建议
90+
1. 聊天头像尺寸保持现有圆形容器,内容改为单个 emoji。
91+
2. 状态栏条目采用三段式布局(左头像、中信息、右状态),减少视觉歧义。
92+
3. Compact/窄宽模式下优先保留左侧 agent emoji 和右侧状态挂件,文本可降级显示。
93+
4. 维持当前主题色体系,避免额外视觉回归风险。
94+
95+
## 风险与注意事项
96+
1. emoji 渲染在不同系统字体下外观存在差异。
97+
2. 哈希映射若 emoji 池过小,可能出现碰撞(可通过扩池缓解)。
98+
3. 需要避免将“状态 emoji”与“头像 emoji”复用同一字段名导致语义混乱。
99+
100+
## 验收标准(建议)
101+
1. 聊天区 agent 头像不再是 2-3 字符,改为 emoji。
102+
2. 状态栏左侧展示 agent emoji,右侧展示状态指示符。
103+
3. 同一 agent 在聊天区与状态栏的 emoji 一致。
104+
4. 无 agent emoji 数据时,显示默认 `🤖`
105+
5. 现有在线/离线状态文案与逻辑不回归。
106+
107+
## 协作结论
108+
- 已与协作 agent 讨论并同意采用“Phase 1 前端映射优先,Phase 2 后端持久化可选”的分阶段策略。
109+
- 本文档作为实现前需求与方案基线。

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ dependencies = [
3232
[project.optional-dependencies]
3333
dev = [
3434
"pytest>=8.0.0",
35-
"pytest-asyncio>=0.23.0"
35+
"pytest-asyncio>=0.23.0",
36+
"aiohttp>=3.10.0"
3637
]
3738
ui = [
3839
"playwright>=1.50.0"
@@ -57,6 +58,7 @@ include = ["src*"]
5758
src = ["static/*.html", "static/*.png", "static/js/*.js"]
5859

5960
[tool.pytest.ini_options]
61+
asyncio_mode = "auto"
6062
filterwarnings = [
6163
"ignore::RuntimeWarning",
6264
"ignore::pytest.PytestUnraisableExceptionWarning"

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ aiosqlite>=0.20.0
1010

1111
# SSE & HTTP utilities
1212
httpx>=0.27.0
13+
aiohttp>=3.10.0
1314
python-multipart>=0.0.9
1415

1516
# Dev & test

src/static/index.html

Lines changed: 16 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -697,9 +697,9 @@
697697
display: flex;
698698
align-items: center;
699699
justify-content: center;
700-
font-size: 11px;
701-
font-weight: 700;
702-
letter-spacing: .02em;
700+
font-size: 16px;
701+
font-weight: 600;
702+
letter-spacing: 0;
703703
margin-top: 2px;
704704
}
705705

@@ -1187,6 +1187,15 @@
11871187
text-align: center;
11881188
}
11891189

1190+
.agent-status-state-emoji {
1191+
margin-left: auto;
1192+
font-size: 16px;
1193+
width: 20px;
1194+
display: flex;
1195+
align-items: center;
1196+
justify-content: center;
1197+
}
1198+
11901199
#agent-status-info {
11911200
display: flex;
11921201
align-items: center;
@@ -2169,11 +2178,7 @@
21692178
const isHuman = authorLabel.toLowerCase() === 'human';
21702179
const authorKey = isHuman ? 'human' : (isSystem ? 'system' : String(m.author_id ?? authorLabel));
21712180
const color = authorColor(authorKey);
2172-
let initials = authorLabel.slice(0, 2).toUpperCase();
2173-
if (!isHuman && !isSystem) {
2174-
const base = authorLabel.split('(')[0].trim().toUpperCase();
2175-
initials = base.substring(0, 3);
2176-
}
2181+
const avatarEmoji = window.AcbUtils?.getAgentAvatarEmoji ? window.AcbUtils.getAgentAvatarEmoji(authorKey) : '🤖';
21772182
const bgAlpha = `${color}22`; // ~13% alpha tint for avatar bg
21782183

21792184
const row = document.createElement('div');
@@ -2192,7 +2197,7 @@
21922197
}
21932198

21942199
row.innerHTML = `
2195-
<div class="msg-avatar" style="background:${bgAlpha};color:${color};border:1px solid ${color}44">${initials}</div>
2200+
<div class="msg-avatar" style="background:${bgAlpha};color:${color};border:1px solid ${color}44">${avatarEmoji}</div>
21962201
<div class="msg-col">
21972202
<div class="msg-header">
21982203
<span class="msg-author-label" style="color:${color}">${esc(authorLabel)}</span>
@@ -2273,13 +2278,12 @@
22732278
let el = document.getElementById(`typing-${agentId}`);
22742279
if (!el) {
22752280
const color = authorColor(agentId);
2276-
const base = agentId.split('(')[0].trim().toUpperCase();
2277-
const initials = base.substring(0, 3);
2281+
const avatarEmoji = window.AcbUtils?.getAgentAvatarEmoji ? window.AcbUtils.getAgentAvatarEmoji(agentId) : '🤖';
22782282
const row = document.createElement('div');
22792283
row.className = 'msg-row msg-row-left';
22802284
row.id = `typing-${agentId}`;
22812285
row.innerHTML = `
2282-
<div class="msg-avatar" style="background:${color}22;color:${color};border:1px solid ${color}44">${initials}</div>
2286+
<div class="msg-avatar" style="background:${color}22;color:${color};border:1px solid ${color}44">${avatarEmoji}</div>
22832287
<div class="msg-col">
22842288
<div class="msg-header">
22852289
<span class="msg-author-label" style="color:${color}">${esc(agentId)}</span>
@@ -2347,107 +2351,6 @@
23472351
return window.AcbUtils.escapeHtml(t);
23482352
}
23492353

2350-
// ==============================================================================================
2351-
// Agent Status Bar Update
2352-
// ==============================================================================================
2353-
async function updateStatusBar() {
2354-
const allAgents = await api('/api/agents') || [];
2355-
currentAgents = allAgents; // Cache for tooltip lookups
2356-
const container = document.getElementById('agent-status-list');
2357-
if (!container) {
2358-
return;
2359-
}
2360-
2361-
let participants = [];
2362-
let isThreadMode = false; // Track if showing thread-specific agents
2363-
2364-
// If a thread is selected, show agents in that thread
2365-
if (activeThreadId) {
2366-
// Σ╗Äσ╜ôσëìthreadτÜäµ╢êµü»Σ╕¡µÉσûµëǵ£ëσéΣ╕Äagents∩╝êµîëauthor_id∩╝ë
2367-
const participantIdMap = new Map(); // id -> agent数据
2368-
const msgArea = document.getElementById('messages');
2369-
if (msgArea) {
2370-
const rows = msgArea.querySelectorAll('[data-author-id]');
2371-
rows.forEach(row => {
2372-
const authorId = row.getAttribute('data-author-id');
2373-
if (authorId && authorId !== 'system' && authorId !== 'human' && !participantIdMap.has(authorId)) {
2374-
// 查找该author_id对应的agent
2375-
const agent = allAgents.find(a => (a.id === authorId || a.agent_id === authorId));
2376-
if (agent) {
2377-
participantIdMap.set(authorId, agent);
2378-
} else {
2379-
// 即使agent不在allAgents中,也创建一个离线表示
2380-
participantIdMap.set(authorId, {
2381-
id: authorId,
2382-
display_name: authorId,
2383-
name: authorId,
2384-
is_online: false,
2385-
});
2386-
}
2387-
}
2388-
});
2389-
}
2390-
participants = Array.from(participantIdMap.values());
2391-
isThreadMode = participants.length > 0; // Only thread mode if we have participants
2392-
}
2393-
2394-
// If no thread selected or no participants in thread, show all agents
2395-
if (!isThreadMode) {
2396-
participants = allAgents;
2397-
isThreadMode = false; // Global mode - no thread context
2398-
}
2399-
2400-
// µÄÆσ║∩╝Üσ£¿τ║┐Σ╝ÿσàê∩╝îτä╢σÉĵîëµ£ÇσÉĵ┤╗σ迵ù╢Θù┤µÄÆσ║
2401-
participants.sort((a, b) => {
2402-
if (a.is_online !== b.is_online) {
2403-
return a.is_online ? -1 : 1; // 在线优先
2404-
}
2405-
if (a.is_online && b.is_online) {
2406-
const timeA = a.last_activity_time ? new Date(a.last_activity_time) : new Date(0);
2407-
const timeB = b.last_activity_time ? new Date(b.last_activity_time) : new Date(0);
2408-
return timeB - timeA; // 最近活动优先
2409-
}
2410-
return 0;
2411-
});
2412-
2413-
container.innerHTML = '';
2414-
2415-
if (participants.length === 0) {
2416-
container.innerHTML = '<div style="color:var(--text-3);font-size:11px;padding:4px 12px;">No active agents</div>';
2417-
return;
2418-
}
2419-
2420-
participants.forEach(a => {
2421-
const state = getAgentState(a);
2422-
const emoji = getStateEmoji(state);
2423-
const label = String(a.display_name ?? a.name ?? '').trim() || 'Unknown';
2424-
const offlineTime = getOfflineTime(a);
2425-
const offlineDisplay = offlineTime ? ` (${offlineTime})` : '';
2426-
const isLongOffline = isOfflineMoreThanHour(a);
2427-
2428-
const compressedChar = getCompressedOfflineChar(offlineTime);
2429-
const item = document.createElement('acb-agent-status-item');
2430-
item.setData({
2431-
emoji,
2432-
label,
2433-
state,
2434-
offlineDisplay,
2435-
isLongOffline,
2436-
compressedChar,
2437-
escapeHtml,
2438-
});
2439-
2440-
// Set data-agent-id for click-to-mention functionality
2441-
if (a && a.id) {
2442-
item.dataset.agentId = a.id;
2443-
bindAgentTooltipEvents(item, a);
2444-
} else if (a && a.agent_id) {
2445-
item.dataset.agentId = a.agent_id;
2446-
}
2447-
container.appendChild(item);
2448-
});
2449-
}
2450-
24512354
function getAgentState(agent) {
24522355
// 优先使用last_activity_time判断状态,而非is_online(后者基于heartbeat超时)
24532356
const activityTime = agent.last_activity_time ? new Date(agent.last_activity_time) : null;

src/static/js/components/acb-agent-status-item.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
if (!this._data) return;
2020

2121
const {
22-
emoji,
22+
avatarEmoji,
23+
stateEmoji,
2324
label,
2425
state,
2526
offlineDisplay,
@@ -34,18 +35,20 @@
3435

3536
if (isLongOffline) {
3637
this.innerHTML = `
37-
<div class="agent-status-emoji">${emoji}</div>
38+
<div class="agent-status-emoji">${avatarEmoji}</div>
3839
<div class="agent-status-text-compact">${compressedChar}</div>
40+
<div class="agent-status-state-emoji" title="${esc(state)}">${stateEmoji}</div>
3941
`;
4042
return;
4143
}
4244

4345
this.innerHTML = `
44-
<div class="agent-status-emoji">${emoji}</div>
46+
<div class="agent-status-emoji">${avatarEmoji}</div>
4547
<div class="agent-status-text">
4648
<div class="agent-alias">${esc(label)}</div>
4749
<div class="agent-state">${state}${offlineDisplay}</div>
4850
</div>
51+
<div class="agent-status-state-emoji" title="${esc(state)}">${stateEmoji}</div>
4952
`;
5053
}
5154
}

src/static/js/shared-agents.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,8 @@
200200

201201
participants.forEach((a) => {
202202
const state = getAgentState(a);
203-
const emoji = getStateEmoji(state);
203+
const avatarEmoji = window.AcbUtils?.getAgentAvatarEmoji ? window.AcbUtils.getAgentAvatarEmoji(a) : "🤖";
204+
const stateEmoji = getStateEmoji(state);
204205
const label = String(a.display_name ?? a.name ?? "").trim() || "Unknown";
205206
const offlineTime = getOfflineTime(a);
206207
const offlineDisplay = offlineTime ? ` (${offlineTime})` : "";
@@ -209,7 +210,8 @@
209210
const compressedChar = getCompressedOfflineChar(offlineTime);
210211
const item = document.createElement("acb-agent-status-item");
211212
item.setData({
212-
emoji,
213+
avatarEmoji,
214+
stateEmoji,
213215
label,
214216
state,
215217
offlineDisplay,

0 commit comments

Comments
 (0)