feat: 第四轮大版本更新 — 修复4个严重Bug、2个UI Bug,实现自主思考重构与主-子会话架构

## 🐛 Bug 修复
- 修复前端对话无响应:消除 ChatContainer 中的双重 WebSocket 连接,优化 sendMessage 失败提示
- 修复 Memory-Service 数据库迁移失败:ai-core 和 memory-service 均添加 ALTER TABLE ADD COLUMN IF NOT EXISTS 模式演化
- 修复语音/STT 不可用:添加 MediaRecorder API 降级方案,修复 whisper-cli 输出文件名错误
- 修复仪表盘数据库按钮失效:补充按钮 ID 属性,重写 controlDB() 控制逻辑

## 🎨 UI 修复
- 修正用户消息头像位置:从 flex-row-reverse 改为 justify-end
- 移除空聊天列表的 emoji 占位图标

##  新功能
- devtools 新增 STT 处理日志面板(环形缓冲区 + WebSocket 广播 + 可视化表格)
- 新增 ADMIN_NICKNAME 环境变量,支持自定义管理员昵称

## 🔧 改进
- 注册流程增加昵称必填字段(前后端同步)

## 🏗️ 架构重构
- 重构自主思考逻辑:从定时器轮询改为事件驱动(对话后触发 + 静默检测),优化提示词使其更自然人性化
- 实现主-子会话架构:新增 4 种子会话类型(general/memory/iot/knowledge),意图分析 → 并行分发 → 结果合成流程

