fix: 修复 AI 回复无法送达发送者 + 重复消息 + action角色泄露 + OS环境支持

广播逻辑重构:
- AI 回复 (stream_start/response/stream_segments/multi_message/stream_end) 改用 broadcastToUser 发送给所有客户端
- 用户消息回显保持 broadcastToUserExcept 排除发送者

消息去重与角色修复:
- CacheMessage(user) 移至回复生成后,避免本轮 LLM 调用出现重复用户消息
- action 角色消息在 DB 存储时映射为 assistant,DeepSeek 等模型不支持自定义角色
- stream_end defer 机制确保错误路径也会终止客户端思考指示器

OS 完整环境支持:
- host 包重构为 HostBackend 接口 + Direct/WSL/Docker 三种后端
- 新增 os_exec/os_file/os_system 工具供 AI 在完整 Linux 环境中自由操作

其他:
- 视觉模型注入 + 图片预处理后清空 Images 避免传给 Chat 模型
- 图片 URL 相对路径→绝对 URL 转换
- DevTools 链路追踪页面 + 重启修复
- 记忆搜索模糊匹配增强
- 后台思考定时调度支持
- 管理后台页面 (模型配置/用户管理等)
- docs/api 更新广播机制说明

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 12:46:17 +08:00
parent aac64ed8b7
commit 91c9ee4b2d
49 changed files with 5032 additions and 299 deletions
+35 -5
View File
@@ -1,10 +1,11 @@
import { useRef, useEffect, useState } from 'react';
import { useRef, useEffect, useState, useCallback } from 'react';
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
import { MoodIndicator } from '@/components/persona/MoodIndicator';
import { useAuth } from '@/hooks/useAuth';
import { usePWA } from '@/hooks/usePWA';
import { useNotificationStore } from '@/store/notificationStore';
import { useSessionStore } from '@/store/sessionStore';
import { getToken } from '@/api/client';
import { ReminderPanel } from '@/components/layout/ReminderPanel';
import { BriefingPanel } from '@/components/layout/BriefingPanel';
import { AutomationPanel } from '@/components/layout/AutomationPanel';
@@ -60,12 +61,31 @@ export function Header({ onMenuClick, onSearchClick }: HeaderProps) {
// PWA Hook
const { isInstallable, isInstalled, hasUpdate, install, update } = usePWA();
// 下拉面板标签页切换:通知 / 提醒 / 简报
// 下拉面板标签页切换
const [dropdownTab, setDropdownTab] = useState<'notifications' | 'reminders' | 'briefing' | 'automation' | 'files' | 'knowledge'>('notifications');
// 获取当前用户 ID
const userId = localStorage.getItem('user_id') || '';
// 健康检查指示器
const [apiOnline, setApiOnline] = useState<boolean | null>(null);
const checkHealth = useCallback(async () => {
try {
const API_BASE = import.meta.env.VITE_API_URL || '/api/v1';
const resp = await fetch(`${API_BASE}/health`);
const data = await resp.json();
setApiOnline(data?.status === 'ok');
} catch {
setApiOnline(false);
}
}, []);
useEffect(() => {
checkHealth();
const interval = setInterval(checkHealth, 30000);
return () => clearInterval(interval);
}, [checkHealth]);
// 点击外部关闭下拉
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
@@ -102,9 +122,19 @@ export function Header({ onMenuClick, onSearchClick }: HeaderProps) {
<CyreneAvatar size="sm" />
<div>
<h1 className="text-base font-semibold text-pink-600 dark:text-pink-400">
</h1>
<div className="flex items-center gap-1.5">
<h1 className="text-base font-semibold text-pink-600 dark:text-pink-400">
</h1>
{apiOnline !== null && (
<span
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
apiOnline ? 'bg-green-400' : 'bg-red-400 animate-pulse'
}`}
title={apiOnline ? 'API 在线' : 'API 离线'}
/>
)}
</div>
<MoodIndicator />
</div>
</div>
+41 -1
View File
@@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import { useSession } from '@/hooks/useSession';
import { useSessionStore } from '@/store/sessionStore';
import { usePageStore } from '@/store/pageStore';
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
import { exportSession, type ExportFormat } from '@/api/sessions';
import type { Session } from '@/types/session';
@@ -60,14 +61,15 @@ export function Sidebar({ onClose, onMemoryClick }: SidebarProps) {
const handleSelectSession = (id: string) => {
setCurrentSession(id);
usePageStore.getState().goToChat();
if (onClose) onClose();
};
const handleMainSession = async () => {
// 找到主对话
const mainSession = displaySessions.find((s) => s.is_main);
if (mainSession) {
setCurrentSession(mainSession.id);
usePageStore.getState().goToChat();
if (onClose) onClose();
}
};
@@ -278,8 +280,25 @@ export function Sidebar({ onClose, onMemoryClick }: SidebarProps) {
)}
</div>
{/* Admin 管理导航 */}
{isAdmin && (
<div className="px-3 pt-2 border-t border-pink-100 dark:border-pink-900 space-y-1">
<p className="px-1 text-[10px] text-gray-400 uppercase tracking-wider"></p>
<AdminNavButton page="admin-models" icon="🤖" label="模型配置" />
<AdminNavButton page="admin-dashboard" icon="📊" label="仪表盘" />
</div>
)}
{/* 底部:记忆管理 + 一键清空所有对话 */}
<div className="p-3 border-t border-pink-100 dark:border-pink-900 space-y-2">
{/* 个人资料 */}
<button
onClick={() => usePageStore.getState().setPage('profile')}
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-50 hover:bg-blue-100 dark:bg-blue-900/20 dark:hover:bg-blue-900/30 text-blue-500 hover:text-blue-600 rounded-xl text-xs font-medium transition-colors border border-blue-200 dark:border-blue-800"
>
<span>👤</span>
<span></span>
</button>
{onMemoryClick && (
<button
onClick={onMemoryClick}
@@ -343,3 +362,24 @@ export function Sidebar({ onClose, onMemoryClick }: SidebarProps) {
</aside>
);
}
/** Admin 导航按钮 */
function AdminNavButton({ page, icon, label }: { page: string; icon: string; label: string }) {
const currentPage = usePageStore((s) => s.currentPage);
const setPage = usePageStore((s) => s.setPage);
const isActive = currentPage === page;
return (
<button
onClick={() => setPage(page as 'admin-models' | 'admin-dashboard')}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium transition-colors ${
isActive
? 'bg-pink-50 dark:bg-pink-900/30 text-pink-600'
: 'text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300'
}`}
>
<span>{icon}</span>
<span>{label}</span>
</button>
);
}