|
1 | | -import React, { useState, useRef, useEffect, useCallback } from 'react'; |
| 1 | +import React, { useState, useRef, useEffect, useCallback, memo } from 'react'; |
2 | 2 | import { |
3 | 3 | Settings, |
4 | 4 | Trash2, |
@@ -195,7 +195,9 @@ Rules: |
195 | 195 | - Data mutations MUST go through file_write/file_delete. app_action only notifies the app to reload, it cannot write data. |
196 | 196 | - After file_write, ALWAYS call app_action with the corresponding REFRESH action. |
197 | 197 | - Do NOT skip step 5. If the user asked to save/create/add something, you must file_write the data. file_list alone does not save anything. |
198 | | -- NAS paths in guide.md like "/articles/xxx.json" map to "apps/{appName}/data/articles/xxx.json". |
| 198 | +- Do NOT skip steps 2-3. You MUST read guide.md before ANY file_write. The guide defines the ONLY valid directory structure and file schemas. Writing to paths not defined in guide.md will cause data loss — the app will not see the files. |
| 199 | +- NEVER invent or guess file paths. ALL file_write paths MUST exactly follow the directory structure in guide.md. For example, if guide.md defines entries under "/entries/{id}.json", you MUST write to "apps/{appName}/data/entries/{id}.json" — NOT to "apps/{appName}/data/{id}.json" or any other path. |
| 200 | +- NAS paths in guide.md like "/articles/xxx.json" map to "apps/{appName}/data/articles/xxx.json". This prefix rule applies to ALL paths — always preserve the full subdirectory structure from guide.md. |
199 | 201 |
|
200 | 202 | When you receive "[User performed action in ... (appName: xxx)]", the appName is already provided. Read its meta.yaml to understand available actions, then respond accordingly. For games, respond with your own move — think strategically. |
201 | 203 |
|
@@ -284,14 +286,105 @@ const ActionsTaken: React.FC<{ calls: string[] }> = ({ calls }) => { |
284 | 286 | ); |
285 | 287 | }; |
286 | 288 |
|
| 289 | +// --------------------------------------------------------------------------- |
| 290 | +// CharacterAvatar – crossfade between emotion media without flashing |
| 291 | +// --------------------------------------------------------------------------- |
| 292 | + |
| 293 | +interface AvatarLayer { |
| 294 | + url: string; |
| 295 | + type: 'video' | 'image'; |
| 296 | + active: boolean; |
| 297 | +} |
| 298 | + |
| 299 | +const CharacterAvatar: React.FC<{ |
| 300 | + character: CharacterConfig; |
| 301 | + emotion?: string; |
| 302 | + onEmotionEnd: () => void; |
| 303 | +}> = memo(({ character, emotion, onEmotionEnd }) => { |
| 304 | + const isIdle = !emotion; |
| 305 | + const media = resolveEmotionMedia(character, emotion || 'default'); |
| 306 | + |
| 307 | + const [layers, setLayers] = useState<AvatarLayer[]>(() => |
| 308 | + media ? [{ url: media.url, type: media.type, active: true }] : [], |
| 309 | + ); |
| 310 | + const activeUrl = layers.find((l) => l.active)?.url; |
| 311 | + |
| 312 | + useEffect(() => { |
| 313 | + if (!media) { |
| 314 | + setLayers([]); |
| 315 | + return; |
| 316 | + } |
| 317 | + if (media.url === activeUrl) return; |
| 318 | + setLayers((prev) => { |
| 319 | + if (prev.some((l) => l.url === media.url)) return prev; |
| 320 | + return [...prev, { url: media.url, type: media.type, active: false }]; |
| 321 | + }); |
| 322 | + }, [media?.url, activeUrl]); |
| 323 | + |
| 324 | + const handleMediaReady = useCallback((readyUrl: string) => { |
| 325 | + setLayers((prev) => { |
| 326 | + const staleUrls = prev.filter((l) => l.url !== readyUrl).map((l) => l.url); |
| 327 | + setTimeout(() => { |
| 328 | + setLayers((curr) => curr.filter((l) => !staleUrls.includes(l.url))); |
| 329 | + }, 300); |
| 330 | + return prev.map((l) => ({ ...l, active: l.url === readyUrl })); |
| 331 | + }); |
| 332 | + }, []); |
| 333 | + |
| 334 | + if (layers.length === 0) { |
| 335 | + return <div className={styles.avatarPlaceholder}>{character.character_name.charAt(0)}</div>; |
| 336 | + } |
| 337 | + |
| 338 | + return ( |
| 339 | + <> |
| 340 | + {layers.map((layer) => { |
| 341 | + const layerStyle: React.CSSProperties = { |
| 342 | + position: 'absolute', |
| 343 | + inset: 0, |
| 344 | + opacity: layer.active ? 1 : 0, |
| 345 | + transition: 'opacity 0.25s ease-out', |
| 346 | + }; |
| 347 | + if (layer.type === 'video') { |
| 348 | + return ( |
| 349 | + <video |
| 350 | + key={layer.url} |
| 351 | + className={styles.avatarImage} |
| 352 | + style={layerStyle} |
| 353 | + src={layer.url} |
| 354 | + autoPlay |
| 355 | + loop={layer.active ? isIdle : false} |
| 356 | + muted |
| 357 | + playsInline |
| 358 | + onCanPlay={!layer.active ? () => handleMediaReady(layer.url) : undefined} |
| 359 | + onEnded={layer.active && !isIdle ? onEmotionEnd : undefined} |
| 360 | + /> |
| 361 | + ); |
| 362 | + } |
| 363 | + return ( |
| 364 | + <img |
| 365 | + key={layer.url} |
| 366 | + className={styles.avatarImage} |
| 367 | + style={layerStyle} |
| 368 | + src={layer.url} |
| 369 | + alt={character.character_name} |
| 370 | + onLoad={!layer.active ? () => handleMediaReady(layer.url) : undefined} |
| 371 | + /> |
| 372 | + ); |
| 373 | + })} |
| 374 | + </> |
| 375 | + ); |
| 376 | +}); |
| 377 | + |
287 | 378 | // --------------------------------------------------------------------------- |
288 | 379 | // ChatPanel |
289 | 380 | // --------------------------------------------------------------------------- |
290 | 381 |
|
291 | | -const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({ |
292 | | - onClose, |
293 | | - visible = true, |
294 | | -}) => { |
| 382 | +const ChatPanel: React.FC<{ |
| 383 | + onClose: () => void; |
| 384 | + visible?: boolean; |
| 385 | + zIndex?: number; |
| 386 | + onFocus?: () => void; |
| 387 | +}> = ({ onClose, visible = true, zIndex, onFocus }) => { |
295 | 388 | // Character + Mod state (collection-based) |
296 | 389 | const [charCollection, setCharCollection] = useState<CharacterCollection>( |
297 | 390 | () => loadCharacterCollectionSync() ?? DEFAULT_CHAR_COLLECTION, |
@@ -915,37 +1008,19 @@ const ChatPanel: React.FC<{ onClose: () => void; visible?: boolean }> = ({ |
915 | 1008 |
|
916 | 1009 | return ( |
917 | 1010 | <> |
918 | | - <div className={styles.panel} data-testid="chat-panel"> |
| 1011 | + <div |
| 1012 | + className={styles.panel} |
| 1013 | + data-testid="chat-panel" |
| 1014 | + style={zIndex !== null && zIndex !== undefined ? { zIndex } : undefined} |
| 1015 | + onMouseDown={onFocus} |
| 1016 | + > |
919 | 1017 | {/* Left: Character Avatar */} |
920 | 1018 | <div className={styles.avatarSide}> |
921 | | - {(() => { |
922 | | - // Resolve media for the active emotion, or fall back to "peaceful" as idle |
923 | | - const idleEmotion = 'default'; |
924 | | - const activeEmotion = currentEmotion || idleEmotion; |
925 | | - const isIdle = !currentEmotion; |
926 | | - const media = resolveEmotionMedia(character, activeEmotion); |
927 | | - |
928 | | - if (!media) { |
929 | | - return ( |
930 | | - <div className={styles.avatarPlaceholder}>{character.character_name.charAt(0)}</div> |
931 | | - ); |
932 | | - } |
933 | | - |
934 | | - return media.type === 'video' ? ( |
935 | | - <video |
936 | | - key={media.url} |
937 | | - className={styles.avatarImage} |
938 | | - src={media.url} |
939 | | - autoPlay |
940 | | - loop={isIdle} |
941 | | - muted |
942 | | - playsInline |
943 | | - onEnded={isIdle ? undefined : () => setCurrentEmotion(undefined)} |
944 | | - /> |
945 | | - ) : ( |
946 | | - <img className={styles.avatarImage} src={media.url} alt={character.character_name} /> |
947 | | - ); |
948 | | - })()} |
| 1019 | + <CharacterAvatar |
| 1020 | + character={character} |
| 1021 | + emotion={currentEmotion} |
| 1022 | + onEmotionEnd={() => setCurrentEmotion(undefined)} |
| 1023 | + /> |
949 | 1024 | </div> |
950 | 1025 |
|
951 | 1026 | {/* Right: Chat */} |
|
0 commit comments