From 7d32b41e90eaa098b471f6fe825327ee4bfc5cbe Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Mon, 22 Jun 2026 10:46:57 +0800 Subject: [PATCH 01/17] =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E6=A1=86=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/RoundtablePlanningPage.tsx | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 4b9cf882..bb7b0733 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -296,6 +296,10 @@ export default function RoundtablePlanningPage() { const [taskRestoreResolved, setTaskRestoreResolved] = useState( !prefillRef.current?.taskId, ); + // 深链/预填自动会话:进入页面后要先建意图线程、(带 taskId 时)回显历史草稿,这段时间 + // 页面只有一句欢迎语、问答还没真正开始,线程创建慢时像卡住。下面这个标志用来在超时后 + // 强制撤掉「启动加载遮罩」,避免万一初始化卡死把界面永久锁住。 + const [bootLoadingExpired, setBootLoadingExpired] = useState(false); const { models: availableModels, isLoading: modelsLoading } = useModels(); @@ -1143,6 +1147,35 @@ export default function RoundtablePlanningPage() { }; }, []); + // ── 启动加载遮罩(深链/预填自动会话)────────────────────────────── + // 是否是「自动会话」:深链 -A、taskId、businessCode/chainId 任一存在即自动进入问答, + // 用户没有手动操作,进入页面到问答之间必须给出明确加载反馈。 + const isAutoSession = Boolean( + prefillRef.current?.autoStart || + prefillRef.current?.taskId || + prefillRef.current?.businessCode || + prefillRef.current?.chainId, + ); + // 「问答已开始」:自动发出的首条用户消息已入列 / 正在流式 / 已得到意图结果,任一为真即认为 + // 已经动起来,可以撤掉遮罩交给 Step1Panel 自身的流式占位。 + const conversationStarted = + step1.isIntentSending || + step1.intentReady != null || + step1.interactiveMessages.some((m) => m.sender === "user"); + // 遮罩展示条件:自动会话 + 仍在 Step1 + 问答还没开始 + 没有初始化报错 + 没到安全超时。 + const showBootLoading = + isAutoSession && + currentStep === 1 && + !conversationStarted && + !step1.intentInitError && + !bootLoadingExpired; + // 安全超时:无论如何最多遮 30s,避免初始化卡死锁住界面(用户仍可手动操作底层页面)。 + useEffect(() => { + if (!showBootLoading) return; + const timer = window.setTimeout(() => setBootLoadingExpired(true), 30_000); + return () => window.clearTimeout(timer); + }, [showBootLoading]); + /** 组装无界回传载荷:总控共识 + 各智能体最终交付(剔除总控席位)。 */ const buildWujieConsensusPayload = useCallback( () => ({ @@ -1780,10 +1813,34 @@ export default function RoundtablePlanningPage() { return (
+ {/* 启动加载遮罩:深链/预填自动会话从进入页面到问答真正开始(建意图线程 / 回显历史草稿) + 期间全屏覆盖,避免线程创建慢时页面像卡住。问答一旦开始或超时即撤掉。 */} + {showBootLoading && ( +
+
+
+

+ 正在准备多智能体会商 +

+

正在初始化任务理解智能体,请稍候…

+
+ + + + + +
+
+ )} + Date: Mon, 22 Jun 2026 11:02:51 +0800 Subject: [PATCH 02/17] =?UTF-8?q?feat(layout):=20=E7=AE=80=E6=B4=81?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=8F=B3=E4=B8=8A=E8=A7=92=E5=8A=A0=E6=8D=A2?= =?UTF-8?q?=E8=82=A4=20+=20=E7=95=8C=E9=9D=A2=E6=A8=A1=E5=BC=8F=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽出共享 appearance-controls(ThemeSwitcher 按 tone 适配 banner/content 配色,新增图标+下拉的 LayoutModeSwitcher);Header 复用换肤组件,简洁模式 ChatPage 顶栏右上角补上换肤与界面模式切换,与全量模式保持一致。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspace/appearance-controls.tsx | 178 ++++++++++++++++++ frontend-web/src/pages/ChatPage.tsx | 8 + .../src/strategy-components/Header.tsx | 96 +--------- 3 files changed, 191 insertions(+), 91 deletions(-) create mode 100644 frontend-web/src/components/workspace/appearance-controls.tsx diff --git a/frontend-web/src/components/workspace/appearance-controls.tsx b/frontend-web/src/components/workspace/appearance-controls.tsx new file mode 100644 index 00000000..8decbe91 --- /dev/null +++ b/frontend-web/src/components/workspace/appearance-controls.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { Check, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useTheme, type ThemeType } from "@/contexts/ThemeContext"; +import { useLayoutMode } from "@/core/system-settings/hooks"; +import { cn } from "@/lib/utils"; + +/** + * 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用, + * 通过 `tone` 适配不同底色: + * - "banner-dark" / "banner-light":Header 落在主题 banner 上(白字 / 浅色 hover)。 + * - "content":简洁模式聊天页浅底顶栏,走 foreground / muted-foreground。 + */ +export type AppearanceControlTone = "banner-dark" | "banner-light" | "content"; + +// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),深蓝色=深色主题(dark) +export const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [ + { id: "light", label: "红色", color: "#cf1322" }, + { id: "lightblue", label: "蓝色", color: "#0062d9" }, + { id: "dark", label: "深蓝色", color: "#002466" }, +]; + +const triggerToneClass = (tone: AppearanceControlTone) => + tone === "banner-dark" + ? "text-slate-400 hover:bg-slate-800/50 hover:text-slate-400" + : tone === "banner-light" + ? "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]" + : "text-muted-foreground hover:bg-muted hover:text-foreground"; + +export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) { + const { theme, setTheme } = useTheme(); + const [open, setOpen] = useState(false); + const closeTimer = useRef | null>(null); + + const cancelClose = () => { + if (closeTimer.current) { + clearTimeout(closeTimer.current); + closeTimer.current = null; + } + }; + const scheduleClose = () => { + cancelClose(); + closeTimer.current = setTimeout(() => setOpen(false), 120); + }; + + useEffect(() => () => cancelClose(), []); + + return ( +
{ + cancelClose(); + setOpen(true); + }} + onMouseLeave={scheduleClose} + > + + {open ? ( +
+
+
+ {THEME_SWATCHES.map((swatch) => { + const active = theme === swatch.id; + return ( +
+
+ ) : null} +
+ ); +} + +const LAYOUT_OPTIONS = [ + { + id: "full" as const, + label: "全量模式", + description: "展示侧边栏与顶栏导航", + icon: PanelLeftIcon, + }, + { + id: "compact" as const, + label: "简洁模式", + description: "隐藏侧边栏与顶栏,获得更大工作区", + icon: LayoutIcon, + }, +]; + +/** + * 界面模式(全量 / 简洁)下拉切换。简洁模式下全局顶栏被隐藏,故把外观设置里的 + * 「界面模式」切换也搬到页面级顶栏右上角,图标 + 下拉,与换肤并排。 + */ +export function LayoutModeSwitcher({ tone }: { tone: AppearanceControlTone }) { + const [mode, setLayoutMode] = useLayoutMode(); + // 未显式设置过时简洁模式才会渲染本控件(全量模式有全局顶栏),故当前态按 compact 兜底。 + const current = mode === "full" ? "full" : "compact"; + const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon; + + return ( + + + + + + 界面模式 + + {LAYOUT_OPTIONS.map((option) => { + const Icon = option.icon; + const active = current === option.id; + return ( + setLayoutMode(option.id)} + className="gap-2" + > + +
+ {option.label} + + {option.description} + +
+ {active && } +
+ ); + })} +
+
+ ); +} diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index b1c24df3..325a8739 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -26,6 +26,10 @@ import { ThreadContext } from "@/components/workspace/messages/context"; import { QuestionNavigator } from "@/components/workspace/question-navigator"; import { ThreadTitle } from "@/components/workspace/thread-title"; import { TodoList } from "@/components/workspace/todo-list"; +import { + LayoutModeSwitcher, + ThemeSwitcher, +} from "@/components/workspace/appearance-controls"; import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator"; import { UserIdentityBadge } from "@/components/workspace/user-identity-badge"; import { Welcome } from "@/components/workspace/welcome"; @@ -484,8 +488,12 @@ export default function ChatPage() { {/* 不带全局顶栏(紧凑模式)时,名称+岗位回落到聊天页顶栏右侧, 与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */} + {/* 简洁模式无全局顶栏:把换肤 + 界面模式切换搬到页面级顶栏右上角, + 与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */} {!compactChrome && ( <> + +
cn( @@ -32,93 +33,6 @@ const iconButtonClass = (isDark: boolean) => const LEFT_MENU_IDS = ["qa-center", "task-management", "light-app-center"]; const RIGHT_MENU_IDS = ["comprehensive-management", "resource-management"]; -// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),深蓝色=深色主题(dark) -const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [ - { id: "light", label: "红色", color: "#cf1322" }, - { id: "lightblue", label: "蓝色", color: "#0062d9" }, - { id: "dark", label: "深蓝色", color: "#002466" }, -]; - -function ThemeSwitcher({ isDark }: { isDark: boolean }) { - const { theme, setTheme } = useTheme(); - const [open, setOpen] = useState(false); - const closeTimer = useRef | null>(null); - - const cancelClose = () => { - if (closeTimer.current) { - clearTimeout(closeTimer.current); - closeTimer.current = null; - } - }; - const scheduleClose = () => { - cancelClose(); - closeTimer.current = setTimeout(() => setOpen(false), 120); - }; - - useEffect(() => () => cancelClose(), []); - - // 切换按钮:悬浮只改背景,不改图标颜色(显式锁定 hover 文字色等于常态色) - const triggerClass = cn( - "size-8 shrink-0 rounded-sm", - isDark - ? "text-slate-400 hover:bg-slate-800/50 hover:text-slate-400" - : "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]", - ); - - return ( -
{ - cancelClose(); - setOpen(true); - }} - onMouseLeave={scheduleClose} - > - - {open ? ( -
-
-
- {THEME_SWATCHES.map((swatch) => { - const active = theme === swatch.id; - return ( -
-
- ) : null} -
- ); -} - type HeaderProps = { collapsed?: boolean; }; @@ -210,7 +124,7 @@ const Header = ({ collapsed }: HeaderProps) => { // 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用 const actionCluster = ( <> - +
) : ( filteredThreads.map((thread) => { - const href = pathOf(thread); + const isBoot = isBootstrapThread(thread); + const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread); const isActive = href === pathname; const pinned = isThreadPinned(thread); const favorited = isThreadFavorite(thread); @@ -468,12 +471,22 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) { : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} > - + {isBoot ? ( + + ) : ( + + )} {pinned && ( )} diff --git a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx index 1273088c..615d6227 100644 --- a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx +++ b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx @@ -43,6 +43,7 @@ import { import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor"; +import { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats"; import { useArtifactsOptional } from "@/components/workspace/artifacts"; import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list"; import { SkillPickerSheet } from "@/components/workspace/agents/skill-picker-sheet"; @@ -133,6 +134,7 @@ export function AgentDetailPanel({ onAskQuestion, disabled, defaultCollapsed, + showRecentChats, }: { agent: Agent | null | undefined; agentName: string; @@ -140,6 +142,8 @@ export function AgentDetailPanel({ disabled?: boolean; /** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */ defaultCollapsed?: boolean; + /** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */ + showRecentChats?: boolean; }) { const router = useRouter(); const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false); @@ -794,6 +798,9 @@ export function AgentDetailPanel({ onViewDetail={setDetailSkillName} /> + + {/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */} + {showRecentChats && !editing && }
); diff --git a/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx b/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx new file mode 100644 index 00000000..de416639 --- /dev/null +++ b/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { MessageSquareIcon } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { useThreadsPage } from "@/core/threads/hooks"; +import { pathOfThread, titleOfThread } from "@/core/threads/utils"; +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; + +const RECENT_LIMIT = 8; + +/** + * 「有关此智能体的最近对话」—— 渲染在智能体详情面板底部(右下角)。 + * 按 `metadata.agent_id` 过滤出该智能体的会话,点击直接打开对应对话。 + */ +export function AgentRecentChats({ agentId }: { agentId: string }) { + const pathname = usePathname(); + const { data } = useThreadsPage({ + page: 1, + pageSize: RECENT_LIMIT, + metadata: { agent_id: agentId }, + }); + // 该智能体的会话里同样排除调度 / 引导 / 会商类系统会话。 + const threads = (data?.threads ?? []).filter( + (th) => + th.metadata?.thread_type !== "bootstrap" && + th.metadata?.thread_type !== "scheduler" && + th.metadata?.thread_type !== "roundtable", + ); + + return ( +
+
+

+ + 最近对话 +

+ + {threads.length} 个 + +
+ {threads.length === 0 ? ( +

暂无对话

+ ) : ( +
+ {threads.map((thread) => { + const href = pathOfThread(thread); + const isActive = href === pathname; + return ( + + + + {desensitize(titleOfThread(thread, "新对话"))} + + + ); + })} +
+ )} +
+ ); +} diff --git a/frontend-web/src/components/workspace/general-qa-sidebar.tsx b/frontend-web/src/components/workspace/general-qa-sidebar.tsx index d9062d86..b06e4123 100644 --- a/frontend-web/src/components/workspace/general-qa-sidebar.tsx +++ b/frontend-web/src/components/workspace/general-qa-sidebar.tsx @@ -75,8 +75,10 @@ import { } from "@/core/threads/hooks"; import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import { + isBootstrapThread, isThreadFavorite, isThreadPinned, + listPathOfThread, pathOfThread, sortThreadsPinnedFirst, titleOfThread, @@ -213,11 +215,12 @@ export function GeneralQaSidebar() { const visibleThreads = useMemo( () => + // 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页); + // 仅排除调度 / 会商类系统会话。 sortThreadsPinnedFirst( threads.filter( (thread) => thread.metadata?.thread_type !== "scheduler" && - thread.metadata?.thread_type !== "bootstrap" && thread.metadata?.thread_type !== "roundtable", ), ), @@ -249,9 +252,9 @@ export function GeneralQaSidebar() { let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath }); if (threadIndex > -1) { if (threads[threadIndex + 1]) { - nextThreadPath = pathOfThread(threads[threadIndex + 1]!); + nextThreadPath = listPathOfThread(threads[threadIndex + 1]!); } else if (threads[threadIndex - 1]) { - nextThreadPath = pathOfThread(threads[threadIndex - 1]!); + nextThreadPath = listPathOfThread(threads[threadIndex - 1]!); } } void router.push(nextThreadPath); @@ -417,7 +420,9 @@ export function GeneralQaSidebar() {
) : ( filteredThreads.map((thread) => { - const isActive = pathOfThread(thread) === pathname; + const href = listPathOfThread(thread); + const isActive = href === pathname; + const isBoot = isBootstrapThread(thread); const pinned = isThreadPinned(thread); const favorited = isThreadFavorite(thread); return ( @@ -426,7 +431,7 @@ export function GeneralQaSidebar() { className="group/item relative flex items-center" > - + {isBoot ? ( + + ) : ( + + )} {pinned && ( )} diff --git a/frontend-web/src/components/workspace/recent-chat-list.tsx b/frontend-web/src/components/workspace/recent-chat-list.tsx index 702bcaa7..b996d0a3 100644 --- a/frontend-web/src/components/workspace/recent-chat-list.tsx +++ b/frontend-web/src/components/workspace/recent-chat-list.tsx @@ -1,6 +1,7 @@ "use client"; import { + Bot, ChevronDown, ChevronRight, Download, @@ -64,8 +65,10 @@ import { } from "@/core/threads/hooks"; import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import { + isBootstrapThread, isThreadFavorite, isThreadPinned, + listPathOfThread, pathOfThread, sortThreadsPinnedFirst, titleOfThread, @@ -130,9 +133,9 @@ export function RecentChatList({ }); if (threadIndex > -1) { if (threads[threadIndex + 1]) { - nextThreadPath = pathOfThread(threads[threadIndex + 1]!); + nextThreadPath = listPathOfThread(threads[threadIndex + 1]!); } else if (threads[threadIndex - 1]) { - nextThreadPath = pathOfThread(threads[threadIndex - 1]!); + nextThreadPath = listPathOfThread(threads[threadIndex - 1]!); } } void router.push(nextThreadPath); @@ -189,11 +192,12 @@ export function RecentChatList({ [t], ); + // 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页); + // 仅排除调度 / 会商类系统会话。 const visibleThreads = sortThreadsPinnedFirst( threads.filter( (thread) => thread.metadata?.thread_type !== "scheduler" && - thread.metadata?.thread_type !== "bootstrap" && thread.metadata?.thread_type !== "roundtable", ), ); @@ -239,7 +243,9 @@ export function RecentChatList({
{shownThreads.map((thread) => { - const isActive = pathOfThread(thread) === pathname; + const href = listPathOfThread(thread); + const isActive = href === pathname; + const isBoot = isBootstrapThread(thread); const pinned = isThreadPinned(thread); const favorited = isThreadFavorite(thread); return ( @@ -261,17 +267,29 @@ export function RecentChatList({ > - {compact && ( - + ) : ( + compact && ( + + ) )} {pinned && ( diff --git a/frontend-web/src/core/auth/dashboard-input.ts b/frontend-web/src/core/auth/dashboard-input.ts index 145259b4..389c4fe6 100644 --- a/frontend-web/src/core/auth/dashboard-input.ts +++ b/frontend-web/src/core/auth/dashboard-input.ts @@ -18,6 +18,7 @@ import { requestCrudService } from "@/service/request-axios-service"; import { readCopTaskDetail } from "@/core/auth/task-deeplink"; +import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config"; /** 本页输入 JSON 暂存的 sessionStorage key(拉取一次后复用,避免重复请求)。 */ export const DASHBOARD_INPUT_STORAGE_KEY = "dashboard.inputJson"; @@ -221,3 +222,43 @@ export async function fetchDashboardInputJson( if (resolved) persistDashboardInputJson(resolved); return resolved; } + +/* ---------------------- rwfx 进入预检:3q 是否完成 ---------------------- */ + +/** + * 「3q(研判情报报告)是否完成」状态: + * - ``complete`` = selectSqReport 数组里有有效内容; + * - ``incomplete`` = 数组为空 / 每条 contentJson 都是 null/空(3q 未完成); + * - ``unknown`` = 缺 consumer 地址 / 请求异常(无法判定,调用方应静默放行)。 + */ +export type SqReportStatus = "complete" | "incomplete" | "unknown"; + +/** + * rwfx(任务分析)进入时的「3q 是否完成」预检:调 selectSqReport,复用 ``extractSqReport`` + * 判定该任务是否已有有效研判情报。判定口径与大屏绘制取数完全一致(空/全 null → 无内容)。 + * - 有可用内容 → ``complete``(正常); + * - 空 / 全 null → ``incomplete``(rwfx 据此弹框引导去 3qfx); + * - 缺地址 / 请求失败 → ``unknown``(不打扰用户)。 + * + * **数据源由后端配置 ``task_deeplink.test`` 决定**(线上 selectSqReport 外部不可用时): + * - ``test=true`` → 直接用 ``config.yaml`` 的 ``mock.sq_report`` 假数据判定,**不发请求** + * (与目的树查询/行动解析的 mock 同一套机制,见 [[task-deeplink-config]]); + * - ``test=false`` → 调真实接口 ``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``。 + */ +export async function fetchSqReportStatus(taskId: string): Promise { + if (!taskId) return "unknown"; + const cfg = await fetchTaskDeeplinkConfig(); + // test=true:外部接口不可用,直接用后端 config 的假数据判定(不打真实接口)。 + if (cfg.test) { + return extractSqReport(cfg.mock.sqReport, taskId) ? "complete" : "incomplete"; + } + // 真实模式:打真实接口。service 拦截器统一注入鉴权并返回整包 body;空/全 null → null。 + const url = sqReportUrl(); + if (!url) return "unknown"; + try { + const body = await requestCrudService.get(url, { taskId }); + return extractSqReport(body, taskId) ? "complete" : "incomplete"; + } catch { + return "unknown"; + } +} diff --git a/frontend-web/src/core/auth/task-deeplink-config.ts b/frontend-web/src/core/auth/task-deeplink-config.ts index 86d5b716..be9b18ac 100644 --- a/frontend-web/src/core/auth/task-deeplink-config.ts +++ b/frontend-web/src/core/auth/task-deeplink-config.ts @@ -34,11 +34,13 @@ export interface TaskDeeplinkConfig { /** 「3q详情」详情页 URL(rwfx 目的树面板链接,跳转追加 ?id= 任务id);留空则前端提示未配置。 */ detail3qUrl: string; /** 测试假数据:purposeDetail = 原始 {state,msg,data};delete = {message,success}; - * actionDetail = 原始 {state,msg,data:{taskId,...}}。 */ + * actionDetail = 原始 {state,msg,data:{taskId,...}};sqReport = 原始 {state,msg,data:[...]} + * (rwfx 3q 预检用,test 为真时生效)。 */ mock: { purposeDetail?: unknown; delete?: { message?: string; success?: boolean }; actionDetail?: unknown; + sqReport?: unknown; }; } @@ -81,6 +83,7 @@ function normalize(body: unknown): TaskDeeplinkConfig { ? (mock.delete as { message?: string; success?: boolean }) : undefined, actionDetail: mock.action_detail, + sqReport: mock.sq_report, }, }; } diff --git a/frontend-web/src/core/threads/utils.ts b/frontend-web/src/core/threads/utils.ts index 7a3d8769..adc0ded4 100644 --- a/frontend-web/src/core/threads/utils.ts +++ b/frontend-web/src/core/threads/utils.ts @@ -81,6 +81,32 @@ export function titleOfThread(thread: AgentThread, fallback = "Untitled") { return fallback; } +/** + * 智能体创建过程产生的「引导会话」(bootstrap)。这类会话用 metadata.thread_type + * === "bootstrap" 标记,点击后应回到「创建智能体」页继续,而不是普通问答页。 + */ +export function isBootstrapThread(thread: { + metadata?: Record | null; +}): boolean { + return thread.metadata?.thread_type === "bootstrap"; +} + +/** + * bootstrap 会话点击后回到「创建智能体」页并恢复该会话。该页只挂在 + * /page/workspace 下(见 WorkspaceRoutes 的 agents/new/:thread_id),故路径固定, + * 不随当前 route base 变化。 + */ +export function bootstrapResumePath(threadId: string): string { + return `/page/workspace/agents/new/${threadId}`; +} + +/** 列表项点击目标:bootstrap 会话回创建页恢复,其余走普通问答路由。 */ +export function listPathOfThread(thread: AgentThread): string { + return isBootstrapThread(thread) + ? bootstrapResumePath(thread.thread_id) + : pathOfThread(thread); +} + export function isThreadPinned(thread: AgentThread): boolean { return (thread.metadata as Record | null)?.is_pinned === true; } diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index b8da2890..659507ba 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -469,6 +469,7 @@ export default function AgentChatPage({ disabled={thread.isLoading || isDemoMode} onAskQuestion={(text) => handleSubmit({ text, files: [] })} defaultCollapsed={taskCtxOn} + showRecentChats={!taskCtxOn} />
diff --git a/frontend-web/src/pages/NewAgentPage.tsx b/frontend-web/src/pages/NewAgentPage.tsx index 0906cd7c..bbe05b4d 100644 --- a/frontend-web/src/pages/NewAgentPage.tsx +++ b/frontend-web/src/pages/NewAgentPage.tsx @@ -7,7 +7,7 @@ import { SaveIcon, SearchIcon, } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -76,7 +76,11 @@ function getCreateAgentErrorMessage( export default function NewAgentPage() { const { t } = useI18n(); const router = useRouter(); - const threadId = useMemo(() => uuid(), []); + // 从「常用对话」点击某条智能体创建(bootstrap)会话回到本页时,URL 带 thread_id, + // 用它恢复该会话(加载历史消息、继续对话);正常进入则新开一个会话。 + const { thread_id: resumeThreadId } = useParams<{ thread_id?: string }>(); + const generatedThreadId = useMemo(() => uuid(), []); + const threadId = resumeThreadId || generatedThreadId; const [nameInput, setNameInput] = useState(""); const [descriptionInput, setDescriptionInput] = useState(""); @@ -93,7 +97,8 @@ export default function NewAgentPage() { const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false); const [detailSkillName, setDetailSkillName] = useState(null); const [skillSearch, setSkillSearch] = useState(""); - const [threadPrepared, setThreadPrepared] = useState(false); + // 恢复已有 bootstrap 会话时该 thread 已存在,无需再 create(置为 true,跳过创建)。 + const [threadPrepared, setThreadPrepared] = useState(!!resumeThreadId); const { skills, isLoading: isSkillsLoading } = useSkills(); const { models, isLoading: isModelsLoading } = useModels(); diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 4810ed01..66d5dce4 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -57,6 +57,12 @@ function NotebookDetailPageWithKey() { return ; } +// 恢复某条 bootstrap 会话时按 thread_id 重挂,切换会话不串台。 +function NewAgentPageWithKey() { + const { thread_id } = useParams<{ thread_id: string }>(); + return ; +} + function isInIframe(): boolean { try { return window.self !== window.top; @@ -279,6 +285,16 @@ export default function WorkspaceRoutes({ } /> + {/* 从「常用对话」点击智能体创建(bootstrap)会话回到创建页并恢复该会话。 + key 绑定 thread_id —— 切换不同 bootstrap 会话时重挂一个干净的页面。 */} + + + + } + /> } diff --git a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx index a8f699f0..2a3fb8c6 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx @@ -30,7 +30,13 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { deleteTaskPurpose, readTaskContext } from "@/core/auth/task-purpose"; -import { fetchActionDetail, readCopTaskDetail, type ActionDetail } from "@/core/auth/task-deeplink"; +import { + fetchActionDetail, + readCopTaskDetail, + resolveTaskDeeplinkTarget, + type ActionDetail, +} from "@/core/auth/task-deeplink"; +import { fetchSqReportStatus } from "@/core/auth/dashboard-input"; import { TASK_BUSINESSES, type TaskBusiness } from "@/core/auth/task-buttons"; import { useTaskThreads } from "@/core/threads/use-task-threads"; import { titleOfThread } from "@/core/threads/utils"; @@ -276,6 +282,79 @@ function TaskClearButton({ taskId }: { taskId: string }) { ); } +/** + * rwfx 专用「3q 完成预检」闸门:进入任务分析工作区时调研判情报报告接口 + * (``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``)。若该任务的报告为空 + * (数组为空 / 内容全为 null,即 3q 未完成)→ 弹框提示「3q 未完成,是否进行 3qfx」, + * 并提供「前往 3qfx」按钮切换到**同一任务**的三情分析工作区(复用深链解析重绑任务上下文)。 + * 报告正常 / 接口不可判定(缺地址、请求失败)时不打扰用户。见 [[dashboard-input]] / [[task-deeplink]]。 + */ +function RwfxSqReportGate({ taskId }: { taskId: string }) { + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [jumping, setJumping] = useState(false); + + useEffect(() => { + if (!taskId) return; + let alive = true; + void fetchSqReportStatus(taskId).then((status) => { + // 仅「明确为空(3q 未完成)」才弹框;complete / unknown 静默放行。 + if (alive && status === "incomplete") setOpen(true); + }); + return () => { + alive = false; + }; + }, [taskId]); + + const handleGo3qfx = async () => { + const detail = readCopTaskDetail(); + if (!detail || !taskId) { + toast.error("缺少任务信息,无法跳转"); + return; + } + setJumping(true); + try { + // 复用深链解析:会清掉当前 rwfx 任务上下文并按 3qfx 业务重新绑定(同一 taskId)。 + const target = await resolveTaskDeeplinkTarget("3qfx", detail, taskId); + if (target) { + setOpen(false); + navigate(target.path, { state: target.state }); + } else { + toast.error("未配置三情分析(3qfx)对应的业务智能体"); + } + } catch { + toast.error("跳转失败,请稍后再试"); + } finally { + setJumping(false); + } + }; + + return ( + !jumping && setOpen(o)}> + + + 3q 未完成 + + 未查询到该任务的研判情报报告(3q 未完成),是否前往该任务的三情分析(3qfx)? + + + + + + + + + ); +} + /** * 任务工作区布局:左列表 + 右 Outlet。 * ``lite``(3qfx/xdfx)= 精简版:左列表无跳转按钮、无底部任务操作面板。 @@ -305,6 +384,8 @@ export function TaskFxWorkspace({
+ {/* rwfx 专属:进入时预检 3q 是否完成,未完成则弹框引导去 3qfx。 */} + {business === "rwfx" && } ); } diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index efe0577d..c6715a5e 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -393,7 +393,10 @@ query-time type, `recommended_question`. Sync only ever adds/removes `origin='position'` rows, so a user's own favorites/subscriptions are never clobbered. Default sets (no binding for a type): agents = built-ins (`user_id IS NULL`); skills = empty (square stays -full, nothing added to 我的); scheduled tasks = empty; questions = unrestricted. +full, nothing added to 我的); scheduled tasks = empty; questions = **untagged +only** (默认岗位 / 无标签绑定 只看到未打标的题目;打标的题目只在绑定了对应标签的 +岗位展示 — the router interprets `resolve_visible → None` as "show only untagged", +not "show everything"). `origin`/`position_id` columns were added to `agent_favorites` and `scheduled_task_subscriptions` — see migration `20260616_01` + `scripts/sql/column_additions_mysql.sql`. The running app also auto-adds these @@ -415,8 +418,12 @@ unassigned users inherit, else `null`); powers the top-bar 岗位 label changes: `GET /api/recommended-questions` is now filtered by the caller's position (`?all=1` bypasses for admin management) and admin can tag questions via `/api/tags/assignments/recommended_question/{id}`. -Each listed question now carries its assigned `tags` (bulk-attached via -`tag_store.list_assignments`), and the list accepts a repeatable `tag_ids` +**Default-position rule**: tags are bulk-attached to **all** questions first, +then the visibility filter applies — a position binding recommended_question 标签 +sees only those tagged questions; a 默认岗位 / 无绑定 (`resolve_visible → None`) +sees only the **untagged** questions (打标的题目不再泄漏到默认岗位). `?all=1` +(admin management) skips this filter and returns every question with its tags. +The list also accepts a repeatable `tag_ids` OR-filter — so the 推荐问题配置 page can display per-question 标签 and search by them (frontend `RecommendedQuestionsPage` + `TagSearchBar`). Tests: `tests/test_recommended_questions_tags.py`. diff --git a/offline-backend-20260512/backend/app/gateway/routers/recommended_questions.py b/offline-backend-20260512/backend/app/gateway/routers/recommended_questions.py index a267c901..cb0e5591 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/recommended_questions.py +++ b/offline-backend-20260512/backend/app/gateway/routers/recommended_questions.py @@ -67,24 +67,11 @@ async def list_recommended_questions( ) -> RecommendedQuestionsListResponse: store = _get_store(request) rows = await store.list_all() - if not all: - # Filter by the caller's 岗位 (position) visibility. ``None`` = - # unrestricted (no skill/question tag binding → show all default - # questions); a set = show only the questions carrying the bound tags. - sync = getattr(request.app.state, "position_sync", None) - if sync is not None: - try: - visible = await sync.resolve_visible(_current_user_id(request), "recommended_question") - except Exception: - logger.debug("recommended-question visibility resolve failed", exc_info=True) - visible = None - if visible is not None: - rows = [r for r in rows if r["id"] in visible] responses = [RecommendedQuestionResponse(**r) for r in rows] # Bulk-attach the tags assigned to each question so the management list can - # display them and filter by 标签. Best-effort: a missing/failing tag store - # never breaks the question list. + # display them and the 岗位 filter below can tell tagged from untagged. + # Best-effort: a missing/failing tag store never breaks the question list. tag_store = getattr(request.app.state, "tag_store", None) if tag_store is not None and responses: try: @@ -95,6 +82,24 @@ async def list_recommended_questions( for resp in responses: resp.tags = assignments.get(resp.id, []) + if not all: + # Filter by the caller's 岗位 (position) visibility: + # * ``set`` — the position binds recommended_question 标签 → show only + # the questions carrying those bound tags. + # * ``None`` — 默认岗位 / 无标签绑定 → show only **untagged** questions. + # 打标的题目只在绑定了对应标签的岗位展示,不再泄漏到默认岗位。 + sync = getattr(request.app.state, "position_sync", None) + if sync is not None: + try: + visible = await sync.resolve_visible(_current_user_id(request), "recommended_question") + except Exception: + logger.debug("recommended-question visibility resolve failed", exc_info=True) + visible = None + if visible is not None: + responses = [r for r in responses if r.id in visible] + else: + responses = [r for r in responses if not r.tags] + if tag_ids: wanted = set(tag_ids) responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py index e5089c2a..535e8faf 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py @@ -5,7 +5,8 @@ 随时改 ``config.yaml`` 即可(Gateway 有 mtime 热重载,无需重新构建前端)。 测试开关(线上接口未通时用假数据,互相独立): - - ``test`` → 目的树查询(``purpose_detail_url``)走 ``mock.purpose_detail`` 假数据。 + - ``test`` → 目的树查询(``purpose_detail_url``)走 ``mock.purpose_detail`` 假数据; + 同时 rwfx 进入「3q 是否完成」预检(``selectSqReport``,外部接口不可用)走 ``mock.sq_report`` 假数据。 - ``delete_test`` → 删除接口(``delete_url``)走 ``mock.delete`` 假数据(该接口尚未开发完时打开)。 - ``action_test`` → 行动详情接口(``action_detail_url``)走 ``mock.action_detail`` 假数据。 xdfx 深链传入的是「行动id」,需先调该接口拿 ``data.taskId`` 解析出真正的「任务id」。 @@ -25,7 +26,7 @@ class TaskDeeplinkConfig(BaseModel): test: bool = Field( default=False, - description="为真时前端目的树查询走 mock.purpose_detail 假数据(不打真实接口)", + description="为真时前端目的树查询走 mock.purpose_detail 假数据;同时 rwfx 进入「3q 是否完成」预检(selectSqReport)走 mock.sq_report 假数据(都不打真实接口)", ) delete_test: bool = Field( default=False, @@ -61,5 +62,5 @@ class TaskDeeplinkConfig(BaseModel): ) mock: dict[str, Any] = Field( default_factory=dict, - description="test/delete_test/action_test 为真时返回的假数据:{purpose_detail: {...}, delete: {message, success}, action_detail: {state, msg, data: {taskId, ...}}}", + description="test/delete_test/action_test 为真时返回的假数据:{purpose_detail: {...}, delete: {message, success}, action_detail: {state, msg, data: {taskId, ...}}, sq_report: {state, msg, data: [...]}}(sq_report 供 rwfx 3q 预检,test 为真时用)", ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py b/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py index bc3ee1e1..2a6e86e8 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py @@ -16,8 +16,11 @@ Resolution rule (per resource type): - ``scheduled_task`` → empty (no built-in tasks) - ``skill`` → empty (the 广场 still shows every visible skill; a position only *adds* its tagged skills to the member's "我的技能") - - ``recommended_question`` → *unrestricted* (handled at query time, never - materialized — see :meth:`resolve_visible`) + - ``recommended_question`` → *untagged-only* (handled at query time, never + materialized — see :meth:`resolve_visible`). The default set is the + questions carrying **no** 标签; tagged questions surface only for a position + that binds their tag. (The recommended-questions router interprets a + ``None`` from :meth:`resolve_visible` as "show only untagged".) Materialization (``sync_user``) only ever touches rows it created itself (``origin='position'``), so a user's own favorites / subscriptions are never @@ -67,12 +70,14 @@ class PositionSyncService: return await self._positions.get_default_position_id() async def resolve_visible(self, user_id: str, resource_type: str) -> set[str] | None: - """Return the set of visible ``resource_type`` ids, or ``None`` = unrestricted. + """Return the set of visible ``resource_type`` ids, or ``None`` = no tag binding. Used by the recommended-questions filter (the only remaining query-time - visibility type; skills are now *materialized*, not filtered). ``None`` - means "no position restriction → show everything"; a set means "restrict - to exactly these ids". + visibility type; skills are now *materialized*, not filtered). A set + means "restrict to exactly these ids". ``None`` means the effective + position binds no tag for this type — the recommended-questions router + treats that as "默认岗位 → show only untagged questions" (see + ``app/gateway/routers/recommended_questions.py``). """ pid = await self._effective_position_id(user_id) if not pid: diff --git a/offline-backend-20260512/backend/tests/test_recommended_questions_tags.py b/offline-backend-20260512/backend/tests/test_recommended_questions_tags.py index f4d19e78..054d9225 100644 --- a/offline-backend-20260512/backend/tests/test_recommended_questions_tags.py +++ b/offline-backend-20260512/backend/tests/test_recommended_questions_tags.py @@ -77,3 +77,65 @@ async def test_list_without_tag_store_does_not_break() -> None: assert [q.id for q in resp.questions] == ["q1", "q2"] assert all(q.tags == [] for q in resp.questions) + + +class _FakeSync: + """Stub position-sync whose ``resolve_visible`` returns a fixed value. + + ``None`` mimics 默认岗位 / 无标签绑定; a set mimics a position that binds + recommended_question 标签. + """ + + def __init__(self, visible: set[str] | None) -> None: + self._visible = visible + + async def resolve_visible(self, _user_id, _resource_type): # noqa: ANN001 + return self._visible + + +@pytest.mark.asyncio +async def test_default_position_shows_only_untagged() -> None: + """默认岗位(resolve_visible → None)只看到未打标的题目。""" + tag_store = MemoryTagStore() + tag = await tag_store.create_tag( + {"id": "t1", "name": "理赔", "tag_type": "recommended_question"} + ) + await tag_store.set_target_tags("recommended_question", "q2", [tag["id"]]) + + req = _request(_FakeQuestionStore(_rows()), tag_store) + req.app.state.position_sync = _FakeSync(None) + resp = await rq_router.list_recommended_questions(req, all=False) + + assert [q.id for q in resp.questions] == ["q1"] + + +@pytest.mark.asyncio +async def test_bound_position_shows_only_its_tagged_questions() -> None: + """绑定了标签的岗位(resolve_visible → set)只看到对应打标的题目。""" + tag_store = MemoryTagStore() + tag = await tag_store.create_tag( + {"id": "t1", "name": "理赔", "tag_type": "recommended_question"} + ) + await tag_store.set_target_tags("recommended_question", "q2", [tag["id"]]) + + req = _request(_FakeQuestionStore(_rows()), tag_store) + req.app.state.position_sync = _FakeSync({"q2"}) + resp = await rq_router.list_recommended_questions(req, all=False) + + assert [q.id for q in resp.questions] == ["q2"] + + +@pytest.mark.asyncio +async def test_admin_view_ignores_position_filter() -> None: + """``all=True`` 管理视图照旧返回全部(含打标),不受岗位过滤影响。""" + tag_store = MemoryTagStore() + tag = await tag_store.create_tag( + {"id": "t1", "name": "理赔", "tag_type": "recommended_question"} + ) + await tag_store.set_target_tags("recommended_question", "q2", [tag["id"]]) + + req = _request(_FakeQuestionStore(_rows()), tag_store) + req.app.state.position_sync = _FakeSync(None) + resp = await rq_router.list_recommended_questions(req, all=True) + + assert [q.id for q in resp.questions] == ["q1", "q2"] diff --git a/offline-backend-20260512/config.yaml b/offline-backend-20260512/config.yaml index 5c70686c..3fd5da8e 100644 --- a/offline-backend-20260512/config.yaml +++ b/offline-backend-20260512/config.yaml @@ -331,7 +331,7 @@ summarization: # ?taskId= / ?id=),随时改这里即可(Gateway mtime 热重载,前端无需重建)。 task_deeplink: # 线上接口未通时用假数据;三个开关互相独立。 - test: true # 目的树查询走 mock.purpose_detail 假数据 + test: true # 目的树查询走 mock.purpose_detail 假数据;同时 rwfx 进入「3q 是否完成」预检走 mock.sq_report 假数据 delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完) action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id) purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail" @@ -382,3 +382,15 @@ task_deeplink: actionName: "" actionStatus: 25 narration: "" + # test=true 时 rwfx 进入「3q 是否完成」预检(selectSqReport,外部不可用)走这份假数据。 + # 结构同真实 GET /consumer/cop/selectSqReport?taskId= 返回;前端判定 data 为空 / 每条 + # contentJson 都是 null/空 = 「3q 未完成」→ 弹框引导去 3qfx;否则视为已完成(正常,不弹框)。 + # 默认给一条有内容的数据 = 已完成(不打扰)。要测「3q 未完成」弹框:把下面 data 改成 []。 + sq_report: + state: "200" + msg: "操作成功!" + data: + - id: "sq-mock-1" + taskId: 3338 + categoryType: "敌情关键事件 JSON" + contentJson: "[{\"topic\":\"示例研判事件\",\"count\":1}]" From e28277b21be620c78b6252c7eace70926ad5b5cb Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Mon, 22 Jun 2026 15:43:55 +0800 Subject: [PATCH 06/17] =?UTF-8?q?feat(layout):=20=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=8F=B3=E4=B8=8A=E8=A7=92=E4=B9=9F=E5=8A=A0?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E6=A8=A1=E5=BC=8F=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用 appearance-controls 的 LayoutModeSwitcher,Header 操作区在换肤旁 补上界面模式下拉,与简洁模式右上角保持一致。LayoutModeSwitcher 新增 fallback 属性指明 mode 未设置时的兜底界面(Header 传 full,ChatPage 传 compact)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspace/appearance-controls.tsx | 18 +++++++++++++----- .../src/strategy-components/Header.tsx | 7 ++++++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/frontend-web/src/components/workspace/appearance-controls.tsx b/frontend-web/src/components/workspace/appearance-controls.tsx index 8decbe91..02efa7c8 100644 --- a/frontend-web/src/components/workspace/appearance-controls.tsx +++ b/frontend-web/src/components/workspace/appearance-controls.tsx @@ -126,13 +126,21 @@ const LAYOUT_OPTIONS = [ ]; /** - * 界面模式(全量 / 简洁)下拉切换。简洁模式下全局顶栏被隐藏,故把外观设置里的 - * 「界面模式」切换也搬到页面级顶栏右上角,图标 + 下拉,与换肤并排。 + * 界面模式(全量 / 简洁)下拉切换。换肤旁并排,图标 + 下拉: + * - 简洁模式 ChatPage 顶栏(全局顶栏被隐藏)。 + * - 全量模式 Header 右上角。 + * 用户未显式设置过界面模式时(mode === undefined),当前态按调用方所在的实际界面 + * 兜底——`fallback` 指明这一回退值(Header 传 "full",ChatPage 传 "compact")。 */ -export function LayoutModeSwitcher({ tone }: { tone: AppearanceControlTone }) { +export function LayoutModeSwitcher({ + tone, + fallback = "compact", +}: { + tone: AppearanceControlTone; + fallback?: "full" | "compact"; +}) { const [mode, setLayoutMode] = useLayoutMode(); - // 未显式设置过时简洁模式才会渲染本控件(全量模式有全局顶栏),故当前态按 compact 兜底。 - const current = mode === "full" ? "full" : "compact"; + const current = mode ?? fallback; const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon; return ( diff --git a/frontend-web/src/strategy-components/Header.tsx b/frontend-web/src/strategy-components/Header.tsx index 57d7480e..0bcb8334 100644 --- a/frontend-web/src/strategy-components/Header.tsx +++ b/frontend-web/src/strategy-components/Header.tsx @@ -4,7 +4,7 @@ import { useLocation, useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; import "@/components/page-sdebar.css"; -import { ThemeSwitcher } from "@/components/workspace/appearance-controls"; +import { LayoutModeSwitcher, ThemeSwitcher } from "@/components/workspace/appearance-controls"; import { NotificationDialog } from "@/components/workspace/notification-dialog"; import { getStoredAuth, getAccountDisplayName } from "@/core/auth"; import { isWujieEnv } from "@/core/embed/wujie"; @@ -126,6 +126,11 @@ const Header = ({ collapsed }: HeaderProps) => { <> + +
-

会商诊断日志

+

会商报错日志

- 多智能体会商全流程报错与关键事件(含后台挂起卡死、超时、产物缺失等) + 多智能体会商全流程报错日志(席位失败 / 空交付 / 超时 / 模型容错 / 产物缺失 / 崩溃等;运行进度类 info 不再收集)

diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 22eca306..68f8f72f 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -245,9 +245,9 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA | **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id | | **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page ? see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. | | **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, ?) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. | -| **Roundtable Drafts** (`/api/roundtable-drafts`) | **Strictly per-user** persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete (all owner-scoped). A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). taskId 深链会商记录 are **not** here — they live in the separate `/api/roundtable-task-drafts` store (see below) so personal history never mixes with task records. Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_task_drafts.py`. | -| **Roundtable Task Drafts** (`/api/roundtable-task-drafts`) | **Task-scoped, unpartitioned** store for taskId-deeplink roundtable 聊天记录 (无界嵌入抽屉). Keyed **only by `task_id`** (one task → many records), **never partitioned by user** — any authenticated caller may read/write/delete a task's records (`created_by` is audit-only). Physically separate from `roundtable_drafts` so the two never mix. `GET /by-task/{task_id}` - all drafts for a task, newest-first (history dropdown), enriched with the latest background-job status per draft via `RoundtableJobRepository.status_map_by_drafts`; `GET /by-task/{task_id}/latest` - newest draft (回显最新一条); `POST /` - create (`task_id` required); `GET/PUT/DELETE /{id}` - full read / partial update / delete (all user-agnostic). Backed by `deerflow.persistence.roundtable_task_drafts` (table `roundtable_task_drafts`, auto-created by `create_all`; migration `20260618_01`); store wired in `deps.py` as `app.state.roundtable_task_draft_store`. Existing `task_id`-bearing `roundtable_drafts` rows are migrated over by `scripts/migrate_roundtable_task_drafts.py`. Tests: `tests/test_roundtable_task_drafts.py`, `tests/test_roundtable_drafts_by_task.py`. | -| **Roundtable Diagnostics** (`/api/roundtable-diagnostics`) | 会商全流程诊断日志 (报错 + 关键事件). One row per event in `roundtable_diagnostics`: `{scope (background = 后台挂起 / foreground = 前台服务端观测 / client = 前端上报), stage (step1_intent/step2_init/step2_leader/step2_seat/step2_recommend/step2_orchestration/step3_summary/step3_report/step3_dashboard/step3_structure/upstream/job), level (info/warning/error), event, message, detail (JSON), job_id/draft_id/task_id/user_id/agent_id/agent_name/cycle, created_at}`. **Reads are admin-only**: `GET /` - filter (scope/stage/level/event/job_id/draft_id/task_id/user_id/q/since/until; `scope` accepts a comma-separated set so 前端「前台调用」= `foreground,client`) + paginate (newest-first), each item enriched with `userName` (user_id → 邮箱 `@`-prefix via `get_local_provider().get_user`, best-effort batched cache); `GET /facets` - distinct values for filter dropdowns; `POST /admin/cleanup` - retention purge. **`POST /report` is NOT admin-gated** — any authenticated user's frontend posts the errors *the user actually hit* (HTTP 502 before any SSE frame / network drop / SSE error frame) so they're collected too; stored as `scope="client"`. Backed by `deerflow.persistence.roundtable_diagnostics` (table `roundtable_diagnostics`, auto-created by `create_all`; migration `20260619_01`); store wired in `deps.py` as `app.state.roundtable_diagnostic_store` + a process-level default store (`register_default_store`) so the harness-side engine/gateway can write across the app/harness boundary. **Writers** (all best-effort — diagnostics never break a run): background path uses `DiagnosticsRecorder` (bound to job ctx) threaded through `RoundtableJobExecutor` → `JobProgressWriter.diag(...)` (engine transitions/step3 failures/empty-delivery) + `InProcessRoundtableGateway` (per-turn timeout/report·summary·dashboard truncation+missing); foreground path uses `app/gateway/roundtable_diag.py::record_foreground_diag` in `multi_agent.py`/`intent.py`/`recommend.py` error frames. **Bug-fix shipped alongside**: `InProcessRoundtableGateway._run_agent_turn` guards each agent run with an **inactivity (idle) timeout** (`_await_run_with_idle_guard`, `ROUNDTABLE_SEAT_IDLE_TIMEOUT_SECONDS` default 1200s = 20min, tuned high for unstable intranet models) — **not** a total-duration cap, so a slow-but-alive run that keeps streaming tokens runs as long as it needs; only a run that emits **zero StreamBridge events for `idle_timeout` seconds straight** (a true freeze: 内网模型连接池挂死 / sqlite 锁在挂载卷上挂死) is cancelled + raises (顶层把 job 标 error,而非永久冻结) + records a `seat_turn_timeout` diagnostic. Activity is observed via `app.state.stream_bridge` (the worker publishes every chunk there). Optional absolute cap `ROUNDTABLE_SEAT_MAX_TOTAL_SECONDS` (default 0 = off). Frontend: admin page `pages/RoundtableDiagnosticsPage.tsx` (route `/page/strategy/admin/roundtable-diagnostics`), opened from the **会商页右上角设置按钮** (admin only — `handleSettingsClick` in `roundtable-planning/pages/RoundtablePlanningPage.tsx`); admin read client `strategy-components/api/roundtable-diagnostics.ts`. **Client error reporter** `roundtable-planning/api/diagnostics-report.ts` (`reportRoundtableError` fire-and-forget + `keepalive`, `setRoundtableErrorContext` for draft/task attribution set by the page, `stageForRoundtableAgent`) is called at every client-observable failure: `multi-agent.ts` (`streamMultiAgent` — covers Step2 leader/seat AND Step3 report/summary/dashboard/structure — reports HTTP error + SSE `{status:"error"}` frame + worker `event:error` frame [模型调用没成功] + fetch-reject [网络不可达] + mid-stream drop [连接重置/超时], deduped via a `reported` flag; `initMultiAgent` HTTP/error-frame/incomplete/mid-stream), `intent.ts` (Step1 HTTP+error-frame), `recommend.ts` (Step2 recommend HTTP+error-frame), `roundtable-jobs.ts` (job api/stream). The admin page's first filter is a **后台挂起 / 前台调用** toggle (frontend = `foreground,client`); the stage dropdown options adapt to it (background → step2+step3+job; frontend → step1+step2+step3) and each row shows the reporting user's name + 👤. Tests: `tests/test_roundtable_diagnostics.py`, `tests/test_roundtable_inprocess_timeout.py`. **Seat skill-recognition (弱模型补偿)**: a seat = one custom agent run whose configured skills (`agent config.yaml → skills`) are injected **only** via the system prompt's `` block (model must `read_file` the SKILL.md itself) — weak models (deepseek-chat 等) ignore it and never use their skills. `app/gateway/roundtable_seat_skills.py::append_seat_skill_directive(agent_id, task, *, chain_enabled=False)` appends an explicit Chinese directive (each configured-and-enabled skill's name + description + SKILL.md container path + "先 read_file 再按其流程取信息/执行") to the **seat's per-turn task (human turn)** — far stronger pull on weak models than the system prompt. Wired into **both** seat paths: foreground `multi_agent._special_run` (only `role in {seat, seat_parallel}`; Step3 singletons report/summary/dashboard/ingest excluded) + background `InProcessRoundtableGateway.run_seat`. No-op (returns task unchanged) when the seat has no configured skills. **2026-06-20: default-off + two opt-in switches (OR semantics)** — was global-on, now injects only when **either** the seat agent's own switch (`AgentConfig.seat_skill_directive`, edited in the agent edit dialog `agent-card.tsx` + `agent-detail-panel.tsx`) **or** the **per-seat** switch on that agent **within the selected business chain** is on. The per-seat chain switch lives **in the chain's `seats` JSON** (`ChainSeatSchema.seat_skill_directive`, no DB column — rides the existing JSON like the per-seat `model` override), edited via a small ✨ icon → dropdown (Switch + description) on each agent card in `BusinessChainEditorPage`. It is threaded **per-seat** to each seat run: foreground `multi_agent` per-call field `seat_skill_directive` (orchestration looks it up per agentId from a `{agentId: bool}` session map) → `_special_run` → `chain_enabled`; background as the job field `seat_skill_directives: dict[str,bool]` (StartJobRequest → StartParams → deps factory → `InProcessRoundtableGateway._seat_skill_directives`) → `run_seat` looks up `agent_id` → `chain_enabled`. `ROUNDTABLE_SEAT_SKILL_DIRECTIVE` is only a **master kill-switch** (default allow; set `0/false` to hard-disable regardless of the switches — never force-enables). Tests: `tests/test_roundtable_seat_skills.py`, `tests/test_agents_backend.py`. **2026-06-20 Step2 提速三件套**:(1) **每席位推理深度覆盖** —— 业务链条 `seats` JSON 里逐席位可配 `seat_mode`(flash/thinking/pro/ultra,覆盖链条级 `seatMode`;编辑器席位卡右侧 ⏱Gauge 图标下拉,null=跟随链条默认)。前台经 `useStep2Orchestration` 三处 dispatch 的 `seatModeToRequestFields(seatModes[id] ?? selectedMode)` 透传;后台经 job `seat_modes: dict[str,str]` → `StartParams` → `InProcessRoundtableGateway._seat_modes`,`run_seat` 用 `_SEAT_MODE_PARAMS`(与前端 `seatModeToRunParams` 逐字对齐)解析覆盖作业级 seat_*(未命中→跟随作业级→policy 默认)。无 DB 列(ride `seats` JSON,但 `ChainSeatSchema.seat_mode` 须显式声明否则 Pydantic 剥掉)。Tests: `tests/test_roundtable_seat_modes.py`。(2) **recommend 批内并行** —— 后端 `engine._run_recommend` 由 for-await 串行改 `asyncio.gather`(对齐前端 2026-06-13 已并行;总控一轮派多席位=判定相互独立→并发;批内用派活前 deliveries 快照、批后统一并入,语义不变)。(3) **研讨前并行『取数』两段式**(业务链条 `gather_first` 列,逐条 opt-in,migration `20260620_02`,editor「研讨前并行取数」开关)—— `gather_first=True` 时 `run_orchestration` 在分发前先 `engine._run_gather_phase`(全席位 `asyncio.gather` 跑 `_GATHER_TASK`「只取数不研讨」),产出 `gathered: dict` 经 `_seat_message(gathered=...)` 作为「已收集资料」块注入 recommend/chain/dag 各研讨轮席位任务(与「前序交付」块分开),把最慢的技能延迟前置并行、给(尤其 chain 串行的)研讨段提速;研讨阶段不限制再调技能。前台对称实现:`useStep2Orchestration.runGatherPhase` 在 `runActiveOrchestration` 分发前跑一次(`gatherPhaseDoneRef` 闸门,resetInitGate 复位),产出存 `gatheredDeliveriesRef` 经 `peerDeliveries`/初始 `priorDeliveries` 注入研讨席位。链条级 `gatherFirst`(camel)↔`gather_first`(snake) 经 `api/chains.ts` 映射;job 经 `StartJobRequest.gather_first`→`StartParams`→`run_orchestration(gather_first=…)`。Tests: `tests/test_roundtable_gather_phase.py`。(4) **削减重复广播**(前端 only,多智能体 foreground 路径)—— `suppress_pre_broadcast`(仅 leader:抑制「总控本轮输入→各席位 thread」预广播 + 「总控给X派活」→兄弟席位的 post 广播,二者一个 flag)此前只在 DAG 派活轮开;现扩到 recommend(`safetyCounter > 1`,即第 2 轮起的续轮/综合)+ chain 逐席位(`i > 0`,第 2 个席位起)+ chain 收口轮(恒 true)。**保留每种模式的首轮预广播**(它为席位打底任务背景),只 suppress 重复的续轮广播(N 次读写巨大席位 thread 的纯浪费 + 弱模型模仿派活);席位的前序交付另经「席位交付广播」`broadcastSeatDeliveryToOthers` 送达,不受影响。后台引擎路径无此广播(`_seat_message` 直接拼上下文),故 #4 仅前端。**2026-06-20 深链接业务链抽取规范**:深链接会商(taskId+goPath,businessCode 如 rwfx→6BF)下,结构化抽取(flow-json)与方案总结报告都按所选业务链**完整层级**强制校正。结构化抽取实为 `roundtable-structure` 智能体(`flow_extract.py` 的 `_system_prompt()=load_agent_soul(STRUCTURE_AGENT_ID)`,抽取→校验两段式 `model.astream`);其 SOUL 全局一份,旧实现把某业务层级写死进 SOUL→所有业务都按「目的→行为体」抽(bug)。修法:**单份领域知识、两角色用法**的 `deerflow.agents.roundtable_orchestrator.extraction_spec`(放 harness 层,app+harness 都可 import;`build_structure_directive`/`build_summary_directive` 按 businessCode 取;rwfx=6BF 写实「任务→目的→行为体→预期行为→驱动因素→叙事,预期行为↔驱动因素 1:1、余 1:N、label 不含步骤/阶段名、优先报告回退席位」,其余业务通用规范=强约束完整性+命名+取数且明令不套固定模板;business_code 空→""零副作用)。注入:(1) 结构化抽取 `flow_extract._hierarchy_hint`+`_validate_messages` 用 `build_structure_directive` 权威覆盖默认层级(前端 `streamFlowExtract` 传 `business_code`,源 `ingestBusinessCode`);(2) 方案总结报告 `build_summary_directive`——前台 `multi_agent._special_run`(role==summary 且 business_code) 追加、后台 `engine.build_summary_prompt(business_code=…)`(StartParams/StartJobRequest 透传)。**仅深链接生效**。种子 `roundtable-structure/SOUL.md` 层级顺序改 spec-driven、连接示例去 rwfx 写死(存量 live SOUL 不被 seed 覆盖,但注入规范权威可纠正)。Tests: `tests/test_roundtable_extraction_spec.py`。 | +| **Roundtable Drafts** (`/api/roundtable-drafts`) | **Strictly per-user** persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta — incl. `chain_title`, the business-chain name used this session, cheaply regex-extracted from the in-memory `step2` blob in `_draft_to_meta`/`_extract_chain_title`; null = no chain / 自由模式; surfaced on the 第一步「分析记录」history card + sidebar list instead of the old「第 X 步」); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete (all owner-scoped). A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). taskId 深链会商记录 are **not** here — they live in the separate `/api/roundtable-task-drafts` store (see below) so personal history never mixes with task records. Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_task_drafts.py`. | +| **Roundtable Task Drafts** (`/api/roundtable-task-drafts`) | **Task-scoped, unpartitioned** store for taskId-deeplink roundtable 聊天记录 (无界嵌入抽屉). Keyed **only by `task_id`** (one task → many records), **never partitioned by user** — any authenticated caller may read/write/delete a task's records (`created_by` is audit-only). Physically separate from `roundtable_drafts` so the two never mix. `GET /by-task/{task_id}` - all drafts for a task, newest-first (history dropdown), enriched with the latest background-job status per draft via `RoundtableJobRepository.status_map_by_drafts` (each meta also carries `chain_title`, same `_extract_chain_title` regex as the personal store); `GET /by-task/{task_id}/latest` - newest draft (回显最新一条); `POST /` - create (`task_id` required); `GET/PUT/DELETE /{id}` - full read / partial update / delete (all user-agnostic). Backed by `deerflow.persistence.roundtable_task_drafts` (table `roundtable_task_drafts`, auto-created by `create_all`; migration `20260618_01`); store wired in `deps.py` as `app.state.roundtable_task_draft_store`. Existing `task_id`-bearing `roundtable_drafts` rows are migrated over by `scripts/migrate_roundtable_task_drafts.py`. Tests: `tests/test_roundtable_task_drafts.py`, `tests/test_roundtable_drafts_by_task.py`. | +| **Roundtable Diagnostics** (`/api/roundtable-diagnostics`) | 会商全流程诊断日志 (报错 + 关键事件). One row per event in `roundtable_diagnostics`: `{scope (background = 后台挂起 / foreground = 前台服务端观测 / client = 前端上报), stage (step1_intent/step2_init/step2_leader/step2_seat/step2_recommend/step2_orchestration/step3_summary/step3_report/step3_dashboard/step3_structure/upstream/job), level (warning/error — **`info` 级运行日志不再收集**:`record_diagnostic` 在写库前丢弃 `level=="info"`,只留报错;begin/consensus/gather/job_started 等纯进度事件即使仍调 `.diag(level="info")` 也成 no-op,故诊断页 = 报错日志页), event, message, detail (JSON), job_id/draft_id/task_id/user_id/agent_id/agent_name/cycle, created_at}`. **Reads are admin-only**: `GET /` - filter (scope/stage/level/event/job_id/draft_id/task_id/user_id/q/since/until; `scope` accepts a comma-separated set so 前端「前台调用」= `foreground,client`) + paginate (newest-first), each item enriched with `userName` (user_id → 邮箱 `@`-prefix via `get_local_provider().get_user`, best-effort batched cache); `GET /facets` - distinct values for filter dropdowns; `POST /admin/cleanup` - retention purge. **`POST /report` is NOT admin-gated** — any authenticated user's frontend posts the errors *the user actually hit* (HTTP 502 before any SSE frame / network drop / SSE error frame) so they're collected too; stored as `scope="client"`. Backed by `deerflow.persistence.roundtable_diagnostics` (table `roundtable_diagnostics`, auto-created by `create_all`; migration `20260619_01`); store wired in `deps.py` as `app.state.roundtable_diagnostic_store` + a process-level default store (`register_default_store`) so the harness-side engine/gateway can write across the app/harness boundary. **Writers** (all best-effort — diagnostics never break a run): background path uses `DiagnosticsRecorder` (bound to job ctx) threaded through `RoundtableJobExecutor` → `JobProgressWriter.diag(...)` (engine transitions/step3 failures/empty-delivery) + `InProcessRoundtableGateway` (per-turn timeout/report·summary·dashboard truncation+missing); foreground path uses `app/gateway/roundtable_diag.py::record_foreground_diag` in `multi_agent.py`/`intent.py`/`recommend.py` error frames. **Bug-fix shipped alongside**: `InProcessRoundtableGateway._run_agent_turn` guards each agent run with an **inactivity (idle) timeout** (`_await_run_with_idle_guard`, `ROUNDTABLE_SEAT_IDLE_TIMEOUT_SECONDS` default 1200s = 20min, tuned high for unstable intranet models) — **not** a total-duration cap, so a slow-but-alive run that keeps streaming tokens runs as long as it needs; only a run that emits **zero StreamBridge events for `idle_timeout` seconds straight** (a true freeze: 内网模型连接池挂死 / sqlite 锁在挂载卷上挂死) is cancelled + raises (顶层把 job 标 error,而非永久冻结) + records a `seat_turn_timeout` diagnostic. Activity is observed via `app.state.stream_bridge` (the worker publishes every chunk there). Optional absolute cap `ROUNDTABLE_SEAT_MAX_TOTAL_SECONDS` (default 0 = off). Frontend: admin page `pages/RoundtableDiagnosticsPage.tsx` (route `/page/strategy/admin/roundtable-diagnostics`), opened from the **会商页右上角设置按钮** (admin only — `handleSettingsClick` in `roundtable-planning/pages/RoundtablePlanningPage.tsx`); admin read client `strategy-components/api/roundtable-diagnostics.ts`. **Client error reporter** `roundtable-planning/api/diagnostics-report.ts` (`reportRoundtableError` fire-and-forget + `keepalive`, `setRoundtableErrorContext` for draft/task attribution set by the page, `stageForRoundtableAgent`) is called at every client-observable failure: `multi-agent.ts` (`streamMultiAgent` — covers Step2 leader/seat AND Step3 report/summary/dashboard/structure — reports HTTP error + SSE `{status:"error"}` frame + worker `event:error` frame [模型调用没成功] + fetch-reject [网络不可达] + mid-stream drop [连接重置/超时], deduped via a `reported` flag; `initMultiAgent` HTTP/error-frame/incomplete/mid-stream), `intent.ts` (Step1 HTTP+error-frame), `recommend.ts` (Step2 recommend HTTP+error-frame), `roundtable-jobs.ts` (job api/stream). The admin page's first filter is a **后台挂起 / 前台调用** toggle (frontend = `foreground,client`); the stage dropdown options adapt to it (background → step2+step3+job; frontend → step1+step2+step3) and each row shows the reporting user's name + 👤. Tests: `tests/test_roundtable_diagnostics.py`, `tests/test_roundtable_inprocess_timeout.py`. **Seat skill-recognition (弱模型补偿)**: a seat = one custom agent run whose configured skills (`agent config.yaml → skills`) are injected **only** via the system prompt's `` block (model must `read_file` the SKILL.md itself) — weak models (deepseek-chat 等) ignore it and never use their skills. `app/gateway/roundtable_seat_skills.py::append_seat_skill_directive(agent_id, task, *, chain_enabled=False)` appends an explicit Chinese directive (each configured-and-enabled skill's name + description + SKILL.md container path + "先 read_file 再按其流程取信息/执行") to the **seat's per-turn task (human turn)** — far stronger pull on weak models than the system prompt. Wired into **both** seat paths: foreground `multi_agent._special_run` (only `role in {seat, seat_parallel}`; Step3 singletons report/summary/dashboard/ingest excluded) + background `InProcessRoundtableGateway.run_seat`. No-op (returns task unchanged) when the seat has no configured skills. **2026-06-20: default-off + two opt-in switches (OR semantics)** — was global-on, now injects only when **either** the seat agent's own switch (`AgentConfig.seat_skill_directive`, edited in the agent edit dialog `agent-card.tsx` + `agent-detail-panel.tsx`) **or** the **per-seat** switch on that agent **within the selected business chain** is on. The per-seat chain switch lives **in the chain's `seats` JSON** (`ChainSeatSchema.seat_skill_directive`, no DB column — rides the existing JSON like the per-seat `model` override), edited via a small ✨ icon → dropdown (Switch + description) on each agent card in `BusinessChainEditorPage`. It is threaded **per-seat** to each seat run: foreground `multi_agent` per-call field `seat_skill_directive` (orchestration looks it up per agentId from a `{agentId: bool}` session map) → `_special_run` → `chain_enabled`; background as the job field `seat_skill_directives: dict[str,bool]` (StartJobRequest → StartParams → deps factory → `InProcessRoundtableGateway._seat_skill_directives`) → `run_seat` looks up `agent_id` → `chain_enabled`. `ROUNDTABLE_SEAT_SKILL_DIRECTIVE` is only a **master kill-switch** (default allow; set `0/false` to hard-disable regardless of the switches — never force-enables). Tests: `tests/test_roundtable_seat_skills.py`, `tests/test_agents_backend.py`. **2026-06-20 Step2 提速三件套**:(1) **每席位推理深度覆盖** —— 业务链条 `seats` JSON 里逐席位可配 `seat_mode`(flash/thinking/pro/ultra,覆盖链条级 `seatMode`;编辑器席位卡右侧 ⏱Gauge 图标下拉,null=跟随链条默认)。前台经 `useStep2Orchestration` 三处 dispatch 的 `seatModeToRequestFields(seatModes[id] ?? selectedMode)` 透传;后台经 job `seat_modes: dict[str,str]` → `StartParams` → `InProcessRoundtableGateway._seat_modes`,`run_seat` 用 `_SEAT_MODE_PARAMS`(与前端 `seatModeToRunParams` 逐字对齐)解析覆盖作业级 seat_*(未命中→跟随作业级→policy 默认)。无 DB 列(ride `seats` JSON,但 `ChainSeatSchema.seat_mode` 须显式声明否则 Pydantic 剥掉)。Tests: `tests/test_roundtable_seat_modes.py`。(2) **recommend 批内并行** —— 后端 `engine._run_recommend` 由 for-await 串行改 `asyncio.gather`(对齐前端 2026-06-13 已并行;总控一轮派多席位=判定相互独立→并发;批内用派活前 deliveries 快照、批后统一并入,语义不变)。(3) **研讨前并行『取数』两段式**(业务链条 `gather_first` 列,逐条 opt-in,migration `20260620_02`,editor「研讨前并行取数」开关)—— `gather_first=True` 时 `run_orchestration` 在分发前先 `engine._run_gather_phase`(全席位 `asyncio.gather` 跑 `_GATHER_TASK`「只取数不研讨」),产出 `gathered: dict` 经 `_seat_message(gathered=...)` 作为「已收集资料」块注入 recommend/chain/dag 各研讨轮席位任务(与「前序交付」块分开),把最慢的技能延迟前置并行、给(尤其 chain 串行的)研讨段提速;研讨阶段不限制再调技能。前台对称实现:`useStep2Orchestration.runGatherPhase` 在 `runActiveOrchestration` 分发前跑一次(`gatherPhaseDoneRef` 闸门,resetInitGate 复位),产出存 `gatheredDeliveriesRef` 经 `peerDeliveries`/初始 `priorDeliveries` 注入研讨席位。链条级 `gatherFirst`(camel)↔`gather_first`(snake) 经 `api/chains.ts` 映射;job 经 `StartJobRequest.gather_first`→`StartParams`→`run_orchestration(gather_first=…)`。Tests: `tests/test_roundtable_gather_phase.py`。(4) **削减重复广播**(前端 only,多智能体 foreground 路径)—— `suppress_pre_broadcast`(仅 leader:抑制「总控本轮输入→各席位 thread」预广播 + 「总控给X派活」→兄弟席位的 post 广播,二者一个 flag)此前只在 DAG 派活轮开;现扩到 recommend(`safetyCounter > 1`,即第 2 轮起的续轮/综合)+ chain 逐席位(`i > 0`,第 2 个席位起)+ chain 收口轮(恒 true)。**保留每种模式的首轮预广播**(它为席位打底任务背景),只 suppress 重复的续轮广播(N 次读写巨大席位 thread 的纯浪费 + 弱模型模仿派活);席位的前序交付另经「席位交付广播」`broadcastSeatDeliveryToOthers` 送达,不受影响。后台引擎路径无此广播(`_seat_message` 直接拼上下文),故 #4 仅前端。**2026-06-20 深链接业务链抽取规范**:深链接会商(taskId+goPath,businessCode 如 rwfx→6BF)下,结构化抽取(flow-json)与方案总结报告都按所选业务链**完整层级**强制校正。结构化抽取实为 `roundtable-structure` 智能体(`flow_extract.py` 的 `_system_prompt()=load_agent_soul(STRUCTURE_AGENT_ID)`,抽取→校验两段式 `model.astream`);其 SOUL 全局一份,旧实现把某业务层级写死进 SOUL→所有业务都按「目的→行为体」抽(bug)。修法:**单份领域知识、两角色用法**的 `deerflow.agents.roundtable_orchestrator.extraction_spec`(放 harness 层,app+harness 都可 import;`build_structure_directive`/`build_summary_directive` 按 businessCode 取;rwfx=6BF 写实「任务→目的→行为体→预期行为→驱动因素→叙事,预期行为↔驱动因素 1:1、余 1:N、label 不含步骤/阶段名、优先报告回退席位」,其余业务通用规范=强约束完整性+命名+取数且明令不套固定模板;business_code 空→""零副作用)。注入:(1) 结构化抽取 `flow_extract._hierarchy_hint`+`_validate_messages` 用 `build_structure_directive` 权威覆盖默认层级(前端 `streamFlowExtract` 传 `business_code`,源 `ingestBusinessCode`);(2) 方案总结报告 `build_summary_directive`——前台 `multi_agent._special_run`(role==summary 且 business_code) 追加、后台 `engine.build_summary_prompt(business_code=…)`(StartParams/StartJobRequest 透传)。**仅深链接生效**。种子 `roundtable-structure/SOUL.md` 层级顺序改 spec-driven、连接示例去 rwfx 写死(存量 live SOUL 不被 seed 覆盖,但注入规范权威可纠正)。Tests: `tests/test_roundtable_extraction_spec.py`。 | | **Roundtable Draft Shares** (`/api/roundtable-draft-shares`) | Per-draft sharing for the roundtable-planning page, mirroring Thread Shares but the shared resource is a `roundtable_drafts` row. Two modes (`roundtable_draft_shares.mode`). **`import`**: `POST /` `{draft_id, mode}` - create (or reuse) a revocable code; `GET /{code}/preview` + `POST /{code}/import` - copy that draft (step1/2/3) into the caller's account under a deterministic per-(code,recipient) id (`imp_` ? re-import is a no-op). **`view`**: a public, read-only result page ? see Public Roundtable Shares. Both: `GET /` - list my codes; `DELETE /{code}` - revoke. Backed by `deerflow.persistence.roundtable_draft_shares` (table `roundtable_draft_shares`, migration `20260606_01`); store wired in `deps.py` as `app.state.roundtable_draft_share_store`. Tests: `tests/test_roundtable_draft_shares.py`. | | **Public Roundtable Shares** (`/api/public/roundtable-shares`) | **Unauthenticated** (`/api/public/` whitelisted). `GET /{code}` - read-only snapshot for a `view`-mode draft share: returns the Step 3??????report HTML (`step3.html`, a self-contained document) + title/summary/`has_report`. The frontend renders it in a sandboxed `iframe` (no `allow-scripts`) at `#/roundtable-share/`. Missing/revoked/non-`view` codes all return 404. | | **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. | diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py index 9ef5ccd5..75574acf 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py @@ -47,6 +47,8 @@ class DraftMetaResponse(BaseModel): task_id: str | None = None title: str = "" furthest_step: int = 1 + # 本次会商所用业务链名(历史卡片展示);无链条(自由/recommend 模式)时为 None。 + chain_title: str | None = None created_at: datetime | str | None = None updated_at: datetime | str | None = None # 最近一次后台作业的状态(Phase 6 历史下拉「进行中」loading 用);无作业时为 None。 diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_task_drafts.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_task_drafts.py index b0696cdd..1bea7a18 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_task_drafts.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_task_drafts.py @@ -42,6 +42,8 @@ class TaskDraftMetaResponse(BaseModel): task_id: str title: str = "" furthest_step: int = 1 + # 本次会商所用业务链名(历史卡片展示);无链条(自由/recommend 模式)时为 None。 + chain_title: str | None = None created_at: datetime | str | None = None updated_at: datetime | str | None = None # Latest background-job status for this draft (history-dropdown loading badge). diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_diagnostics/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_diagnostics/__init__.py index 2afba98c..99204114 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_diagnostics/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_diagnostics/__init__.py @@ -41,10 +41,17 @@ def get_default_store() -> RoundtableDiagnosticRepository | None: async def record_diagnostic(store: RoundtableDiagnosticRepository | None = None, **fields: Any) -> None: - """Best-effort 写一条诊断事件。``store`` 缺省用进程级默认仓储。任何异常静默吞掉。""" + """Best-effort 写一条诊断事件。``store`` 缺省用进程级默认仓储。任何异常静默吞掉。 + + 会商诊断**只收集报错日志**:``info`` 级是纯运行进度(begin/consensus/gather/job_started + 等「运行日志」),对排障没价值、还会淹没真正的报错,直接不落库;只保留 ``warning`` / + ``error``(席位失败、空交付、超时、模型容错、产物缺失、崩溃等)。 + """ repo = store or _default_store if repo is None: return + if str(fields.get("level") or "").strip().lower() == "info": + return try: await repo.record(**fields) except Exception: # noqa: BLE001 — 诊断日志写失败绝不打断会商 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_drafts/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_drafts/sql.py index aeb74b88..2ca3ddcb 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_drafts/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_drafts/sql.py @@ -11,6 +11,7 @@ All write/read paths are scoped by ``user_id`` for ownership isolation. from __future__ import annotations import json +import re from datetime import UTC, datetime from typing import Any @@ -24,6 +25,29 @@ from deerflow.persistence.roundtable_drafts.model import ( _UNSET = object() +# 业务链名抽取:在已加载进内存的 step2 JSON 串里直接找 `"chain":{...,"title":"X"}`, +# 不整体 json.loads(step2 是 LONGTEXT 大块),只做一次廉价正则扫描。`"chain":null` +# (recommend/自由模式)不匹配 → 返回 None。`[^{}]*?` 限定在 chain 对象内({id,title} +# 无嵌套花括号)。 +_CHAIN_TITLE_RE = re.compile(r'"chain"\s*:\s*\{[^{}]*?"title"\s*:\s*"((?:\\.|[^"\\])*)"') + + +def _extract_chain_title(step2_raw: Any) -> str | None: + """从 step2 原始 JSON 串里抽取所用业务链名(无链条 → None,前端显示「自由模式」)。""" + if not isinstance(step2_raw, str) or not step2_raw: + return None + m = _CHAIN_TITLE_RE.search(step2_raw) + if not m: + return None + title = m.group(1) + try: + # 还原 JSON 字符串转义(\" \\ \n \uXXXX 等)。 + title = json.loads(f'"{title}"') + except Exception: + pass + title = title.strip() + return title or None + def _loads(value: Any) -> Any: if not isinstance(value, str): @@ -56,6 +80,8 @@ class RoundtableDraftRepository: "task_id": row.task_id, "title": row.title, "furthest_step": row.furthest_step, + # 所用业务链名(历史卡片展示用);无链条 → None → 前端显示「自由模式」。 + "chain_title": _extract_chain_title(row.step2), "created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at, "updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at, } diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_task_drafts/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_task_drafts/sql.py index c415f62d..fad0ef0a 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_task_drafts/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_task_drafts/sql.py @@ -15,6 +15,7 @@ in ``PortableLongText`` and round-tripped with ``json.dumps`` / ``json.loads``. from __future__ import annotations import json +import re from datetime import UTC, datetime from typing import Any @@ -25,6 +26,27 @@ from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraf _UNSET = object() +# 业务链名抽取:在已加载进内存的 step2 JSON 串里直接找 `"chain":{...,"title":"X"}`, +# 不整体 json.loads(step2 是 LONGTEXT 大块)。`"chain":null`(自由模式)不匹配 → None。 +# 与 roundtable_drafts/sql.py 保持一致。 +_CHAIN_TITLE_RE = re.compile(r'"chain"\s*:\s*\{[^{}]*?"title"\s*:\s*"((?:\\.|[^"\\])*)"') + + +def _extract_chain_title(step2_raw: Any) -> str | None: + """从 step2 原始 JSON 串里抽取所用业务链名(无链条 → None,前端显示「自由模式」)。""" + if not isinstance(step2_raw, str) or not step2_raw: + return None + m = _CHAIN_TITLE_RE.search(step2_raw) + if not m: + return None + title = m.group(1) + try: + title = json.loads(f'"{title}"') + except Exception: + pass + title = title.strip() + return title or None + def _loads(value: Any) -> Any: if not isinstance(value, str): @@ -55,6 +77,8 @@ class RoundtableTaskDraftRepository: "task_id": row.task_id, "title": row.title, "furthest_step": row.furthest_step, + # 所用业务链名(历史卡片展示用);无链条 → None → 前端显示「自由模式」。 + "chain_title": _extract_chain_title(row.step2), "created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at, "updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at, } From 23743ee1930a329a5e46d3338fe57650ca32f113 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Mon, 22 Jun 2026 15:52:35 +0800 Subject: [PATCH 08/17] =?UTF-8?q?chore:=20=E7=B2=BE=E7=AE=80=20AI=20?= =?UTF-8?q?=E5=86=99=E4=BD=9C=E7=A4=BA=E4=BE=8B=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ai-writing 表单/检索/欢迎页的占位与示例改为通用文案。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/open-canvas/components/ai-writing/AIWritingForm.tsx | 2 +- .../src/open-canvas/components/ai-writing/MaterialsToolbar.tsx | 2 +- .../src/open-canvas/components/ai-writing/WritingWelcome.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx index aa6a7d60..c89efb9f 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx @@ -192,7 +192,7 @@ export function AIWritingForm({