From f5df6b4a67609ac553b0cdb154d1f49e37cd3463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BD=9C=E6=98=9F?= Date: Wed, 11 Feb 2026 22:56:08 +0800 Subject: [PATCH 1/4] fix(avatar): resolve duplicate avatars in multi-agent conversations --- .../src/components/chat/AsChat/avatar.tsx | 112 ++++++++---------- .../src/components/chat/AsChat/index.tsx | 27 ++++- 2 files changed, 76 insertions(+), 63 deletions(-) diff --git a/packages/client/src/components/chat/AsChat/avatar.tsx b/packages/client/src/components/chat/AsChat/avatar.tsx index 3bb865b2..ebda063c 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,40 @@ const getAvatarPathByName = ( filteredPaths = AVATAR_PATHS; } - const hash = hashString(name, seed); - const index = hash % filteredPaths.length; - return filteredPaths[index]; + return filteredPaths; +}; + +const seededShuffle = (arr: T[], seed: number): T[] => { + const result = [...arr]; + let s = seed; + for (let i = result.length - 1; i > 0; i--) { + s = (s * 1664525 + 1013904223) & 0xffffffff; + const j = Math.abs(s) % (i + 1); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +}; + +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 shuffledPaths = seededShuffle(filteredPaths, seed); + const sortedNames = [...names].sort(); + + for (let i = 0; i < sortedNames.length; i++) { + assignment.set(sortedNames[i], shuffledPaths[i % shuffledPaths.length]); + } + + return assignment; }; /* @@ -103,50 +103,42 @@ 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); - } + if (avatarPath && role.toLowerCase() !== 'system') { + loadAvatarComponent(avatarPath) + .then((component) => { + if (component) { + setAvatarComponent(() => component); + } + }) + .catch(console.error); + } else { + setAvatarComponent(null); } - }, [name, role, avatarSet, seed]); + }, [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 e978e4fd..ccb985a5 100644 --- a/packages/client/src/components/chat/AsChat/index.tsx +++ b/packages/client/src/components/chat/AsChat/index.tsx @@ -43,7 +43,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'; interface Props { /** List of chat replies to display */ @@ -195,6 +199,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) { @@ -296,8 +316,9 @@ const AsChat = ({ } key={reply.replyId} From 70f67c8e0baed9913e5e9f7ca946b5aa155fa09e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BD=9C=E6=98=9F?= Date: Thu, 12 Feb 2026 08:10:25 +0800 Subject: [PATCH 2/4] fix(avatar): prevent race condition in async avatar loading --- packages/client/src/components/chat/AsChat/avatar.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/client/src/components/chat/AsChat/avatar.tsx b/packages/client/src/components/chat/AsChat/avatar.tsx index ebda063c..5640f9c5 100644 --- a/packages/client/src/components/chat/AsChat/avatar.tsx +++ b/packages/client/src/components/chat/AsChat/avatar.tsx @@ -122,10 +122,11 @@ export const AsAvatar = ({ > | null>(null); useEffect(() => { + let stale = false; if (avatarPath && role.toLowerCase() !== 'system') { loadAvatarComponent(avatarPath) .then((component) => { - if (component) { + if (!stale && component) { setAvatarComponent(() => component); } }) @@ -133,6 +134,9 @@ export const AsAvatar = ({ } else { setAvatarComponent(null); } + return () => { + stale = true; + }; }, [role, avatarPath]); let avatarComponent; From ff539313e940378905d019f7fbb8ce6287a34ae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BD=9C=E6=98=9F?= Date: Thu, 12 Feb 2026 13:47:35 +0800 Subject: [PATCH 3/4] fix(avatar): use first-appearance order to keep avatars stable when new agents join --- packages/client/src/components/chat/AsChat/avatar.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/client/src/components/chat/AsChat/avatar.tsx b/packages/client/src/components/chat/AsChat/avatar.tsx index 5640f9c5..d508f94c 100644 --- a/packages/client/src/components/chat/AsChat/avatar.tsx +++ b/packages/client/src/components/chat/AsChat/avatar.tsx @@ -66,10 +66,11 @@ export const assignUniqueAvatars = ( } const shuffledPaths = seededShuffle(filteredPaths, seed); - const sortedNames = [...names].sort(); - for (let i = 0; i < sortedNames.length; i++) { - assignment.set(sortedNames[i], shuffledPaths[i % shuffledPaths.length]); + // Assign in first-appearance order (preserved by Set in the caller) + // so that existing agents keep their avatars when new agents join. + for (let i = 0; i < names.length; i++) { + assignment.set(names[i], shuffledPaths[i % shuffledPaths.length]); } return assignment; From 7d845b5c22e2879f9ee462300e003c6e264f0bf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=BD=9C=E6=98=9F?= Date: Sat, 14 Feb 2026 16:43:21 +0800 Subject: [PATCH 4/4] fix(avatar): add linear probing to resolve hash collisions in avatar assignment --- .../src/components/chat/AsChat/avatar.tsx | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/client/src/components/chat/AsChat/avatar.tsx b/packages/client/src/components/chat/AsChat/avatar.tsx index d508f94c..deae2656 100644 --- a/packages/client/src/components/chat/AsChat/avatar.tsx +++ b/packages/client/src/components/chat/AsChat/avatar.tsx @@ -42,15 +42,14 @@ const getFilteredPaths = (avatarSet: AvatarSet): string[] => { return filteredPaths; }; -const seededShuffle = (arr: T[], seed: number): T[] => { - const result = [...arr]; - let s = seed; - for (let i = result.length - 1; i > 0; i--) { - s = (s * 1664525 + 1013904223) & 0xffffffff; - const j = Math.abs(s) % (i + 1); - [result[i], result[j]] = [result[j], result[i]]; +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 result; + return Math.abs(hash); }; export const assignUniqueAvatars = ( @@ -65,12 +64,18 @@ export const assignUniqueAvatars = ( return assignment; } - const shuffledPaths = seededShuffle(filteredPaths, seed); + const N = filteredPaths.length; + const usedIndices = new Set(); - // Assign in first-appearance order (preserved by Set in the caller) - // so that existing agents keep their avatars when new agents join. - for (let i = 0; i < names.length; i++) { - assignment.set(names[i], shuffledPaths[i % shuffledPaths.length]); + 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;