# Conflicts:
#	AGENTS.md
#	frontend-web/src/pages/AgentChatPage.tsx
This commit is contained in:
董宏凯 committed 2026-08-26 16:37:41 +08:00
commit 38c3de39f0
41 files changed
+2113 -737

No files matched your search

@@ -237,9 +237,10 @@ export function AgentDetailPanel({
const isSaving = updateAgent.isPending || setFeatured.isPending;
// 对话生成文件(md/txt 等 artifact)并在右侧展开预览时,自动收起智能体
// 信息面板给预览让位;切换到新文件时同样收起。编辑中不打断用户,用户随时
// 可手动重新展开。
// 对话生成文件(md/txt 等 artifact)并首次展开预览时,自动收起智能体
// 信息面板给预览让位。目录内切换文件不能再次改写用户的手动选择:否则用户
// 刚展开智能体信息,点另一个文件时面板又会突然收起,看起来就像收起按钮被
// 移除了。编辑中也不打断用户。
//
// 关键:判定用 open + selectedArtifact,而非 artifacts.length。流式写文件时
// message-group 一开始就调用 select()+setOpen(true) 打开预览并选中该文件,但
@@ -251,17 +252,13 @@ export function AgentDetailPanel({
const artifactPanelVisible =
!!artifactsCtx?.open && (!!selectedArtifact || artifactCount > 0);
const prevArtifactPanelVisible = useRef(false);
const prevSelectedArtifact = useRef<string | null>(null);
useEffect(() => {
const opened = artifactPanelVisible && !prevArtifactPanelVisible.current;
const switched =
artifactPanelVisible && selectedArtifact !== prevSelectedArtifact.current;
prevArtifactPanelVisible.current = artifactPanelVisible;
prevSelectedArtifact.current = selectedArtifact;
if ((opened || switched) && !editing) {
if (opened && !editing) {
setCollapsed(true);
}
}, [artifactPanelVisible, selectedArtifact, editing]);
}, [artifactPanelVisible, editing]);
const displayName = desensitize(agent?.name ?? agentName);
const questions = useMemo(
@@ -929,14 +926,16 @@ export function AgentDetailPanel({
const panelChrome = (collapseButton: ReactNode) => (
<>
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<span className="shrink-0 whitespace-nowrap text-sm font-semibold">
<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">
{panelTitle ?? "智能体信息"}
</span>
<div className="flex min-w-0 items-center gap-1">
{headerActions}
{compactAppearanceActions}
{collapseButton}
<div className="flex min-w-0 items-center justify-end gap-1">
<div className="flex min-w-0 items-center justify-end gap-1 overflow-hidden">
{headerActions}
{compactAppearanceActions}
</div>
{collapseButton && <div className="shrink-0">{collapseButton}</div>}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
@@ -954,12 +953,13 @@ export function AgentDetailPanel({
<div className="hidden shrink-0 items-start border-l px-2 pt-3 lg:flex">
<div className="flex items-center gap-1">
{compactAppearanceActions}
<Tooltip content="智能体信息">
<Tooltip content="展开智能体信息">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setCollapsed(false)}
aria-label="展开智能体信息"
aria-expanded={false}
>
<PanelRightOpenIcon className="size-4" />
</Button>
@@ -975,6 +975,7 @@ export function AgentDetailPanel({
size="icon-sm"
onClick={() => setCollapsed(true)}
aria-label="收起智能体信息"
aria-expanded={true}
>
<PanelRightCloseIcon className="size-4" />
</Button>
@@ -1460,7 +1460,7 @@ export function ArtifactFileDetail({
return (
<Artifact className={cn(className)}>
<ArtifactHeader className="flex flex-wrap items-center gap-3 px-4 py-3">
<ArtifactHeader className="relative flex flex-wrap items-center gap-3 px-4 py-3 pr-14">
<div className="flex min-w-0 items-center gap-2">
<div
className={cn(
@@ -1554,8 +1554,8 @@ export function ArtifactFileDetail({
</div>
)}
</div>
<div className="flex w-full items-center justify-end gap-2 md:ml-auto md:w-auto">
<ArtifactActions className="bg-muted/45 max-w-full flex-wrap justify-end rounded-3xl border border-black/6 p-1 dark:border-white/8">
<div className="flex min-w-0 w-full items-center justify-end gap-2 md:ml-auto md:w-auto">
<ArtifactActions className="bg-muted/45 min-w-0 max-w-full flex-wrap justify-end rounded-3xl border border-black/6 p-1 dark:border-white/8">
{canPublishToKnowledge && (
<ArtifactAction
icon={isPublishingToKnowledge ? LoaderIcon : UploadCloudIcon}
@@ -1683,16 +1683,18 @@ export function ArtifactFileDetail({
}
}}
/>
<ArtifactAction
className="hover:bg-background"
icon={XIcon}
label={t.common.close}
onClick={() => (onClose ? onClose() : setOpen(false))}
tooltip={t.common.close}
/>
</ArtifactActions>
</div>
</ArtifactHeader>
</ArtifactActions>
</div>
{/* 文件目录会压缩详情区宽度。关闭入口固定在右上角,不再跟随可换行的
动作组,避免工具较多或面板较窄时最右侧按钮被裁掉。 */}
<ArtifactAction
className="bg-background/90 hover:bg-accent absolute top-3 right-3 z-10 border shadow-sm"
icon={XIcon}
label="收起文件面板"
onClick={() => (onClose ? onClose() : setOpen(false))}
tooltip="收起文件面板"
/>
</ArtifactHeader>
<ArtifactContent
ref={artifactContentRef}
className="relative p-0"
@@ -0,0 +1,162 @@
"use client";
import { Wrench, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { cn } from "@/lib/utils";
import { useWorkspaceCompactNavItems } from "./workspace-nav-chat-list";
const TOOLBOX_ICON_TONES = [
"bg-blue-500/12 text-blue-600 dark:text-blue-400",
"bg-violet-500/12 text-violet-600 dark:text-violet-400",
"bg-emerald-500/12 text-emerald-600 dark:text-emerald-400",
"bg-amber-500/14 text-amber-600 dark:text-amber-400",
] as const;
/**
* 简洁模式右侧工具箱。
*
* 菜单来源与左侧简洁菜单、设置和更多完全一致,由管理员在菜单管理中把项目
* 放到 ``toolbox`` 泳道。点击右侧竖排按钮后展开单列竖向抽屉。
*/
export function CompactToolbox() {
const { toolboxItems } = useWorkspaceCompactNavItems();
const { pathname } = useLocation();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => setOpen(false), [pathname]);
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
triggerRef.current?.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
closeRef.current?.focus();
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
if (toolboxItems.length === 0) return null;
const closeDrawer = () => {
setOpen(false);
requestAnimationFrame(() => triggerRef.current?.focus());
};
return (
<>
<button
ref={triggerRef}
type="button"
aria-label="打开工具箱"
aria-controls="compact-toolbox-panel"
aria-expanded={open}
title="打开工具箱"
onClick={() => setOpen(true)}
className={cn(
"fixed right-0 top-1/2 z-[80] flex size-16 -translate-y-1/2 flex-col items-center justify-center gap-1 rounded-l-2xl border border-r-0 border-primary/25 bg-primary text-primary-foreground shadow-xl transition-[transform,opacity,background-color] duration-200 hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
open && "pointer-events-none translate-x-full opacity-0",
)}
>
<Wrench className="size-6 shrink-0" />
<span className="text-xs font-semibold leading-4">工具箱</span>
</button>
<button
type="button"
tabIndex={-1}
aria-label="关闭工具箱抽屉"
aria-hidden={!open}
onClick={closeDrawer}
className={cn(
"fixed inset-0 z-[90] bg-black/20 transition-opacity duration-200",
open ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0",
)}
/>
<aside
id="compact-toolbox-panel"
role="dialog"
aria-modal={open}
aria-label="工具箱"
aria-hidden={!open}
inert={!open}
className={cn(
"fixed right-0 top-1/2 z-[100] flex max-h-[calc(100vh-32px)] w-32 max-w-[calc(100vw-48px)] -translate-y-1/2 flex-col overflow-hidden rounded-l-2xl bg-background text-foreground shadow-2xl transition-transform duration-300 ease-out",
open
? "pointer-events-auto translate-x-0"
: "pointer-events-none translate-x-full",
)}
>
<header className="relative flex h-12 shrink-0 items-center justify-center px-2">
<h2 className="text-sm font-semibold">工具箱</h2>
<button
ref={closeRef}
type="button"
aria-label="关闭工具箱"
title="关闭"
onClick={closeDrawer}
className="absolute right-2 flex size-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-4" />
</button>
</header>
<nav
aria-label="工具箱菜单"
className="flex min-h-0 flex-col items-center gap-2.5 overflow-y-auto bg-muted/20 p-2"
>
{toolboxItems.map((item, index) => {
const Icon = item.icon;
const active = item.match(pathname);
return (
<button
key={item.id}
type="button"
aria-current={active ? "page" : undefined}
className={cn(
"group flex h-24 w-24 aspect-square shrink-0 flex-col items-center justify-center gap-1.5 rounded-xl p-2 text-center transition-[color,background-color,transform] hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active
? "bg-primary/12 text-primary ring-1 ring-inset ring-primary/25"
: "bg-muted/55 text-card-foreground hover:bg-primary/8 hover:text-primary",
)}
onClick={() => {
setOpen(false);
navigate(item.path ?? "/page/workspace/chats/new");
}}
>
<span
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform group-hover:scale-105",
active
? "bg-primary text-primary-foreground"
: TOOLBOX_ICON_TONES[index % TOOLBOX_ICON_TONES.length],
)}
>
<Icon className="size-6" strokeWidth={1.8} />
</span>
<span
className="line-clamp-2 w-full text-center text-xs font-medium leading-4"
title={item.label}
>
{item.label}
</span>
</button>
);
})}
</nav>
</aside>
</>
);
}
@@ -1,8 +1,14 @@
import { FileText, Loader2 } from "lucide-react";
import { FileText, Loader2, MessagesSquare } from "lucide-react";
import { useState, type ComponentProps } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { Tooltip } from "../tooltip";
@@ -11,29 +17,84 @@ export function DownloadWordButton({
markdown,
fileName,
title,
questionMarkdown,
...props
}: ComponentProps<typeof Button> & {
markdown: string;
fileName?: string;
title?: string;
/** When present, the button offers both answer-only and current Q&A export. */
questionMarkdown?: string;
}) {
const [busy, setBusy] = useState(false);
const handleDownload = async () => {
const [busyScope, setBusyScope] = useState<"answer" | "qa" | null>(null);
const handleDownload = async (scope: "answer" | "qa" = "answer") => {
const source = markdown?.trim();
if (!source) {
toast.error("无可下载的 Markdown 内容");
return;
}
setBusy(true);
const question = questionMarkdown?.trim();
setBusyScope(scope);
try {
await downloadMarkdownAsDocx(source, fileName || "message", title);
if (scope === "qa" && question) {
const qaMarkdown = `## 问题\n\n${question}\n\n## 回答\n\n${source}`;
await downloadMarkdownAsDocx(
qaMarkdown,
`${fileName || "message"}-问答`,
"问答记录",
);
} else {
await downloadMarkdownAsDocx(
source,
fileName || "message",
title || (question ? "问答回答" : undefined),
);
}
toast.success("Word 文件已生成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成 Word 失败");
} finally {
setBusy(false);
setBusyScope(null);
}
};
const busy = busyScope !== null;
const icon = busy ? (
<Loader2 size={12} className="animate-spin" />
) : (
<FileText size={12} />
);
if (questionMarkdown?.trim()) {
return (
<DropdownMenu>
<Tooltip content="下载为 Word">
<DropdownMenuTrigger asChild>
<Button
size="icon-sm"
type="button"
variant="ghost"
disabled={busy}
{...props}
>
{icon}
</Button>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => void handleDownload("answer")}>
<FileText className="text-muted-foreground" />
仅下载当前回答
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleDownload("qa")}>
<MessagesSquare className="text-muted-foreground" />
下载当前问题和回答
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
return (
<Tooltip content="下载为 Word">
<Button
@@ -41,14 +102,10 @@ export function DownloadWordButton({
type="button"
variant="ghost"
disabled={busy}
onClick={handleDownload}
onClick={() => void handleDownload("answer")}
{...props}
>
{busy ? (
<Loader2 size={12} className="animate-spin" />
) : (
<FileText size={12} />
)}
{icon}
</Button>
</Tooltip>
);
@@ -159,8 +159,9 @@ export function MessageListItem({
className,
fitRowToContent = false,
anchorId,
message,
isLoading,
message,
questionMarkdown,
isLoading,
threadId,
tokenUsageEnabled = false,
displaySources = [],
@@ -177,7 +178,9 @@ export function MessageListItem({
* message. `scroll-mt` keeps the target clear of the overlay header.
*/
anchorId?: string;
message: Message;
message: Message;
/** Nearest preceding user question, used by the current-Q&A Word export. */
questionMarkdown?: string;
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
@@ -327,10 +330,12 @@ export function MessageListItem({
<div className="flex gap-1">
<CopyButton clipboardData={messageMarkdown} />
{messageMarkdown && (
<DownloadWordButton
markdown={messageMarkdown}
fileName="message"
/>
<DownloadWordButton
markdown={messageMarkdown}
fileName="message"
title="问答回答"
questionMarkdown={questionMarkdown}
/>
)}
{canSaveAssistantEdit && (
<Tooltip content="编辑消息">
@@ -262,7 +262,7 @@ export function MessageList({
// Durable ask time per message id, used to time-stamp the AI answer (which has
// no timestamp of its own) with the time of the question that triggered it.
const turnTimes = useMemo(() => {
const turnTimes = useMemo(() => {
const map = new Map<string, string>();
let lastQuestionTime: string | undefined;
for (const message of messages) {
@@ -276,7 +276,22 @@ export function MessageList({
}
}
return map;
}, [messages, askTimes]);
}, [messages, askTimes]);
// Associate each assistant answer with the nearest preceding user question.
// This powers the per-answer Word menu without changing message persistence.
const questionMarkdownByAssistantId = useMemo(() => {
const map = new Map<string, string>();
let currentQuestion = "";
for (const message of messages) {
if (message.type === "human") {
currentQuestion = extractContentFromMessage(message)?.trim() ?? "";
} else if (message.type === "ai" && message.id && currentQuestion) {
map.set(message.id, currentQuestion);
}
}
return map;
}, [messages]);
const [settings] = useLocalSettings();
const ragModeEnabled =
@@ -529,8 +544,11 @@ export function MessageList({
? getQuestionAnchorId(msg, messages.indexOf(msg))
: undefined
}
message={msg}
isLoading={messageIsLoading}
message={msg}
questionMarkdown={
msg.id ? questionMarkdownByAssistantId.get(msg.id) : undefined
}
isLoading={messageIsLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
@@ -135,6 +135,7 @@ export type EffectiveWorkspaceCompactNavItem = CompactMenuItem & WorkspaceCompac
export function useWorkspaceCompactNavItems(): {
sidebarItems: EffectiveWorkspaceCompactNavItem[];
settingsItems: EffectiveWorkspaceCompactNavItem[];
toolboxItems: EffectiveWorkspaceCompactNavItem[];
} {
const { data: overrides = [] } = useCompactMenuOverrides();
return useMemo(() => {
@@ -142,6 +143,7 @@ export function useWorkspaceCompactNavItems(): {
return {
sidebarItems: applied.sidebarItems as EffectiveWorkspaceCompactNavItem[],
settingsItems: applied.settingsItems as EffectiveWorkspaceCompactNavItem[],
toolboxItems: applied.toolboxItems as EffectiveWorkspaceCompactNavItem[],
};
}, [overrides]);
}