Merge branch 'master-nj' into 'master'
feat: 智能体问答页右侧面板受控折叠,收起整列归零并在 header 提供展开入口 See merge request knowledge-base-bj/deerflow-code!33
This commit is contained in:
commit
4ce2f3a502
3 files changed
+239
-82
No files matched your search
@@ -144,6 +144,8 @@ export function AgentDetailPanel({
|
||||
onAskQuestion,
|
||||
disabled,
|
||||
defaultCollapsed,
|
||||
collapsed: collapsedProp,
|
||||
onCollapsedChange,
|
||||
showRecentChats,
|
||||
compactAppearanceActions,
|
||||
avatarSrc,
|
||||
@@ -163,6 +165,14 @@ export function AgentDetailPanel({
|
||||
disabled?: boolean;
|
||||
/** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */
|
||||
defaultCollapsed?: boolean;
|
||||
/**
|
||||
* 受控折叠态:传入时由父级持有状态(智能体问答页把展开/收起按钮放在顶部
|
||||
* 「新对话」右侧)。此时收起 = 整列宽度动画归零(不保留细条),面板内置的
|
||||
* 收起按钮不再渲染。不传则沿用面板内置状态 + 细条收起交互(会商等工作区)。
|
||||
*/
|
||||
collapsed?: boolean;
|
||||
/** 受控折叠态变化回调(配合 `collapsed` 使用)。 */
|
||||
onCollapsedChange?: (collapsed: boolean) => void;
|
||||
/** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */
|
||||
showRecentChats?: boolean;
|
||||
/** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */
|
||||
@@ -187,7 +197,15 @@ export function AgentDetailPanel({
|
||||
editIcon?: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
|
||||
// 折叠态:受控(collapsedProp 传入,智能体问答页)优先;否则面板内置(会商等工作区)。
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(
|
||||
defaultCollapsed ?? false,
|
||||
);
|
||||
const collapsed = collapsedProp ?? internalCollapsed;
|
||||
const setCollapsed = (next: boolean) => {
|
||||
if (collapsedProp === undefined) setInternalCollapsed(next);
|
||||
onCollapsedChange?.(next);
|
||||
};
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<AgentDraft | null>(null);
|
||||
@@ -249,8 +267,11 @@ export function AgentDetailPanel({
|
||||
const artifactsCtx = useArtifactsOptional();
|
||||
const artifactCount = artifactsCtx?.artifacts.length ?? 0;
|
||||
const selectedArtifact = artifactsCtx?.selectedArtifact ?? null;
|
||||
// preOpen:流式自动开沙箱的两阶段标记 —— 沙箱真正展开(open 翻转)前就
|
||||
// 先收起本面板,让收起布局先落地,避免与沙箱展开同帧叠加造成卡顿。
|
||||
const artifactPanelVisible =
|
||||
!!artifactsCtx?.open && (!!selectedArtifact || artifactCount > 0);
|
||||
(!!artifactsCtx?.open || !!artifactsCtx?.preOpen) &&
|
||||
(!!selectedArtifact || artifactCount > 0);
|
||||
const prevArtifactPanelVisible = useRef(false);
|
||||
useEffect(() => {
|
||||
const opened = artifactPanelVisible && !prevArtifactPanelVisible.current;
|
||||
@@ -927,12 +948,14 @@ export function AgentDetailPanel({
|
||||
const panelChrome = (collapseButton: ReactNode) => (
|
||||
<>
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||
<span className="min-w-0 flex-1 truncate whitespace-nowrap text-sm font-semibold">
|
||||
{/* 标题不参与压缩(此前被简洁模式外观控件挤成单字);空间不足时只
|
||||
裁剪 compactAppearanceActions,编辑等主操作与标题始终完整。 */}
|
||||
<span className="shrink-0 text-sm font-semibold whitespace-nowrap">
|
||||
{panelTitle ?? "智能体信息"}
|
||||
</span>
|
||||
<div className="flex min-w-0 items-center justify-end gap-1">
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-1">
|
||||
<div className="flex shrink-0 items-center gap-1">{headerActions}</div>
|
||||
<div className="flex min-w-0 items-center justify-end gap-1 overflow-hidden">
|
||||
{headerActions}
|
||||
{compactAppearanceActions}
|
||||
</div>
|
||||
{collapseButton && <div className="shrink-0">{collapseButton}</div>}
|
||||
@@ -944,11 +967,37 @@ export function AgentDetailPanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
|
||||
{/* ── Desktop (lg+) ────────────────────────────────────────────────── */}
|
||||
{embedded ? (
|
||||
<aside className="bg-card/40 flex h-full w-full min-w-0 shrink-0 flex-col border-l">
|
||||
{panelChrome(null)}
|
||||
</aside>
|
||||
) : collapsedProp !== undefined ? (
|
||||
/* 受控模式(智能体问答页):收起时整列宽度动画归零(含左边框),不再保留细条;
|
||||
展开态的「收起」按钮在面板右上角,收起态的「展开」按钮在页面
|
||||
header「新对话」按钮右侧。 */
|
||||
<div
|
||||
className={cn(
|
||||
"hidden h-full shrink-0 overflow-hidden transition-[width] duration-300 ease-in-out lg:block",
|
||||
collapsed ? "w-0" : "w-[360px]",
|
||||
)}
|
||||
>
|
||||
<aside className="bg-card/40 flex h-full w-[360px] flex-col border-l">
|
||||
{panelChrome(
|
||||
<Tooltip content="收起">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setCollapsed(true)}
|
||||
aria-label="收起智能体信息"
|
||||
aria-expanded={true}
|
||||
>
|
||||
<PanelRightCloseIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>,
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
) : collapsed ? (
|
||||
<div className="hidden shrink-0 items-start border-l px-2 pt-3 lg:flex">
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import {
|
||||
createContext,
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||
import { env } from "@/env";
|
||||
|
||||
export function artifactPreviewKey(artifact: string): string {
|
||||
@@ -26,11 +27,11 @@ export function artifactPreviewKey(artifact: string): string {
|
||||
}
|
||||
return artifact;
|
||||
}
|
||||
|
||||
export interface ArtifactsContextType {
|
||||
artifacts: string[];
|
||||
setArtifacts: (artifacts: string[]) => void;
|
||||
|
||||
|
||||
export interface ArtifactsContextType {
|
||||
artifacts: string[];
|
||||
setArtifacts: (artifacts: string[]) => void;
|
||||
|
||||
selectedArtifact: string | null;
|
||||
selectedArtifactKey: string | null;
|
||||
autoSelect: boolean;
|
||||
@@ -38,20 +39,33 @@ export interface ArtifactsContextType {
|
||||
/** Open/update a streamed write without overriding a manual close of that call. */
|
||||
openStreamedArtifact: (artifact: string, previewKey: string) => boolean;
|
||||
deselect: () => void;
|
||||
|
||||
open: boolean;
|
||||
autoOpen: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const ArtifactsContext = createContext<ArtifactsContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface ArtifactsProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
|
||||
open: boolean;
|
||||
autoOpen: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
/**
|
||||
* 流式写文件自动开沙箱的两阶段标记:openStreamedArtifact 先收起两侧侧栏
|
||||
* (阶段一),等收起布局落定后再真正翻转 open(阶段二)。两阶段之间为
|
||||
* true —— 右侧智能体信息面板据此提前收起,避免与沙箱展开同帧叠加造成卡顿。
|
||||
*/
|
||||
preOpen: boolean;
|
||||
}
|
||||
|
||||
const ArtifactsContext = createContext<ArtifactsContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface ArtifactsProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// 流式写文件自动开沙箱的分步延迟:t=0 收起左侧聊天记录侧栏,延迟
|
||||
// STREAMED_PREOPEN_DELAY_MS 后再收右侧智能体信息面板(两次收起错开、
|
||||
// 不做「左边收完再收右边」的判断,只给固定延迟,避免收起瞬间同帧叠加
|
||||
// 卡顿),到 STREAMED_OPEN_DELAY_MS(右侧 300ms 收起动画结束)才展开沙箱。
|
||||
const STREAMED_PREOPEN_DELAY_MS = 200;
|
||||
const STREAMED_OPEN_DELAY_MS = 500;
|
||||
|
||||
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
const [artifacts, setArtifactsState] = useState<string[]>([]);
|
||||
const setArtifacts = useCallback((next: string[]) => {
|
||||
@@ -69,14 +83,50 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
const [open, setOpenState] = useState(
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
const [preOpen, setPreOpen] = useState(false);
|
||||
const [autoOpen, setAutoOpen] = useState(true);
|
||||
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
|
||||
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
|
||||
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
|
||||
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
|
||||
const autoOpenRef = useRef(autoOpen);
|
||||
const openStateRef = useRef(open);
|
||||
const preOpenTimerRef = useRef<number | null>(null);
|
||||
const openTimerRef = useRef<number | null>(null);
|
||||
const sidebar = useSidebarSafe();
|
||||
|
||||
|
||||
const applyOpen = useCallback((next: boolean) => {
|
||||
openStateRef.current = next;
|
||||
setOpenState((previous) => (previous === next ? previous : next));
|
||||
}, []);
|
||||
|
||||
// 取消尚未触发的分步自动开沙箱(用户手动关闭 / 切换会话时)。
|
||||
const cancelPendingStreamedOpen = useCallback(() => {
|
||||
if (preOpenTimerRef.current !== null) {
|
||||
window.clearTimeout(preOpenTimerRef.current);
|
||||
preOpenTimerRef.current = null;
|
||||
}
|
||||
if (openTimerRef.current !== null) {
|
||||
window.clearTimeout(openTimerRef.current);
|
||||
openTimerRef.current = null;
|
||||
}
|
||||
setPreOpen(false);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (preOpenTimerRef.current !== null) {
|
||||
window.clearTimeout(preOpenTimerRef.current);
|
||||
preOpenTimerRef.current = null;
|
||||
}
|
||||
if (openTimerRef.current !== null) {
|
||||
window.clearTimeout(openTimerRef.current);
|
||||
openTimerRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const select = useCallback(
|
||||
(artifact: string, autoSelect = false) => {
|
||||
const previewKey = artifactPreviewKey(artifact);
|
||||
@@ -84,12 +134,12 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
|
||||
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
|
||||
sidebar?.setOpen(false);
|
||||
}
|
||||
sidebar?.setOpen(false);
|
||||
}
|
||||
if (!autoSelect) {
|
||||
setAutoSelect((previous) => (previous ? false : previous));
|
||||
}
|
||||
},
|
||||
},
|
||||
[sidebar],
|
||||
);
|
||||
|
||||
@@ -104,43 +154,70 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
|
||||
setAutoSelect((previous) => (previous ? previous : true));
|
||||
setAutoOpen((previous) => (previous ? previous : true));
|
||||
setOpenState((previous) => (previous ? previous : true));
|
||||
// 阶段一(t=0):立即收起左侧聊天记录侧栏。
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
|
||||
sidebar?.setOpen(false);
|
||||
}
|
||||
if (openStateRef.current) {
|
||||
// 沙箱已展开(如连续写多个文件):只需切换选中文件,无需分步。
|
||||
cancelPendingStreamedOpen();
|
||||
return true;
|
||||
}
|
||||
if (openTimerRef.current === null) {
|
||||
// 阶段二(t=+200ms):延迟固定时长再置 preOpen,右侧智能体信息
|
||||
// 面板此时才开始收起 —— 与左侧错开,避免两次收起同帧叠加卡顿。
|
||||
preOpenTimerRef.current = window.setTimeout(() => {
|
||||
preOpenTimerRef.current = null;
|
||||
setPreOpen(true);
|
||||
}, STREAMED_PREOPEN_DELAY_MS);
|
||||
// 阶段三(t=+500ms,右侧 300ms 收起动画结束):翻转 open 展开
|
||||
// 沙箱,把「聊天列变窄 + 沙箱内容挂载」与侧栏收起彻底分帧。
|
||||
openTimerRef.current = window.setTimeout(() => {
|
||||
openTimerRef.current = null;
|
||||
applyOpen(true);
|
||||
setPreOpen(false);
|
||||
}, STREAMED_OPEN_DELAY_MS);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[sidebar],
|
||||
[applyOpen, cancelPendingStreamedOpen, sidebar],
|
||||
);
|
||||
|
||||
|
||||
const deselect = useCallback(() => {
|
||||
selectedArtifactKeyRef.current = null;
|
||||
setSelectedArtifact((previous) => (previous === null ? previous : null));
|
||||
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
|
||||
setAutoSelect((previous) => (previous ? previous : true));
|
||||
setOpenState((previous) => (previous ? false : previous));
|
||||
cancelPendingStreamedOpen();
|
||||
applyOpen(false);
|
||||
suppressedAutoOpenKeyRef.current = null;
|
||||
}, []);
|
||||
}, [applyOpen, cancelPendingStreamedOpen]);
|
||||
|
||||
const setOpen = useCallback((isOpen: boolean) => {
|
||||
if (!isOpen && autoOpenRef.current) {
|
||||
autoOpenRef.current = false;
|
||||
setAutoOpen((previous) => (previous ? false : previous));
|
||||
setAutoSelect((previous) => (previous ? false : previous));
|
||||
}
|
||||
if (!isOpen) {
|
||||
suppressedAutoOpenKeyRef.current = selectedArtifactKeyRef.current;
|
||||
}
|
||||
setOpenState((previous) => (previous === isOpen ? previous : isOpen));
|
||||
}, []);
|
||||
|
||||
const value: ArtifactsContextType = {
|
||||
artifacts,
|
||||
setArtifacts,
|
||||
|
||||
open,
|
||||
autoOpen,
|
||||
autoSelect,
|
||||
const setOpen = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
// 两阶段等待期间用户手动关闭:取消尚未触发的自动展开。
|
||||
cancelPendingStreamedOpen();
|
||||
if (autoOpenRef.current) {
|
||||
autoOpenRef.current = false;
|
||||
setAutoOpen((previous) => (previous ? false : previous));
|
||||
setAutoSelect((previous) => (previous ? false : previous));
|
||||
}
|
||||
suppressedAutoOpenKeyRef.current = selectedArtifactKeyRef.current;
|
||||
}
|
||||
applyOpen(isOpen);
|
||||
},
|
||||
[applyOpen, cancelPendingStreamedOpen],
|
||||
);
|
||||
|
||||
const value: ArtifactsContextType = {
|
||||
artifacts,
|
||||
setArtifacts,
|
||||
|
||||
open,
|
||||
preOpen,
|
||||
autoOpen,
|
||||
autoSelect,
|
||||
setOpen,
|
||||
|
||||
selectedArtifact,
|
||||
@@ -148,23 +225,23 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
select,
|
||||
openStreamedArtifact,
|
||||
deselect,
|
||||
};
|
||||
|
||||
return (
|
||||
<ArtifactsContext.Provider value={value}>
|
||||
{children}
|
||||
</ArtifactsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useArtifacts() {
|
||||
const context = useContext(ArtifactsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useArtifacts must be used within an ArtifactsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useArtifactsOptional(): ArtifactsContextType | null {
|
||||
return useContext(ArtifactsContext) ?? null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ArtifactsContext.Provider value={value}>
|
||||
{children}
|
||||
</ArtifactsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useArtifacts() {
|
||||
const context = useContext(ArtifactsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useArtifacts must be used within an ArtifactsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useArtifactsOptional(): ArtifactsContextType | null {
|
||||
return useContext(ArtifactsContext) ?? null;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, Compass, PlusSquare } from "lucide-react";
|
||||
import {
|
||||
BotIcon,
|
||||
Compass,
|
||||
PanelRightOpenIcon,
|
||||
PlusSquare,
|
||||
} from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
@@ -191,6 +196,10 @@ export default function AgentChatPage({
|
||||
|
||||
// 任务深链上下文(taskId + 开场信息):闸门(rwfx)与纯绑定(3qfx/xdfx)都要读取。
|
||||
const taskCtxOn = !!(taskGate || taskBind);
|
||||
// 右侧智能体详情面板折叠态(受控):普通问答默认展开,任务工作区默认收起。
|
||||
// 收起时整列宽度动画关闭;展开态的「收起」按钮在面板右上角,收起态的
|
||||
// 「展开」按钮在顶部 header「新对话」按钮右侧。
|
||||
const [detailCollapsed, setDetailCollapsed] = useState(taskCtxOn);
|
||||
const isXdfxTaskBind = !!chatBasePath?.includes("/plan-task-action/");
|
||||
const taskContext = useMemo<TaskContext | null>(() => {
|
||||
if (!taskCtxOn) return null;
|
||||
@@ -621,7 +630,11 @@ export default function AgentChatPage({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground mr-1"
|
||||
onClick={() => startAgentGuide("chat")}
|
||||
onClick={() => {
|
||||
// 引导的 chat-detail 步骤会高亮右侧面板,重放前先展开。
|
||||
setDetailCollapsed(false);
|
||||
startAgentGuide("chat");
|
||||
}}
|
||||
>
|
||||
<Compass className="size-4" />
|
||||
使用引导
|
||||
@@ -687,6 +700,23 @@ export default function AgentChatPage({
|
||||
<PlusSquare /> {t.agents.newChat}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{/* 右侧智能体详情面板收起时的「展开」入口:仅收起态显示
|
||||
(展开态的「收起」按钮在面板右上角)。桌面端专用(lg+),
|
||||
窄屏面板走抽屉交互;嵌入态面板固定渲染,不显示该开关。 */}
|
||||
{!isEmbedded && detailCollapsed && (
|
||||
<Tooltip content="展开智能体信息">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground hidden lg:inline-flex"
|
||||
onClick={() => setDetailCollapsed(false)}
|
||||
aria-label="展开智能体信息"
|
||||
aria-expanded={false}
|
||||
>
|
||||
<PanelRightOpenIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
@@ -851,7 +881,8 @@ export default function AgentChatPage({
|
||||
agentName={agentDisplayName}
|
||||
disabled={thread.isLoading || isDemoMode}
|
||||
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
||||
defaultCollapsed
|
||||
collapsed={detailCollapsed}
|
||||
onCollapsedChange={setDetailCollapsed}
|
||||
showRecentChats={!taskCtxOn}
|
||||
compactAppearanceActions={compactAppearanceActions}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user