This commit is contained in:
ZyGuo committed 2026-06-01 17:29:30 +08:00
commit 5f88efb2c9
76 files changed
+11989 -4307

No files matched your search

+178 -41
View File
@@ -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);
}
+138 -321
View File
@@ -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>
);
}
+9 -302
View File
@@ -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";
}
+45
View File
@@ -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,
);
}
@@ -29,8 +29,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';
@@ -45,6 +52,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('');
const { models, isLoading: modelsLoading } = useModels();
@@ -111,6 +120,7 @@ export function AIWritingForm({
wordCountTarget: wordCount,
keywordCount: DEFAULT_KEYWORD_COUNT,
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
materialSource,
modelName: selectedModel?.name || modelName || undefined,
});
}
@@ -178,6 +188,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>
)}
@@ -75,6 +75,7 @@ const INITIAL_STATE: AIWritingState = {
skillSearchSteps: [],
skillSelectStreamingText: '',
skillSelectThinkingText: '',
skillSearchPending: false,
streamingReviewItems: [],
revisionSummary: null,
pendingRevisionContext: null,
@@ -105,10 +106,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: '' } : {}),
@@ -341,7 +345,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':
@@ -628,12 +634,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__ 滤掉,
@@ -728,6 +747,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,6 +228,8 @@ export interface AIWritingRequest {
keywordCount: number;
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
writingMode: 'strict' | 'loose';
/** 素材来源:knowledge_base=知识库(默认检索)/ skill=技能检索(默认 knowledge_base) */
materialSource: 'knowledge_base' | 'skill';
}
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
@@ -320,6 +323,9 @@ export interface AIWritingState {
// 技能选择 LLM 流式打字内容(正文 / 思考分开),select_skills 完成后清空
skillSelectStreamingText: string;
skillSelectThinkingText: string;
/** 技能检索是否正在进行:用户点击"技能检索"瞬间 true,进入 awaiting 时清掉。
* 用于在 LangGraph 还没推任何 skill_search_step 事件时给前端展示占位卡。 */
skillSearchPending: boolean;
// 流式审核(逐条到达)
streamingReviewItems: StreamingReviewItem[];
// 修改摘要
+74 -24
View File
@@ -1,33 +1,44 @@
"use client";
'use client';
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { loginByUsername } from "@/core/auth/api";
import { setStoredAuth } from "@/core/auth";
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,
} from "@/core/page-layout/sidebar-enabled";
} from '@/core/page-layout/sidebar-enabled';
const DEFAULT_USERNAME = "guest";
const IFRAME_LOGIN_SOURCE = "iframe";
const DEFAULT_REDIRECT_PATH = "/page/workspace/chats/new";
const IFRAME_REDIRECT_PATH = "/page/workspace/chats_iframe/new";
const DEFAULT_USERNAME = 'guest';
const IFRAME_LOGIN_SOURCE = 'iframe';
const DEFAULT_REDIRECT_PATH = '/page/workspace/chats/new';
const IFRAME_REDIRECT_PATH = '/page/workspace/chats_iframe/new';
type LoginStatus = "loading" | "success" | "error";
type LoginStatus = 'loading' | 'success' | 'error';
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],
);
const redirectPath = useMemo(
() =>
searchParams.get("from") === IFRAME_LOGIN_SOURCE
searchParams.get('from') === IFRAME_LOGIN_SOURCE
? IFRAME_REDIRECT_PATH
: DEFAULT_REDIRECT_PATH,
[searchParams],
@@ -36,31 +47,64 @@ export default function LoginPage() {
const raw = username?.trim();
return raw && raw.length > 0 ? raw : DEFAULT_USERNAME;
}, [username]);
const [status, setStatus] = useState<LoginStatus>("loading");
const [errorMessage, setErrorMessage] = useState<string>("");
// 外部跳转参数: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;
async function runLogin() {
setStatus("loading");
setErrorMessage("");
setStatus('loading');
setErrorMessage('');
try {
if (yUserId) {
await loginByYUserId(yUserId, toolToken || null);
} else if (toolToken) {
persistToolsToken(toolToken);
}
const auth = await loginByUsername(resolvedUsername);
if (cancelled) {
return;
}
setStoredAuth(auth);
persistSidebarEnabled(sidebarEnabled);
setStatus("success");
router.replace(redirectPath);
setStatus('success');
router.replace(yUserId ? GENERAL_QA_ROUTE : redirectPath);
} catch (error) {
if (cancelled) {
return;
}
setStatus("error");
setStatus('error');
setErrorMessage(
error instanceof Error ? error.message : "Login request failed.",
error instanceof Error ? error.message : 'Login request failed.',
);
}
}
@@ -69,19 +113,25 @@ export default function LoginPage() {
return () => {
cancelled = true;
};
}, [redirectPath, resolvedUsername, router, sidebarEnabled]);
}, [
redirectPath,
resolvedUsername,
router,
sidebarEnabled,
yUserId,
toolToken,
]);
return (
<div className="flex min-h-screen items-center justify-center bg-background px-6">
<div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-sm">
<h1 className="text-xl font-semibold">正在进入智能体模式</h1>
{status === "error" && (
{status === 'error' && (
<p className="mt-4 text-sm text-red-600">
登录失败:{errorMessage || "未知错误"}
登录失败:{errorMessage || '未知错误'}
</p>
)}
</div>
</div>
);
}
+11 -2
View File
@@ -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}
/>
}
/>
+331 -315
View File
@@ -1,47 +1,47 @@
import type { CSSProperties, ReactNode } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import type { CSSProperties, ReactNode } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { getStoredAuth } from "@/core/auth";
import { getStoredAuth } from '@/core/auth';
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { CommandPalette } from "@/components/workspace/command-palette";
import { Toaster } from "sonner";
import { SubtasksProvider } from "@/core/tasks/context";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
import { WorkspaceSidebar } from '@/components/workspace/workspace-sidebar';
import { CommandPalette } from '@/components/workspace/command-palette';
import { Toaster } from 'sonner';
import { SubtasksProvider } from '@/core/tasks/context';
import { ArtifactsProvider } from '@/components/workspace/artifacts';
import { RouteBaseProvider } from "@/core/route-base";
import { RouteBaseProvider } from '@/core/route-base';
import ChatsPage from "./ChatsPage";
import ChatPage from "./ChatPage";
import IframeChatPage from "./IframeChatPage";
import AgentsPage from "./AgentsPage";
import AgentChatPage from "./AgentChatPage";
import NewAgentPage from "./NewAgentPage";
import AdminAgentsPage from "./AdminAgentsPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminSkillsPage from "./AdminSkillsPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
import LlmMetricsPage from "./LlmMetricsPage";
import MemoryPage from "./MemoryPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage";
import UsersPage from "./UsersPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import AdminMemoryPage from "./AdminMemoryPage";
import AdminUserHubPage from "./AdminUserHubPage";
import TagsPage from "./TagsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
import StudioNotebooksPage from "./StudioNotebooksPage";
import StudioNotebookDetailPage from "./StudioNotebookDetailPage";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
import { cn } from "@/lib/utils";
import ChatsPage from './ChatsPage';
import ChatPage from './ChatPage';
import IframeChatPage from './IframeChatPage';
import AgentsPage from './AgentsPage';
import AgentChatPage from './AgentChatPage';
import NewAgentPage from './NewAgentPage';
import AdminAgentsPage from './AdminAgentsPage';
import AdminScheduledTasksPage from './AdminScheduledTasksPage';
import AdminSkillsPage from './AdminSkillsPage';
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
import ScheduledTasksPage from './ScheduledTasksPage';
import LlmMetricsPage from './LlmMetricsPage';
import MemoryPage from './MemoryPage';
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
import SkillEvolutionPage from './SkillEvolutionPage';
import SkillsPage from './SkillsPage';
import UsersPage from './UsersPage';
import AdminUserActivityPage from './AdminUserActivityPage';
import AdminMemoryPage from './AdminMemoryPage';
import AdminUserHubPage from './AdminUserHubPage';
import TagsPage from './TagsPage';
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import StudioNotebooksPage from './StudioNotebooksPage';
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
import '@/roundtable-planning/styles/roundtable-planning.css';
import { cn } from '@/lib/utils';
export function WorkspaceLayout({
children,
@@ -51,6 +51,7 @@ export function WorkspaceLayout({
compactWorkspaceSidebar = false,
wrapperOverflowHidden = false,
insetOverflowHidden = false,
pageSidebarDark = false,
}: {
children: ReactNode;
pageSidebarCollapsed: boolean;
@@ -59,23 +60,33 @@ export function WorkspaceLayout({
compactWorkspaceSidebar?: boolean;
wrapperOverflowHidden?: boolean;
insetOverflowHidden?: boolean;
pageSidebarDark?: boolean;
}) {
const pageSidebarWidth = pageSidebarHidden
? "0rem"
: pageSidebarCollapsed
? "4rem"
: "16rem";
? '0rem'
: 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
className={[
"h-full w-full [&_[data-slot=sidebar-container]]:left-[var(--page-sidebar-width)]",
wrapperOverflowHidden ? "overflow-hidden" : "overflow-x-hidden",
].join(" ")}
'h-full w-full [&_[data-slot=sidebar-container]]:left-[var(--page-sidebar-width)]',
wrapperOverflowHidden ? 'overflow-hidden' : 'overflow-x-hidden',
].join(' ')}
style={
{
"--page-sidebar-width": pageSidebarWidth,
"--header-height": headerHidden ? "0rem" : "4rem",
'--page-sidebar-width': pageSidebarWidth,
'--header-height': headerHidden ? '0rem' : '4rem',
} as CSSProperties
}
// Keep the fixed workspace sidebar aligned to the outer page sidebar width.
@@ -85,7 +96,7 @@ export function WorkspaceLayout({
>
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
<SidebarInset
className={cn("min-w-0", insetOverflowHidden && "overflow-hidden")}
className={cn('min-w-0', insetOverflowHidden && 'overflow-hidden')}
>
{children}
</SidebarInset>
@@ -110,302 +121,307 @@ 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 (
<RouteBaseProvider value="/page/workspace">
<Routes>
<Route index element={<Navigate to="chats/new" replace />} />
<Route
path="chats"
element={
<WorkspaceLayout {...layoutProps}>
<ChatsPage />
</WorkspaceLayout>
}
/>
<Route
path="chats/:thread_id"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="chats_iframe/new"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="chats_iframe/:thread_id"
element={
<WorkspaceLayout
{...layoutProps}
wrapperOverflowHidden
insetOverflowHidden
>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="agents"
element={
<WorkspaceLayout {...layoutProps}>
<AgentsPage />
</WorkspaceLayout>
}
/>
<Route
path="agents/new"
element={
<WorkspaceLayout {...layoutProps}>
<NewAgentPage />
</WorkspaceLayout>
}
/>
<Route
path="agents/:agent_id/chats"
element={<Navigate to="/page/workspace/agents" replace />}
/>
<Route
path="agents/:agent_id/chats/:thread_id"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="skills"
element={
<WorkspaceLayout {...layoutProps}>
<SkillsPage />
</WorkspaceLayout>
}
/>
<Route
path="skills/evolution"
element={
<WorkspaceLayout {...layoutProps}>
<SkillEvolutionPage />
</WorkspaceLayout>
}
/>
<Route
path="memory"
element={
<WorkspaceLayout {...layoutProps}>
<MemoryPage />
</WorkspaceLayout>
}
/>
<Route
path="ai-writing"
element={
<WorkspaceLayout {...layoutProps}>
<AIWritingPage hideAdvancedFields />
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks"
element={
<WorkspaceLayout {...layoutProps}>
<StudioNotebooksPage />
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks/:notebookId"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<StudioNotebookDetailPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="roundtable"
element={<Navigate to="/page/workspace/roundtable/planning" replace />}
/>
<Route
path="roundtable/planning"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="scheduled"
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
/>
<Route
path="scheduled/tasks"
element={
<WorkspaceLayout {...layoutProps}>
<ScheduledTasksPage />
</WorkspaceLayout>
}
/>
<Route
path="scheduled/runs/:run_id"
element={
<WorkspaceLayout {...layoutProps}>
<ScheduledTaskRunDetailPage />
</WorkspaceLayout>
}
/>
{/* Unified admin user hub */}
<Route
path="admin"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps} pageSidebarHidden>
<AdminUserHubPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
{/* Legacy redirects */}
<Route
path="admin/users"
element={<Navigate to="/page/workspace/admin" replace />}
/>
<Route
path="admin/user-activity"
element={<Navigate to="/page/workspace/admin" replace />}
/>
<Route
path="admin/memory"
element={<Navigate to="/page/workspace/admin" replace />}
/>
<Route
path="admin/scheduled-tasks"
element={
getStoredAuth()?.system_role === "admin" ? (
<Routes>
<Route index element={<Navigate to="chats/new" replace />} />
<Route
path="chats"
element={
<WorkspaceLayout {...layoutProps}>
<AdminScheduledTasksPage />
<ChatsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/agents"
element={
getStoredAuth()?.system_role === "admin" ? (
}
/>
<Route
path="chats/:thread_id"
element={
<WorkspaceLayout {...layoutProps}>
<AdminAgentsPage />
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/skills"
element={
getStoredAuth()?.system_role === "admin" ? (
}
/>
<Route
path="chats_iframe/new"
element={
<WorkspaceLayout {...layoutProps}>
<AdminSkillsPage />
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === "admin" ? (
}
/>
<Route
path="chats_iframe/:thread_id"
element={
<WorkspaceLayout
{...layoutProps}
wrapperOverflowHidden
insetOverflowHidden
>
<ChatRuntime>
<IframeChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="agents"
element={
<WorkspaceLayout {...layoutProps}>
<TagsPage />
<AgentsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/recommended-questions"
element={
getStoredAuth()?.system_role === "admin" ? (
}
/>
<Route
path="agents/new"
element={
<WorkspaceLayout {...layoutProps}>
<RecommendedQuestionsPage />
<NewAgentPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/prompt-prefix"
element={
getStoredAuth()?.system_role === "admin" ? (
}
/>
<Route
path="agents/:agent_id/chats"
element={<Navigate to="/page/workspace/agents" replace />}
/>
<Route
path="agents/:agent_id/chats/:thread_id"
element={
<WorkspaceLayout {...layoutProps}>
<PromptPrefixSettingsPage />
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/private-square"
element={
getStoredAuth()?.system_role === "admin" ? (
}
/>
<Route
path="skills"
element={
<WorkspaceLayout {...layoutProps}>
<PrivateSquareSettingsPage />
<SkillsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/llm-metrics"
element={
getStoredAuth()?.system_role === "admin" ? (
}
/>
<Route
path="skills/evolution"
element={
<WorkspaceLayout {...layoutProps}>
<LlmMetricsPage />
<SkillEvolutionPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes>
}
/>
<Route
path="memory"
element={
<WorkspaceLayout {...layoutProps}>
<MemoryPage />
</WorkspaceLayout>
}
/>
<Route
path="ai-writing"
element={
<WorkspaceLayout {...layoutProps}>
<AIWritingPage hideAdvancedFields />
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks"
element={
<WorkspaceLayout {...layoutProps}>
<StudioNotebooksPage />
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks/:notebookId"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<StudioNotebookDetailPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="roundtable"
element={
<Navigate to="/page/workspace/roundtable/planning" replace />
}
/>
<Route
path="roundtable/planning"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="scheduled"
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
/>
<Route
path="scheduled/tasks"
element={
<WorkspaceLayout {...layoutProps}>
<ScheduledTasksPage />
</WorkspaceLayout>
}
/>
<Route
path="scheduled/runs/:run_id"
element={
<WorkspaceLayout {...layoutProps}>
<ScheduledTaskRunDetailPage />
</WorkspaceLayout>
}
/>
{/* Unified admin user hub */}
<Route
path="admin"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps} pageSidebarHidden>
<AdminUserHubPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
{/* Legacy redirects */}
<Route
path="admin/users"
element={<Navigate to="/page/workspace/admin" replace />}
/>
<Route
path="admin/user-activity"
element={<Navigate to="/page/workspace/admin" replace />}
/>
<Route
path="admin/memory"
element={<Navigate to="/page/workspace/admin" replace />}
/>
<Route
path="admin/scheduled-tasks"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<AdminScheduledTasksPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/agents"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<AdminAgentsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/skills"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<AdminSkillsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<TagsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/recommended-questions"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<RecommendedQuestionsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/prompt-prefix"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<PromptPrefixSettingsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/private-square"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<PrivateSquareSettingsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/llm-metrics"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<LlmMetricsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes>
</RouteBaseProvider>
);
}
@@ -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>
@@ -20,6 +20,7 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { listAgents, type Agent } from "@/core/agents";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
import {
@@ -31,9 +32,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 +60,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 +92,8 @@ const FALLBACK_AGENT_IDS = [
type DialogPhase =
| "idle"
| "loading_agents"
| "loading_history"
| "history"
| "recommending"
| "ready"
| "fallback"
@@ -111,6 +124,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 +138,7 @@ const INITIAL_STATE: InternalState = {
recommendedIds: [],
warning: null,
errorMessage: null,
historyCreatedAt: null,
};
/** 推荐流程的 phase → 中文标签;与 RoundtablePlanningPage::phaseLabel 风格保持一致。 */
@@ -164,9 +180,15 @@ export function RecommendAgentsDialog({
open,
intent,
model,
draftId,
onCancel,
onConfirm,
}: RecommendAgentsDialogProps) {
// 弹窗经 Radix portal 渲染到 .roundtable-planning 之外,页面的深蓝 scoped 样式
// 够不着它。深色下手动挂上 .roundtable-planning--dark,让同一套深蓝 token + 兜底
// 映射也作用于弹窗;浅色不挂,弹窗浅色外观不变。
const { theme } = useTheme();
const isDark = theme === "dark";
const [state, setState] = useState<InternalState>(INITIAL_STATE);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -185,172 +207,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 +449,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 +481,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;
@@ -432,7 +522,12 @@ export function RecommendAgentsDialog({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col overflow-hidden gap-3">
<DialogContent
className={cn(
"sm:max-w-3xl max-h-[85vh] flex flex-col overflow-hidden gap-3",
isDark && "roundtable-planning--dark",
)}
>
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<SparklesIcon className="size-5 text-purple-500" />
@@ -452,7 +547,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 +594,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 +660,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 +736,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 +752,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 +830,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 +850,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,50 @@
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 错误 */
/* —— brand-* 别名改深蓝(关键):globals.css 只在 :root 把 --brand-* 定为浅红/红,
* .dark 未覆盖,导致组件里 var(--brand-200/900/950) 在深色下泛粉/泛红、与深蓝冲突。
* 这里改为渐蓝系 ZS-002,所有 bg-[var(--brand-200)] / text-[var(--brand-900)] 等
* 一并由红转蓝。 —— */
--workspace-brand: #1677ff; /* 与 primary 对齐 */
--brand-200: #16335f; /* 浅红 chip 底 → 深蓝软底(chip / 头像底) */
--brand-900: #4096ff; /* 红强调 / 按钮 hover → 蓝(zs-002-05) */
--brand-950: #91caff; /* 深红文字 → 浅蓝(zs-002-03,深底上可读) */
/* —— 圆桌局部别名(回落到上面已覆盖的 token) —— */
--rt-page-bg: var(--background);
--rt-primary: var(--primary);
--rt-primary-hover: var(--primary);
@@ -47,98 +88,253 @@
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;
}
/* =============================================================================
* 残留红色清理 —— 内联 SVG 红填充 + 红色发光阴影 → 深蓝系。
* (这些是写死在组件里的红:头像 fill 属性、按钮 rgba(245,34,45) 阴影,
* 不经 token,需在此单独改写;仅深色生效。)
* ========================================================================== */
/* 头像等内联 SVG 的红色 fill(CSS 可覆盖 SVG presentation 属性) */
:is(.dark .roundtable-planning, .roundtable-planning--dark) [fill="#f5222d"] {
fill: #1677ff !important;
}
:is(.dark .roundtable-planning, .roundtable-planning--dark) [fill="#cf1322"] {
fill: #0958d9 !important;
}
:is(.dark .roundtable-planning, .roundtable-planning--dark) [fill="#a8071a"] {
fill: #003eb3 !important;
}
/* 红色发光阴影 → 蓝色发光 */
:is(.dark .roundtable-planning, .roundtable-planning--dark)
.shadow-\[0_2px_8px_rgba\(245\2c 34\2c 45\2c 0\.15\)\] {
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.28) !important;
}
:is(.dark .roundtable-planning, .roundtable-planning--dark)
.shadow-\[0_8px_30px_rgba\(245\2c 34\2c 45\2c 0\.06\)\] {
box-shadow: 0 8px 30px rgba(22, 119, 255, 0.14) !important;
}
/* =============================================================================
* 结构件覆盖(顶栏 / 步骤条 / 次要按钮)—— 保留原有针对性规则。
* ========================================================================== */
/* 顶栏 */
.dark .roundtable-planning > header,
.roundtable-planning--dark > header {
@@ -161,29 +357,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 })}`;
}