From 6aaa4c841e5484198b75f4830f8e5fef4e160f68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Thu, 18 Jun 2026 20:55:41 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(task-deeplink):=203qfx/xdfx=20?= =?UTF-8?q?=E6=96=B0=E5=BB=BA=E5=AF=B9=E8=AF=9D=E6=94=B9=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E3=80=8C=E5=BC=80=E5=A7=8B=E5=9B=9E=E7=AD=94=E3=80=8D+=20xdfx?= =?UTF-8?q?=20=E8=A1=8C=E5=8A=A8=E8=AF=A6=E6=83=85=E5=8D=A1=E7=89=87=20+?= =?UTF-8?q?=20=E7=8B=AC=E7=AB=8B=E9=97=AE=E7=AD=94=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E5=88=97=E8=A1=A8=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AgentChatPage: 纯绑定深链(3qfx/xdfx)不再自动发开场信息,改由右上角「开始回答」按钮手动触发,从根上消除新建对话竞态导致的重复发送;移除 autoSentArmedRef/streamThreadId 相关逻辑 - task-deeplink.ts: 抽出 fetchActionDetail 返回整包 ActionDetail,fetchTaskIdFromAction 复用之 - TaskFxWorkspace: xdfx 左栏「当前任务」下新增「行动详情」卡片(行动名称/叙事) - StrategyRoutes/WorkspaceRoutes: 独立智能体问答整页新增最左「智能体列表」栏(showAgentList);xdfx 右侧宿主改用 TaskBindChat(精简版) - 新增 agent-chat-sidebar.tsx Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspace/agents/agent-chat-sidebar.tsx | 172 ++++++++++++++++++ frontend-web/src/core/auth/task-deeplink.ts | 50 ++++- frontend-web/src/pages/AgentChatPage.tsx | 64 ++++--- frontend-web/src/pages/StrategyRoutes.tsx | 7 +- frontend-web/src/pages/WorkspaceRoutes.tsx | 2 +- .../task-components/TaskFxWorkspace.tsx | 52 +++++- 6 files changed, 298 insertions(+), 49 deletions(-) create mode 100644 frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx diff --git a/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx new file mode 100644 index 00000000..01fc66ef --- /dev/null +++ b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { Bot, Loader2 } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; + +import { agentColor } from "@/components/workspace/agent-icon-color"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useTheme } from "@/contexts/ThemeContext"; +import { useAgentsPaginated } from "@/core/agents"; +import { useRouteBase } from "@/core/route-base"; +import { usePublishSquares } from "@/core/system-settings/hooks"; +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; + +/** 「我的」作用域的下拉选项值;其余选项值为各发布广场(publish square)的 id。 */ +const MINE_SCOPE = "mine"; + +// 列表一次拉取的条数。超出时底部出现「查看全部」入口,跳到完整的智能体管理页, +// 避免静默截断。侧边栏只做快速切换,不在此分页。 +const LIST_PAGE_SIZE = 50; + +/** + * 智能体问答页左侧「智能体列表」栏。 + * + * - 顶部下拉:选「我的」(默认,= 我创建/收藏的)或任一「广场」(发布广场 publish square), + * 与「智能体管理」页的主标签一致。 + * - 下方:所选作用域下的智能体列表;点击进入该智能体的新对话(保持当前路由命名空间)。 + * - 当前正在对话的智能体高亮。 + * + * 仅在独立的智能体问答整页(/page/strategy|workspace/agents/:id/chats/:thread)展示; + * 任务工作区 / zzJC·zzXD 模块 / 嵌入态不展示(它们各有自己的左栏)。 + */ +export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string }) { + const routeBase = useRouteBase(); + const { theme } = useTheme(); + const isDark = theme === "dark"; + + const { data: publishSquares } = usePublishSquares(); + // 每个发布广场对应一个下拉选项;后端至少返回一个内置默认广场。 + const squares = useMemo( + () => publishSquares?.squares ?? [], + [publishSquares?.squares], + ); + + // 作用域:MINE_SCOPE 或某个广场 id。默认「我的」。 + const [scope, setScope] = useState(MINE_SCOPE); + + // 若所选广场被管理员删除,回退到「我的」,避免指向不存在的广场。 + useEffect(() => { + if ( + scope !== MINE_SCOPE && + squares.length > 0 && + !squares.some((sq) => sq.id === scope) + ) { + setScope(MINE_SCOPE); + } + }, [scope, squares]); + + const isMine = scope === MINE_SCOPE; + const { agents, total, isLoading, isFetching } = useAgentsPaginated({ + scope: isMine ? "mine" : "square", + sort: "time_desc", + page: 1, + pageSize: LIST_PAGE_SIZE, + square: isMine ? undefined : scope, + }); + + return ( + // 配色与 AgentModuleSidebar 一致:浅色白底,深蓝主题半透明深蓝玻璃面板。 + + ); +} diff --git a/frontend-web/src/core/auth/task-deeplink.ts b/frontend-web/src/core/auth/task-deeplink.ts index 08e73e69..c8a2322c 100644 --- a/frontend-web/src/core/auth/task-deeplink.ts +++ b/frontend-web/src/core/auth/task-deeplink.ts @@ -111,13 +111,29 @@ export function readCopTaskDetail(): CopTaskDetail | null { } } +/** 行动详情接口 ``{action_detail_url}/{actionId}`` 返回的 ``data`` 字段(xdfx 行动分析用)。 */ +export interface ActionDetail { + /** 行动id(深链 ?taskId= 传入的就是它)。 */ + id: string; + /** 该行动归属的真正「任务id」。 */ + taskId: string; + /** 所属目的id。 */ + purposeId: string; + /** 行动名称。 */ + actionName: string; + /** 行动状态码。 */ + actionStatus: number | string; + /** 行动叙事 / 描述。 */ + narration: string; +} + /** - * xdfx 深链:用「行动id」调行动详情接口 ``{action_detail_url}/{actionId}``,取出 ``data.taskId`` - * 作为真正的「任务id」。``action_test=true`` 时直接用后端配置里的假数据(接口未通时用)。 - * 缺地址 / 返回不合法 → 返回 ""(调用方据此回退)。 + * xdfx 深链:用「行动id」调行动详情接口 ``{action_detail_url}/{actionId}``,取出整包行动详情。 + * ``action_test=true`` 时直接用后端配置里的假数据(接口未通时用)。 + * 缺地址 / 返回不合法 → 返回 null。 */ -export async function fetchTaskIdFromAction(actionId: string): Promise { - if (!actionId) return ""; +export async function fetchActionDetail(actionId: string): Promise { + if (!actionId) return null; const cfg = await fetchTaskDeeplinkConfig(); let body: unknown; if (cfg.actionTest) { @@ -127,12 +143,28 @@ export async function fetchTaskIdFromAction(actionId: string): Promise { // service 拦截器已统一注入 Authorization: Bearer admin 并返回整包 body;行动id 走路径段。 body = await requestCrudService.get(`${base}/${encodeURIComponent(actionId)}`); } else { - return ""; + return null; } const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null; - if (!data || typeof data !== "object") return ""; - const taskId = (data as Record).taskId; - return taskId != null && taskId !== "" ? String(taskId) : ""; + if (!data || typeof data !== "object") return null; + const d = data as Record; + return { + id: d.id != null ? String(d.id) : "", + taskId: d.taskId != null ? String(d.taskId) : "", + purposeId: d.purposeId != null ? String(d.purposeId) : "", + actionName: typeof d.actionName === "string" ? d.actionName : "", + actionStatus: + typeof d.actionStatus === "number" || typeof d.actionStatus === "string" ? d.actionStatus : "", + narration: typeof d.narration === "string" ? d.narration : "", + }; +} + +/** + * xdfx 深链:用「行动id」解析出真正的「任务id」(取行动详情的 ``data.taskId``)。 + * 缺地址 / 返回不合法 → 返回 ""(调用方据此回退)。 + */ +export async function fetchTaskIdFromAction(actionId: string): Promise { + return (await fetchActionDetail(actionId))?.taskId ?? ""; } /** diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index fb2726f2..9761cede 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { Button } from "@/components/ui/button"; +import { AgentChatSidebar } from "@/components/workspace/agents/agent-chat-sidebar"; import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel"; import { AgentQuestionChips } from "@/components/workspace/agents/agent-question-chips"; import { AgentWelcome } from "@/components/workspace/agent-welcome"; @@ -39,6 +40,7 @@ export default function AgentChatPage({ taskGate, taskBind, autoStartWhenPurposeEmpty, + showAgentList, }: { /** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。 * 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */ @@ -56,6 +58,9 @@ export default function AgentChatPage({ taskBind?: boolean; /** rwfx:查询任务目的为空数据时,自动开始第一次问答(跳过手动点「开始问答」)。 */ autoStartWhenPurposeEmpty?: boolean; + /** 是否在最左侧展示「智能体列表」栏(顶部下拉切换 我的/广场,点击进入该智能体对话)。 + * 仅独立的智能体问答整页开启;任务工作区 / 模块页 / 嵌入态不传(各有自己的左栏)。 */ + showAgentList?: boolean; } = {}) { const routeBase = useRouteBase(); const { t } = useI18n(); @@ -80,16 +85,13 @@ export default function AgentChatPage({ taskCtxOn && taskContext && taskContext.agentId === agent_id ? taskContext.taskId : ""; - // 纯绑定(taskBind 且无闸门):新建对话自动发出的任务开场信息。rwfx 走闸门发出,不在此列。 - const taskAutoSendText = + // 纯绑定(taskBind,无闸门,3qfx/xdfx):新建对话进问答页后,右上角「开始回答」按钮点击时 + // 发出的任务开场信息。不再自动发(避免新建对话竞态重复发送),改由用户手动点按钮触发。 + // rwfx 走目的树闸门「开始问答」,不在此列。 + const taskStartText = taskBind && !taskGate && taskContext && taskContext.agentId === agent_id ? (taskContext.openingText?.trim() ?? "") : ""; - // 纯绑定每次进入「新建对话」自动发一次开场信息。用「武装」标志去重,而不是绑定到 threadId—— - // 新建态首帧 useThreadChat 会把 threadId 从 useState 初值换成新 uuid(抖动),若用 threadId 做 - // 去重,会被误判成「又一个新会话」而重复发送(表现为「发了两次、开始回答后又变回一个」)。 - // 「武装」只在会话真正创建完成(isNewThread 落回 false)后才重置,故对 threadId 抖动免疫。 - const autoSentArmedRef = useRef(true); // If ChatPage stashed an opening message for this agent, skip the landing // screen on first render so the user doesn't see a flash of the welcome UI @@ -106,7 +108,7 @@ export default function AgentChatPage({ } }; const [hasStartedConversation, setHasStartedConversation] = useState( - () => hasPendingForThisAgent() || !!taskAutoSendText, + () => hasPendingForThisAgent(), ); const { agent, isLoading: isAgentLoading } = useAgent(agent_id); @@ -125,19 +127,13 @@ export default function AgentChatPage({ useEffect(() => { if (isNewThread) { - setHasStartedConversation(hasPendingForThisAgent() || !!taskAutoSendText); + setHasStartedConversation(hasPendingForThisAgent()); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isNewThread, threadId, agent_id]); - // 会话真正创建完成(isNewThread 落回 false)后重新「武装」,让下一次「新建对话」能再自动发一次。 - // 不依赖 threadId,故新建态首帧的 threadId 抖动不会误触发重复发送。 - useEffect(() => { - if (!isNewThread) autoSentArmedRef.current = true; - }, [isNewThread]); - const { showNotification } = useNotification(); - const [thread, sendMessage, , streamThreadId] = useThreadStream({ + const [thread, sendMessage] = useThreadStream({ threadId: isNewThread ? undefined : threadId, assistantId: agent_id, context: { ...settings.context, agent_id }, @@ -234,22 +230,11 @@ export default function AgentChatPage({ handleSubmit({ text: pending.text, files: [] }); }, [agent_id, threadId, isNewThread, handleSubmit]); - // 纯绑定任务深链(3qfx/xdfx,无闸门):进入新建对话即自动发出任务开场信息(每个新建会话恰好一次)。 - // - // 两道守卫缺一不可: - // 1) `streamThreadId == null`:等底层 stream 真正切到全新线程再发。从已有会话点「新建对话」时 - // isNewThread 先翻 true,但 onStreamThreadId 慢一帧才归零;若那一帧就 submit,hook 仍绑定上一个 - // 会话(submit 的 threadId 选项仅在 hook 线程为空时生效)→ 开场信息发进旧对话。 - // 2) `autoSentArmedRef`:本轮新建会话是否还「待发」。只在会话创建完成(isNewThread→false)后重置, - // 不受 threadId 抖动影响,避免同一个新建会话里重复发出。 - useEffect(() => { - if (!taskAutoSendText) return; - if (!isNewThread) return; - if (streamThreadId != null) return; - if (!autoSentArmedRef.current) return; - autoSentArmedRef.current = false; - handleSubmit({ text: taskAutoSendText, files: [] }); - }, [taskAutoSendText, isNewThread, streamThreadId, handleSubmit]); + // 纯绑定任务深链(3qfx/xdfx):新建对话进问答页 landing 后,由 header 右上角「开始回答」按钮 + // 手动触发发出开场信息(见下方 header)。不再自动发,从根上避免新建对话竞态导致的重复发送。 + const handleTaskStart = useCallback(() => { + if (taskStartText) handleSubmit({ text: taskStartText, files: [] }); + }, [handleSubmit, taskStartText]); const handleClarificationSubmit = useCallback( (answer: string) => { @@ -265,7 +250,7 @@ export default function AgentChatPage({ const isLandingState = isNewThread && !hasStartedConversation; // 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。 - // 3qfx/xdfx(taskBind)不展示闸门,靠 taskAutoSendText 直接发出开场信息。 + // 3qfx/xdfx(taskBind)不展示闸门,新建对话进问答页后由右上角「开始回答」按钮手动发出开场信息。 const showTaskGate = !!taskGate && !!taskId && isNewThread && !hasStartedConversation; const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"; @@ -283,6 +268,8 @@ export default function AgentChatPage({ always opens between the chat and the detail panel — the detail panel stays rightmost. */}
+ {/* 最左:智能体列表栏(仅独立整页问答开启)。顶部下拉切换 我的/广场。 */} + {showAgentList && }
{/* Main conversation column. `relative` hosts the floating @@ -305,6 +292,17 @@ export default function AgentChatPage({
+ {/* 3qfx/xdfx 任务深链:新建对话进问答页后,点此发出任务开场信息开始问答(不自动发)。 */} + {taskStartText && isLandingState && ( + + )}
+ {/* xdfx:任务详情下面再展示行动详情 */} + {business === "xdfx" && }
任务对话
@@ -308,7 +354,7 @@ export function TaskFxChat({ ); } -/** 右侧对话宿主(3qfx/xdfx):AgentChatPage taskBind → 仅 1:N 绑定,新建对话直接带开场信息自动发出。 */ +/** 右侧对话宿主(3qfx/xdfx):AgentChatPage taskBind → 仅 1:N 绑定,新建对话进问答页,右上角「开始回答」按钮手动发出开场信息。 */ export function TaskBindChat({ taskBase }: { taskBase: string }) { const { agent_id } = useParams<{ agent_id: string }>(); return ( From 0bd45243349c4e80a040dc13e2c97ada58f6d2ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Fri, 19 Jun 2026 09:51:50 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(task-deeplink):=20=E9=A6=96=E6=AC=A1?= =?UTF-8?q?=E8=BF=9B=E5=85=A5=E4=B8=94=E4=BB=BB=E5=8A=A1=E6=97=A0=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E6=97=B6=E8=87=AA=E5=8A=A8=E5=8F=91=E9=80=81=EF=BC=8C?= =?UTF-8?q?=E6=97=A0=E9=9C=80=E6=89=8B=E5=8A=A8=E7=82=B9=E5=BC=80=E5=A7=8B?= =?UTF-8?q?=E5=9B=9E=E7=AD=94/=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增「首次进入且该任务无聊天对话」信号 taskAutoStart:TaskFxIndexResolver 在 「无历史 → /new」时注入 state,用户主动点「新建对话」则不带。 - 3qfx/xdfx:taskAutoStart 时自动发出开场词(一次性 ref,不受 mount 时 threadId 抖动影响、不会重复发),不显示「开始回答」按钮;用户主动新建对话才显示按钮手动发。 - rwfx:闸门 autoStartWhenEmpty = autoStartWhenPurposeEmpty && taskAutoStart,即仅 首次无对话历史时目的树为空才自动开始;目的树有数据始终弹「覆盖并开始问答」确认(不误删 B 系统数据)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend-web/src/pages/AgentChatPage.tsx | 31 ++++++++++++++++--- .../task-components/TaskFxWorkspace.tsx | 4 ++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 9761cede..b8da2890 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -2,6 +2,7 @@ import { BotIcon, PlusSquare } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; +import { useLocation } from "react-router-dom"; import { useRouteBase } from "@/core/route-base"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -92,6 +93,11 @@ export default function AgentChatPage({ taskBind && !taskGate && taskContext && taskContext.agentId === agent_id ? (taskContext.openingText?.trim() ?? "") : ""; + // 「首次进入且该任务无历史」信号:由 TaskFxIndexResolver 在「无聊天对话 → /new」时注入 + // (用户主动点「新建对话」不带)。3qfx/xdfx 据此自动发出开场词、跳过「开始回答」按钮; + // rwfx 据此决定目的树为空时是否自动开始(仍由 TaskPurposePanel 在有数据时弹覆盖确认)。 + const location = useLocation(); + const taskAutoStart = !!(location.state as { taskAutoStart?: boolean } | null)?.taskAutoStart; // If ChatPage stashed an opening message for this agent, skip the landing // screen on first render so the user doesn't see a flash of the welcome UI @@ -230,12 +236,24 @@ export default function AgentChatPage({ handleSubmit({ text: pending.text, files: [] }); }, [agent_id, threadId, isNewThread, handleSubmit]); - // 纯绑定任务深链(3qfx/xdfx):新建对话进问答页 landing 后,由 header 右上角「开始回答」按钮 - // 手动触发发出开场信息(见下方 header)。不再自动发,从根上避免新建对话竞态导致的重复发送。 + // 纯绑定任务深链(3qfx/xdfx):用户主动「新建对话」进 landing 后,由 header 右上角「开始回答」 + // 按钮手动触发发出开场信息(见下方 header)。手动场景不自动发,避免新建对话竞态导致的重复发送。 const handleTaskStart = useCallback(() => { if (taskStartText) handleSubmit({ text: taskStartText, files: [] }); }, [handleSubmit, taskStartText]); + // 首次进入且该任务无历史(taskAutoStart):3qfx/xdfx 直接自动发出开场信息,无需手动点「开始回答」。 + // autoStartedRef 一次性、不随 threadId 抖动重置,故只发一次(首次进入是干净 mount,无上一会话之忧)。 + const autoStartedRef = useRef(false); + useEffect(() => { + if (!taskAutoStart) return; + if (!taskStartText) return; + if (!isNewThread) return; + if (autoStartedRef.current) return; + autoStartedRef.current = true; + handleSubmit({ text: taskStartText, files: [] }); + }, [taskAutoStart, taskStartText, isNewThread, handleSubmit]); + const handleClarificationSubmit = useCallback( (answer: string) => { setHasStartedConversation(true); @@ -292,8 +310,9 @@ export default function AgentChatPage({
- {/* 3qfx/xdfx 任务深链:新建对话进问答页后,点此发出任务开场信息开始问答(不自动发)。 */} - {taskStartText && isLandingState && ( + {/* 3qfx/xdfx 任务深链:用户主动「新建对话」进问答页后,点此发出任务开场信息开始问答。 + 首次进入且无历史(taskAutoStart)会自动发,不显示此按钮。 */} + {taskStartText && isLandingState && !taskAutoStart && (