# Conflicts:
#	frontend-web/src/components/workspace/chats/chat-box.tsx
#	offline-backend-20260512/backend/app/gateway/routers/agents.py
This commit is contained in:
吉鑫升 committed 2026-05-28 17:29:36 +08:00
commit 6792b4f4c4
200 files changed
+78520 -392

No files matched your search

@@ -0,0 +1,24 @@
.sidebar-contents{
width:98px;
height:986px;
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);
}
.item-nav{
width:78px;
height:70px;
background:rgba(0, 12, 28, 0.15);
border:1px solid;
border-color:rgba(85, 166, 254, 0.2);
line-height:8px;
font-family:Noto Sans S Chinese;
font-weight:500;
color:rgba(255, 255, 255, 0.8);
font-size:20px;
padding-bottom:18px;
text-align:center;
}
@@ -0,0 +1,311 @@
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,
Network,
NotebookPen,
Plus,
Settings,
Sparkles,
Target,
User,
Users,
Wrench,
} 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 './page-sdebar.css'
type MenuItem = {
id: string;
label: string;
icon: ElementType;
path?: string;
adminOnly?: boolean;
children?: MenuItem[];
};
type PageSidebarDarkProps = {
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: "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 },
{ id: "coh-task-fx", label: "任务FX", icon: ListTodo },
{ id: "coh-task-xdfx", label: "XDFX", icon: ListTodo },
],
},
{
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 },
{ id: "resource-knowledge-base", label: "知识库资源管理", icon: Library },
{ id: "resource-zz", label: "ZZ资源管理", icon: FileBox },
],
},
];
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";
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = filterMenuItemsByRole(menuItems, !!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);
const navigate = useNavigate();
const { pathname } = useLocation();
useEffect(() => {
setMounted(true);
}, []);
const openSettings = (section: SettingsSection) => {
setSettingsDefaultSection(section);
setSettingsOpen(true);
};
const toggleExpanded = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
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">
问答管理
</div>
</div>
</div>
</>
);
}
@@ -42,6 +42,7 @@ import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-di
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import {
useAgent,
useDeleteAgent,
useSetAgentFeatured,
useSetAgentPrivate,
@@ -81,6 +82,13 @@ export function AgentCard({ agent }: AgentCardProps) {
);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
// The /api/agents list response intentionally omits `soul` (perf optimization
// — SOUL.md is only read on demand). Fetch the detail row when the edit
// dialog opens so the SOUL textarea can be hydrated.
const { agent: agentDetail, isLoading: isAgentDetailLoading } = useAgent(
editOpen ? agent.id : null,
);
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
@@ -105,7 +113,6 @@ export function AgentCard({ agent }: AgentCardProps) {
}
setEditName(agent.name);
setEditDescription(agent.description ?? "");
setEditSoul(agent.soul ?? "");
setEditPublished(agent.published);
setEditIsPrivate(agent.is_private);
setSelectedSkills(agent.skills ?? []);
@@ -115,10 +122,23 @@ export function AgentCard({ agent }: AgentCardProps) {
agent.name,
agent.published,
agent.skills,
agent.soul,
editOpen,
]);
useEffect(() => {
if (!editOpen) {
return;
}
// SOUL.md only ships on the detail endpoint; fall back to whatever the list
// happened to carry so older deployments (or cached objects that still
// include `soul`) keep working.
if (agentDetail) {
setEditSoul(agentDetail.soul ?? "");
} else {
setEditSoul(agent.soul ?? "");
}
}, [agent.soul, agentDetail, editOpen]);
function handleChat() {
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
}
@@ -563,7 +583,11 @@ export function AgentCard({ agent }: AgentCardProps) {
<Textarea
value={editSoul ?? ""}
onChange={(e) => setEditSoul(e.target.value)}
placeholder="输入 SOUL.md 内容"
placeholder={
isAgentDetailLoading && !agentDetail
? "正在加载 SOUL.md..."
: "输入 SOUL.md 内容"
}
className="min-h-40 font-mono text-xs"
/>
</div>
@@ -32,7 +32,10 @@ interface ArtifactsProviderProps {
}
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifacts] = useState<string[]>([]);
const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => {
setArtifactsState(Array.isArray(next) ? next : []);
}, []);
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [open, setOpen] = useState(
@@ -163,7 +163,8 @@ export function RecentChatList() {
const visibleThreads = threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "bootstrap",
thread.metadata?.thread_type !== "bootstrap" &&
thread.metadata?.thread_type !== "roundtable",
);
if (visibleThreads.length === 0) {
+1
View File
@@ -34,6 +34,7 @@ export default function ChatsPage() {
return threads?.filter((thread) => {
return (
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable" &&
titleOfThread(thread).toLowerCase().includes(search.toLowerCase())
);
});
+15 -4
View File
@@ -2,7 +2,9 @@ import { useMemo, useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { PageSidebar } from "@/components/page-sidebar";
import { PageSidebarDark } from "@/components/page-sidebar-dark";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import { useTheme } from "@/contexts/ThemeContext";
import WorkspaceRoutes from "./WorkspaceRoutes";
import StrategyRoutes from "./StrategyRoutes";
@@ -13,16 +15,25 @@ import RoundtableRoutes from "./RoundtableRoutes";
function PageLayout() {
const [collapsed, setCollapsed] = useState(false);
const sidebarEnabled = useMemo(() => readSidebarEnabled(), []);
const { theme } = useTheme();
const isDark = theme === "dark";
return (
<div className="h-screen flex flex-col bg-muted/20">
{sidebarEnabled && <Header collapsed={collapsed} />}
<div className="flex flex-1 overflow-hidden">
{sidebarEnabled && (
<PageSidebar
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
/>
isDark ? (
<PageSidebarDark
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
/>
) : (
<PageSidebar
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
/>
)
)}
<main
className={`flex-1 overflow-hidden transition-[padding] duration-300 ${
+156 -135
View File
@@ -28,151 +28,172 @@ import TagsPage from "./TagsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage";
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
import TaskManagement from "@/strategy-components/components/task-components/TaskManagement";
export default function StrategyRoutes() {
return (
<RouteBaseProvider value="/page/strategy">
<SidebarProvider>
<div className="h-full w-full overflow-hidden">
<Routes>
<Route index element={<Navigate to="qa/general" replace />} />
<SidebarProvider>
<div className="h-full w-full overflow-hidden">
<Routes>
<Route index element={<Navigate to="qa/general" replace />} />
{/* 问答中心 */}
<Route path="qa/general" element={<GeneralQA />} />
{/* 问答中心 */}
<Route path="qa/general" element={<GeneralQA />} />
{/* 任务管理 */}
<Route path="task-management" element={<TaskManagement />}>
{/* XK任务 */}
<Route
path="xk-task-jc"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageHome/pageSwrw'}
/>
}
/>
<Route
path="xk-task-dispatch"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageJczl'}
/>
}
/>
</Route>
{/* 轻应用 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
{/* 轻应用 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
{/* 对话 */}
<Route path="chats" element={<ChatsPage />} />
<Route
path="chats/:thread_id"
element={
<ChatRuntime>
<ChatPage />
</ChatRuntime>
}
/>
{/* 对话 */}
<Route path="chats" element={<ChatsPage />} />
<Route
path="chats/:thread_id"
element={
<ChatRuntime>
<ChatPage />
</ChatRuntime>
}
/>
{/* 智能体管理 */}
<Route path="agents" element={<AgentsPage />} />
<Route path="agents/new" element={<NewAgentPage />} />
<Route
path="agents/:agent_id/chats"
element={<Navigate to="/page/strategy/agents" replace />}
/>
<Route
path="agents/:agent_id/chats/:thread_id"
element={
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
}
/>
{/* 智能体管理 */}
<Route path="agents" element={<AgentsPage />} />
<Route path="agents/new" element={<NewAgentPage />} />
<Route
path="agents/:agent_id/chats"
element={<Navigate to="/page/strategy/agents" replace />}
/>
<Route
path="agents/:agent_id/chats/:thread_id"
element={
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
}
/>
{/* 技能 / 记忆 */}
<Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
{/* 技能 / 记忆 */}
<Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
{/* 定时任务 */}
<Route
path="scheduled"
element={<Navigate to="/page/strategy/scheduled/tasks" replace />}
/>
<Route path="scheduled/tasks" element={<ScheduledTasksPage />} />
<Route
path="scheduled/runs/:run_id"
element={<ScheduledTaskRunDetailPage />}
/>
{/* 定时任务 */}
<Route
path="scheduled"
element={<Navigate to="/page/strategy/scheduled/tasks" replace />}
/>
<Route path="scheduled/tasks" element={<ScheduledTasksPage />} />
<Route
path="scheduled/runs/:run_id"
element={<ScheduledTaskRunDetailPage />}
/>
{/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */}
<Route
path="settings/recommended-questions"
element={
getStoredAuth()?.system_role === "admin" ? (
<RecommendedQuestionsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/prompt-prefix"
element={
getStoredAuth()?.system_role === "admin" ? (
<PromptPrefixSettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/private-square"
element={
getStoredAuth()?.system_role === "admin" ? (
<PrivateSquareSettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/llm-metrics"
element={
getStoredAuth()?.system_role === "admin" ? (
<LlmMetricsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/users"
element={
getStoredAuth()?.system_role === "admin" ? (
<UsersPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/scheduled-tasks"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminScheduledTasksPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/user-activity"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminUserActivityPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === "admin" ? (
<TagsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes>
</div>
</SidebarProvider>
<Toaster position="top-center" />
{/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */}
<Route
path="settings/recommended-questions"
element={
getStoredAuth()?.system_role === "admin" ? (
<RecommendedQuestionsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/prompt-prefix"
element={
getStoredAuth()?.system_role === "admin" ? (
<PromptPrefixSettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/private-square"
element={
getStoredAuth()?.system_role === "admin" ? (
<PrivateSquareSettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/llm-metrics"
element={
getStoredAuth()?.system_role === "admin" ? (
<LlmMetricsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/users"
element={
getStoredAuth()?.system_role === "admin" ? (
<UsersPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/scheduled-tasks"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminScheduledTasksPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/user-activity"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminUserActivityPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === "admin" ? (
<TagsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes>
</div>
</SidebarProvider>
<Toaster position="top-center" />
</RouteBaseProvider>
);
}
@@ -27,7 +27,7 @@ export interface StreamRecommendResult {
* `done` → picks parsed and validated, ready to populate the dialog as
* default-checked selections.
* `asking` → recommender failed to emit a usable [RECOMMEND_READY] block,
* caller should pick from the live candidate pool as fallback.
* caller should apply its fallback list (typically the 6 roundtable-* seats).
* `error` → upstream / network failure.
*/
status: "done" | "asking" | "error";
@@ -202,6 +202,8 @@ export interface ReasoningBlockProps {
/** 用于显示「思考用时」的可选时长(秒)。流式期间不显示。 */
durationSec?: number;
className?: string;
/** 展开内容区额外 class,例如 max-h + overflow-y-auto。 */
contentClassName?: string;
}
const REASONING_AUTO_CLOSE_DELAY_MS = 1000;
@@ -211,6 +213,7 @@ export function ReasoningBlock({
streaming = false,
durationSec,
className,
contentClassName,
}: ReasoningBlockProps) {
// open 初始值跟 streaming 同步:流式中默认展开方便观察思考过程,完成后会自动收起一次。
const [open, setOpen] = useState<boolean>(streaming);
@@ -260,7 +263,12 @@ export function ReasoningBlock({
/>
</button>
{open && (
<div className="rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-[11.5px] text-slate-600 leading-relaxed">
<div
className={cn(
"rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-[11.5px] text-slate-600 leading-relaxed",
contentClassName,
)}
>
<MarkdownContent>{children}</MarkdownContent>
</div>
)}
@@ -46,6 +46,8 @@ export interface MessageStepsCardProps {
className?: string;
/** 点击 write_file / present_files 等产物相关步骤时打开沙箱。 */
onArtifactStepClick?: (step: BubbleStep) => void;
/** 把 agent_orchestration 派活的 agent id 解析成中文席位名(用于 chip 显示)。 */
resolveAgentName?: (idOrName: string) => string;
}
/** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */
@@ -58,6 +60,7 @@ export function MessageStepsCard({
streaming,
className,
onArtifactStepClick,
resolveAgentName,
}: MessageStepsCardProps) {
// 把步骤拆成「工具调用」与「思考过程」两类 —— 与主聊天 MessageGroup 的 `lastToolCallStep` /
// `lastReasoningStep` 划分对齐。
@@ -131,12 +134,14 @@ export function MessageStepsCard({
step={s}
status="complete"
onArtifactStepClick={onArtifactStepClick}
resolveAgentName={resolveAgentName}
/>
))}
<ToolStep
step={lastToolStep}
status={streaming && trailingReasoning.length === 0 ? "active" : "complete"}
onArtifactStepClick={onArtifactStepClick}
resolveAgentName={resolveAgentName}
/>
</ChainOfThoughtContent>
)}
@@ -155,18 +160,23 @@ function ToolStep({
step,
status,
onArtifactStepClick,
resolveAgentName,
}: {
step: BubbleStep;
status: "complete" | "active";
onArtifactStepClick?: (step: BubbleStep) => void;
resolveAgentName?: (idOrName: string) => string;
}) {
const display = getStepDisplay(step.key, step.label, step.args);
const argNode = renderStepArgs(step.key, step.args);
const argNode = renderStepArgs(step.key, step.args, resolveAgentName);
const [, toolName] = step.key.split(":");
// read_file 也可点 —— 验证类「查看文件…」步骤常出现在最后一条,点击后在沙箱里
// 查看该文件内容(走虚拟预览或 outputs HTTP)。主聊天只让 write 类可点,这里增强。
const isArtifactStep =
toolName === "write_file" ||
toolName === "str_replace" ||
toolName === "present_files";
toolName === "present_files" ||
toolName === "read_file";
return (
<ChainOfThoughtStep
@@ -62,6 +62,20 @@ export interface RecommendAgentsDialogProps {
const MIN_SELECTED = 2;
const MAX_SELECTED = 8;
/**
* Default selection when the recommender fails or returns < 2 valid picks.
* Intersected with the live candidate pool at runtime so missing built-ins
* don't break the dialog.
*/
const FALLBACK_AGENT_IDS = [
"roundtable-intelligence",
"roundtable-environment",
"roundtable-solution-design",
"roundtable-risk-review",
"roundtable-execution-plan",
"roundtable-summary",
];
type DialogPhase =
| "idle"
| "loading_agents"
@@ -305,17 +319,12 @@ export function RecommendAgentsDialog({
return;
}
// Fallback: status === "asking" or picks too few — pick from live pool.
const seed = candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
if (seed.length < MIN_SELECTED) {
setState((prev) => ({
...prev,
phase: "error",
streamPhase: null,
errorMessage: `候选智能体不足 ${MIN_SELECTED} 个,无法进入圆桌研讨。请先发布更多智能体。`,
}));
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) => ({
@@ -325,7 +334,7 @@ export function RecommendAgentsDialog({
recommendedIds: seed,
reasons,
streamPhase: null,
warning: "智能推荐未生成有效结果,已为你勾选候选池中的前几位智能体,可手动调整。",
warning: "智能推荐未生成有效结果,已为你勾选默认的圆桌席位,可手动调整。",
}));
} catch (err) {
if (myGen !== generationRef.current) return;
@@ -421,8 +430,8 @@ export function RecommendAgentsDialog({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col overflow-hidden gap-3">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<SparklesIcon className="size-5 text-purple-500" />
选择参会智能体
@@ -457,7 +466,7 @@ export function RecommendAgentsDialog({
<CheckIcon className="size-3 text-emerald-500" />
推荐参会智能体
</div>
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1 space-y-4">
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar pr-1 -mr-1 space-y-4">
{state.phase === "error" ? (
<div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5">
{state.errorMessage ?? "未知错误"}
@@ -527,7 +536,7 @@ export function RecommendAgentsDialog({
</div>
</section>
<DialogFooter className="flex-row sm:justify-between items-center gap-2 flex-wrap">
<DialogFooter className="shrink-0 flex-row sm:justify-between items-center gap-2 flex-wrap border-t pt-3 mt-1">
<div className="flex items-center gap-3 text-xs">
<span
className={cn(
@@ -635,14 +644,14 @@ function CollapsibleModelOutput({
})();
return (
<section className="border rounded-md bg-muted/20 shrink-0">
<section className="border rounded-md bg-muted/20 shrink-0 max-h-[38vh] flex flex-col min-h-0 overflow-hidden">
<button
type="button"
onClick={() => {
userTouchedRef.current = true;
setOpen((v) => !v);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider hover:bg-muted/40 transition-colors cursor-pointer"
className="w-full shrink-0 flex items-center gap-2 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider hover:bg-muted/40 transition-colors cursor-pointer"
>
{isLoading ? (
<Loader2Icon className="size-3 text-purple-500 shrink-0 animate-spin" />
@@ -667,7 +676,11 @@ function CollapsibleModelOutput({
)}
/>
</button>
{open && <div className="px-3 pb-3">{children}</div>}
{open && (
<div className="px-3 pb-3 min-h-0 flex-1 overflow-y-auto custom-scrollbar">
{children}
</div>
)}
</section>
);
}
@@ -736,7 +749,12 @@ function RecommenderOutput({ state }: { state: InternalState }) {
)}
{hasReasoning && (
<ReasoningBlock streaming={isStreaming}>{reasoning!}</ReasoningBlock>
<ReasoningBlock
streaming={isStreaming}
contentClassName="max-h-48 overflow-y-auto custom-scrollbar"
>
{reasoning!}
</ReasoningBlock>
)}
{!hasRationale && !hasReasoning && isStreaming && (
@@ -744,7 +762,7 @@ function RecommenderOutput({ state }: { state: InternalState }) {
)}
{hasRationale && (
<div className="max-h-72 overflow-y-auto rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed">
<div className="max-h-40 overflow-y-auto custom-scrollbar rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed">
<MarkdownContent>{cleanedRationale}</MarkdownContent>
{isStreaming && <StreamingCursor />}
</div>
@@ -353,7 +353,13 @@ function Step1Message({
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words space-y-2 roundtable-md">
{introText && <MarkdownContent>{introText}</MarkdownContent>}
{parts.summary && <IntentReadySummaryCard summary={parts.summary} />}
{parts.after && <MarkdownContent>{parts.after}</MarkdownContent>}
{/*
parts.after 是模型违反 SOUL §0「硬规则 3」在 [INTENT_READY] 块后
继续输出的内容(典型:"那么我先帮你列一下大纲……")。后端 intent.py
的终态帧已经把 content 截断到 marker 结尾,但流式期间 LangGraph
messages-tuple 还是会把那段废话逐 token 推过来——故意在 UI 层
把它丢弃,让用户看到的就是"复述 + 摘要卡",干净结束。
*/}
{isStreaming && <StreamingCursor />}
{isPendingClarification && clarification && (
<IntentClarificationCard
@@ -15,7 +15,7 @@
*
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import {
Cpu,
FileIcon,
@@ -67,6 +67,11 @@ export interface Step2PanelProps {
agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null;
roles: RoleEntry[];
/** agent id(小写)→ 席位元数据,用于把派活 chip 的 id 显示成中文席位名。 */
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
selectedModel: string;
availableModels: ModelOption[];
modelsLoading: boolean;
@@ -89,7 +94,7 @@ export interface Step2PanelProps {
// ─── Handlers ─────────────────────────────────────────────────
onPauseRoundtable: () => void;
onResumeRoundtable: () => void;
onRecordInjectMessage: () => void;
onHandleInjectToRoundtable: () => void;
onSubmitClarification: (answer: string) => void;
onSavePersona: (id: number) => void;
onProceedToStep3: () => void;
@@ -109,6 +114,7 @@ export function Step2Panel(props: Step2PanelProps) {
agentConfigs,
selectedAgentId,
roles,
agentNameToRole,
selectedModel,
availableModels,
modelsLoading,
@@ -123,7 +129,7 @@ export function Step2Panel(props: Step2PanelProps) {
setSelectedAgentId,
onPauseRoundtable,
onResumeRoundtable,
onRecordInjectMessage,
onHandleInjectToRoundtable,
onSubmitClarification,
onSavePersona,
onProceedToStep3,
@@ -134,24 +140,20 @@ export function Step2Panel(props: Step2PanelProps) {
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
// 干预研审 → 暂停会商:与右上角按钮共用 onPauseRoundtable;乐观 UI 避免等父 state 刷新。
const [pauseFlash, setPauseFlash] = useState(false);
const [optimisticPaused, setOptimisticPaused] = useState(false);
const showPausedUi = !isRoundTableRunning || optimisticPaused;
const resolveAgentName = useCallback(
(idOrName: string) =>
agentNameToRole[idOrName.toLowerCase()]?.displayName ?? idOrName,
[agentNameToRole],
);
useEffect(() => {
if (!isRoundTableRunning) {
setOptimisticPaused(false);
}
}, [isRoundTableRunning]);
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
const [pauseFlash, setPauseFlash] = useState(false);
const handleInject = useCallback(() => {
setOptimisticPaused(true);
onPauseRoundtable();
onRecordInjectMessage();
onHandleInjectToRoundtable();
setPauseFlash(true);
window.setTimeout(() => setPauseFlash(false), 1600);
}, [onPauseRoundtable, onRecordInjectMessage]);
}, [onHandleInjectToRoundtable]);
const isSandboxStreaming =
isOrchestrating ||
@@ -196,7 +198,7 @@ export function Step2Panel(props: Step2PanelProps) {
</option>
))}
</select>
{showPausedUi ? (
{!isRoundTableRunning ? (
<button
type="button"
onClick={onResumeRoundtable}
@@ -255,6 +257,7 @@ export function Step2Panel(props: Step2PanelProps) {
<Step2Message
key={msg.id}
msg={msg}
resolveAgentName={resolveAgentName}
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) {
@@ -354,10 +357,10 @@ export function Step2Panel(props: Step2PanelProps) {
<button
type="button"
onClick={handleInject}
title="暂停会商并记录干预指令;点击右上角「恢复会商」继续执行"
title="记录干预指令并立即恢复会商执行"
className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10"
>
干预研审
干预并发送
</button>
<div className="h-6 w-[1px] bg-slate-200 shrink-0" />
@@ -406,6 +409,8 @@ export function Step2Panel(props: Step2PanelProps) {
*/
interface Step2MessageProps {
msg: Step2Dialogue;
/** 把派活 chip 的 agent id 解析成中文席位名。 */
resolveAgentName?: (idOrName: string) => string;
onArtifactStepClick?: (
step: NonNullable<Step2Dialogue["steps"]>[number],
) => void;
@@ -415,6 +420,7 @@ interface Step2MessageProps {
function Step2Message({
msg,
resolveAgentName,
onArtifactStepClick,
onFileCardClick,
}: Step2MessageProps) {
@@ -445,6 +451,7 @@ function Step2Message({
steps={msg.steps}
streaming={isStreaming}
onArtifactStepClick={onArtifactStepClick}
resolveAgentName={resolveAgentName}
/>
)}
{reasoning && (
@@ -469,8 +476,13 @@ function Step2Message({
}
/**
* 扫描所有 `tool_calling:present_files` 步骤里的 `filepaths`,按出现顺序去重。
* 同一气泡可能多次 present_files(总控分批交付),都聚合到一行卡片里。
* 收集气泡里所有"写了文件"的产出路径,按出现顺序去重,聚合到一行文件卡片。
*
* 三类来源都算:
* - `present_files`:总控呈现文件,args.filepaths 是数组(可一次多个);
* - `write_file` / `str_replace`:子智能体被后端 excluded_tools 禁用了 present_files,
* 只能 write_file 写文件,单文件 path 在 args.path —— 也聚合进卡片,保证"凡是写了
* 文件就出可点的卡片",而不是只有 present_files 才出。
*/
function collectPresentedFiles(
steps: Step2Dialogue["steps"],
@@ -478,14 +490,22 @@ function collectPresentedFiles(
if (!Array.isArray(steps) || steps.length === 0) return [];
const out: string[] = [];
const seen = new Set<string>();
const add = (fp: unknown) => {
if (typeof fp !== "string" || !fp || seen.has(fp)) return;
seen.add(fp);
out.push(fp);
};
for (const step of steps) {
if (step.key !== "tool_calling:present_files") continue;
const fps = step.args?.filepaths;
if (!Array.isArray(fps)) continue;
for (const fp of fps) {
if (typeof fp !== "string" || !fp || seen.has(fp)) continue;
seen.add(fp);
out.push(fp);
if (step.key === "tool_calling:present_files") {
const fps = step.args?.filepaths;
if (Array.isArray(fps)) fps.forEach(add);
continue;
}
if (
step.key === "tool_calling:write_file" ||
step.key === "tool_calling:str_replace"
) {
add(step.args?.path);
}
}
return out;
@@ -50,11 +50,12 @@ export function Step2SandboxLayout({
}: Step2SandboxLayoutProps) {
const layoutRef = useRef<GroupImperativeHandle>(null);
const {
artifacts,
artifacts: rawArtifacts,
open: artifactsOpen,
setOpen: setArtifactsOpen,
selectedArtifact,
} = useArtifacts();
const artifacts = rawArtifacts ?? [];
const artifactPanelOpen = artifactsOpen;
@@ -26,6 +26,7 @@ import {
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import {
DEFAULT_SELECTED_AGENTS,
MAX_CYCLES,
buildAgentNameToRole,
buildRoleTemplates,
@@ -98,8 +99,18 @@ export type Step2Snapshot = {
seatStubMessages: Record<string, StubAiMessage[]>;
};
/** 席位 Persona 配置;初始为空,用户在 Persona 抽屉保存后写入。 */
const DEFAULT_AGENT_CONFIGS: Record<number, AgentConfig> = {};
/**
* 6 个默认席位的初始 Persona 配置。model 留空 → 运行时 `agentConfigs[seat]?.model
* || selectedModel || undefined`,即「席位覆盖优先,否则跟随全局模型」。
*/
const DEFAULT_AGENT_CONFIGS: Record<number, AgentConfig> = {
2: { model: "", weight: 90, temp: 0.2, prompt: "你负责解析核心任务、资源极限和用户目标偏好,监督全局研商收敛。" },
3: { model: "", weight: 85, temp: 0.3, prompt: "你负责搜集电力、通信系统、历史极端部署条件情报,支持备灾架构体系。" },
4: { model: "", weight: 88, temp: 0.1, prompt: "你评估服务器机房热载荷、算力并发承载极限,预报运行瓶颈与风险时钟。" },
5: { model: "", weight: 92, temp: 0.4, prompt: "你负责双全栈主备建模设计,高内聚低耦合,规避不可预知停机崩溃。" },
6: { model: "", weight: 95, temp: 0.1, prompt: "你作为安全主管,严厉把控预算开销与合规通过率防线,识别计划漏洞。" },
7: { model: "", weight: 80, temp: 0.4, prompt: "你负责对72小时里程碑执行路径规划、重排和资源调配演练序列。" },
};
export interface UseStep2OrchestrationOptions {
/** 当前页面 currentStep,===2 触发自动 init。 */
@@ -164,11 +175,11 @@ export interface UseStep2OrchestrationReturn {
pauseRoundtable: () => void;
/** 与 Step2Panel「恢复会商」按钮相同 —— 恢复自动研讨(含待处理的干预指令)。 */
resumeRoundtable: () => void;
/** 记录干预指令(不暂停);通常由 Step2Panel 在 pauseRoundtable 之后调用。 */
/** 记录干预指令(不暂停不恢复);通常由 Step2Panel 在 pauseRoundtable 之后调用。 */
recordInjectMessage: () => void;
/** 中断所有 in-flight SSE(给新建任务 / 加载草稿用)。 */
abortAllInFlight: () => void;
/** @deprecated 请用 pauseRoundtable + recordInjectMessage;保留兼容组合调用。 */
/** 一键干预:记录指令 + 自动恢复会商 + 触发新一轮编排(替代 pause + record + resume 组合)。 */
handleInjectToRoundtable: () => void;
/** 总控澄清回复 —— 把回答拼到 prompt 里再起一轮编排。 */
submitClarification: (rawAnswer: string) => void;
@@ -223,7 +234,7 @@ export function useStep2Orchestration(
} = useArtifacts();
const artifactPathsRef = useRef<string[]>([]);
useEffect(() => {
artifactPathsRef.current = artifacts;
artifactPathsRef.current = artifacts ?? [];
}, [artifacts]);
// 用户手动关掉沙箱后,autoOpen 会被 ArtifactsProvider 置 false —— 把它们打到 ref
// 上,让 openSeatVirtualArtifact 不用进 useCallback deps,避免每次 toggle 重建函数。
@@ -242,7 +253,9 @@ export function useStep2Orchestration(
Record<string, StubAiMessage[]>
>({});
const seatStubMessagesRef = useRef<Record<string, StubAiMessage[]>>({});
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>([]);
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(
DEFAULT_SELECTED_AGENTS,
);
const [threadIds, setThreadIds] = useState<ThreadIdMap | null>(null);
const [coordinatorName, setCoordinatorName] = useState<string | null>(null);
const [isInitializing, setIsInitializing] = useState<boolean>(false);
@@ -421,6 +434,51 @@ export function useStep2Orchestration(
[],
);
/**
* 反查某个产出文件是由哪个席位 thread 写入的。
*
* 总控(coordinator)常用 `present_files` 呈现「子智能体写的文件」,但文件物理上落在
* 子席位 thread 的 outputs 里,coordinator thread 并没有 —— 直接拿总控气泡的
* `agentThreadId`(=coordinator thread)走 HTTP 拉会 404(`Thread ... not found`)。
* 流式期间子席位 write_file 时,内容已缓存进 seatStubMessages(key=真实写入 thread)。
* 这里按 path 反查那条 stub,让关闭沙箱后再点文件卡也能复用虚拟预览(内存内容,
* 与流式期间体验一致,且不依赖后端 thread 是否仍可访问)。
*/
const findSeatArtifactSource = useCallback(
(
rawPath: string,
): { seatThreadId: string; messageId: string; toolCallId: string } | null => {
const target = rawPath.trim();
if (!target) return null;
const targetBase = target.split("/").pop() ?? target;
let basenameMatch:
| { seatThreadId: string; messageId: string; toolCallId: string }
| null = null;
for (const [seatThreadId, msgs] of Object.entries(
seatStubMessagesRef.current,
)) {
const msg = msgs?.[0];
if (!msg) continue;
for (const tc of msg.tool_calls) {
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
const p =
typeof (tc.args as { path?: unknown })?.path === "string"
? ((tc.args as { path?: string }).path as string)
: "";
if (!p) continue;
if (p === target) {
return { seatThreadId, messageId: msg.id, toolCallId: tc.id };
}
if (!basenameMatch && (p.split("/").pop() ?? p) === targetBase) {
basenameMatch = { seatThreadId, messageId: msg.id, toolCallId: tc.id };
}
}
}
return basenameMatch;
},
[],
);
/**
* `openArtifactPreview` —— 走真实路径(present_files 等已落盘场景),走 HTTP API 加载。
* 沙箱 write_file 实时预览请用 `openSeatVirtualArtifact`。
@@ -428,7 +486,27 @@ export function useStep2Orchestration(
const openArtifactPreview = useCallback(
(threadId: string, path: string, options?: { auto?: boolean }) => {
const normalized = path.trim();
if (!normalized || !threadId) return;
if (!normalized) return;
// present_files 呈现的文件多半是子智能体 write_file 写入的:真实内容/thread 在 stub 里。
// 先反查 stub 走虚拟预览(内存内容)—— 关闭沙箱后再点文件卡也能显示,且不受
// 总控 coordinator thread 在后端 artifacts 路由下 not found 的影响。
const seatSource = findSeatArtifactSource(normalized);
if (seatSource) {
const virtualUrl = buildWriteFileVirtualUrl(
normalized,
seatSource.messageId,
seatSource.toolCallId,
);
openSeatVirtualArtifact(
seatSource.seatThreadId,
virtualUrl,
options?.auto ?? false,
);
return;
}
if (!threadId) return;
setActiveArtifactThreadId(threadId);
const prev = artifactPathsRef.current;
@@ -449,7 +527,14 @@ export function useStep2Orchestration(
openPanel();
}
},
[selectArtifact, setArtifacts, setArtifactsOpen],
[
findSeatArtifactSource,
buildWriteFileVirtualUrl,
openSeatVirtualArtifact,
selectArtifact,
setArtifacts,
setArtifactsOpen,
],
);
const openArtifactFromStep = useCallback(
@@ -472,6 +557,16 @@ export function useStep2Orchestration(
openSeatVirtualArtifact(seatThreadId, virtualUrl, false);
return;
}
if (toolName === "read_file") {
// 验证类「查看文件…」步骤:用 args.path 在沙箱预览该文件。
// openArtifactPreview 内部 findSeatArtifactSource 优先按 path 命中流式期间
// write_file 缓存的内容(虚拟预览);未命中再走 seat thread 的 outputs HTTP。
const path = step.args?.path;
if (typeof path === "string" && path) {
openArtifactPreview(seatThreadId, path);
}
return;
}
if (toolName === "present_files") {
const filepaths = step.args?.filepaths;
if (Array.isArray(filepaths)) {
@@ -1166,13 +1261,41 @@ export function useStep2Orchestration(
confidence: "最高指令",
});
pendingInjectPromptRef.current = `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`;
triggerToast("干预指令已记录,点击「恢复会商」后执行");
triggerToast("干预指令已记录,会商已自动恢复执行");
}, [appendDialogue, customAgentInput, triggerToast]);
const handleInjectToRoundtable = useCallback(() => {
pauseRoundtable();
recordInjectMessage();
}, [pauseRoundtable, recordInjectMessage]);
const userText = customAgentInput.trim();
if (!userText) return;
if (!threadIdsRef.current || !coordinatorNameRef.current) {
triggerToast("⚠️ 圆桌尚未就绪,无法记录干预指令");
return;
}
// 中止当前编排(如有)
orchestrationCancelRef.current = true;
abortAllInFlight();
// 强制标记非编排中,使后续 runOrchestration 可立即启动
isOrchestratingRef.current = false;
setIsOrchestrating(false);
setCustomAgentInput("");
appendDialogue({
sender: "用户 (插话干预)",
avatarType: "human",
content: `💡 (人工指令介入) ${userText}`,
confidence: "最高指令",
});
const prompt = `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`;
// 确保处于运行状态(同步更新按钮 UI)
setIsRoundTableRunning(true);
triggerToast("干预指令已发送,会商继续执行");
// 直接启动新一轮编排
void runOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
}, [customAgentInput, appendDialogue, abortAllInFlight, runOrchestration, triggerToast]);
const submitClarification = useCallback(
(rawAnswer: string) => {
@@ -24,7 +24,23 @@ export type RoleEntry = {
/** 用户在推荐弹窗选定的一个研讨席位。 */
export type SelectedAgent = { agent_id: string; name: string };
/** 研讨席位的头像配色轮换池。 */
/**
* 6 个内置 roundtable 席位的展示元数据。用户保留默认席位时,
* 头像 / 配色 / 中文显示名都按这里走;非内置(用户自建)走 FALLBACK_AVATAR_TYPES。
*/
export const BUILTIN_AGENT_META: Record<
string,
{ avatarType: string; displayName: string }
> = {
"roundtable-intelligence": { avatarType: "purple", displayName: "情报收集" },
"roundtable-environment": { avatarType: "emerald", displayName: "环境评估" },
"roundtable-solution-design": { avatarType: "human", displayName: "方案设计" },
"roundtable-risk-review": { avatarType: "amber", displayName: "风险审查" },
"roundtable-execution-plan": { avatarType: "pink", displayName: "执行规划" },
"roundtable-summary": { avatarType: "blue", displayName: "总结陈述" },
};
/** 非内置 agent 的头像配色轮换池。 */
export const FALLBACK_AVATAR_TYPES = [
"blue",
"purple",
@@ -33,12 +49,28 @@ export const FALLBACK_AVATAR_TYPES = [
"pink",
] as const;
export function getAvatarTypeFor(_agentId: string, index: number): string {
return FALLBACK_AVATAR_TYPES[index % FALLBACK_AVATAR_TYPES.length];
/**
* 用户未确认推荐弹窗时,Step 2 默认使用的 6 个内置席位。
* 保留默认时页面与原硬编码行为完全一致。
*/
export const DEFAULT_SELECTED_AGENTS: SelectedAgent[] = [
{ agent_id: "roundtable-summary", name: "总结陈述" },
{ agent_id: "roundtable-intelligence", name: "情报收集" },
{ agent_id: "roundtable-environment", name: "环境评估" },
{ agent_id: "roundtable-solution-design", name: "方案设计" },
{ agent_id: "roundtable-risk-review", name: "风险审查" },
{ agent_id: "roundtable-execution-plan", name: "执行规划" },
];
export function getAvatarTypeFor(agentId: string, index: number): string {
return (
BUILTIN_AGENT_META[agentId]?.avatarType ??
FALLBACK_AVATAR_TYPES[index % FALLBACK_AVATAR_TYPES.length]
);
}
export function getDisplayNameFor(agent: SelectedAgent): string {
return agent.name;
return BUILTIN_AGENT_META[agent.agent_id]?.displayName ?? agent.name;
}
/**
@@ -103,14 +135,187 @@ export function newCoordinatorName(): string {
}
/**
* 后端 intent agent 输出的 `[INTENT_READY]\n\`\`\`json {...}\`\`\`` 块。
* 前端用同样形状的正则把摘要从对话气泡里抽出来,渲染成绿色摘要卡片。
* 后端 intent agent 输出的**标准** `[INTENT_READY]\n\`\`\`json {...}\`\`\`` 块形状。
* 仅作格式参考 / 快速匹配用;实际从气泡正文里抽摘要走 `splitIntentReadyBlock` 的
* 容错提取(marker 定位 + 括号配对 + 多级降级 parse),不再依赖这条严格正则。
*/
export const INTENT_READY_RE = /\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/;
/**
* 把 JSON 文本里**字符串值内部**的字面控制字符(换行 / 回车 / 制表符)转义成合法的
* `\n` / `\r` / `\t`。模型偶尔会在 summary 的字符串值里直接敲换行,产出的 JSON 不合法,
* `JSON.parse` 会抛 `Bad control character in string literal`。这里只在字符串内替换,
* 不动结构性空白,避免破坏本来就合法的 JSON。
*/
function sanitizeJsonControlChars(raw: string): string {
let out = "";
let inString = false;
let escaped = false;
for (const ch of raw) {
if (escaped) {
out += ch;
escaped = false;
continue;
}
if (ch === "\\") {
out += ch;
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
out += ch;
continue;
}
if (inString) {
if (ch === "\n") {
out += "\\n";
continue;
}
if (ch === "\r") {
out += "\\r";
continue;
}
if (ch === "\t") {
out += "\\t";
continue;
}
}
out += ch;
}
return out;
}
/**
* 从 `fromIndex` 起找第一个 `{`,按花括号配对(跳过字符串内部的括号)找到匹配的 `}`,
* 返回 JSON 子串及其在原文里的起止下标。
*
* 比 `\{[\s\S]*?\}` 这种正则更抗模型跑偏:容忍 JSON 内嵌套对象、字符串里的裸换行 /
* 花括号,以及模型漏写 ```json 代码围栏(直接 `[INTENT_READY] {...}`)的情况。
*/
function extractBalancedJson(
text: string,
fromIndex: number,
): { json: string; end: number } | null {
const start = text.indexOf("{", fromIndex);
if (start < 0) return null;
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < text.length; i += 1) {
const ch = text[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch === "{") depth += 1;
else if (ch === "}") {
depth -= 1;
if (depth === 0) return { json: text.slice(start, i + 1), end: i + 1 };
}
}
return null; // 括号未闭合(流式中段 / 模型截断)
}
/** 去掉对象 / 数组里 `}` `]` 之前的尾随逗号(只在字符串外处理)。 */
function stripTrailingCommas(raw: string): string {
let out = "";
let inString = false;
let escaped = false;
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i];
if (escaped) {
out += ch;
escaped = false;
continue;
}
if (ch === "\\") {
out += ch;
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
out += ch;
continue;
}
if (!inString && ch === ",") {
let j = i + 1;
while (j < raw.length && /\s/.test(raw[j])) j += 1;
if (raw[j] === "}" || raw[j] === "]") continue; // 丢弃尾随逗号
}
out += ch;
}
return out;
}
/** 把任意值归一成 string[]:数组逐项转字符串、单个字符串包成单元素、其它视为空。 */
function toStringArray(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.map((x) => (typeof x === "string" ? x : x == null ? "" : String(x)))
.filter((x) => x.trim().length > 0);
}
if (typeof value === "string" && value.trim().length > 0) return [value];
return [];
}
/**
* 把解析出的对象归一成 IntentSummary。只要 `objective` 是非空字符串就认。
* constraints / assumptions 容忍模型写成字符串 / null / 缺失,统一归到 string[]。
*/
function normalizeIntentSummary(parsed: unknown): IntentSummary | null {
if (!parsed || typeof parsed !== "object") return null;
const obj = parsed as Record<string, unknown>;
const objective =
typeof obj.objective === "string" ? obj.objective.trim() : "";
if (!objective) return null;
return {
objective,
constraints: toStringArray(obj.constraints),
assumptions: toStringArray(obj.assumptions),
};
}
/**
* 解析 INTENT_READY 的 JSON 块。内网模型输出不可控,这里逐级降级重试:
* 1) 原文直接 parse
* 2) 转义字符串内的裸控制字符(换行 / 制表符)后再 parse
* 3) 再去掉尾随逗号后 parse
* 三次都失败才放弃。任一成功都过 `normalizeIntentSummary` 兜字段类型。
*/
function parseIntentSummary(raw: string): IntentSummary | null {
const sanitized = sanitizeJsonControlChars(raw);
const candidates = [raw, sanitized, stripTrailingCommas(sanitized)];
for (const candidate of candidates) {
try {
const summary = normalizeIntentSummary(JSON.parse(candidate));
if (summary) return summary;
} catch {
// 下一个候选
}
}
return null;
}
const INTENT_READY_MARKER_RE = /\[INTENT_READY\]/i;
/**
* 把含 [INTENT_READY] 标记的气泡正文拆成前 / 摘要 / 后三段。
* 摘要 JSON 格式异常时整体保留在 `before`,用户仍能看到原文。
*
* 提取不再依赖严格的 ```json 围栏正则:先定位 [INTENT_READY](大小写不敏感),
* 再用括号配对抓出后面的第一个完整 JSON 对象,最后多级降级 parse。这样模型漏写
* 代码围栏、JSON 里有裸换行 / 尾随逗号、字段类型写偏等情况都能尽量恢复。
*/
export function splitIntentReadyBlock(text: string): {
before: string;
@@ -118,20 +323,16 @@ export function splitIntentReadyBlock(text: string): {
after: string;
} {
if (!text) return { before: text, summary: null, after: "" };
const m = INTENT_READY_RE.exec(text);
if (!m) return { before: text, summary: null, after: "" };
let summary: IntentSummary | null = null;
try {
const parsed = JSON.parse(m[1]);
if (parsed && typeof parsed === "object" && typeof parsed.objective === "string") {
summary = parsed as IntentSummary;
}
} catch {
return { before: text, summary: null, after: "" };
}
const marker = INTENT_READY_MARKER_RE.exec(text);
if (!marker) return { before: text, summary: null, after: "" };
const block = extractBalancedJson(text, marker.index + marker[0].length);
if (!block) return { before: text, summary: null, after: "" };
const summary = parseIntentSummary(block.json);
if (!summary) return { before: text, summary: null, after: "" };
return {
before: text.slice(0, m.index).trimEnd(),
before: text.slice(0, marker.index).trimEnd(),
summary,
after: text.slice(m.index + m[0].length).trimStart(),
// 丢掉 JSON 块之后残留的代码围栏 ``` 与模型多说的话(SOUL 要求 READY 后即停)。
after: text.slice(block.end).replace(/^\s*```+/, "").trimStart(),
};
}
@@ -23,6 +23,8 @@ import {
} from "lucide-react";
import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-thought";
import { CodeBlock } from "@/components/ai-elements/code-block";
import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble";
/**
* 根据工具名 + args 渲染 ChainOfThoughtStep 的子节点(路径 chip / 命令代码块等)。
@@ -34,6 +36,7 @@ import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-th
export function renderStepArgs(
key: string,
args?: Record<string, unknown>,
resolveAgentName?: (idOrName: string) => string,
): React.ReactNode {
if (!args) return null;
const [, toolName] = key.split(":");
@@ -43,15 +46,18 @@ export function renderStepArgs(
const agentName = typeof args.agent_name === "string" ? args.agent_name : "";
const task = typeof args.task === "string" ? args.task : "";
if (!agentName && !task) return null;
// 派活 chip 显示中文席位名而非 agent id(客户自建 agent 的 id 常是一串 hex)。
const displayName =
agentName && resolveAgentName ? resolveAgentName(agentName) : agentName;
return (
<>
{agentName && (
<ChainOfThoughtSearchResult>{agentName}</ChainOfThoughtSearchResult>
{displayName && (
<ChainOfThoughtSearchResult>{displayName}</ChainOfThoughtSearchResult>
)}
{task && (
<span className="block text-[11px] text-slate-600 line-clamp-3 mt-0.5">
{task}
</span>
<div className="mt-1 max-h-64 overflow-y-auto rounded-md border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-600 leading-relaxed">
<MarkdownContent>{task}</MarkdownContent>
</div>
)}
</>
);
@@ -75,9 +81,14 @@ export function renderStepArgs(
case "bash": {
const command = typeof args.command === "string" ? args.command : "";
return command ? (
<code className="block bg-slate-100 rounded px-2 py-1 text-[11px] font-mono text-slate-800 mt-0.5 line-clamp-2 break-all">
{command}
</code>
<div className="mt-1 max-h-60 overflow-auto rounded-md border border-slate-200 [&_pre]:px-2.5! [&_pre]:py-1.5!">
<CodeBlock
className="rounded-none border-none"
showLineNumbers={false}
language="bash"
code={command}
/>
</div>
) : null;
}
case "write_todos": {
@@ -62,6 +62,7 @@ import {
import { useStep2Orchestration } from "@/roundtable-planning/hooks/useStep2Orchestration";
import { useDraftPersistence } from "@/roundtable-planning/hooks/useDraftPersistence";
import {
BUILTIN_AGENT_META,
getAvatarTypeFor,
getDisplayNameFor,
makeRolesFromTemplates,
@@ -259,7 +260,8 @@ export default function RoundtablePlanningPage() {
(agents: Array<{ agent_id: string; name: string }>) => {
const normalized: SelectedAgent[] = agents.map(({ agent_id, name }) => ({
agent_id,
name: name ?? agent_id,
// 内置 agent 走 BUILTIN_AGENT_META;非内置用候选池传过来的 name。
name: BUILTIN_AGENT_META[agent_id]?.displayName ?? name ?? agent_id,
}));
step2.setSelectedAgents(normalized);
step2.resetInitGate();
@@ -486,6 +488,7 @@ export default function RoundtablePlanningPage() {
agentConfigs={step2.agentConfigs}
selectedAgentId={step2.selectedAgentId}
roles={roles}
agentNameToRole={step2.agentNameToRole}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
@@ -496,7 +499,7 @@ export default function RoundtablePlanningPage() {
openArtifactPreview={step2.openArtifactPreview}
onPauseRoundtable={step2.pauseRoundtable}
onResumeRoundtable={step2.resumeRoundtable}
onRecordInjectMessage={step2.recordInjectMessage}
onHandleInjectToRoundtable={step2.handleInjectToRoundtable}
setClarificationDraft={step2.setClarificationDraft}
setCustomAgentInput={step2.setCustomAgentInput}
setAgentConfigs={step2.setAgentConfigs}
@@ -893,7 +896,9 @@ function LeftSidebar({
...selectedAgents.map((agent, i) => ({
id: agent.agent_id,
name: getDisplayNameFor(agent),
role: "圆桌席位",
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) => (
@@ -0,0 +1,166 @@
import { Outlet } from 'react-router-dom';
export interface SceneInfo {
applicableTask: string;
createTime: string;
deleted: number;
id: number;
relatedAgent: string;
sceneCode: string;
sceneIntro: string;
sceneName: string;
sceneType: string;
status: number;
updateTime: string;
version: string;
}
export interface TaskListItem {
createBy: number;
createTime: string;
deptId: null;
id: number;
isTop: number;
issuingDept: string;
keywords: null;
overview: string;
planTaskTop: number;
platform: null;
ptaskId: null;
recommendedBasis: null;
scene: string; // 或 SceneInfo,如果你已经解析
sourceType: number;
step: null;
taskContent: string;
taskDirection: string;
taskEndDate: string;
taskLevel: number;
taskName: string;
taskNo: string;
taskStartDate: string;
taskStatus: number;
taskSuperiorFlag: number;
taskTimeLimit: null;
taskType: number;
column: string;
updateBy: number;
updateTime: string;
endCount: number;
sumAll: number;
progress: number;
taskStatusData: any;
}
export interface ActionListItem {
actionDay: number;
actionEndDate: string;
actionManager: number;
actionName: string;
actionNo: any;
actionStartDate: string;
actionStatus: number;
actionTerm: number;
actionTitle: any;
actorName: any;
audienceGroup: any
audienceGroupId: any;
audienceRecReason: any
behavior: any
createBy: number
createTime: string;
deleteFlag: number;
deptId: any;
deptName: string;
drivingFactor: any;
expectedBehaviorId: any;
id: number
keywords: string
level: number
scene: string
sourceType: number
step: null
taskId: number
updateBy: number
updateTime: number
useActionId: null
}
export interface actionColumnItem {
icon: string;
columnName: string;
actionList: ActionListItem[]
}
export const taskStatusList = {
'0': {
label: '待签收',
color: '#0062d9',
icon: '待签收.svg'
},
'10': {
label: '待分析',
color: '#2f9acc',
icon: '待CEH.svg'
},
'20': {
label: '分析中',
color: '#d97b00',
icon: '进行中.svg'
},
'25': {
label: '分析完成已提交',
color: '#1eb07f',
icon: '完成.svg'
},
'30': {
label: '已分析待审核',
color: '#1eb07f',
icon: '完成.svg'
},
'40': {
label: '分析审核通过',
color: '#1eb07f',
icon: '完成.svg'
},
'50': {
label: '分析审核未通过',
color: '#cf1322',
icon: '未通过.svg'
},
'60': {
label: '行动已下发待签收',
color: '#1eb07f',
icon: '完成.svg'
},
'70': {
label: '行动已签收待策划',
color: '#1eb07f',
icon: '完成.svg'
},
'80': {
label: '行动策划已提交',
color: '#1eb07f',
icon: '完成.svg'
},
'100': {
label: '作战方案待审核',
color: '#1eb07f',
icon: '完成.svg'
},
'110': {
label: '作战方案审核通过',
color: '#1eb07f',
icon: '完成.svg'
},
'120': {
label: '作战方案未通过',
color: '#cf1322',
icon: '未通过.svg'
}
};
export default function TaskManagement() {
return (
<div className="h-full flex flex-col">
<Outlet />
</div>
);
}
@@ -0,0 +1,23 @@
import { Outlet, useNavigate } from 'react-router-dom';
import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
const userInfo = localStorage.getItem('userInfo');
const xkLinkUrl = import.meta.env.VITE_AIPI_XKLINKTO_URL;
interface TaskPgFromProps {
gopath: string
}
const TaskXkFrom: React.FC<TaskPgFromProps> = ({gopath}) => {
const [xkUrl, setXkUrl] = useState(xkLinkUrl);
useEffect(() => {
if(userInfo) {
const userData: any = JSON.parse(userInfo);
setXkUrl(`${xkLinkUrl}${gopath}?accessToken=${userData.accessToken}`);
}
}, [gopath]);
return (
<div className="h-full flex flex-col">
<iframe className="h-[calc(100vh-92px)]" src={xkUrl}></iframe>
</div>
);
}
export default TaskXkFrom