# 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]);
}
+10 -102
View File
@@ -1,114 +1,22 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
function filenameFromContentDisposition(header: string | null): string | null {
if (!header) return null;
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
if (star) {
const raw = star[1].replace(/["']/g, "").trim();
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
const plain = /filename="?([^";\n]+)"?/i.exec(header);
return plain ? plain[1].trim() : null;
}
function sanitizeFallbackFilename(title: string | undefined): string {
const base = (title?.trim() || "document").replace(/[<>:"/\\|?*\x00-\x1f]+/g, "_");
return base.toLowerCase().endsWith(".docx") ? base : `${base}.docx`;
}
async function downloadBlobResponse(response: Response, fallbackFilename: string) {
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "导出 Word 失败" }));
const detail = error.detail;
if (typeof detail === "string") throw new Error(detail);
throw new Error("导出 Word 失败");
}
const blob = await response.blob();
const filename =
filenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
fallbackFilename;
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.rel = "noopener";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
/**
* 通用 Markdown → Word 导出(Gateway `writing` 路由,与 `/api/writing/rewrite` 同级)。
* Unified Markdown → Word export.
*
* POST /api/writing/export/docx
* Body: { title?: string, markdown: string }
*
* 返回 `application/vnd.openxmlformats-officedocument.wordprocessingml.document`,
* 文件名在 `Content-Disposition` 中。
* All download surfaces use the same required Gateway endpoint. The Gateway
* applies the formal OOXML profile and embeds its bundled title font; there is
* deliberately no client-font fallback.
*/
export async function downloadMarkdownDocxFromBackend(options: {
title?: string;
markdown: string;
}): Promise<void> {
const markdown = options.markdown.trim();
if (!markdown) {
throw new Error("文档内容为空");
}
if (!markdown) throw new Error("文档内容为空");
const fallbackFilename = sanitizeFallbackFilename(options.title);
try {
const response = await apiFetch(`${getBackendBaseURL()}/api/writing/export/docx`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: options.title?.trim() || undefined,
markdown,
}),
});
// Older/offline gateways mount /api/writing but do not provide export/docx;
// FastAPI then answers 404 or 405. The browser converter is feature-equivalent.
if (response.status === 404 || response.status === 405) {
await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title);
writeYLog({
optType: OptType.export,
optDetail: "CM助手-全量-导出-Word",
url: `${getBackendBaseURL()}/api/writing/export/docx`,
reqParam: { title: options.title?.trim() || undefined },
});
return;
}
await downloadBlobResponse(response, fallbackFilename);
writeYLog({
optType: OptType.export,
optDetail: "CM助手-全量-导出-Word",
url: `${getBackendBaseURL()}/api/writing/export/docx`,
reqParam: { title: options.title?.trim() || undefined },
});
} catch (err) {
if (err instanceof TypeError) {
await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title);
writeYLog({
optType: OptType.export,
optDetail: "CM助手-全量-导出-Word",
url: `${getBackendBaseURL()}/api/writing/export/docx`,
reqParam: { title: options.title?.trim() || undefined },
});
return;
}
throw err;
}
await downloadMarkdownAsDocx(
markdown,
options.title?.trim() || "document",
options.title,
);
}
@@ -18,13 +18,15 @@ test("normalizeCompactMenuType defaults to A", () => {
assert.equal(isDefaultCompactMenuType("B"), false);
});
test("applyCompactMenuOverrides splits sidebar vs settings", () => {
test("applyCompactMenuOverrides splits sidebar, settings, and toolbox", () => {
const items: MenuItem[] = [
{ id: "a", label: "对话", icon: I, path: "/a" },
{ id: "b", label: "智能体", icon: I, path: "/b" },
{ id: "c", label: "技能", icon: I, path: "/c" },
];
const applied = applyCompactMenuOverrides(items, [
{ nodeId: "b", placement: "settings", sortOrder: 0, label: "智能体设置" },
{ nodeId: "c", placement: "toolbox", sortOrder: 0, label: "技能工具" },
]);
assert.deepEqual(
applied.sidebarItems.map((item) => item.id),
@@ -32,4 +34,6 @@ test("applyCompactMenuOverrides splits sidebar vs settings", () => {
);
assert.equal(applied.settingsItems[0]?.id, "b");
assert.equal(applied.settingsItems[0]?.label, "智能体设置");
assert.equal(applied.toolboxItems[0]?.id, "c");
assert.equal(applied.toolboxItems[0]?.label, "技能工具");
});
@@ -1,7 +1,7 @@
import type { MenuItem } from "./sidebar-menu";
import { readRuntimeEnv } from "@/env";
export type CompactMenuPlacement = "sidebar" | "settings";
export type CompactMenuPlacement = "sidebar" | "settings" | "toolbox";
export const COMPACT_MENU_TYPES = ["A", "B"] as const;
export type CompactMenuType = (typeof COMPACT_MENU_TYPES)[number];
@@ -64,7 +64,11 @@ function isNavigable(item: MenuItem): boolean {
export function applyCompactMenuOverrides(
items: MenuItem[],
overrides: CompactMenuOverride[],
): { sidebarItems: CompactMenuItem[]; settingsItems: CompactMenuItem[] } {
): {
sidebarItems: CompactMenuItem[];
settingsItems: CompactMenuItem[];
toolboxItems: CompactMenuItem[];
} {
const byId = new Map(overrides.map((override) => [override.nodeId, override]));
const enhanced = items.filter(isNavigable).map((item, index) => {
const override = byId.get(item.id);
@@ -82,6 +86,7 @@ export function applyCompactMenuOverrides(
return {
sidebarItems: sortItems(enhanced.filter((item) => item.compactPlacement === "sidebar")),
settingsItems: sortItems(enhanced.filter((item) => item.compactPlacement === "settings")),
toolboxItems: sortItems(enhanced.filter((item) => item.compactPlacement === "toolbox")),
};
}
@@ -1,485 +1,63 @@
/**
* Convert markdown text to a Word (.docx) Blob in the browser.
* Unified server-side Markdown -> Word export.
*
* Designed for scheduled-task reports that often contain Chinese text,
* markdown tables, and emojis. Compared to Word's stock heading styles we
* deliberately:
* - Force every run color to pure black (`#000000`) so headings, links,
* and inline code never render as Word's default blue / hyperlink theme.
* - Render markdown tables as real OOXML tables (full-width, auto-wrapping)
* instead of falling back to plain text rows that overflow the page.
* - Strip variation selectors, zero-width joiners, and other invisible
* characters that often break Word renderers when emojis leak into the
* report.
*
* Block grammar handled line-by-line:
* - ATX heading: `^(#{1,6})\s+(.*)$`
* - Fenced code: lines between ``` ``` are emitted verbatim in a mono font
* - Blockquote: `^>\s?(.*)$`
* - Ordered list: `^\s*(\d+)\.\s+(.*)$`
* - Unordered list: `^\s*[-*+]\s+(.*)$`
* - Horizontal rule: `^(---|\*\*\*|___)\s*$`
* - Pipe table: contiguous lines starting with `|` plus a separator row
* - Blank line: paragraph break
* - Anything else: paragraph (with inline formatting)
*
* Inline grammar handled:
* `code`, **bold**, __bold__, *italic*, _italic_, [text](url)
* Every Word download surface calls the Gateway so the document receives the
* same formal OOXML styles and the server-bundled 方正小标宋简体 title font.
* The user's browser and desktop Word installation are never used for font
* detection or document generation.
*/
import {
AlignmentType,
BorderStyle,
Document,
HeadingLevel,
Packer,
Paragraph,
Table,
TableCell,
TableRow,
TextRun,
WidthType,
type IRunOptions,
} from "docx";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
const BLACK = "000000";
const BODY_FONT = { ascii: "Calibri", eastAsia: "Microsoft YaHei" };
const MONO_FONT = { ascii: "Consolas", eastAsia: "Microsoft YaHei" };
const DOCX_MEDIA_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
const HEADING_SIZES_HALF_PT: Record<number, number> = {
// Word "size" property is half-points. 32 = 16pt.
1: 36,
2: 32,
3: 28,
4: 26,
5: 24,
6: 22,
};
const BODY_SIZE_HALF_PT = 22; // 11pt
type InlineSegment = {
text: string;
bold?: boolean;
italic?: boolean;
code?: boolean;
};
/**
* Drop characters that Word renders inconsistently or that frequently smuggle
* emoji glitches into corporate reports.
*
* Removes:
* - Variation selectors (FE00–FE0F, E0100–E01EF)
* - Zero-width joiner / non-joiner / space (200C, 200D, FEFF, 200B)
* - Soft hyphen (00AD)
* - Object replacement character (FFFC)
*/
function sanitizeText(text: string): string {
return text
.replace(/[︀-️]/g, "")
.replace(/[​-‍]/g, "")
.replace(/­/g, "")
.replace(//g, "")
.replace(/[\u{E0100}-\u{E01EF}]/gu, "");
function sanitizeFilename(value: string): string {
const name = value
.replace(/\.(md|markdown|docx)$/i, "")
.replace(/[<>:"/\\|?*\x00-\x1f]+/g, "_")
.trim();
return name || "document";
}
/**
* Tokenise a single line of markdown into formatted segments.
*
* Recognised inline syntax:
* `code`, **bold**, __bold__, *italic*, _italic_, [text](url)
* Markdown links are flattened to their text content — we deliberately do not
* emit hyperlink runs because they pick up Word's blue theme color.
*/
function parseInlineSegments(text: string): InlineSegment[] {
const cleaned = sanitizeText(text);
// Strip markdown link syntax first so the regex below doesn't trip on `[...]`.
const withoutLinks = cleaned.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
const tokens: InlineSegment[] = [];
const pattern =
/(`+)([^`]+)\1|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g;
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(withoutLinks)) !== null) {
if (match.index > cursor) {
tokens.push({ text: withoutLinks.slice(cursor, match.index) });
}
if (match[2] !== undefined) tokens.push({ text: match[2], code: true });
else if (match[3] !== undefined) tokens.push({ text: match[3], bold: true });
else if (match[4] !== undefined) tokens.push({ text: match[4], bold: true });
else if (match[5] !== undefined)
tokens.push({ text: match[5], italic: true });
else if (match[6] !== undefined)
tokens.push({ text: match[6], italic: true });
cursor = pattern.lastIndex;
}
if (cursor < withoutLinks.length) {
tokens.push({ text: withoutLinks.slice(cursor) });
}
return tokens.length > 0 ? tokens : [{ text: withoutLinks }];
async function responseError(response: Response): Promise<Error> {
const payload = await response
.json()
.catch(() => ({ detail: `生成 Word 失败(HTTP ${response.status})` }));
const detail = payload?.detail;
return new Error(typeof detail === "string" ? detail : "生成 Word 失败");
}
function segmentsToRuns(
segments: InlineSegment[],
extra?: Partial<IRunOptions>,
): TextRun[] {
return segments.map((seg) => {
const opts: IRunOptions = {
text: seg.text,
bold: seg.bold,
italics: seg.italic,
color: BLACK,
font: seg.code ? MONO_FONT : BODY_FONT,
size: extra?.size ?? BODY_SIZE_HALF_PT,
...extra,
};
return new TextRun(opts);
});
}
function makeParagraphFromInline(text: string): Paragraph {
return new Paragraph({
spacing: { after: 120 },
children: segmentsToRuns(parseInlineSegments(text)),
});
}
function makeHeadingParagraph(level: number, text: string): Paragraph {
const clamped = Math.max(1, Math.min(level, 6));
return new Paragraph({
// We attach a heading level so the table-of-contents still works, but the
// run-level color / font / size below override Word's blue theme.
heading:
({
1: HeadingLevel.HEADING_1,
2: HeadingLevel.HEADING_2,
3: HeadingLevel.HEADING_3,
4: HeadingLevel.HEADING_4,
5: HeadingLevel.HEADING_5,
6: HeadingLevel.HEADING_6,
}[clamped] as (typeof HeadingLevel)[keyof typeof HeadingLevel]) ?? undefined,
spacing: { before: 200, after: 120 },
children: segmentsToRuns(parseInlineSegments(text), {
bold: true,
color: BLACK,
size: HEADING_SIZES_HALF_PT[clamped] ?? BODY_SIZE_HALF_PT,
}),
});
}
function makeListItem(
text: string,
ordered: boolean,
level: number,
): Paragraph {
return new Paragraph({
spacing: { after: 80 },
children: segmentsToRuns(parseInlineSegments(text)),
numbering: ordered
? { reference: "ordered-list", level: Math.min(level, 8) }
: undefined,
bullet: ordered ? undefined : { level: Math.min(level, 8) },
});
}
function makeCodeParagraph(line: string): Paragraph {
return new Paragraph({
spacing: { after: 0 },
children: [
new TextRun({
text: sanitizeText(line) || " ",
font: MONO_FONT,
color: BLACK,
size: BODY_SIZE_HALF_PT,
}),
],
});
}
function makeQuoteParagraph(text: string): Paragraph {
return new Paragraph({
indent: { left: 720 },
spacing: { after: 120 },
children: segmentsToRuns(parseInlineSegments(text), { italics: true }),
});
}
function makeHorizontalRule(): Paragraph {
return new Paragraph({
alignment: AlignmentType.CENTER,
spacing: { before: 120, after: 120 },
children: [
new TextRun({
text: "────────",
color: BLACK,
font: BODY_FONT,
size: BODY_SIZE_HALF_PT,
}),
],
});
}
const CELL_BORDER = {
style: BorderStyle.SINGLE,
size: 4,
color: "BFBFBF",
};
function makeTableCell(
text: string,
options: { header?: boolean } = {},
): TableCell {
const segments = parseInlineSegments(text);
return new TableCell({
margins: { top: 80, bottom: 80, left: 100, right: 100 },
children: [
new Paragraph({
spacing: { after: 0 },
children: segmentsToRuns(segments, {
bold: options.header || undefined,
size: BODY_SIZE_HALF_PT,
}),
}),
],
});
}
function splitTableRow(line: string): string[] {
// Strip leading/trailing pipe and any escaped pipes (rare in agent output).
const trimmed = line.replace(/^\s*\|/, "").replace(/\|\s*$/, "");
return trimmed.split("|").map((cell) => cell.trim());
}
function buildTable(headerLine: string, bodyLines: string[]): Table {
const headerCells = splitTableRow(headerLine);
const colCount = headerCells.length;
const rows: TableRow[] = [];
rows.push(
new TableRow({
tableHeader: true,
children: headerCells.map((cell) =>
makeTableCell(cell, { header: true }),
),
}),
);
for (const row of bodyLines) {
const cells = splitTableRow(row);
// Pad/truncate to match the header column count so Word renders evenly.
const normalized = Array.from(
{ length: colCount },
(_, i) => cells[i] ?? "",
);
rows.push(
new TableRow({
children: normalized.map((c) => makeTableCell(c)),
}),
);
}
return new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows,
borders: {
top: CELL_BORDER,
bottom: CELL_BORDER,
left: CELL_BORDER,
right: CELL_BORDER,
insideHorizontal: CELL_BORDER,
insideVertical: CELL_BORDER,
},
});
}
function isTableSeparator(line: string): boolean {
// e.g. |---|:---:|---:| with optional spaces
return /^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line);
}
function looksLikeTableRow(line: string): boolean {
return /^\s*\|.*\|\s*$/.test(line) || /^\s*\|.+/.test(line);
}
/**
* Convert markdown text into an array of docx block elements (Paragraph | Table).
*/
function markdownToBlocks(markdown: string): Array<Paragraph | Table> {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const blocks: Array<Paragraph | Table> = [];
let inFence = false;
let paragraphBuffer: string[] = [];
const flushParagraph = () => {
if (paragraphBuffer.length === 0) return;
blocks.push(makeParagraphFromInline(paragraphBuffer.join(" ")));
paragraphBuffer = [];
};
for (let i = 0; i < lines.length; i++) {
const rawLine = lines[i] ?? "";
const line = rawLine.replace(/\t/g, " ");
if (line.trim().startsWith("```")) {
flushParagraph();
inFence = !inFence;
continue;
}
if (inFence) {
blocks.push(makeCodeParagraph(line));
continue;
}
if (!line.trim()) {
flushParagraph();
continue;
}
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
if (heading) {
flushParagraph();
blocks.push(
makeHeadingParagraph(heading[1]!.length, heading[2]!.trim()),
);
continue;
}
if (/^(?:---|\*\*\*|___)\s*$/.test(line)) {
flushParagraph();
blocks.push(makeHorizontalRule());
continue;
}
const blockquote = /^>\s?(.*)$/.exec(line);
if (blockquote) {
flushParagraph();
blocks.push(makeQuoteParagraph(blockquote[1] ?? ""));
continue;
}
// Table detection — a header row followed by a separator row.
if (
looksLikeTableRow(line) &&
i + 1 < lines.length &&
isTableSeparator(lines[i + 1] ?? "")
) {
flushParagraph();
const headerLine = line;
i += 2; // skip header + separator
const body: string[] = [];
while (i < lines.length && looksLikeTableRow(lines[i] ?? "")) {
body.push(lines[i] ?? "");
i++;
}
// Re-step back one so the outer loop's i++ lands on the next unprocessed
// line. Without this, blank lines after the table get swallowed.
i--;
blocks.push(buildTable(headerLine, body));
// Word's table rendering eats following spacing; inject a tiny gap.
blocks.push(
new Paragraph({ spacing: { after: 0 }, children: [new TextRun("")] }),
);
continue;
}
const orderedItem = /^(\s*)(\d+)\.\s+(.*)$/.exec(line);
if (orderedItem) {
flushParagraph();
const indent = Math.floor(orderedItem[1]!.length / 2);
blocks.push(makeListItem(orderedItem[3]!, true, indent));
continue;
}
const unorderedItem = /^(\s*)[-*+]\s+(.*)$/.exec(line);
if (unorderedItem) {
flushParagraph();
const indent = Math.floor(unorderedItem[1]!.length / 2);
blocks.push(makeListItem(unorderedItem[2]!, false, indent));
continue;
}
paragraphBuffer.push(line.trim());
}
flushParagraph();
return blocks;
}
/**
* Convert a markdown string into a .docx Blob suitable for download.
*/
/** Ask the Gateway to build a DOCX with its embedded licensed font resource. */
export async function markdownToDocxBlob(
markdown: string,
title?: string,
): Promise<Blob> {
const children: Array<Paragraph | Table> = [];
if (title?.trim()) {
children.push(
new Paragraph({
spacing: { before: 0, after: 240 },
children: [
new TextRun({
text: sanitizeText(title.trim()),
bold: true,
color: BLACK,
font: BODY_FONT,
size: 40, // 20pt
}),
],
}),
);
}
children.push(...markdownToBlocks(markdown));
const source = markdown?.trim();
if (!source) throw new Error("文档内容为空");
const doc = new Document({
styles: {
default: {
document: {
run: {
color: BLACK,
font: BODY_FONT,
size: BODY_SIZE_HALF_PT,
},
},
},
},
numbering: {
config: [
{
reference: "ordered-list",
levels: [
{
level: 0,
format: "decimal",
text: "%1.",
alignment: AlignmentType.START,
},
{
level: 1,
format: "decimal",
text: "%2.",
alignment: AlignmentType.START,
},
{
level: 2,
format: "decimal",
text: "%3.",
alignment: AlignmentType.START,
},
],
},
],
},
sections: [{ children }],
const endpoint = `${getBackendBaseURL()}/api/writing/export/docx`;
const response = await apiFetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title?.trim() || undefined,
markdown: source,
}),
});
return Packer.toBlob(doc);
if (!response.ok) throw await responseError(response);
const blob = await response.blob();
if (blob.size === 0) throw new Error("服务器返回了空的 Word 文件");
if (blob.type && blob.type !== DOCX_MEDIA_TYPE) {
throw new Error("服务器返回的文件类型不是 Word 文档");
}
return blob;
}
/**
* Trigger a browser download of the markdown source converted to .docx.
*/
/** Generate and immediately download a formal DOCX. */
export async function downloadMarkdownAsDocx(
markdown: string,
fileName: string,
@@ -488,15 +66,15 @@ export async function downloadMarkdownAsDocx(
const blob = await markdownToDocxBlob(markdown, title);
writeYLog({
optType: OptType.export,
optDetail: "CM助手-全量-定时任务-导出",
url: "/scheduled-tasks/export/docx",
optDetail: "CM助手-Word导出",
url: "/api/writing/export/docx",
reqParam: { fileName, title },
});
const safeName = fileName.replace(/\.(md|markdown)$/i, "") || "document";
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${safeName}.docx`;
anchor.download = `${sanitizeFilename(fileName)}.docx`;
anchor.rel = "noopener";
document.body.appendChild(anchor);
anchor.click();
+1 -1
View File
@@ -144,7 +144,7 @@ export function exportThreadAsJSON(thread: AgentThread, messages: Message[]) {
/**
* Export the conversation as a Word document (.docx). The whole thread is
* serialised to Markdown first (same formatter the `.md` export uses), and
* then converted to a docx Blob in the browser via `markdownToDocxBlob`.
* then sent to the Gateway's shared formal DOCX generator.
*/
export async function exportThreadAsWord(
thread: AgentThread,
+1 -1
View File
@@ -851,7 +851,7 @@ export default function AgentChatPage({
agentName={agentDisplayName}
disabled={thread.isLoading || isDemoMode}
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
defaultCollapsed={taskCtxOn}
defaultCollapsed
showRecentChats={!taskCtxOn}
compactAppearanceActions={compactAppearanceActions}
/>
@@ -261,12 +261,17 @@ function compactRowsFromTree(tree: MenuItem[], overrides: CompactMenuOverride[])
}
function normalizeCompactRows(rows: CompactRow[]): CompactRow[] {
const placementOrder: Record<CompactMenuPlacement, number> = {
sidebar: 0,
settings: 1,
toolbox: 2,
};
return rows
.slice()
.sort(
(a, b) =>
a.topIndex - b.topIndex ||
Number(a.placement === "settings") - Number(b.placement === "settings") ||
placementOrder[a.placement] - placementOrder[b.placement] ||
a.order - b.order ||
a.id.localeCompare(b.id),
);
@@ -1091,7 +1096,7 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
<p className="flex max-w-2xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span>
这里配置的是简洁模式工作区左侧菜单,保存后仅对当前网页使用的
这里配置简洁模式菜单在左侧菜单、设置和更多、右侧工具箱之间的分布,保存后仅对当前网页使用的
{COMPACT_MENU_TYPE_LABELS[runtimeType]}生效(两套部署各改各的);全量模式菜单不受影响。
</span>
</p>
@@ -1132,6 +1137,7 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
<SelectContent>
<SelectItem value="sidebar">左侧菜单</SelectItem>
<SelectItem value="settings">设置和更多</SelectItem>
<SelectItem value="toolbox">右侧工具箱</SelectItem>
</SelectContent>
</Select>
<div className="flex justify-end gap-1.5">
+8
View File
@@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { PageSidebar } from "@/components/page-sidebar";
import { PageSidebarDark } from "@/components/page-sidebar-dark";
import { CompactToolbox } from "@/components/workspace/compact-toolbox";
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import { isWujieEnv } from "@/core/embed/wujie";
@@ -102,12 +103,19 @@ function PageLayout() {
!embedActive &&
// `?hideChrome=1` 显式去 chrome:优先级高于上面对 dashboard-agent 的强制全局 chrome。
!chromeHidden;
const compactToolboxAllowed =
!chromeByPreference &&
!forceChromeForDashboardAgent &&
!embedActive &&
!chromeHidden &&
!isChatsIframeWorkspacePath(location.pathname);
// h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100%
// 高度链,根布局跟随容器高度。全屏下 100% 解析到视口,与 h-screen 等价;无界等嵌套
// 下容器不占满整屏时,高度才能正确收缩而非锁死整屏。
return (
<RoundtableJobNotifier>
{compactToolboxAllowed && <CompactToolbox />}
<div className="h-full flex flex-col bg-muted/20">
{pageSidebarAllowed && !headerHidden && <Header collapsed={collapsed} />}
<div className="flex flex-1 overflow-hidden">
@@ -14,16 +14,20 @@ const BASE = () => `${getBackendBaseURL()}/api/compact-menu-overrides`;
interface ServerCompactOverride {
node_id: string;
placement: "sidebar" | "settings";
placement: "sidebar" | "settings" | "toolbox";
sort_order: number;
label: string | null;
menu_type?: string | null;
}
function toOverride(s: ServerCompactOverride): CompactMenuOverride {
const placement =
s.placement === "settings" || s.placement === "toolbox"
? s.placement
: "sidebar";
return {
nodeId: s.node_id,
placement: s.placement === "settings" ? "settings" : "sidebar",
placement,
sortOrder: s.sort_order ?? 0,
label: s.label ?? null,
};
@@ -96,8 +100,11 @@ export function useCompactMenuOverrides(menuType?: CompactMenuType) {
return useQuery({
queryKey: [COMPACT_MENU_OVERRIDES_KEY, type],
queryFn: () => listCompactMenuOverrides(type),
refetchOnWindowFocus: false,
staleTime: 60_000,
// 菜单管理常在另一个标签页操作。简洁模式页面重新挂载或获得焦点时必须
// 立即读取最新配置,否则新放入右侧工具箱的项目会被旧缓存隐藏一分钟。
refetchOnMount: "always",
refetchOnWindowFocus: "always",
staleTime: 0,
});
}