fix: 第三轮修复 — 前端Session切换、DevTools UI刷新保持、头像背景替换
1. 修复前端清空对话无反应 (clearMainSessionMessages 链路) 2. 修复清除所有对话后侧边栏残留 + 重复新增按钮 3. 修复侧边栏点击无法切换会话 (Zustand 竞态 + URL hash) 4. 修复 URL 不显示 session ID (hash 同步链) 5. DevTools 会话监看刷新保持展开/折叠状态 6. 首页性能仪表盘去重 + 资源使用卡片 60s sparkline 7. DevTools 全局刷新改为 DOM 局部增量更新 8. 替换前端昔涟头像、聊天背景、用户头像为实际图片 9. 修复图片文件名 (双.png + 目录拼写)
|
After Width: | Height: | Size: 668 KiB |
|
After Width: | Height: | Size: 684 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 7.0 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
@@ -64,6 +64,7 @@ export default function App() {
|
||||
const found = currentSessions.find((s) => s.id === hashId);
|
||||
if (found) {
|
||||
setCurrentSessionId(found.id);
|
||||
setHashSessionId(found.id);
|
||||
await loadMessagesFromServer(found.id);
|
||||
return;
|
||||
}
|
||||
@@ -73,6 +74,7 @@ export default function App() {
|
||||
if (resp.messages && resp.messages.length > 0) {
|
||||
// 消息存在说明会话仍有效(虽然不在列表里,可能是刚创建的)
|
||||
setCurrentSessionId(hashId);
|
||||
setHashSessionId(hashId);
|
||||
const msgs = resp.messages.map((m: any, i: number) => ({
|
||||
id: m.id ? String(m.id) : `hist_${i}_${Date.now()}`,
|
||||
role: m.role,
|
||||
|
||||
@@ -12,7 +12,7 @@ export function ChatContainer() {
|
||||
const statusLabel = continuousMode ? '主对话 · 进行中' : '';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
<div className="flex flex-col h-full overflow-hidden chat-background">
|
||||
{/* 状态指示器栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-1.5 border-b border-pink-100 dark:border-pink-900 bg-pink-50/50 dark:bg-pink-950/20 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
@@ -120,12 +121,35 @@ export function MessageBubble({ role, content, timestamp, isStreaming }: Message
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 用户头像占位 */}
|
||||
{isUser && (
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-pink-300 to-pink-500 flex items-center justify-center flex-shrink-0 mt-1 shadow-sm">
|
||||
<span className="text-white text-sm">开拓者</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 用户头像 */}
|
||||
{isUser && <UserAvatar />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 用户头像组件:管理员使用 Admin_Avatar.jpg,普通用户使用 Default_Avatar.png */
|
||||
function UserAvatar() {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const userId = useAuthStore((s) => s.userId);
|
||||
const isAdmin = userId?.startsWith('admin_') ?? false;
|
||||
const avatarSrc = isAdmin
|
||||
? '/images/User_Avatar/Admin_Avatar.jpg'
|
||||
: '/images/User_Avatar/Default_Avatar.png';
|
||||
|
||||
if (imgError) {
|
||||
return (
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-pink-300 to-pink-500 flex items-center justify-center flex-shrink-0 mt-1 shadow-sm">
|
||||
<span className="text-white text-sm">开拓者</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={avatarSrc}
|
||||
alt={isAdmin ? '管理员' : '用户'}
|
||||
className="w-8 h-8 rounded-full object-cover flex-shrink-0 mt-1 shadow-sm"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
import { usePersonaStore } from '@/store/personaStore';
|
||||
import type { CyreneForm } from '@/types/persona';
|
||||
import type { CyreneForm, Mood } from '@/types/persona';
|
||||
|
||||
interface CyreneAvatarProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const FORM_AVATAR: Record<CyreneForm, string> = {
|
||||
/**
|
||||
* 根据昔涟形态和心情获取对应的头像图片路径。
|
||||
*
|
||||
* 图片目录结构:
|
||||
* - 1st_Form/ (空 — 尚未收集,回退到 2nd_Form)
|
||||
* - 2nd_Form/: Cyrene-2F-N-Happy-1.png, Cyrene-2F-N-Tangle-1.png
|
||||
* - 3rd_Form/: Cyrene-3F-Q-Happy-1.png, Cyrene-3F-Q-Happy-2.png
|
||||
*/
|
||||
function getAvatarPath(form: CyreneForm, _mood: Mood): string {
|
||||
const base = '/images/Cyrene_Avatar';
|
||||
|
||||
// de_moi_ge 形态使用 3rd_Form,其余形态(mimi/default)使用 2nd_Form
|
||||
// 注意:1st_Form 目录为空,mimi 也回退到 2nd_Form
|
||||
if (form === 'de_moi_ge') {
|
||||
return `${base}/3rd_Form/Cyrene-3F-Q-Happy-1.png`;
|
||||
}
|
||||
|
||||
// mimi / default 形态:happy 用 Happy 图,thoughtful/worried 用 Tangle 图
|
||||
if (_mood === 'thoughtful' || _mood === 'worried') {
|
||||
return `${base}/2nd_Form/Cyrene-2F-N-Tangle-1.png`;
|
||||
}
|
||||
|
||||
return `${base}/2nd_Form/Cyrene-2F-N-Happy-1.png`;
|
||||
}
|
||||
|
||||
/** 图片加载失败时的回退 emoji */
|
||||
const FORM_FALLBACK_EMOJI: Record<CyreneForm, string> = {
|
||||
mimi: '🌸',
|
||||
default: '🌺',
|
||||
de_moi_ge: '🌌',
|
||||
@@ -18,18 +45,35 @@ const SIZE_CLASS = {
|
||||
lg: 'w-20 h-20 text-4xl',
|
||||
};
|
||||
|
||||
const FORM_LABEL: Record<CyreneForm, string> = {
|
||||
mimi: '迷迷',
|
||||
default: '小昔涟',
|
||||
de_moi_ge: '德谬歌',
|
||||
};
|
||||
|
||||
export function CyreneAvatar({ size = 'md', className = '' }: CyreneAvatarProps) {
|
||||
const { currentForm } = usePersonaStore();
|
||||
const emoji = FORM_AVATAR[currentForm] || '🌸';
|
||||
const { currentForm, mood } = usePersonaStore();
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const avatarSrc = getAvatarPath(currentForm, mood);
|
||||
const fallbackEmoji = FORM_FALLBACK_EMOJI[currentForm] || '🌸';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${SIZE_CLASS[size]} rounded-full bg-gradient-to-br from-pink-200 to-pink-400 dark:from-pink-800 dark:to-pink-600 flex items-center justify-center shadow-md ${className}`}
|
||||
title={`昔涟 · ${currentForm === 'mimi' ? '迷迷' : currentForm === 'de_moi_ge' ? '德谬歌' : '小昔涟'}`}
|
||||
className={`${SIZE_CLASS[size]} rounded-full bg-gradient-to-br from-pink-200 to-pink-400 dark:from-pink-800 dark:to-pink-600 flex items-center justify-center shadow-md overflow-hidden ${className}`}
|
||||
title={`昔涟 · ${FORM_LABEL[currentForm]}`}
|
||||
>
|
||||
<span role="img" aria-label="昔涟">
|
||||
{emoji}
|
||||
</span>
|
||||
{imgError ? (
|
||||
<span role="img" aria-label="昔涟">
|
||||
{fallbackEmoji}
|
||||
</span>
|
||||
) : (
|
||||
<img
|
||||
src={avatarSrc}
|
||||
alt={`昔涟 · ${FORM_LABEL[currentForm]}`}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useSessionStore, isAdminUser } from '@/store/sessionStore';
|
||||
import { useChatStore } from '@/store/chatStore';
|
||||
import { createSession as apiCreateSession } from '@/api/sessions';
|
||||
import type { Session } from '@/types/session';
|
||||
|
||||
const SESSION_HASH_PREFIX = 'session=';
|
||||
|
||||
function setHashSessionId(sessionId: string | null) {
|
||||
if (sessionId) {
|
||||
window.location.hash = SESSION_HASH_PREFIX + sessionId;
|
||||
} else {
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成简易随机ID */
|
||||
function randomID(n: number = 12): string {
|
||||
const letters = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
@@ -73,6 +83,7 @@ export function useSession() {
|
||||
};
|
||||
addSession(newSession);
|
||||
setCurrentSessionId(newSession.id);
|
||||
setHashSessionId(newSession.id);
|
||||
return newSession;
|
||||
}
|
||||
return null;
|
||||
@@ -111,6 +122,7 @@ export function useSession() {
|
||||
const setCurrentSession = useCallback(
|
||||
async (id: string) => {
|
||||
setCurrentSessionId(id);
|
||||
setHashSessionId(id);
|
||||
// 加载该会话的消息历史
|
||||
await loadMessagesFromServer(id);
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useChatStore } from '@/store/chatStore';
|
||||
import { useSessionStore } from '@/store/sessionStore';
|
||||
import { getToken } from '@/api/client';
|
||||
import { fetchMessages } from '@/api/sessions';
|
||||
import type { WSClientMessage, WSServerMessage } from '@/types/chat';
|
||||
|
||||
const WS_BASE_URL =
|
||||
@@ -14,7 +13,6 @@ export function useWebSocket() {
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const shouldReconnectRef = useRef(true);
|
||||
const activeSessionRef = useRef<string | null>(null);
|
||||
const loadingRef = useRef(false); // 防止重复加载消息
|
||||
|
||||
// 订阅 sessionStore 中的 currentSessionId 变化
|
||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||
@@ -83,38 +81,11 @@ export function useWebSocket() {
|
||||
wsRef.current = ws;
|
||||
}, []);
|
||||
|
||||
// 会话切换时:先通过 REST API 加载历史消息,再建立 WS 连接
|
||||
// 会话切换时:重建 WebSocket 连接(消息历史由 useSession.setCurrentSession 负责加载)
|
||||
useEffect(() => {
|
||||
activeSessionRef.current = currentSessionId;
|
||||
|
||||
const loadAndConnect = async () => {
|
||||
// 如果是从 URL 恢复的 session,先加载历史消息
|
||||
if (currentSessionId && !loadingRef.current) {
|
||||
loadingRef.current = true;
|
||||
try {
|
||||
const resp = await fetchMessages(currentSessionId);
|
||||
const rawMessages = resp.messages || [];
|
||||
const msgs = rawMessages.map((m: any, i: number) => ({
|
||||
id: m.id ? String(m.id) : `hist_${i}_${Date.now()}`,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp:
|
||||
typeof m.created_at === 'number' ? m.created_at : Date.now(),
|
||||
isStreaming: false,
|
||||
}));
|
||||
useSessionStore.getState().setMessages(msgs);
|
||||
useChatStore.getState().setMessages(msgs);
|
||||
} catch {
|
||||
// 加载失败不影响后续连接
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
connect();
|
||||
};
|
||||
|
||||
loadAndConnect();
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
if (reconnectTimerRef.current) {
|
||||
|
||||
@@ -103,6 +103,21 @@
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* ===== 聊天背景 ===== */
|
||||
.chat-background {
|
||||
background-image: url('/images/Cyrene_ChatBackground/Vertical/2nd_Form/1.png');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* 横屏时使用 Landscape 背景 */
|
||||
@media (orientation: landscape) {
|
||||
.chat-background {
|
||||
background-image: url('/images/Cyrene_ChatBackground/Landscape/3rd_Form/1.png');
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色模式 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
|
||||
@@ -66,9 +66,10 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
||||
messages: state.currentSessionId === id ? [] : state.messages,
|
||||
})),
|
||||
setCurrentSessionId: (id) => {
|
||||
const oldId = get().currentSessionId;
|
||||
set({ currentSessionId: id });
|
||||
// 切换会话时清空旧消息,等待加载
|
||||
if (id !== get().currentSessionId) {
|
||||
if (id !== oldId) {
|
||||
set({ messages: [], loading: true });
|
||||
useChatStore.getState().clearMessages();
|
||||
}
|
||||
@@ -141,22 +142,31 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
||||
if (!ok) return;
|
||||
|
||||
const state = get();
|
||||
const remaining = state.sessions.filter((s) => s.id !== id);
|
||||
const wasCurrent = state.currentSessionId === id;
|
||||
|
||||
// 更新本地列表
|
||||
set({ sessions: remaining });
|
||||
// 从服务端重新加载会话列表,保证侧边栏同步
|
||||
await get().loadSessionsFromServer(userId);
|
||||
const refreshed = get().sessions;
|
||||
|
||||
if (wasCurrent) {
|
||||
if (remaining.length > 0) {
|
||||
if (refreshed.length > 0) {
|
||||
// 切换到列表中的第一个会话
|
||||
const nextId = remaining[0].id;
|
||||
const nextId = refreshed[0].id;
|
||||
set({ currentSessionId: nextId });
|
||||
await get().loadMessagesFromServer(nextId);
|
||||
} else {
|
||||
// 没有会话了:管理员回到主对话,普通用户创建新对话
|
||||
set({ currentSessionId: null, messages: [] });
|
||||
useChatStore.getState().clearMessages();
|
||||
|
||||
// 管理员:自动创建主对话
|
||||
if (isAdminUser(userId)) {
|
||||
const mainSession = await get().ensureMainSession(userId);
|
||||
if (mainSession) {
|
||||
set({ currentSessionId: mainSession.id });
|
||||
useChatStore.getState().clearMessages();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -168,8 +178,31 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
||||
const ok = await apiDeleteAllSessions(userId);
|
||||
if (!ok) return;
|
||||
|
||||
// 从服务端重新加载会话列表
|
||||
await get().loadSessionsFromServer(userId);
|
||||
const refreshed = get().sessions;
|
||||
|
||||
if (refreshed.length > 0) {
|
||||
// 服务端仍有会话(不应该发生,但做防御性处理)
|
||||
const latest = refreshed[0];
|
||||
set({ currentSessionId: latest.id, messages: [] });
|
||||
useChatStore.getState().clearMessages();
|
||||
await get().loadMessagesFromServer(latest.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// 所有会话已删除
|
||||
set({ sessions: [], currentSessionId: null, messages: [] });
|
||||
useChatStore.getState().clearMessages();
|
||||
|
||||
// 管理员:自动创建主对话
|
||||
if (isAdminUser(userId)) {
|
||||
const mainSession = await get().ensureMainSession(userId);
|
||||
if (mainSession) {
|
||||
set({ currentSessionId: mainSession.id });
|
||||
useChatStore.getState().clearMessages();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||