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:
2026-05-21 23:10:07 +08:00
parent 8b7d4ec19a
commit a058b0ab8e
53 changed files with 5535 additions and 241 deletions
@@ -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 />}