fix: 第一轮修复 - 记忆管理/IoT操控/历史消息持久化/动作消息/链路优化/安全配置
- 修复记忆管理数据库连接不可用 (ai-core重编译+Unicode修复) - 修复IoT子会话工具调用链路日志缺失 - 新增最终审查子会话(review_provider) 支持消息格式解析拆分 - 实现历史消息持久化(后端存储+前端分页加载) - 前端新增动作消息(ActionMessage)类型和渲染 - 优化对话链路速度(非阻塞子会话+快速问候通道) - JWT密钥环境变量化(无默认值启动panic) - Token自动刷新机制(401拦截器+refresh接口) - WebSocket指数退避重连(jitter+最大10次) - localStorage清理一致性(cyrene_前缀+版本检查) - IoT环境变量统一为IOT_SERVICE_URL
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useChatStore } from '@/store/chatStore';
|
||||
import { useSessionStore } from '@/store/sessionStore';
|
||||
import { MessageList } from './MessageList';
|
||||
import { IoTStatusBar } from './IoTStatusBar';
|
||||
|
||||
@@ -43,7 +44,18 @@ export function ChatContainer() {
|
||||
|
||||
{/* 消息列表 */}
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<MessageList messages={messages} isTyping={isTyping} />
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isTyping={isTyping}
|
||||
hasMoreMessages={useChatStore((s) => s.hasMoreMessages)}
|
||||
isLoadingHistory={useChatStore((s) => s.isLoadingHistory)}
|
||||
onLoadMore={() => {
|
||||
const sessionId = useSessionStore.getState().currentSessionId;
|
||||
if (sessionId) {
|
||||
useSessionStore.getState().loadMoreMessagesFromServer(sessionId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* IoT 状态栏(底部) */}
|
||||
|
||||
@@ -2,17 +2,18 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useSpeechSynthesis } from '@/hooks/useSpeechSynthesis';
|
||||
import type { MessageAttachment, MultiMessageItem, StreamSegment } from '@/types/chat';
|
||||
import type { MessageAttachment, MultiMessageItem, StreamSegment, MessageDisplayType } from '@/types/chat';
|
||||
import { ImageLightbox } from './ImageLightbox';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
role: 'user' | 'assistant' | 'system' | 'action';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isStreaming?: boolean;
|
||||
attachments?: MessageAttachment[];
|
||||
multiMessages?: MultiMessageItem[];
|
||||
streamSegments?: StreamSegment[];
|
||||
msgType?: MessageDisplayType;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,8 +140,24 @@ function AIMessageActions({ content }: { content: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({ role, content, timestamp, isStreaming, attachments, multiMessages, streamSegments }: MessageBubbleProps) {
|
||||
export function MessageBubble({
|
||||
role,
|
||||
content,
|
||||
timestamp,
|
||||
isStreaming,
|
||||
attachments,
|
||||
multiMessages,
|
||||
streamSegments,
|
||||
msgType,
|
||||
}: MessageBubbleProps) {
|
||||
const isUser = role === 'user';
|
||||
const isAction = role === 'action' || msgType === 'action';
|
||||
|
||||
// 动作消息使用独立的渲染方式
|
||||
if (isAction) {
|
||||
return <ActionMessageBubble content={content} timestamp={timestamp} />;
|
||||
}
|
||||
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
@@ -263,6 +280,27 @@ export function MessageBubble({ role, content, timestamp, isStreaming, attachmen
|
||||
);
|
||||
}
|
||||
|
||||
/** 动作消息气泡 — 灰色/斜体/居中,视觉上与聊天消息区分 */
|
||||
function ActionMessageBubble({ content, timestamp }: { content: string; timestamp: number }) {
|
||||
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex justify-center px-4 py-1 animate-fadeIn">
|
||||
<div className="max-w-[70%] text-center">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 italic leading-relaxed whitespace-pre-wrap break-words">
|
||||
<span className="select-none text-gray-300 dark:text-gray-600">~ </span>
|
||||
{content}
|
||||
<span className="select-none text-gray-300 dark:text-gray-600"> ~</span>
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-300 dark:text-gray-600 mt-0.5">{time}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 图片缩略图组件 */
|
||||
function ImageThumbnail({
|
||||
attachment,
|
||||
|
||||
@@ -6,17 +6,29 @@ import type { Message } from '@/types/chat';
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
isTyping: boolean;
|
||||
hasMoreMessages?: boolean;
|
||||
isLoadingHistory?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
}
|
||||
|
||||
export function MessageList({ messages, isTyping }: MessageListProps) {
|
||||
export function MessageList({
|
||||
messages,
|
||||
isTyping,
|
||||
hasMoreMessages = false,
|
||||
isLoadingHistory = false,
|
||||
onLoadMore,
|
||||
}: MessageListProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 自动滚动到底部
|
||||
// 自动滚动到底部(仅在消息追加时,不在加载历史时)
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, isTyping]);
|
||||
if (!isLoadingHistory) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [messages, isTyping, isLoadingHistory]);
|
||||
|
||||
if (messages.length === 0) {
|
||||
if (messages.length === 0 && !isLoadingHistory) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 p-8">
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
@@ -27,7 +39,33 @@ export function MessageList({ messages, isTyping }: MessageListProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-pink-200 dark:scrollbar-thumb-pink-900">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-pink-200 dark:scrollbar-thumb-pink-900"
|
||||
>
|
||||
{/* 加载更多历史消息按钮 */}
|
||||
{hasMoreMessages && (
|
||||
<div className="flex justify-center py-3">
|
||||
<button
|
||||
onClick={onLoadMore}
|
||||
disabled={isLoadingHistory}
|
||||
className="text-xs text-gray-400 hover:text-pink-500 disabled:text-gray-300 disabled:cursor-not-allowed px-4 py-1.5 rounded-full bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:border-pink-200 dark:hover:border-pink-800 transition-all"
|
||||
>
|
||||
{isLoadingHistory ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg className="animate-spin h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
加载中...
|
||||
</span>
|
||||
) : (
|
||||
'加载更早的消息'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
@@ -36,8 +74,7 @@ export function MessageList({ messages, isTyping }: MessageListProps) {
|
||||
timestamp={msg.timestamp}
|
||||
isStreaming={msg.isStreaming}
|
||||
attachments={msg.attachments}
|
||||
multiMessages={(msg as any).multiMessages}
|
||||
streamSegments={(msg as any).streamSegments}
|
||||
msgType={msg.msgType}
|
||||
/>
|
||||
))}
|
||||
{isTyping && !messages.some((m) => m.isStreaming) && <TypingIndicator />}
|
||||
|
||||
Reference in New Issue
Block a user