Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd
This commit is contained in:
commit
e30b44dae1
68 files changed
+10813
-3976
No files matched your search
@@ -1,6 +1,16 @@
|
||||
.sidebar-contnet {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 4rem;
|
||||
z-index: 40;
|
||||
width: 98px;
|
||||
height: calc(100vh - 4rem);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-contents {
|
||||
width: 98px;
|
||||
min-height: 986px;
|
||||
min-height: calc(100% - 20px);
|
||||
background: rgba(3, 38, 80, 0.8);
|
||||
border: 1px solid;
|
||||
border-color: rgba(43, 143, 254, 0.6);
|
||||
@@ -11,16 +21,30 @@
|
||||
align-items: center;
|
||||
padding: 12px 10px;
|
||||
position: relative;
|
||||
height: calc(100% - 20px);
|
||||
box-sizing: border-box;
|
||||
transform: translate(10px, 10px);
|
||||
}
|
||||
|
||||
.item-nav-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item-nav-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item-nav-group:first-child .item-nav {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.item-nav {
|
||||
width: 100%;
|
||||
height: 70px;
|
||||
background: #ffffff1a;
|
||||
border-color: #fefafa4d;
|
||||
height: 75px;
|
||||
margin-top: 13px;
|
||||
font-family: Noto Sans S Chinese;
|
||||
font-weight: 550;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
@@ -35,9 +59,46 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item-nav:hover {
|
||||
background: rgba(85, 166, 254, 0.12);
|
||||
.item-nav:hover,
|
||||
.item-nav-active,
|
||||
.item-nav-hovered {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(134.25deg,
|
||||
rgba(0, 98, 217, 0.59) 2.28%,
|
||||
rgba(0, 98, 217, 0.3) 99.59%) !important;
|
||||
background-size: cover;
|
||||
border: 1px solid;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item-nav:hover::before,
|
||||
.item-nav-active::before,
|
||||
.item-nav-hovered::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 8px solid transparent;
|
||||
border-bottom: 8px solid transparent;
|
||||
border-left: 10px solid rgba(0, 98, 217, 0.45);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.item-nav:hover::after,
|
||||
.item-nav-active::after,
|
||||
.item-nav-hovered::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 29px;
|
||||
width: 77px;
|
||||
height: 45px;
|
||||
background: url('../assets/icon/nav-line-logo.svg') no-repeat;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.item-nav__label {
|
||||
@@ -52,53 +113,47 @@
|
||||
|
||||
.item-nav-content {
|
||||
width: 155px;
|
||||
min-height: 970px;
|
||||
height: calc(100vh - 4rem - 20px);
|
||||
min-height: calc(100vh - 4rem - 20px);
|
||||
background: rgba(3, 38, 80, 0.8);
|
||||
border: 1px solid;
|
||||
border-color: rgba(43, 143, 254, 0.6);
|
||||
border-radius: 4px;
|
||||
backdrop-filter: blur(2px);
|
||||
position: absolute;
|
||||
left: 97px;
|
||||
left: 108px;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
padding: 14px 13px 18px 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.item-nav-active {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(134.25deg,
|
||||
rgba(0, 98, 217, 0.59) 2.28%,
|
||||
rgba(0, 98, 217, 0.3) 99.59%) !important;
|
||||
background-size: cover;
|
||||
border: 1px solid;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item-nav-active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 8px solid transparent;
|
||||
border-bottom: 8px solid transparent;
|
||||
border-left: 10px solid rgba(0, 98, 217, 0.45);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||
}
|
||||
.item-nav-active::after {
|
||||
|
||||
.item-nav-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 24px;
|
||||
width: 77px;
|
||||
height: 45px;
|
||||
background: url('../assets/icon/nav-line-logo.svg') no-repeat;
|
||||
background-size: cover;
|
||||
left: -30px;
|
||||
top: 0;
|
||||
width: 30px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.item-nav-hover-bridge {
|
||||
position: absolute;
|
||||
right: -30px;
|
||||
top: 0;
|
||||
width: 30px;
|
||||
height: 100%;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.item-nav-content--visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.sidebar-flyout-list {
|
||||
@@ -140,7 +195,7 @@
|
||||
padding: 3px 0;
|
||||
font-family: Noto Sans S Chinese;
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
@@ -182,4 +237,86 @@
|
||||
.sidebar-flyout-label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
color: #80e9ff;
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn__label {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn--inline {
|
||||
width: calc(100% - 6px);
|
||||
min-height: 58px;
|
||||
margin-top: auto;
|
||||
margin-bottom: 2px;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
border: 1px solid #55a6fe33;
|
||||
background: #000c1c26;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: inset 0 0 16px #00b9ff1f;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn--inline .sidebar-collapse-btn__label {
|
||||
font-size: 16px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn--inline:hover {
|
||||
background: rgba(0, 98, 217, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn--floating {
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 60;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(128, 233, 255, 0.35);
|
||||
background: rgba(3, 38, 80, 0.82);
|
||||
color: rgba(205, 245, 255, 0.95);
|
||||
backdrop-filter: blur(2px);
|
||||
transition: width 0.22s ease, opacity 0.2s ease, background-color 0.2s ease, border-color 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn--floating:hover {
|
||||
width: 104px;
|
||||
background: rgba(7, 52, 110, 0.9);
|
||||
border-color: rgba(128, 233, 255, 0.65);
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn--floating .sidebar-collapse-btn__label {
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
transition: max-width 0.22s ease, opacity 0.16s ease, transform 0.22s ease;
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn--floating:hover .sidebar-collapse-btn__label {
|
||||
max-width: 64px;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
@@ -1,312 +1,66 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ElementType } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
Bell,
|
||||
Bot,
|
||||
Brain,
|
||||
CalendarClock,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
CircleHelp,
|
||||
Database,
|
||||
FileBox,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Gauge,
|
||||
Grid3X3,
|
||||
HelpCircle,
|
||||
History,
|
||||
Layout,
|
||||
Library,
|
||||
List,
|
||||
ListChecks,
|
||||
ListTodo,
|
||||
MessageSquareCode,
|
||||
MessageSquarePlus,
|
||||
Network,
|
||||
NotebookPen,
|
||||
Plus,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Target,
|
||||
User,
|
||||
Users,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { PanelLeftOpen, PanelLeftClose } from "lucide-react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { NotificationDialog } from "@/components/workspace/notification-dialog";
|
||||
import { SettingsDialog } from "@/components/workspace/settings";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useUnreadCount } from "@/core/notifications";
|
||||
import {
|
||||
defaultExpandedIds,
|
||||
filterMenuItemsByRole,
|
||||
sidebarMenuItems,
|
||||
type MenuItem,
|
||||
} from "@/core/page-layout/sidebar-menu";
|
||||
import './page-sdebar.css'
|
||||
|
||||
type MenuItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ElementType;
|
||||
path?: string;
|
||||
adminOnly?: boolean;
|
||||
children?: MenuItem[];
|
||||
};
|
||||
|
||||
type PageSidebarDarkProps = {
|
||||
/** 是否收起(true 时只渲染左下角浮动入口,sidebar 主体隐藏) */
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
};
|
||||
|
||||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: "qa-center",
|
||||
label: "问答中心",
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
|
||||
{ id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
|
||||
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
|
||||
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
|
||||
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "light-app-center",
|
||||
label: "轻应用",
|
||||
icon: Grid3X3,
|
||||
children: [
|
||||
{
|
||||
id: "light-app-note-management",
|
||||
label: "笔记管理",
|
||||
icon: NotebookPen,
|
||||
children: [
|
||||
{
|
||||
id: "light-app-new-note",
|
||||
label: "新建笔记",
|
||||
icon: Plus,
|
||||
path: "/page/strategy/light-app/new-note",
|
||||
},
|
||||
{
|
||||
id: "light-app-note-list",
|
||||
label: "笔记列表",
|
||||
icon: List,
|
||||
path: "/page/strategy/light-app/notes",
|
||||
},
|
||||
{
|
||||
id: "light-app-note-qa",
|
||||
label: "笔记问答",
|
||||
icon: MessageSquareCode,
|
||||
path: "/page/strategy/light-app/note-qa",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: "light-app-square", label: "应用广场", icon: Layout },
|
||||
{ id: "light-app-records", label: "运行记录", icon: History },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "task-management",
|
||||
label: "任务管理",
|
||||
icon: Target,
|
||||
children: [
|
||||
{
|
||||
id: "xk-center-task",
|
||||
label: "XK中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "xk-task-jc", label: "任务JC", icon: ListTodo, path: "/page/strategy/task-management/xk-task-jc" },
|
||||
{ id: "xk-task-dispatch", label: "任务调度", icon: ListTodo, path: "/page/strategy/task-management/xk-task-dispatch" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "coh-center-task",
|
||||
label: "COH中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{
|
||||
id: "coh-task-3qfx",
|
||||
label: "3QFX",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/plan-task-sentiment",
|
||||
},
|
||||
{
|
||||
id: "coh-task-fx",
|
||||
label: "任务FX",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/plan-task/plan-task-fx",
|
||||
},
|
||||
{
|
||||
id: "coh-task-xdfx",
|
||||
label: "XDFX",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/plan-task/plan-task-action",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pg-center-task",
|
||||
label: "PG中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "pg-task-pg", label: "任务PG", icon: ListTodo },
|
||||
{ id: "pg-task-xdpg", label: "XDPG", icon: ListTodo },
|
||||
{ id: "pg-task-info-pg", label: "信息PG", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ty-center-task",
|
||||
label: "TY中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "ty-task-xdty", label: "XDTY任务", icon: ListTodo },
|
||||
{ id: "ty-task-plan", label: "方案TY任务", icon: ListTodo },
|
||||
{ id: "ty-task-xxty", label: "XXTY任务", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "comprehensive-management",
|
||||
label: "综合管理",
|
||||
icon: Settings,
|
||||
children: [
|
||||
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
|
||||
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
|
||||
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
||||
{
|
||||
id: "mgmt-tasks",
|
||||
label: "任务管理",
|
||||
icon: ListChecks,
|
||||
path: "/page/strategy/scheduled/tasks",
|
||||
},
|
||||
{
|
||||
id: "mgmt-recommended-questions",
|
||||
label: "推荐问题配置",
|
||||
icon: MessageSquarePlus,
|
||||
path: "/page/strategy/settings/recommended-questions",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-prompt-prefix",
|
||||
label: "问答前缀配置",
|
||||
icon: MessageSquareCode,
|
||||
path: "/page/strategy/settings/prompt-prefix",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-llm-metrics",
|
||||
label: "大模型调用统计",
|
||||
icon: Gauge,
|
||||
path: "/page/strategy/settings/llm-metrics",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-users",
|
||||
label: "用户管理",
|
||||
icon: Users,
|
||||
path: "/page/strategy/admin/users",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-admin-scheduled-tasks",
|
||||
label: "定时任务管理",
|
||||
icon: CalendarClock,
|
||||
path: "/page/strategy/admin/scheduled-tasks",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-user-activity",
|
||||
label: "用户活动审计",
|
||||
icon: History,
|
||||
path: "/page/strategy/admin/user-activity",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "resource-management",
|
||||
label: "资源管理",
|
||||
icon: Database,
|
||||
children: [
|
||||
{
|
||||
id: "resource-network-xwt",
|
||||
label: "网络xwt资源管理",
|
||||
icon: Network,
|
||||
path: "/page/strategy/resource-management/resource-network-actor",
|
||||
},
|
||||
{
|
||||
id: "resource-knowledge-base",
|
||||
label: "知识库资源管理",
|
||||
icon: Library,
|
||||
path: "/page/strategy/resource-management/resource-knowledge-base",
|
||||
},
|
||||
{
|
||||
id: "resource-evaluation-index",
|
||||
label: "作战资源管理",
|
||||
icon: FileBox,
|
||||
path: "/page/strategy/resource-management/resource-evaluation-index",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const defaultExpandedIds = new Set([
|
||||
"qa-center",
|
||||
"light-app-center",
|
||||
"light-app-note-management",
|
||||
"task-management",
|
||||
"xk-center-task",
|
||||
]);
|
||||
|
||||
function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] {
|
||||
return items
|
||||
.filter((item) => !item.adminOnly || isAdmin)
|
||||
.map((item) =>
|
||||
item.children
|
||||
? { ...item, children: filterMenuItemsByRole(item.children, isAdmin) }
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
export function PageSidebarDark({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
}: PageSidebarDarkProps) {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDarkProps) {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const visibleMenuItems = useMemo(
|
||||
() => filterMenuItemsByRole(menuItems, !!isAdmin),
|
||||
() => filterMenuItemsByRole(sidebarMenuItems, !!isAdmin),
|
||||
[isAdmin],
|
||||
);
|
||||
const { t } = useI18n();
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(defaultExpandedIds);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsDefaultSection, setSettingsDefaultSection] =
|
||||
useState<SettingsSection>("appearance");
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
// defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变)
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(defaultExpandedIds));
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
// pathname 变化时取消任何未触发的 hover 关闭计时(cleanup 在卸载时也跑)
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
return () => {
|
||||
if (hoverCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(hoverCloseTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openSettings = (section: SettingsSection) => {
|
||||
setSettingsDefaultSection(section);
|
||||
setSettingsOpen(true);
|
||||
const handleNavHoverEnter = (id: string) => {
|
||||
if (hoverCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(hoverCloseTimerRef.current);
|
||||
hoverCloseTimerRef.current = null;
|
||||
}
|
||||
setHoveredTopId(id);
|
||||
};
|
||||
|
||||
const scheduleNavPanelClose = () => {
|
||||
if (hoverCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(hoverCloseTimerRef.current);
|
||||
}
|
||||
hoverCloseTimerRef.current = window.setTimeout(() => {
|
||||
setHoveredTopId(null);
|
||||
hoverCloseTimerRef.current = null;
|
||||
}, 180);
|
||||
};
|
||||
|
||||
const closeNavPanel = () => {
|
||||
if (hoverCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(hoverCloseTimerRef.current);
|
||||
hoverCloseTimerRef.current = null;
|
||||
}
|
||||
setHoveredTopId(null);
|
||||
};
|
||||
|
||||
const hasActiveChild = (item: MenuItem): boolean => {
|
||||
@@ -317,18 +71,21 @@ export function PageSidebarDark({
|
||||
return item.children?.some(hasActiveChild) ?? false;
|
||||
};
|
||||
|
||||
const initialActiveTopItem = visibleMenuItems.find(hasActiveChild) ?? visibleMenuItems[0];
|
||||
const [activeTopId, setActiveTopId] = useState(initialActiveTopItem?.id ?? "qa-center");
|
||||
// lazy init:仅在首次 render 计算一次 active id;之后由下面的 useEffect 跟随 pathname 同步
|
||||
const [activeTopId, setActiveTopId] = useState<string>(
|
||||
() => visibleMenuItems.find(hasActiveChild)?.id ?? visibleMenuItems[0]?.id ?? "qa-center",
|
||||
);
|
||||
const [hoveredTopId, setHoveredTopId] = useState<string | null>(null);
|
||||
const hoverCloseTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const matchedTopItem = visibleMenuItems.find(hasActiveChild);
|
||||
if (matchedTopItem) {
|
||||
setActiveTopId(matchedTopItem.id);
|
||||
}
|
||||
closeNavPanel();
|
||||
}, [pathname, visibleMenuItems]);
|
||||
|
||||
const activeTopItem = visibleMenuItems.find((item) => item.id === activeTopId) ?? initialActiveTopItem;
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -341,10 +98,38 @@ export function PageSidebarDark({
|
||||
});
|
||||
};
|
||||
|
||||
const findFirstRoutePath = (items?: MenuItem[]): string | null => {
|
||||
if (!items?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const child of items) {
|
||||
if (child.path) {
|
||||
return child.path;
|
||||
}
|
||||
const nestedPath = findFirstRoutePath(child.children);
|
||||
if (nestedPath) {
|
||||
return nestedPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleTopItemClick = (item: MenuItem) => {
|
||||
setActiveTopId(item.id);
|
||||
|
||||
if (!item.children?.length && item.path) {
|
||||
if (item.children?.length) {
|
||||
const firstPath = findFirstRoutePath(item.children);
|
||||
if (firstPath) {
|
||||
closeNavPanel();
|
||||
navigate(firstPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.path) {
|
||||
closeNavPanel();
|
||||
navigate(item.path);
|
||||
}
|
||||
};
|
||||
@@ -356,6 +141,7 @@ export function PageSidebarDark({
|
||||
}
|
||||
|
||||
if (item.path) {
|
||||
closeNavPanel();
|
||||
navigate(item.path);
|
||||
}
|
||||
};
|
||||
@@ -386,35 +172,66 @@ export function PageSidebarDark({
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
// collapsed 状态:sidebar 主体完全消失(包括占据的宽度),只渲染左下角浮动入口
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title="展开侧边栏"
|
||||
aria-label="展开侧边栏"
|
||||
className="sidebar-collapse-btn sidebar-collapse-btn--floating"
|
||||
onClick={onToggleCollapsed}
|
||||
>
|
||||
<PanelLeftOpen className="sidebar-collapse-btn__icon" aria-hidden="true" />
|
||||
<span className="sidebar-collapse-btn__label">收起面板</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
defaultSection={settingsDefaultSection}
|
||||
/>
|
||||
<NotificationDialog
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
{/* TODO: 在这里编写深色主题的侧边栏 HTML 结构 */}
|
||||
<div className="sidebar-contnet">
|
||||
<div className="sidebar-contents">
|
||||
<div className="item-nav-content">
|
||||
{activeTopItem?.children?.length ? renderFlyoutMenu(activeTopItem.children) : null}
|
||||
<aside className="sidebar-contnet">
|
||||
<div className="sidebar-contents">
|
||||
{visibleMenuItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="item-nav-group"
|
||||
onMouseEnter={() => handleNavHoverEnter(item.id)}
|
||||
onMouseLeave={scheduleNavPanelClose}
|
||||
>
|
||||
{item.children?.length ? (
|
||||
<div
|
||||
className={`item-nav-content ${hoveredTopId === item.id ? "item-nav-content--visible" : ""}`}
|
||||
>
|
||||
{renderFlyoutMenu(item.children)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="item-nav-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className={`item-nav ${item.id === activeTopId ? "item-nav-active" : ""} ${hoveredTopId === item.id ? "item-nav-hovered" : ""}`}
|
||||
onClick={() => handleTopItemClick(item)}
|
||||
>
|
||||
<span className="item-nav__label">{item.label}</span>
|
||||
</button>
|
||||
{item.children?.length && hoveredTopId === item.id ? (
|
||||
<span className="item-nav-hover-bridge" aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{visibleMenuItems.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.id}
|
||||
className={`item-nav ${item.id === activeTopId ? "item-nav-active" : ""}`}
|
||||
onClick={() => handleTopItemClick(item)}
|
||||
>
|
||||
<span className="item-nav__label">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 底部"收起面板"按钮:独立样式,mt-auto 推到底 */}
|
||||
<button
|
||||
type="button"
|
||||
title="收起侧边栏"
|
||||
aria-label="收起侧边栏"
|
||||
className="sidebar-collapse-btn sidebar-collapse-btn--inline"
|
||||
onClick={onToggleCollapsed}
|
||||
>
|
||||
<PanelLeftClose className="sidebar-collapse-btn__icon" aria-hidden="true" />
|
||||
<span className="sidebar-collapse-btn__label">收起面板</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ElementType } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
Bell,
|
||||
Bot,
|
||||
Brain,
|
||||
CalendarClock,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
CircleHelp,
|
||||
Database,
|
||||
FileBox,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Gauge,
|
||||
Grid3X3,
|
||||
HelpCircle,
|
||||
History,
|
||||
Layout,
|
||||
Library,
|
||||
List,
|
||||
ListChecks,
|
||||
ListTodo,
|
||||
MessageSquareCode,
|
||||
MessageSquarePlus,
|
||||
ShieldCheck,
|
||||
Network,
|
||||
NotebookPen,
|
||||
Plus,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Target,
|
||||
User,
|
||||
Users,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -50,14 +24,12 @@ import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useUnreadCount } from "@/core/notifications";
|
||||
type MenuItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ElementType;
|
||||
path?: string;
|
||||
adminOnly?: boolean;
|
||||
children?: MenuItem[];
|
||||
};
|
||||
import {
|
||||
defaultExpandedIds,
|
||||
filterMenuItemsByRole,
|
||||
sidebarMenuItems,
|
||||
type MenuItem,
|
||||
} from "@/core/page-layout/sidebar-menu";
|
||||
|
||||
type PageSidebarProps = {
|
||||
collapsed: boolean;
|
||||
@@ -66,272 +38,6 @@ type PageSidebarProps = {
|
||||
|
||||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: "qa-center",
|
||||
label: "问答中心",
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
|
||||
{ id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
|
||||
// 「圆桌规划」入口暂时隐藏 —— 功能仍在 /page/roundtable/planning,只是不在侧边栏出现。
|
||||
// 需要重新开放时取消注释即可。
|
||||
// {
|
||||
// id: "qa-roundtable",
|
||||
// label: "圆桌规划",
|
||||
// icon: Bot,
|
||||
// path: "/page/roundtable/planning",
|
||||
// },
|
||||
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
|
||||
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
|
||||
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "light-app-center",
|
||||
label: "轻应用",
|
||||
icon: Grid3X3,
|
||||
children: [
|
||||
{
|
||||
id: "light-app-note-management",
|
||||
label: "笔记管理",
|
||||
icon: NotebookPen,
|
||||
children: [
|
||||
{
|
||||
id: "light-app-new-note",
|
||||
label: "新建笔记",
|
||||
icon: Plus,
|
||||
path: "/page/strategy/light-app/new-note",
|
||||
},
|
||||
{
|
||||
id: "light-app-note-list",
|
||||
label: "笔记列表",
|
||||
icon: List,
|
||||
path: "/page/strategy/light-app/notes",
|
||||
},
|
||||
{
|
||||
id: "light-app-note-qa",
|
||||
label: "笔记问答",
|
||||
icon: MessageSquareCode,
|
||||
path: "/page/strategy/light-app/note-qa",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: "light-app-square", label: "应用广场", icon: Layout },
|
||||
{ id: "light-app-records", label: "运行记录", icon: History },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "task-management",
|
||||
label: "任务管理",
|
||||
icon: Target,
|
||||
children: [
|
||||
{
|
||||
id: "xk-center-task",
|
||||
label: "XK中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{
|
||||
id: "xk-task-jc",
|
||||
label: "任务JC",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/xk-task-jc",
|
||||
},
|
||||
{
|
||||
id: "xk-task-dispatch",
|
||||
label: "任务调度",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/xk-task-dispatch",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "coh-center-task",
|
||||
label: "COH中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{
|
||||
id: "coh-task-3qfx",
|
||||
label: "3QFX",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/plan-task-sentiment",
|
||||
},
|
||||
{
|
||||
id: "coh-task-fx",
|
||||
label: "任务FX",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/plan-task/plan-task-fx",
|
||||
},
|
||||
{
|
||||
id: "coh-task-xdfx",
|
||||
label: "XDFX",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/plan-task/plan-task-action",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pg-center-task",
|
||||
label: "PG中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{
|
||||
id: "pg-task-pg",
|
||||
label: "任务PG",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/effect-task-pg",
|
||||
},
|
||||
{
|
||||
id: "pg-task-xdpg",
|
||||
label: "XDPG",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/effect-action-pg",
|
||||
},
|
||||
{
|
||||
id: "pg-task-info-pg",
|
||||
label: "信息PG",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/effect-info-pg",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ty-center-task",
|
||||
label: "TY中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{
|
||||
id: "ty-task-xdty",
|
||||
label: "XDTY任务",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/mnty-task-mechanism",
|
||||
},
|
||||
{
|
||||
id: "ty-task-plan",
|
||||
label: "方案TY任务",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/mnty-task-plan",
|
||||
},
|
||||
{
|
||||
id: "ty-task-xxty",
|
||||
label: "XXTY任务",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/mnty-task-zsty",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "comprehensive-management",
|
||||
label: "综合管理",
|
||||
icon: Settings,
|
||||
children: [
|
||||
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
|
||||
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
|
||||
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
||||
{
|
||||
id: "mgmt-tasks",
|
||||
label: "定时任务",
|
||||
icon: ListChecks,
|
||||
path: "/page/strategy/scheduled/tasks",
|
||||
},
|
||||
{
|
||||
id: "mgmt-recommended-questions",
|
||||
label: "推荐问题配置",
|
||||
icon: MessageSquarePlus,
|
||||
path: "/page/strategy/settings/recommended-questions",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-prompt-prefix",
|
||||
label: "问答前缀配置",
|
||||
icon: MessageSquareCode,
|
||||
path: "/page/strategy/settings/prompt-prefix",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-private-square",
|
||||
label: "私密广场配置",
|
||||
icon: ShieldCheck,
|
||||
path: "/page/strategy/settings/private-square",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-llm-metrics",
|
||||
label: "大模型调用统计",
|
||||
icon: Gauge,
|
||||
path: "/page/strategy/settings/llm-metrics",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-users",
|
||||
label: "用户管理",
|
||||
icon: Users,
|
||||
path: "/page/strategy/admin/users",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-admin-scheduled-tasks",
|
||||
label: "定时任务管理",
|
||||
icon: CalendarClock,
|
||||
path: "/page/strategy/admin/scheduled-tasks",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-user-activity",
|
||||
label: "用户活动审计",
|
||||
icon: History,
|
||||
path: "/page/strategy/admin/user-activity",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "resource-management",
|
||||
label: "资源管理",
|
||||
icon: Database,
|
||||
children: [
|
||||
{
|
||||
id: "resource-network-xwt",
|
||||
label: "网络xwt资源管理",
|
||||
icon: Network,
|
||||
path: "/page/strategy/resource-management/resource-network-actor",
|
||||
},
|
||||
{
|
||||
id: "resource-knowledge-base",
|
||||
label: "知识库资源管理",
|
||||
icon: Library,
|
||||
path: "/page/strategy/resource-management/resource-knowledge-base",
|
||||
},
|
||||
{
|
||||
id: "resource-evaluation-index",
|
||||
label: "作战资源管理",
|
||||
icon: FileBox,
|
||||
path: "/page/strategy/resource-management/resource-evaluation-index",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const defaultExpandedIds = new Set([
|
||||
"qa-center",
|
||||
"light-app-center",
|
||||
"light-app-note-management",
|
||||
"task-management",
|
||||
"xk-center-task",
|
||||
]);
|
||||
|
||||
function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] {
|
||||
return items
|
||||
.filter((item) => !item.adminOnly || isAdmin)
|
||||
.map((item) =>
|
||||
item.children
|
||||
? { ...item, children: filterMenuItemsByRole(item.children, isAdmin) }
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
export function PageSidebar({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
@@ -339,10 +45,11 @@ export function PageSidebar({
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const visibleMenuItems = filterMenuItemsByRole(menuItems, !!isAdmin);
|
||||
const visibleMenuItems = filterMenuItemsByRole(sidebarMenuItems, !!isAdmin);
|
||||
const { t } = useI18n();
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(defaultExpandedIds);
|
||||
// defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变)
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(defaultExpandedIds));
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsDefaultSection, setSettingsDefaultSection] =
|
||||
useState<SettingsSection>("appearance");
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 外部系统跳转登录时透传的参数:
|
||||
* theme —— 主题色,"dark-blue" => 深色,其他 => 浅色(在 LoginPage 调 setTheme 直接生效)
|
||||
* ticket —— 第三方 ticket,持久化到 localStorage 供后续请求使用
|
||||
* userId —— 外部用户 id,同上
|
||||
* env —— 环境标识,同上
|
||||
*
|
||||
* 这里只暴露 ``ticket / userId / env`` 三个 storage key 和读写工具;
|
||||
* theme 不存(直接走 ThemeContext,避免双源)。
|
||||
*/
|
||||
|
||||
export const EXTERNAL_LOGIN_STORAGE_KEYS = {
|
||||
ticket: "login.ticket",
|
||||
userId: "login.userId",
|
||||
env: "login.env",
|
||||
} as const;
|
||||
|
||||
export type ExternalLoginParams = {
|
||||
ticket?: string;
|
||||
userId?: string;
|
||||
env?: string;
|
||||
};
|
||||
|
||||
/** 把外部跳转带来的三个参数写到 localStorage;隐私模式 / quota 异常时静默失败 */
|
||||
export function persistExternalLoginParams(params: ExternalLoginParams): void {
|
||||
try {
|
||||
if (params.ticket) {
|
||||
localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.ticket, params.ticket);
|
||||
}
|
||||
if (params.userId) {
|
||||
localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.userId, params.userId);
|
||||
}
|
||||
if (params.env) {
|
||||
localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.env, params.env);
|
||||
}
|
||||
} catch {
|
||||
// localStorage 不可用时不阻断登录流程
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取持久化的外部登录参数;不可用时返回空字符串 */
|
||||
export function readExternalLoginParams(): Required<ExternalLoginParams> {
|
||||
try {
|
||||
return {
|
||||
ticket: localStorage.getItem(EXTERNAL_LOGIN_STORAGE_KEYS.ticket) ?? "",
|
||||
userId: localStorage.getItem(EXTERNAL_LOGIN_STORAGE_KEYS.userId) ?? "",
|
||||
env: localStorage.getItem(EXTERNAL_LOGIN_STORAGE_KEYS.env) ?? "",
|
||||
};
|
||||
} catch {
|
||||
return { ticket: "", userId: "", env: "" };
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 ``theme`` query 值映射成 ThemeContext 的 ``'dark' | 'light'`` */
|
||||
export function resolveThemeFromQuery(themeParam: string | null | undefined): "dark" | "light" {
|
||||
return themeParam === "dark-blue" ? "dark" : "light";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { requestCrudService } from "@/service/request-axios-service";
|
||||
|
||||
const CONSUMER_URL = import.meta.env.VITE_AIPI_CONSUMER_URL || "";
|
||||
|
||||
export const TOOLS_TOKEN_STORAGE_KEY = "tools_token";
|
||||
export const EXTERNAL_USER_ID_STORAGE_KEY = "userId";
|
||||
export const EXTERNAL_USER_INFO_STORAGE_KEY = "userInfo";
|
||||
export const GENERAL_QA_ROUTE = "/page/strategy/qa/general";
|
||||
|
||||
type UserDetailResponse = {
|
||||
data?: {
|
||||
id?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* External login via yUserId + optional token (same flow as web-code Login.tsx):
|
||||
* persist tools_token, resolve user via consumer API, store userId/userInfo.
|
||||
*/
|
||||
export async function loginByYUserId(
|
||||
yUserId: string,
|
||||
token?: string | null,
|
||||
): Promise<void> {
|
||||
if (token) {
|
||||
localStorage.setItem(TOOLS_TOKEN_STORAGE_KEY, token);
|
||||
}
|
||||
|
||||
const res = (await requestCrudService.get(
|
||||
`${CONSUMER_URL}/cop/userDetailByYuserId`,
|
||||
{ yuserId: yUserId },
|
||||
)) as UserDetailResponse;
|
||||
|
||||
const result = res.data;
|
||||
if (!result?.id) {
|
||||
throw new Error("无法根据 yUserId 获取用户信息。");
|
||||
}
|
||||
|
||||
localStorage.setItem(EXTERNAL_USER_ID_STORAGE_KEY, result.id);
|
||||
localStorage.setItem(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(result));
|
||||
}
|
||||
|
||||
export function persistToolsToken(token: string): void {
|
||||
localStorage.setItem(TOOLS_TOKEN_STORAGE_KEY, token);
|
||||
}
|
||||
@@ -6,7 +6,14 @@ export function isSidebarEnabledFromSearch(search: string): boolean {
|
||||
const params = new URLSearchParams(
|
||||
search.startsWith("?") ? search : `?${search}`,
|
||||
);
|
||||
return params.get(PAGE_SIDEBAR_QUERY_PARAM) === "1";
|
||||
if (params.get(PAGE_SIDEBAR_QUERY_PARAM) === "1") {
|
||||
return true;
|
||||
}
|
||||
// External yUserId login enables the page sidebar (see LoginPage + yuser-login).
|
||||
if (params.get("yUserId")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function persistSidebarEnabled(enabled: boolean): void {
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* 全局侧边栏菜单数据与权限过滤逻辑。
|
||||
*
|
||||
* 浅色 ``PageSidebar`` 和深色 ``PageSidebarDark`` 共用这份数据 —— 菜单结构与
|
||||
* adminOnly 权限策略对两种皮肤完全一致;UI 渲染(icon 显示/折叠样式/hover flyout)
|
||||
* 在各自组件里实现。
|
||||
*
|
||||
* 修改菜单只需改这一处,两个 sidebar 自动同步。
|
||||
*/
|
||||
import type { ElementType } from "react";
|
||||
import {
|
||||
Bot,
|
||||
Brain,
|
||||
CalendarClock,
|
||||
CircleHelp,
|
||||
Database,
|
||||
FileBox,
|
||||
FileText,
|
||||
Folder,
|
||||
Gauge,
|
||||
Grid3X3,
|
||||
HelpCircle,
|
||||
History,
|
||||
Layout,
|
||||
Library,
|
||||
List,
|
||||
ListChecks,
|
||||
ListTodo,
|
||||
MessageSquareCode,
|
||||
MessageSquarePlus,
|
||||
Network,
|
||||
NotebookPen,
|
||||
Plus,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Target,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
export type MenuItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ElementType;
|
||||
path?: string;
|
||||
adminOnly?: boolean;
|
||||
children?: MenuItem[];
|
||||
};
|
||||
|
||||
export const sidebarMenuItems: MenuItem[] = [
|
||||
{
|
||||
id: "qa-center",
|
||||
label: "问答中心",
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
|
||||
{ id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
|
||||
// 「圆桌规划」入口暂时隐藏 —— 功能仍在 /page/roundtable/planning,只是不在侧边栏出现。
|
||||
// 需要重新开放时取消注释即可。
|
||||
// {
|
||||
// id: "qa-roundtable",
|
||||
// label: "圆桌规划",
|
||||
// icon: Bot,
|
||||
// path: "/page/roundtable/planning",
|
||||
// },
|
||||
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
|
||||
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
|
||||
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
|
||||
],
|
||||
},
|
||||
// {
|
||||
// id: "light-app-center",
|
||||
// label: "轻应用",
|
||||
// icon: Grid3X3,
|
||||
// children: [
|
||||
// {
|
||||
// id: "light-app-note-management",
|
||||
// label: "笔记管理",
|
||||
// icon: NotebookPen,
|
||||
// children: [
|
||||
// {
|
||||
// id: "light-app-new-note",
|
||||
// label: "新建笔记",
|
||||
// icon: Plus,
|
||||
// path: "/page/strategy/light-app/new-note",
|
||||
// },
|
||||
// {
|
||||
// id: "light-app-note-list",
|
||||
// label: "笔记列表",
|
||||
// icon: List,
|
||||
// path: "/page/strategy/light-app/notes",
|
||||
// },
|
||||
// {
|
||||
// id: "light-app-note-qa",
|
||||
// label: "笔记问答",
|
||||
// icon: MessageSquareCode,
|
||||
// path: "/page/strategy/light-app/note-qa",
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// { id: "light-app-square", label: "应用广场", icon: Layout },
|
||||
// { id: "light-app-records", label: "运行记录", icon: History },
|
||||
// ],
|
||||
// },
|
||||
{
|
||||
id: "task-management",
|
||||
label: "任务管理",
|
||||
icon: Target,
|
||||
children: [
|
||||
{
|
||||
id: "xk-center-task",
|
||||
label: "XK中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{
|
||||
id: "xk-task-jc",
|
||||
label: "任务JC",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/xk-task-jc",
|
||||
},
|
||||
{
|
||||
id: "xk-task-dispatch",
|
||||
label: "任务调度",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/xk-task-dispatch",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "coh-center-task",
|
||||
label: "COH中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{
|
||||
id: "coh-task-3qfx",
|
||||
label: "3QFX",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/plan-task-sentiment",
|
||||
},
|
||||
{
|
||||
id: "coh-task-fx",
|
||||
label: "任务FX",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/plan-task/plan-task-fx",
|
||||
},
|
||||
{
|
||||
id: "coh-task-xdfx",
|
||||
label: "XDFX",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/plan-task/plan-task-action",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pg-center-task",
|
||||
label: "PG中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{
|
||||
id: "pg-task-pg",
|
||||
label: "任务PG",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/effect-task-pg",
|
||||
},
|
||||
{
|
||||
id: "pg-task-xdpg",
|
||||
label: "XDPG",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/effect-action-pg",
|
||||
},
|
||||
{
|
||||
id: "pg-task-info-pg",
|
||||
label: "信息PG",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/effect-info-pg",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ty-center-task",
|
||||
label: "TY中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{
|
||||
id: "ty-task-xdty",
|
||||
label: "XDTY任务",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/mnty-task-mechanism",
|
||||
},
|
||||
{
|
||||
id: "ty-task-plan",
|
||||
label: "方案TY任务",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/mnty-task-plan",
|
||||
},
|
||||
{
|
||||
id: "ty-task-xxty",
|
||||
label: "XXTY任务",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/mnty-task-zsty",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "comprehensive-management",
|
||||
label: "综合管理",
|
||||
icon: Settings,
|
||||
children: [
|
||||
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
|
||||
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
|
||||
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
||||
{
|
||||
id: "mgmt-tasks",
|
||||
label: "定时任务",
|
||||
icon: ListChecks,
|
||||
path: "/page/strategy/scheduled/tasks",
|
||||
},
|
||||
{
|
||||
id: "mgmt-recommended-questions",
|
||||
label: "推荐问题配置",
|
||||
icon: MessageSquarePlus,
|
||||
path: "/page/strategy/settings/recommended-questions",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-prompt-prefix",
|
||||
label: "问答前缀配置",
|
||||
icon: MessageSquareCode,
|
||||
path: "/page/strategy/settings/prompt-prefix",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-private-square",
|
||||
label: "私密广场配置",
|
||||
icon: ShieldCheck,
|
||||
path: "/page/strategy/settings/private-square",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-llm-metrics",
|
||||
label: "大模型调用统计",
|
||||
icon: Gauge,
|
||||
path: "/page/strategy/settings/llm-metrics",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-users",
|
||||
label: "用户管理",
|
||||
icon: Users,
|
||||
path: "/page/strategy/admin/users",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-admin-scheduled-tasks",
|
||||
label: "定时任务管理",
|
||||
icon: CalendarClock,
|
||||
path: "/page/strategy/admin/scheduled-tasks",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-user-activity",
|
||||
label: "用户活动审计",
|
||||
icon: History,
|
||||
path: "/page/strategy/admin/user-activity",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "resource-management",
|
||||
label: "资源管理",
|
||||
icon: Database,
|
||||
children: [
|
||||
{
|
||||
id: "resource-network-xwt",
|
||||
label: "网络xwt资源管理",
|
||||
icon: Network,
|
||||
path: "/page/strategy/resource-management/resource-network-actor",
|
||||
},
|
||||
{
|
||||
id: "resource-knowledge-base",
|
||||
label: "知识库资源管理",
|
||||
icon: Library,
|
||||
path: "/page/strategy/resource-management/resource-knowledge-base",
|
||||
},
|
||||
{
|
||||
id: "resource-evaluation-index",
|
||||
label: "作战资源管理",
|
||||
icon: FileBox,
|
||||
path: "/page/strategy/resource-management/resource-evaluation-index",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** 默认展开的菜单 id 集合(浅色版可折叠交互的初始状态) */
|
||||
export const defaultExpandedIds: ReadonlySet<string> = new Set([
|
||||
"qa-center",
|
||||
"light-app-center",
|
||||
"light-app-note-management",
|
||||
"task-management",
|
||||
"xk-center-task",
|
||||
]);
|
||||
|
||||
/** 按 admin 角色过滤菜单:非 admin 不展示 ``adminOnly: true`` 的项及其子项 */
|
||||
export function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] {
|
||||
return items
|
||||
.filter((item) => !item.adminOnly || isAdmin)
|
||||
.map((item) =>
|
||||
item.children
|
||||
? { ...item, children: filterMenuItemsByRole(item.children, isAdmin) }
|
||||
: item,
|
||||
);
|
||||
}
|
||||
@@ -30,8 +30,15 @@ const INPUT_CLASS =
|
||||
|
||||
const DEFAULT_KEYWORD_COUNT = 1;
|
||||
const DEFAULT_AUDIENCE = '普通读者';
|
||||
const DEFAULT_MATERIAL_SOURCE: 'knowledge_base' | 'skill' = 'knowledge_base';
|
||||
const MAX_INTENT_LENGTH = 500;
|
||||
|
||||
// 素材来源选项:知识库=素材专家默认 web/intranet 检索;技能检索=用技能编排去检索
|
||||
const MATERIAL_SOURCE_OPTIONS: Array<{ value: 'knowledge_base' | 'skill'; label: string }> = [
|
||||
{ value: 'knowledge_base', label: '知识库' },
|
||||
{ value: 'skill', label: '技能检索' },
|
||||
];
|
||||
|
||||
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222]';
|
||||
const FIELD_TRIGGER_CLASS =
|
||||
'h-12 w-full rounded-xl border-[#e8dedd] bg-white text-sm shadow-sm';
|
||||
@@ -46,6 +53,8 @@ export function AIWritingForm({
|
||||
const [audience, setAudience] = useState('普通读者');
|
||||
const [wordCountInput, setWordCountInput] = useState('800');
|
||||
const [strictMode, setStrictMode] = useState(false);
|
||||
const [materialSource, setMaterialSource] =
|
||||
useState<'knowledge_base' | 'skill'>(DEFAULT_MATERIAL_SOURCE);
|
||||
const [modelName, setModelName] = useState('');
|
||||
// 笔记来源:选中的 search_space_id 和多个 document_id
|
||||
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(null);
|
||||
@@ -134,6 +143,7 @@ export function AIWritingForm({
|
||||
wordCountTarget: wordCount,
|
||||
keywordCount: DEFAULT_KEYWORD_COUNT,
|
||||
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
||||
materialSource,
|
||||
modelName: selectedModel?.name || modelName || undefined,
|
||||
noteSearchSpaceId: selectedNotebookId ?? undefined,
|
||||
// 有勾选文档则传 document_id 列表,否则传空数组(搜整个笔记本)
|
||||
@@ -204,6 +214,29 @@ export function AIWritingForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<label className={FIELD_LABEL_CLASS}>素材来源</label>
|
||||
<Select
|
||||
value={materialSource}
|
||||
onValueChange={(v) => setMaterialSource(v as 'knowledge_base' | 'skill')}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder="选择素材来源" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MATERIAL_SOURCE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-sm">
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground/80">
|
||||
知识库:素材专家默认检索方式;技能检索:用已启用的技能去编排检索。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<label className={FIELD_LABEL_CLASS}>写作模型</label>
|
||||
<Select
|
||||
|
||||
@@ -448,16 +448,24 @@ export function AIWritingPanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 技能选择 LLM 流式打字(含 <think> 思考分离),select_skills 完成后自动清空 */}
|
||||
{state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText) && (
|
||||
{/* 技能选择 LLM 流式打字(含 <think> 思考分离),select_skills 完成后自动清空。
|
||||
挂载条件包含"过渡态":已经走到 match_skills 但还没出 select_skills 时也保持挂载,
|
||||
让首个空 chunk 一到就能立刻显示等待提示 + 闪烁光标,避免模型 buffer 期间静默。 */}
|
||||
{state.status === 'researching' && (
|
||||
state.skillSelectStreamingText
|
||||
|| state.skillSelectThinkingText
|
||||
|| (state.skillSearchSteps.some((s) => s.type === 'match_skills')
|
||||
&& !state.skillSearchSteps.some((s) => s.type === 'select_skills'))
|
||||
) && (
|
||||
<SkillSelectStreamingView
|
||||
text={state.skillSelectStreamingText}
|
||||
thinkingText={state.skillSelectThinkingText}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 研究中 + 已有技能步骤:实时流式展示技能检索过程,避免 skeleton 卡片掩盖步骤 */}
|
||||
{state.status === 'researching' && state.skillSearchSteps.length > 0 && (
|
||||
{/* 技能检索 pending:用户点击瞬间到 awaiting 之间,始终显示 SkillStepsCard 占位
|
||||
steps.length === 0 时 SkillStepsCard 内部会渲染骨架行,不让用户干等空白 */}
|
||||
{state.skillSearchPending && (
|
||||
<div className="px-4 pb-2">
|
||||
<SkillStepsCard steps={state.skillSearchSteps} inProgress />
|
||||
</div>
|
||||
@@ -467,8 +475,9 @@ export function AIWritingPanel({
|
||||
state.status !== 'writing_draft' &&
|
||||
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
|
||||
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) &&
|
||||
/* 技能检索已经在流式展示步骤 / 选择打字机,不需要再叠加 skeleton */
|
||||
!(state.status === 'researching' && (state.skillSearchSteps.length > 0 || state.skillSelectStreamingText || state.skillSelectThinkingText)) &&
|
||||
/* 技能检索占位卡 / 选择打字机 已经在流式展示,不再叠加 skeleton */
|
||||
!state.skillSearchPending &&
|
||||
!(state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText)) &&
|
||||
/* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */
|
||||
!state.intentStreamingText && !state.intentThinkingText && (
|
||||
<div className="px-4 pb-4">
|
||||
|
||||
@@ -73,7 +73,7 @@ export function MaterialsToolbar({
|
||||
value={userQuery}
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder ?? '例如:想查看各大平台销量 / 2025 年财报 / 行业头部公司近期动态…'}
|
||||
placeholder={placeholder ?? '输入文字后点击“重新搜索”或“技能检索”按钮…'}
|
||||
disabled={disabled}
|
||||
className="w-full resize-none rounded-md border border-input bg-background px-2.5 py-1.5 text-xs leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 min-h-[28px] max-h-24"
|
||||
/>
|
||||
|
||||
@@ -7,25 +7,22 @@ interface SkillSelectStreamingViewProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从流式 JSON 文本里增量提取已经识别出的技能名。
|
||||
* 从流式 JSON 文本里增量提取已识别的技能名(仅取**已闭合**的字符串项)。
|
||||
*
|
||||
* LLM 输出形如 `{"selected_skills": ["skill-a", "skill-b", "skill-c"], "reason": "..."}`,
|
||||
* 我们不等整段 JSON 解析完,逐字符扫描"已闭合"的双引号字符串:
|
||||
* - 找到 `"selected_skills"`(或末尾不完整时回退兜底)后取后续内容
|
||||
* - 在该位置之后的 `[ ... ]` 内逐个匹配 `"..."`
|
||||
* - 只取**已闭合**的字符串(结尾必须是 `"`),确保不把正在打字的半截名字露出来
|
||||
* LLM 输出形如 `{"selected_skills": ["skill-a", "skill-b", "skill-c"], "reason": "..."}`。
|
||||
* 不等整段 JSON 解析完,逐字符扫描:
|
||||
* - 定位 `"selected_skills":\s*[`
|
||||
* - 在该位置后到 `]`(或文本末尾)之间逐个匹配 `"..."`
|
||||
* - 只取已闭合的字符串(结尾必须是 `"`),保证不显示正在打字的半截名字
|
||||
*/
|
||||
function extractStreamingSkillNames(text: string): string[] {
|
||||
if (!text) return [];
|
||||
// 定位 selected_skills 的值起点(容错:允许中英文引号 / 不同空白)
|
||||
const keyMatch = text.match(/["']?selected_skills["']?\s*:\s*\[/);
|
||||
if (!keyMatch) return [];
|
||||
const start = keyMatch.index! + keyMatch[0].length;
|
||||
// 截取数组段(找到对应的 ],或者文本结尾)
|
||||
const rest = text.slice(start);
|
||||
const endIdx = rest.indexOf(']');
|
||||
const arrBody = endIdx >= 0 ? rest.slice(0, endIdx) : rest;
|
||||
// 只提取已经"闭合"的字符串:[^"\\]*(?:\\.[^"\\]*)* 处理转义引号
|
||||
const names: string[] = [];
|
||||
const re = /"((?:[^"\\]|\\.)*)"/g;
|
||||
let m: RegExpExecArray | null;
|
||||
@@ -37,32 +34,46 @@ function extractStreamingSkillNames(text: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* 技能选择 LLM 流式打字机视图。
|
||||
* 从流式 JSON 文本里提取 `reason` 字段当前已输出的内容。
|
||||
*
|
||||
* - 思考过程(<think></think> 内)用 amber 折叠面板(与 OutlineStreamingView 风格一致)
|
||||
* - 正文 JSON 用 violet 主题(与 SkillStepsCard 同色系,强调"技能"语义)
|
||||
* - 两边都自动滚动到底;正文带闪烁光标,思考过程在正文未开始时也有光标
|
||||
* 和 skill name 不同,reason 是流式叙述文本,**未闭合也要展示**(带闪烁光标),
|
||||
* 让用户看到 LLM 正在解释选择理由。
|
||||
*/
|
||||
function extractStreamingReason(text: string): { value: string; closed: boolean } {
|
||||
if (!text) return { value: '', closed: false };
|
||||
// 匹配 "reason": "..." 直到下一个未转义的 " 或文本末尾
|
||||
const m = text.match(/["']?reason["']?\s*:\s*"((?:[^"\\]|\\.)*?)("|$)/);
|
||||
if (!m) return { value: '', closed: false };
|
||||
const raw = m[1];
|
||||
const closed = m[2] === '"';
|
||||
// 反转义常见序列
|
||||
const value = raw
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\\\/g, '\\');
|
||||
return { value, closed };
|
||||
}
|
||||
|
||||
export function SkillSelectStreamingView({ text, thinkingText }: SkillSelectStreamingViewProps) {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const thinkRef = useRef<HTMLPreElement>(null);
|
||||
const [thinkOpen, setThinkOpen] = useState(true);
|
||||
|
||||
// 实时从流式 JSON 中增量解析出已识别的技能名
|
||||
// 增量解析:候选技能 + 选择原因
|
||||
const streamingSkills = useMemo(() => extractStreamingSkillNames(text), [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight;
|
||||
}, [text]);
|
||||
const reason = useMemo(() => extractStreamingReason(text), [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (thinkOpen && thinkRef.current) thinkRef.current.scrollTop = thinkRef.current.scrollHeight;
|
||||
}, [thinkingText, thinkOpen]);
|
||||
|
||||
if (!text && !thinkingText) return null;
|
||||
// 步骤预览始终渲染(挂载即显示),让父组件控制挂载时机;
|
||||
// 这样首个空 chunk 到达时用户就能看到"等待模型输出"占位 + 跳动光标
|
||||
const showStepPreview = true;
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-2 space-y-2">
|
||||
{/* 思考过程(amber,可折叠) */}
|
||||
{thinkingText && (
|
||||
<div className="rounded-xl border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden shadow-sm">
|
||||
<button
|
||||
@@ -94,15 +105,16 @@ export function SkillSelectStreamingView({ text, thinkingText }: SkillSelectStre
|
||||
</div>
|
||||
)}
|
||||
|
||||
{text && (
|
||||
{/* 步骤预览(violet,与 SkillStepsCard 的"筛选相关技能"步骤同构) */}
|
||||
{showStepPreview && (
|
||||
<div className="rounded-xl border border-violet-200 dark:border-violet-900 bg-violet-50/50 dark:bg-violet-950/20 overflow-hidden shadow-sm">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-violet-200/60 dark:border-violet-900/60 bg-violet-100/40 dark:bg-violet-900/30">
|
||||
<Sparkles className="size-3.5 text-violet-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-violet-600 dark:text-violet-400">
|
||||
正在筛选相关技能
|
||||
筛选相关技能
|
||||
{streamingSkills.length > 0 && (
|
||||
<span className="ml-1 text-violet-500/80">
|
||||
· 已识别 {streamingSkills.length} 个
|
||||
<span className="ml-1 text-violet-500/80 font-normal">
|
||||
· 候选 {streamingSkills.length} 个(将按顺序使用)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -117,29 +129,42 @@ export function SkillSelectStreamingView({ text, thinkingText }: SkillSelectStre
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 已识别的技能名:每个新出现的 badge 会 fade-in,给用户即时反馈 */}
|
||||
{streamingSkills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 px-3 py-2 border-b border-violet-200/40 dark:border-violet-900/40 bg-violet-50/40 dark:bg-violet-950/10">
|
||||
{streamingSkills.map((name, idx) => (
|
||||
<span
|
||||
key={`${name}-${idx}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-mono bg-violet-100 dark:bg-violet-900/60 text-violet-700 dark:text-violet-200 animate-[skill-fade-in_0.25s_ease-out]"
|
||||
style={{ animationFillMode: 'both' }}
|
||||
>
|
||||
<Zap className="size-2.5 shrink-0" />
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 py-2.5 space-y-2">
|
||||
{/* 已识别的候选技能 badges */}
|
||||
{streamingSkills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{streamingSkills.map((name, idx) => (
|
||||
<span
|
||||
key={`${name}-${idx}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-mono bg-violet-100 dark:bg-violet-900/60 text-violet-700 dark:text-violet-200 animate-[skill-fade-in_0.25s_ease-out]"
|
||||
style={{ animationFillMode: 'both' }}
|
||||
>
|
||||
<Zap className="size-2.5 shrink-0" />
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="text-[11px] font-mono leading-relaxed p-3 text-muted-foreground whitespace-pre-wrap max-h-52 overflow-y-auto"
|
||||
>
|
||||
{text}
|
||||
<span className="inline-block w-0.5 h-3.5 bg-violet-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
</pre>
|
||||
{/* reason 实时文本:未闭合也展示(带光标),闭合后光标消失 */}
|
||||
{reason.value && (
|
||||
<div className="text-[11px] text-violet-700/85 dark:text-violet-300/85 italic leading-relaxed whitespace-pre-wrap">
|
||||
<span className="not-italic font-medium text-violet-600 dark:text-violet-400 mr-1">原因:</span>
|
||||
{reason.value}
|
||||
{!reason.closed && (
|
||||
<span className="inline-block w-0.5 h-3 bg-violet-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 还没出现任何 skill / reason 时给一个占位提示 */}
|
||||
{!streamingSkills.length && !reason.value && (
|
||||
<div className="text-[11px] text-muted-foreground italic flex items-center gap-1">
|
||||
等待模型输出
|
||||
<span className="inline-block w-0.5 h-3 bg-violet-400 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop } from 'lucide-react';
|
||||
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop, Search } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SkillSearchStep } from '../../types/ai-writing';
|
||||
import { CollapseTransition } from './CollapseTransition';
|
||||
@@ -12,6 +12,7 @@ interface SkillStepsCardProps {
|
||||
|
||||
const STEP_ICON: Record<SkillSearchStep['type'], typeof Sparkles> = {
|
||||
list_skills: ListChecks,
|
||||
match_skills: Search,
|
||||
select_skills: Sparkles,
|
||||
invoke_skill: Zap,
|
||||
skill_result: CheckCircle2,
|
||||
@@ -20,6 +21,7 @@ const STEP_ICON: Record<SkillSearchStep['type'], typeof Sparkles> = {
|
||||
|
||||
const STEP_LABEL: Record<SkillSearchStep['type'], string> = {
|
||||
list_skills: '列出可用技能',
|
||||
match_skills: '分析中',
|
||||
select_skills: '筛选相关技能',
|
||||
invoke_skill: '调用技能',
|
||||
skill_result: '检索结果',
|
||||
@@ -30,6 +32,14 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
if (steps.length === 0 && !inProgress) return null;
|
||||
|
||||
// 进行中的步骤:最后一条是 invoke_skill 且还没出对应的 skill_result,
|
||||
// 说明该技能的 web 检索正在 await 中——把它的序号徽标换成 spinner,
|
||||
// 让每次(可能数秒~数十秒的)检索等待都有"正在调用技能X检索中"的实时反馈。
|
||||
const activeIdx =
|
||||
inProgress && steps.length > 0 && steps[steps.length - 1].type === 'invoke_skill'
|
||||
? steps.length - 1
|
||||
: -1;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-violet-200 bg-violet-50/40 dark:border-violet-900/40 dark:bg-violet-950/20 overflow-hidden">
|
||||
<button
|
||||
@@ -61,19 +71,22 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
|
||||
const label = STEP_LABEL[step.type] ?? step.type;
|
||||
const isStopEarly = step.type === 'stop_early';
|
||||
const isResult = step.type === 'skill_result';
|
||||
const isActive = idx === activeIdx;
|
||||
return (
|
||||
<li key={idx} className="flex items-start gap-2 text-[11px]">
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 mt-0.5 inline-flex items-center justify-center size-4 rounded-full text-[10px] font-medium',
|
||||
isStopEarly
|
||||
? 'bg-amber-100 dark:bg-amber-900/60 text-amber-600 dark:text-amber-300'
|
||||
: isResult
|
||||
? 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-300'
|
||||
: 'bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300',
|
||||
isActive
|
||||
? 'bg-violet-500 text-white'
|
||||
: isStopEarly
|
||||
? 'bg-amber-100 dark:bg-amber-900/60 text-amber-600 dark:text-amber-300'
|
||||
: isResult
|
||||
? 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-300'
|
||||
: 'bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300',
|
||||
)}
|
||||
>
|
||||
{idx + 1}
|
||||
{isActive ? <Loader2 className="size-2.5 animate-spin" /> : idx + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
@@ -116,9 +129,30 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{inProgress && (
|
||||
{inProgress && steps.length === 0 && (
|
||||
// 用户刚点击"技能检索",还没收到任何步骤事件:用骨架行 + spinner 占位,
|
||||
// 避免用户在等待后端 emit 时干等空白
|
||||
<>
|
||||
<li className="flex items-center gap-2 text-[11px] text-violet-700/80 dark:text-violet-300/70 italic">
|
||||
<Loader2 className="size-3 animate-spin shrink-0" />
|
||||
正在准备技能检索…
|
||||
</li>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<li key={`skel-${i}`} className="flex items-start gap-2 animate-pulse" style={{ animationDelay: `${i * 120}ms` }}>
|
||||
<span className="shrink-0 mt-0.5 size-4 rounded-full bg-violet-100 dark:bg-violet-900/60" />
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="h-2.5 w-24 rounded bg-violet-100 dark:bg-violet-900/60" />
|
||||
<div className="h-2 w-40 rounded bg-violet-50 dark:bg-violet-900/30" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{inProgress && steps.length > 0 && activeIdx < 0 && (
|
||||
// 当前不在某个技能的检索等待中(最后一条是 result / select 等),
|
||||
// 提示还有后续步骤;正在检索时由该步自身的 spinner 体现进行中,不重复。
|
||||
<li className="flex items-center gap-2 text-[11px] text-muted-foreground italic">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<Loader2 className="size-3 animate-spin shrink-0" />
|
||||
更多步骤生成中…
|
||||
</li>
|
||||
)}
|
||||
|
||||
@@ -77,6 +77,7 @@ const INITIAL_STATE: AIWritingState = {
|
||||
skillSearchSteps: [],
|
||||
skillSelectStreamingText: '',
|
||||
skillSelectThinkingText: '',
|
||||
skillSearchPending: false,
|
||||
streamingReviewItems: [],
|
||||
revisionSummary: null,
|
||||
pendingRevisionContext: null,
|
||||
@@ -107,10 +108,13 @@ function reducer(prev: AIWritingState, action: Action): AIWritingState {
|
||||
|
||||
case 'intervention_submitted': {
|
||||
const ns = action.nextStatus;
|
||||
// 点击瞬间标记技能检索 pending(动机 = 用户的 action 类型),让 SkillStepsCard 占位卡立即挂载
|
||||
const enablingSkillSearch = action.record.action === 'enable_skill_search';
|
||||
return {
|
||||
...prev,
|
||||
currentPause: null,
|
||||
status: ns,
|
||||
skillSearchPending: enablingSkillSearch ? true : prev.skillSearchPending,
|
||||
...(ns === 'researching' ? { researchProgress: '', skillSearchSteps: [], skillSelectStreamingText: '', skillSelectThinkingText: '' } : {}),
|
||||
...(ns === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
|
||||
...(ns === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
|
||||
@@ -343,7 +347,9 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
|
||||
const extra: Partial<AIWritingState> = {};
|
||||
if (pp === 'material_confirm' && event.materials?.length) extra.materials = event.materials;
|
||||
if (pp === 'outline_confirm' && event.outline) extra.outline = event.outline;
|
||||
return { ...base, ...extra, status: pauseStatus, currentPause: event };
|
||||
// 进入素材确认(material_confirm)= 技能检索(如果在进行)已经完成,清掉 pending 占位
|
||||
const clearPending = pp === 'material_confirm' ? { skillSearchPending: false } : {};
|
||||
return { ...base, ...extra, ...clearPending, status: pauseStatus, currentPause: event };
|
||||
}
|
||||
|
||||
case 'done':
|
||||
@@ -630,12 +636,25 @@ export function useAIWriting() {
|
||||
dispatch({ type: 'set_session', sessionId: meta.thread_id });
|
||||
},
|
||||
onCustomEvent(event: unknown) {
|
||||
// 技能检索时机 trace:开 window.__AI_WRITING_DEBUG__ = true 后,控制台按
|
||||
// performance.now() 打印每条 custom 事件到达时刻 + type,用来钉死
|
||||
// skill_search_step(技能检索过程卡)与 skill_select_chunk(筛选技能打字)
|
||||
// 的实际到达顺序,区分"后端未实时 emit" vs "前端渲染滞后"。
|
||||
if (typeof window !== 'undefined' && (window as { __AI_WRITING_DEBUG__?: boolean }).__AI_WRITING_DEBUG__) {
|
||||
const t = (event as { type?: string } | null)?.type;
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(`[ai-writing:custom] +${Math.round(performance.now())}ms type=${t ?? '?'}`, event);
|
||||
}
|
||||
const pe = customToProgressEvent(event, state.sessionId ?? '');
|
||||
if (pe) dispatch({ type: 'handle', event: pe });
|
||||
},
|
||||
onUpdateEvent(data: Record<string, unknown> | undefined) {
|
||||
// data 形如 { node_name: { ...node_output } },可能多个节点同一 step
|
||||
if (!data) return;
|
||||
if (typeof window !== 'undefined' && (window as { __AI_WRITING_DEBUG__?: boolean }).__AI_WRITING_DEBUG__) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(`[ai-writing:updates] +${Math.round(performance.now())}ms nodes=${Object.keys(data).join(',')}`);
|
||||
}
|
||||
// 兜底:updates 通道可能直接带 ``__interrupt__`` 数组(节点进 interrupt 时
|
||||
// LangGraph 推 ``{__interrupt__: [...]}``),SDK 主路径只在 values 通道
|
||||
// 暴露 stream.interrupt —— 万一后端 serialize 又把 __interrupt__ 滤掉,
|
||||
@@ -743,6 +762,7 @@ export function useAIWriting() {
|
||||
word_count_target: request.wordCountTarget,
|
||||
keyword_count: Math.max(1, Math.min(10, request.keywordCount)),
|
||||
writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose',
|
||||
material_source: request.materialSource === 'skill' ? 'skill' : 'knowledge_base',
|
||||
revision_count: 0,
|
||||
max_revisions: 3,
|
||||
status: 'researching',
|
||||
|
||||
@@ -16,12 +16,13 @@ export interface AIMaterial {
|
||||
|
||||
// 技能检索过程的单步记录(前端时间线展示"使用了 X 技能")
|
||||
// list_skills:列出全部 enabled 技能
|
||||
// match_skills:进入 LLM 流式选择前的过渡提示(让用户立刻看到反馈)
|
||||
// select_skills:LLM 按相关度排序的候选
|
||||
// invoke_skill:开始调用某个技能去检索
|
||||
// skill_result:某个技能检索返回了 N 条素材
|
||||
// stop_early:累计素材达到上限,停止后续技能
|
||||
export interface SkillSearchStep {
|
||||
type: 'list_skills' | 'select_skills' | 'invoke_skill' | 'skill_result' | 'stop_early';
|
||||
type: 'list_skills' | 'match_skills' | 'select_skills' | 'invoke_skill' | 'skill_result' | 'stop_early';
|
||||
message: string;
|
||||
skillNames?: string[];
|
||||
skillName?: string;
|
||||
@@ -227,10 +228,8 @@ export interface AIWritingRequest {
|
||||
keywordCount: number;
|
||||
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
|
||||
writingMode: 'strict' | 'loose';
|
||||
/** 笔记来源(可选):笔记本 search_space_id */
|
||||
noteSearchSpaceId?: number;
|
||||
/** 笔记来源(可选):笔记本内指定文档的 document_id 列表,空数组表示检索整个笔记本 */
|
||||
noteSourceIds?: number[];
|
||||
/** 素材来源:knowledge_base=知识库(默认检索)/ skill=技能检索(默认 knowledge_base) */
|
||||
materialSource: 'knowledge_base' | 'skill';
|
||||
}
|
||||
|
||||
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
|
||||
@@ -324,6 +323,9 @@ export interface AIWritingState {
|
||||
// 技能选择 LLM 流式打字内容(正文 / 思考分开),select_skills 完成后清空
|
||||
skillSelectStreamingText: string;
|
||||
skillSelectThinkingText: string;
|
||||
/** 技能检索是否正在进行:用户点击"技能检索"瞬间 true,进入 awaiting 时清掉。
|
||||
* 用于在 LangGraph 还没推任何 skill_search_step 事件时给前端展示占位卡。 */
|
||||
skillSearchPending: boolean;
|
||||
// 流式审核(逐条到达)
|
||||
streamingReviewItems: StreamingReviewItem[];
|
||||
// 修改摘要
|
||||
|
||||
@@ -3,8 +3,18 @@
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { loginByUsername } from "@/core/auth/api";
|
||||
import { setStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
persistExternalLoginParams,
|
||||
resolveThemeFromQuery,
|
||||
} from "@/core/auth/external-login";
|
||||
import {
|
||||
GENERAL_QA_ROUTE,
|
||||
loginByYUserId,
|
||||
persistToolsToken,
|
||||
} from "@/core/auth/yuser-login";
|
||||
import {
|
||||
isSidebarEnabledFromSearch,
|
||||
persistSidebarEnabled,
|
||||
@@ -18,6 +28,7 @@ export default function LoginPage() {
|
||||
const { username } = useParams<{ username?: string }>();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { setTheme } = useTheme();
|
||||
const sidebarEnabled = useMemo(
|
||||
() => isSidebarEnabledFromSearch(searchParams.toString()),
|
||||
[searchParams],
|
||||
@@ -26,9 +37,33 @@ export default function LoginPage() {
|
||||
const raw = username?.trim();
|
||||
return raw && raw.length > 0 ? raw : DEFAULT_USERNAME;
|
||||
}, [username]);
|
||||
// 外部跳转参数:theme 直接调 setTheme;ticket/userId/env 持久化到 localStorage
|
||||
const yUserId = searchParams.get("yUserId")?.trim() ?? "";
|
||||
const toolToken = searchParams.get("token")?.trim() ?? "";
|
||||
const externalParams = useMemo(() => ({
|
||||
theme: searchParams.get("theme") ?? "",
|
||||
ticket: searchParams.get("ticket") ?? "",
|
||||
userId: searchParams.get("userId") ?? "",
|
||||
env: searchParams.get("env") ?? "",
|
||||
}), [searchParams]);
|
||||
const [status, setStatus] = useState<LoginStatus>("loading");
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
|
||||
// 主题切换:theme=dark-blue → 深色,其他值或缺省 → 浅色
|
||||
useEffect(() => {
|
||||
if (!externalParams.theme) return;
|
||||
setTheme(resolveThemeFromQuery(externalParams.theme));
|
||||
}, [externalParams.theme, setTheme]);
|
||||
|
||||
// ticket / userId / env 持久化(与 login 请求并行做,互不阻塞)
|
||||
useEffect(() => {
|
||||
persistExternalLoginParams({
|
||||
ticket: externalParams.ticket,
|
||||
userId: externalParams.userId,
|
||||
env: externalParams.env,
|
||||
});
|
||||
}, [externalParams.ticket, externalParams.userId, externalParams.env]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -36,6 +71,12 @@ export default function LoginPage() {
|
||||
setStatus("loading");
|
||||
setErrorMessage("");
|
||||
try {
|
||||
if (yUserId) {
|
||||
await loginByYUserId(yUserId, toolToken || null);
|
||||
} else if (toolToken) {
|
||||
persistToolsToken(toolToken);
|
||||
}
|
||||
|
||||
const auth = await loginByUsername(resolvedUsername);
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -43,7 +84,7 @@ export default function LoginPage() {
|
||||
setStoredAuth(auth);
|
||||
persistSidebarEnabled(sidebarEnabled);
|
||||
setStatus("success");
|
||||
router.replace("/page/workspace/chats/new");
|
||||
router.replace(yUserId ? GENERAL_QA_ROUTE : "/page/workspace/chats/new");
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -59,7 +100,7 @@ export default function LoginPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [resolvedUsername, router, sidebarEnabled]);
|
||||
}, [resolvedUsername, router, sidebarEnabled, yUserId, toolToken]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-6">
|
||||
|
||||
@@ -36,8 +36,16 @@ function PageLayout() {
|
||||
)
|
||||
)}
|
||||
<main
|
||||
className={`flex-1 overflow-hidden transition-[padding] duration-300 ${
|
||||
!sidebarEnabled ? "" : collapsed ? "pl-16" : "pl-64"
|
||||
className={`flex-1 overflow-hidden ${
|
||||
!sidebarEnabled
|
||||
? ""
|
||||
: isDark
|
||||
// 深色 sidebar:展开 118px(10 + 98 + 10 gap),收起 = 0 sidebar 完全消失,
|
||||
// 只剩左下角浮动按钮(fixed,叠在 main 上不影响布局)。
|
||||
// 不加 transition:transition + sidebar 立刻 unmount 不同步会抽搐
|
||||
? (collapsed ? "pl-0" : "pl-[118px]")
|
||||
// 浅色仍保留过渡动画(pl-16 ↔ pl-64 平滑收展)
|
||||
: `transition-[padding] duration-300 ${collapsed ? "pl-16" : "pl-64"}`
|
||||
}`}
|
||||
>
|
||||
<Routes>
|
||||
@@ -50,6 +58,7 @@ function PageLayout() {
|
||||
pageSidebarHidden={!sidebarEnabled}
|
||||
headerHidden={!sidebarEnabled}
|
||||
compactWorkspaceSidebar={sidebarEnabled}
|
||||
pageSidebarDark={isDark}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -47,18 +47,27 @@ export function WorkspaceLayout({
|
||||
pageSidebarHidden = false,
|
||||
headerHidden = false,
|
||||
compactWorkspaceSidebar = false,
|
||||
pageSidebarDark = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
pageSidebarCollapsed: boolean;
|
||||
pageSidebarHidden?: boolean;
|
||||
headerHidden?: boolean;
|
||||
compactWorkspaceSidebar?: boolean;
|
||||
pageSidebarDark?: boolean;
|
||||
}) {
|
||||
const pageSidebarWidth = pageSidebarHidden
|
||||
? "0rem"
|
||||
: pageSidebarCollapsed
|
||||
? "4rem"
|
||||
: "16rem";
|
||||
: pageSidebarDark
|
||||
// dark page sidebar:
|
||||
// 展开: 视觉右边界 108px(外框 98 + 内层 translateX 10)+ 10px gap = 118px
|
||||
// 收起: sidebar 主体完全消失(连同其占据的宽度),workspace 内容铺到屏幕左边;
|
||||
// 左下角浮动按钮是 fixed,叠在主内容上不影响 layout
|
||||
// 与 PageRoutes.tsx 的 main pl-[118px] / pl-0 保持一致
|
||||
? (pageSidebarCollapsed ? "0rem" : "118px")
|
||||
: pageSidebarCollapsed
|
||||
? "4rem"
|
||||
: "16rem";
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
@@ -97,17 +106,20 @@ export default function WorkspaceRoutes({
|
||||
pageSidebarHidden = false,
|
||||
headerHidden = false,
|
||||
compactWorkspaceSidebar = false,
|
||||
pageSidebarDark = false,
|
||||
}: {
|
||||
pageSidebarCollapsed?: boolean;
|
||||
pageSidebarHidden?: boolean;
|
||||
headerHidden?: boolean;
|
||||
compactWorkspaceSidebar?: boolean;
|
||||
pageSidebarDark?: boolean;
|
||||
}) {
|
||||
const layoutProps = {
|
||||
pageSidebarCollapsed,
|
||||
pageSidebarHidden,
|
||||
headerHidden,
|
||||
compactWorkspaceSidebar,
|
||||
pageSidebarDark,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Backend-persisted draft storage for /page/roundtable/planning.
|
||||
*
|
||||
* One draft = one full roundtable session (Step 1 intent + Step 2 multi-agent
|
||||
* roundtable). Persisted server-side (MySQL) bound to the logged-in user, so it
|
||||
* follows the account across devices/sessions — replacing the old browser-only
|
||||
* `localStorage` storage (see git history of `utils/drafts.ts`).
|
||||
*
|
||||
* The list endpoint returns lightweight metadata only; the full step1/step2
|
||||
* snapshots are fetched on demand via {@link getDraft}. This keeps the history
|
||||
* dropdown cheap and guarantees a load always reads the freshest server copy
|
||||
* (the old localStorage path loaded from a possibly-stale in-memory array).
|
||||
*/
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { IntentSummary } from "@/roundtable-planning/api/intent";
|
||||
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||
|
||||
/**
|
||||
* Step 1 snapshot. Shapes for the chat / timeline lists stay loose (`unknown[]`)
|
||||
* because their inline types in the hooks are wide; the hook casts on hydrate.
|
||||
*/
|
||||
export interface DraftStep1Snapshot {
|
||||
intentThreadId: string | null;
|
||||
intentReady: IntentSummary | null;
|
||||
interactiveMessages: unknown[];
|
||||
timelineEntries: unknown[];
|
||||
}
|
||||
|
||||
export interface DraftStep2Snapshot {
|
||||
/**
|
||||
* The seats actually selected for this session. Restored on load so the
|
||||
* Step 2/3 「参与角色」panel shows the chosen agents instead of the default 6.
|
||||
* Optional — drafts saved before 2026-06 won't carry it.
|
||||
*/
|
||||
selectedAgents?: Array<{ agent_id: string; name: string }>;
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
step2RoundtableDialogues: unknown[];
|
||||
lastLeaderContent: string;
|
||||
hasConsensus: boolean;
|
||||
consensusPercentage: number;
|
||||
budgetLimit: number;
|
||||
/** Per-seat stub AI messages for the sandbox `write-file:` virtual URL loader. */
|
||||
seatStubMessages?: Record<string, unknown[]>;
|
||||
}
|
||||
|
||||
/** Lightweight metadata row returned by the list endpoint. */
|
||||
export interface DraftMeta {
|
||||
id: string;
|
||||
/** Auto-derived task summary (objective → first user message → timestamp). */
|
||||
title: string;
|
||||
furthestStep: 1 | 2 | 3;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Full record including the step snapshots. */
|
||||
export interface DraftRecord extends DraftMeta {
|
||||
step1: DraftStep1Snapshot | null;
|
||||
step2: DraftStep2Snapshot | null;
|
||||
}
|
||||
|
||||
/** Fields accepted when creating a draft. */
|
||||
export interface DraftCreatePayload {
|
||||
/** Optional stable id so the client can keep one id across create + updates. */
|
||||
id?: string;
|
||||
title: string;
|
||||
furthestStep: 1 | 2 | 3;
|
||||
step1: DraftStep1Snapshot;
|
||||
step2: DraftStep2Snapshot;
|
||||
}
|
||||
|
||||
/** Partial update — send only what changed. Omitting `title` preserves a rename. */
|
||||
export interface DraftUpdatePayload {
|
||||
title?: string;
|
||||
furthestStep?: 1 | 2 | 3;
|
||||
step1?: DraftStep1Snapshot;
|
||||
step2?: DraftStep2Snapshot;
|
||||
}
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/roundtable-drafts`;
|
||||
|
||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`${ctx} failed (${res.status}): ${text}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// ── server ⇄ client field mapping ──────────────────────────────────────────
|
||||
// Backend uses snake_case (furthest_step / created_at / updated_at); the rest
|
||||
// of the frontend reads camelCase. Map at this boundary so callers stay clean.
|
||||
|
||||
interface ServerDraftMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
furthest_step: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
interface ServerDraftFull extends ServerDraftMeta {
|
||||
step1: DraftStep1Snapshot | null;
|
||||
step2: DraftStep2Snapshot | null;
|
||||
}
|
||||
|
||||
function toMeta(s: ServerDraftMeta): DraftMeta {
|
||||
const step = Math.min(3, Math.max(1, s.furthest_step || 1)) as 1 | 2 | 3;
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.title ?? "",
|
||||
furthestStep: step,
|
||||
createdAt: s.created_at ?? "",
|
||||
updatedAt: s.updated_at ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function toRecord(s: ServerDraftFull): DraftRecord {
|
||||
return { ...toMeta(s), step1: s.step1 ?? null, step2: s.step2 ?? null };
|
||||
}
|
||||
|
||||
function fromCreate(p: DraftCreatePayload) {
|
||||
return {
|
||||
...(p.id ? { id: p.id } : {}),
|
||||
title: p.title,
|
||||
furthest_step: p.furthestStep,
|
||||
step1: p.step1,
|
||||
step2: p.step2,
|
||||
};
|
||||
}
|
||||
|
||||
function fromUpdate(p: DraftUpdatePayload) {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (p.title !== undefined) out.title = p.title;
|
||||
if (p.furthestStep !== undefined) out.furthest_step = p.furthestStep;
|
||||
if (p.step1 !== undefined) out.step1 = p.step1;
|
||||
if (p.step2 !== undefined) out.step2 = p.step2;
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── CRUD ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** List the current user's drafts, newest first (lightweight metadata only). */
|
||||
export async function listDrafts(): Promise<DraftMeta[]> {
|
||||
const res = await apiFetch(BASE(), { method: "GET" });
|
||||
const data = await readJson<{ drafts: ServerDraftMeta[] }>(res, "list drafts");
|
||||
return (data.drafts ?? []).map(toMeta);
|
||||
}
|
||||
|
||||
/** Fetch the full draft (incl. step1/step2). Returns null on 404. */
|
||||
export async function getDraft(id: string): Promise<DraftRecord | null> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
|
||||
if (res.status === 404) return null;
|
||||
const data = await readJson<ServerDraftFull>(res, "get draft");
|
||||
return toRecord(data);
|
||||
}
|
||||
|
||||
export async function createDraft(payload: DraftCreatePayload): Promise<DraftRecord> {
|
||||
const res = await apiFetch(BASE(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(fromCreate(payload)),
|
||||
});
|
||||
const data = await readJson<ServerDraftFull>(res, "create draft");
|
||||
return toRecord(data);
|
||||
}
|
||||
|
||||
export async function updateDraft(id: string, patch: DraftUpdatePayload): Promise<DraftRecord | null> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(fromUpdate(patch)),
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
const data = await readJson<ServerDraftFull>(res, "update draft");
|
||||
return toRecord(data);
|
||||
}
|
||||
|
||||
export async function deleteDraft(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (!res.ok && res.status !== 404) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`delete draft failed (${res.status}): ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a short task-summary name from whatever the user supplied so far.
|
||||
* Preference: intent objective → first user message → timestamp. Trimmed to
|
||||
* ~40 chars so dropdown rows stay scannable. (Ported from the old utils/drafts.)
|
||||
*/
|
||||
export function deriveDraftName(args: {
|
||||
intentReady?: IntentSummary | null;
|
||||
interactiveMessages?: unknown[];
|
||||
}): string {
|
||||
const MAX = 40;
|
||||
const truncate = (s: string) => (s.length > MAX ? s.slice(0, MAX) + "…" : s);
|
||||
|
||||
const objective = args.intentReady?.objective?.trim();
|
||||
if (objective) return truncate(objective);
|
||||
|
||||
if (Array.isArray(args.interactiveMessages)) {
|
||||
for (const m of args.interactiveMessages) {
|
||||
if (!m || typeof m !== "object") continue;
|
||||
const obj = m as Record<string, unknown>;
|
||||
if (obj.sender === "user" && typeof obj.content === "string" && obj.content.trim()) {
|
||||
return truncate(obj.content.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `未命名草稿 ${new Date().toLocaleString("zh-CN", { hour12: false })}`;
|
||||
}
|
||||
@@ -160,6 +160,14 @@ export interface StreamIntentRequest {
|
||||
toolName?: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => void;
|
||||
/**
|
||||
* Fires for every `ask_clarification` tool message parsed out of the live
|
||||
* stream (the intent agent may emit several in a single turn). Lets the UI
|
||||
* surface each assistance question in real-time instead of waiting for the
|
||||
* terminal frame — which only carries the *first* question. `index` is the
|
||||
* 0-based arrival order within this turn.
|
||||
*/
|
||||
onClarification?: (clarification: ClarificationPayload, index: number) => void;
|
||||
}
|
||||
|
||||
export interface StreamIntentResult {
|
||||
@@ -172,8 +180,16 @@ export interface StreamIntentResult {
|
||||
/**
|
||||
* Populated when the intent agent called ask_clarification — render option
|
||||
* cards + custom input instead of treating the turn as plain text only.
|
||||
* This is the *first* clarification (kept for backward compat); prefer
|
||||
* `clarifications` which carries the full set.
|
||||
*/
|
||||
clarification: ClarificationPayload | null;
|
||||
/**
|
||||
* All `ask_clarification` questions the agent asked this turn, in arrival
|
||||
* order. Collected from the live `type:"tool"` frames (the terminal frame
|
||||
* only carries the first). Empty unless status === "clarification".
|
||||
*/
|
||||
clarifications: ClarificationPayload[];
|
||||
/**
|
||||
* Populated only when status === "error" and the backend emitted a
|
||||
* structured diagnostic (new in `_diagnose_exception`). UI uses this to show
|
||||
@@ -241,6 +257,54 @@ function extractAiTextDelta(parsed: unknown): { text: string; id: string } | nul
|
||||
return { text, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a live `type:"tool"` `ask_clarification` frame into a ClarificationPayload.
|
||||
*
|
||||
* The intent agent emits one such frame per question (ClarificationMiddleware
|
||||
* formats them), each carrying a fully-formed clarification in
|
||||
* `additional_kwargs.clarification` — options already normalized to
|
||||
* `{id, label}` objects, snake_case keys. Returns null for any other frame
|
||||
* (AI text chunks, tool_call_chunks, value snapshots).
|
||||
*/
|
||||
function extractClarificationToolMessage(
|
||||
parsed: unknown,
|
||||
): { payload: ClarificationPayload; toolCallId: string } | null {
|
||||
if (!Array.isArray(parsed) || parsed.length < 1) return null;
|
||||
const msg = parsed[0];
|
||||
if (!msg || typeof msg !== "object") return null;
|
||||
const obj = msg as Record<string, unknown>;
|
||||
if (String(obj.type ?? "").toLowerCase() !== "tool") return null;
|
||||
if (obj.name !== "ask_clarification") return null;
|
||||
const kwargs = obj.additional_kwargs;
|
||||
if (!kwargs || typeof kwargs !== "object") return null;
|
||||
const clar = (kwargs as Record<string, unknown>).clarification;
|
||||
if (!clar || typeof clar !== "object") return null;
|
||||
const c = clar as Record<string, unknown>;
|
||||
const toolCallId =
|
||||
typeof obj.tool_call_id === "string" && obj.tool_call_id
|
||||
? obj.tool_call_id
|
||||
: typeof obj.id === "string"
|
||||
? obj.id
|
||||
: "";
|
||||
const clarificationType =
|
||||
typeof c.clarification_type === "string" ? c.clarification_type : "";
|
||||
return {
|
||||
toolCallId,
|
||||
payload: {
|
||||
id: toolCallId || undefined,
|
||||
question: typeof c.question === "string" ? c.question : "",
|
||||
clarificationType,
|
||||
context: typeof c.context === "string" ? c.context : "",
|
||||
options: Array.isArray(c.options) ? c.options : [],
|
||||
allowCustom: c.allow_custom !== false,
|
||||
allowMultiple: resolveAllowMultiple(
|
||||
typeof c.allow_multiple === "boolean" ? c.allow_multiple : undefined,
|
||||
clarificationType,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an SSE connection to /api/intent/stream and resolve when the upstream
|
||||
* stream completes. Each call is one user turn; reuse the same threadId
|
||||
@@ -288,9 +352,24 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
content: "",
|
||||
summary: null,
|
||||
clarification: null,
|
||||
clarifications: [],
|
||||
errorDetail: null,
|
||||
};
|
||||
|
||||
// Clarifications collected live from `type:"tool"` frames, deduped by
|
||||
// tool_call_id. The terminal frame only carries the first question, so this
|
||||
// is the source of truth for multi-question turns.
|
||||
const seenClarificationIds = new Set<string>();
|
||||
const collectClarification = (payload: ClarificationPayload, toolCallId: string) => {
|
||||
const dedupeKey = toolCallId || `idx-${result.clarifications.length}`;
|
||||
if (seenClarificationIds.has(dedupeKey)) return;
|
||||
seenClarificationIds.add(dedupeKey);
|
||||
const index = result.clarifications.length;
|
||||
result.clarifications.push(payload);
|
||||
if (!result.clarification) result.clarification = payload;
|
||||
req.onClarification?.(payload, index);
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
@@ -331,10 +410,15 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
if (parsed.status === "error") {
|
||||
result.errorDetail = parseIntentErrorDetail(parsed.error_detail);
|
||||
}
|
||||
if (parsed.status === "clarification") {
|
||||
if (parsed.status === "clarification" && result.clarifications.length === 0) {
|
||||
// Fallback path: no live `type:"tool"` clarification frames were seen
|
||||
// (older backend, or single-question turn) — synthesize one from the
|
||||
// terminal frame so the UI still gets a card. When tool frames *were*
|
||||
// seen, `clarifications` already holds the full (possibly multi-question)
|
||||
// set and we keep it.
|
||||
const clarificationType =
|
||||
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
|
||||
result.clarification = {
|
||||
const fallback: ClarificationPayload = {
|
||||
question: typeof parsed.question === "string" ? parsed.question : "",
|
||||
clarificationType,
|
||||
context:
|
||||
@@ -346,11 +430,22 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
clarificationType,
|
||||
),
|
||||
};
|
||||
result.clarification = fallback;
|
||||
result.clarifications = [fallback];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastEvent === "messages") {
|
||||
// `ask_clarification` tool result frame → collect + surface in real-time.
|
||||
// These carry the fully-formed clarification (one per question); the
|
||||
// agent may emit several in a single turn.
|
||||
const toolClar = extractClarificationToolMessage(parsed);
|
||||
if (toolClar) {
|
||||
collectClarification(toolClar.payload, toolClar.toolCallId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase inference from LangGraph messages-tuple frames. Tool calls
|
||||
// (typically ask_clarification) flip phase to tool_calling; AI text
|
||||
// chunks flip to streaming. Everything else leaves phase alone.
|
||||
|
||||
@@ -227,6 +227,13 @@ export interface StreamMultiAgentRequest {
|
||||
}
|
||||
|
||||
export interface ClarificationPayload {
|
||||
/**
|
||||
* Stable id for this clarification — the originating `ask_clarification`
|
||||
* tool_call_id. Lets the UI key per-question selections when the agent asks
|
||||
* several clarifications in one turn (Step 1 multi-question support).
|
||||
* Omitted on the Step 2 leader path (single clarification per turn).
|
||||
*/
|
||||
id?: string;
|
||||
/** Raw question text from the model's ask_clarification args. */
|
||||
question: string;
|
||||
/** Optional category hint from the model (e.g. "missing_info"). */
|
||||
|
||||
@@ -198,6 +198,104 @@ function extractAiTextDelta(parsed: unknown): { text: string; id: string } | nul
|
||||
return { text, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* One persisted recommendation run, bound to a draft/session. Returned by
|
||||
* {@link listRecommendHistory}; lets the dialog show "last recommendation for
|
||||
* this session" before deciding whether to re-analyze.
|
||||
*/
|
||||
export interface RecommendHistoryItem {
|
||||
id: string;
|
||||
draftId: string;
|
||||
objective: string;
|
||||
/** How the run ended on the backend: "done" | "fallback" | "asking" | "error". */
|
||||
status: string;
|
||||
model: string | null;
|
||||
rationale: string | null;
|
||||
picks: RecommendPick[];
|
||||
/** Candidate-pool snapshot captured at recommend time. */
|
||||
candidates: RecommendCandidate[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Payload persisted after a recommendation run completes. */
|
||||
export interface SaveRecommendHistoryPayload {
|
||||
objective: string;
|
||||
status: string;
|
||||
model?: string;
|
||||
rationale?: string;
|
||||
picks: RecommendPick[];
|
||||
candidates: RecommendCandidate[];
|
||||
}
|
||||
|
||||
interface ServerHistoryItem {
|
||||
id: string;
|
||||
draft_id: string;
|
||||
objective: string;
|
||||
status: string;
|
||||
model: string | null;
|
||||
rationale: string | null;
|
||||
picks: RecommendPick[];
|
||||
candidates: RecommendCandidate[];
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
function toHistoryItem(s: ServerHistoryItem): RecommendHistoryItem {
|
||||
return {
|
||||
id: s.id,
|
||||
draftId: s.draft_id,
|
||||
objective: s.objective ?? "",
|
||||
status: s.status ?? "done",
|
||||
model: s.model ?? null,
|
||||
rationale: s.rationale ?? null,
|
||||
picks: Array.isArray(s.picks) ? s.picks : [],
|
||||
candidates: Array.isArray(s.candidates) ? s.candidates : [],
|
||||
createdAt: s.created_at ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** List a draft's recommendation history, newest first. */
|
||||
export async function listRecommendHistory(draftId: string): Promise<RecommendHistoryItem[]> {
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/roundtable-drafts/${encodeURIComponent(draftId)}/recommendations`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
if (res.status === 404) return [];
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`list recommend history failed (${res.status}): ${text}`);
|
||||
}
|
||||
const data = (await res.json()) as { history: ServerHistoryItem[] };
|
||||
return (data.history ?? []).map(toHistoryItem);
|
||||
}
|
||||
|
||||
/** Append one recommendation run to a draft's history. */
|
||||
export async function saveRecommendHistory(
|
||||
draftId: string,
|
||||
payload: SaveRecommendHistoryPayload,
|
||||
): Promise<RecommendHistoryItem | null> {
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/roundtable-drafts/${encodeURIComponent(draftId)}/recommendations`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
objective: payload.objective,
|
||||
status: payload.status,
|
||||
model: payload.model,
|
||||
rationale: payload.rationale,
|
||||
picks: payload.picks,
|
||||
candidates: payload.candidates,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (res.status === 404) return null; // draft gone — skip silently
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`save recommend history failed (${res.status}): ${text}`);
|
||||
}
|
||||
return toHistoryItem((await res.json()) as ServerHistoryItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an SSE connection to `/api/recommend/stream` and resolve once the
|
||||
* upstream stream finishes. Each call is one recommendation round — the
|
||||
|
||||
@@ -12,6 +12,20 @@ export type IntentClarificationSelection = {
|
||||
custom: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Composite selection-map key for one clarification question. When the intent
|
||||
* agent asks several questions in one turn, each needs its own selection state,
|
||||
* so we key by `${msgId}::${clarificationId}` (falling back to the arrival
|
||||
* index when the payload has no id). The map itself lives in `useStep1Intent`.
|
||||
*/
|
||||
export function clarificationKey(
|
||||
msgId: number,
|
||||
clarId: string | undefined,
|
||||
index: number,
|
||||
): string {
|
||||
return `${msgId}::${clarId ?? `idx-${index}`}`;
|
||||
}
|
||||
|
||||
type ClarificationOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -72,6 +86,13 @@ interface IntentClarificationCardProps {
|
||||
submitting?: boolean;
|
||||
/** Multi-select: submit current selection. Single-select: also used for custom text. */
|
||||
onSubmit: (overrideSelection?: IntentClarificationSelection) => void;
|
||||
/**
|
||||
* Defer submission: even single-select option clicks only *record* the
|
||||
* selection (via onSelectionChange) instead of sending immediately, and the
|
||||
* card hides its own submit button. Used when several questions are answered
|
||||
* together and the parent owns one combined submit button.
|
||||
*/
|
||||
deferSubmit?: boolean;
|
||||
}
|
||||
|
||||
export function IntentClarificationCard({
|
||||
@@ -82,6 +103,7 @@ export function IntentClarificationCard({
|
||||
disabled = false,
|
||||
submitting = false,
|
||||
onSubmit,
|
||||
deferSubmit = false,
|
||||
}: IntentClarificationCardProps) {
|
||||
const options = useMemo(
|
||||
() => normalizeOptions(Array.isArray(payload.options) ? payload.options : []),
|
||||
@@ -97,11 +119,18 @@ export function IntentClarificationCard({
|
||||
return selection.selectedIds.length > 0 || selection.custom.trim().length > 0;
|
||||
}, [selection.custom, selection.selectedIds]);
|
||||
|
||||
const showSubmitButton = allowMultiple || payload.allowCustom !== false;
|
||||
// In defer mode the parent owns the (combined) submit button.
|
||||
const showSubmitButton = !deferSubmit && (allowMultiple || payload.allowCustom !== false);
|
||||
|
||||
function handleOptionClick(optionId: string) {
|
||||
if (disabled || submitting) return;
|
||||
if (!allowMultiple) {
|
||||
if (deferSubmit) {
|
||||
// Single-select but combined submit: just record the choice (replace),
|
||||
// keep any custom text the user already typed.
|
||||
onSelectionChange({ ...selection, selectedIds: [optionId] });
|
||||
return;
|
||||
}
|
||||
onSubmit({ selectedIds: [optionId], custom: "" });
|
||||
return;
|
||||
}
|
||||
@@ -144,6 +173,10 @@ export function IntentClarificationCard({
|
||||
</div>
|
||||
{options.map((option) => {
|
||||
const isSelected = selection.selectedIds.includes(option.id);
|
||||
// Highlight selected options whenever the choice is "sticky" — i.e.
|
||||
// multi-select, or single-select in defer mode (where a click records
|
||||
// rather than immediately sends).
|
||||
const showSelected = isSelected && (allowMultiple || deferSubmit);
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
@@ -152,10 +185,10 @@ export function IntentClarificationCard({
|
||||
onClick={() => handleOptionClick(option.id)}
|
||||
className={cn(
|
||||
"flex w-full items-start justify-between gap-2 rounded-xl border px-3 py-2 text-left transition",
|
||||
isSelected && allowMultiple
|
||||
showSelected
|
||||
? "border-primary bg-primary text-white shadow-sm"
|
||||
: "border-slate-200 bg-slate-50/80 text-slate-800 hover:border-primary/40 hover:bg-white",
|
||||
!allowMultiple && !disabled && !submitting && "active:scale-[0.99]",
|
||||
!allowMultiple && !deferSubmit && !disabled && !submitting && "active:scale-[0.99]",
|
||||
(disabled || submitting) && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
@@ -165,14 +198,14 @@ export function IntentClarificationCard({
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] leading-snug",
|
||||
isSelected && allowMultiple ? "text-white/80" : "text-slate-500",
|
||||
showSelected ? "text-white/80" : "text-slate-500",
|
||||
)}
|
||||
>
|
||||
{option.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isSelected && allowMultiple ? (
|
||||
{showSelected ? (
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
@@ -31,9 +31,12 @@ import { MessageStepsCard } from "./MessageStepsCard";
|
||||
import type { IntentSummary } from "../api/intent";
|
||||
import {
|
||||
filterRecommendCandidates,
|
||||
listRecommendHistory,
|
||||
parseRecommendStreamContent,
|
||||
saveRecommendHistory,
|
||||
streamRecommend,
|
||||
type RecommendCandidate,
|
||||
type RecommendHistoryItem,
|
||||
type RecommendStreamPhase,
|
||||
} from "../api/recommend";
|
||||
import { useAutoScroll } from "../hooks/useAutoScroll";
|
||||
@@ -56,6 +59,13 @@ export interface RecommendAgentsDialogProps {
|
||||
intent: IntentSummary | null;
|
||||
/** Falls back to the recommender's own default if omitted. */
|
||||
model?: string;
|
||||
/**
|
||||
* Draft/session id this recommendation belongs to. When present, the dialog
|
||||
* shows the session's last recommendation on open (with a「重新分析」button)
|
||||
* and persists each fresh run to history. Null → behaves statelessly (always
|
||||
* runs a fresh recommendation, no history).
|
||||
*/
|
||||
draftId?: string | null;
|
||||
onCancel: () => void;
|
||||
/** Called with the user's final selection of agent ids (in display order). */
|
||||
onConfirm: (agents: Array<{ agent_id: string; name: string }>) => void;
|
||||
@@ -81,6 +91,8 @@ const FALLBACK_AGENT_IDS = [
|
||||
type DialogPhase =
|
||||
| "idle"
|
||||
| "loading_agents"
|
||||
| "loading_history"
|
||||
| "history"
|
||||
| "recommending"
|
||||
| "ready"
|
||||
| "fallback"
|
||||
@@ -111,6 +123,8 @@ interface InternalState {
|
||||
warning: string | null;
|
||||
/** Latest error message; only shown when `phase === "error"`. */
|
||||
errorMessage: string | null;
|
||||
/** ISO time of the historical record being shown (phase === "history"). */
|
||||
historyCreatedAt: string | null;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: InternalState = {
|
||||
@@ -123,6 +137,7 @@ const INITIAL_STATE: InternalState = {
|
||||
recommendedIds: [],
|
||||
warning: null,
|
||||
errorMessage: null,
|
||||
historyCreatedAt: null,
|
||||
};
|
||||
|
||||
/** 推荐流程的 phase → 中文标签;与 RoundtablePlanningPage::phaseLabel 风格保持一致。 */
|
||||
@@ -164,6 +179,7 @@ export function RecommendAgentsDialog({
|
||||
open,
|
||||
intent,
|
||||
model,
|
||||
draftId,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: RecommendAgentsDialogProps) {
|
||||
@@ -185,172 +201,240 @@ export function RecommendAgentsDialog({
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* The full pipeline: fetch agents → run recommendation → seed selection.
|
||||
* Wrapped so the user can re-trigger it via the "重新推荐" button.
|
||||
* Render a stored history record as the current view (no streaming). Used on
|
||||
* open when the session already has a prior recommendation. The user can
|
||||
* confirm these picks directly or click「重新分析」to run a fresh one.
|
||||
*/
|
||||
const runRecommendation = useCallback(async () => {
|
||||
if (!intent || !intent.objective) {
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
phase: "error",
|
||||
errorMessage: "缺少任务目标,无法启动推荐",
|
||||
});
|
||||
return;
|
||||
const applyHistory = useCallback((item: RecommendHistoryItem, candidates: RecommendCandidate[]) => {
|
||||
const reasons: Record<string, string> = {};
|
||||
const recommendedIds: string[] = [];
|
||||
for (const p of item.picks) {
|
||||
reasons[p.agent_id] = p.reason;
|
||||
recommendedIds.push(p.agent_id);
|
||||
}
|
||||
|
||||
cancelInflight();
|
||||
const myGen = ++generationRef.current;
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setState({ ...INITIAL_STATE, phase: "loading_agents" });
|
||||
setSelectedIds(new Set());
|
||||
|
||||
let agents: Agent[];
|
||||
try {
|
||||
agents = await listAgents();
|
||||
} catch (err) {
|
||||
if (myGen !== generationRef.current) return;
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
phase: "error",
|
||||
errorMessage: `加载智能体列表失败:${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (myGen !== generationRef.current) return;
|
||||
|
||||
const candidates = filterRecommendCandidates(agents);
|
||||
if (candidates.length === 0) {
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
phase: "error",
|
||||
errorMessage: "候选智能体池为空,请先在系统里创建至少 2 个智能体",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const seed = intersectInOrder(recommendedIds, candidates);
|
||||
setSelectedIds(new Set(seed));
|
||||
const { rationale } = parseRecommendStreamContent(item.rationale || "");
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "recommending",
|
||||
phase: "history",
|
||||
candidates,
|
||||
rationale: "",
|
||||
streamPhase: "connecting",
|
||||
// 推荐开始,清空上一轮的步骤时间线并打第一条点。
|
||||
steps: [
|
||||
{ id: "0", key: "connecting", label: recommendPhaseLabel("connecting"), ts: nowTimeStr() },
|
||||
],
|
||||
rationale: rationale || item.rationale || "",
|
||||
recommendedIds: seed,
|
||||
reasons,
|
||||
streamPhase: null,
|
||||
steps: [],
|
||||
warning: null,
|
||||
historyCreatedAt: item.createdAt || null,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
try {
|
||||
const result = await streamRecommend({
|
||||
intent,
|
||||
/**
|
||||
* Stream a fresh recommendation over the given candidate pool, seed the
|
||||
* selection, and (when bound to a draft) persist the run to history.
|
||||
*/
|
||||
const analyze = useCallback(
|
||||
async (candidates: RecommendCandidate[], myGen: number, signal: AbortSignal) => {
|
||||
if (!intent) return;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "recommending",
|
||||
candidates,
|
||||
model,
|
||||
signal: controller.signal,
|
||||
onRationaleUpdate: (rationale) => {
|
||||
if (myGen !== generationRef.current) return;
|
||||
setState((prev) => ({ ...prev, rationale }));
|
||||
},
|
||||
onPicksUpdate: (picks) => {
|
||||
if (myGen !== generationRef.current) return;
|
||||
const reasons: Record<string, string> = {};
|
||||
const recommendedIds: string[] = [];
|
||||
for (const p of picks) {
|
||||
rationale: "",
|
||||
reasons: {},
|
||||
recommendedIds: [],
|
||||
warning: null,
|
||||
historyCreatedAt: null,
|
||||
streamPhase: "connecting",
|
||||
// 推荐开始,清空上一轮的步骤时间线并打第一条点。
|
||||
steps: [
|
||||
{ id: "0", key: "connecting", label: recommendPhaseLabel("connecting"), ts: nowTimeStr() },
|
||||
],
|
||||
}));
|
||||
setSelectedIds(new Set());
|
||||
|
||||
try {
|
||||
const result = await streamRecommend({
|
||||
intent,
|
||||
candidates,
|
||||
model,
|
||||
signal,
|
||||
onRationaleUpdate: (rationale) => {
|
||||
if (myGen !== generationRef.current) return;
|
||||
setState((prev) => ({ ...prev, rationale }));
|
||||
},
|
||||
onPicksUpdate: (picks) => {
|
||||
if (myGen !== generationRef.current) return;
|
||||
const reasons: Record<string, string> = {};
|
||||
const recommendedIds: string[] = [];
|
||||
for (const p of picks) {
|
||||
reasons[p.agent_id] = p.reason;
|
||||
recommendedIds.push(p.agent_id);
|
||||
}
|
||||
const seed = intersectInOrder(recommendedIds, candidates);
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of seed) next.add(id);
|
||||
return next;
|
||||
});
|
||||
setState((prev) => ({ ...prev, recommendedIds, reasons }));
|
||||
},
|
||||
onPhaseChange: (phase) => {
|
||||
if (myGen !== generationRef.current) return;
|
||||
setState((prev) => {
|
||||
const key = phase;
|
||||
const last = prev.steps[prev.steps.length - 1];
|
||||
const steps =
|
||||
last && last.key === key
|
||||
? prev.steps
|
||||
: [
|
||||
...prev.steps,
|
||||
{
|
||||
id: String(prev.steps.length),
|
||||
key,
|
||||
label: recommendPhaseLabel(phase),
|
||||
ts: nowTimeStr(),
|
||||
},
|
||||
];
|
||||
return { ...prev, streamPhase: phase, steps };
|
||||
});
|
||||
},
|
||||
});
|
||||
if (myGen !== generationRef.current) return;
|
||||
|
||||
const reasons: Record<string, string> = {};
|
||||
const recommendedIds: string[] = [];
|
||||
if (result.picks) {
|
||||
for (const p of result.picks) {
|
||||
reasons[p.agent_id] = p.reason;
|
||||
recommendedIds.push(p.agent_id);
|
||||
}
|
||||
const seed = intersectInOrder(recommendedIds, candidates);
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of seed) next.add(id);
|
||||
return next;
|
||||
});
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
recommendedIds,
|
||||
reasons,
|
||||
}));
|
||||
},
|
||||
onPhaseChange: (phase) => {
|
||||
if (myGen !== generationRef.current) return;
|
||||
setState((prev) => {
|
||||
const key = phase;
|
||||
const last = prev.steps[prev.steps.length - 1];
|
||||
// 连续重复阶段不重复 push,只在阶段切换时打点。
|
||||
const steps =
|
||||
last && last.key === key
|
||||
? prev.steps
|
||||
: [
|
||||
...prev.steps,
|
||||
{
|
||||
id: String(prev.steps.length),
|
||||
key,
|
||||
label: recommendPhaseLabel(phase),
|
||||
ts: nowTimeStr(),
|
||||
},
|
||||
];
|
||||
return { ...prev, streamPhase: phase, steps };
|
||||
});
|
||||
},
|
||||
});
|
||||
if (myGen !== generationRef.current) return;
|
||||
|
||||
const reasons: Record<string, string> = {};
|
||||
const recommendedIds: string[] = [];
|
||||
if (result.picks) {
|
||||
for (const p of result.picks) {
|
||||
reasons[p.agent_id] = p.reason;
|
||||
recommendedIds.push(p.agent_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.status === "done" && recommendedIds.length >= MIN_SELECTED) {
|
||||
const seed = intersectInOrder(recommendedIds, candidates);
|
||||
setSelectedIds(new Set(seed));
|
||||
const { rationale } = parseRecommendStreamContent(result.content || "");
|
||||
|
||||
let finalPhase: DialogPhase;
|
||||
let finalIds: string[];
|
||||
let warning: string | null;
|
||||
if (result.status === "done" && recommendedIds.length >= MIN_SELECTED) {
|
||||
finalPhase = "ready";
|
||||
finalIds = intersectInOrder(recommendedIds, candidates);
|
||||
warning = null;
|
||||
} else {
|
||||
// Fallback: status === "asking" or picks too few. Use the 6 built-in
|
||||
// roundtable seats, intersected against the live pool.
|
||||
finalPhase = "fallback";
|
||||
const fallback = intersectInOrder(FALLBACK_AGENT_IDS, candidates);
|
||||
finalIds =
|
||||
fallback.length >= MIN_SELECTED
|
||||
? fallback
|
||||
: candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
|
||||
warning = "智能推荐未生成有效结果,已为你勾选默认的圆桌席位,可手动调整。";
|
||||
}
|
||||
|
||||
setSelectedIds(new Set(finalIds));
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "ready",
|
||||
phase: finalPhase,
|
||||
rationale: rationale || prev.rationale,
|
||||
recommendedIds,
|
||||
recommendedIds: finalIds,
|
||||
reasons,
|
||||
streamPhase: null,
|
||||
warning: null,
|
||||
warning,
|
||||
}));
|
||||
|
||||
// Persist this run to the session's history (best-effort).
|
||||
if (draftId) {
|
||||
const picks = finalIds.map((id) => ({ agent_id: id, reason: reasons[id] ?? "" }));
|
||||
void saveRecommendHistory(draftId, {
|
||||
objective: intent.objective,
|
||||
status: finalPhase === "ready" ? "done" : "fallback",
|
||||
model,
|
||||
rationale: rationale || result.content || "",
|
||||
picks,
|
||||
candidates,
|
||||
}).catch((err) => console.warn("[recommend] 保存推荐历史失败:", err));
|
||||
}
|
||||
} catch (err) {
|
||||
if (myGen !== generationRef.current) return;
|
||||
// AbortError fires when the user closes the dialog mid-stream — no
|
||||
// state update needed because the dialog is going away.
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "error",
|
||||
streamPhase: null,
|
||||
errorMessage: `推荐过程出错:${err instanceof Error ? err.message : String(err)}`,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[intent, model, draftId],
|
||||
);
|
||||
|
||||
/**
|
||||
* Open pipeline: load candidates → (unless forceFresh) show the session's
|
||||
* last recommendation from history → otherwise run a fresh analysis.
|
||||
* `forceFresh=true` is the「重新分析」/「重新推荐」path.
|
||||
*/
|
||||
const start = useCallback(
|
||||
async (forceFresh: boolean) => {
|
||||
if (!intent || !intent.objective) {
|
||||
setState({ ...INITIAL_STATE, phase: "error", errorMessage: "缺少任务目标,无法启动推荐" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: status === "asking" or picks too few. Use the 6 built-in
|
||||
// roundtable seats, intersected against the live pool.
|
||||
const fallback = intersectInOrder(FALLBACK_AGENT_IDS, candidates);
|
||||
const seed = fallback.length >= MIN_SELECTED
|
||||
? fallback
|
||||
: candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
|
||||
setSelectedIds(new Set(seed));
|
||||
const { rationale } = parseRecommendStreamContent(result.content || "");
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "fallback",
|
||||
rationale: rationale || prev.rationale,
|
||||
recommendedIds: seed,
|
||||
reasons,
|
||||
streamPhase: null,
|
||||
warning: "智能推荐未生成有效结果,已为你勾选默认的圆桌席位,可手动调整。",
|
||||
}));
|
||||
} catch (err) {
|
||||
cancelInflight();
|
||||
const myGen = ++generationRef.current;
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setState({ ...INITIAL_STATE, phase: "loading_agents" });
|
||||
setSelectedIds(new Set());
|
||||
|
||||
let agents: Agent[];
|
||||
try {
|
||||
agents = await listAgents();
|
||||
} catch (err) {
|
||||
if (myGen !== generationRef.current) return;
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
phase: "error",
|
||||
errorMessage: `加载智能体列表失败:${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (myGen !== generationRef.current) return;
|
||||
// AbortError fires when the user closes the dialog mid-stream — no
|
||||
// state update needed because the dialog is going away.
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
phase: "error",
|
||||
streamPhase: null,
|
||||
errorMessage: `推荐过程出错:${err instanceof Error ? err.message : String(err)}`,
|
||||
}));
|
||||
}
|
||||
}, [intent, model, cancelInflight]);
|
||||
|
||||
const candidates = filterRecommendCandidates(agents);
|
||||
if (candidates.length === 0) {
|
||||
setState({
|
||||
...INITIAL_STATE,
|
||||
phase: "error",
|
||||
errorMessage: "候选智能体池为空,请先在系统里创建至少 2 个智能体",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 若该会话已有推荐历史,默认展示最近一次(可重新分析),不自动重跑模型。
|
||||
if (!forceFresh && draftId) {
|
||||
setState((prev) => ({ ...prev, phase: "loading_history", candidates }));
|
||||
try {
|
||||
const history = await listRecommendHistory(draftId);
|
||||
if (myGen !== generationRef.current) return;
|
||||
if (history.length > 0) {
|
||||
applyHistory(history[0], candidates);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (myGen !== generationRef.current) return;
|
||||
// 历史拉取失败不阻断:退化为直接跑一次新推荐。
|
||||
console.warn("[recommend] 拉取推荐历史失败,回退到新推荐:", err);
|
||||
}
|
||||
}
|
||||
|
||||
await analyze(candidates, myGen, controller.signal);
|
||||
},
|
||||
[intent, draftId, cancelInflight, analyze, applyHistory],
|
||||
);
|
||||
|
||||
// Trigger the pipeline whenever the dialog opens. Closing it aborts any
|
||||
// in-flight stream so the next open starts clean.
|
||||
@@ -359,11 +443,11 @@ export function RecommendAgentsDialog({
|
||||
cancelInflight();
|
||||
return;
|
||||
}
|
||||
void runRecommendation();
|
||||
void start(false);
|
||||
return () => {
|
||||
cancelInflight();
|
||||
};
|
||||
}, [open, runRecommendation, cancelInflight]);
|
||||
}, [open, start, cancelInflight]);
|
||||
|
||||
const handleToggle = useCallback((agentId: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
@@ -391,7 +475,7 @@ export function RecommendAgentsDialog({
|
||||
|
||||
const selectedCount = selectedIds.size;
|
||||
const canConfirm =
|
||||
(state.phase === "ready" || state.phase === "fallback") &&
|
||||
(state.phase === "ready" || state.phase === "fallback" || state.phase === "history") &&
|
||||
selectedCount >= MIN_SELECTED &&
|
||||
selectedCount <= MAX_SELECTED;
|
||||
|
||||
@@ -452,7 +536,9 @@ export function RecommendAgentsDialog({
|
||||
============================================================ */}
|
||||
<CollapsibleModelOutput
|
||||
isLoading={
|
||||
state.phase === "loading_agents" || state.phase === "recommending"
|
||||
state.phase === "loading_agents" ||
|
||||
state.phase === "loading_history" ||
|
||||
state.phase === "recommending"
|
||||
}
|
||||
phase={state.phase}
|
||||
scrollDeps={[state.rationale, state.steps, state.streamPhase]}
|
||||
@@ -497,8 +583,20 @@ export function RecommendAgentsDialog({
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (state.phase === "ready" || state.phase === "fallback") ? (
|
||||
) : (state.phase === "ready" || state.phase === "fallback" || state.phase === "history") ? (
|
||||
<>
|
||||
{state.phase === "history" && (
|
||||
<div className="text-xs text-sky-700 dark:text-sky-300 p-2 border border-sky-300/50 dark:border-sky-700/50 rounded-md bg-sky-50 dark:bg-sky-950/30 flex items-center justify-between gap-2">
|
||||
<span>
|
||||
📌 这是本会话上次的推荐结果
|
||||
{state.historyCreatedAt
|
||||
? `(${new Date(state.historyCreatedAt).toLocaleString("zh-CN", { hour12: false })})`
|
||||
: ""}
|
||||
,可直接进入研讨,或点「重新分析」让模型重新推荐。
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.warning && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-300 p-2 border border-amber-300/50 dark:border-amber-700/50 rounded-md bg-amber-50 dark:bg-amber-950/30">
|
||||
{state.warning}
|
||||
@@ -551,15 +649,18 @@ export function RecommendAgentsDialog({
|
||||
>
|
||||
{selectionHint}
|
||||
</span>
|
||||
{(state.phase === "ready" || state.phase === "fallback" || state.phase === "error") && (
|
||||
{(state.phase === "ready" ||
|
||||
state.phase === "fallback" ||
|
||||
state.phase === "history" ||
|
||||
state.phase === "error") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant={state.phase === "history" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => void runRecommendation()}
|
||||
onClick={() => void start(true)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<RefreshCwIcon className="size-3 mr-1" />
|
||||
重新推荐
|
||||
{state.phase === "history" ? "重新分析" : "重新推荐"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -624,7 +725,8 @@ function CollapsibleModelOutput({
|
||||
hasAutoClosedRef.current = false;
|
||||
return;
|
||||
}
|
||||
const isCompleted = phase === "ready" || phase === "fallback" || phase === "error";
|
||||
const isCompleted =
|
||||
phase === "ready" || phase === "fallback" || phase === "error" || phase === "history";
|
||||
if (isCompleted && !hasAutoClosedRef.current && open) {
|
||||
const timer = setTimeout(() => {
|
||||
setOpen(false);
|
||||
@@ -639,6 +741,10 @@ function CollapsibleModelOutput({
|
||||
switch (phase) {
|
||||
case "loading_agents":
|
||||
return { label: "加载候选", className: "bg-slate-100 text-slate-600" };
|
||||
case "loading_history":
|
||||
return { label: "读取历史", className: "bg-slate-100 text-slate-600" };
|
||||
case "history":
|
||||
return { label: "历史记录", className: "bg-sky-50 text-sky-700 border border-sky-200" };
|
||||
case "recommending":
|
||||
return { label: "正在生成", className: "bg-purple-50 text-purple-700 border border-purple-200" };
|
||||
case "ready":
|
||||
@@ -713,11 +819,11 @@ function CollapsibleModelOutput({
|
||||
* - 流式中正文末尾跟一个闪烁竖线
|
||||
*/
|
||||
function RecommenderOutput({ state }: { state: InternalState }) {
|
||||
if (state.phase === "loading_agents") {
|
||||
if (state.phase === "loading_agents" || state.phase === "loading_history") {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在加载候选智能体...
|
||||
{state.phase === "loading_history" ? "正在读取历史推荐..." : "正在加载候选智能体..."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -733,7 +839,8 @@ function RecommenderOutput({ state }: { state: InternalState }) {
|
||||
const showRationaleStage =
|
||||
state.phase === "recommending" ||
|
||||
state.phase === "ready" ||
|
||||
state.phase === "fallback";
|
||||
state.phase === "fallback" ||
|
||||
state.phase === "history";
|
||||
if (!showRationaleStage) return null;
|
||||
|
||||
const isStreaming = state.phase === "recommending";
|
||||
|
||||
@@ -25,8 +25,10 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
|
||||
import {
|
||||
clarificationKey,
|
||||
IntentClarificationCard,
|
||||
type IntentClarificationSelection,
|
||||
} from "@/roundtable-planning/components/IntentClarificationCard";
|
||||
@@ -55,10 +57,10 @@ export interface Step1PanelProps {
|
||||
intentReady: object | null;
|
||||
isIntentSending: boolean;
|
||||
pendingIntentClarificationMsgId: number | null;
|
||||
intentClarificationSelections: Map<number, IntentClarificationSelection>;
|
||||
intentClarificationSelections: Map<string, IntentClarificationSelection>;
|
||||
// ─── Setters / Handlers ──────────────────────────────────────
|
||||
setIntentClarificationSelections: React.Dispatch<
|
||||
React.SetStateAction<Map<number, IntentClarificationSelection>>
|
||||
React.SetStateAction<Map<string, IntentClarificationSelection>>
|
||||
>;
|
||||
onSendMessage: (text: string) => void;
|
||||
onStopIntent: () => void;
|
||||
@@ -67,10 +69,10 @@ export interface Step1PanelProps {
|
||||
onEnterRoundtable: () => void;
|
||||
onManualSave: () => void;
|
||||
onTriggerToast: (msg: string) => void;
|
||||
submitIntentClarification: (
|
||||
submitIntentClarifications: (
|
||||
msgId: number,
|
||||
payload: ClarificationPayload,
|
||||
overrideSelection?: IntentClarificationSelection,
|
||||
clarifications: ClarificationPayload[],
|
||||
overrideByKey?: Record<string, IntentClarificationSelection>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -90,7 +92,7 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
onEnterRoundtable,
|
||||
onManualSave,
|
||||
onTriggerToast,
|
||||
submitIntentClarification,
|
||||
submitIntentClarifications,
|
||||
} = props;
|
||||
|
||||
const step1ScrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -138,7 +140,7 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
intentClarificationSelections={intentClarificationSelections}
|
||||
setIntentClarificationSelections={setIntentClarificationSelections}
|
||||
isIntentSending={isIntentSending}
|
||||
submitIntentClarification={submitIntentClarification}
|
||||
submitIntentClarifications={submitIntentClarifications}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -266,15 +268,15 @@ interface Step1MessageProps {
|
||||
msg: InteractiveMessage;
|
||||
intentReady: object | null;
|
||||
pendingIntentClarificationMsgId: number | null;
|
||||
intentClarificationSelections: Map<number, IntentClarificationSelection>;
|
||||
intentClarificationSelections: Map<string, IntentClarificationSelection>;
|
||||
setIntentClarificationSelections: React.Dispatch<
|
||||
React.SetStateAction<Map<number, IntentClarificationSelection>>
|
||||
React.SetStateAction<Map<string, IntentClarificationSelection>>
|
||||
>;
|
||||
isIntentSending: boolean;
|
||||
submitIntentClarification: (
|
||||
submitIntentClarifications: (
|
||||
msgId: number,
|
||||
payload: ClarificationPayload,
|
||||
overrideSelection?: IntentClarificationSelection,
|
||||
clarifications: ClarificationPayload[],
|
||||
overrideByKey?: Record<string, IntentClarificationSelection>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -285,7 +287,7 @@ function Step1Message({
|
||||
intentClarificationSelections,
|
||||
setIntentClarificationSelections,
|
||||
isIntentSending,
|
||||
submitIntentClarification,
|
||||
submitIntentClarifications,
|
||||
}: Step1MessageProps) {
|
||||
const isStreaming = msg.streaming === true;
|
||||
const isAgent = msg.sender !== "user";
|
||||
@@ -297,24 +299,23 @@ function Step1Message({
|
||||
? splitInlineReasoning(rawContent)
|
||||
: { content: rawContent, reasoning: null };
|
||||
const hasContent = thoughtFreeContent.length > 0;
|
||||
const clarification = msg.clarification;
|
||||
// 一轮可能有多条澄清问题(任务理解智能体一次问多个)。流式期就逐个灌进来,
|
||||
// 实时展示「协助过程」;终态再以权威集合覆盖。
|
||||
const clarifications = msg.clarifications ?? [];
|
||||
const parts = isAgent
|
||||
? splitIntentReadyBlock(thoughtFreeContent)
|
||||
: { before: thoughtFreeContent, summary: null as IntentSummary | null, after: "" };
|
||||
const isPendingClarification =
|
||||
isAgent &&
|
||||
clarification &&
|
||||
!intentReady &&
|
||||
!parts.summary &&
|
||||
pendingIntentClarificationMsgId === msg.id;
|
||||
const clarificationSelection =
|
||||
intentClarificationSelections.get(msg.id) ?? {
|
||||
selectedIds: [],
|
||||
custom: "",
|
||||
};
|
||||
// 是否展示澄清卡(还没出 summary / 还没整体就绪时)。流式中也展示(只读),
|
||||
// 终态且该消息为当前待澄清条目时才可交互。
|
||||
const showClarifications =
|
||||
isAgent && clarifications.length > 0 && !intentReady && !parts.summary;
|
||||
// 一轮多个问题才进入「逐题作答 + 合并发送」模式;单问题沿用「点击即发送」。
|
||||
const multiQuestion = clarifications.length > 1;
|
||||
const isPending = pendingIntentClarificationMsgId === msg.id;
|
||||
const clarificationsInteractive = isPending && !isStreaming;
|
||||
const introText =
|
||||
clarification &&
|
||||
parts.before.trim() === (clarification.question || "").trim()
|
||||
clarifications.length === 1 &&
|
||||
parts.before.trim() === (clarifications[0].question || "").trim()
|
||||
? ""
|
||||
: parts.before;
|
||||
|
||||
@@ -361,24 +362,63 @@ function Step1Message({
|
||||
把它丢弃,让用户看到的就是"复述 + 摘要卡",干净结束。
|
||||
*/}
|
||||
{isStreaming && <StreamingCursor />}
|
||||
{isPendingClarification && clarification && (
|
||||
<IntentClarificationCard
|
||||
payload={clarification}
|
||||
fallbackContent={parts.before || undefined}
|
||||
selection={clarificationSelection}
|
||||
onSelectionChange={(next) => {
|
||||
setIntentClarificationSelections((prev) => {
|
||||
const map = new Map(prev);
|
||||
map.set(msg.id, next);
|
||||
return map;
|
||||
});
|
||||
}}
|
||||
disabled={!isPendingClarification}
|
||||
submitting={isIntentSending && Boolean(isPendingClarification)}
|
||||
onSubmit={(override) =>
|
||||
submitIntentClarification(msg.id, clarification, override)
|
||||
}
|
||||
/>
|
||||
{showClarifications && (
|
||||
<div className="space-y-2">
|
||||
{clarifications.map((clar, index) => {
|
||||
const key = clarificationKey(msg.id, clar.id, index);
|
||||
const selection =
|
||||
intentClarificationSelections.get(key) ?? {
|
||||
selectedIds: [],
|
||||
custom: "",
|
||||
};
|
||||
return (
|
||||
<IntentClarificationCard
|
||||
key={key}
|
||||
payload={clar}
|
||||
fallbackContent={clar.question || parts.before || undefined}
|
||||
selection={selection}
|
||||
// 单问题:沿用原交互(单选点击即发送 / 多选 / 自定义走卡片自己的按钮)。
|
||||
// 多问题:才 defer —— 逐题只记录选择,由下方一个合并按钮统一发送。
|
||||
deferSubmit={multiQuestion}
|
||||
onSelectionChange={(next) => {
|
||||
setIntentClarificationSelections((prev) => {
|
||||
const map = new Map(prev);
|
||||
map.set(key, next);
|
||||
return map;
|
||||
});
|
||||
}}
|
||||
disabled={!clarificationsInteractive}
|
||||
submitting={isIntentSending && isPending}
|
||||
onSubmit={(override) =>
|
||||
submitIntentClarifications(
|
||||
msg.id,
|
||||
clarifications,
|
||||
// 单题单选「点击即发送」时选择还没进 state map,把即时值
|
||||
// 按 key 传进去直接用。
|
||||
override ? { [key]: override } : undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* 合并提交按钮仅多问题时出现;单问题点选项即发送,无需此按钮。 */}
|
||||
{multiQuestion && clarificationsInteractive && (
|
||||
<div className="flex items-center justify-between gap-2 pt-0.5">
|
||||
<span className="text-[10px] text-slate-400">
|
||||
共 {clarifications.length} 个问题,可逐个作答后一并发送
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isIntentSending}
|
||||
onClick={() => submitIntentClarifications(msg.id, clarifications)}
|
||||
className="h-8 rounded-xl px-4 text-xs font-bold"
|
||||
>
|
||||
{isIntentSending ? "发送中…" : "发送全部回答"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,26 +1,42 @@
|
||||
/**
|
||||
* 圆桌规划草稿持久化 hook。
|
||||
* 圆桌规划草稿持久化 hook(后端版)。
|
||||
*
|
||||
* 维护 localStorage 草稿列表与 currentDraftId、自动保存(前进切换 1→2、2→3)、
|
||||
* 手动保存、加载 / 重命名 / 删除。Step 1 / Step 2 的状态由各自的 hook 提供
|
||||
* 快照函数与 hydrateFromDraft;本 hook 只负责编排「什么时机存 / 加载到哪里」。
|
||||
* 2026-06 改造:草稿从浏览器 localStorage 迁移到后端 MySQL(`/api/roundtable-drafts`),
|
||||
* 按登录用户隔离、跨设备/会话保留。本 hook 仍只负责编排「什么时机存 / 加载到哪里」,
|
||||
* Step 1 / Step 2 的状态由各自的 hook 提供快照函数与 hydrate。
|
||||
*
|
||||
* 对外返回的接口(`drafts` 列表项含 `name` 等字段、各 handler 签名)与旧版保持一致,
|
||||
* 主页面无需改动。
|
||||
*
|
||||
* 同时修复了旧 localStorage 版的几个存储 bug:
|
||||
* 1. **自动保存会覆盖手动重命名** —— 现在 create 时写一次派生标题,之后所有 update
|
||||
* 一律不带 title;标题只由显式「重命名」改写。
|
||||
* 2. **快速连续前进(1→2→3)产生重复草稿** —— 所有保存经一条串行队列,create 完成
|
||||
* 即写入 `currentDraftIdRef`,后续保存读到 id 走 update 而非再 create。
|
||||
* 3. **加载读到 state 里的旧列表快照** —— load 走 `getDraft(id)` 实时拉服务端最新全量。
|
||||
* 4. localStorage 配额导致的静默写失败 / 数据丢失 —— 改后端后不复存在。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type {
|
||||
Step1Snapshot,
|
||||
} from "@/roundtable-planning/hooks/useStep1Intent";
|
||||
import type {
|
||||
Step2Snapshot,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import type { Step1Snapshot } from "@/roundtable-planning/hooks/useStep1Intent";
|
||||
import type { Step2Snapshot } from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import {
|
||||
createDraft,
|
||||
deleteDraft,
|
||||
deriveDraftName,
|
||||
getDraft,
|
||||
listDrafts,
|
||||
renameDraft,
|
||||
upsertDraft,
|
||||
type DraftRecord,
|
||||
} from "@/roundtable-planning/utils/drafts";
|
||||
updateDraft,
|
||||
} from "@/roundtable-planning/api/drafts";
|
||||
|
||||
/** 历史下拉渲染用的轻量列表项(`name` 映射自后端 `title`)。 */
|
||||
export interface DraftListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
furthestStep: 1 | 2 | 3;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UseDraftPersistenceOptions {
|
||||
currentStep: number;
|
||||
@@ -44,16 +60,12 @@ export interface UseDraftPersistenceOptions {
|
||||
}
|
||||
|
||||
export interface UseDraftPersistenceReturn {
|
||||
drafts: DraftRecord[];
|
||||
drafts: DraftListItem[];
|
||||
currentDraftId: string | null;
|
||||
isHistoryOpen: boolean;
|
||||
renamingDraftId: string | null;
|
||||
renameDraftValue: string;
|
||||
historyDropdownRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
/**
|
||||
* Shared latch: 加载草稿后置 true,影响 LeftSidebar 在 Step 2 时角色面板的
|
||||
* 初始 status(在线 vs 正在上线)。由主页面在加载完后 read,使用完可手动复位。
|
||||
*/
|
||||
loadedFromDraftRef: React.MutableRefObject<boolean>;
|
||||
setIsHistoryOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
@@ -61,9 +73,14 @@ export interface UseDraftPersistenceReturn {
|
||||
handleManualSave: () => void;
|
||||
handleLoadDraft: (id: string) => void;
|
||||
handleDeleteDraft: (id: string) => void;
|
||||
handleStartRename: (draft: DraftRecord) => void;
|
||||
handleStartRename: (draft: DraftListItem) => void;
|
||||
handleCommitRename: (id: string) => void;
|
||||
handleCancelRename: () => void;
|
||||
/**
|
||||
* 幂等地保证后端存在一条对应当前会话的草稿,返回其 id(无可保存内容时返回 null)。
|
||||
* 推荐弹窗打开前调用,确保推荐历史有 draft_id 可归属。
|
||||
*/
|
||||
ensureDraft: (furthestStep?: 1 | 2 | 3) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export function useDraftPersistence(
|
||||
@@ -81,8 +98,8 @@ export function useDraftPersistence(
|
||||
setStep2InitGate,
|
||||
} = options;
|
||||
|
||||
const [drafts, setDrafts] = useState<DraftRecord[]>(() => listDrafts());
|
||||
const [currentDraftId, setCurrentDraftId] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<DraftListItem[]>([]);
|
||||
const [currentDraftId, setCurrentDraftIdState] = useState<string | null>(null);
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState<boolean>(false);
|
||||
const [renamingDraftId, setRenamingDraftId] = useState<string | null>(null);
|
||||
const [renameDraftValue, setRenameDraftValue] = useState<string>("");
|
||||
@@ -91,95 +108,164 @@ export function useDraftPersistence(
|
||||
const prevStepRef = useRef<number>(currentStep);
|
||||
const historyDropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const buildDraftPayload = useCallback(
|
||||
(
|
||||
furthestStep: 1 | 2 | 3,
|
||||
idOverride?: string | null,
|
||||
): Parameters<typeof upsertDraft>[0] => {
|
||||
const step1 = getStep1Snapshot();
|
||||
const step2 = getStep2Snapshot();
|
||||
const resolvedId = idOverride ?? currentDraftId;
|
||||
return {
|
||||
...(resolvedId ? { id: resolvedId } : {}),
|
||||
name: deriveDraftName({
|
||||
intentReady: step1.intentReady,
|
||||
interactiveMessages: step1.interactiveMessages,
|
||||
}),
|
||||
furthestStep,
|
||||
step1,
|
||||
step2,
|
||||
};
|
||||
// 同步镜像,供保存队列在 render 之前读到最新 id(修复重复建草稿竞态)。
|
||||
const currentDraftIdRef = useRef<string | null>(null);
|
||||
// 串行化所有写操作,避免并发 create。
|
||||
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
|
||||
// 包一层 setter:同时更新 state 与同步 ref。主页面通过它 setCurrentDraftId(null)。
|
||||
const setCurrentDraftId = useCallback<React.Dispatch<React.SetStateAction<string | null>>>(
|
||||
(action) => {
|
||||
setCurrentDraftIdState((prev) => {
|
||||
const next = typeof action === "function" ? (action as (p: string | null) => string | null)(prev) : action;
|
||||
currentDraftIdRef.current = next;
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[currentDraftId, getStep1Snapshot, getStep2Snapshot],
|
||||
[],
|
||||
);
|
||||
|
||||
const saveDraft = useCallback(
|
||||
(furthestStep: 1 | 2 | 3): DraftRecord | null => {
|
||||
const step1 = getStep1Snapshot();
|
||||
const step2 = getStep2Snapshot();
|
||||
const hasUserMessage = step1.interactiveMessages.some(
|
||||
(m) => m.sender === "user",
|
||||
const refreshList = useCallback(async () => {
|
||||
try {
|
||||
const metas = await listDrafts();
|
||||
setDrafts(
|
||||
metas.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.title,
|
||||
furthestStep: m.furthestStep,
|
||||
createdAt: m.createdAt,
|
||||
updatedAt: m.updatedAt,
|
||||
})),
|
||||
);
|
||||
if (!hasUserMessage && step2.step2RoundtableDialogues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const saved = upsertDraft(buildDraftPayload(furthestStep));
|
||||
setCurrentDraftId(saved.id);
|
||||
setDrafts(listDrafts());
|
||||
return saved;
|
||||
} catch (err) {
|
||||
console.warn("[drafts] 刷新草稿列表失败:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 首次挂载拉一次草稿列表。
|
||||
useEffect(() => {
|
||||
void refreshList();
|
||||
}, [refreshList]);
|
||||
|
||||
/** 当前会话是否有值得保存的内容。 */
|
||||
const hasSavableContent = useCallback((step1: Step1Snapshot, step2: Step2Snapshot): boolean => {
|
||||
const hasUserMessage = step1.interactiveMessages.some((m) => m.sender === "user");
|
||||
return hasUserMessage || step2.step2RoundtableDialogues.length > 0;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 串行执行一次保存:有 id 走 update(不带 title,保护重命名);无 id 走 create
|
||||
* (写一次派生标题)。create/update 成功后同步 currentDraftIdRef。
|
||||
* 返回 draft id(无可保存内容返回 null)。
|
||||
*/
|
||||
const enqueueSave = useCallback(
|
||||
(furthestStep: 1 | 2 | 3): Promise<string | null> => {
|
||||
const run = async (): Promise<string | null> => {
|
||||
const step1 = getStep1Snapshot();
|
||||
const step2 = getStep2Snapshot();
|
||||
if (!hasSavableContent(step1, step2)) return null;
|
||||
|
||||
const id = currentDraftIdRef.current;
|
||||
if (id) {
|
||||
const updated = await updateDraft(id, { furthestStep, step1, step2 });
|
||||
if (updated) return updated.id;
|
||||
// 服务端已不存在(被删/换账号)→ 落回新建。
|
||||
}
|
||||
const created = await createDraft({
|
||||
title: deriveDraftName({
|
||||
intentReady: step1.intentReady,
|
||||
interactiveMessages: step1.interactiveMessages,
|
||||
}),
|
||||
furthestStep,
|
||||
step1,
|
||||
step2,
|
||||
});
|
||||
currentDraftIdRef.current = created.id;
|
||||
setCurrentDraftId(created.id);
|
||||
return created.id;
|
||||
};
|
||||
const next = saveQueueRef.current.then(run, run);
|
||||
saveQueueRef.current = next.catch(() => undefined);
|
||||
return next as Promise<string | null>;
|
||||
},
|
||||
[buildDraftPayload, getStep1Snapshot, getStep2Snapshot],
|
||||
[getStep1Snapshot, getStep2Snapshot, hasSavableContent, setCurrentDraftId],
|
||||
);
|
||||
|
||||
const ensureDraft = useCallback(
|
||||
async (furthestStep: 1 | 2 | 3 = (currentStep as 1 | 2 | 3) || 1): Promise<string | null> => {
|
||||
if (currentDraftIdRef.current) return currentDraftIdRef.current;
|
||||
const id = await enqueueSave(furthestStep);
|
||||
if (id) await refreshList();
|
||||
return id;
|
||||
},
|
||||
[currentStep, enqueueSave, refreshList],
|
||||
);
|
||||
|
||||
const handleManualSave = useCallback(() => {
|
||||
const furthest = (currentStep as 1 | 2 | 3) ?? 1;
|
||||
const saved = saveDraft(furthest);
|
||||
if (saved) {
|
||||
triggerToast(`💾 已保存为草稿:${saved.name}`);
|
||||
} else {
|
||||
triggerToast("当前会话还没有可保存的内容");
|
||||
}
|
||||
}, [currentStep, saveDraft, triggerToast]);
|
||||
const furthest = (currentStep as 1 | 2 | 3) || 1;
|
||||
void (async () => {
|
||||
const id = await enqueueSave(furthest);
|
||||
if (id) {
|
||||
await refreshList();
|
||||
triggerToast("💾 已保存为草稿");
|
||||
} else {
|
||||
triggerToast("当前会话还没有可保存的内容");
|
||||
}
|
||||
})();
|
||||
}, [currentStep, enqueueSave, refreshList, triggerToast]);
|
||||
|
||||
// 前进切换时自动保存;后退不保存以避免覆盖 furthestStep。
|
||||
useEffect(() => {
|
||||
const prev = prevStepRef.current;
|
||||
prevStepRef.current = currentStep;
|
||||
if (currentStep > prev) {
|
||||
saveDraft(currentStep as 1 | 2 | 3);
|
||||
void (async () => {
|
||||
const id = await enqueueSave(currentStep as 1 | 2 | 3);
|
||||
if (id) await refreshList();
|
||||
})();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentStep]);
|
||||
|
||||
const handleLoadDraft = useCallback(
|
||||
(id: string) => {
|
||||
const draft = drafts.find((d) => d.id === id) ?? null;
|
||||
if (!draft) {
|
||||
triggerToast("⚠️ 该草稿已不存在");
|
||||
setDrafts(listDrafts());
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
let draft;
|
||||
try {
|
||||
draft = await getDraft(id);
|
||||
} catch (err) {
|
||||
console.warn("[drafts] 加载草稿失败:", err);
|
||||
triggerToast("⚠️ 加载草稿失败,请重试");
|
||||
return;
|
||||
}
|
||||
if (!draft) {
|
||||
triggerToast("⚠️ 该草稿已不存在");
|
||||
await refreshList();
|
||||
return;
|
||||
}
|
||||
|
||||
cancelAllStreams();
|
||||
// 先置 latch,再 setCurrentStep —— 顺序反过来会让 step1/step2 init effect
|
||||
// 把 loadedFromDraftRef.current 当成 false 处理,导致角色面板卡在「正在上线」。
|
||||
loadedFromDraftRef.current = true;
|
||||
cancelAllStreams();
|
||||
// 先置 latch,再 setCurrentStep —— 顺序反过来会让 step1/step2 init effect
|
||||
// 把 loadedFromDraftRef.current 当成 false 处理,导致角色面板卡在「正在上线」。
|
||||
loadedFromDraftRef.current = true;
|
||||
|
||||
hydrateStep1(draft.step1 as Step1Snapshot);
|
||||
hydrateStep2(draft.step2 as Step2Snapshot);
|
||||
setStep2InitGate(draft.furthestStep >= 2);
|
||||
if (draft.step1) hydrateStep1(draft.step1 as Step1Snapshot);
|
||||
if (draft.step2) hydrateStep2(draft.step2 as Step2Snapshot);
|
||||
setStep2InitGate(draft.furthestStep >= 2);
|
||||
|
||||
setCurrentDraftId(draft.id);
|
||||
setIsHistoryOpen(false);
|
||||
prevStepRef.current = draft.furthestStep;
|
||||
setCurrentStep(draft.furthestStep);
|
||||
triggerToast(`📂 已加载草稿:${draft.name}`);
|
||||
setCurrentDraftId(draft.id);
|
||||
setIsHistoryOpen(false);
|
||||
prevStepRef.current = draft.furthestStep;
|
||||
setCurrentStep(draft.furthestStep);
|
||||
triggerToast(`📂 已加载草稿:${draft.title}`);
|
||||
})();
|
||||
},
|
||||
[
|
||||
cancelAllStreams,
|
||||
drafts,
|
||||
hydrateStep1,
|
||||
hydrateStep2,
|
||||
refreshList,
|
||||
setCurrentDraftId,
|
||||
setCurrentStep,
|
||||
setStep2InitGate,
|
||||
triggerToast,
|
||||
@@ -191,31 +277,54 @@ export function useDraftPersistence(
|
||||
if (typeof window !== "undefined" && !window.confirm("确定要删除这条历史记录吗?此操作不可恢复。")) {
|
||||
return;
|
||||
}
|
||||
deleteDraft(id);
|
||||
setDrafts(listDrafts());
|
||||
if (currentDraftId === id) setCurrentDraftId(null);
|
||||
if (renamingDraftId === id) setRenamingDraftId(null);
|
||||
triggerToast("🗑 已删除历史记录");
|
||||
void (async () => {
|
||||
try {
|
||||
await deleteDraft(id);
|
||||
} catch (err) {
|
||||
console.warn("[drafts] 删除草稿失败:", err);
|
||||
triggerToast("⚠️ 删除失败,请重试");
|
||||
return;
|
||||
}
|
||||
await refreshList();
|
||||
if (currentDraftIdRef.current === id) setCurrentDraftId(null);
|
||||
if (renamingDraftId === id) setRenamingDraftId(null);
|
||||
triggerToast("🗑 已删除历史记录");
|
||||
})();
|
||||
},
|
||||
[currentDraftId, renamingDraftId, triggerToast],
|
||||
[refreshList, renamingDraftId, setCurrentDraftId, triggerToast],
|
||||
);
|
||||
|
||||
const handleStartRename = useCallback((draft: DraftRecord) => {
|
||||
const handleStartRename = useCallback((draft: DraftListItem) => {
|
||||
setRenamingDraftId(draft.id);
|
||||
setRenameDraftValue(draft.name);
|
||||
}, []);
|
||||
|
||||
const handleCommitRename = useCallback(
|
||||
(id: string) => {
|
||||
const updated = renameDraft(id, renameDraftValue);
|
||||
if (updated) {
|
||||
setDrafts(listDrafts());
|
||||
triggerToast("✏️ 已重命名");
|
||||
const trimmed = renameDraftValue.trim();
|
||||
if (!trimmed) {
|
||||
setRenamingDraftId(null);
|
||||
setRenameDraftValue("");
|
||||
return;
|
||||
}
|
||||
setRenamingDraftId(null);
|
||||
setRenameDraftValue("");
|
||||
void (async () => {
|
||||
try {
|
||||
// 只发 title —— 与自动保存的「不带 title」互补,重命名是唯一改标题的入口。
|
||||
const updated = await updateDraft(id, { title: trimmed });
|
||||
if (updated) {
|
||||
await refreshList();
|
||||
triggerToast("✏️ 已重命名");
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[drafts] 重命名失败:", err);
|
||||
triggerToast("⚠️ 重命名失败,请重试");
|
||||
} finally {
|
||||
setRenamingDraftId(null);
|
||||
setRenameDraftValue("");
|
||||
}
|
||||
})();
|
||||
},
|
||||
[renameDraftValue, triggerToast],
|
||||
[refreshList, renameDraftValue, triggerToast],
|
||||
);
|
||||
|
||||
const handleCancelRename = useCallback(() => {
|
||||
@@ -253,5 +362,6 @@ export function useDraftPersistence(
|
||||
handleStartRename,
|
||||
handleCommitRename,
|
||||
handleCancelRename,
|
||||
ensureDraft,
|
||||
};
|
||||
}
|
||||
@@ -22,7 +22,11 @@ import {
|
||||
type IntentStreamPhase,
|
||||
type IntentSummary,
|
||||
} from "@/roundtable-planning/api/intent";
|
||||
import { formatIntentClarificationAnswer, type IntentClarificationSelection } from "@/roundtable-planning/components/IntentClarificationCard";
|
||||
import {
|
||||
clarificationKey,
|
||||
formatIntentClarificationAnswer,
|
||||
type IntentClarificationSelection,
|
||||
} from "@/roundtable-planning/components/IntentClarificationCard";
|
||||
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
|
||||
import {
|
||||
nowTime,
|
||||
@@ -38,7 +42,15 @@ export type InteractiveMessage = {
|
||||
time: string;
|
||||
streaming?: boolean;
|
||||
phase?: string;
|
||||
/** First clarification (back-compat); prefer `clarifications`. */
|
||||
clarification?: ClarificationPayload;
|
||||
/**
|
||||
* All `ask_clarification` questions the agent asked this turn (the agent may
|
||||
* ask several at once). Populated live from the stream so each question card
|
||||
* appears in real-time. Each carries a stable `id` (tool_call_id) for keying
|
||||
* per-question selections.
|
||||
*/
|
||||
clarifications?: ClarificationPayload[];
|
||||
/**
|
||||
* ChainOfThought 折叠卡的步骤累积。每次 `onPhaseChange` 都会把当前 phase + toolName
|
||||
* 拼成 `phase:toolName` 作为 key,与 Step 2 的 `Step2Dialogue.steps` 同形。
|
||||
@@ -86,23 +98,30 @@ export interface UseStep1IntentReturn {
|
||||
intentInitErrorDetail: IntentErrorDetail | null;
|
||||
isIntentSending: boolean;
|
||||
pendingIntentClarificationMsgId: number | null;
|
||||
intentClarificationSelections: Map<number, IntentClarificationSelection>;
|
||||
/** Per-question selections, keyed by `clarificationKey(msgId, clarId, index)`. */
|
||||
intentClarificationSelections: Map<string, IntentClarificationSelection>;
|
||||
interactiveMessages: InteractiveMessage[];
|
||||
timelineEntries: TimelineEntry[];
|
||||
// ─── Mutators ────────────────────────────────────────────────────────
|
||||
setIntentClarificationSelections: React.Dispatch<
|
||||
React.SetStateAction<Map<number, IntentClarificationSelection>>
|
||||
React.SetStateAction<Map<string, IntentClarificationSelection>>
|
||||
>;
|
||||
// ─── Handlers ────────────────────────────────────────────────────────
|
||||
/** 清空当前 Step 1 状态并重新拉取 intent thread。 */
|
||||
runStep1Init: () => void;
|
||||
/** 把 `userText` 作为用户气泡发给 intent agent,流式渲染回复。 */
|
||||
sendIntentMessage: (userText: string) => Promise<void>;
|
||||
/** 提交澄清卡片的选项 / 自定义回答。 */
|
||||
submitIntentClarification: (
|
||||
/**
|
||||
* 提交某条 agent 消息的全部澄清问题答案 —— 逐题取各自选择,拼成一条合并消息
|
||||
* 发回。至少答一题才会发送。单题时退化为发送该题答案。
|
||||
*
|
||||
* `overrideByKey`:单题「点击即发送」用 —— 单选卡点击选项时选择还没写进 state
|
||||
* map(setState 异步),把该题的即时选择按 `clarificationKey` 传进来直接用。
|
||||
*/
|
||||
submitIntentClarifications: (
|
||||
msgId: number,
|
||||
payload: ClarificationPayload,
|
||||
overrideSelection?: IntentClarificationSelection,
|
||||
clarifications: ClarificationPayload[],
|
||||
overrideByKey?: Record<string, IntentClarificationSelection>,
|
||||
) => void;
|
||||
/** Abort 当前 intent 流。气泡尾部追加「(已暂停)」并 toast。 */
|
||||
stopIntentStream: () => void;
|
||||
@@ -143,7 +162,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] =
|
||||
useState<number | null>(null);
|
||||
const [intentClarificationSelections, setIntentClarificationSelections] = useState<
|
||||
Map<number, IntentClarificationSelection>
|
||||
Map<string, IntentClarificationSelection>
|
||||
>(new Map());
|
||||
|
||||
// 原始页面带了一段写死的 demo 对话,只是首次进入 Step 1 还没初始化前的占位;
|
||||
@@ -355,10 +374,53 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
}),
|
||||
);
|
||||
},
|
||||
onClarification: (clarification, index) => {
|
||||
// 实时把每条澄清问题灌进气泡 —— 用户能边流边看到「协助过程」逐个出现,
|
||||
// 而不是等终态帧(终态帧只带第 1 个问题)。同时往步骤链补一条
|
||||
// 「追问 N:<问题>」,与「强化步骤链」诉求对齐。
|
||||
const clarId = clarification.id ?? `idx-${index}`;
|
||||
const stepKey = `clarification:${clarId}`;
|
||||
const qLabel = clarification.question
|
||||
? `追问 ${index + 1}:${
|
||||
clarification.question.length > 24
|
||||
? clarification.question.slice(0, 24) + "…"
|
||||
: clarification.question
|
||||
}`
|
||||
: `追问 ${index + 1}`;
|
||||
setInteractiveMessages((prev) =>
|
||||
prev.map((m) => {
|
||||
if (m.id !== agentMsgId) return m;
|
||||
const list = m.clarifications ?? [];
|
||||
// 按 id 去重 / 覆盖,流式重发同一条时不重复 push。
|
||||
const existingIdx = list.findIndex(
|
||||
(c) => (c.id ?? "") === (clarification.id ?? ""),
|
||||
);
|
||||
const nextList =
|
||||
existingIdx >= 0 && clarification.id
|
||||
? list.map((c, i) => (i === existingIdx ? clarification : c))
|
||||
: [...list, clarification];
|
||||
const steps = m.steps ?? [];
|
||||
const hasStep = steps.some((s) => s.key === stepKey);
|
||||
const nextSteps = hasStep
|
||||
? steps
|
||||
: [
|
||||
...steps,
|
||||
{
|
||||
id: `${agentMsgId}-clar-${clarId}`,
|
||||
key: stepKey,
|
||||
label: qLabel,
|
||||
ts: nowTime(),
|
||||
},
|
||||
];
|
||||
return { ...m, clarifications: nextList, steps: nextSteps };
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const parsedFromContent = splitIntentReadyBlock(res.content || "").summary;
|
||||
const resolvedSummary = res.summary ?? parsedFromContent;
|
||||
|
||||
const finalClarifications = resolvedSummary ? [] : res.clarifications;
|
||||
setInteractiveMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === agentMsgId
|
||||
@@ -366,9 +428,13 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
...m,
|
||||
content: res.content || m.content || "(无文本输出)",
|
||||
streaming: false,
|
||||
// 终态以 res.clarifications 为权威集合(覆盖流式期增量构建的),
|
||||
// 既保证多问题完整,也在解析出 summary 时清空澄清卡。
|
||||
clarification: resolvedSummary
|
||||
? undefined
|
||||
: res.clarification ?? undefined,
|
||||
clarifications:
|
||||
finalClarifications.length > 0 ? finalClarifications : undefined,
|
||||
}
|
||||
: m,
|
||||
),
|
||||
@@ -390,11 +456,14 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
},
|
||||
]);
|
||||
triggerToast("✅ 任务理解完成,可进入多智能体圆桌研讨");
|
||||
} else if (res.status === "clarification" && res.clarification) {
|
||||
} else if (res.status === "clarification" && res.clarifications.length > 0) {
|
||||
setPendingIntentClarificationMsgId(agentMsgId);
|
||||
setIntentClarificationSelections((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentMsgId, { selectedIds: [], custom: "" });
|
||||
res.clarifications.forEach((c, i) => {
|
||||
const key = clarificationKey(agentMsgId, c.id, i);
|
||||
if (!next.has(key)) next.set(key, { selectedIds: [], custom: "" });
|
||||
});
|
||||
return next;
|
||||
});
|
||||
} else if (res.status === "error") {
|
||||
@@ -448,24 +517,37 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
[intentThreadId, triggerToast],
|
||||
);
|
||||
|
||||
const submitIntentClarification = useCallback(
|
||||
const submitIntentClarifications = useCallback(
|
||||
(
|
||||
msgId: number,
|
||||
payload: ClarificationPayload,
|
||||
overrideSelection?: IntentClarificationSelection,
|
||||
clarifications: ClarificationPayload[],
|
||||
overrideByKey?: Record<string, IntentClarificationSelection>,
|
||||
) => {
|
||||
const selection =
|
||||
overrideSelection ??
|
||||
intentClarificationSelections.get(msgId) ?? {
|
||||
selectedIds: [],
|
||||
custom: "",
|
||||
};
|
||||
const answer = formatIntentClarificationAnswer(payload, selection);
|
||||
if (!answer) {
|
||||
triggerToast("请先选择选项或输入回答");
|
||||
// 逐题取选择并格式化;只把答了的题拼进去。
|
||||
const answered = clarifications
|
||||
.map((payload, index) => {
|
||||
const key = clarificationKey(msgId, payload.id, index);
|
||||
const selection =
|
||||
overrideByKey?.[key] ??
|
||||
intentClarificationSelections.get(key) ?? { selectedIds: [], custom: "" };
|
||||
const answer = formatIntentClarificationAnswer(payload, selection);
|
||||
return answer ? { question: payload.question, answer } : null;
|
||||
})
|
||||
.filter((x): x is { question: string; answer: string } => x !== null);
|
||||
|
||||
if (answered.length === 0) {
|
||||
triggerToast("请至少回答一个问题");
|
||||
return;
|
||||
}
|
||||
void sendIntentMessage(answer);
|
||||
|
||||
// 单题:直接发答案(与旧行为一致)。多题:带上问题做编号 Q/A,合并成一条消息。
|
||||
const combined =
|
||||
answered.length === 1
|
||||
? answered[0].answer
|
||||
: answered
|
||||
.map((qa, i) => `${i + 1}. ${qa.question}\n答:${qa.answer}`)
|
||||
.join("\n\n");
|
||||
void sendIntentMessage(combined);
|
||||
},
|
||||
[intentClarificationSelections, sendIntentMessage, triggerToast],
|
||||
);
|
||||
@@ -499,7 +581,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
setIntentClarificationSelections,
|
||||
runStep1Init,
|
||||
sendIntentMessage,
|
||||
submitIntentClarification,
|
||||
submitIntentClarifications,
|
||||
stopIntentStream,
|
||||
hydrateFromDraft,
|
||||
appendLocalMessage,
|
||||
|
||||
@@ -84,6 +84,13 @@ export type AgentConfig = {
|
||||
|
||||
/** 草稿持久化的 Step 2 快照。 */
|
||||
export type Step2Snapshot = {
|
||||
/**
|
||||
* 本次研讨实际选中的席位 —— 加载草稿后据此重建左侧「参与角色」面板。
|
||||
* 不存的话(2026-06 之前的旧草稿)hydrate 会回退到 DEFAULT_SELECTED_AGENTS,
|
||||
* 导致 Step 2/3 左侧显示默认 6 个而非当时真正选的那几个。反序列化时
|
||||
* 老草稿按 undefined 兜底(保留当前 selectedAgents,不强行覆盖)。
|
||||
*/
|
||||
selectedAgents?: SelectedAgent[];
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
step2RoundtableDialogues: Step2Dialogue[];
|
||||
@@ -1420,6 +1427,12 @@ export function useStep2Orchestration(
|
||||
setPendingClarification(null);
|
||||
setClarificationDraft("");
|
||||
|
||||
// 先恢复 selectedAgents —— roleTemplates / agentNameToRole 都由它派生,
|
||||
// 主页面的「参与角色」面板据此重建,否则会停留在默认 6 个席位。
|
||||
// 老草稿没有这字段时保持当前值不动(避免清空成空数组)。
|
||||
if (snapshot.selectedAgents && snapshot.selectedAgents.length > 0) {
|
||||
setSelectedAgents(snapshot.selectedAgents);
|
||||
}
|
||||
setThreadIds(snapshot.threadIds);
|
||||
setCoordinatorName(snapshot.coordinatorName);
|
||||
setStep2RoundtableDialogues(snapshot.step2RoundtableDialogues);
|
||||
|
||||
@@ -63,8 +63,6 @@ import { useStep2Orchestration } from "@/roundtable-planning/hooks/useStep2Orche
|
||||
import { useDraftPersistence } from "@/roundtable-planning/hooks/useDraftPersistence";
|
||||
import {
|
||||
BUILTIN_AGENT_META,
|
||||
getAvatarTypeFor,
|
||||
getDisplayNameFor,
|
||||
makeRolesFromTemplates,
|
||||
nowTime,
|
||||
type RoleEntry,
|
||||
@@ -187,6 +185,7 @@ export default function RoundtablePlanningPage() {
|
||||
timelineEntries: step1.timelineEntries,
|
||||
}),
|
||||
getStep2Snapshot: () => ({
|
||||
selectedAgents: step2.selectedAgents,
|
||||
threadIds: step2.threadIds,
|
||||
coordinatorName: step2.coordinatorName,
|
||||
step2RoundtableDialogues: step2.step2RoundtableDialogues,
|
||||
@@ -320,6 +319,7 @@ export default function RoundtablePlanningPage() {
|
||||
open={isRecommendDialogOpen}
|
||||
intent={step1.intentReady}
|
||||
model={selectedModel || undefined}
|
||||
draftId={drafts.currentDraftId}
|
||||
onCancel={() => setIsRecommendDialogOpen(false)}
|
||||
onConfirm={handleConfirmAgents}
|
||||
/>
|
||||
@@ -437,7 +437,6 @@ export default function RoundtablePlanningPage() {
|
||||
currentStep={currentStep}
|
||||
roles={roles}
|
||||
agentNameToRole={step2.agentNameToRole}
|
||||
selectedAgents={step2.selectedAgents}
|
||||
budgetLimit={step2.budgetLimit}
|
||||
intentReady={step1.intentReady}
|
||||
sidebarExpanded={step2SidebarExpanded}
|
||||
@@ -467,11 +466,13 @@ export default function RoundtablePlanningPage() {
|
||||
triggerToast("⚠️ 请先与任务理解智能体完成澄清,生成任务摘要后再进入");
|
||||
return;
|
||||
}
|
||||
setIsRecommendDialogOpen(true);
|
||||
// 先把草稿落地拿到 draft_id,推荐历史才有归属;失败也照常打开弹窗
|
||||
// (弹窗在无 draftId 时退化为「直接推荐、不存历史」)。
|
||||
void drafts.ensureDraft(1).finally(() => setIsRecommendDialogOpen(true));
|
||||
}}
|
||||
onManualSave={drafts.handleManualSave}
|
||||
onTriggerToast={triggerToast}
|
||||
submitIntentClarification={step1.submitIntentClarification}
|
||||
submitIntentClarifications={step1.submitIntentClarifications}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -811,7 +812,6 @@ interface LeftSidebarProps {
|
||||
string,
|
||||
{ speakerId: number; avatarType: string; displayName: string }
|
||||
>;
|
||||
selectedAgents: SelectedAgent[];
|
||||
budgetLimit: number;
|
||||
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
|
||||
sidebarExpanded: boolean;
|
||||
@@ -848,7 +848,6 @@ function LeftSidebar({
|
||||
currentStep,
|
||||
roles,
|
||||
agentNameToRole,
|
||||
selectedAgents,
|
||||
budgetLimit: _budgetLimit,
|
||||
intentReady,
|
||||
sidebarExpanded,
|
||||
@@ -856,6 +855,10 @@ function LeftSidebar({
|
||||
}: LeftSidebarProps) {
|
||||
const isStep2 = currentStep === 2;
|
||||
|
||||
// Step 3 隐藏左侧栏(原「参与与结论贡献」列),让中间「最终方案展示」自适应
|
||||
// 撑满整行宽度(右侧栏在 Step !==1 时也已 return null)。
|
||||
if (currentStep === 3) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
id="left-sidebar"
|
||||
@@ -876,55 +879,7 @@ function LeftSidebar({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{currentStep === 3 ? (
|
||||
<>
|
||||
{/* Step 3 时只保留「参与与结论贡献」面板;原文件里其它两个 hidden 块未启用,
|
||||
删掉以降低维护负担,需要时去 git history 翻 fdeffde 之前的 commit。 */}
|
||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.015)] flex-1 flex flex-col min-h-[300px]">
|
||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
|
||||
<Users className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||
<h2 className="text-xs font-black text-slate-900 uppercase tracking-widest">参与与结论贡献</h2>
|
||||
</div>
|
||||
<div className="space-y-2 overflow-y-auto flex-1 pr-1 custom-scrollbar">
|
||||
{[
|
||||
{
|
||||
id: "human",
|
||||
name: "用户 (指挥官)",
|
||||
role: "人类介入协调",
|
||||
icon: <Man1Avatar size="h-7 w-7" />,
|
||||
},
|
||||
...selectedAgents.map((agent, i) => ({
|
||||
id: agent.agent_id,
|
||||
name: getDisplayNameFor(agent),
|
||||
role: BUILTIN_AGENT_META[agent.agent_id]
|
||||
? `${BUILTIN_AGENT_META[agent.agent_id].displayName} 席位`
|
||||
: "圆桌席位",
|
||||
icon: getAvatar(getAvatarTypeFor(agent.agent_id, i), "h-7 w-7"),
|
||||
})),
|
||||
].map((contrib, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between p-2 rounded-xl bg-slate-50 border border-slate-100 transition hover:border-emerald-100 hover:bg-emerald-50/10"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{contrib.icon}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold text-slate-800 tracking-tight leading-tight">
|
||||
{contrib.name}
|
||||
</span>
|
||||
<span className="text-[9px] text-primary font-semibold">{contrib.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0 text-emerald-600 bg-emerald-50 font-bold text-[9.5px] px-1.5 py-0.5 rounded-full">
|
||||
<Check className="h-3 w-3 text-emerald-500 font-black" />
|
||||
已贡献
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : currentStep === 2 ? (
|
||||
{currentStep === 2 ? (
|
||||
sidebarExpanded ? (
|
||||
<>
|
||||
<IntentSummaryCard
|
||||
|
||||
@@ -21,9 +21,41 @@
|
||||
background: #fddadc;
|
||||
}
|
||||
|
||||
/* 深色模式:跟随全局 ThemeContext(html.dark)或组件 .roundtable-planning--dark */
|
||||
/* 深色模式:跟随全局 ThemeContext(html.dark)或组件 .roundtable-planning--dark
|
||||
*
|
||||
* 仅在本页 + 深色下,把语义 token 覆盖为《深蓝色 UI 规范》取值
|
||||
* (docs/ui规范-深蓝/tokens.css,§1 深蓝系 ZS-004 / 渐蓝系 ZS-002)。
|
||||
* 全模块组件均经由这些 token(bg-background / text-foreground / border-border …)
|
||||
* 着色,所以这里统一覆盖即可整页换肤;浅色模式(无 .dark)保持红色主题不受影响。 */
|
||||
.dark .roundtable-planning,
|
||||
.roundtable-planning--dark {
|
||||
/* —— 背景 / 容器(ZS-004 深蓝系) —— */
|
||||
--background: #0c1836; /* zs-004-09 页面背景 */
|
||||
--foreground: #c5cfe0; /* zs-004-02 正文字体 */
|
||||
--card: #122347; /* zs-004-08 卡片背景 */
|
||||
--card-foreground: #c5cfe0;
|
||||
--popover: #122347; /* 下拉 / 浮层与卡片同底 */
|
||||
--popover-foreground: #c5cfe0;
|
||||
--muted: #16294d; /* 次级容器(步骤条 / slate-50 表面) */
|
||||
--muted-foreground: #6e88b0; /* zs-004-04 次级字体 */
|
||||
--secondary: #1a2f5a; /* zs-004-07 次级按钮底 */
|
||||
--secondary-foreground: #c5cfe0;
|
||||
--accent: #1a2f5a; /* hover / 软高亮表面 */
|
||||
--accent-foreground: #e8edf5; /* zs-004-01 */
|
||||
|
||||
/* —— 主色 / 链接(ZS-002 渐蓝系) —— */
|
||||
--primary: #1677ff; /* zs-002-06 主操作 */
|
||||
--primary-foreground: #ffffff;
|
||||
--ring: rgba(22, 119, 255, 0.45);
|
||||
|
||||
/* —— 边框 / 控件描边(取较浅阶以保证深底上可见) —— */
|
||||
--border: #2d4473; /* zs-004-06 容器边框 */
|
||||
--input: #4a6491; /* zs-004-05 控件描边 */
|
||||
|
||||
/* —— 功能语义色 —— */
|
||||
--destructive: #f5222d; /* zs-001-06 错误 */
|
||||
|
||||
/* —— 圆桌局部别名(回落到上面已覆盖的 token) —— */
|
||||
--rt-page-bg: var(--background);
|
||||
--rt-primary: var(--primary);
|
||||
--rt-primary-hover: var(--primary);
|
||||
@@ -47,98 +79,226 @@
|
||||
background: color-mix(in oklch, var(--primary) 35%, transparent);
|
||||
}
|
||||
|
||||
/* 页面底与浅色专用背景 */
|
||||
.dark .roundtable-planning.bg-\[\#fef6f6\],
|
||||
.roundtable-planning--dark.bg-\[\#fef6f6\] {
|
||||
/* =============================================================================
|
||||
* 深色兜底映射 —— 把组件里写死的浅色 Tailwind 类映射到上面的深蓝 token。
|
||||
*
|
||||
* 这些组件原本只为浅色红主题写死了 slate / white / emerald 等色,深色靠这里
|
||||
* 统一改写。作用域 :is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
* 同时覆盖「全局 html.dark」与「组件 --dark 修饰类」两种触发方式;浅色模式
|
||||
* (无 .dark / 无 --dark)完全不命中,红主题不受影响。
|
||||
*
|
||||
* 维护:组件里新增写死颜色时,往下面对应分组补一行即可,不要在组件里写
|
||||
* `dark:` 变体与本表打架(本表用 !important,会盖过同元素的 dark: 变体)。
|
||||
* ========================================================================== */
|
||||
|
||||
/* ---- 页面底色:浅红 → 深蓝页面底 ---- */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark).bg-\[\#fef6f6\],
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark) .bg-\[\#fef6f6\] {
|
||||
background-color: var(--background) !important;
|
||||
}
|
||||
|
||||
/* 表面:白底卡片 → card */
|
||||
.dark .roundtable-planning .bg-white,
|
||||
.roundtable-planning--dark .bg-white {
|
||||
/* ---- 表面:白 / 近白底 → 卡片底 ---- */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(.bg-white, .hover\:bg-white:hover, .bg-\[\#fcfcfd\], .bg-\[\#fffdfa\]) {
|
||||
background-color: var(--card) !important;
|
||||
}
|
||||
|
||||
.dark .roundtable-planning .bg-slate-50,
|
||||
.dark .roundtable-planning .bg-slate-50\/20,
|
||||
.dark .roundtable-planning .bg-slate-50\/30,
|
||||
.roundtable-planning--dark .bg-slate-50,
|
||||
.roundtable-planning--dark .bg-slate-50\/20,
|
||||
.roundtable-planning--dark .bg-slate-50\/30 {
|
||||
background-color: color-mix(in oklch, var(--muted) 80%, transparent) !important;
|
||||
/* ---- 次级表面:slate-50/100(含透明度档)→ muted ---- */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(
|
||||
.bg-slate-50, .bg-slate-50\/20, .bg-slate-50\/30, .bg-slate-50\/50,
|
||||
.bg-slate-50\/60, .bg-slate-50\/80, .bg-slate-100, .bg-slate-100\/80
|
||||
) {
|
||||
background-color: var(--muted) !important;
|
||||
}
|
||||
|
||||
.dark .roundtable-planning .from-slate-50,
|
||||
.roundtable-planning--dark .from-slate-50 {
|
||||
/* ---- 次级表面 hover → accent ---- */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(
|
||||
.hover\:bg-slate-50:hover, .hover\:bg-slate-50\/60:hover,
|
||||
.hover\:bg-slate-100:hover
|
||||
) {
|
||||
background-color: var(--accent) !important;
|
||||
}
|
||||
|
||||
/* ---- 禁用 / 较实的中性底 → secondary ---- */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(.bg-slate-300, .disabled\:bg-slate-300:disabled) {
|
||||
background-color: var(--secondary) !important;
|
||||
}
|
||||
|
||||
/* ---- slate-200 作分隔条 → border 色 ---- */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark) .bg-slate-200 {
|
||||
background-color: var(--border) !important;
|
||||
}
|
||||
|
||||
/* ---- 渐变端点 ---- */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark) .from-slate-50 {
|
||||
--tw-gradient-from: var(--muted) !important;
|
||||
}
|
||||
|
||||
.dark .roundtable-planning .to-white,
|
||||
.roundtable-planning--dark .to-white {
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark) .to-white {
|
||||
--tw-gradient-to: var(--card) !important;
|
||||
}
|
||||
|
||||
/* 品牌浅红底 → accent */
|
||||
.dark .roundtable-planning [class*="brand-200"],
|
||||
.roundtable-planning--dark [class*="brand-200"] {
|
||||
background-color: color-mix(in oklch, var(--accent) 55%, transparent) !important;
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(.from-\[var\(--brand-200\)\]) {
|
||||
--tw-gradient-from: color-mix(in oklch, var(--accent) 60%, transparent) !important;
|
||||
}
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(.via-\[var\(--brand-200\)\]) {
|
||||
--tw-gradient-via: color-mix(in oklch, var(--accent) 60%, transparent) !important;
|
||||
}
|
||||
|
||||
.dark .roundtable-planning .border-red-100,
|
||||
.dark .roundtable-planning .border-red-100\/50,
|
||||
.dark .roundtable-planning .border-red-200,
|
||||
.dark .roundtable-planning .border-red-200\/50,
|
||||
.dark .roundtable-planning .border-red-200\/85,
|
||||
.roundtable-planning--dark .border-red-100,
|
||||
.roundtable-planning--dark .border-red-100\/50,
|
||||
.roundtable-planning--dark .border-red-200,
|
||||
.roundtable-planning--dark .border-red-200\/50,
|
||||
.roundtable-planning--dark .border-red-200\/85 {
|
||||
border-color: var(--border) !important;
|
||||
/* ---- 品牌浅红底(brand-200 等) → accent ---- */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark) [class*="brand-200"] {
|
||||
background-color: color-mix(in oklch, var(--accent) 70%, transparent) !important;
|
||||
}
|
||||
|
||||
.dark .roundtable-planning .border-slate-100,
|
||||
.dark .roundtable-planning .border-slate-200,
|
||||
.dark .roundtable-planning .border-slate-200\/50,
|
||||
.dark .roundtable-planning .border-\[\#e2e8f0\],
|
||||
.roundtable-planning--dark .border-slate-100,
|
||||
.roundtable-planning--dark .border-slate-200,
|
||||
.roundtable-planning--dark .border-slate-200\/50,
|
||||
.roundtable-planning--dark .border-\[\#e2e8f0\] {
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
/* 文字 */
|
||||
.dark .roundtable-planning .text-slate-900,
|
||||
.dark .roundtable-planning .text-slate-800,
|
||||
.dark .roundtable-planning .text-slate-700,
|
||||
.dark .roundtable-planning .text-\[\#334155\],
|
||||
.roundtable-planning--dark .text-slate-900,
|
||||
.roundtable-planning--dark .text-slate-800,
|
||||
.roundtable-planning--dark .text-slate-700,
|
||||
.roundtable-planning--dark .text-\[\#334155\] {
|
||||
/* ---- 中性文字:深 → 正文色 ---- */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(
|
||||
.text-slate-900, .text-slate-800, .text-slate-700, .text-\[\#334155\],
|
||||
.hover\:text-slate-900:hover, .hover\:text-slate-800:hover,
|
||||
.hover\:text-slate-700:hover
|
||||
) {
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
.dark .roundtable-planning .text-slate-600,
|
||||
.dark .roundtable-planning .text-slate-500,
|
||||
.dark .roundtable-planning .text-slate-400,
|
||||
.roundtable-planning--dark .text-slate-600,
|
||||
.roundtable-planning--dark .text-slate-500,
|
||||
.roundtable-planning--dark .text-slate-400 {
|
||||
/* ---- 中性文字:中 / 浅 → 次级文字色 ---- */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(
|
||||
.text-slate-650, .text-slate-600, .text-slate-500, .text-slate-400,
|
||||
.text-slate-300, .text-\[\#94a3b8\],
|
||||
.hover\:text-slate-650:hover, .hover\:text-slate-600:hover,
|
||||
.placeholder\:text-slate-400::placeholder
|
||||
) {
|
||||
color: var(--muted-foreground) !important;
|
||||
}
|
||||
|
||||
.dark .roundtable-planning .hover\:text-slate-800:hover,
|
||||
.roundtable-planning--dark .hover\:text-slate-800:hover {
|
||||
color: var(--foreground) !important;
|
||||
/* ---- 中性边框:slate / red / 白 / #e2e8f0(含透明度档)→ border ---- */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(
|
||||
.border-white, .border-slate-50, .border-slate-100, .border-slate-150,
|
||||
.border-slate-200, .border-slate-200\/50, .border-slate-200\/80,
|
||||
.border-slate-300, .border-slate-400, .border-slate-700,
|
||||
.border-\[\#e2e8f0\],
|
||||
.border-red-50, .border-red-100, .border-red-100\/50, .border-red-200,
|
||||
.border-red-200\/50, .border-red-200\/85
|
||||
) {
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
.dark .roundtable-planning .hover\:bg-slate-50:hover,
|
||||
.roundtable-planning--dark .hover\:bg-slate-50:hover {
|
||||
background-color: var(--accent) !important;
|
||||
/* ---- divide-slate-100 子项分隔线 → border ---- */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
.divide-slate-100 > :not([hidden]) ~ :not([hidden]) {
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
/* ---- focus ring:brand-200 → 主色淡环 ---- */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(
|
||||
.ring-\[var\(--brand-200\)\],
|
||||
.focus-within\:ring-\[var\(--brand-200\)\]\/50:focus-within,
|
||||
.focus\:ring-\[var\(--brand-200\)\]\/50:focus
|
||||
) {
|
||||
--tw-ring-color: color-mix(in oklch, var(--primary) 30%, transparent) !important;
|
||||
}
|
||||
|
||||
/* ---- 选中边框 border-primary → 主色描边(柔化) ---- */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark) .border-primary,
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark) .border-2.border-primary {
|
||||
border-color: color-mix(in oklch, var(--primary) 55%, var(--border)) !important;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* 功能语义色(成功/警告/错误/信息/装饰)—— 深色下:浅底改深色同色调、深字提亮。
|
||||
* 保留色相作为语义提示,只调明度以适配深蓝底。实心中等色按钮(emerald-600 等
|
||||
* 配白字)本就在深底上可读,故不改。
|
||||
* ========================================================================== */
|
||||
|
||||
/* —— 成功 · emerald —— */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(.bg-emerald-50, .bg-emerald-50\/40, .bg-emerald-50\/50, .bg-emerald-100, .bg-emerald-100\/40) {
|
||||
background-color: color-mix(in oklch, #10b981 18%, var(--card)) !important;
|
||||
}
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(.border-emerald-200, .border-emerald-200\/40, .border-emerald-200\/50) {
|
||||
border-color: color-mix(in oklch, #10b981 42%, var(--border)) !important;
|
||||
}
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(.text-emerald-800, .text-emerald-700, .text-emerald-600) {
|
||||
color: #6ee7b7 !important;
|
||||
}
|
||||
|
||||
/* —— 警告 · amber —— */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(
|
||||
.bg-amber-50, .bg-amber-50\/60, .bg-amber-100, .bg-amber-100\/40, .bg-amber-200,
|
||||
.hover\:bg-amber-100:hover, .hover\:bg-amber-200:hover
|
||||
) {
|
||||
background-color: color-mix(in oklch, #f59e0b 20%, var(--card)) !important;
|
||||
}
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(
|
||||
.border-amber-100, .border-amber-200, .border-amber-200\/40, .border-amber-300,
|
||||
.border-amber-300\/50, .border-amber-500, .border-amber-700\/50
|
||||
) {
|
||||
border-color: color-mix(in oklch, #f59e0b 42%, var(--border)) !important;
|
||||
}
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(.text-amber-900, .text-amber-800, .text-amber-700, .text-amber-600) {
|
||||
color: #fcd34d !important;
|
||||
}
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark) .focus\:ring-amber-200:focus {
|
||||
--tw-ring-color: color-mix(in oklch, #f59e0b 35%, transparent) !important;
|
||||
}
|
||||
|
||||
/* —— 错误 · red / rose —— */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(.bg-red-50, .bg-red-200, .bg-rose-50, .hover\:bg-rose-50:hover) {
|
||||
background-color: color-mix(in oklch, #ef4444 18%, var(--card)) !important;
|
||||
}
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(.text-red-700, .text-rose-600, .hover\:text-rose-600:hover) {
|
||||
color: #fca5a5 !important;
|
||||
}
|
||||
|
||||
/* —— 信息 · sky(部分组件已自带 dark: 变体,这里兜底无 dark: 的) —— */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark) .bg-sky-50 {
|
||||
background-color: color-mix(in oklch, #0ea5e9 18%, var(--card)) !important;
|
||||
}
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(.border-sky-200, .border-sky-300\/50) {
|
||||
border-color: color-mix(in oklch, #0ea5e9 42%, var(--border)) !important;
|
||||
}
|
||||
|
||||
/* —— 装饰 · purple —— */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(.bg-purple-50, .bg-purple-100\/40) {
|
||||
background-color: color-mix(in oklch, #a855f7 18%, var(--card)) !important;
|
||||
}
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark) .border-purple-200 {
|
||||
border-color: color-mix(in oklch, #a855f7 42%, var(--border)) !important;
|
||||
}
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark)
|
||||
:is(.text-purple-700, .text-purple-500) {
|
||||
color: #d8b4fe !important;
|
||||
}
|
||||
|
||||
/* —— 装饰 · orange / pink —— */
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark) .bg-orange-50 {
|
||||
background-color: color-mix(in oklch, #f97316 18%, var(--card)) !important;
|
||||
}
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark) .border-orange-200\/50 {
|
||||
border-color: color-mix(in oklch, #f97316 42%, var(--border)) !important;
|
||||
}
|
||||
:is(.dark .roundtable-planning, .roundtable-planning--dark) .bg-pink-100\/40 {
|
||||
background-color: color-mix(in oklch, #ec4899 18%, var(--card)) !important;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* 结构件覆盖(顶栏 / 步骤条 / 次要按钮)—— 保留原有针对性规则。
|
||||
* ========================================================================== */
|
||||
|
||||
/* 顶栏 */
|
||||
.dark .roundtable-planning > header,
|
||||
.roundtable-planning--dark > header {
|
||||
@@ -161,29 +321,6 @@
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
/* 输入区 focus ring */
|
||||
.dark .roundtable-planning .focus-within\:ring-\[var\(--brand-200\)\]\/50:focus-within,
|
||||
.roundtable-planning--dark .focus-within\:ring-\[var\(--brand-200\)\]\/50:focus-within {
|
||||
--tw-ring-color: color-mix(in oklch, var(--primary) 25%, transparent) !important;
|
||||
}
|
||||
|
||||
/* 渐变横幅 */
|
||||
.dark .roundtable-planning .from-\[var\(--brand-200\)\],
|
||||
.roundtable-planning--dark .from-\[var\(--brand-200\)\] {
|
||||
--tw-gradient-from: color-mix(in oklch, var(--accent) 40%, transparent) !important;
|
||||
}
|
||||
|
||||
.dark .roundtable-planning .border-primary,
|
||||
.roundtable-planning--dark .border-2.border-primary {
|
||||
border-color: color-mix(in oklch, var(--primary) 50%, var(--border)) !important;
|
||||
}
|
||||
|
||||
/* 分隔线 */
|
||||
.dark .roundtable-planning .bg-slate-200,
|
||||
.roundtable-planning--dark .bg-slate-200 {
|
||||
background-color: var(--border) !important;
|
||||
}
|
||||
|
||||
/* Custom elegant scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* Local-only draft storage for /page/roundtable/planning.
|
||||
*
|
||||
* One draft = one full roundtable session (Step 1 intent + Step 2 multi-agent
|
||||
* roundtable). Persisted in `localStorage` so refresh / browser-restart keeps
|
||||
* it; cross-device sync is out of scope (would need a backend). The Step 2
|
||||
* back-end LangGraph threads are reused on load, so list/preview/download of
|
||||
* artifacts in Step 3 continue to work as long as the user hasn't deleted the
|
||||
* threads server-side.
|
||||
*/
|
||||
|
||||
import type { IntentSummary } from "@/roundtable-planning/api/intent";
|
||||
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||
|
||||
const STORAGE_KEY = "roundtable-planning:drafts:v1";
|
||||
|
||||
/**
|
||||
* Snapshot of every piece of UI state needed to resume a session. We capture
|
||||
* shapes loosely (`unknown[]`) for the chat / role lists because their inline
|
||||
* types in the page component are wide and would force a refactor we don't
|
||||
* want as part of the persistence feature.
|
||||
*/
|
||||
export interface DraftStep1Snapshot {
|
||||
intentThreadId: string | null;
|
||||
intentReady: IntentSummary | null;
|
||||
interactiveMessages: unknown[];
|
||||
timelineEntries: unknown[];
|
||||
}
|
||||
|
||||
export interface DraftStep2Snapshot {
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
step2RoundtableDialogues: unknown[];
|
||||
lastLeaderContent: string;
|
||||
hasConsensus: boolean;
|
||||
consensusPercentage: number;
|
||||
budgetLimit: number;
|
||||
/**
|
||||
* Per-seat stub AI messages used by the sandbox `write-file:` virtual URL
|
||||
* loader. Older drafts won't have this — callers should default to `{}`.
|
||||
*/
|
||||
seatStubMessages?: Record<string, unknown[]>;
|
||||
}
|
||||
|
||||
export interface DraftRecord {
|
||||
id: string;
|
||||
/** Auto-derived task summary (objective → first user message → timestamp). */
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Furthest step the user had reached when this snapshot was taken. */
|
||||
furthestStep: 1 | 2 | 3;
|
||||
step1: DraftStep1Snapshot;
|
||||
step2: DraftStep2Snapshot;
|
||||
}
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||
}
|
||||
|
||||
function readAll(): DraftRecord[] {
|
||||
if (!isBrowser()) return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed as DraftRecord[];
|
||||
} catch (err) {
|
||||
console.warn("[drafts] failed to parse localStorage payload:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeAll(records: DraftRecord[]): void {
|
||||
if (!isBrowser()) return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(records));
|
||||
} catch (err) {
|
||||
// Quota errors are the realistic failure mode — surface but don't throw,
|
||||
// since the user can keep working with their in-memory state.
|
||||
console.warn("[drafts] failed to write localStorage payload:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function newId(): string {
|
||||
if (isBrowser() && typeof window.crypto !== "undefined" && typeof window.crypto.randomUUID === "function") {
|
||||
return window.crypto.randomUUID();
|
||||
}
|
||||
return `draft-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/** List drafts newest first. */
|
||||
export function listDrafts(): DraftRecord[] {
|
||||
return readAll()
|
||||
.slice()
|
||||
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
|
||||
}
|
||||
|
||||
export function getDraft(id: string): DraftRecord | null {
|
||||
return readAll().find((d) => d.id === id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create-or-update a draft. If `id` is given and exists, that record is
|
||||
* overwritten (updatedAt refreshed). Otherwise a new record is inserted.
|
||||
* Returns the (re-)saved record so callers can read back the new id.
|
||||
*/
|
||||
export function upsertDraft(
|
||||
payload: Omit<DraftRecord, "createdAt" | "updatedAt" | "id"> & { id?: string },
|
||||
): DraftRecord {
|
||||
const records = readAll();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (payload.id) {
|
||||
const idx = records.findIndex((d) => d.id === payload.id);
|
||||
if (idx >= 0) {
|
||||
const existing = records[idx];
|
||||
const updated: DraftRecord = {
|
||||
...existing,
|
||||
name: payload.name,
|
||||
furthestStep: payload.furthestStep,
|
||||
step1: payload.step1,
|
||||
step2: payload.step2,
|
||||
updatedAt: now,
|
||||
};
|
||||
records[idx] = updated;
|
||||
writeAll(records);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
const created: DraftRecord = {
|
||||
id: payload.id ?? newId(),
|
||||
name: payload.name,
|
||||
furthestStep: payload.furthestStep,
|
||||
step1: payload.step1,
|
||||
step2: payload.step2,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
records.push(created);
|
||||
writeAll(records);
|
||||
return created;
|
||||
}
|
||||
|
||||
export function deleteDraft(id: string): void {
|
||||
const records = readAll();
|
||||
const next = records.filter((d) => d.id !== id);
|
||||
if (next.length !== records.length) writeAll(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a draft. No-op when `id` isn't found or `name` is empty. Returns the
|
||||
* updated record so callers can refresh local UI state.
|
||||
*/
|
||||
export function renameDraft(id: string, name: string): DraftRecord | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
const records = readAll();
|
||||
const idx = records.findIndex((d) => d.id === id);
|
||||
if (idx < 0) return null;
|
||||
const updated: DraftRecord = {
|
||||
...records[idx],
|
||||
name: trimmed,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
records[idx] = updated;
|
||||
writeAll(records);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a short task-summary name from whatever the user has supplied so far.
|
||||
* Preference order: intent agent's structured objective → first user message
|
||||
* in Step 1 → ISO timestamp fallback. The result is trimmed to ~40 chars so
|
||||
* the dropdown rows stay scannable.
|
||||
*/
|
||||
export function deriveDraftName(args: {
|
||||
intentReady?: IntentSummary | null;
|
||||
interactiveMessages?: unknown[];
|
||||
}): string {
|
||||
const MAX = 40;
|
||||
const truncate = (s: string) => (s.length > MAX ? s.slice(0, MAX) + "…" : s);
|
||||
|
||||
const objective = args.intentReady?.objective?.trim();
|
||||
if (objective) return truncate(objective);
|
||||
|
||||
if (Array.isArray(args.interactiveMessages)) {
|
||||
for (const m of args.interactiveMessages) {
|
||||
if (!m || typeof m !== "object") continue;
|
||||
const obj = m as Record<string, unknown>;
|
||||
if (obj.sender === "user" && typeof obj.content === "string" && obj.content.trim()) {
|
||||
return truncate(obj.content.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `未命名草稿 ${new Date().toLocaleString("zh-CN", { hour12: false })}`;
|
||||
}
|
||||
Reference in new issue
Block a user