Merge branch 'dhk-1' into 'master'

feat(roundtable): 第二步席位执行模式(快速/思考/专业问答/多智能体)+ 子智能体任务卡片 + 后台完成跨页通知 + 链条默认模式持久化

See merge request knowledge-base-bj/deerflow-server!124
This commit is contained in:
董宏凯 committed 2026-06-16 06:19:04 +00:00
commit fe0dc8d1a5
48 files changed
+1733 -598

No files matched your search

@@ -54,7 +54,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
};
const hasActiveChild = (item: MenuItem): boolean => {
if (item.path && pathname === item.path) {
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true;
}
@@ -153,7 +153,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
inWujieEnv
? item.id === activeTopId
: item.path
? pathname === item.path
? pathname === item.path || pathname.startsWith(item.path + "/")
: hasActiveChild(item);
const handleSidebarItemClick = (item: MenuItem) => {
if (inWujieEnv) {
+5 -3
View File
@@ -42,7 +42,7 @@ type PageSidebarProps = {
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
const hasActiveChild = (item: MenuItem, pathname: string): boolean => {
if (item.path && pathname === item.path) {
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true;
}
return item.children?.some((child) => hasActiveChild(child, pathname)) ?? false;
@@ -116,7 +116,9 @@ function SecondaryNavSidebar() {
const secondaryItems = activeTopItem?.children ?? [];
const isItemActive = (item: MenuItem) =>
item.path ? pathname === item.path : hasActiveChild(item, pathname);
item.path
? pathname === item.path || pathname.startsWith(item.path + "/")
: hasActiveChild(item, pathname);
const handleItemClick = (item: MenuItem) => {
const path = item.path ?? findFirstRoutePath(item.children);
@@ -210,7 +212,7 @@ function SecondaryNavSidebar() {
{thirdItems.map((child) => {
const ChildIcon = child.icon;
const childActive = child.path
? pathname === child.path
? pathname === child.path || pathname.startsWith(child.path + "/")
: hasActiveChild(child, pathname);
return (
<button
@@ -0,0 +1,127 @@
"use client";
import { Bot, ChevronRight } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useAgentsPaginated } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { desensitize } from "@/lib/desensitize";
/** 常用智能体最多展示个数(需求 1.2 ②)。 */
const MAX_FREQUENT = 3;
/** 「更多」跳转目标:智能体管理页(需求 1.3)。 */
const MORE_HREF = "/page/strategy/agents";
/** 打开某个智能体主页(其对话落地页,thread_id=new = 新会话)。 */
function agentHomeHref(agentId: string): string {
return `/page/strategy/agents/${agentId}/chats/new`;
}
/**
* 「常用智能体」侧栏区块(通用问答左栏,全量模式)。
*
* 级联兜底取数(需求 1.2 ② / 1.3)——按优先级补足到最多 3 个、去重:
* 1. 置顶智能体(``is_pinned``,可来自「我的」或「广场」);
* 2. 不足 3 个 → 用「我的」智能体(``scope="mine"``)补足;
* 3. 仍不足 → 用「广场」默认推荐(``scope="square"``)补足。
*
* 取不到任何智能体时整块隐藏(与 ``RecentChatList`` 的空态约定一致)。
*/
export function FrequentAgentsList() {
const pathname = usePathname();
const { agents: mine } = useAgentsPaginated({
scope: "mine",
page: 1,
pageSize: 20,
});
const { agents: square } = useAgentsPaginated({
scope: "square",
page: 1,
pageSize: 20,
});
const frequent = useMemo<Agent[]>(() => {
const out: Agent[] = [];
const seen = new Set<string>();
const add = (agent: Agent | undefined) => {
if (!agent || out.length >= MAX_FREQUENT || seen.has(agent.id)) return;
seen.add(agent.id);
out.push(agent);
};
// 1. 置顶组优先(够 3 个则下面我的/广场都不再补)。
[...mine, ...square].filter((a) => a.is_pinned).forEach(add);
// 2. 我的智能体补足。
mine.forEach(add);
// 3. 广场默认推荐补足。
square.forEach(add);
return out;
}, [mine, square]);
if (frequent.length === 0) {
return null;
}
return (
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
<SidebarGroupLabel className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
常用智能体
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{frequent.map((agent) => {
const href = agentHomeHref(agent.id);
const isActive = pathname === href;
return (
<SidebarMenuItem key={agent.id}>
<SidebarMenuButton
isActive={isActive}
asChild
className={
"rounded-lg h-7 px-3 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60 " +
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)] " +
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none"
}
>
<Link
className="flex w-full items-center gap-2 whitespace-nowrap"
href={href}
>
<Bot className="size-3.5 shrink-0 text-muted-foreground/70" />
<span className="truncate">{desensitize(agent.name)}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="rounded-lg h-7 px-3 text-[12px] text-muted-foreground/70 hover:text-foreground hover:bg-sidebar-accent/60"
>
<Link
className="flex w-full items-center justify-between gap-1"
href={MORE_HREF}
>
<span>更多</span>
<ChevronRight className="size-3.5 shrink-0" />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
@@ -1,7 +1,8 @@
import type { Message } from "@langchain/langgraph-sdk";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useStickToBottomContext } from "use-stick-to-bottom";
import {
Conversation,
@@ -52,6 +53,38 @@ import { SubtaskCard } from "./subtask-card";
export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
/** 用户发送新问题时强制滚到底部(即使用户此前上滑看过历史)。 */
function ScrollToBottomOnUserMessage({
threadId,
messages,
}: {
threadId: string;
messages: Message[];
}) {
const { scrollToBottom } = useStickToBottomContext();
const prevHumanCountRef = useRef<number | null>(null);
useEffect(() => {
prevHumanCountRef.current = null;
}, [threadId]);
useEffect(() => {
const humanCount = messages.reduce(
(count, message) => count + (message.type === "human" ? 1 : 0),
0,
);
const prev = prevHumanCountRef.current;
prevHumanCountRef.current = humanCount;
if (prev !== null && humanCount > prev) {
requestAnimationFrame(() => {
requestAnimationFrame(() => scrollToBottom());
});
}
}, [messages, scrollToBottom]);
return null;
}
export function MessageList({
className,
contentClassName,
@@ -266,6 +299,7 @@ export function MessageList({
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
>
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
@@ -2,6 +2,7 @@
import {
ChevronDown,
ChevronRight,
Download,
FileJson,
FileText,
@@ -74,7 +75,17 @@ import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
export function RecentChatList({ compact = false }: { compact?: boolean }) {
export function RecentChatList({
compact = false,
limit,
moreHref,
}: {
compact?: boolean;
/** 最多展示的会话条数;省略则不限制(需求 1.3:通用问答最多 10 条)。 */
limit?: number;
/** 「更多」入口跳转目标;省略则不展示「更多」行。 */
moreHref?: string;
}) {
const { t } = useI18n();
const router = useRouter();
const pathname = usePathname();
@@ -190,6 +201,8 @@ export function RecentChatList({ compact = false }: { compact?: boolean }) {
if (visibleThreads.length === 0) {
return null;
}
const shownThreads =
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
return (
<>
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
@@ -225,7 +238,7 @@ export function RecentChatList({ compact = false }: { compact?: boolean }) {
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{visibleThreads.map((thread) => {
{shownThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
@@ -368,6 +381,22 @@ export function RecentChatList({ compact = false }: { compact?: boolean }) {
</SidebarMenuItem>
);
})}
{moreHref && (
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="rounded-lg h-7 px-3 text-[12px] text-muted-foreground/70 hover:text-foreground hover:bg-sidebar-accent/60"
>
<Link
className="flex w-full items-center justify-between gap-1"
href={moreHref}
>
<span>{t.common.more}</span>
<ChevronRight className="size-3.5 shrink-0" />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
@@ -165,7 +165,7 @@ export function WorkspaceNavMenu() {
Checkpoint 迁移
</DropdownMenuItem>
)}
{isAdmin && (
{isAdmin ? (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/admin/user-leaderboard")
@@ -174,6 +174,15 @@ export function WorkspaceNavMenu() {
<TrophyIcon />
用户排行榜
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/user-leaderboard")
}
>
<TrophyIcon />
用户排行榜
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
@@ -10,7 +10,9 @@ import {
} from "@/components/ui/sidebar";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
import { usePathname } from "next/navigation";
import { FrequentAgentsList } from "./frequent-agents-list";
import { RecentChatList } from "./recent-chat-list";
import { RecentRoundtableList } from "./recent-roundtable-list";
import { RecentWritingList } from "./recent-writing-list";
@@ -25,7 +27,11 @@ export function WorkspaceSidebar({
}: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) {
const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme();
const pathname = usePathname();
const useWhiteSidebar = compactChrome && theme === "light";
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;其余 workspace 页保持原样。
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
@@ -44,7 +50,7 @@ export function WorkspaceSidebar({
variant="sidebar"
collapsible="none"
className={cn(
"h-full w-(--sidebar-width)",
"h-full w-(--sidebar-width) border-r border-border",
useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className,
@@ -53,7 +59,12 @@ export function WorkspaceSidebar({
>
<SidebarContent>
<WorkspaceNavChatList showCollapseTrigger compact />
<RecentChatList compact />
{isGeneralQa && <FrequentAgentsList />}
<RecentChatList
compact
limit={isGeneralQa ? 10 : undefined}
moreHref={isGeneralQa ? "/page/workspace/chats" : undefined}
/>
</SidebarContent>
<SidebarRail />
</Sidebar>
@@ -43,6 +43,8 @@ export type MenuItem = {
label: string;
icon: ElementType;
path?: string;
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
userPath?: string;
adminOnly?: boolean;
children?: MenuItem[];
};
@@ -81,18 +83,18 @@ export const sidebarMenuItems: MenuItem[] = [
label: "XK中任务",
icon: ClipboardList,
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: "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",
// },
],
},
{
@@ -125,24 +127,24 @@ export const sidebarMenuItems: MenuItem[] = [
label: "PG中任务",
icon: Gauge,
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: "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",
// },
],
},
{
@@ -150,24 +152,24 @@ export const sidebarMenuItems: MenuItem[] = [
label: "TY中任务",
icon: Radar,
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: "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",
// },
],
},
],
@@ -197,6 +199,13 @@ export const sidebarMenuItems: MenuItem[] = [
label: "综合管理",
icon: Settings,
children: [
{
id: "mgmt-user-leaderboard",
label: "用户排行榜",
icon: Trophy,
path: "/page/strategy/admin/user-leaderboard",
userPath: "/page/strategy/user-leaderboard",
},
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
{
id: "mgmt-business-chain-mapping",
@@ -232,13 +241,6 @@ export const sidebarMenuItems: MenuItem[] = [
path: "/page/strategy/admin/users",
adminOnly: true,
},
{
id: "mgmt-user-leaderboard",
label: "用户排行榜",
icon: Trophy,
path: "/page/strategy/admin/user-leaderboard",
adminOnly: true,
},
{
id: "mgmt-admin-scheduled-tasks",
label: "定时任务管理",
@@ -260,12 +262,12 @@ export const sidebarMenuItems: MenuItem[] = [
label: "资源管理",
icon: Database,
children: [
{
id: "resource-network-xwt",
label: "网络xwt资源管理",
icon: Network,
path: "/page/strategy/resource-management/resource-network-actor",
},
// {
// id: "resource-network-xwt",
// label: "网络xwt资源管理",
// icon: Network,
// path: "/page/strategy/resource-management/resource-network-actor",
// },
{
id: "resource-knowledge-native",
label: "问答梳理",
@@ -278,12 +280,12 @@ export const sidebarMenuItems: MenuItem[] = [
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: "resource-evaluation-index",
// label: "作战资源管理",
// icon: FileBox,
// path: "/page/strategy/resource-management/resource-evaluation-index",
// },
],
},
];
@@ -298,9 +300,9 @@ export const defaultExpandedIds: ReadonlySet<string> = new Set([
"comprehensive-management",
]);
/** 该菜单项自身或任意后代的 path 是否等于当前路由 */
/** 该菜单项自身或任意后代的 path 是否匹配当前路由(支持子路径,如 /knowledge/:id) */
export function menuItemContainsPath(item: MenuItem, pathname: string): boolean {
if (item.path && item.path === pathname) {
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true;
}
return item.children?.some((child) => menuItemContainsPath(child, pathname)) ?? false;
@@ -308,7 +310,8 @@ export function menuItemContainsPath(item: MenuItem, pathname: string): boolean
/**
* 在「一级 → 二级 → 三级」菜单中,取当前路由所在的「激活二级项」的三级子项。
* 浅色侧边栏据此在二级右侧展开三级列;无三级则返回空数组(侧边栏保持窄栏)。
* 浅色侧边栏据此在二级右侧展开三级列;深色顶栏据此渲染三级横条。
* 全量模式:三级仅一项时不返回(无选择必要,侧边栏保持窄栏 / 不渲染横条)。
*/
export function getActiveThirdLevelItems(
items: MenuItem[],
@@ -320,16 +323,23 @@ export function getActiveThirdLevelItems(
const activeSecond = secondary.find(
(item) => (item.children?.length ?? 0) > 0 && menuItemContainsPath(item, pathname),
);
return activeSecond?.children ?? [];
const third = activeSecond?.children ?? [];
return third.length <= 1 ? [] : third;
}
/** 按 admin 角色过滤菜单:非 admin 不展示 ``adminOnly: true`` 的项及其子项 */
/** 按 admin 角色过滤菜单:非 admin 不展示 ``adminOnly: true`` 的项;``userPath`` 替换 ``path``。 */
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,
);
.map((item) => {
const children = item.children
? filterMenuItemsByRole(item.children, isAdmin)
: undefined;
const base = children ? { ...item, children } : item;
if (!isAdmin && base.userPath) {
const { userPath: _userPath, ...rest } = base;
return { ...rest, path: base.userPath };
}
return base;
});
}
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop, Search } from 'lucide-react';
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop, Search, AlertTriangle, Shuffle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { SkillSearchStep } from '../../types/ai-writing';
import { CollapseTransition } from './CollapseTransition';
@@ -16,6 +16,8 @@ const STEP_ICON: Record<SkillSearchStep['type'], typeof Sparkles> = {
select_skills: Sparkles,
invoke_skill: Zap,
skill_result: CheckCircle2,
skill_error: AlertTriangle,
fallback: Shuffle,
stop_early: CircleStop,
};
@@ -25,6 +27,8 @@ const STEP_LABEL: Record<SkillSearchStep['type'], string> = {
select_skills: '筛选相关技能',
invoke_skill: '调用技能',
skill_result: '检索结果',
skill_error: '技能调用失败',
fallback: '改用其他技能',
stop_early: '提前停止',
};
@@ -71,6 +75,9 @@ 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 isError = step.type === 'skill_error';
const isFallback = step.type === 'fallback';
const isActive = idx === activeIdx;
return (
<li key={idx} className="flex items-start gap-2 text-[11px]">
@@ -79,11 +86,13 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
'shrink-0 mt-0.5 inline-flex items-center justify-center size-4 rounded-full text-[10px] font-medium',
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',
: isError
? 'bg-rose-100 dark:bg-rose-900/60 text-rose-600 dark:text-rose-300'
: isStopEarly || isFallback
? '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 ? <Loader2 className="size-2.5 animate-spin" /> : idx + 1}
@@ -92,11 +101,13 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
<div
className={cn(
'flex items-center gap-1 font-medium',
isStopEarly
? 'text-amber-700 dark:text-amber-300'
: isResult
? 'text-emerald-700 dark:text-emerald-300'
: 'text-violet-700 dark:text-violet-300',
isError
? 'text-rose-700 dark:text-rose-300'
: isStopEarly || isFallback
? 'text-amber-700 dark:text-amber-300'
: isResult
? 'text-emerald-700 dark:text-emerald-300'
: 'text-violet-700 dark:text-violet-300',
)}
>
<Icon className="size-3 shrink-0" />
@@ -20,6 +20,8 @@ export interface AIMaterial {
// select_skills:LLM 按相关度排序的候选
// invoke_skill:开始调用某个技能去检索
// skill_result:某个技能检索返回了 N 条素材
// skill_error:某个技能调用失败(超时/报错),将改用其他技能(容错)
// fallback:配置技能全部失败,回退到其他技能继续搜集(容错)
// stop_early:累计素材达到上限,停止后续技能
export interface SkillSearchStep {
type:
@@ -28,6 +30,8 @@ export interface SkillSearchStep {
| "select_skills"
| "invoke_skill"
| "skill_result"
| "skill_error"
| "fallback"
| "stop_early";
message: string;
skillNames?: string[];
@@ -133,6 +133,8 @@ export default function LightAppManagementPage() {
const [form, setForm] = useState<FormState>(emptyForm);
const [activeParamPopover, setActiveParamPopover] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"table" | "cards">("table");
const [categoryFilter, setCategoryFilter] = useState<"all" | LightAppType>("all");
const [mountFilter, setMountFilter] = useState<string>("");
const saving = actions.create.isPending || actions.update.isPending;
@@ -220,7 +222,22 @@ export default function LightAppManagementPage() {
toast.success("已复制地址");
};
const tableApps = useMemo(() => apps, [apps]);
const selectCategory = (cat: "all" | LightAppType) => {
setCategoryFilter(cat);
if (cat !== "iframe") setMountFilter("");
};
const tableApps = useMemo(
() =>
apps.filter((app) => {
if (categoryFilter !== "all" && app.appType !== categoryFilter) return false;
if (categoryFilter === "iframe" && mountFilter && app.mountLocation !== mountFilter) {
return false;
}
return true;
}),
[apps, categoryFilter, mountFilter],
);
return (
<div className="light-app-management-page h-full overflow-y-auto bg-neutral-50 dark:bg-[#0C1836] dark:text-[#C5CFE0]">
@@ -252,7 +269,7 @@ export default function LightAppManagementPage() {
</button>
</div>
<div className="lam-toolbar flex flex-col items-center gap-3.5 rounded-[5px] bg-white p-3 shadow-[0_1px_3px_rgba(0,0,0,0.01)] md:flex-row">
<div className="lam-toolbar flex flex-col items-center gap-3.5 rounded-[5px] bg-white p-3 shadow-[0_1px_3px_rgba(0,0,0,0.01)] md:flex-row md:flex-wrap">
<div className="relative w-full flex-1">
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-400" />
<input
@@ -272,6 +289,41 @@ export default function LightAppManagementPage() {
)}
</div>
<div className="lam-cat-toggle flex shrink-0 items-center space-x-0.5 self-end rounded-[5px] border border-neutral-200/50 bg-neutral-100/60 p-0.5 md:self-auto">
{(
[
{ value: "all", label: "全部" },
{ value: "window_open", label: "新页跳转" },
{ value: "iframe", label: "iframe 嵌套" },
] as const
).map((c) => (
<button
key={c.value}
onClick={() => selectCategory(c.value)}
className={`lam-cat-toggle-btn cursor-pointer rounded-[5px] px-4 py-1.5 text-xs font-bold transition-all ${
categoryFilter === c.value
? "is-active bg-white text-[#F5222D] shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
{c.label}
</button>
))}
</div>
{categoryFilter === "iframe" && (
<div className="lam-mount-filter w-full shrink-0 animate-in fade-in self-end md:w-[180px] md:self-auto">
<Select
className="w-full"
value={mountFilter || undefined}
placeholder="按挂载菜单筛选"
clearable
options={MOUNT_OPTIONS}
onChange={(v) => setMountFilter((v as string) ?? "")}
/>
</div>
)}
<div className="lam-view-toggle flex shrink-0 items-center space-x-0.5 self-end rounded-[5px] border border-neutral-200/50 bg-neutral-100/60 p-0.5 md:self-auto">
<button
onClick={() => setViewMode("table")}
@@ -650,7 +702,7 @@ export default function LightAppManagementPage() {
onClick={() => setDialogOpen(false)}
/>
<div className="lam-dialog relative z-[110] m-4 flex max-h-[90vh] w-full max-w-[580px] flex-col rounded-[5px] border border-[#E8E8E8] bg-white p-6 shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
<div className="lam-dialog relative z-[110] m-4 flex max-h-[90vh] w-full max-w-[900px] flex-col rounded-[5px] border border-[#E8E8E8] bg-white p-6 shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
<div className="lam-dialog-header flex flex-shrink-0 items-center justify-between border-b border-[#E8E8E8] pb-4">
<div className="flex items-center space-x-2.5">
<span className="lam-dialog-icon rounded-[5px] bg-rose-50 p-1.5 text-[#A8071A]">
+3
View File
@@ -17,6 +17,7 @@ import Header from "@/strategy-components/Header";
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
import RoundtableRoutes from "./RoundtableRoutes";
import NotebookRoutes from "./NotebookRoutes";
import { RoundtableJobNotifier } from "@/roundtable-planning/components/RoundtableJobNotifier";
function PageLayout() {
const [collapsed, setCollapsed] = useState(false);
@@ -43,6 +44,7 @@ function PageLayout() {
// 高度链,根布局跟随容器高度。全屏下 100% 解析到视口,与 h-screen 等价;无界等嵌套
// 下容器不占满整屏时,高度才能正确收缩而非锁死整屏。
return (
<RoundtableJobNotifier>
<div className="h-full flex flex-col bg-muted/20">
{pageSidebarAllowed && <Header collapsed={collapsed} />}
<div className="flex flex-1 overflow-hidden">
@@ -97,6 +99,7 @@ function PageLayout() {
</main>
</div>
</div>
</RoundtableJobNotifier>
);
}
@@ -25,6 +25,7 @@ import ChatPage from "./ChatPage";
import ChatsPage from "./ChatsPage";
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
import AdminUserLeaderboardSettingsPage from "./AdminUserLeaderboardSettingsPage";
import PublicLeaderboardPage from "./PublicLeaderboardPage";
import LightAppIframeView from "./LightAppIframeView";
import LightAppListPage from "./LightAppListPage";
import LightAppManagementPage from "./LightAppManagementPage";
@@ -253,6 +254,7 @@ export default function StrategyRoutes() {
)
}
/>
<Route path="user-leaderboard" element={<PublicLeaderboardPage />} />
<Route
path="admin/user-leaderboard"
element={
+6 -4
View File
@@ -70,6 +70,7 @@ export function WorkspaceLayout({
workspaceSidebarHidden = false,
headerHidden = false,
compactWorkspaceSidebar = false,
defaultHistorySidebarOpen,
wrapperOverflowHidden = false,
insetOverflowHidden = false,
pageSidebarDark = false,
@@ -80,6 +81,8 @@ export function WorkspaceLayout({
workspaceSidebarHidden?: boolean;
headerHidden?: boolean;
compactWorkspaceSidebar?: boolean;
/** 全量模式下通用问答等页:聊天记录侧栏默认展开;未指定时紧凑模式仍默认收起。 */
defaultHistorySidebarOpen?: boolean;
wrapperOverflowHidden?: boolean;
insetOverflowHidden?: boolean;
pageSidebarDark?: boolean;
@@ -110,8 +113,7 @@ export function WorkspaceLayout({
}
key={String(compactWorkspaceSidebar)}
data-page-sidebar-collapsed={pageSidebarCollapsed}
// 紧凑模式下历史侧栏默认收起(聊天区更宽),由聊天页右上角图标钮展开
defaultOpen={!compactWorkspaceSidebar}
defaultOpen={defaultHistorySidebarOpen ?? !compactWorkspaceSidebar}
>
{!workspaceSidebarHidden && (
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
@@ -153,7 +155,7 @@ function NewChatEntryRoute({
}
return (
<WorkspaceLayout {...layoutProps}>
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime>
<ChatPage />
</ChatRuntime>
@@ -201,7 +203,7 @@ export default function WorkspaceRoutes({
<Route
path="chats/:thread_id"
element={
<WorkspaceLayout {...layoutProps}>
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime>
<ChatPage />
</ChatRuntime>
@@ -65,22 +65,26 @@
outline: none;
}
.dark .light-app-management-page .lam-view-toggle {
.dark .light-app-management-page .lam-view-toggle,
.dark .light-app-management-page .lam-cat-toggle {
background: var(--color-bg-page);
border-color: var(--color-border);
}
.dark .light-app-management-page .lam-view-toggle-btn.is-active {
.dark .light-app-management-page .lam-view-toggle-btn.is-active,
.dark .light-app-management-page .lam-cat-toggle-btn.is-active {
background: var(--color-bg-card);
color: var(--color-link);
box-shadow: var(--shadow-1);
}
.dark .light-app-management-page .lam-view-toggle-btn:not(.is-active) {
.dark .light-app-management-page .lam-view-toggle-btn:not(.is-active),
.dark .light-app-management-page .lam-cat-toggle-btn:not(.is-active) {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-view-toggle-btn:not(.is-active):hover {
.dark .light-app-management-page .lam-view-toggle-btn:not(.is-active):hover,
.dark .light-app-management-page .lam-cat-toggle-btn:not(.is-active):hover {
color: var(--color-text);
}
@@ -592,7 +596,8 @@
}
/* ── 视图切换激活态 ──────────────────────────────────────────────────── */
[data-theme="lightblue"] .light-app-management-page .lam-view-toggle-btn.is-active {
[data-theme="lightblue"] .light-app-management-page .lam-view-toggle-btn.is-active,
[data-theme="lightblue"] .light-app-management-page .lam-cat-toggle-btn.is-active {
color: #0062D9;
}
@@ -14,6 +14,7 @@ import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
import type { SeatMode } from "@/roundtable-planning/lib/seat-mode";
/** Fields accepted when creating a chain. */
export interface ChainCreatePayload {
@@ -28,6 +29,8 @@ export interface ChainCreatePayload {
stageGoals?: (string | null)[] | null;
/** Optional coordinator orchestration prompt (dag-only). null/"" = none. */
coordinatorPrompt?: string | null;
/** 默认席位执行模式(flash/thinking/pro/ultra);null = 未设。 */
seatMode?: SeatMode | null;
}
/** Partial update — send only what changed. */
@@ -41,6 +44,8 @@ export interface ChainUpdatePayload {
stageGoals?: (string | null)[] | null;
/** 写给总控的编排提示;send `null`/"" to clear, omit to leave. */
coordinatorPrompt?: string | null;
/** 默认席位执行模式;send `null` to clear, omit to leave. */
seatMode?: SeatMode | null;
/** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */
isPublic?: boolean;
}
@@ -70,6 +75,7 @@ interface ServerChain {
stages?: string[][] | null;
stage_goals?: (string | null)[] | null;
coordinator_prompt?: string | null;
seat_mode?: string | null;
is_public?: boolean;
is_mine?: boolean;
owner_name?: string | null;
@@ -86,6 +92,7 @@ function toChain(s: ServerChain): BusinessChain {
stages: Array.isArray(s.stages) ? s.stages : null,
stageGoals: Array.isArray(s.stage_goals) ? s.stage_goals : null,
coordinatorPrompt: s.coordinator_prompt ?? null,
seatMode: (s.seat_mode as SeatMode | null) ?? undefined,
isPublic: s.is_public ?? false,
// 后端缺省时按「我的」处理(向后兼容旧响应)。
isMine: s.is_mine ?? true,
@@ -104,6 +111,7 @@ function fromCreate(p: ChainCreatePayload) {
...(p.stages !== undefined ? { stages: p.stages } : {}),
...(p.stageGoals !== undefined ? { stage_goals: p.stageGoals } : {}),
...(p.coordinatorPrompt !== undefined ? { coordinator_prompt: p.coordinatorPrompt } : {}),
...(p.seatMode !== undefined ? { seat_mode: p.seatMode } : {}),
};
}
@@ -117,6 +125,7 @@ function fromUpdate(p: ChainUpdatePayload) {
// Forward stage goals even when explicitly null (clears goals server-side).
if (p.stageGoals !== undefined) out.stage_goals = p.stageGoals;
if (p.coordinatorPrompt !== undefined) out.coordinator_prompt = p.coordinatorPrompt;
if (p.seatMode !== undefined) out.seat_mode = p.seatMode;
if (p.isPublic !== undefined) out.is_public = p.isPublic;
return out;
}
@@ -202,6 +202,15 @@ export interface StreamMultiAgentRequest {
displayName?: string;
newMessage: string;
model?: string;
/**
* 仅 special 用。席位执行模式(快速/思考/专业问答/多智能体问答)派生的运行参数,
* 覆盖后端 `roundtable_run_policy` 的 thinking/reasoning 默认值。省略时后端按 policy
* 默认执行(向后兼容,旧前端不传不破坏)。三者通常一起传(见 `lib/seat-mode.ts`)。
*/
thinkingEnabled?: boolean;
reasoningEffort?: "minimal" | "low" | "medium" | "high";
/** 仅 special 用;ultra 档置真,给席位解锁 `task` 子代理工具。 */
subagentEnabled?: boolean;
skillStopNames?: string[];
/**
* 仅 leader 用。置 true 表示这是「派活后让总控判断继续/综合」的轮次:后端会把各已交付
@@ -444,6 +453,10 @@ export async function streamMultiAgent(
display_name: req.displayName,
new_message: req.newMessage,
model: req.model,
// 席位模式派生参数(仅 special);undefined 时不带上 → 后端按 policy 默认执行。
thinking_enabled: req.thinkingEnabled,
reasoning_effort: req.reasoningEffort,
subagent_enabled: req.subagentEnabled,
skill_stop_names: req.skillStopNames ?? [],
// 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。
synthesis_mode: req.synthesisMode ?? false,
@@ -15,6 +15,7 @@ import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
import { seatModeToRunParams } from "../lib/seat-mode";
import {
isTerminalJobStatus,
type DispatchChainNode,
@@ -75,6 +76,9 @@ function mapJob(raw: Record<string, unknown>): RoundtableJob {
}
function toStartBody(p: StartJobPayload) {
// 席位模式 → thinking/reasoning/subagent,与前端实时路径(/run/stream)同一份映射;
// 缺省(未选模式)时不带这三字段 → 后端按 policy 默认(≈pro),向后兼容。
const seat = p.seatMode ? seatModeToRunParams(p.seatMode) : null;
return {
draftId: p.draftId ?? null,
intent: p.intent,
@@ -86,6 +90,13 @@ function toStartBody(p: StartJobPayload) {
seedMessage: p.seedMessage,
threadIds: p.threadIds ?? null,
coordinatorName: p.coordinatorName ?? "roundtable-coordinator",
...(seat
? {
thinking_enabled: seat.thinking_enabled,
reasoning_effort: seat.reasoning_effort,
subagent_enabled: seat.subagent_enabled,
}
: {}),
};
}
@@ -13,7 +13,7 @@
*
* 详见 `docs/multi-agent-business-chain-dev.md` §6.2 / §7 阶段 3。
*/
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
ArrowRightIcon,
CheckIcon,
@@ -44,12 +44,15 @@ export interface BusinessChainPickerProps {
onCancel: () => void;
/** 跳转到业务链条配置页(同时关闭弹窗)。 */
onGoConfig: () => void;
/** 选中链条变化时回调(含其 seatMode),父弹窗据此把执行模式默认设为链条的配置。 */
onSelectedChainChange?: (chain: BusinessChain | null) => void;
}
export function BusinessChainPicker({
onConfirm,
onCancel,
onGoConfig,
onSelectedChainChange,
}: BusinessChainPickerProps) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [loading, setLoading] = useState(true);
@@ -102,6 +105,15 @@ export function BusinessChainPicker({
() => chains.find((c) => c.id === selectedId) ?? null,
[chains, selectedId],
);
// 选中链条变化 → 通知父弹窗(用 ref 持有回调,只在 selectedChain 真正变化时触发,
// 避免父级每次 render 重置用户已手动改过的执行模式)。
const onSelectedChainChangeRef = useRef(onSelectedChainChange);
useEffect(() => {
onSelectedChainChangeRef.current = onSelectedChainChange;
}, [onSelectedChainChange]);
useEffect(() => {
onSelectedChainChangeRef.current?.(selectedChain);
}, [selectedChain]);
const survivingSeats = useMemo(
() => (selectedChain ? selectedChain.seats.filter((s) => isExisting(s.agent_id)) : []),
[selectedChain, isExisting],
@@ -52,6 +52,8 @@ export interface MessageStepsCardProps {
/** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */
function isToolStep(step: BubbleStep): boolean {
// `task`(派生子智能体)单独渲染成任务卡片(SubtaskCard),不在步骤折叠卡里重复展示。
if (step.key === "tool_calling:task") return false;
return step.key.startsWith("tool_calling:") && step.key.length > "tool_calling:".length;
}
@@ -34,6 +34,11 @@ import { MessageStepsCard } from "./MessageStepsCard";
import { BusinessChainPicker } from "./BusinessChainPicker";
import type { IntentSummary } from "../api/intent";
import type { SeatSelectionResult } from "../lib/business-chain";
import {
DEFAULT_SEAT_MODE,
SEAT_MODE_OPTIONS,
type SeatMode,
} from "../lib/seat-mode";
import {
filterRecommendCandidates,
listRecommendHistory,
@@ -200,6 +205,9 @@ export function RecommendAgentsDialog({
const navigate = useNavigate();
// 席位来源模式:智能推荐 / 业务链条。默认**业务链条**。
const [mode, setMode] = useState<"recommend" | "chain">("chain");
// 席位执行模式(快速/思考/专业问答/多智能体问答)——推荐 / 业务链条两种来源通用,
// 决定第二步每个子智能体席位的推理深度。默认 pro(= 改造前席位固定行为)。
const [seatMode, setSeatMode] = useState<SeatMode>(DEFAULT_SEAT_MODE);
const [state, setState] = useState<InternalState>(INITIAL_STATE);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -515,6 +523,14 @@ export function RecommendAgentsDialog({
return `已选 ${selectedCount} / ${MAX_SELECTED}`;
})();
// 统一在回传前合并席位模式,推荐 / 业务链条两条确认路径共用,保证 seatMode 总被带上。
const handleConfirmWithMode = useCallback(
(result: SeatSelectionResult) => {
onConfirm({ ...result, seatMode });
},
[onConfirm, seatMode],
);
const handleConfirm = useCallback(() => {
if (!canConfirm) return;
// Order: recommended-and-selected first (in recommender order), then
@@ -530,8 +546,8 @@ export function RecommendAgentsDialog({
for (const c of otherCards) {
if (selectedIds.has(c.agent_id)) ordered.push({ agent_id: c.agent_id, name: c.name });
}
onConfirm({ agents: ordered, mode: "recommend" });
}, [canConfirm, recommendedCards, otherCards, selectedIds, onConfirm]);
handleConfirmWithMode({ agents: ordered, mode: "recommend" });
}, [canConfirm, recommendedCards, otherCards, selectedIds, handleConfirmWithMode]);
const handleGoConfig = useCallback(() => {
onCancel();
@@ -595,6 +611,35 @@ export function RecommendAgentsDialog({
</button>
</div>
{/* 席位执行模式:快速 / 思考 / 专业问答 / 多智能体问答。推荐 & 业务链条通用,
决定第二步每个子智能体席位的推理深度(thinking/reasoning/subagent)。 */}
<div className="shrink-0 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">执行模式</span>
<div className="inline-flex rounded-lg border border-border bg-muted/40 p-0.5 text-xs font-medium">
{SEAT_MODE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setSeatMode(opt.value)}
title={opt.description}
className={cn(
"rounded-md px-2.5 py-1 transition-colors",
seatMode === opt.value
? "bg-card text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
))}
</div>
</div>
<p className="text-[11px] leading-relaxed text-muted-foreground">
{SEAT_MODE_OPTIONS.find((o) => o.value === seatMode)?.description}
</p>
</div>
{/* ===== 智能推荐模式(现状,零行为变更)===== */}
{mode === "recommend" && (
<>
@@ -750,9 +795,13 @@ export function RecommendAgentsDialog({
{/* ===== 业务链条模式 ===== */}
{mode === "chain" && (
<BusinessChainPicker
onConfirm={onConfirm}
onConfirm={handleConfirmWithMode}
onCancel={onCancel}
onGoConfig={handleGoConfig}
onSelectedChainChange={(chain) => {
// 选中链条若配了默认执行模式 → 把顶部模式选择器默认设为它(用户仍可临时改)。
if (chain?.seatMode) setSeatMode(chain.seatMode);
}}
/>
)}
</DialogContent>
@@ -0,0 +1,125 @@
/**
* 圆桌「后台挂起作业」全工作区跨页完成通知器。
*
* 挂在 `PageLayout`(`/page/*` 的常驻层,导航切页不卸载)里:圆桌页点「后台挂起」后
* 调 `watchJob(jobId)` 注册监听,本 Provider 用 `streamJob` 订阅该作业 SSE;作业跑到
* 终态时弹一个**与「达成共识」同款**的顶部浮窗(bg-primary + Sparkles + bounce),
* 因此用户切到聊天/任务/其它工作区页面也能收到完成提示。
*
* 自带浮窗(不走 sonner)—— 各工作区页各自挂了一个 sonner `<Toaster>`,若这里也用
* sonner 会出现多 Toaster 重复弹;自渲染浮窗由本常驻 Provider 独占,天然不重复。
*
* 不含「刷新恢复」:watchedJobIds 是内存态,整页刷新后丢失(本期范围)。Provider 缺失
* 时(如 embed 路由在 PageRoutes 之外)`watchJob` 为 no-op,调用方不报错。
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { Sparkles } from "lucide-react";
import { streamJob } from "@/roundtable-planning/api/roundtable-jobs";
import type { RoundtableJob } from "@/roundtable-planning/lib/job-types";
interface RoundtableJobNotifierValue {
/** 注册一个后台作业 id,作业完成(done/error)时弹全局提示。重复 id 自动去重。 */
watchJob: (jobId: string) => void;
}
const RoundtableJobNotifierContext = createContext<RoundtableJobNotifierValue>({
watchJob: () => {
/* 无 Provider(如 embed 路由)时 no-op,不报错 */
},
});
export function useRoundtableJobNotifier(): RoundtableJobNotifierValue {
return useContext(RoundtableJobNotifierContext);
}
/** 单个作业的 SSE 监听器(无 UI):到终态回调 onTerminal;传输中断回调 onStreamError。 */
function JobWatcher({
jobId,
onTerminal,
onStreamError,
}: {
jobId: string;
onTerminal: (job: RoundtableJob) => void;
onStreamError: (jobId: string) => void;
}) {
const onTerminalRef = useRef(onTerminal);
const onStreamErrorRef = useRef(onStreamError);
useEffect(() => {
onTerminalRef.current = onTerminal;
onStreamErrorRef.current = onStreamError;
}, [onTerminal, onStreamError]);
useEffect(() => {
const stop = streamJob(jobId, {
onProgress: () => {},
onDone: (job) => onTerminalRef.current(job),
onError: () => onStreamErrorRef.current(jobId),
});
return () => stop();
}, [jobId]);
return null;
}
/** 提示浮窗存活时长(ms)—— 比页内 toast(3.5s) 略长,给跨页用户更多时间看到。 */
const NOTICE_DURATION_MS = 6000;
export function RoundtableJobNotifier({ children }: { children: ReactNode }) {
const [watchedJobIds, setWatchedJobIds] = useState<string[]>([]);
const [notice, setNotice] = useState<string | null>(null);
const watchJob = useCallback((jobId: string) => {
setWatchedJobIds((prev) => (prev.includes(jobId) ? prev : [...prev, jobId]));
}, []);
const handleTerminal = useCallback((job: RoundtableJob) => {
setWatchedJobIds((prev) => prev.filter((id) => id !== job.id));
if (job.status === "done") {
setNotice("🎉 后台研讨已完成!可在「历史记录」查看结论与报告");
} else if (job.status === "error") {
setNotice("❌ 后台任务执行出错,请在「历史记录」中查看");
}
// cancelled:用户主动取消,不提示。
}, []);
const handleStreamError = useCallback((jobId: string) => {
// SSE 传输中断(非作业终态):静默停止监听,不误报完成。
setWatchedJobIds((prev) => prev.filter((id) => id !== jobId));
}, []);
useEffect(() => {
if (!notice) return;
const timer = setTimeout(() => setNotice(null), NOTICE_DURATION_MS);
return () => clearTimeout(timer);
}, [notice]);
return (
<RoundtableJobNotifierContext.Provider value={{ watchJob }}>
{children}
{watchedJobIds.map((id) => (
<JobWatcher
key={id}
jobId={id}
onTerminal={handleTerminal}
onStreamError={handleStreamError}
/>
))}
{notice && (
<div
className="fixed top-4 left-1/2 z-[100] flex -translate-x-1/2 transform items-center gap-2 rounded-xl border border-primary/40 bg-primary px-4 py-2.5 text-xs font-medium text-white shadow-lg animate-bounce"
role="status"
>
<Sparkles className="h-4 w-4 shrink-0" />
<span>{notice}</span>
</div>
)}
</RoundtableJobNotifierContext.Provider>
);
}
@@ -57,6 +57,8 @@ import {
StreamingCursor,
} from "@/roundtable-planning/components/MessageBubble";
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
import { SubtaskCard } from "@/components/workspace/messages/subtask-card";
import { useSubtask } from "@/core/tasks/context";
import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeatStrip";
import { DagFlowMiniPanel } from "@/roundtable-planning/components/DagFlowMiniPanel";
import { useArtifacts } from "@/components/workspace/artifacts";
@@ -909,6 +911,15 @@ export function Step2Message({
resolveAgentName={resolveAgentName}
/>
)}
{/* 「多智能体问答」模式:本席位派生的子智能体任务卡片(复用主聊天 SubtaskCard,
从共享 SubtaskContext 取状态)。同时发起多个 task → 多张卡片纵向堆叠。 */}
{Array.isArray(msg.subtaskIds) && msg.subtaskIds.length > 0 && (
<div className="flex w-full flex-col gap-2">
{msg.subtaskIds.map((taskId) => (
<RoundtableSubtaskCard key={taskId} taskId={taskId} isLoading={isStreaming} />
))}
</div>
)}
{reasoning && (
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
)}
@@ -932,6 +943,23 @@ export function Step2Message({
);
}
/**
* 子智能体任务卡片包裹层:子任务内容是**实时态**,存放在共享 SubtaskContext(主聊天同一份);
* 草稿回显 / 历史记录等场景气泡上虽留有 subtaskId,但 context 里已无对应 Subtask —— 此时
* 返回 null 不渲染(而非让 SubtaskCard 的 `useSubtask(id)!` 取到 undefined 崩溃)。
*/
function RoundtableSubtaskCard({
taskId,
isLoading,
}: {
taskId: string;
isLoading: boolean;
}) {
const task = useSubtask(taskId);
if (!task) return null;
return <SubtaskCard taskId={taskId} isLoading={isLoading} />;
}
/**
* 收集气泡里所有"写了文件"的产出路径,按出现顺序去重,聚合到一行文件卡片。
*
@@ -120,6 +120,11 @@ export interface UseDraftPersistenceReturn {
* 发起前 / 点查看前各存一次,保住当时的聊天记录不丢。返回 draft id。
*/
saveDraftNow: (furthestStep?: 1 | 2 | 3) => Promise<string | null>;
/**
* 用**显式传入的 step2 快照**强制存一次(不读 live 状态)。「后台挂起直接跳转」用:
* 跳转会清空 step2,调用方在跳转前冻结快照传进来,保证落库的是挂起那一刻的研讨记录。
*/
saveStep2SnapshotNow: (step2: DraftStep2Snapshot, furthestStep?: 1 | 2 | 3) => Promise<string | null>;
/** 强制刷新草稿列表。 */
refresh: () => Promise<void>;
}
@@ -287,6 +292,51 @@ export function useDraftPersistence(
[currentStep, enqueueSave, refreshList],
);
/**
* 用**显式传入的 step2 快照**强制存一次(不读 live 状态)。
*
* 「后台挂起改直接跳转」用:点挂起后页面立刻 resetToStep1 会清空 step2,而存稿是
* fire-and-forget 在跳转之后才真正落库——若仍走 `saveDraftNow`(其 `getStep2Snapshot`
* 是延迟读取)就会存到被重置后的空 step2。调用方在跳转前同步 `getStep2Snapshot()` 冻结
* 一份快照传进来,这里只认这份,确保存的是挂起那一刻的研讨记录。
*
* 有草稿 id → update(保护重命名,不带 title);无 id → 用当前 step1 + 传入 step2 建档。
*/
const saveStep2SnapshotNow = useCallback(
(step2: DraftStep2Snapshot, furthestStep: 1 | 2 | 3 = 2): Promise<string | null> => {
const run = async (): Promise<string | null> => {
const id = currentDraftIdRef.current;
if (id) {
const updated = await updateDraft(id, { furthestStep, step2 });
if (updated) {
currentDraftIdRef.current = updated.id;
return updated.id;
}
// 服务端已不存在 → 落回新建。
}
const step1 = getStep1Snapshot();
if (!hasSavableContent(step1, step2)) return null;
const created = await createDraft({
...(taskIdRef.current ? { taskId: taskIdRef.current } : {}),
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>;
},
[getStep1Snapshot, hasSavableContent, setCurrentDraftId],
);
const handleManualSave = useCallback(() => {
const furthest = (currentStep as 1 | 2 | 3) || 1;
void (async () => {
@@ -535,6 +585,7 @@ export function useDraftPersistence(
handleCancelRename,
ensureDraft,
saveDraftNow,
saveStep2SnapshotNow,
/** 强制刷新草稿列表(如后台挂起后让「进行中」状态立刻出现)。 */
refresh: refreshList,
};
@@ -27,6 +27,7 @@ import {
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { uploadFiles } from "@/core/uploads/api";
import { useUpdateSubtask } from "@/core/tasks/context";
/** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */
type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
@@ -37,12 +38,56 @@ function takeOnce<T>(ref: { current: T | null }): T | undefined {
ref.current = null;
return v;
}
/**
* 从一帧里识别子智能体 custom 事件(task_started/running/completed/failed/cancelled/
* timed_out)。custom 流帧通常是裸 payload 对象 `{type:"task_*", task_id, ...}`;
* 开启 stream_subgraphs 时也可能被包成 `[namespace, payload]`,这里两种都兼容。
*/
function asSubagentTaskEvent(frame: unknown): {
type: string;
task_id: string;
message?: unknown;
result?: unknown;
error?: unknown;
description?: unknown;
} | null {
const cand =
frame && typeof frame === "object" && !Array.isArray(frame)
? (frame as Record<string, unknown>)
: Array.isArray(frame) && frame.length >= 2 && frame[1] && typeof frame[1] === "object"
? (frame[1] as Record<string, unknown>)
: null;
if (!cand) return null;
const type = typeof cand.type === "string" ? cand.type : "";
const taskId = typeof cand.task_id === "string" ? cand.task_id : "";
if (!type.startsWith("task_") || !taskId) return null;
return { type, task_id: taskId, message: cand.message, result: cand.result, error: cand.error, description: cand.description };
}
/** 从 LangGraph 消息 chunk 的 content(string 或结构化 block 数组)里抽纯文本。 */
function extractFrameText(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
let out = "";
for (const item of content) {
if (typeof item === "string") out += item;
else if (item && typeof item === "object") {
const block = item as Record<string, unknown>;
if (typeof block.text === "string") out += block.text;
}
}
return out;
}
return "";
}
import {
buildDagStageLeaderTask,
pickDagSeatTask,
type OrchestrationPlan,
} from "@/roundtable-planning/lib/dag-types";
import type { ChainNodeState } from "@/roundtable-planning/lib/job-types";
import { seatModeToRequestFields, type SeatMode } from "@/roundtable-planning/lib/seat-mode";
import {
DEFAULT_SELECTED_AGENTS,
MAX_CYCLES,
@@ -65,6 +110,12 @@ export type Step2Dialogue = {
streaming?: boolean;
/** special 席位气泡对应的 LangGraph thread,用于沙箱预览。 */
agentThreadId?: string;
/**
* 「多智能体问答」模式下,本席位用 `task` 工具派生的子智能体任务 id 列表(=task 工具
* 调用的 tool_call_id)。渲染时据此从共享 SubtaskContext 取每个 Subtask 渲染任务卡片
* (复用主聊天 `SubtaskCard`,见 Step2Panel.Step2Message)。非 ultra 模式恒为空。
*/
subtaskIds?: string[];
steps?: Array<{
id: string;
key: string;
@@ -222,6 +273,12 @@ export interface UseStep2OrchestrationOptions {
currentStep: number;
/** 全局选中模型(顶栏「推理模型」下拉)。 */
selectedModel: string;
/**
* 席位执行模式(快速/思考/专业问答/多智能体问答)。决定第二步**每个子智能体席位**
* 的推理深度(thinking/reasoning_effort/subagent);总控派活不受影响。由「选择参会
* 智能体」弹窗确认时的 `SeatSelectionResult.seatMode` 决定。缺省 `pro`。
*/
selectedMode: SeatMode;
/**
* 编排模式:
* - `recommend`(默认)—— 总控自由派活循环(`runOrchestration`);
@@ -376,6 +433,7 @@ export function useStep2Orchestration(
const {
currentStep,
selectedModel,
selectedMode,
orchestrationMode,
orchestrationPlan,
dagExecMode = "fast",
@@ -507,6 +565,8 @@ export function useStep2Orchestration(
// runOrchestration 的 useCallback deps 大幅收窄,避免每次模型切换、agentConfigs
// 改写都重建一遍循环函数 —— 之前没收窄时 useEffect 会因 deps 变化重复 init。
const selectedModelRef = useRef<string>(selectedModel);
// 席位执行模式同样打到 ref,让席位调用在编排循环里读到最新值而不重建回调。
const selectedModeRef = useRef<SeatMode>(selectedMode);
const agentConfigsRef = useRef<Record<number, AgentConfig>>(agentConfigs);
const agentNameToRoleRef = useRef(agentNameToRole);
const threadIdsRef = useRef<ThreadIdMap | null>(threadIds);
@@ -543,6 +603,9 @@ export function useStep2Orchestration(
useEffect(() => {
selectedModelRef.current = selectedModel;
}, [selectedModel]);
useEffect(() => {
selectedModeRef.current = selectedMode;
}, [selectedMode]);
useEffect(() => {
agentConfigsRef.current = agentConfigs;
}, [agentConfigs]);
@@ -945,6 +1008,137 @@ export function useStep2Orchestration(
[upsertSeatToolCall],
);
// ── 「多智能体问答」子智能体任务卡片(参照主聊天 message-list 的 task 识别)──────────
// ultra 模式下席位用 `task` 工具派生子智能体。从席位流的 messages-tuple 帧里:
// (1) AI chunk 的 tool_calls 里 name==="task" → 建/更新一条 Subtask(in_progress);
// (2) type==="tool" 且 tool_call_id 命中已知 task id → 据「Task Succeeded./failed./
// timed out」前缀更新为 completed/failed(与主聊天 message-list.tsx 同源解析)。
// Subtask 存进共享 SubtaskContext(主聊天同一份),气泡只记 id 列表并渲染 <SubtaskCard>。
const updateSubtask = useUpdateSubtask();
// 已识别的 task tool_call_id —— 用于在收到 tool 结果帧时只认领「子智能体任务」结果,
// 不误把 write_file 等其它工具的结果当成子任务处理。
const knownTaskIdsRef = useRef<Set<string>>(new Set());
/** 把某个子任务 id 记到对应席位气泡上(新引用 → 触发气泡重渲染,卡片随之刷新状态)。 */
const touchBubbleSubtaskId = useCallback((bubbleId: number, taskId: string) => {
setStep2RoundtableDialogues((prev) =>
prev.map((d) => {
if (d.id !== bubbleId) return d;
const ids = d.subtaskIds ?? [];
// 每次都换新数组引用,确保状态(in_progress→completed/failed)变化时卡片重渲染。
return { ...d, subtaskIds: ids.includes(taskId) ? [...ids] : [...ids, taskId] };
}),
);
}, []);
/** 处理一条消息(values 快照里的整条 message,或 messages-tuple 的 chunk)的子任务语义。 */
const ingestSubagentMessage = useCallback(
(msg: Record<string, unknown>, bubbleId: number) => {
// (1) AI 消息:抓 task 工具调用 → 建/更新 in_progress 子任务。
const toolCalls = msg.tool_calls;
if (Array.isArray(toolCalls)) {
for (const tc of toolCalls) {
if (!tc || typeof tc !== "object") continue;
const t = tc as Record<string, unknown>;
if (t.name !== "task") continue;
const id = typeof t.id === "string" ? t.id : "";
if (!id) continue;
const a = (t.args && typeof t.args === "object" ? t.args : {}) as Record<string, unknown>;
updateSubtask({
id,
status: "in_progress",
subagent_type: typeof a.subagent_type === "string" ? a.subagent_type : "",
description: typeof a.description === "string" ? a.description : "",
prompt: typeof a.prompt === "string" ? a.prompt : "",
});
knownTaskIdsRef.current.add(id);
touchBubbleSubtaskId(bubbleId, id);
}
}
// (2) tool 结果消息:命中已知 task id 时按前缀判完成/失败(与主聊天解析一致)。
const type = typeof msg.type === "string" ? msg.type.toLowerCase() : "";
if (type === "tool") {
const taskId = typeof msg.tool_call_id === "string" ? msg.tool_call_id : "";
if (taskId && knownTaskIdsRef.current.has(taskId)) {
const result = extractFrameText(msg.content);
if (result.startsWith("Task Succeeded. Result:")) {
updateSubtask({
id: taskId,
status: "completed",
result: result.split("Task Succeeded. Result:")[1]?.trim(),
});
} else if (result.startsWith("Task failed.")) {
updateSubtask({
id: taskId,
status: "failed",
error: result.split("Task failed.")[1]?.trim(),
});
} else if (result.startsWith("Task timed out")) {
updateSubtask({ id: taskId, status: "failed", error: result });
}
touchBubbleSubtaskId(bubbleId, taskId);
}
}
},
[updateSubtask, touchBubbleSubtaskId],
);
const ingestSubagentTaskFrame = useCallback(
(frame: unknown, bubbleId: number) => {
// custom 事件帧(ultra 席位子智能体进度):task_running 带子智能体最新消息 → 回填
// latestMessage(卡片实时显示「正在调用 X 工具」);完成/失败即时定状态(比 values 更快)。
const ev = asSubagentTaskEvent(frame);
if (ev) {
knownTaskIdsRef.current.add(ev.task_id);
if (ev.type === "task_running") {
// latestMessage 非空才会触发 SubtaskContext setTasks 重渲染(见 useUpdateSubtask)。
updateSubtask({ id: ev.task_id, status: "in_progress", latestMessage: ev.message as never });
} else if (ev.type === "task_started") {
updateSubtask({
id: ev.task_id,
status: "in_progress",
description: typeof ev.description === "string" ? ev.description : undefined,
});
} else if (ev.type === "task_completed") {
updateSubtask({
id: ev.task_id,
status: "completed",
result: typeof ev.result === "string" ? ev.result : extractFrameText(ev.result),
});
} else if (
ev.type === "task_failed" ||
ev.type === "task_cancelled" ||
ev.type === "task_timed_out"
) {
updateSubtask({
id: ev.task_id,
status: "failed",
error: typeof ev.error === "string" ? ev.error : "子智能体任务失败",
});
}
touchBubbleSubtaskId(bubbleId, ev.task_id);
return;
}
// values 快照帧:{messages:[...]} —— 权威来源,按整条消息列表对账(子任务完成/失败)。
if (frame && typeof frame === "object" && !Array.isArray(frame)) {
const msgs = (frame as Record<string, unknown>).messages;
if (Array.isArray(msgs)) {
for (const m of msgs) {
if (m && typeof m === "object") ingestSubagentMessage(m as Record<string, unknown>, bubbleId);
}
}
return;
}
// messages-tuple 帧:[chunk, metadata] —— 增量,派活时即时建卡。
if (!Array.isArray(frame) || frame.length === 0) return;
const chunk = frame[0];
if (!chunk || typeof chunk !== "object") return;
ingestSubagentMessage(chunk as Record<string, unknown>, bubbleId);
},
[ingestSubagentMessage, updateSubtask, touchBubbleSubtaskId],
);
// ─── Dialogue / Speaker helpers ────────────────────────────────────
const appendDialogue = useCallback(
(card: {
@@ -1406,6 +1600,8 @@ export function useStep2Orchestration(
newMessage: fullTask,
peerDeliveries: priorDeliveries,
model: perSeatModel || selectedModelRef.current || undefined,
// 席位执行模式(快速/思考/专业问答/多智能体问答)→ thinking/reasoning/subagent。
...seatModeToRequestFields(selectedModeRef.current),
signal: subAborter.signal,
onTextDelta: (text) =>
a.isParallel ? ingestParallelDelta(a.bubbleId, text) : appendToDialogue(a.bubbleId, text),
@@ -1415,7 +1611,10 @@ export function useStep2Orchestration(
appendStepIfNew(a.bubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, a.seatThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, a.seatThreadId),
onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, a.seatThreadId);
ingestSubagentTaskFrame(frame, a.bubbleId);
},
});
activeAbortersRef.current.delete(subAborter);
if (isStale()) {
@@ -1690,6 +1889,7 @@ export function useStep2Orchestration(
triggerToast,
handleArtifactToolPhase,
handleUpstreamFrame,
ingestSubagentTaskFrame,
resolveSeatThreadId,
],
);
@@ -1829,6 +2029,8 @@ export function useStep2Orchestration(
displayName: seatName,
newMessage: task,
model: perSeatModel || selectedModelRef.current || undefined,
// 席位执行模式 → thinking/reasoning/subagent(与推荐/DAG 路径一致)。
...seatModeToRequestFields(selectedModeRef.current),
signal: subAborter.signal,
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
@@ -1837,7 +2039,10 @@ export function useStep2Orchestration(
appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, seatThreadId),
onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, seatThreadId);
ingestSubagentTaskFrame(frame, subBubbleId);
},
});
if (isStale()) {
finalizeDialogue(subBubbleId, specRes.content || "(已停止)", "已停止");
@@ -1936,6 +2141,7 @@ export function useStep2Orchestration(
triggerToast,
handleArtifactToolPhase,
handleUpstreamFrame,
ingestSubagentTaskFrame,
resolveSeatThreadId,
],
);
@@ -2163,6 +2369,8 @@ export function useStep2Orchestration(
// 前序各阶段的完整交付 → 供本席位 read_peer_delivery 按需取全文(§12.9)。
peerDeliveries: priorDeliveries,
model: perSeatModel || selectedModelRef.current || undefined,
// 席位执行模式 → thinking/reasoning/subagent(与推荐/链条路径一致)。
...seatModeToRequestFields(selectedModeRef.current),
signal: subAborter.signal,
// §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点
// stage 实际串行,1 路流直接 setState 不卡,沿用 appendToDialogue。
@@ -2174,7 +2382,10 @@ export function useStep2Orchestration(
appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, seatThreadId),
onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, seatThreadId);
ingestSubagentTaskFrame(frame, bubbleId);
},
});
if (isStale()) {
finalizeDialogue(bubbleId, specRes.content || "(已停止)", "已停止");
@@ -2276,6 +2487,7 @@ export function useStep2Orchestration(
triggerToast,
handleArtifactToolPhase,
handleUpstreamFrame,
ingestSubagentTaskFrame,
resolveSeatThreadId,
ingestParallelDelta,
resetParallelStreams,
@@ -10,6 +10,7 @@
*/
import { buildPlanFromStages } from "./dag-types";
import type { SeatMode } from "./seat-mode";
/** 链条里的一个有序席位。`seats[0]` 是第一个发言者。 */
export interface ChainSeat {
@@ -48,6 +49,11 @@ export interface BusinessChain {
* 空 / null = 不注入。
*/
coordinatorPrompt?: string | null;
/**
* 默认席位执行模式(快速/思考/专业问答/多智能体)。选入研讨时弹窗默认用它(仍可临时改)。
* 缺省 = 未设,调用方回退 `DEFAULT_SEAT_MODE`(pro)。
*/
seatMode?: SeatMode;
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
isPublic?: boolean;
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
@@ -85,6 +91,12 @@ export interface SeatSelectionResult {
* 缺省 `fast`。
*/
dagExecMode?: "fast" | "sandbox";
/**
* 席位执行模式(快速/思考/专业问答/多智能体问答)——决定第二步**每个子智能体席位**
* 的推理深度(thinking/reasoning_effort/subagent)。三种编排(recommend/chain/dag)通用。
* 缺省由调用方按 `DEFAULT_SEAT_MODE`(pro)处理。
*/
seatMode?: SeatMode;
}
/** 链条席位数硬约束,与圆桌推荐弹窗的 `MIN_SELECTED`/`MAX_SELECTED` 对齐。 */
@@ -132,12 +144,12 @@ export function buildChainSeatSelection(
const agents = plan.stages
.flatMap((st) => st.agentIds)
.map((id) => ({ agent_id: id, name: nameOf(id) }));
return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "fast" };
return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "fast", seatMode: chain.seatMode };
}
}
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id) }));
return { agents, mode: "chain", chain: chainMeta };
return { agents, mode: "chain", chain: chainMeta, seatMode: chain.seatMode };
}
/**
@@ -9,6 +9,7 @@
*/
import type { OrchestrationPlan } from "./dag-types";
import type { SeatMode } from "./seat-mode";
/** 作业状态机(§2.1)。 */
export type JobStatus =
@@ -120,6 +121,12 @@ export interface StartJobPayload {
/** 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。 */
threadIds?: Record<string, string> | null;
coordinatorName?: string;
/**
* 席位执行模式(快速/思考/专业问答/多智能体)。后台续跑时席位按此模式执行(与前端实时
* 路径一致);缺省由后端按 policy 默认(≈pro)。在 `toStartBody` 里派生成 thinking/
* reasoning/subagent 三字段发给后端。
*/
seatMode?: SeatMode;
}
/** 终态集合——SSE/轮询判定作业是否结束。 */
@@ -0,0 +1,77 @@
/**
* 圆桌第二步「席位执行模式」共享定义。
*
* 与主聊天输入框(`components/workspace/input-box.tsx`)的四档模式同源:
* 用户在「选择参会智能体」弹窗里选一档,第二步派活的**每个子智能体(special 席位)**
* 都按这一档的推理深度执行。模式只决定 `thinking_enabled / reasoning_effort /
* subagent_enabled` 三个运行参数;席位的工具裁剪仍由后端 `roundtable_run_policy`
* 决定(见 `app/gateway/roundtable_run_policy.py`)。
*
* 总控(leader)派活是短决策任务,**不**受本模式影响(沿用其快速派活策略)。
*
* 映射逻辑刻意对齐主聊天 `core/threads/hooks.ts` 的 mode → context 转换,保持一致体验:
* - flash 快速 —— 不思考、最快
* - thinking 思考 —— 思考 + low
* - pro 专业问答 —— 思考 + medium(= 现有席位默认行为,向后兼容)
* - ultra 多智能体 —— 思考 + high + 席位可派子代理(task 工具)
*/
export type SeatMode = "flash" | "thinking" | "pro" | "ultra";
/** 默认档位:专业问答(pro)—— 与改造前席位的固定行为(thinking + medium)一致。 */
export const DEFAULT_SEAT_MODE: SeatMode = "pro";
/** 席位单轮 run 的派生参数(透传给 `/api/multi-agent/run/stream`,覆盖后端 policy 默认)。 */
export interface SeatRunParams {
thinking_enabled: boolean;
reasoning_effort: "minimal" | "low" | "medium" | "high";
subagent_enabled: boolean;
}
/** 把一个席位模式映射成具体的运行参数。 */
export function seatModeToRunParams(mode: SeatMode): SeatRunParams {
switch (mode) {
case "flash":
return { thinking_enabled: false, reasoning_effort: "minimal", subagent_enabled: false };
case "thinking":
return { thinking_enabled: true, reasoning_effort: "low", subagent_enabled: false };
case "ultra":
return { thinking_enabled: true, reasoning_effort: "high", subagent_enabled: true };
case "pro":
default:
return { thinking_enabled: true, reasoning_effort: "medium", subagent_enabled: false };
}
}
/**
* 把席位模式映射成 `streamMultiAgent` 请求字段(camelCase),方便在席位调用处直接 spread。
* 与 `seatModeToRunParams` 同一份逻辑,只是改成 API 客户端期望的字段名。
*/
export function seatModeToRequestFields(mode: SeatMode): {
thinkingEnabled: boolean;
reasoningEffort: SeatRunParams["reasoning_effort"];
subagentEnabled: boolean;
} {
const p = seatModeToRunParams(mode);
return {
thinkingEnabled: p.thinking_enabled,
reasoningEffort: p.reasoning_effort,
subagentEnabled: p.subagent_enabled,
};
}
/** 弹窗模式选择器用的展示元数据(标签 + 一句话说明),顺序即展示顺序。 */
export const SEAT_MODE_OPTIONS: ReadonlyArray<{
value: SeatMode;
label: string;
description: string;
}> = [
{ value: "flash", label: "快速", description: "快速且高效地完成任务,但可能不够精准" },
{ value: "thinking", label: "思考", description: "思考后再行动,在时间与准确性之间取得平衡" },
{ value: "pro", label: "专业问答", description: "思考、计划再执行,获得更精准的结果,可能需要更多时间" },
{
value: "ultra",
label: "多智能体问答",
description: "在专业问答基础上,每个席位可调用子代理分工协作,能力最强、更慢更耗时",
},
];
@@ -85,6 +85,11 @@ import {
type BusinessChain,
type ChainSeat,
} from "@/roundtable-planning/lib/business-chain";
import {
DEFAULT_SEAT_MODE,
SEAT_MODE_OPTIONS,
type SeatMode,
} from "@/roundtable-planning/lib/seat-mode";
import {
addEmptyStage,
addSeatAsNewStage,
@@ -153,6 +158,8 @@ export default function BusinessChainEditorPage() {
const [description, setDescription] = useState("");
// 写给总控的「编排提示」:告诉总控该怎么派活、做什么(仅分层 DAG 模式注入)。
const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
// 默认席位执行模式(快速/思考/专业问答/多智能体)—— 选入研讨时弹窗默认用它。
const [seatMode, setSeatMode] = useState<SeatMode>(DEFAULT_SEAT_MODE);
// 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。
const [stageGroups, setStageGroups] = useState<StageGroups>([]);
const [saving, setSaving] = useState(false);
@@ -277,6 +284,7 @@ export default function BusinessChainEditorPage() {
setTitle(chain.title);
setDescription(chain.description ?? "");
setCoordinatorPrompt(chain.coordinatorPrompt ?? "");
setSeatMode(chain.seatMode ?? DEFAULT_SEAT_MODE);
setStageGroups(chainToStageGroups(chain));
setStep(1);
}, []);
@@ -288,6 +296,7 @@ export default function BusinessChainEditorPage() {
setTitle("");
setDescription("");
setCoordinatorPrompt("");
setSeatMode(DEFAULT_SEAT_MODE);
setStageGroups([]);
setStep(1);
}, []);
@@ -560,6 +569,7 @@ export default function BusinessChainEditorPage() {
stages,
stageGoals,
coordinatorPrompt: coordPrompt,
seatMode,
});
if (!updated) {
toast.error("链条已不存在,可能被删除");
@@ -578,6 +588,7 @@ export default function BusinessChainEditorPage() {
stages,
stageGoals,
coordinatorPrompt: coordPrompt,
seatMode,
});
setCurrentChainId(created.id);
toast.success("已创建业务链条");
@@ -592,6 +603,7 @@ export default function BusinessChainEditorPage() {
title,
description,
coordinatorPrompt,
seatMode,
stageGroups,
seatCountInvalid,
currentChainId,
@@ -699,6 +711,37 @@ export default function BusinessChainEditorPage() {
/>
</div>
{/* 默认执行模式:选入研讨时弹窗默认用它(仍可临时改)。 */}
<div className="space-y-2">
<label className="flex items-center gap-1 text-xs font-semibold text-foreground">
默认执行模式
<span className="text-[10px] font-normal text-muted-foreground">
(选入研讨时默认用它,进研讨前仍可临时切换)
</span>
</label>
<div className="inline-flex flex-wrap gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5 text-xs font-medium">
{SEAT_MODE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setSeatMode(opt.value)}
title={opt.description}
className={cn(
"rounded-md px-2.5 py-1 transition-colors",
seatMode === opt.value
? "bg-card text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
))}
</div>
<p className="text-[11px] leading-relaxed text-muted-foreground">
{SEAT_MODE_OPTIONS.find((o) => o.value === seatMode)?.description}
</p>
</div>
{/* 总控提示词:label + AI 优化(配色沿用现有 primary)+ 大号等宽编辑区 */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
@@ -100,6 +100,7 @@ import {
buildChainSeatSelection,
type SeatSelectionResult,
} from "@/roundtable-planning/lib/business-chain";
import { DEFAULT_SEAT_MODE, type SeatMode } from "@/roundtable-planning/lib/seat-mode";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import {
@@ -108,6 +109,7 @@ import {
startJob,
startJobKeepalive,
} from "@/roundtable-planning/api/roundtable-jobs";
import { useRoundtableJobNotifier } from "@/roundtable-planning/components/RoundtableJobNotifier";
import { getChain } from "@/roundtable-planning/api/chains";
import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend";
import { listAgents } from "@/core/agents";
@@ -169,6 +171,8 @@ function readEmbedHashQueryPrefill(): RoundtablePrefill | null {
export default function RoundtablePlanningPage() {
const { theme } = useTheme();
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
const { watchJob } = useRoundtableJobNotifier();
const isDark = theme === "dark";
const navigate = useNavigate();
const location = useLocation();
@@ -222,6 +226,9 @@ export default function RoundtablePlanningPage() {
// 「导入会商」弹窗(输入分享码把别人分享的会商复制到本账号)。
const [isImportOpen, setIsImportOpen] = useState<boolean>(false);
const [selectedModel, setSelectedModel] = useState<string>("");
// Step 2 席位执行模式(快速/思考/专业问答/多智能体问答)——决定每个子智能体席位的
// 推理深度。由「选择参会智能体」弹窗确认时的 SeatSelectionResult.seatMode 决定。
const [seatMode, setSeatMode] = useState<SeatMode>(DEFAULT_SEAT_MODE);
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活;
// dag = 分层 DAG(stage 内并行、stage 间串行,配合 orchestrationPlan)。
// 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。
@@ -337,6 +344,7 @@ export default function RoundtablePlanningPage() {
const step2 = useStep2Orchestration({
currentStep,
selectedModel,
selectedMode: seatMode,
orchestrationMode,
orchestrationPlan,
dagExecMode,
@@ -750,6 +758,8 @@ export default function RoundtablePlanningPage() {
// result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活;
// dag = 分层并行(来源链条带 stages,携带 result.plan)。
setOrchestrationMode(result.mode);
// 席位执行模式:弹窗未带(旧调用)时回退默认 pro,保证行为可预期。
setSeatMode(result.seatMode ?? DEFAULT_SEAT_MODE);
setChainMeta(result.mode === "recommend" ? null : result.chain ?? null);
setOrchestrationPlan(result.mode === "dag" ? result.plan ?? null : null);
setDagExecMode(result.mode === "dag" ? result.dagExecMode ?? "fast" : "fast");
@@ -1133,12 +1143,34 @@ export default function RoundtablePlanningPage() {
});
}, [resetToStep1]);
// 「挂起中」本地标记:点「后台挂起」后立刻跳转,建作业是 fire-and-forget——从点击到作业
// 真正建好的窗口里,用它让历史记录显示「挂起中」(作业建好后转 queued/running,由后端状态接管)。
// resetToStep1 不卸载本页,故该 state 跨「跳转」存活。
const [pendingSuspendIds, setPendingSuspendIds] = useState<Set<string>>(() => new Set());
const markPendingSuspend = useCallback((draftId: string) => {
setPendingSuspendIds((prev) => {
const next = new Set(prev);
next.add(draftId);
return next;
});
}, []);
const clearPendingSuspend = useCallback((draftId: string) => {
setPendingSuspendIds((prev) => {
if (!prev.has(draftId)) return prev;
const next = new Set(prev);
next.delete(draftId);
return next;
});
}, []);
// 「后台挂起」核心(手动按钮 / 离开页面自动挂起共用):幂等保证草稿存在 →
// 同草稿已有在跑作业则跳过 → 启动后台作业。后端另有「同草稿未终态作业幂等返回」
// 兜底(覆盖跨标签页 / beforeunload 与本函数并发触发),绝不重复建作业。
const startBackgroundJob = useCallback(async (): Promise<boolean> => {
// 返回本次后台作业的 jobId(已在跑则返回既有作业 id),供「完成时提示」监听;
// 未发起(无席位)返回 null。
const startBackgroundJob = useCallback(async (): Promise<string | null> => {
const agents = step2.selectedAgents.map((a) => ({ agentId: a.agent_id, name: a.name }));
if (agents.length === 0) return false;
if (agents.length === 0) return null;
// 后台挂起发起前**立刻强制存一次**当前聊天(保住发起时刻的本地研讨记录,防丢)。
const draftId = await drafts.saveDraftNow(2);
// 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。
@@ -1148,52 +1180,118 @@ export default function RoundtablePlanningPage() {
(existing.status === "running" ||
existing.status === "awaiting_input" ||
existing.status === "queued");
if (!alreadyRunning) {
await startJob({
draftId: draftId ?? undefined,
intent: buildInitialIntent(),
agents,
model: selectedModel || undefined, // 模型沿用第二步选择
// 后台作业引擎支持 dag(后端 _run_dag:stage 串行 / stage 内并行)——
// 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。
orchestrationMode,
chain: chainMeta,
// dag 模式:把整份分层计划带给后端(含 stages / 阶段目标 / 总控提示词 / finalSynthesis)。
// 非 dag 时为 null(后端按 mode 走 chain/recommend)。
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
// 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程
// (那些线程可能有活跃 run,复用会撞 409)。
});
if (alreadyRunning) {
return existing?.id ?? null;
}
return true;
const job = await startJob({
draftId: draftId ?? undefined,
intent: buildInitialIntent(),
agents,
model: selectedModel || undefined, // 模型沿用第二步选择
seatMode, // 席位执行模式沿用第二步选择(后台续跑同样按此模式跑席位)
// 后台作业引擎支持 dag(后端 _run_dag:stage 串行 / stage 内并行)——
// 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。
orchestrationMode,
chain: chainMeta,
// dag 模式:把整份分层计划带给后端(含 stages / 阶段目标 / 总控提示词 / finalSynthesis)。
// 非 dag 时为 null(后端按 mode 走 chain/recommend)。
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
// 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程
// (那些线程可能有活跃 run,复用会撞 409)。
});
return job.id;
}, [
step2,
drafts,
selectedModel,
seatMode,
orchestrationMode,
orchestrationPlan,
chainMeta,
buildInitialIntent,
]);
// 「后台挂起」按钮:把当前研讨交给后端自动跑完 Step2 研讨 + Step3(总结报告 + 结果绘制)。
// 挂起后页面像「新建任务」一样重置回第一步(后台作业继续在历史记录里跑)。
const handleBackgroundSuspend = useCallback(async () => {
if (step2.selectedAgents.length === 0) {
// 「后台挂起」按钮(2026-06-16 改「直接跳转」):点击后**立刻**跳回第一步并提示,挂起动作
// (存稿 → 去重 → 建后台作业)改为 fire-and-forget 在后台完成,不再等接口返回才跳转。
// 历史记录在作业真正建好前显示「挂起中」(pendingSuspendIds),建好后由后端状态(queued→
// running)接管。⚠️ resetToStep1 会清空 step2 等 live 状态,故挂起入参 + step2 快照必须在
// 跳转前同步冻结(getStep2SnapshotWithRuns 返回的是不可变快照对象,reset 替换 state 不影响它)。
const handleBackgroundSuspend = useCallback(() => {
const selected = step2.selectedAgents;
if (selected.length === 0) {
triggerToast("⚠️ 请先选择研讨席位再后台挂起");
return;
}
try {
await startBackgroundJob();
} catch (err) {
triggerToast(`❌ 后台挂起失败:${(err as Error).message}`);
return;
}
// 重置回第一步(同新建任务);后台作业在历史记录里继续跑、可点开看进度。
// —— 跳转前同步冻结所有需要的输入(reset 之后这些 live 状态都会被清空)——
const step2Snapshot = getStep2SnapshotWithRuns();
const agents = selected.map((a) => ({ agentId: a.agent_id, name: a.name }));
const intent = buildInitialIntent();
const model = selectedModel || undefined;
const seatModeSnap = seatMode;
const mode = orchestrationMode;
const chainSnap = chainMeta;
const planSnap = mode === "dag" ? orchestrationPlan : null;
const draftId = drafts.currentDraftId;
// 立刻:标记「挂起中」+ 跳回第一步 + 提示(不等接口)。
if (draftId) markPendingSuspend(draftId);
resetToStep1();
triggerToast("🚀 已转入后台,任务会自动跑完研讨、总结报告与结果绘制;可在「历史记录」查看进度");
void drafts.refresh();
}, [step2, drafts, startBackgroundJob, resetToStep1, triggerToast]);
// 后台 fire-and-forget:用冻结快照存稿 → 去重 → 建作业 → 注册完成通知。失败只提示、不挡跳转。
void (async () => {
let watchId: string | null = null;
try {
const savedId = (await drafts.saveStep2SnapshotNow(step2Snapshot, 2)) ?? draftId ?? null;
// 防重复:该草稿已有未终态作业就不再新建(同 startBackgroundJob 的客户端去重,
// 后端另有「同草稿未终态作业幂等返回」兜底)。
const existing = savedId ? await getJobByDraft(savedId).catch(() => null) : null;
const alreadyRunning =
existing &&
(existing.status === "running" ||
existing.status === "awaiting_input" ||
existing.status === "queued");
if (alreadyRunning) {
watchId = existing?.id ?? null;
} else {
const job = await startJob({
draftId: savedId ?? undefined,
intent,
agents,
model,
seatMode: seatModeSnap,
orchestrationMode: mode,
chain: chainSnap,
orchestrationPlan: planSnap,
});
watchId = job.id;
}
} catch (err) {
triggerToast(`❌ 后台挂起失败:${(err as Error).message}`);
} finally {
// 注册到全工作区通知器,作业完成时**跨页**弹「达成共识同款」提示。
if (watchId) watchJob(watchId);
// 清「挂起中」本地标记:此后历史记录的状态由后端作业(queued→running)权威接管。
if (draftId) clearPendingSuspend(draftId);
void drafts.refresh();
}
})();
}, [
step2,
drafts,
getStep2SnapshotWithRuns,
buildInitialIntent,
selectedModel,
seatMode,
orchestrationMode,
orchestrationPlan,
chainMeta,
resetToStep1,
triggerToast,
watchJob,
markPendingSuspend,
clearPendingSuspend,
]);
// ── 自动后台挂起:研讨进行中离开本页面(路由卸载)或刷新 / 关标签页时,任务自动转后台 ──
// 最新状态进 ref:卸载 cleanup 与 beforeunload 都是「拍快照时刻」的同步回调,
@@ -1223,6 +1321,7 @@ export default function RoundtablePlanningPage() {
intent: buildInitialIntent(),
agents,
model: selectedModel || undefined,
seatMode,
orchestrationMode,
chain: chainMeta,
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
@@ -1347,7 +1446,7 @@ export default function RoundtablePlanningPage() {
)}
{/* HEADER */}
<header className="relative z-[2000] flex shrink-0 items-center justify-between gap-4 border-b border-slate-100 bg-white px-6 py-3 shadow-[0_1px_3px_rgba(0,0,0,0.02)] dark:border-border dark:bg-card dark:shadow-none">
<header className="relative z-[40] flex shrink-0 items-center justify-between gap-4 border-b border-slate-100 bg-white px-6 py-3 shadow-[0_1px_3px_rgba(0,0,0,0.02)] dark:border-border dark:bg-card dark:shadow-none">
<StepNavigation
currentStep={currentStep}
intentReady={step1.intentReady}
@@ -1408,6 +1507,7 @@ export default function RoundtablePlanningPage() {
setIsOpen={drafts.setIsHistoryOpen}
drafts={drafts.drafts}
currentDraftId={drafts.currentDraftId}
pendingSuspendIds={pendingSuspendIds}
renamingDraftId={drafts.renamingDraftId}
renameDraftValue={drafts.renameDraftValue}
setRenameDraftValue={drafts.setRenameDraftValue}
@@ -1733,6 +1833,8 @@ interface HistoryDropdownProps {
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
drafts: ReturnType<typeof useDraftPersistence>["drafts"];
currentDraftId: string | null;
/** 正在「挂起中」(已点后台挂起、作业尚未建好)的草稿 id 集合 —— 历史记录显示「挂起中」。 */
pendingSuspendIds: Set<string>;
renamingDraftId: string | null;
renameDraftValue: string;
setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>;
@@ -1755,6 +1857,7 @@ function HistoryDropdown(props: HistoryDropdownProps) {
setIsOpen,
drafts,
currentDraftId,
pendingSuspendIds,
renamingDraftId,
renameDraftValue,
setRenameDraftValue,
@@ -1825,6 +1928,8 @@ function HistoryDropdown(props: HistoryDropdownProps) {
{drafts.map((d) => {
const isCurrent = d.id === currentDraftId;
const isRenaming = d.id === renamingDraftId;
// 「挂起中」:已点后台挂起、作业还没建好(本地标记),或作业刚建好仍排队(queued)。
const isSuspending = pendingSuspendIds.has(d.id) || d.status === "queued";
// 「进行中 / 待澄清」且有 jobId → 点击打开流程图弹窗,而非加载草稿。
const isActiveJob =
(d.status === "running" || d.status === "awaiting_input") && !!d.jobId;
@@ -1862,7 +1967,12 @@ function HistoryDropdown(props: HistoryDropdownProps) {
{d.name}
</span>
<span className="text-[10px] text-slate-400 mt-0.5 flex items-center gap-1 font-mono">
{d.status === "running" ? (
{isSuspending ? (
<span className="inline-flex items-center gap-1 text-sky-500 font-semibold">
<Loader2 className="w-3 h-3 animate-spin" />
挂起中
</span>
) : d.status === "running" ? (
<span className="inline-flex items-center gap-1 text-primary font-semibold">
<Loader2 className="w-3 h-3 animate-spin" />
进行中
+13 -12
View File
@@ -8,7 +8,10 @@ import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { getStoredAuth } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie";
import { useUnreadCount } from "@/core/notifications";
import type { MenuItem } from "@/core/page-layout/sidebar-menu";
import {
getActiveThirdLevelItems,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
import { cn } from "@/lib/utils";
@@ -145,7 +148,7 @@ const Header = ({ collapsed }: HeaderProps) => {
const showLightTopNav = !isDark && !isWujieEnv();
const hasActiveChild = (item: MenuItem): boolean => {
if (item.path && pathname === item.path) {
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true;
}
@@ -170,9 +173,7 @@ const Header = ({ collapsed }: HeaderProps) => {
return null;
};
const activeTopItem = visibleMenuItems.find(hasActiveChild);
const activeSecondItem = activeTopItem?.children?.find(hasActiveChild);
const thirdSubNavItems = activeSecondItem?.children ?? [];
const thirdSubNavItems = getActiveThirdLevelItems(visibleMenuItems, pathname);
const leftMenuItems = visibleMenuItems.filter((i) => LEFT_MENU_IDS.includes(i.id));
const rightMenuItems = visibleMenuItems.filter((i) => RIGHT_MENU_IDS.includes(i.id));
@@ -185,7 +186,7 @@ const Header = ({ collapsed }: HeaderProps) => {
};
const renderTopItem = (item: MenuItem) => {
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
const isSelected = item.path ? (pathname === item.path || pathname.startsWith(item.path + "/")) : hasActiveChild(item);
return (
<button
key={item.id}
@@ -199,7 +200,7 @@ const Header = ({ collapsed }: HeaderProps) => {
};
const renderLightTopItem = (item: MenuItem) => {
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
const isSelected = item.path ? (pathname === item.path || pathname.startsWith(item.path + "/")) : hasActiveChild(item);
return (
<button
key={item.id}
@@ -294,7 +295,7 @@ const Header = ({ collapsed }: HeaderProps) => {
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<header className="strategy-app-header theme-transition relative z-[2101] flex h-16 items-center justify-between px-4">
<header className="strategy-app-header theme-transition relative z-[45] flex h-16 items-center justify-between px-4">
{showTopNav ? (
<>
{/* 左分区(flex-1):菜单靠右贴近中间基座 */}
@@ -310,7 +311,7 @@ const Header = ({ collapsed }: HeaderProps) => {
</div>
{/* 中:CM助手(落在背景图发光基座上,纯文字无图标) */}
<div className="header-center-title">CM助手</div>
<div className="header-center-title">CM业务中心</div>
{/* 右分区(flex-1):菜单靠左贴近中间基座,操作区靠最右 */}
<div className="flex h-full min-w-0 flex-1 items-center justify-between">
@@ -336,7 +337,7 @@ const Header = ({ collapsed }: HeaderProps) => {
<FileText className="h-4 w-4 text-white" />
</div>
<span className="whitespace-nowrap text-base font-semibold text-white">
CM助手
CM业务中心
</span>
</div>
<nav className="light-topnav" aria-label="一级菜单">
@@ -363,7 +364,7 @@ const Header = ({ collapsed }: HeaderProps) => {
</div>
{!collapsed && (
<span className="text-sm font-semibold text-white">
CM助手
CM业务中心
</span>
)}
</div>
@@ -379,7 +380,7 @@ const Header = ({ collapsed }: HeaderProps) => {
<nav className="third-submenu-bar" aria-label="三级菜单">
<div className="third-submenu-list">
{thirdSubNavItems.map((item) => {
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
const isSelected = item.path ? (pathname === item.path || pathname.startsWith(item.path + "/")) : hasActiveChild(item);
return (
<button
+4 -1
View File
@@ -950,7 +950,10 @@ html.dark .strategy-app-header {
display: flex;
align-items: center;
justify-content: center;
padding: 0 155px;
/* 左右内边距随视口宽度等比缩放,与背景图基座的 background-size:cover 同步,
使两侧菜单在 1080p / 2K / 4K 下都贴齐发光基座边缘。
基准:1920 宽时 155px → 155/1920×100 ≈ 8.07vw。 */
padding: 0 8.07vw;
/* 文字略微上移,贴合基座视觉中心 */
transform: translateY(-6px);
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese",