diff --git a/frontend-web/index.html b/frontend-web/index.html index cad07df8..e1d8ce8a 100644 --- a/frontend-web/index.html +++ b/frontend-web/index.html @@ -3,7 +3,7 @@ - DeerFlow Vite + CM 助手
diff --git a/frontend-web/package.json b/frontend-web/package.json index cf9c3a7b..7e2adf0e 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -50,6 +50,7 @@ "cmdk": "^1.1.1", "codemirror": "^6.0.2", "date-fns": "^4.1.0", + "docx": "^9.5.1", "dotenv": "^17.2.3", "embla-carousel-react": "^8.6.0", "gsap": "^3.13.0", diff --git a/frontend-web/src/components/ai-elements/prompt-input.tsx b/frontend-web/src/components/ai-elements/prompt-input.tsx index 50065afb..c15ce7c9 100644 --- a/frontend-web/src/components/ai-elements/prompt-input.tsx +++ b/frontend-web/src/components/ai-elements/prompt-input.tsx @@ -674,12 +674,22 @@ export const PromptInput = ({ useEffect(() => { if (!globalDrop) return; + // Suppress the global chat-upload handler when the drop happens inside an open + // modal dialog or popover — those owners (e.g. the skill-install dialog) handle + // their own file uploads and should not leak into the active chat thread. + const isInsideModal = (target: EventTarget | null): boolean => { + if (!(target instanceof Element)) return false; + return target.closest('[role="dialog"], [role="alertdialog"]') !== null; + }; + const onDragOver = (e: DragEvent) => { + if (isInsideModal(e.target)) return; if (e.dataTransfer?.types?.includes("Files")) { e.preventDefault(); } }; const onDrop = (e: DragEvent) => { + if (isInsideModal(e.target)) return; if (e.dataTransfer?.types?.includes("Files")) { e.preventDefault(); } diff --git a/frontend-web/src/components/landing/footer.tsx b/frontend-web/src/components/landing/footer.tsx index 38118056..1895a7db 100644 --- a/frontend-web/src/components/landing/footer.tsx +++ b/frontend-web/src/components/landing/footer.tsx @@ -12,7 +12,7 @@ export function Footer() {

Licensed under MIT License

-

© {year} CM业务中心-问答管理

+

© {year} CM 助手

); diff --git a/frontend-web/src/components/landing/header.tsx b/frontend-web/src/components/landing/header.tsx index 53fc46c3..c1903dad 100644 --- a/frontend-web/src/components/landing/header.tsx +++ b/frontend-web/src/components/landing/header.tsx @@ -1,14 +1,9 @@ "use client"; -import { StarFilledIcon, GitHubLogoIcon } from "@radix-ui/react-icons"; import Link from "next/link"; -import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { NumberTicker } from "@/components/ui/number-ticker"; import type { Locale } from "@/core/i18n/locale"; import { useI18n } from "@/core/i18n/hooks"; -import { env } from "@/env"; import { cn } from "@/lib/utils"; export type HeaderProps = { @@ -30,11 +25,11 @@ export function Header({ className, homeURL }: HeaderProps) { >
-

CM业务中心-问答管理

+

CM 助手

-
-
- -

); } - -function StarCounter() { - const [stars, setStars] = useState(10000); - - useEffect(() => { - let cancelled = false; - - async function loadStars() { - try { - const response = await fetch( - "https://api.github.com/repos/bytedance/deer-flow", - { - headers: env.GITHUB_OAUTH_TOKEN - ? { - Authorization: `Bearer ${env.GITHUB_OAUTH_TOKEN}`, - "Content-Type": "application/json", - } - : {}, - }, - ); - - if (!response.ok) return; - const data = await response.json(); - if (!cancelled && typeof data?.stargazers_count === "number") { - setStars(data.stargazers_count); - } - } catch (error) { - console.error("Error fetching GitHub stars:", error); - } - } - - void loadStars(); - return () => { - cancelled = true; - }; - }, []); - - return ( - <> - - - - ); -} diff --git a/frontend-web/src/components/landing/hero.tsx b/frontend-web/src/components/landing/hero.tsx index 54c7490e..afa80e20 100644 --- a/frontend-web/src/components/landing/hero.tsx +++ b/frontend-web/src/components/landing/hero.tsx @@ -55,7 +55,7 @@ export function Hero({ className }: { className?: string }) { "Learn Anything", ]} />{" "} -
CM业务中心-问答管理
+
CM 助手
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && (
- CM业务中心-问答管理 Agent + CM 助手 Agent
@@ -691,7 +691,7 @@ export default function ProgressiveSkillsAnimation() { {/* Chat Input (decorative) */}
- Ask CM业务中心-问答管理 anything... + Ask CM 助手 anything...
diff --git a/frontend-web/src/components/landing/sections/case-study-section.tsx b/frontend-web/src/components/landing/sections/case-study-section.tsx index 346524f3..f36768a2 100644 --- a/frontend-web/src/components/landing/sections/case-study-section.tsx +++ b/frontend-web/src/components/landing/sections/case-study-section.tsx @@ -49,7 +49,7 @@ export function CaseStudySection({ className }: { className?: string }) {
{caseStudies.map((caseStudy) => ( diff --git a/frontend-web/src/components/landing/sections/community-section.tsx b/frontend-web/src/components/landing/sections/community-section.tsx index 2c8c54e1..f5b042a5 100644 --- a/frontend-web/src/components/landing/sections/community-section.tsx +++ b/frontend-web/src/components/landing/sections/community-section.tsx @@ -1,10 +1,6 @@ "use client"; -import { GitHubLogoIcon } from "@radix-ui/react-icons"; -import Link from "next/link"; - import { AuroraText } from "@/components/ui/aurora-text"; -import { Button } from "@/components/ui/button"; import { Section } from "../section"; @@ -16,20 +12,9 @@ export function CommunitySection() { Join the Community } - subtitle="Contribute brilliant ideas to shape the future of DeerFlow. Collaborate, innovate, and make impacts." + subtitle="Contribute brilliant ideas to shape the future of CM Assistant. Collaborate, innovate, and make impacts." > -
- -
+
); } diff --git a/frontend-web/src/components/landing/sections/sandbox-section.tsx b/frontend-web/src/components/landing/sections/sandbox-section.tsx index 6bea414a..1b916980 100644 --- a/frontend-web/src/components/landing/sections/sandbox-section.tsx +++ b/frontend-web/src/components/landing/sections/sandbox-section.tsx @@ -15,7 +15,7 @@ export function SandboxSection({ className }: { className?: string }) { title="Agent Runtime Environment" subtitle={

- We give DeerFlow a "computer", which can execute commands, + We give CM Assistant a "computer", which can execute commands, manage files, and run long tasks — all in a secure Docker-based sandbox

diff --git a/frontend-web/src/components/landing/sections/skills-section.tsx b/frontend-web/src/components/landing/sections/skills-section.tsx index 744eaa76..24eb216f 100644 --- a/frontend-web/src/components/landing/sections/skills-section.tsx +++ b/frontend-web/src/components/landing/sections/skills-section.tsx @@ -15,7 +15,7 @@ export function SkillsSection({ className }: { className?: string }) { Agent Skills are loaded progressively — only what's needed, when it's needed.
- Extend DeerFlow with your own skill files, or use our built-in + Extend CM Assistant with your own skill files, or use our built-in library. } diff --git a/frontend-web/src/components/landing/sections/whats-new-section.tsx b/frontend-web/src/components/landing/sections/whats-new-section.tsx index c1846a76..aa723719 100644 --- a/frontend-web/src/components/landing/sections/whats-new-section.tsx +++ b/frontend-web/src/components/landing/sections/whats-new-section.tsx @@ -52,8 +52,8 @@ export function WhatsNewSection({ className }: { className?: string }) { return (
diff --git a/frontend-web/src/components/workspace/recent-chat-list.tsx b/frontend-web/src/components/workspace/recent-chat-list.tsx index e27821b3..cc60f7e5 100644 --- a/frontend-web/src/components/workspace/recent-chat-list.tsx +++ b/frontend-web/src/components/workspace/recent-chat-list.tsx @@ -117,14 +117,7 @@ export function RecentChatList() { const handleShare = useCallback( async (thread: AgentThread) => { - // Always use Vercel URL for sharing so others can access - const VERCEL_URL = "https://deer-flow-v2.vercel.app"; - const isLocalhost = - window.location.hostname === "localhost" || - window.location.hostname === "127.0.0.1"; - // On localhost: use Vercel URL; On production: use current origin - const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin; - const shareUrl = `${baseUrl}${pathOfThread(thread)}`; + const shareUrl = `${window.location.origin}${pathOfThread(thread)}`; try { await navigator.clipboard.writeText(shareUrl); toast.success(t.clipboard.linkCopied); diff --git a/frontend-web/src/components/workspace/settings/about-content.ts b/frontend-web/src/components/workspace/settings/about-content.ts index 6aa44d66..73d0cd6d 100644 --- a/frontend-web/src/components/workspace/settings/about-content.ts +++ b/frontend-web/src/components/workspace/settings/about-content.ts @@ -1,67 +1,18 @@ /** - * About DeerFlow markdown content. Inlined to avoid raw-loader dependency + * About CM Assistant markdown content. Inlined to avoid raw-loader dependency * (Turbopack cannot resolve raw-loader for .md imports). */ -export const aboutMarkdown = `# 🦌 [About DeerFlow 2.0](https://github.com/bytedance/deer-flow) +export const aboutMarkdown = `# About CM Assistant -> **From Open Source, Back to Open Source** - -DeerFlow (**D**eep **E**xploration and **E**fficient **R**esearch **Flow**) is an open-source **super agent harness** that orchestrates **sub-agents**, **memory**, and **sandboxes** to do almost anything — powered by **extensible skills**. +CM Assistant is an internal super-agent platform that orchestrates sub-agents, memory, and sandboxes — powered by extensible skills — to research, analyze, and create on your behalf. --- ## 🚀 Core Features -* **Skills & Tools**: With built-in and extensible skills and tools, DeerFlow can do almost anything. +* **Skills & Tools**: With built-in and extensible skills and tools, CM Assistant can do almost anything. * **Sub-Agents**: Sub-Agents help the main agent to do the tasks that are too complex to be done by the main agent. * **Sandbox & File System**: Safely execute code and manipulate files in the sandbox. * **Context Engineering**: Isolated sub-agent context, summarization to keep the context window sharp. * **Long-Term Memory**: Keep recording the user's profile, top of mind, and conversation history. - ---- - -## 🌟 GitHub Repository - -![Star History Chart](https://api.star-history.com/svg?repos=bytedance/deer-flow&type=Date) - -Explore DeerFlow on GitHub: [github.com/bytedance/deer-flow](https://github.com/bytedance/deer-flow) - -## 🌐 Official Website - -Visit the official website of DeerFlow: [deerflow.tech](https://deerflow.tech/) - -## 📧 Support - -If you have any questions or need help, please contact us at [support@deerflow.tech](mailto:support@deerflow.tech). - ---- - -## 📜 License - -DeerFlow is proudly open source and distributed under the **MIT License**. - ---- - -## 🙌 Acknowledgments - -We extend our heartfelt gratitude to the open source projects and contributors who have made DeerFlow a reality. We truly stand on the shoulders of giants. - -### Core Frameworks -- **[LangChain](https://github.com/langchain-ai/langchain)**: A phenomenal framework that powers our LLM interactions and chains. -- **[LangGraph](https://github.com/langchain-ai/langgraph)**: Enabling sophisticated multi-agent orchestration. -- **[Next.js](https://nextjs.org/)**: A cutting-edge framework for building web applications. - -### UI Libraries -- **[Shadcn](https://ui.shadcn.com/)**: Minimalistic components that power our UI. -- **[SToneX](https://github.com/stonexer)**: For his invaluable contribution to token-by-token visual effects. - -These outstanding projects form the backbone of DeerFlow and exemplify the transformative power of open source collaboration. - -### Special Thanks -Finally, we want to express our heartfelt gratitude to the core authors of DeerFlow 1.0 and 2.0: - -- **[Daniel Walnut](https://github.com/hetaoBackend/)** -- **[Henry Li](https://github.com/magiccube/)** - -Without their vision, passion and dedication, \`DeerFlow\` would not be what it is today. `; diff --git a/frontend-web/src/components/workspace/settings/about.md b/frontend-web/src/components/workspace/settings/about.md index 73c2cc4d..9ddb0189 100644 --- a/frontend-web/src/components/workspace/settings/about.md +++ b/frontend-web/src/components/workspace/settings/about.md @@ -1,55 +1,15 @@ -# 🦌 [About DeerFlow 2.0](https://github.com/bytedance/deer-flow) +# About CM Assistant -> **From Open Source, Back to Open Source** - -**DeerFlow** (**D**eep **E**xploration and **E**fficient **R**esearch **Flow**) is a community-driven SuperAgent harness that researches, codes, and creates. -With the help of sandboxes, memories, tools and skills, it handles -different levels of tasks that could take minutes to hours. +CM Assistant is an internal super-agent platform that orchestrates sub-agents, +memory, and sandboxes — powered by extensible skills — to research, analyze, +and create on your behalf. --- -## 🌟 GitHub Repository +## Core Features -Explore DeerFlow on GitHub: [github.com/bytedance/deer-flow](https://github.com/bytedance/deer-flow) - -## 🌐 Official Website - -Visit the official website of DeerFlow: [deerflow.tech](https://deerflow.tech/) - -## 📧 Support - -If you have any questions or need help, please contact us at [support@deerflow.tech](mailto:support@deerflow.tech). - ---- - -## 📜 License - -DeerFlow is proudly open source and distributed under the **MIT License**. - ---- - -## 🙌 Acknowledgments - -We extend our heartfelt gratitude to the open source projects and contributors who have made DeerFlow a reality. We truly stand on the shoulders of giants. - -### Core Frameworks - -- **[LangChain](https://github.com/langchain-ai/langchain)**: A phenomenal framework that powers our LLM interactions and chains. -- **[LangGraph](https://github.com/langchain-ai/langgraph)**: Enabling sophisticated multi-agent orchestration. -- **[Next.js](https://nextjs.org/)**: A cutting-edge framework for building web applications. - -### UI Libraries - -- **[Shadcn](https://ui.shadcn.com/)**: Minimalistic components that power our UI. -- **[SToneX](https://github.com/stonexer)**: For his invaluable contribution to token-by-token visual effects. - -These outstanding projects form the backbone of DeerFlow and exemplify the transformative power of open source collaboration. - -### Special Thanks - -Finally, we want to express our heartfelt gratitude to the core authors of DeerFlow 1.0 and 2.0: - -- **[Daniel Walnut](https://github.com/hetaoBackend/)** -- **[Henry Li](https://github.com/magiccube/)** - -Without their vision, passion and dedication, `DeerFlow` would not be what it is today. +* **Skills & Tools**: Built-in and extensible skills let CM Assistant tackle a wide range of tasks. +* **Sub-Agents**: Specialized sub-agents handle complex jobs the main agent delegates to them. +* **Sandbox & File System**: Safely execute code and manipulate files inside an isolated sandbox. +* **Context Engineering**: Isolated sub-agent context plus summarization keeps the context window sharp. +* **Long-Term Memory**: Continuously records your profile, top-of-mind notes, and conversation history. diff --git a/frontend-web/src/components/workspace/settings/memory-settings-page.tsx b/frontend-web/src/components/workspace/settings/memory-settings-page.tsx index 5a7e4a8a..b6f59515 100644 --- a/frontend-web/src/components/workspace/settings/memory-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/memory-settings-page.tsx @@ -389,7 +389,7 @@ export function MemorySettingsPage() { try { setIsExporting(true); const exportedMemory = await exportMemory(); - const fileName = `deerflow-memory-${(exportedMemory.lastUpdated || new Date().toISOString()).replace(/[:.]/g, "-")}.json`; + const fileName = `cm-assistant-memory-${(exportedMemory.lastUpdated || new Date().toISOString()).replace(/[:.]/g, "-")}.json`; const blob = new Blob([JSON.stringify(exportedMemory, null, 2)], { type: "application/json", }); diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx index d1cb5eda..c42d77c1 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -1,10 +1,19 @@ "use client"; -import { SparklesIcon } from "lucide-react"; +import { AlertCircle, CheckCircle2, FileArchive, Loader2, SparklesIcon, Upload } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Empty, EmptyContent, @@ -23,9 +32,16 @@ import { import { Switch } from "@/components/ui/switch"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useI18n } from "@/core/i18n/hooks"; -import { useEnableSkill, useSkills } from "@/core/skills/hooks"; +import type { ValidateSkillUploadResponse } from "@/core/skills/api"; +import { + useEnableSkill, + useInstallSkillUpload, + useSkills, + useValidateSkillUpload, +} from "@/core/skills/hooks"; import type { Skill } from "@/core/skills/type"; import { env } from "@/env"; +import { cn } from "@/lib/utils"; import { SettingsSection } from "./settings-section"; @@ -58,6 +74,7 @@ function SkillSettingsList({ const { t } = useI18n(); const router = useRouter(); const [filter, setFilter] = useState("public"); + const [installOpen, setInstallOpen] = useState(false); const { mutate: enableSkill } = useEnableSkill(); const filteredSkills = useMemo( () => skills.filter((skill) => skill.category === filter), @@ -78,7 +95,16 @@ function SkillSettingsList({
-
+
+
); } @@ -133,3 +163,265 @@ function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) { ); } + +function InstallSkillDialog({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + const { t } = useI18n(); + const dialog = t.settings.skills.installDialog; + const inputRef = useRef(null); + const [file, setFile] = useState(null); + const [validation, setValidation] = useState(null); + const [validationError, setValidationError] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const validateMutation = useValidateSkillUpload(); + const installMutation = useInstallSkillUpload(); + + const reset = useCallback(() => { + setFile(null); + setValidation(null); + setValidationError(null); + setIsDragging(false); + if (inputRef.current) inputRef.current.value = ""; + }, []); + + const handleClose = useCallback(() => { + if (validateMutation.isPending || installMutation.isPending) return; + reset(); + onClose(); + }, [installMutation.isPending, onClose, reset, validateMutation.isPending]); + + const acceptFile = useCallback( + async (selected: File) => { + const lower = selected.name.toLowerCase(); + if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) { + toast.error(dialog.rejectInvalidExt); + return; + } + setFile(selected); + setValidation(null); + setValidationError(null); + try { + const result = await validateMutation.mutateAsync(selected); + setValidation(result); + } catch (err) { + setValidationError(err instanceof Error ? err.message : String(err)); + } + }, + [dialog.rejectInvalidExt, validateMutation], + ); + + const onDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + // Stop the global chat-upload listener on `document` from also receiving this drop. + // React's stopPropagation alone doesn't reach native document listeners — we need to + // halt the underlying DOM event too. + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + setIsDragging(false); + const dropped = e.dataTransfer.files?.[0]; + if (dropped) void acceptFile(dropped); + }, + [acceptFile], + ); + + const onPick = useCallback( + (e: React.ChangeEvent) => { + const picked = e.target.files?.[0]; + if (picked) void acceptFile(picked); + }, + [acceptFile], + ); + + const handleInstall = useCallback( + async (overwrite: boolean) => { + if (!file) return; + try { + const result = await installMutation.mutateAsync({ file, overwrite }); + toast.success( + (result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace( + "{name}", + result.skill_name, + ), + ); + reset(); + onClose(); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + }, + [dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset], + ); + + const isPublicConflict = validation?.conflict && validation.conflict_kind === "public"; + const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom"; + const canInstall = !!validation && !validationError && !isPublicConflict && !isCustomConflict; + const canOverwrite = !!validation && !validationError && isCustomConflict; + + return ( + !o && handleClose()}> + + + {dialog.title} + +
+
{ + e.preventDefault(); + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={onDrop} + className={cn( + "flex flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-6 text-center transition-colors", + isDragging ? "border-primary bg-accent/40" : "border-muted-foreground/30", + )} + > + +
+ {dialog.dropHint} + +
+ {file && ( +
+ {file.name} · {(file.size / 1024).toFixed(1)} KB +
+ )} + +
+ + {validateMutation.isPending && ( +
+ + {dialog.validating} +
+ )} + + {validationError && ( +
+ + {validationError} +
+ )} + + {validation && !validationError && ( +
+
+ + {validation.skill_name} +
+ + + {validation.description || "—"} + + + {validation.license && ( + {validation.license} + )} + {validation.allowed_tools.length > 0 && ( + +
+ {validation.allowed_tools.map((tool) => ( + + {tool} + + ))} +
+
+ )} + {validation.warnings.length > 0 && ( + +
    + {validation.warnings.map((w, idx) => ( +
  • {w}
  • + ))} +
+
+ )} + {isCustomConflict && ( +
+ + + {dialog.conflictCustom.replace("{name}", validation.skill_name)} + +
+ )} + {isPublicConflict && ( +
+ + + {dialog.conflictPublic.replace("{name}", validation.skill_name)} + +
+ )} +
+ )} +
+ + + {canOverwrite && ( + + )} + + +
+
+ ); +} + +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ {label} +
{children}
+
+ ); +} diff --git a/frontend-web/src/components/workspace/workspace-container.tsx b/frontend-web/src/components/workspace/workspace-container.tsx index b3fb2a5f..de73a9f5 100644 --- a/frontend-web/src/components/workspace/workspace-container.tsx +++ b/frontend-web/src/components/workspace/workspace-container.tsx @@ -15,9 +15,6 @@ import { import { useI18n } from "@/core/i18n/hooks"; import { cn } from "@/lib/utils"; -import { GithubIcon } from "./github-icon"; -import { Tooltip } from "./tooltip"; - export function WorkspaceContainer({ className, children, @@ -90,18 +87,6 @@ export function WorkspaceHeader({
-
- - - - - -
); } diff --git a/frontend-web/src/core/agents/api.ts b/frontend-web/src/core/agents/api.ts index 7690fa61..4ffd5870 100644 --- a/frontend-web/src/core/agents/api.ts +++ b/frontend-web/src/core/agents/api.ts @@ -74,7 +74,7 @@ export async function checkAgentName( ); } catch { throw new AgentNameCheckError( - "Could not reach the DeerFlow backend.", + "Could not reach the CM Assistant backend.", "backend_unreachable", ); } @@ -83,7 +83,7 @@ export async function checkAgentName( const err = (await res.json().catch(() => ({}))) as { detail?: string }; if (BACKEND_UNAVAILABLE_STATUSES.has(res.status)) { throw new AgentNameCheckError( - "Could not reach the DeerFlow backend.", + "Could not reach the CM Assistant backend.", "backend_unreachable", ); } diff --git a/frontend-web/src/core/i18n/locales/en-US.ts b/frontend-web/src/core/i18n/locales/en-US.ts index ae43cc70..25dc471b 100644 --- a/frontend-web/src/core/i18n/locales/en-US.ts +++ b/frontend-web/src/core/i18n/locales/en-US.ts @@ -61,11 +61,11 @@ export const enUS: Translations = { welcome: { greeting: "Hello, again!", description: - "Welcome to 🦌 DeerFlow, an open source super agent. With built-in and custom skills, DeerFlow helps you search on the web, analyze data, and generate artifacts like slides, web pages and do almost anything.", + "Welcome to CM Assistant. With built-in and custom skills, CM Assistant helps you search on the web, analyze data, and generate artifacts like slides, web pages and do almost anything.", createYourOwnSkill: "Create Your Own Skill", createYourOwnSkillDescription: - "Create your own skill to release the power of DeerFlow. With customized skills,\nDeerFlow can help you search on the web, analyze data, and generate\n artifacts like slides, web pages and do almost anything.", + "Create your own skill to release the power of CM Assistant. With customized skills,\nCM Assistant can help you search on the web, analyze data, and generate\n artifacts like slides, web pages and do almost anything.", }, // Clipboard @@ -208,13 +208,13 @@ export const enUS: Translations = { save: "Save agent", saving: "Saving agent...", saveRequested: - "Save requested. DeerFlow is generating and saving an initial version now.", + "Save requested. CM Assistant is generating and saving an initial version now.", saveHint: "You can save this agent at any time from the top-right menu, even if this is only a first draft.", saveCommandMessage: "Please save this custom agent now based on everything we have discussed so far. Treat this as my explicit confirmation to save. If some details are still missing, make reasonable assumptions, generate a concise first SOUL.md in English, and call setup_agent immediately without asking me for more confirmation.", agentCreatedPendingRefresh: - "The agent was created, but DeerFlow could not load it yet. Please refresh this page in a moment.", + "The agent was created, but CM Assistant could not load it yet. Please refresh this page in a moment.", more: "More actions", agentCreated: "Agent created!", startChatting: "Start chatting", @@ -229,13 +229,13 @@ export const enUS: Translations = { // Workspace workspace: { - officialWebsite: "DeerFlow's official website", - githubTooltip: "DeerFlow on Github", + officialWebsite: "CM Assistant's official website", + githubTooltip: "CM Assistant on Github", settingsAndMore: "Settings and more", - visitGithub: "DeerFlow on GitHub", + visitGithub: "CM Assistant on GitHub", reportIssue: "Report a issue", contactUs: "Contact us", - about: "About DeerFlow", + about: "About CM Assistant", }, // Conversation @@ -251,7 +251,7 @@ export const enUS: Translations = { // Page titles (document title) pages: { - appName: "DeerFlow", + appName: "CM Assistant", brandSubtitle: "Smart CM Platform", chats: "Chats", newChat: "New chat", @@ -278,7 +278,7 @@ export const enUS: Translations = { writeFile: "Write file", clickToViewContent: "Click to view file content", writeTodos: "Update to-do list", - skillInstallTooltip: "Install skill and make it available to DeerFlow", + skillInstallTooltip: "Install skill and make it available to CM Assistant", }, // Subtasks @@ -315,7 +315,7 @@ export const enUS: Translations = { actions: "Actions", keyboardShortcuts: "Keyboard Shortcuts", keyboardShortcutsDescription: - "Navigate DeerFlow faster with keyboard shortcuts.", + "Navigate CM Assistant faster with keyboard shortcuts.", openCommandPalette: "Open Command Palette", toggleSidebar: "Toggle Sidebar", }, @@ -323,7 +323,7 @@ export const enUS: Translations = { // Settings settings: { title: "Settings", - description: "Adjust how DeerFlow looks and behaves for you.", + description: "Adjust how CM Assistant looks and behaves for you.", sections: { appearance: "Appearance", memory: "Memory", @@ -335,7 +335,7 @@ export const enUS: Translations = { memory: { title: "Memory", description: - "DeerFlow automatically learns from your conversations in the background. These memories help DeerFlow understand you better and deliver a more personalized experience.", + "CM Assistant automatically learns from your conversations in the background. These memories help CM Assistant understand you better and deliver a more personalized experience.", empty: "No memory data to display.", rawJson: "Raw JSON", exportButton: "Export memory", @@ -433,20 +433,43 @@ export const enUS: Translations = { description: "Manage the configuration and enabled status of the agent skills.", createSkill: "Create skill", + installSkill: "Install skill", emptyTitle: "No agent skill yet", emptyDescription: - "Put your agent skill folders under the `/skills/custom` folder under the root folder of DeerFlow.", + "Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.", emptyButton: "Create Your First Skill", + installDialog: { + title: "Install skill", + dropHint: "Drop a .zip or .skill archive here, or", + chooseFile: "Choose file", + validating: "Validating archive…", + installing: "Installing…", + replace: "Replace existing", + install: "Install", + cancel: "Cancel", + nameLabel: "Name", + descriptionLabel: "Description", + licenseLabel: "License", + toolsLabel: "Allowed tools", + warningsLabel: "Warnings", + conflictCustom: + "A custom skill named '{name}' is already installed. Use 'Replace existing' to overwrite (its enabled state will be kept).", + conflictPublic: + "A built-in public skill named '{name}' already exists and cannot be overwritten.", + installSuccess: "Skill '{name}' installed.", + installSuccessReplaced: "Skill '{name}' replaced.", + rejectInvalidExt: "Only .zip or .skill files are accepted.", + }, }, notification: { title: "Notification", description: - "DeerFlow only sends a completion notification when the window is not active. This is especially useful for long-running tasks so you can switch to other work and get notified when done.", + "CM Assistant only sends a completion notification when the window is not active. This is especially useful for long-running tasks so you can switch to other work and get notified when done.", requestPermission: "Request notification permission", deniedHint: "Notification permission was denied. You can enable it in your browser's site settings to receive completion alerts.", testButton: "Send test notification", - testTitle: "DeerFlow", + testTitle: "CM Assistant", testBody: "This is a test notification.", notSupported: "Your browser does not support notifications.", disableNotification: "Disable notification", diff --git a/frontend-web/src/core/i18n/locales/types.ts b/frontend-web/src/core/i18n/locales/types.ts index dfe56102..5551939c 100644 --- a/frontend-web/src/core/i18n/locales/types.ts +++ b/frontend-web/src/core/i18n/locales/types.ts @@ -354,9 +354,30 @@ export interface Translations { title: string; description: string; createSkill: string; + installSkill: string; emptyTitle: string; emptyDescription: string; emptyButton: string; + installDialog: { + title: string; + dropHint: string; + chooseFile: string; + validating: string; + installing: string; + replace: string; + install: string; + cancel: string; + nameLabel: string; + descriptionLabel: string; + licenseLabel: string; + toolsLabel: string; + warningsLabel: string; + conflictCustom: string; + conflictPublic: string; + installSuccess: string; + installSuccessReplaced: string; + rejectInvalidExt: string; + }; }; notification: { title: string; diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts index acf20df8..64d52a81 100644 --- a/frontend-web/src/core/i18n/locales/zh-CN.ts +++ b/frontend-web/src/core/i18n/locales/zh-CN.ts @@ -61,11 +61,11 @@ export const zhCN: Translations = { welcome: { greeting: "你好,欢迎回来!", description: - "欢迎使用 CM业务中心-问答管理", + "欢迎使用 CM 助手", createYourOwnSkill: "创建你自己的 Agent SKill", createYourOwnSkillDescription: - "创建你的 Agent Skill 来释放 CM业务中心-问答管理的潜力。通过自定义技能,CM业务中心-问答管理\n可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n网页等作品,几乎可以做任何事情。", + "创建你的 Agent Skill 来释放 CM 助手的潜力。通过自定义技能,CM 助手\n可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n网页等作品,几乎可以做任何事情。", }, // Clipboard @@ -196,13 +196,13 @@ export const zhCN: Translations = { save: "保存智能体", saving: "正在保存智能体...", saveRequested: - "已提交保存请求,CM业务中心-问答管理 正在根据当前对话生成并保存初版智能体。", + "已提交保存请求,CM 助手 正在根据当前对话生成并保存初版智能体。", saveHint: "你可以在右上角的菜单里随时保存这个智能体,就算目前还只是初稿也可以。", saveCommandMessage: "请现在根据我们目前已经讨论的全部内容保存这个自定义智能体。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。", agentCreatedPendingRefresh: - "智能体已创建,但 CM业务中心-问答管理 暂时还无法读取到它。请稍后刷新当前页面。", + "智能体已创建,但 CM 助手 暂时还无法读取到它。请稍后刷新当前页面。", more: "更多操作", agentCreated: "智能体已创建!", startChatting: "开始对话", @@ -217,13 +217,13 @@ export const zhCN: Translations = { // Workspace workspace: { - officialWebsite: "访问 CM业务中心-问答管理 官方网站", - githubTooltip: "访问 CM业务中心-问答管理 的 Github 仓库", + officialWebsite: "访问 CM 助手 官方网站", + githubTooltip: "访问 CM 助手 的 Github 仓库", settingsAndMore: "设置和更多", - visitGithub: "在 Github 上查看 CM业务中心-问答管理", + visitGithub: "在 Github 上查看 CM 助手", reportIssue: "报告问题", contactUs: "联系我们", - about: "关于 CM业务中心-问答管理", + about: "关于 CM 助手", }, // Conversation @@ -239,7 +239,7 @@ export const zhCN: Translations = { // Page titles (document title) pages: { - appName: "CM业务中心-问答管理", + appName: "CM 助手", brandSubtitle: "Smart CM Platform", chats: "对话", newChat: "新对话", @@ -265,7 +265,7 @@ export const zhCN: Translations = { writeFile: "写入文件", clickToViewContent: "点击查看文件内容", writeTodos: "更新 To-do 列表", - skillInstallTooltip: "安装技能并使其可在 CM业务中心-问答管理 中使用", + skillInstallTooltip: "安装技能并使其可在 CM 助手 中使用", }, uploads: { @@ -300,7 +300,7 @@ export const zhCN: Translations = { noResults: "未找到结果。", actions: "操作", keyboardShortcuts: "键盘快捷键", - keyboardShortcutsDescription: "使用键盘快捷键更快地操作 CM业务中心-问答管理。", + keyboardShortcutsDescription: "使用键盘快捷键更快地操作 CM 助手。", openCommandPalette: "打开命令面板", toggleSidebar: "切换侧边栏", }, @@ -308,7 +308,7 @@ export const zhCN: Translations = { // Settings settings: { title: "设置", - description: "根据你的偏好调整 CM业务中心-问答管理 的界面和行为。", + description: "根据你的偏好调整 CM 助手 的界面和行为。", sections: { appearance: "外观", memory: "记忆", @@ -320,7 +320,7 @@ export const zhCN: Translations = { memory: { title: "记忆", description: - "CM业务中心-问答管理 会在后台不断从你的对话中自动学习。这些记忆能帮助 CM业务中心-问答管理 更好地理解你,并提供更个性化的体验。", + "CM 助手 会在后台不断从你的对话中自动学习。这些记忆能帮助 CM 助手 更好地理解你,并提供更个性化的体验。", empty: "暂无可展示的记忆数据。", rawJson: "原始 JSON", exportButton: "导出记忆", @@ -414,20 +414,42 @@ export const zhCN: Translations = { title: "技能", description: "管理 Agent Skill 配置和启用状态。", createSkill: "新建技能", + installSkill: "安装技能", emptyTitle: "还没有技能", emptyDescription: - "将你的 Agent Skill 文件夹放在 CM业务中心-问答管理 根目录下的 `/skills/custom` 文件夹中。", + "将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。", emptyButton: "创建你的第一个技能", + installDialog: { + title: "安装技能", + dropHint: "拖拽 .zip 或 .skill 文件到此处,或者", + chooseFile: "选择文件", + validating: "正在校验文件…", + installing: "安装中…", + replace: "覆盖已有", + install: "安装", + cancel: "取消", + nameLabel: "名称", + descriptionLabel: "描述", + licenseLabel: "许可证", + toolsLabel: "允许的工具", + warningsLabel: "警告", + conflictCustom: + "已存在同名自定义技能 “{name}”。点击「覆盖已有」可替换(原启用状态保留)。", + conflictPublic: "存在同名内置技能 “{name}”,无法覆盖。", + installSuccess: "技能 “{name}” 已安装。", + installSuccessReplaced: "技能 “{name}” 已替换。", + rejectInvalidExt: "只接受 .zip 或 .skill 文件。", + }, }, notification: { title: "通知", description: - "CM业务中心-问答管理 只会在窗口不活跃时发送完成通知,特别适合长时间任务:你可以先去做别的事,完成后会收到提醒。", + "CM 助手 只会在窗口不活跃时发送完成通知,特别适合长时间任务:你可以先去做别的事,完成后会收到提醒。", requestPermission: "请求通知权限", deniedHint: "通知权限已被拒绝。可在浏览器的网站设置中重新开启,以接收完成提醒。", testButton: "发送测试通知", - testTitle: "CM业务中心-问答管理", + testTitle: "CM 助手", testBody: "这是一条测试通知。", notSupported: "当前浏览器不支持通知功能。", disableNotification: "关闭通知", diff --git a/frontend-web/src/core/scheduled-tasks/api.ts b/frontend-web/src/core/scheduled-tasks/api.ts index 6d8a1942..674ba733 100644 --- a/frontend-web/src/core/scheduled-tasks/api.ts +++ b/frontend-web/src/core/scheduled-tasks/api.ts @@ -7,6 +7,7 @@ import type { ScheduledTaskDeliveryProfile, ScheduledTaskResultFileContent, ScheduledTaskRun, + ScheduledTaskRunFileUpdateResponse, ScheduledTaskSubscription, SchedulerThread, } from "./types"; @@ -81,6 +82,20 @@ export async function getScheduledTaskRunFileContent(runId: string, fileIndex: n ); } +export async function updateScheduledTaskRunFile( + runId: string, + fileIndex: number, + content: string, +) { + return parseResponse( + await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}/files/${fileIndex}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }), + ); +} + function filenameFromContentDisposition(header: string | null): string | null { if (!header) return null; const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header); diff --git a/frontend-web/src/core/scheduled-tasks/markdown-docx.ts b/frontend-web/src/core/scheduled-tasks/markdown-docx.ts new file mode 100644 index 00000000..0c90bbdd --- /dev/null +++ b/frontend-web/src/core/scheduled-tasks/markdown-docx.ts @@ -0,0 +1,274 @@ +/** + * Convert markdown text to a Word (.docx) Blob in the browser. + * + * Implementation notes: + * - Uses the `docx` npm package to construct an OOXML document client-side. + * - Parser is intentionally lightweight: it covers the markdown features that + * scheduled-task agent reports actually emit — ATX headings, paragraphs, + * ordered/unordered lists, fenced code blocks, blockquotes, horizontal + * rules, and inline `**bold**` / `*italic*` / `` `code` `` runs. + * - We deliberately do NOT reach for a full markdown AST library (remark/ + * unified) to keep the bundle small. Edge cases (tables, footnotes, html) + * degrade to plain text rather than failing loudly. + */ + +import { + AlignmentType, + Document, + HeadingLevel, + Packer, + Paragraph, + TextRun, + type IRunOptions, +} from "docx"; + +const HEADING_LEVELS = [ + HeadingLevel.HEADING_1, + HeadingLevel.HEADING_2, + HeadingLevel.HEADING_3, + HeadingLevel.HEADING_4, + HeadingLevel.HEADING_5, + HeadingLevel.HEADING_6, +]; + +type InlineSegment = { text: string; bold?: boolean; italic?: boolean; code?: boolean }; + +/** + * Tokenise a single line of markdown into formatted segments. + * + * Recognised inline syntax: + * `code`, **bold**, __bold__, *italic*, _italic_ + * Order matters: code spans win over emphasis so backticks don't get parsed + * as italic delimiters. + */ +function parseInlineSegments(text: string): InlineSegment[] { + const tokens: InlineSegment[] = []; + const pattern = /(`+)([^`]+)\1|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g; + let cursor = 0; + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + if (match.index > cursor) { + tokens.push({ text: text.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 < text.length) { + tokens.push({ text: text.slice(cursor) }); + } + return tokens.length > 0 ? tokens : [{ text }]; +} + +function segmentsToRuns(segments: InlineSegment[], extra?: Partial): TextRun[] { + return segments.map((seg) => { + const opts: IRunOptions = { + text: seg.text, + bold: seg.bold, + italics: seg.italic, + ...extra, + }; + if (seg.code) { + opts.font = { name: "Consolas" }; + } + return new TextRun(opts); + }); +} + +function makeParagraphFromInline(text: string): Paragraph { + return new Paragraph({ + children: segmentsToRuns(parseInlineSegments(text)), + }); +} + +function makeHeadingParagraph(level: number, text: string): Paragraph { + return new Paragraph({ + heading: HEADING_LEVELS[Math.min(level, HEADING_LEVELS.length) - 1], + children: segmentsToRuns(parseInlineSegments(text)), + }); +} + +function makeListItem(text: string, ordered: boolean, level: number): Paragraph { + return new Paragraph({ + 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({ + children: [ + new TextRun({ + text: line || " ", + font: { name: "Consolas" }, + }), + ], + }); +} + +function makeQuoteParagraph(text: string): Paragraph { + return new Paragraph({ + children: segmentsToRuns(parseInlineSegments(text), { italics: true }), + indent: { left: 720 }, + }); +} + +function makeHorizontalRule(): Paragraph { + return new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: "────────" })], + }); +} + +/** + * Convert markdown text into an array of docx Paragraph objects. + * + * Block grammar handled (line-by-line): + * - ATX heading: `^(#{1,6})\s+(.*)$` + * - Fenced code: lines between ``` ``` are emitted verbatim + * - Blockquote: `^>\s?(.*)$` + * - Ordered list: `^\s*(\d+)\.\s+(.*)$` + * - Unordered list: `^\s*[-*+]\s+(.*)$` + * - Horizontal rule: `^(---|\*\*\*|___)\s*$` + * - Blank line: paragraph break + * - Anything else: paragraph (with inline formatting) + */ +function markdownToParagraphs(markdown: string): Paragraph[] { + const lines = markdown.replace(/\r\n/g, "\n").split("\n"); + const paragraphs: Paragraph[] = []; + + let inFence = false; + let paragraphBuffer: string[] = []; + + const flushParagraph = () => { + if (paragraphBuffer.length === 0) return; + paragraphs.push(makeParagraphFromInline(paragraphBuffer.join(" "))); + paragraphBuffer = []; + }; + + for (const rawLine of lines) { + const line = rawLine.replace(/\t/g, " "); + + if (line.trim().startsWith("```")) { + flushParagraph(); + inFence = !inFence; + continue; + } + if (inFence) { + paragraphs.push(makeCodeParagraph(line)); + continue; + } + + if (!line.trim()) { + flushParagraph(); + continue; + } + + const heading = /^(#{1,6})\s+(.*)$/.exec(line); + if (heading) { + flushParagraph(); + paragraphs.push(makeHeadingParagraph(heading[1].length, heading[2].trim())); + continue; + } + + if (/^(?:---|\*\*\*|___)\s*$/.test(line)) { + flushParagraph(); + paragraphs.push(makeHorizontalRule()); + continue; + } + + const blockquote = /^>\s?(.*)$/.exec(line); + if (blockquote) { + flushParagraph(); + paragraphs.push(makeQuoteParagraph(blockquote[1])); + continue; + } + + const orderedItem = /^(\s*)(\d+)\.\s+(.*)$/.exec(line); + if (orderedItem) { + flushParagraph(); + const indent = Math.floor(orderedItem[1].length / 2); + paragraphs.push(makeListItem(orderedItem[3], true, indent)); + continue; + } + + const unorderedItem = /^(\s*)[-*+]\s+(.*)$/.exec(line); + if (unorderedItem) { + flushParagraph(); + const indent = Math.floor(unorderedItem[1].length / 2); + paragraphs.push(makeListItem(unorderedItem[2], false, indent)); + continue; + } + + paragraphBuffer.push(line.trim()); + } + + flushParagraph(); + return paragraphs; +} + +/** + * Convert a markdown string into a .docx Blob suitable for download. + */ +export async function markdownToDocxBlob(markdown: string, title?: string): Promise { + const children: Paragraph[] = []; + if (title?.trim()) { + children.push( + new Paragraph({ + heading: HeadingLevel.TITLE, + children: [new TextRun({ text: title.trim(), bold: true })], + }), + ); + } + children.push(...markdownToParagraphs(markdown)); + + const doc = new Document({ + 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 }], + }); + return Packer.toBlob(doc); +} + +/** + * Trigger a browser download of the markdown source converted to .docx. + */ +export async function downloadMarkdownAsDocx( + markdown: string, + fileName: string, + title?: string, +): Promise { + const blob = await markdownToDocxBlob(markdown, 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.rel = "noopener"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 30_000); +} diff --git a/frontend-web/src/core/scheduled-tasks/types.ts b/frontend-web/src/core/scheduled-tasks/types.ts index e8009dc1..4373375c 100644 --- a/frontend-web/src/core/scheduled-tasks/types.ts +++ b/frontend-web/src/core/scheduled-tasks/types.ts @@ -89,4 +89,13 @@ export type ScheduledTaskRun = { result?: ScheduledTaskRunResult | null; created_at?: string | null; updated_at?: string | null; + can_edit?: boolean; +}; + +export type ScheduledTaskRunFileUpdateResponse = { + index: number; + name: string; + size: number; + mime_type?: string | null; + kind?: string | null; }; diff --git a/frontend-web/src/core/skills/api.ts b/frontend-web/src/core/skills/api.ts index 5c099045..d2d89fb5 100644 --- a/frontend-web/src/core/skills/api.ts +++ b/frontend-web/src/core/skills/api.ts @@ -34,6 +34,18 @@ export interface InstallSkillResponse { success: boolean; skill_name: string; message: string; + overwritten?: boolean; +} + +export interface ValidateSkillUploadResponse { + ok: boolean; + skill_name: string; + description: string; + license: string | null; + allowed_tools: string[]; + conflict: boolean; + conflict_kind: "public" | "custom" | null; + warnings: string[]; } export async function installSkill( @@ -61,3 +73,40 @@ export async function installSkill( return response.json(); } + +async function parseDetailedError(response: Response, fallback: string): Promise { + const data = await response.json().catch(() => ({})); + const detail = (data as { detail?: unknown }).detail; + const message = typeof detail === "string" && detail ? detail : `${fallback} (HTTP ${response.status})`; + return new Error(message); +} + +export async function validateSkillUpload(file: File): Promise { + const form = new FormData(); + form.append("file", file, file.name); + const response = await apiFetch(`${getBackendBaseURL()}/api/skills/validate`, { + method: "POST", + body: form, + }); + if (!response.ok) { + throw await parseDetailedError(response, "Validation failed"); + } + return response.json() as Promise; +} + +export async function installSkillUpload( + file: File, + options: { overwrite?: boolean } = {}, +): Promise { + const form = new FormData(); + form.append("file", file, file.name); + const query = options.overwrite ? "?overwrite=true" : ""; + const response = await apiFetch( + `${getBackendBaseURL()}/api/skills/install-upload${query}`, + { method: "POST", body: form }, + ); + if (!response.ok) { + throw await parseDetailedError(response, "Install failed"); + } + return response.json() as Promise; +} diff --git a/frontend-web/src/core/skills/hooks.ts b/frontend-web/src/core/skills/hooks.ts index e9b5adf9..5abd7f71 100644 --- a/frontend-web/src/core/skills/hooks.ts +++ b/frontend-web/src/core/skills/hooks.ts @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { enableSkill } from "./api"; +import { enableSkill, installSkillUpload, validateSkillUpload } from "./api"; import { loadSkills } from "."; @@ -29,3 +29,20 @@ export function useEnableSkill() { }, }); } + +export function useValidateSkillUpload() { + return useMutation({ + mutationFn: (file: File) => validateSkillUpload(file), + }); +} + +export function useInstallSkillUpload() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ file, overwrite }: { file: File; overwrite?: boolean }) => + installSkillUpload(file, { overwrite }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["skills"] }); + }, + }); +} diff --git a/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx b/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx index cb8d52f1..35e950ee 100644 --- a/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx +++ b/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import { ChevronDown, Download, FileText, ListChecks, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ChevronDown, Download, Edit3, FileText, ListChecks, Loader2, Save, Trash2, X } from "lucide-react"; import { useNavigate, useParams } from "react-router-dom"; import { toast } from "sonner"; @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; import { WorkspaceBody, WorkspaceContainer, @@ -22,9 +23,11 @@ import { getScheduledTaskRunFileContent, type ScheduledTaskResultFileContent, type ScheduledTaskRun, + updateScheduledTaskRunFile, useScheduledTaskActions, useScheduledTaskRun, } from "@/core/scheduled-tasks"; +import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { cn } from "@/lib/utils"; function formatDate(value?: string | null) { @@ -91,15 +94,23 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) { return
{file.content}
; } +type EditState = { + draft: string; + saving: boolean; +}; + export default function ScheduledTaskRunDetailPage() { const navigate = useNavigate(); const { run_id } = useParams(); const actions = useScheduledTaskActions(); const { data: run, isLoading } = useScheduledTaskRun(run_id); const result = run?.result ?? null; + const canEdit = run?.can_edit ?? false; const [fileContents, setFileContents] = useState([]); const [filesLoading, setFilesLoading] = useState(false); const [showMeta, setShowMeta] = useState(false); + // Per-file-index edit state. `null` = not in edit mode. + const [edits, setEdits] = useState>({}); const resultFiles = useMemo(() => result?.files ?? [], [result]); const fileCount = resultFiles.length; @@ -180,6 +191,64 @@ export default function ScheduledTaskRunDetailPage() { } }; + const handleEnterEdit = useCallback( + (file: ScheduledTaskResultFileContent) => { + setEdits((prev) => ({ + ...prev, + [file.index]: { draft: file.content ?? "", saving: false }, + })); + }, + [], + ); + + const handleCancelEdit = useCallback((index: number) => { + setEdits((prev) => ({ ...prev, [index]: null })); + }, []); + + const handleSaveEdit = useCallback( + async (file: ScheduledTaskResultFileContent) => { + if (!run) return; + const current = edits[file.index]; + if (!current) return; + setEdits((prev) => ({ + ...prev, + [file.index]: { ...current, saving: true }, + })); + try { + const updated = await updateScheduledTaskRunFile(run.id, file.index, current.draft); + setFileContents((prev) => + prev.map((entry) => + entry.index === file.index + ? { ...entry, content: current.draft, size: updated.size } + : entry, + ), + ); + setEdits((prev) => ({ ...prev, [file.index]: null })); + toast.success(`文件 ${file.name} 已保存`); + } catch (error) { + setEdits((prev) => ({ + ...prev, + [file.index]: { ...current, saving: false }, + })); + toast.error(error instanceof Error ? error.message : "保存失败"); + } + }, + [edits, run], + ); + + const handleDownloadAsWord = useCallback( + async (file: ScheduledTaskResultFileContent) => { + const draft = edits[file.index]?.draft; + const source = draft ?? file.content ?? ""; + try { + await downloadMarkdownAsDocx(source, file.name, result?.title); + } catch (error) { + toast.error(error instanceof Error ? error.message : "导出 Word 失败"); + } + }, + [edits, result?.title], + ); + return ( @@ -280,26 +349,94 @@ export default function ScheduledTaskRunDetailPage() { )}
- {fileContents.map((file) => ( -
-
-
-
{file.name}
-
- {file.mime_type || file.kind || "附件"} - {typeof file.size === "number" ? ` · ${file.size} bytes` : ""} + {fileContents.map((file) => { + const isMarkdown = isMarkdownFile(file); + const editable = canEdit && file.previewable; + const editState = edits[file.index] ?? null; + const isEditing = editState !== null; + return ( +
+
+
+
{file.name}
+
+ {file.mime_type || file.kind || "附件"} + {typeof file.size === "number" ? ` · ${file.size} bytes` : ""} +
+
+
+ {isMarkdown && ( + + )} + + {editable && !isEditing && ( + + )} + {isEditing && ( + <> + + + + )}
- +
+ {isEditing ? ( +