fix: Phase 6联调 — 插件管理器端口修正 + 多模型配置系统整合 + 历史消息刷新修复

## 调试日志

### 1. 插件管理器启动失败
- **症状**: DevTools 显示插件管理器一直"已停止",手动启动正常
- **排查**: 对比 process-manager.js 传入的环境变量 vs plugin-manager config.go 读取的变量
- **根因**: config.js 传入 PLUGIN_MANAGER_PORT=8094,但 config.go 读取 os.Getenv("PORT"),env 名不匹配。且 process.env 中 PORT 泄露时被误读为 9090,与 DevTools 端口冲突
- **修复**: config.js 将 PLUGIN_MANAGER_PORT → PORT,使 env 名与代码一致 (c3055f4)

### 2. 历史消息刷新后消失
- **症状**: 浏览器刷新后聊天历史清空
- **排查**: WebSocket history_response handler 中 if (msg.messages) 对空数组 [] 为 truthy
- **根因**: 后端返回空的 history_response (缓存为空) 时,空数组覆盖了 HTTP 已加载的消息
- **修复**: useWebSocket.ts 改为 if (msg.messages && msg.messages.length > 0),空数组走 else-if 分支仅打日志,不覆盖已有消息

### 3. Phase 6 多模型配置系统
- Gateway: ModelsConfigStore (JSON文件持久化) + Admin CRUD API (providers/models/routing)
- ai-core: ModelSelector 支持按 purpose 选择 + fallback_chain,无配置时回退 .env
- DevTools: 模型配置管理面板 (Providers/Models/Routing 三Tab)、在线模型查询代理、路由表单 checkbox 多选、关键词搜索过滤
- .gitignore: models.json + platform_configs.json

