diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 3319a2d6..23f7257b 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -13,6 +13,7 @@ import { HashRouter, useLocation } from "react-router-dom"; import LandingPage from "./pages/LandingPage"; import LoginPage from "./pages/LoginPage"; import PageRoutes from "./pages/PageRoutes"; +import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage"; import PublicSharePage from "./pages/PublicSharePage"; import { NotFoundPage } from "./pages/NotFoundPage"; @@ -50,6 +51,11 @@ export function App() { } /> {/* Public read-only conversation viewer — no authentication. */} } /> + {/* Public read-only scheduled-task runs viewer — no authentication. */} + } + /> } /> } /> diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index 0e96bd10..9b5f17b0 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -6,6 +6,7 @@ import { CopyIcon, DownloadIcon, EyeIcon, + FileType2Icon, LoaderIcon, PackageIcon, SaveIcon, @@ -44,6 +45,7 @@ import { } from "@/core/artifacts/hooks"; import { urlOfArtifact } from "@/core/artifacts/utils"; import { useI18n } from "@/core/i18n/hooks"; +import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { installSkill } from "@/core/skills/api"; import { streamdownPlugins } from "@/core/streamdown"; import { checkCodeFile, getFileName } from "@/core/utils/files"; @@ -377,6 +379,26 @@ export function ArtifactFileDetail({ updateArtifact, ]); + const [isExportingWord, setIsExportingWord] = useState(false); + const handleDownloadAsWord = useCallback(async () => { + const source = (displayContent ?? "").trim(); + if (!source) { + toast.error("Markdown 内容为空,无法另存为 Word"); + return; + } + setIsExportingWord(true); + try { + const baseName = getFileName(filepath).replace(/\.(md|markdown)$/i, ""); + await downloadMarkdownAsDocx(source, baseName || "document", baseName); + toast.success("已另存为 Word"); + } catch (error) { + console.error("Failed to export markdown as docx:", error); + toast.error(error instanceof Error ? error.message : "另存为 Word 失败"); + } finally { + setIsExportingWord(false); + } + }, [displayContent, filepath]); + return ( @@ -518,6 +540,15 @@ export function ArtifactFileDetail({ tooltip={t.clipboard.copyToClipboard} /> )} + {!isWriteFile && language === "markdown" && ( + + )} {!isWriteFile && ( { + async (format: "markdown" | "json" | "word") => { if (messages.length === 0) { toast.error(t.conversation.noMessages); return; @@ -39,12 +40,18 @@ export function ExportTrigger({ threadId }: { threadId: string }) { values: thread.values, } as AgentThread; - if (format === "markdown") { - exportThreadAsMarkdown(agentThread, messages); - } else { - exportThreadAsJSON(agentThread, messages); + try { + if (format === "markdown") { + exportThreadAsMarkdown(agentThread, messages); + } else if (format === "json") { + exportThreadAsJSON(agentThread, messages); + } else { + await exportThreadAsWord(agentThread, messages); + } + toast.success(t.common.exportSuccess); + } catch (error) { + toast.error(error instanceof Error ? error.message : "导出失败"); } - toast.success(t.common.exportSuccess); }, [messages, thread.values, threadId, t], ); @@ -67,11 +74,15 @@ export function ExportTrigger({ threadId }: { threadId: string }) { - handleExport("markdown")}> + void handleExport("markdown")}> {t.common.exportAsMarkdown} - handleExport("json")}> + void handleExport("word")}> + + {t.common.exportAsWord} + + void handleExport("json")}> {t.common.exportAsJSON} diff --git a/frontend-web/src/components/workspace/messages/download-word-button.tsx b/frontend-web/src/components/workspace/messages/download-word-button.tsx new file mode 100644 index 00000000..c50c734f --- /dev/null +++ b/frontend-web/src/components/workspace/messages/download-word-button.tsx @@ -0,0 +1,55 @@ +import { FileText, Loader2 } from "lucide-react"; +import { useState, type ComponentProps } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; + +import { Tooltip } from "../tooltip"; + +export function DownloadWordButton({ + markdown, + fileName, + title, + ...props +}: ComponentProps & { + markdown: string; + fileName?: string; + title?: string; +}) { + const [busy, setBusy] = useState(false); + const handleDownload = async () => { + const source = markdown?.trim(); + if (!source) { + toast.error("无可下载的 Markdown 内容"); + return; + } + setBusy(true); + try { + await downloadMarkdownAsDocx(source, fileName || "message", title); + toast.success("Word 文件已生成"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "生成 Word 失败"); + } finally { + setBusy(false); + } + }; + return ( + + + + ); +} diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index e42d3dbd..c6bdcfe8 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -42,6 +42,7 @@ import { cn } from "@/lib/utils"; import { CopyButton } from "../copy-button"; import { ThreadContext } from "./context"; +import { DownloadWordButton } from "./download-word-button"; import { MarkdownContent } from "./markdown-content"; import { MessageTokenUsage } from "./message-token-usage"; @@ -82,6 +83,10 @@ export function MessageListItem({ tokenUsageEnabled?: boolean; }) { const isHuman = message.type === "human"; + const messageMarkdown = + extractContentFromMessage(message) ?? + extractReasoningContentFromMessage(message) ?? + ""; return (
- + + {!isHuman && messageMarkdown && ( + + )}
)} diff --git a/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx b/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx index 3b5f20a6..62ec4bc3 100644 --- a/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx +++ b/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx @@ -1,7 +1,8 @@ "use client"; import { - ChevronDownIcon, + CopyIcon, + EyeIcon, FileCode2Icon, FileTextIcon, GlobeIcon, @@ -14,11 +15,6 @@ import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; import { Dialog, DialogContent, @@ -26,6 +22,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { getStoredAuth } from "@/core/auth"; @@ -37,17 +34,21 @@ import { } from "@/core/skills/hooks"; import type { Skill } from "@/core/skills/type"; import { reasoningPlugins } from "@/core/streamdown"; -import { cn } from "@/lib/utils"; -/** - * Whether the current user may edit a skill's detail text and view its raw - * SKILL.md source. - * - * Admins qualify for any skill; the publisher (owner of a custom skill) for - * their own. Built-in skills have no human owner, so only admins qualify. - * Mirrors the backend authz in `PUT /api/skills/{name}/detail` and - * `GET /api/skills/{name}/content`. - */ +const markdownClassName = + "!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " + + "[&_h1]:mt-0 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold " + + "[&_h2]:mt-6 [&_h2]:mb-2 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-lg [&_h2]:font-semibold " + + "[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold " + + "[&_p]:my-3 [&_ul]:my-3 [&_ol]:my-3 [&_li]:my-1.5 " + + "[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " + + "[&_blockquote]:my-4 [&_blockquote]:border-l-4 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground " + + "[&_code]:rounded [&_code]:border [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs " + + "[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-muted [&_pre]:p-4 " + + "[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0 " + + "[&_table]:my-4 [&_table]:w-full [&_table]:min-w-max [&_table]:border-collapse " + + "[&_th]:border [&_th]:bg-muted [&_th]:px-3 [&_th]:py-2 [&_td]:border [&_td]:px-3 [&_td]:py-2"; + function canManageSkill(skill: Skill | null | undefined): boolean { if (!skill) return false; const auth = getStoredAuth(); @@ -60,12 +61,10 @@ function canManageSkill(skill: Skill | null | undefined): boolean { ); } -/** - * Reusable skill detail dialog. Given a skill name it fetches the skill - * metadata (including the admin/owner-authored `detail`) and lets authorized - * users edit the detail inline. The raw SKILL.md source is shown only to the - * publisher or an admin — everyone else sees just the description and detail. - */ +function stripYamlFrontmatter(content: string): string { + return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trimStart(); +} + export function SkillDetailDialog({ skillName, open, @@ -77,33 +76,38 @@ export function SkillDetailDialog({ }) { const { t } = useI18n(); const tx = t.settings.skills; - // Only fetch while the dialog is open to avoid background requests. const activeName = open ? skillName : null; const { data: skill, isLoading: isSkillLoading } = useSkill(activeName); const setDetail = useSetSkillDetail(); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(""); - const [showSource, setShowSource] = useState(false); + const [activeTab, setActiveTab] = useState<"detail" | "source" | null>( + null, + ); + const [showRawSource, setShowRawSource] = useState(false); const canManage = useMemo(() => canManageSkill(skill), [skill]); - - // The SKILL.md source endpoint is publisher/admin-only — only request it - // when the viewer is actually allowed to see it. const { data: contentData, isLoading: isContentLoading, error: contentError, } = useSkillContent(open && canManage ? skillName : null); - // Reset transient UI whenever the dialog closes or switches to another skill. useEffect(() => { - if (!open) { - setEditing(false); - setShowSource(false); - } + setEditing(false); + setActiveTab(null); + setShowRawSource(false); }, [open, skillName]); + const skillBody = useMemo(() => { + return stripYamlFrontmatter(contentData?.content ?? ""); + }, [contentData]); + + const defaultTab: "detail" | "source" = + canManage && skill && !skill.detail ? "source" : "detail"; + const selectedTab = activeTab ?? defaultTab; + function startEditing() { setDraft(skill?.detail ?? ""); setEditing(true); @@ -120,11 +124,18 @@ export function SkillDetailDialog({ } } + async function copySource() { + await navigator.clipboard.writeText(contentData?.content ?? ""); + toast.success("Copied SKILL.md content"); + } + return ( - - {/* Header */} - + +
@@ -142,49 +153,110 @@ export function SkillDetailDialog({ {skill?.published && ( {tx.badgePublished} )}
- - {skill?.description || "—"} + + {skill?.description || "-"}
- {/* Body */} -
- {/* Detail — human-authored long-form introduction */} -
-
-

- + {isSkillLoading || !skill ? ( +
+
+ + {t.common.loading} +
+
+ ) : ( + + setActiveTab(value as Exclude) + } + className="flex min-h-0 flex-1 flex-col gap-0" + > +
+ + + {tx.detailSectionTitle} -

- {canManage && !editing && ( + + {canManage && ( + + + {tx.contentSectionTitle} + + )} + + +
+ {selectedTab === "detail" && canManage && !editing && ( )} + {selectedTab === "source" && + canManage && + !isContentLoading && + !contentError && ( + <> + + + + )}
+
+ {editing ? (