## 📄 新增文档
- docs/architecture/main-session-sub-session-design.md — 子会话架构设计文档
This commit is contained in:
2026-05-19 21:09:48 +08:00
parent bcf4d4e621
commit 26a61cb57c
42 changed files with 2953 additions and 568 deletions
+17 -2
View File
@@ -37,6 +37,7 @@ export default function App() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = useState('');
const [nickname, setNickname] = useState('');
const [verifyCode, setVerifyCode] = useState('');
const [error, setError] = useState('');
const [successMsg, setSuccessMsg] = useState('');
@@ -166,7 +167,11 @@ export default function App() {
setError('请输入邮箱');
return;
}
const result = await register(username, password, email, verifyCode || '000000');
if (!nickname) {
setError('请输入昵称');
return;
}
const result = await register(username, password, email, nickname, verifyCode || '000000');
if (!result.success) {
setError(result.error || '注册失败');
} else {
@@ -239,6 +244,16 @@ export default function App() {
/>
)}
{authMode === 'register' && (
<input
type="text"
placeholder="昵称 (昔涟会这样称呼你)"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400"
/>
)}
<input
type="password"
placeholder="密码"
@@ -277,7 +292,7 @@ export default function App() {
) : (
<button
onClick={handleRegister}
disabled={authLoading || !username || !password || !email}
disabled={authLoading || !username || !password || !email || !nickname}
className="w-full py-2.5 rounded-xl bg-pink-400 hover:bg-pink-500 disabled:bg-pink-300 text-white font-medium text-sm transition-colors"
>
{authLoading ? '请稍候...' : '注册并进入 ♪'}
+2 -2
View File
@@ -98,10 +98,10 @@ export async function login(username: string, password: string): Promise<ApiResp
return resp;
}
export async function register(username: string, password: string, email: string, verifyCode: string): Promise<ApiResponse<AuthResponse>> {
export async function register(username: string, password: string, email: string, nickname: string, verifyCode: string): Promise<ApiResponse<AuthResponse>> {
const resp = await request<AuthResponse>('/auth/register', {
method: 'POST',
body: { username, password, email, verify_code: verifyCode },
body: { username, password, email, nickname, verify_code: verifyCode },
auth: false,
});
if (resp.data?.token) {
@@ -1,10 +1,10 @@
import { useChat } from '@/hooks/useChat';
import { useChatStore } from '@/store/chatStore';
import { MessageList } from './MessageList';
import { IoTStatusBar } from './IoTStatusBar';
export function ChatContainer() {
const { messages, isTyping } = useChat();
const messages = useChatStore((s) => s.messages);
const isTyping = useChatStore((s) => s.isTyping);
const continuousMode = useChatStore((s) => s.continuousMode);
const backgroundThinkingStatus = useChatStore((s) => s.backgroundThinkingStatus);
@@ -30,6 +30,7 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
const {
isListening,
isSupported,
isFallbackMode,
interimText,
finalText,
error,
@@ -423,14 +424,14 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
{/* 语音输入状态提示 */}
{isListening && (
<p className="text-xs text-red-400 text-center animate-pulse">
🎤 ...
{isFallbackMode ? '🎤 后端语音识别中...' : '🎤 正在聆听...'}
<span className="text-gray-400 ml-2">(Ctrl+Shift+V )</span>
</p>
)}
{mode !== 'text' && !isListening && (
<p className="text-xs text-gray-400 text-center">
{mode === 'voice_msg' ? '语音消息功能即将上线 ♪' : ''}
{mode === 'voice_msg' ? '点击麦克风按钮开始语音输入 ♪' : ''}
</p>
)}
</div>
@@ -154,7 +154,7 @@ export function MessageBubble({ role, content, timestamp, isStreaming, attachmen
const imageAttachments = attachments?.filter((a) => a.type === 'image') ?? [];
return (
<div className={`flex px-4 py-2 gap-3 ${isUser ? 'flex-row-reverse' : ''}`}>
<div className={`flex px-4 py-2 gap-3 ${isUser ? 'justify-end' : ''}`}>
{/* 头像 */}
{!isUser && (
<CyreneAvatar size="sm" className="flex-shrink-0 mt-1" />
@@ -19,12 +19,8 @@ export function MessageList({ messages, isTyping }: MessageListProps) {
if (messages.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 p-8">
<div className="text-6xl mb-4">🌸</div>
<p className="text-lg font-medium text-pink-300 mb-2">
</p>
<p className="text-sm">
~
<p className="text-sm text-gray-400 dark:text-gray-500">
</p>
</div>
);
+131 -20
View File
@@ -1,4 +1,5 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { transcribeAudio } from '@/api/voice';
/**
* 浏览器 Speech Recognition API 的类型声明补充
@@ -11,6 +12,8 @@ interface UseSpeechRecognitionReturn {
interimText: string;
finalText: string;
error: string | null;
/** 是否正在使用后端 STT 降级模式 */
isFallbackMode: boolean;
startListening: () => void;
stopListening: () => void;
resetText: () => void;
@@ -30,7 +33,7 @@ const ERROR_MESSAGES: Record<RecognitionError, string> = {
'no-speech': '未检测到语音,请再试一次',
'aborted': '语音输入已中止',
'audio-capture': '无法访问麦克风设备',
'network': '网络错误,语音识别需要网络连接',
'network': '网络错误,已切换到后端语音识别',
'not-allowed': '麦克风权限被拒绝,请在浏览器设置中允许麦克风访问',
'service-not-allowed': '语音识别服务不可用',
'bad-grammar': '语法配置错误',
@@ -46,16 +49,25 @@ export function useSpeechRecognition(): UseSpeechRecognitionReturn {
const [interimText, setInterimText] = useState('');
const [finalText, setFinalText] = useState('');
const [error, setError] = useState<string | null>(null);
const [isFallbackMode, setIsFallbackMode] = useState(false);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const finalAccRef = useRef<string[]>([]);
// --- 后端 STT 降级:MediaRecorder ---
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const fallbackStreamRef = useRef<MediaStream | null>(null);
const SpeechRecognitionAPI =
typeof window !== 'undefined'
? window.SpeechRecognition || window.webkitSpeechRecognition
: undefined;
const isSupported = SpeechRecognitionAPI != null;
// 只要浏览器支持 MediaRecorder 就算语音输入可用
const isSupported =
typeof window !== 'undefined' &&
(SpeechRecognitionAPI != null || typeof MediaRecorder !== 'undefined');
const resetText = useCallback(() => {
setInterimText('');
@@ -63,14 +75,35 @@ export function useSpeechRecognition(): UseSpeechRecognitionReturn {
finalAccRef.current = [];
}, []);
const stopListening = useCallback(() => {
// 清理浏览器 STT
const cleanupBrowserSTT = useCallback(() => {
if (recognitionRef.current) {
recognitionRef.current.stop();
recognitionRef.current.abort();
recognitionRef.current = null;
}
setIsListening(false);
}, []);
// 清理 MediaRecorder 降级
const cleanupFallback = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
mediaRecorderRef.current = null;
if (fallbackStreamRef.current) {
fallbackStreamRef.current.getTracks().forEach((t) => t.stop());
fallbackStreamRef.current = null;
}
audioChunksRef.current = [];
}, []);
const stopListening = useCallback(() => {
cleanupBrowserSTT();
cleanupFallback();
setIsListening(false);
setIsFallbackMode(false);
setError(null);
}, [cleanupBrowserSTT, cleanupFallback]);
// 自动静默超时:若 3 秒内无任何结果,自动停止
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -83,22 +116,93 @@ export function useSpeechRecognition(): UseSpeechRecognitionReturn {
}, 3000);
}, [stopListening]);
const startListening = useCallback(() => {
if (!SpeechRecognitionAPI) {
setError('浏览器不支持语音识别');
return;
}
// --- 后端 STT 降级:启动 MediaRecorder 录音 ---
const startFallbackRecognition = useCallback(async () => {
try {
setIsFallbackMode(true);
setError(null);
// 如果已有实例则先停止
if (recognitionRef.current) {
recognitionRef.current.abort();
recognitionRef.current = null;
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
fallbackStreamRef.current = stream;
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: MediaRecorder.isTypeSupported('audio/webm')
? 'audio/webm'
: 'audio/mp4';
const recorder = new MediaRecorder(stream, { mimeType });
mediaRecorderRef.current = recorder;
audioChunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) {
audioChunksRef.current.push(e.data);
}
};
recorder.onstop = async () => {
const audioBlob = new Blob(audioChunksRef.current, { type: recorder.mimeType });
audioChunksRef.current = [];
// 调用后端 STT
const result = await transcribeAudio(audioBlob, 'zh');
if (result.error) {
setError(`后端语音识别失败: ${result.error}`);
setIsListening(false);
setIsFallbackMode(false);
return;
}
if (result.data?.text) {
setFinalText(result.data.text);
} else {
setError('未识别到语音内容');
}
setIsListening(false);
setIsFallbackMode(false);
};
recorder.onerror = () => {
setError('录音设备出错,请重试');
setIsListening(false);
setIsFallbackMode(false);
};
recorder.start();
setIsListening(true);
// 降级模式没有 interim results,所以超时设长一点
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
silenceTimerRef.current = setTimeout(() => {
stopListening();
}, 15000); // 15s 最大录音时长
} catch (err) {
const msg = err instanceof DOMException && err.name === 'NotAllowedError'
? '麦克风权限被拒绝'
: err instanceof Error ? err.message : '无法启动录音';
setError(msg);
setIsFallbackMode(false);
}
}, [stopListening]);
const startListening = useCallback(() => {
// 如果已有实例则先停止
cleanupBrowserSTT();
cleanupFallback();
setError(null);
setInterimText('');
setFinalText('');
finalAccRef.current = [];
setIsFallbackMode(false);
if (!SpeechRecognitionAPI) {
// 浏览器不支持 SpeechRecognition,直接使用后端 STT
startFallbackRecognition();
return;
}
const recognition = new SpeechRecognitionAPI();
recognition.continuous = true;
@@ -124,6 +228,14 @@ export function useSpeechRecognition(): UseSpeechRecognitionReturn {
};
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
if (event.error === 'network') {
// 网络错误 → 自动降级到后端 STT
cleanupBrowserSTT();
setError('浏览器语音识别网络不可达,正在切换到后端识别...');
startFallbackRecognition();
return;
}
const message = getRecognitionError(event.error);
setError(message);
@@ -157,7 +269,7 @@ export function useSpeechRecognition(): UseSpeechRecognitionReturn {
recognitionRef.current = recognition;
recognition.start();
}, [SpeechRecognitionAPI, resetSilenceTimer]);
}, [SpeechRecognitionAPI, resetSilenceTimer, cleanupBrowserSTT, cleanupFallback, startFallbackRecognition]);
// cleanup: 组件卸载时停止识别
useEffect(() => {
@@ -165,12 +277,10 @@ export function useSpeechRecognition(): UseSpeechRecognitionReturn {
if (silenceTimerRef.current) {
clearTimeout(silenceTimerRef.current);
}
if (recognitionRef.current) {
recognitionRef.current.abort();
recognitionRef.current = null;
}
cleanupBrowserSTT();
cleanupFallback();
};
}, []);
}, [cleanupBrowserSTT, cleanupFallback]);
return {
isListening,
@@ -178,6 +288,7 @@ export function useSpeechRecognition(): UseSpeechRecognitionReturn {
interimText,
finalText,
error,
isFallbackMode,
startListening,
stopListening,
resetText,
+42 -7
View File
@@ -8,22 +8,30 @@ import type { WSClientMessage, WSServerMessage } from '@/types/chat';
const WS_BASE_URL =
import.meta.env.VITE_WS_URL || 'ws://localhost:8080/ws/chat';
let wsInstanceCounter = 0;
export function useWebSocket() {
const [isConnected, setIsConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const shouldReconnectRef = useRef(true);
const activeSessionRef = useRef<string | null>(null);
const instanceIdRef = useRef(++wsInstanceCounter);
// 订阅 sessionStore 中的 currentSessionId 变化
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const connect = useCallback(() => {
const instanceId = instanceIdRef.current;
const token = getToken();
if (!token) return;
if (!token) {
console.warn(`[WS#${instanceId}] connect: 无 token,跳过连接`);
return;
}
// 关闭旧连接
if (wsRef.current) {
console.log(`[WS#${instanceId}] 关闭旧连接`);
shouldReconnectRef.current = false;
wsRef.current.close();
wsRef.current = null;
@@ -34,12 +42,13 @@ export function useWebSocket() {
? `${WS_BASE_URL}?token=${token}&session_id=${sessionID}`
: `${WS_BASE_URL}?token=${token}`;
console.log(`[WS#${instanceId}] 正在连接, session_id=${sessionID || '(无)'}`);
const ws = new WebSocket(url);
ws.onopen = () => {
setIsConnected(true);
shouldReconnectRef.current = true;
console.log('[WS] 已连接, session_id:', sessionID);
console.log(`[WS#${instanceId}] 已连接, session_id:`, sessionID);
// 连接后发送会话恢复消息,恢复后端上下文
const sid = useSessionStore.getState().currentSessionId;
@@ -50,15 +59,15 @@ export function useWebSocket() {
timestamp: Date.now(),
};
ws.send(JSON.stringify(resumeMsg));
console.log('[WS] 已发送会话恢复请求, session_id:', sid);
console.log(`[WS#${instanceId}] 已发送会话恢复请求, session_id:`, sid);
}
};
ws.onclose = () => {
setIsConnected(false);
console.log('[WS] 已断开');
console.log(`[WS#${instanceId}] 已断开`);
if (shouldReconnectRef.current) {
console.log('[WS] 3秒后重连...');
console.log(`[WS#${instanceId}] 3秒后重连...`);
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
}
@@ -67,7 +76,7 @@ export function useWebSocket() {
};
ws.onerror = (err) => {
console.error('[WS] 连接错误:', err);
console.error(`[WS#${instanceId}] 连接错误:`, err);
};
ws.onmessage = (event) => {
@@ -75,7 +84,7 @@ export function useWebSocket() {
const msg: WSServerMessage = JSON.parse(event.data);
handleServerMessage(msg);
} catch (err) {
console.error('[WS] 消息解析失败:', err);
console.error(`[WS#${instanceId}] 消息解析失败:`, err);
}
};
@@ -98,6 +107,7 @@ export function useWebSocket() {
}, [connect, currentSessionId]);
const sendMessage = useCallback((msg: WSClientMessage) => {
const instanceId = instanceIdRef.current;
if (wsRef.current?.readyState === WebSocket.OPEN) {
const sessionID = useSessionStore.getState().currentSessionId;
wsRef.current.send(
@@ -106,6 +116,24 @@ export function useWebSocket() {
session_id: msg.session_id || sessionID || undefined,
})
);
} else {
const stateLabels = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'];
const state = wsRef.current
? (stateLabels[wsRef.current.readyState] || `UNKNOWN(${wsRef.current.readyState})`)
: 'NO_SOCKET';
console.error(
`[WS#${instanceId}] sendMessage 失败: WebSocket 未就绪 (readyState=${state}),消息被丢弃:`,
msg.type,
msg.content?.slice(0, 50)
);
// 通知用户连接已断开
useChatStore.getState().setTyping(false);
useChatStore.getState().addMessage({
id: 'err_' + Date.now(),
role: 'system',
content: '⚠️ 连接未建立,请刷新页面后重试',
timestamp: Date.now(),
});
}
}, []);
@@ -228,6 +256,13 @@ function handleServerMessage(msg: WSServerMessage) {
case 'error':
console.error('[WS] 服务端错误:', msg.error);
setTyping(false);
// 在聊天界面显示错误提示,让用户知情
addMessage({
id: 'err_' + Date.now(),
role: 'system',
content: '❌ 服务端错误: ' + (msg.error || '未知错误'),
timestamp: msg.timestamp || Date.now(),
});
break;
case 'pong':
+8 -3
View File
@@ -11,7 +11,7 @@ interface AuthStore {
token: string | null;
loading: boolean;
login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>;
register: (username: string, password: string, email: string, verifyCode: string) => Promise<{ success: boolean; error?: string }>;
register: (username: string, password: string, email: string, nickname: string, verifyCode: string) => Promise<{ success: boolean; error?: string }>;
logout: () => void;
}
@@ -42,14 +42,18 @@ export const useAuthStore = create<AuthStore>((set) => ({
}
},
register: async (username: string, password: string, email: string, verifyCode: string) => {
register: async (username: string, password: string, email: string, nickname: string, verifyCode: string) => {
set({ loading: true });
try {
const resp = await apiRegister(username, password, email, verifyCode);
const resp = await apiRegister(username, password, email, nickname, verifyCode);
if (resp.error) {
set({ loading: false });
return { success: false, error: resp.error };
}
// 保存昵称到 localStorage
if (resp.data?.nickname) {
localStorage.setItem('user_nickname', resp.data.nickname);
}
set({
isLoggedIn: true,
userId: resp.data?.user_id || null,
@@ -65,6 +69,7 @@ export const useAuthStore = create<AuthStore>((set) => ({
logout: () => {
clearToken();
localStorage.removeItem('user_nickname');
set({
isLoggedIn: false,
userId: null,
+2
View File
@@ -42,6 +42,7 @@ export interface AuthResponse {
user_id: string;
token: string;
expires: number;
nickname?: string;
}
export interface LoginParams {
@@ -53,5 +54,6 @@ export interface RegisterParams {
username: string;
password: string;
email: string;
nickname: string;
verify_code: string;
}