feat(agent-chat): 修复智能体会话归属与历史路由恢复:侧边栏按当前agent过滤会话并携带agent上下文跳转,补充旧线程路径回溯到智能体工作区,防止误入通用问答,并完善输入框记忆按钮显隐与侧边栏展开入口,提升体验一致性。

This commit is contained in:
董宏凯 committed 2026-09-01 13:47:31 +08:00
1 parent 051e66f7d8
commit aeb9a55497
5 files changed
+140 -30

No files matched your search

@@ -64,7 +64,7 @@ import {
import { import {
useDeleteThread, useDeleteThread,
useRenameThread, useRenameThread,
useThreads, useThreadsPage,
useToggleThreadFavorite, useToggleThreadFavorite,
useToggleThreadPin, useToggleThreadPin,
} from "@/core/threads/hooks"; } from "@/core/threads/hooks";
@@ -351,8 +351,8 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string })
} }
/** /**
* 「最近的对话」面板(参考首页 GeneralQaSidebar 的「最近的对话」区块)。 * 当前智能体的「最近对话」面板(参考首页 GeneralQaSidebar 的「最近的对话」区块)。
* 跨智能体的近期会话,点击打开;支持重命名/置顶/收藏/分享/导出/删除。 * 按 agent_id 在服务端过滤;支持重命名/置顶/收藏/分享/导出/删除。
*/ */
function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) { function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
const { t } = useI18n(); const { t } = useI18n();
@@ -361,7 +361,12 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
const pathOf = usePathOfThread(); const pathOf = usePathOfThread();
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const { data: threads = [] } = useThreads(); const { data: threadPage } = useThreadsPage({
page: 1,
pageSize: 50,
metadata: { agent_id: activeAgentId ?? "" },
});
const threads = threadPage?.threads ?? [];
const { mutate: deleteThread } = useDeleteThread(); const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread(); const { mutate: renameThread } = useRenameThread();
const { toggle: togglePin } = useToggleThreadPin(); const { toggle: togglePin } = useToggleThreadPin();
@@ -373,8 +378,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
const [renameValue, setRenameValue] = useState(""); const [renameValue, setRenameValue] = useState("");
const [shareThreadId, setShareThreadId] = useState<string | null>(null); const [shareThreadId, setShareThreadId] = useState<string | null>(null);
// 排除调度/会商/报告结构&写作配置向导等系统会话,与首页一致; // 服务端先按当前智能体过滤;客户端再排除调度/会商/报告结构&写作配置向导等
// 智能体创建(bootstrap)会话保留展示(带机器人图标、点击回创建页);置顶优先。 // 系统会话。智能体创建(bootstrap)会话保留展示(带机器人图标、点击回创建页);置顶优先。
const visibleThreads = useMemo( const visibleThreads = useMemo(
() => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))), () => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
[threads], [threads],
@@ -479,7 +484,11 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
) : ( ) : (
filteredThreads.map((thread) => { filteredThreads.map((thread) => {
const isBoot = isBootstrapThread(thread); const isBoot = isBootstrapThread(thread);
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread); // 正常记录依据自身保存的 agent_id 跳转;早期记录可能尚未写入
// agent_id,此时保留当前智能体上下文,避免从智能体页误落到通用问答。
const href = isBoot
? bootstrapResumePath(thread.thread_id)
: pathOf(thread, { agent_id: activeAgentId });
const isActive = href === pathname; const isActive = href === pathname;
const pinned = isThreadPinned(thread); const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread); const favorited = isThreadFavorite(thread);
@@ -758,7 +758,7 @@ export function InputBox({
)} )}
/> />
)} )}
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip {!frontendRagActive && !hideContextTools && <Tooltip
content={ content={
context.writing_mode context.writing_mode
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料" ? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
@@ -813,7 +813,7 @@ export function InputBox({
<span className="text-xs font-normal">文件</span> <span className="text-xs font-normal">文件</span>
</PromptInputButton> </PromptInputButton>
</Tooltip>} </Tooltip>}
{!frontendRagActive && !hideContextTools && <Tooltip {!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
content={ content={
memoryInjectionEnabled memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆" ? "记忆注入已开启:对话前会注入历史记忆"
+13 -1
View File
@@ -27,6 +27,18 @@ function pickAgentRef(
return undefined; return undefined;
} }
/**
* Return the agent that owns a thread when that ownership was persisted on
* either its run context or its metadata. Keeping this in one place ensures
* sidebars and route recovery make the same decision for older deep links.
*/
export function agentRefOfThread(thread: {
context?: ThreadAgentRef | null;
metadata?: Record<string, unknown> | null;
}): string | undefined {
return pickAgentRef(thread.context, thread.metadata);
}
export function pathOfThread( export function pathOfThread(
thread: ThreadRouteTarget, thread: ThreadRouteTarget,
context?: ThreadAgentRef | null, context?: ThreadAgentRef | null,
@@ -36,7 +48,7 @@ export function pathOfThread(
const agentRef = const agentRef =
typeof thread === "string" typeof thread === "string"
? pickAgentRef(context, null) ? pickAgentRef(context, null)
: pickAgentRef(thread.context, thread.metadata); : agentRefOfThread(thread) ?? pickAgentRef(context, null);
return agentRef return agentRef
? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}` ? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
+37 -18
View File
@@ -3,6 +3,7 @@
import { import {
BotIcon, BotIcon,
Compass, Compass,
PanelLeftOpenIcon,
PanelRightOpenIcon, PanelRightOpenIcon,
PlusSquare, PlusSquare,
} from "lucide-react"; } from "lucide-react";
@@ -13,6 +14,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useSidebarSafe } from "@/components/ui/sidebar";
import { AgentChatSidebar } from "@/components/workspace/agents/agent-chat-sidebar"; import { AgentChatSidebar } from "@/components/workspace/agents/agent-chat-sidebar";
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel"; import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
import { AgentQuestionChips } from "@/components/workspace/agents/agent-question-chips"; import { AgentQuestionChips } from "@/components/workspace/agents/agent-question-chips";
@@ -225,6 +227,7 @@ export default function AgentChatPage({
const { t } = useI18n(); const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false); const [showFollowups, setShowFollowups] = useState(false);
const router = useRouter(); const router = useRouter();
const workspaceSidebar = useSidebarSafe();
const { agent_id: agentIdFromPath } = useParams<{ const { agent_id: agentIdFromPath } = useParams<{
agent_id: string; agent_id: string;
@@ -806,6 +809,23 @@ export default function AgentChatPage({
<main className="relative flex size-full min-h-0 min-w-0 flex-col"> <main className="relative flex size-full min-h-0 min-w-0 flex-col">
{/* Header in normal flow, fixed height. */} {/* Header in normal flow, fixed height. */}
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md"> <header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
{/* 工作区左栏收起后,在智能体聊天区左上角提供展开入口。 */}
{showAgentList &&
!isEmbedded &&
workspaceSidebar &&
!workspaceSidebar.open && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={workspaceSidebar.toggleSidebar}
title="展开左侧栏"
aria-label="展开左侧栏"
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<PanelLeftOpenIcon className="size-4" />
</Button>
)}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1"> <div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" /> <BotIcon className="text-primary h-3.5 w-3.5" />
<span <span
@@ -881,23 +901,6 @@ export default function AgentChatPage({
开始回答 开始回答
</Button> </Button>
)} )}
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
// 嵌入态:保持停留在通用问答右侧,开同一智能体的新对话;
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
router.push(
isEmbedded
? `/page/workspace/chats/new?agent=${agent_id}`
: `${agentChatsBase}/new`,
);
}}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
{/* 右侧智能体详情面板收起时的「展开」入口:仅收起态显示 {/* 右侧智能体详情面板收起时的「展开」入口:仅收起态显示
(展开态的「收起」按钮在面板右上角)。桌面端专用(lg+), (展开态的「收起」按钮在面板右上角)。桌面端专用(lg+),
窄屏面板走抽屉交互;嵌入态面板固定渲染,不显示该开关。 */} 窄屏面板走抽屉交互;嵌入态面板固定渲染,不显示该开关。 */}
@@ -919,6 +922,23 @@ export default function AgentChatPage({
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}
messages={displayThread.messages} messages={displayThread.messages}
/> />
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
// 嵌入态:保持停留在通用问答右侧,开同一智能体的新对话;
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
router.push(
isEmbedded
? `/page/workspace/chats/new?agent=${agent_id}`
: `${agentChatsBase}/new`,
);
}}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
<ExportTrigger threadId={threadId} /> <ExportTrigger threadId={threadId} />
<ArtifactTrigger /> <ArtifactTrigger />
</div> </div>
@@ -1101,7 +1121,6 @@ export default function AgentChatPage({
onAskQuestion={(text) => handleSubmit({ text, files: [] })} onAskQuestion={(text) => handleSubmit({ text, files: [] })}
collapsed={detailCollapsed} collapsed={detailCollapsed}
onCollapsedChange={setDetailCollapsed} onCollapsedChange={setDetailCollapsed}
showRecentChats={!taskCtxOn}
compactAppearanceActions={compactAppearanceActions} compactAppearanceActions={compactAppearanceActions}
/> />
</div> </div>
+72 -2
View File
@@ -1,7 +1,8 @@
import type { CSSProperties, ReactNode } from 'react'; import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom'; import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { getStoredAuth } from '@/core/auth'; import { getStoredAuth } from '@/core/auth';
import { getAPIClient } from '@/core/api';
import { isSentimentAgentVisible } from '@/core/sentiment-agent/config'; import { isSentimentAgentVisible } from '@/core/sentiment-agent/config';
import { import {
resolveChatsIframeEntryRedirect, resolveChatsIframeEntryRedirect,
@@ -25,6 +26,7 @@ import { ArtifactsProvider } from '@/components/workspace/artifacts';
import { DocumentRewritePresentationProvider } from '@/core/document-rewrite/context'; import { DocumentRewritePresentationProvider } from '@/core/document-rewrite/context';
import { RouteBaseProvider } from '@/core/route-base'; import { RouteBaseProvider } from '@/core/route-base';
import { agentRefOfThread } from '@/core/threads/utils';
import ChatsPage from './ChatsPage'; import ChatsPage from './ChatsPage';
import ChatPage from './ChatPage'; import ChatPage from './ChatPage';
@@ -341,12 +343,80 @@ function ChatThreadEntryRoute({
return ( return (
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen> <WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime> <ChatRuntime>
<ChatPage /> <AgentAwareChatThreadContent />
</ChatRuntime> </ChatRuntime>
</WorkspaceLayout> </WorkspaceLayout>
); );
} }
/**
* Older bookmarks and a few historic list entries use the generic
* ``/chats/:thread_id`` path even for an agent-owned conversation. Recover the
* owning agent from the persisted thread record before rendering so those
* links keep the agent workspace (agent list and detail panel) instead of
* falling back to the general Q&A page. Task workspace threads intentionally
* remain on their explicit task routes because their surrounding layout is
* part of the task context.
*/
function AgentAwareChatThreadContent() {
const { thread_id: threadId } = useParams<{ thread_id: string }>();
const location = useLocation();
const [agentId, setAgentId] = useState<string | null>(null);
const [resolved, setResolved] = useState(false);
useEffect(() => {
let cancelled = false;
setAgentId(null);
setResolved(false);
if (!threadId) {
setResolved(true);
return () => {
cancelled = true;
};
}
void getAPIClient()
.threads.get(threadId)
.then((thread) => {
if (cancelled) return;
const record = thread as {
context?: { agent_id?: string; agent_name?: string } | null;
metadata?: Record<string, unknown> | null;
};
// Task-bound threads need their task host to preserve the purpose gate,
// action partitioning, and other task-specific controls.
const isTaskThread = typeof record.metadata?.taskId === 'string';
setAgentId(isTaskThread ? null : agentRefOfThread(record) ?? null);
})
.catch(() => {
if (!cancelled) setAgentId(null);
})
.finally(() => {
if (!cancelled) setResolved(true);
});
return () => {
cancelled = true;
};
}, [threadId]);
if (!resolved) {
return <div className="size-full bg-background" aria-busy="true" />;
}
if (threadId && agentId) {
return (
<Navigate
replace
to={`/page/workspace/agents/${encodeURIComponent(agentId)}/chats/${threadId}${location.search}`}
/>
);
}
return <ChatPage />;
}
/** /**
* `chats_iframe` is reserved for the Brainy postMessage protocol. `from=iframe` * `chats_iframe` is reserved for the Brainy postMessage protocol. `from=iframe`
* and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding * and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding