feat: unify formal Word export
This commit is contained in:
1 parent
d35d075669
commit
0980eea975
16 files changed
+1356
-609
No files matched your search
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user