diff --git a/packages/client/src/components/chat/AsChat/avatar.tsx b/packages/client/src/components/chat/AsChat/avatar.tsx index 3bb865b2..deae2656 100644 --- a/packages/client/src/components/chat/AsChat/avatar.tsx +++ b/packages/client/src/components/chat/AsChat/avatar.tsx @@ -19,40 +19,9 @@ const AVATAR_PATHS = Object.keys(avatarModules) }) .filter(Boolean); -/* - * Simple hash function to convert a string to a number - * - * @param str - The input string to hash. - * @param seed - The seed value for the hash function. - * - * @return A non-negative integer hash of the input string. - */ -const hashString = (str: string, seed: number): number => { - let hash = seed; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32bit integer - } - return Math.abs(hash); -}; - -/* - * Get avatar path based on the hash of the name - * - * @param name - The name to hash for avatar selection. - * @param seed - The seed value for the hash function. - * @param avatarSet - The avatar set to select from. - * - * @return The selected avatar path. - */ -const getAvatarPathByName = ( - name: string, - seed: number, - avatarSet: AvatarSet, -): string => { +const getFilteredPaths = (avatarSet: AvatarSet): string[] => { if (AVATAR_PATHS.length === 0) { - return ''; + return []; } // Filter avatar paths based on avatarSet @@ -70,9 +39,46 @@ const getAvatarPathByName = ( filteredPaths = AVATAR_PATHS; } - const hash = hashString(name, seed); - const index = hash % filteredPaths.length; - return filteredPaths[index]; + return filteredPaths; +}; + +const hashString = (str: string, seed: number): number => { + let hash = seed; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return Math.abs(hash); +}; + +export const assignUniqueAvatars = ( + names: string[], + seed: number, + avatarSet: AvatarSet, +): Map => { + const assignment = new Map(); + const filteredPaths = getFilteredPaths(avatarSet); + + if (filteredPaths.length === 0 || names.length === 0) { + return assignment; + } + + const N = filteredPaths.length; + const usedIndices = new Set(); + + for (const name of names) { + const preferred = hashString(name, seed) % N; + let index = preferred; + while (usedIndices.has(index)) { + index = (index + 1) % N; + if (index === preferred) break; + } + usedIndices.add(index); + assignment.set(name, filteredPaths[index]); + } + + return assignment; }; /* @@ -103,50 +109,46 @@ const loadAvatarComponent = async ( * * @param name - The name of the user. * @param role - The role of the user (e.g., 'system', 'user'). - * @param randomAvatar - Whether to use a random avatar or not. - * @param seed - The seed value for random avatar selection. - * @param renderAvatar - A render function for custom avatar rendering. + * @param avatarPath - Pre-assigned avatar path from assignUniqueAvatars. + * If undefined, displays initials (letter mode). * * @return The avatar JSX element. */ export const AsAvatar = ({ name, role, - avatarSet, - seed, + avatarPath, }: { name: string; role: string; - avatarSet: AvatarSet; - seed: number; + avatarPath?: string; }) => { const [AvatarComponent, setAvatarComponent] = useState > | null>(null); useEffect(() => { - if (avatarSet !== AvatarSet.LETTER && role.toLowerCase() !== 'system') { - // TODO: 我需要这里根据 avatarSet 来在对应的集合中根据seed随机选择头像 - // avatarSet 决定了'../../../assets/svgs/avatar/**/*.svg'中**的字段 - // 如果是 AvatarSet.RANDOM 则从所有头像中选择 - // 如果是 AvatarSet.POKEMON 则从pokemon文件夹中选择,依此类推 - const avatarPath = getAvatarPathByName(name, seed, avatarSet); - if (avatarPath) { - loadAvatarComponent(avatarPath) - .then((component) => { - if (component) { - setAvatarComponent(() => component); - } - }) - .catch(console.error); - } + let stale = false; + if (avatarPath && role.toLowerCase() !== 'system') { + loadAvatarComponent(avatarPath) + .then((component) => { + if (!stale && component) { + setAvatarComponent(() => component); + } + }) + .catch(console.error); + } else { + setAvatarComponent(null); } - }, [name, role, avatarSet, seed]); + return () => { + stale = true; + }; + }, [role, avatarPath]); let avatarComponent; if (role.toLowerCase() === 'system') { avatarComponent = ; - } else if (avatarSet !== AvatarSet.LETTER && AvatarComponent) { + } else if (AvatarComponent) { avatarComponent = ; } else { // Fallback: Display initials diff --git a/packages/client/src/components/chat/AsChat/index.tsx b/packages/client/src/components/chat/AsChat/index.tsx index f16ed808..3dd499e9 100644 --- a/packages/client/src/components/chat/AsChat/index.tsx +++ b/packages/client/src/components/chat/AsChat/index.tsx @@ -59,7 +59,11 @@ import Character1Icon from '@/assets/svgs/avatar/character/018-waiter.svg?react' import Character2Icon from '@/assets/svgs/avatar/character/035-daughter.svg?react'; import Character3Icon from '@/assets/svgs/avatar/character/050-woman.svg?react'; import { Avatar } from '@/components/ui/avatar.tsx'; -import { AsAvatar, AvatarSet } from '@/components/chat/AsChat/avatar.tsx'; +import { + AsAvatar, + AvatarSet, + assignUniqueAvatars, +} from '@/components/chat/AsChat/avatar.tsx'; import { SpeechStatesRecord } from '@/context/RunRoomContext'; import { cn } from '@/lib/utils'; @@ -245,6 +249,22 @@ const AsChat = ({ return flattedReplies; }, [replies, byReplyId]); + // Precompute unique avatar assignments for all agent names + // This ensures different agents always get different avatars (when possible) + const avatarAssignmentMap = useMemo(() => { + if (avatarSet === AvatarSet.LETTER) { + return new Map(); + } + const uniqueNames = [ + ...new Set( + organizedReplies + .filter((r) => r.replyRole.toLowerCase() !== 'system') + .map((r) => r.replyName), + ), + ]; + return assignUniqueAvatars(uniqueNames, randomSeed, avatarSet); + }, [organizedReplies, randomSeed, avatarSet]); + // When new replies arrive, auto-scroll to bottom if user is at bottom useEffect(() => { if (bubbleListRef.current && isAtBottom) { @@ -473,8 +493,9 @@ const AsChat = ({ } key={reply.replyId}