Skip to content

Commit ce281bd

Browse files
authored
fix(avatar): resolve duplicate avatars in multi-agent conversations (#123)
* fix(avatar): resolve duplicate avatars in multi-agent conversations * fix(avatar): prevent race condition in async avatar loading * fix(avatar): use first-appearance order to keep avatars stable when new agents join * fix(avatar): add linear probing to resolve hash collisions in avatar assignment
1 parent 0473d30 commit ce281bd

File tree

2 files changed

+86
-63
lines changed

2 files changed

+86
-63
lines changed

packages/client/src/components/chat/AsChat/avatar.tsx

Lines changed: 62 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -19,40 +19,9 @@ const AVATAR_PATHS = Object.keys(avatarModules)
1919
})
2020
.filter(Boolean);
2121

22-
/*
23-
* Simple hash function to convert a string to a number
24-
*
25-
* @param str - The input string to hash.
26-
* @param seed - The seed value for the hash function.
27-
*
28-
* @return A non-negative integer hash of the input string.
29-
*/
30-
const hashString = (str: string, seed: number): number => {
31-
let hash = seed;
32-
for (let i = 0; i < str.length; i++) {
33-
const char = str.charCodeAt(i);
34-
hash = (hash << 5) - hash + char;
35-
hash = hash & hash; // Convert to 32bit integer
36-
}
37-
return Math.abs(hash);
38-
};
39-
40-
/*
41-
* Get avatar path based on the hash of the name
42-
*
43-
* @param name - The name to hash for avatar selection.
44-
* @param seed - The seed value for the hash function.
45-
* @param avatarSet - The avatar set to select from.
46-
*
47-
* @return The selected avatar path.
48-
*/
49-
const getAvatarPathByName = (
50-
name: string,
51-
seed: number,
52-
avatarSet: AvatarSet,
53-
): string => {
22+
const getFilteredPaths = (avatarSet: AvatarSet): string[] => {
5423
if (AVATAR_PATHS.length === 0) {
55-
return '';
24+
return [];
5625
}
5726

5827
// Filter avatar paths based on avatarSet
@@ -70,9 +39,46 @@ const getAvatarPathByName = (
7039
filteredPaths = AVATAR_PATHS;
7140
}
7241

73-
const hash = hashString(name, seed);
74-
const index = hash % filteredPaths.length;
75-
return filteredPaths[index];
42+
return filteredPaths;
43+
};
44+
45+
const hashString = (str: string, seed: number): number => {
46+
let hash = seed;
47+
for (let i = 0; i < str.length; i++) {
48+
const char = str.charCodeAt(i);
49+
hash = (hash << 5) - hash + char;
50+
hash = hash & hash;
51+
}
52+
return Math.abs(hash);
53+
};
54+
55+
export const assignUniqueAvatars = (
56+
names: string[],
57+
seed: number,
58+
avatarSet: AvatarSet,
59+
): Map<string, string> => {
60+
const assignment = new Map<string, string>();
61+
const filteredPaths = getFilteredPaths(avatarSet);
62+
63+
if (filteredPaths.length === 0 || names.length === 0) {
64+
return assignment;
65+
}
66+
67+
const N = filteredPaths.length;
68+
const usedIndices = new Set<number>();
69+
70+
for (const name of names) {
71+
const preferred = hashString(name, seed) % N;
72+
let index = preferred;
73+
while (usedIndices.has(index)) {
74+
index = (index + 1) % N;
75+
if (index === preferred) break;
76+
}
77+
usedIndices.add(index);
78+
assignment.set(name, filteredPaths[index]);
79+
}
80+
81+
return assignment;
7682
};
7783

7884
/*
@@ -103,50 +109,46 @@ const loadAvatarComponent = async (
103109
*
104110
* @param name - The name of the user.
105111
* @param role - The role of the user (e.g., 'system', 'user').
106-
* @param randomAvatar - Whether to use a random avatar or not.
107-
* @param seed - The seed value for random avatar selection.
108-
* @param renderAvatar - A render function for custom avatar rendering.
112+
* @param avatarPath - Pre-assigned avatar path from assignUniqueAvatars.
113+
* If undefined, displays initials (letter mode).
109114
*
110115
* @return The avatar JSX element.
111116
*/
112117
export const AsAvatar = ({
113118
name,
114119
role,
115-
avatarSet,
116-
seed,
120+
avatarPath,
117121
}: {
118122
name: string;
119123
role: string;
120-
avatarSet: AvatarSet;
121-
seed: number;
124+
avatarPath?: string;
122125
}) => {
123126
const [AvatarComponent, setAvatarComponent] = useState<FC<
124127
SVGProps<SVGSVGElement>
125128
> | null>(null);
126129

127130
useEffect(() => {
128-
if (avatarSet !== AvatarSet.LETTER && role.toLowerCase() !== 'system') {
129-
// TODO: 我需要这里根据 avatarSet 来在对应的集合中根据seed随机选择头像
130-
// avatarSet 决定了'../../../assets/svgs/avatar/**/*.svg'中**的字段
131-
// 如果是 AvatarSet.RANDOM 则从所有头像中选择
132-
// 如果是 AvatarSet.POKEMON 则从pokemon文件夹中选择,依此类推
133-
const avatarPath = getAvatarPathByName(name, seed, avatarSet);
134-
if (avatarPath) {
135-
loadAvatarComponent(avatarPath)
136-
.then((component) => {
137-
if (component) {
138-
setAvatarComponent(() => component);
139-
}
140-
})
141-
.catch(console.error);
142-
}
131+
let stale = false;
132+
if (avatarPath && role.toLowerCase() !== 'system') {
133+
loadAvatarComponent(avatarPath)
134+
.then((component) => {
135+
if (!stale && component) {
136+
setAvatarComponent(() => component);
137+
}
138+
})
139+
.catch(console.error);
140+
} else {
141+
setAvatarComponent(null);
143142
}
144-
}, [name, role, avatarSet, seed]);
143+
return () => {
144+
stale = true;
145+
};
146+
}, [role, avatarPath]);
145147

146148
let avatarComponent;
147149
if (role.toLowerCase() === 'system') {
148150
avatarComponent = <SystemAvatar />;
149-
} else if (avatarSet !== AvatarSet.LETTER && AvatarComponent) {
151+
} else if (AvatarComponent) {
150152
avatarComponent = <AvatarComponent />;
151153
} else {
152154
// Fallback: Display initials

packages/client/src/components/chat/AsChat/index.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,11 @@ import Character1Icon from '@/assets/svgs/avatar/character/018-waiter.svg?react'
5959
import Character2Icon from '@/assets/svgs/avatar/character/035-daughter.svg?react';
6060
import Character3Icon from '@/assets/svgs/avatar/character/050-woman.svg?react';
6161
import { Avatar } from '@/components/ui/avatar.tsx';
62-
import { AsAvatar, AvatarSet } from '@/components/chat/AsChat/avatar.tsx';
62+
import {
63+
AsAvatar,
64+
AvatarSet,
65+
assignUniqueAvatars,
66+
} from '@/components/chat/AsChat/avatar.tsx';
6367
import { SpeechStatesRecord } from '@/context/RunRoomContext';
6468
import { cn } from '@/lib/utils';
6569

@@ -245,6 +249,22 @@ const AsChat = ({
245249
return flattedReplies;
246250
}, [replies, byReplyId]);
247251

252+
// Precompute unique avatar assignments for all agent names
253+
// This ensures different agents always get different avatars (when possible)
254+
const avatarAssignmentMap = useMemo(() => {
255+
if (avatarSet === AvatarSet.LETTER) {
256+
return new Map<string, string>();
257+
}
258+
const uniqueNames = [
259+
...new Set(
260+
organizedReplies
261+
.filter((r) => r.replyRole.toLowerCase() !== 'system')
262+
.map((r) => r.replyName),
263+
),
264+
];
265+
return assignUniqueAvatars(uniqueNames, randomSeed, avatarSet);
266+
}, [organizedReplies, randomSeed, avatarSet]);
267+
248268
// When new replies arrive, auto-scroll to bottom if user is at bottom
249269
useEffect(() => {
250270
if (bubbleListRef.current && isAtBottom) {
@@ -473,8 +493,9 @@ const AsChat = ({
473493
<AsAvatar
474494
name={reply.replyName}
475495
role={reply.replyRole}
476-
avatarSet={avatarSet}
477-
seed={randomSeed}
496+
avatarPath={avatarAssignmentMap.get(
497+
reply.replyName,
498+
)}
478499
/>
479500
}
480501
key={reply.replyId}

0 commit comments

Comments
 (0)