### 4. 多端客户端追踪
- Hub 新增 knownClients 映射 (clientID → KnownClient),在线/离线状态追踪
- 客户端备注持久化到 PostgreSQL
- DevTools 客户端管理面板

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 21:23:10 +08:00
parent 965cce7192
commit 0717928496
29 changed files with 3177 additions and 137 deletions
@@ -1,11 +1,13 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
import { useAuthStore } from '@/store/authStore';
import { useChatStore } from '@/store/chatStore';
import { useSpeechSynthesis } from '@/hooks/useSpeechSynthesis';
import type { MessageAttachment, MultiMessageItem, StreamSegment, MessageDisplayType } from '@/types/chat';
import { ImageLightbox } from './ImageLightbox';
interface MessageBubbleProps {
id: string;
role: 'user' | 'assistant' | 'system' | 'action';
content: string;
timestamp: number;
@@ -141,6 +143,7 @@ function AIMessageActions({ content }: { content: string }) {
}
export function MessageBubble({
id,
role,
content,
timestamp,
@@ -203,6 +206,19 @@ export function MessageBubble({
// 判断是否还有未显示完的字符
const hasMoreChars = isStreaming && displayedContent.length < content.length;
// When typewriter animation finishes, notify store to process next queued message.
const typewriterComplete = isStreaming && content.length > 0 && displayedContent.length >= content.length;
const doneNotifiedRef = useRef(false);
useEffect(() => {
if (typewriterComplete && !doneNotifiedRef.current) {
doneNotifiedRef.current = true;
const timer = setTimeout(() => {
useChatStore.getState().onTypewriterDone(id);
}, 200);
return () => clearTimeout(timer);
}
}, [typewriterComplete, id]);
// 图片附件
const imageAttachments = attachments?.filter((a) => a.type === 'image') ?? [];
@@ -66,6 +66,7 @@ export function MessageList({
{messages.map((msg) => (
<MessageBubble
key={msg.id}
id={msg.id}
role={msg.role}
content={msg.content}
timestamp={msg.timestamp}
+6 -1
View File
@@ -249,7 +249,8 @@ function handleServerMessage(msg: WSServerMessage) {
case 'history_response':
// 防御性检查:仅当当前消息为空时才加载 WebSocket 历史响应
// 避免 WebSocket 的 history_response (可能来自后端空缓存) 覆盖 HTTP loadMessagesFromServer 已加载的消息
if (msg.messages) {
// 注意:空数组 [] 在 JS 中是 truthy,必须显式检查 length > 0
if (msg.messages && msg.messages.length > 0) {
const sessionState = useSessionStore.getState();
// 如果 sessionStore 或 chatStore 中已有消息,说明 HTTP 已加载完成,忽略 WS 的历史响应
if (sessionState.messages.length > 0 || chatState.messages.length > 0) {
@@ -263,9 +264,13 @@ function handleServerMessage(msg: WSServerMessage) {
const msgsWithIds: Message[] = msg.messages.map((m, i) => ({
...m,
id: m.id || `hist_${i}_${Date.now()}`,
// 规范化 msg_type → msgType 以保持与 HTTP 加载路径一致
msgType: (m as any).msg_type || m.msgType,
}));
setMessages(msgsWithIds);
useChatStore.getState().setMessages(msgsWithIds);
} else if (msg.messages && msg.messages.length === 0) {
console.log('[WS] 忽略空的 history_response(后端缓存和数据库均无消息),等待 HTTP 加载');
}
setTyping(false);
break;
+44 -2
View File
@@ -21,9 +21,16 @@ interface ChatStore {
isLoadingHistory: boolean;
historyPage: number;
// 多气泡消息队列:确保气泡依次出现 + 逐字动画
messageQueue: Message[];
addMessage: (message: Message) => void;
appendToLastMessage: (content: string) => void;
finishStreaming: () => void;
/** 将一个消息加入队列,等待上一个气泡的逐字动画完成后再展示 */
enqueueMessage: (message: Message) => void;
/** 当前气泡逐字动画完成后调用:关闭 isStreaming,出队下一个 */
onTypewriterDone: (messageId: string) => void;
setMessages: (messages: Message[]) => void;
setTyping: (typing: boolean) => void;
clearMessages: () => void;
@@ -49,6 +56,7 @@ export const useChatStore = create<ChatStore>((set) => ({
hasMoreMessages: false,
isLoadingHistory: false,
historyPage: 1,
messageQueue: [],
addMessage: (message) =>
set((state) => ({
@@ -84,14 +92,48 @@ export const useChatStore = create<ChatStore>((set) => ({
isStreaming: false,
};
}
return { messages: msgs, isTyping: false };
// Process queued messages after streaming finishes
const queue = [...state.messageQueue];
if (queue.length > 0) {
const next = queue.shift()!;
return {
messages: [...msgs, { ...next, isStreaming: true }],
messageQueue: queue,
isTyping: false,
};
}
return { messages: msgs, isTyping: false, messageQueue: queue };
}),
enqueueMessage: (message) =>
set((state) => ({
messageQueue: [...state.messageQueue, message],
})),
onTypewriterDone: (messageId) =>
set((state) => {
// Mark this specific message as no longer streaming
const msgs = state.messages.map((m) =>
m.id === messageId ? { ...m, isStreaming: false } : m
);
// Process next queued message
const queue = [...state.messageQueue];
if (queue.length > 0) {
const next = queue.shift()!;
return {
messages: [...msgs, { ...next, isStreaming: true }],
messageQueue: queue,
isTyping: false,
};
}
return { messages: msgs, isTyping: false, messageQueue: queue };
}),
setMessages: (messages) => set({ messages, isTyping: false }),
setTyping: (typing) => set({ isTyping: typing }),
clearMessages: () => set({ messages: [], isTyping: false, hasMoreMessages: false, historyPage: 1 }),
clearMessages: () => set({ messages: [], isTyping: false, hasMoreMessages: false, historyPage: 1, messageQueue: [] }),
setContinuousMode: (enabled) => set({ continuousMode: enabled }),
+13
View File
@@ -38,6 +38,13 @@ export interface ReviewMessage {
content: string;
}
/** 客户端信息 (多端区分) */
export interface ClientInfo {
client_id: string;
device_name?: string;
user_agent?: string;
}
/** 单条消息 */
export interface Message {
id: string;
@@ -50,6 +57,8 @@ export interface Message {
isStreaming?: boolean;
/** 消息显示类型: 区分聊天消息与动作消息 */
msgType?: MessageDisplayType;
/** 消息来源客户端信息 (多端区分) */
client_info?: ClientInfo;
}
/** IoT 设备类型定义 */
@@ -99,6 +108,9 @@ export interface WSClientMessage {
audio_data?: string; // base64
attachments?: MessageAttachment[];
timestamp: number;
client_id?: string;
device_name?: string;
user_agent?: string;
}
/** 通知类型 */
@@ -146,6 +158,7 @@ export interface WSServerMessage {
system_info?: SystemInfoPayload;
protocol_version?: number;
timestamp: number;
client_info?: ClientInfo;
}
/** 工具进度信息 */