Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-nj
# Conflicts: # AGENTS.md # frontend-web/src/pages/AgentChatPage.tsx
This commit is contained in:
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]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user