Update local frontend and backend changes
This commit is contained in:
1 parent
faca2c9a2d
commit
a27206c543
35 files changed
+1756
-334
No files matched your search
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DeerFlow Vite</title>
|
||||
<title>CM 助手</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function Footer() {
|
||||
</div>
|
||||
<div className="text-muted-foreground container mb-8 flex flex-col items-center justify-center text-xs">
|
||||
<p>Licensed under MIT License</p>
|
||||
<p>© {year} CM业务中心-问答管理</p>
|
||||
<p>© {year} CM 助手</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
>
|
||||
<div className="flex items-center gap-6">
|
||||
<a
|
||||
href={homeURL ?? "https://github.com/bytedance/deer-flow"}
|
||||
href={homeURL ?? "/"}
|
||||
target={isExternalHome ? "_blank" : "_self"}
|
||||
rel={isExternalHome ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
<h1 className="font-serif text-xl">CM业务中心-问答管理</h1>
|
||||
<h1 className="font-serif text-xl">CM 助手</h1>
|
||||
</a>
|
||||
</div>
|
||||
<nav className="mr-8 ml-auto flex items-center gap-8 text-sm font-medium">
|
||||
@@ -51,77 +46,7 @@ export function Header({ className, homeURL }: HeaderProps) {
|
||||
{t.home.blog}
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-0 h-full w-full rounded-full opacity-30 blur-2xl"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #ff80b5 0%, #9089fc 100%)",
|
||||
filter: "blur(16px)",
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="group relative z-10"
|
||||
>
|
||||
<a
|
||||
href="https://github.com/bytedance/deer-flow"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<GitHubLogoIcon className="size-4" />
|
||||
Star on GitHub
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||
env.GITHUB_OAUTH_TOKEN && <StarCounter />}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<hr className="from-border/0 via-border/70 to-border/0 absolute top-16 right-0 left-0 z-10 m-0 h-px w-full border-none bg-linear-to-r" />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<StarFilledIcon className="size-4 transition-colors duration-300 group-hover:text-yellow-500" />
|
||||
<NumberTicker className="font-mono tabular-nums" value={stars} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function Hero({ className }: { className?: string }) {
|
||||
"Learn Anything",
|
||||
]}
|
||||
/>{" "}
|
||||
<div>CM业务中心-问答管理</div>
|
||||
<div>CM 助手</div>
|
||||
</h1>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && (
|
||||
<a
|
||||
|
||||
@@ -461,7 +461,7 @@ export default function ProgressiveSkillsAnimation() {
|
||||
<div className="border-b border-zinc-800 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-green-500" />
|
||||
<span className="text-sm text-zinc-400">CM业务中心-问答管理 Agent</span>
|
||||
<span className="text-sm text-zinc-400">CM 助手 Agent</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -691,7 +691,7 @@ export default function ProgressiveSkillsAnimation() {
|
||||
{/* Chat Input (decorative) */}
|
||||
<div className="border-t border-zinc-800 p-4">
|
||||
<div className="rounded-xl bg-zinc-800 px-4 py-3 text-sm text-zinc-500">
|
||||
Ask CM业务中心-问答管理 anything...
|
||||
Ask CM 助手 anything...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,7 @@ export function CaseStudySection({ className }: { className?: string }) {
|
||||
<Section
|
||||
className={className}
|
||||
title="Case Studies"
|
||||
subtitle="CM业务中心-问答管理"
|
||||
subtitle="CM 助手"
|
||||
>
|
||||
<div className="container-md mt-8 grid grid-cols-1 gap-4 px-4 md:grid-cols-2 md:px-20 lg:grid-cols-3">
|
||||
{caseStudies.map((caseStudy) => (
|
||||
|
||||
@@ -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
|
||||
</AuroraText>
|
||||
}
|
||||
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."
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<Button className="text-xl" size="lg" asChild>
|
||||
<Link
|
||||
href="https://github.com/bytedance/deer-flow"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<GitHubLogoIcon />
|
||||
Contribute Now
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export function SandboxSection({ className }: { className?: string }) {
|
||||
title="Agent Runtime Environment"
|
||||
subtitle={
|
||||
<p>
|
||||
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
|
||||
</p>
|
||||
|
||||
@@ -15,7 +15,7 @@ export function SkillsSection({ className }: { className?: string }) {
|
||||
Agent Skills are loaded progressively — only what's needed, when
|
||||
it's needed.
|
||||
<br />
|
||||
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.
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -52,8 +52,8 @@ export function WhatsNewSection({ className }: { className?: string }) {
|
||||
return (
|
||||
<Section
|
||||
className={cn("", className)}
|
||||
title="CM业务中心-问答管理"
|
||||
subtitle="CM业务中心-问答管理"
|
||||
title="CM 助手"
|
||||
subtitle="CM 助手"
|
||||
>
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<MagicBento data={features} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
`;
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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<string>("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({
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setInstallOpen(true)}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t.settings.skills.installSkill}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleCreateSkill}>
|
||||
<SparklesIcon className="size-4" />
|
||||
{t.settings.skills.createSkill}
|
||||
@@ -110,6 +136,10 @@ function SkillSettingsList({
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
<InstallSkillDialog
|
||||
open={installOpen}
|
||||
onClose={() => setInstallOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -133,3 +163,265 @@ function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallSkillDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const dialog = t.settings.skills.installDialog;
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [validation, setValidation] = useState<ValidateSkillUploadResponse | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(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<HTMLDivElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialog.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<FileArchive className="text-muted-foreground size-8" />
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{dialog.dropHint}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary ml-1 underline underline-offset-4"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{dialog.chooseFile}
|
||||
</button>
|
||||
</div>
|
||||
{file && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{file.name} · {(file.size / 1024).toFixed(1)} KB
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".zip,.skill,application/zip"
|
||||
hidden
|
||||
onChange={onPick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{validateMutation.isPending && (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{dialog.validating}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationError && (
|
||||
<div className="text-destructive flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{validationError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validation && !validationError && (
|
||||
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-emerald-600">
|
||||
<CheckCircle2 className="size-4" />
|
||||
<span className="font-medium">{validation.skill_name}</span>
|
||||
</div>
|
||||
<Field label={dialog.descriptionLabel}>
|
||||
<span className="line-clamp-3 whitespace-pre-wrap">
|
||||
{validation.description || "—"}
|
||||
</span>
|
||||
</Field>
|
||||
{validation.license && (
|
||||
<Field label={dialog.licenseLabel}>{validation.license}</Field>
|
||||
)}
|
||||
{validation.allowed_tools.length > 0 && (
|
||||
<Field label={dialog.toolsLabel}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{validation.allowed_tools.map((tool) => (
|
||||
<Badge key={tool} variant="secondary" className="text-xs">
|
||||
{tool}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
{validation.warnings.length > 0 && (
|
||||
<Field label={dialog.warningsLabel}>
|
||||
<ul className="list-disc pl-4 text-xs">
|
||||
{validation.warnings.map((w, idx) => (
|
||||
<li key={idx}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Field>
|
||||
)}
|
||||
{isCustomConflict && (
|
||||
<div className="text-amber-600 mt-1 flex items-start gap-2 text-xs">
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{dialog.conflictCustom.replace("{name}", validation.skill_name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isPublicConflict && (
|
||||
<div className="text-destructive mt-1 flex items-start gap-2 text-xs">
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{dialog.conflictPublic.replace("{name}", validation.skill_name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={installMutation.isPending}
|
||||
>
|
||||
{dialog.cancel}
|
||||
</Button>
|
||||
{canOverwrite && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => handleInstall(true)}
|
||||
disabled={installMutation.isPending}
|
||||
>
|
||||
{installMutation.isPending && (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
{dialog.replace}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => handleInstall(false)}
|
||||
disabled={!canInstall || installMutation.isPending}
|
||||
>
|
||||
{installMutation.isPending && (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
{installMutation.isPending ? dialog.installing : dialog.install}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[80px_1fr] gap-2">
|
||||
<span className="text-muted-foreground shrink-0 text-xs">{label}</span>
|
||||
<div className="min-w-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className="pr-4">
|
||||
<Tooltip content={t.workspace.githubTooltip}>
|
||||
<a
|
||||
href="https://github.com/bytedance/deer-flow"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="opacity-75 transition hover:opacity-100"
|
||||
>
|
||||
<GithubIcon className="size-6" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: "关闭通知",
|
||||
|
||||
@@ -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<ScheduledTaskRunFileUpdateResponse>(
|
||||
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);
|
||||
|
||||
@@ -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<IRunOptions>): 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<Blob> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<Error> {
|
||||
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<ValidateSkillUploadResponse> {
|
||||
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<ValidateSkillUploadResponse>;
|
||||
}
|
||||
|
||||
export async function installSkillUpload(
|
||||
file: File,
|
||||
options: { overwrite?: boolean } = {},
|
||||
): Promise<InstallSkillResponse> {
|
||||
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<InstallSkillResponse>;
|
||||
}
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
|
||||
}
|
||||
|
||||
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<ScheduledTaskResultFileContent[]>([]);
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
const [showMeta, setShowMeta] = useState(false);
|
||||
// Per-file-index edit state. `null` = not in edit mode.
|
||||
const [edits, setEdits] = useState<Record<number, EditState | null>>({});
|
||||
|
||||
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 (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
@@ -280,26 +349,94 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
{fileContents.map((file) => (
|
||||
<div key={file.index} className="rounded-md border">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{file.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{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 (
|
||||
<div key={file.index} className="rounded-md border">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{file.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{file.mime_type || file.kind || "附件"}
|
||||
{typeof file.size === "number" ? ` · ${file.size} bytes` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isMarkdown && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDownloadAsWord(file)}
|
||||
disabled={!file.content && !editState?.draft}
|
||||
title="将 Markdown 导出为 Word"
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
下载 Word
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
|
||||
<Download className="size-4" />
|
||||
下载
|
||||
</Button>
|
||||
{editable && !isEditing && (
|
||||
<Button size="sm" variant="outline" onClick={() => handleEnterEdit(file)}>
|
||||
<Edit3 className="size-4" />
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
{isEditing && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCancelEdit(file.index)}
|
||||
disabled={editState?.saving}
|
||||
>
|
||||
<X className="size-4" />
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSaveEdit(file)}
|
||||
disabled={editState?.saving}
|
||||
>
|
||||
{editState?.saving ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-4" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
|
||||
<Download className="size-4" />
|
||||
下载
|
||||
</Button>
|
||||
<div className="p-3">
|
||||
{isEditing ? (
|
||||
<Textarea
|
||||
className="min-h-80 font-mono text-xs"
|
||||
value={editState?.draft ?? ""}
|
||||
disabled={editState?.saving}
|
||||
onChange={(event) =>
|
||||
setEdits((prev) => ({
|
||||
...prev,
|
||||
[file.index]: {
|
||||
draft: event.target.value,
|
||||
saving: prev[file.index]?.saving ?? false,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<RenderPreview file={file} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<RenderPreview file={file} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="meta" className="m-0 p-3">
|
||||
|
||||
@@ -47,6 +47,13 @@ class ScheduledTaskSubscribeRequest(BaseModel):
|
||||
notify_element: bool = False
|
||||
|
||||
|
||||
class ScheduledTaskRunFileUpdateRequest(BaseModel):
|
||||
content: str = Field(..., description="New text content to write into the run file")
|
||||
|
||||
|
||||
_MAX_RUN_FILE_EDIT_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||
|
||||
|
||||
async def _user_id(request: Request) -> str:
|
||||
user_id = await get_current_user(request)
|
||||
if not user_id:
|
||||
@@ -184,6 +191,27 @@ async def get_scheduled_task_run_file(run_id: str, file_index: int, request: Req
|
||||
}
|
||||
|
||||
|
||||
@router.put("/runs/{run_id}/files/{file_index}")
|
||||
async def update_scheduled_task_run_file(
|
||||
run_id: str,
|
||||
file_index: int,
|
||||
body: ScheduledTaskRunFileUpdateRequest,
|
||||
request: Request,
|
||||
) -> dict:
|
||||
if len(body.content.encode("utf-8")) > _MAX_RUN_FILE_EDIT_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Edited content exceeds the 5 MB limit")
|
||||
service = get_scheduled_task_service()
|
||||
user_id = await _user_id(request)
|
||||
try:
|
||||
return await service.update_run_file(user_id, run_id, file_index, body.content)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/download")
|
||||
async def download_scheduled_task_run(run_id: str, request: Request):
|
||||
service = get_scheduled_task_service()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_config
|
||||
@@ -56,6 +57,20 @@ class SkillInstallResponse(BaseModel):
|
||||
success: bool = Field(..., description="Whether the installation was successful")
|
||||
skill_name: str = Field(..., description="Name of the installed skill")
|
||||
message: str = Field(..., description="Installation result message")
|
||||
overwritten: bool = Field(default=False, description="True if an existing skill was replaced")
|
||||
|
||||
|
||||
class SkillUploadValidateResponse(BaseModel):
|
||||
"""Response model for the pre-install validation endpoint."""
|
||||
|
||||
ok: bool = Field(..., description="Whether the archive passed cheap validation")
|
||||
skill_name: str = Field(..., description="Skill name parsed from frontmatter")
|
||||
description: str = Field(default="", description="Skill description parsed from frontmatter")
|
||||
license: str | None = Field(default=None, description="Skill license, if declared")
|
||||
allowed_tools: list[str] = Field(default_factory=list, description="Tools the skill may invoke")
|
||||
conflict: bool = Field(default=False, description="True if a skill with the same name already exists")
|
||||
conflict_kind: str | None = Field(default=None, description="'public' or 'custom' when conflict=true")
|
||||
warnings: list[str] = Field(default_factory=list, description="Non-fatal warnings to surface to the user")
|
||||
|
||||
|
||||
class CustomSkillContentResponse(SkillResponse):
|
||||
@@ -100,6 +115,110 @@ async def list_skills(config: AppConfig = Depends(get_config)) -> SkillsListResp
|
||||
raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
|
||||
|
||||
|
||||
_MAX_SKILL_UPLOAD_BYTES = 512 * 1024 * 1024
|
||||
_ZIP_MAGIC = b"PK\x03\x04"
|
||||
|
||||
|
||||
async def _persist_uploaded_skill(file: UploadFile) -> Path:
|
||||
"""Stream an uploaded skill archive to a temp file. Caller must unlink it.
|
||||
|
||||
Validates the leading bytes look like a ZIP archive and enforces the same
|
||||
512 MB cap that the extractor uses, so we reject obvious garbage early.
|
||||
"""
|
||||
if file.filename is None:
|
||||
raise HTTPException(status_code=400, detail="No file provided")
|
||||
suffix = Path(file.filename).suffix.lower()
|
||||
if suffix not in {".zip", ".skill"}:
|
||||
raise HTTPException(status_code=400, detail="Skill upload must be a .zip or .skill file")
|
||||
|
||||
fd, tmp_name = tempfile.mkstemp(suffix=".skill", prefix="skill-upload-")
|
||||
tmp_path = Path(tmp_name)
|
||||
total = 0
|
||||
head: bytes = b""
|
||||
try:
|
||||
with open(fd, "wb") as out:
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
if not head:
|
||||
head = chunk[: len(_ZIP_MAGIC)]
|
||||
total += len(chunk)
|
||||
if total > _MAX_SKILL_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=400, detail="Uploaded skill archive exceeds the 512 MB limit")
|
||||
out.write(chunk)
|
||||
except HTTPException:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
if not head.startswith(_ZIP_MAGIC):
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
|
||||
return tmp_path
|
||||
|
||||
|
||||
@router.post(
|
||||
"/skills/validate",
|
||||
response_model=SkillUploadValidateResponse,
|
||||
summary="Validate Skill Archive (No Install)",
|
||||
description="Inspect an uploaded .zip/.skill archive and report parsed metadata + conflicts. Does NOT install or run the LLM security scan.",
|
||||
)
|
||||
async def validate_skill_upload(
|
||||
file: UploadFile = File(..., description="The .zip or .skill archive to validate"),
|
||||
config: AppConfig = Depends(get_config),
|
||||
) -> SkillUploadValidateResponse:
|
||||
tmp_path = await _persist_uploaded_skill(file)
|
||||
try:
|
||||
result = get_or_new_skill_storage(app_config=config).validate_skill_archive(tmp_path)
|
||||
return SkillUploadValidateResponse(**result)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to validate uploaded skill: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to validate skill: {e}")
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/skills/install-upload",
|
||||
response_model=SkillInstallResponse,
|
||||
summary="Install Skill from Upload",
|
||||
description="Install a skill directly from an uploaded .zip/.skill archive. Set ?overwrite=true to replace an existing skill with the same name.",
|
||||
)
|
||||
async def install_skill_upload(
|
||||
file: UploadFile = File(..., description="The .zip or .skill archive to install"),
|
||||
overwrite: bool = False,
|
||||
config: AppConfig = Depends(get_config),
|
||||
) -> SkillInstallResponse:
|
||||
tmp_path = await _persist_uploaded_skill(file)
|
||||
try:
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False)
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
return SkillInstallResponse(**result)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except SkillAlreadyExistsError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to install uploaded skill: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to install skill: {e}")
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/skills/install",
|
||||
response_model=SkillInstallResponse,
|
||||
|
||||
+19
-21
@@ -384,7 +384,7 @@ task(description="Oracle Cloud analysis", prompt="...", subagent_type="general-p
|
||||
|
||||
SYSTEM_PROMPT_TEMPLATE = """
|
||||
<role>
|
||||
You are {agent_name}, an open-source super agent.
|
||||
You are {agent_name}, an internal super agent.
|
||||
</role>
|
||||
|
||||
{soul}
|
||||
@@ -438,21 +438,19 @@ You are {agent_name}, an open-source super agent.
|
||||
- ❌ DO NOT skip clarification for "efficiency" - accuracy matters more than speed
|
||||
- ❌ DO NOT make assumptions when information is missing - ALWAYS ask
|
||||
- ❌ DO NOT proceed with guesses - STOP and call ask_clarification first
|
||||
- ✅ Analyze the request in thinking → Identify unclear aspects → Ask BEFORE any action
|
||||
- ✅ If you identify the need for clarification in your thinking, you MUST call the tool IMMEDIATELY
|
||||
- ✅ After calling ask_clarification, execution will be interrupted automatically
|
||||
- ✅ Wait for user response - do NOT continue with assumptions
|
||||
- ✅ When practical, provide 3-4 clear, mutually distinct options so the user can choose quickly
|
||||
- ✅ Keep options concise and actionable; the user can still provide a custom answer if none fit
|
||||
- ✅ Analyze the request in thinking → Identify unclear aspects → Ask BEFORE any action
|
||||
- ✅ If you identify the need for clarification in your thinking, you MUST call the tool IMMEDIATELY
|
||||
- ✅ After calling ask_clarification, execution will be interrupted automatically
|
||||
- ✅ Wait for user response - do NOT continue with assumptions
|
||||
|
||||
**How to Use:**
|
||||
```python
|
||||
ask_clarification(
|
||||
question="Your specific question here?",
|
||||
clarification_type="missing_info", # or other type
|
||||
context="Why you need this information", # optional but recommended
|
||||
options=["option1", "option2", "option3"] # use 3-4 options when practical
|
||||
)
|
||||
ask_clarification(
|
||||
question="Your specific question here?",
|
||||
clarification_type="missing_info", # or other type
|
||||
context="Why you need this information", # optional but recommended
|
||||
options=["option1", "option2"] # optional, for choices
|
||||
)
|
||||
```
|
||||
|
||||
**Example:**
|
||||
@@ -519,10 +517,10 @@ Recent breakthroughs in language models have also accelerated progress
|
||||
```markdown
|
||||
## Executive Summary
|
||||
|
||||
DeerFlow is an open-source AI agent framework that gained significant traction in early 2026
|
||||
[citation:GitHub Repository](https://github.com/bytedance/deer-flow). The project focuses on
|
||||
providing a production-ready agent system with sandbox execution and memory management
|
||||
[citation:DeerFlow Documentation](https://deer-flow.dev/docs).
|
||||
LangGraph is an open-source workflow orchestration framework for LLM applications
|
||||
[citation:LangGraph Repository](https://github.com/langchain-ai/langgraph). The project
|
||||
focuses on providing stateful multi-agent graphs with built-in checkpointing and streaming
|
||||
[citation:LangGraph Documentation](https://langchain-ai.github.io/langgraph/).
|
||||
|
||||
## Key Analysis
|
||||
|
||||
@@ -534,8 +532,8 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
|
||||
## Sources
|
||||
|
||||
### Primary Sources
|
||||
- [GitHub Repository](https://github.com/bytedance/deer-flow) - Official source code and documentation
|
||||
- [DeerFlow Documentation](https://deer-flow.dev/docs) - Technical specifications
|
||||
- [LangGraph Repository](https://github.com/langchain-ai/langgraph) - Official source code and documentation
|
||||
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/) - Technical specifications
|
||||
|
||||
### Media Coverage
|
||||
- [AI Trends 2026](https://techcrunch.com/ai-trends) - Industry analysis
|
||||
@@ -547,7 +545,7 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
|
||||
- The `[citation:Title](URL)` format is ONLY for inline citations within the report body
|
||||
- ❌ WRONG: `GitHub 仓库 - 官方源代码和文档` (no URL!)
|
||||
- ❌ WRONG in Sources: `[citation:GitHub Repository](url)` (citation prefix is for inline only!)
|
||||
- ✅ RIGHT in Sources: `[GitHub Repository](https://github.com/bytedance/deer-flow) - 官方源代码和文档`
|
||||
- ✅ RIGHT in Sources: `[LangGraph Repository](https://github.com/langchain-ai/langgraph) - 官方源代码和文档`
|
||||
|
||||
**WORKFLOW for Research Tasks:**
|
||||
1. Use web_search to find sources → Extract {{title, url, snippet}} from results
|
||||
@@ -839,7 +837,7 @@ def apply_prompt_template(
|
||||
|
||||
# Format the prompt with dynamic skills and memory
|
||||
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
||||
agent_name=agent_name or storage_agent_id or "DeerFlow 2.0",
|
||||
agent_name=agent_name or storage_agent_id or "CM 助手",
|
||||
soul=get_agent_soul(storage_agent_id),
|
||||
skills_section=skills_section,
|
||||
deferred_tools_section=deferred_tools_section,
|
||||
|
||||
+71
@@ -252,6 +252,7 @@ class ScheduledTaskService:
|
||||
async def get_run(self, user_id: str, run_id: str) -> dict[str, Any] | None:
|
||||
run = await self.store.get_run(run_id, user_id)
|
||||
if run is not None:
|
||||
run["can_edit"] = True
|
||||
return run
|
||||
# Subscriber visibility: look up the run unfiltered, then verify the
|
||||
# caller has a subscription on the parent task.
|
||||
@@ -260,8 +261,78 @@ class ScheduledTaskService:
|
||||
return None
|
||||
if await self.store.get_subscription(candidate["task_id"], user_id) is None:
|
||||
return None
|
||||
candidate["can_edit"] = False
|
||||
return candidate
|
||||
|
||||
async def update_run_file(
|
||||
self,
|
||||
user_id: str,
|
||||
run_id: str,
|
||||
file_index: int,
|
||||
new_content: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Overwrite an archived run file in place. Owner-only.
|
||||
|
||||
Returns updated file metadata. Raises:
|
||||
- PermissionError if the caller is not the run owner (subscribers blocked)
|
||||
- FileNotFoundError if the run or file index does not exist
|
||||
- ValueError if the file is not editable (binary / not previewable)
|
||||
"""
|
||||
run = await self.store.get_run(run_id, user_id)
|
||||
if run is None:
|
||||
# Either the run doesn't exist, or the caller is a subscriber, not owner.
|
||||
candidate = await self.store.get_run_any(run_id)
|
||||
if candidate is None:
|
||||
raise FileNotFoundError(f"Run {run_id} not found")
|
||||
raise PermissionError("Only the run owner may edit result files")
|
||||
|
||||
result = run.get("result") or {}
|
||||
files = result.get("files") if isinstance(result, dict) else None
|
||||
if not isinstance(files, list) or file_index < 0 or file_index >= len(files):
|
||||
raise FileNotFoundError(f"File index {file_index} not found in run {run_id}")
|
||||
item = files[file_index]
|
||||
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
|
||||
raise FileNotFoundError(f"File index {file_index} has no archive path")
|
||||
|
||||
archive_path = Path(item["path"])
|
||||
if not archive_path.exists() or not archive_path.is_file():
|
||||
raise FileNotFoundError(f"Archived file {archive_path} is no longer available")
|
||||
|
||||
# Constrain edits to text-previewable files. We mirror the same rule the
|
||||
# router uses for previews so binary files cannot be silently corrupted.
|
||||
suffix = archive_path.suffix.lower()
|
||||
mime_type = item.get("mime_type")
|
||||
is_text = (
|
||||
(isinstance(mime_type, str) and (mime_type.startswith("text/") or mime_type in {"application/json", "application/xml"}))
|
||||
or suffix in {".md", ".markdown", ".txt", ".json", ".csv", ".xml", ".yaml", ".yml", ".log"}
|
||||
)
|
||||
if not is_text:
|
||||
raise ValueError("Only text-previewable files (markdown, txt, json, etc.) can be edited")
|
||||
|
||||
# Make sure we stay inside the per-run archive root before touching disk.
|
||||
archive_root = (get_paths().base_dir / "scheduled-task-runs" / run_id).resolve()
|
||||
try:
|
||||
archive_path.resolve().relative_to(archive_root)
|
||||
except ValueError as exc:
|
||||
raise PermissionError("Refusing to edit file outside the run archive") from exc
|
||||
|
||||
archive_path.write_text(new_content, encoding="utf-8")
|
||||
new_size = archive_path.stat().st_size
|
||||
|
||||
# Reflect the new size in the run row so subsequent list/preview calls
|
||||
# report the latest figure. Other fields stay untouched.
|
||||
files[file_index] = {**item, "size": new_size}
|
||||
new_result = {**result, "files": files}
|
||||
await self.store.update_run(run_id, {"result": new_result})
|
||||
|
||||
return {
|
||||
"index": file_index,
|
||||
"name": item.get("name") or archive_path.name,
|
||||
"size": new_size,
|
||||
"mime_type": mime_type,
|
||||
"kind": item.get("kind"),
|
||||
}
|
||||
|
||||
async def delete_run(self, user_id: str, run_id: str) -> bool:
|
||||
deleted = await self.store.delete_run(run_id, user_id)
|
||||
if deleted:
|
||||
|
||||
+113
-23
@@ -91,41 +91,48 @@ class LocalSkillStorage(SkillStorage):
|
||||
tmp_path = Path(tmp_file.name)
|
||||
tmp_path.replace(target)
|
||||
|
||||
async def ainstall_skill_from_archive(self, archive_path: str | Path) -> dict:
|
||||
def _open_skill_archive(self, archive_path: str | Path, *, require_skill_suffix: bool):
|
||||
import zipfile
|
||||
|
||||
from deerflow.skills.installer import (
|
||||
SkillAlreadyExistsError,
|
||||
_move_staged_skill_into_reserved_target,
|
||||
_scan_skill_archive_contents_or_raise,
|
||||
resolve_skill_dir_from_archive,
|
||||
safe_extract_skill_archive,
|
||||
)
|
||||
from deerflow.skills.validation import _validate_skill_frontmatter
|
||||
|
||||
logger.info("Installing skill from %s", archive_path)
|
||||
path = Path(archive_path)
|
||||
if not path.is_file():
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Skill file not found: {archive_path}")
|
||||
raise ValueError(f"Path is not a file: {archive_path}")
|
||||
if path.suffix != ".skill":
|
||||
if require_skill_suffix and path.suffix != ".skill":
|
||||
raise ValueError("File must have .skill extension")
|
||||
|
||||
custom_dir = self._host_root / "custom"
|
||||
custom_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
zf = zipfile.ZipFile(path, "r")
|
||||
except FileNotFoundError:
|
||||
raise FileNotFoundError(f"Skill file not found: {archive_path}") from None
|
||||
except (zipfile.BadZipFile, IsADirectoryError):
|
||||
raise ValueError("File is not a valid ZIP archive") from None
|
||||
return zf
|
||||
|
||||
def validate_skill_archive(self, archive_path: str | Path, *, require_skill_suffix: bool = False) -> dict:
|
||||
"""Inspect a skill archive WITHOUT installing it.
|
||||
|
||||
Returns a dict with ``skill_name``, ``description``, ``license``,
|
||||
``allowed_tools``, ``conflict`` (bool), ``warnings`` (list[str]).
|
||||
Raises ``ValueError`` for fatal validation problems (bad zip, bad
|
||||
frontmatter). The expensive LLM security scan is **not** invoked here.
|
||||
"""
|
||||
import re
|
||||
|
||||
import yaml
|
||||
|
||||
from deerflow.skills.installer import (
|
||||
resolve_skill_dir_from_archive,
|
||||
safe_extract_skill_archive,
|
||||
)
|
||||
from deerflow.skills.types import SKILL_MD_FILE
|
||||
from deerflow.skills.validation import _validate_skill_frontmatter
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
|
||||
try:
|
||||
zf = zipfile.ZipFile(path, "r")
|
||||
except FileNotFoundError:
|
||||
raise FileNotFoundError(f"Skill file not found: {archive_path}") from None
|
||||
except (zipfile.BadZipFile, IsADirectoryError):
|
||||
raise ValueError("File is not a valid ZIP archive") from None
|
||||
|
||||
with zf:
|
||||
with self._open_skill_archive(archive_path, require_skill_suffix=require_skill_suffix) as zf:
|
||||
safe_extract_skill_archive(zf, tmp_path)
|
||||
|
||||
skill_dir = resolve_skill_dir_from_archive(tmp_path)
|
||||
@@ -136,9 +143,91 @@ class LocalSkillStorage(SkillStorage):
|
||||
if not skill_name or "/" in skill_name or "\\" in skill_name or ".." in skill_name:
|
||||
raise ValueError(f"Invalid skill name: {skill_name}")
|
||||
|
||||
skill_md_text = (skill_dir / SKILL_MD_FILE).read_text(encoding="utf-8")
|
||||
frontmatter: dict = {}
|
||||
match = re.match(r"^---\n(.*?)\n---", skill_md_text, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
parsed = yaml.safe_load(match.group(1)) or {}
|
||||
if isinstance(parsed, dict):
|
||||
frontmatter = parsed
|
||||
except yaml.YAMLError:
|
||||
frontmatter = {}
|
||||
|
||||
allowed_tools = frontmatter.get("allowed-tools") or []
|
||||
if not isinstance(allowed_tools, list):
|
||||
allowed_tools = [str(allowed_tools)]
|
||||
else:
|
||||
allowed_tools = [str(item) for item in allowed_tools]
|
||||
|
||||
warnings: list[str] = []
|
||||
script_files = [
|
||||
path for path in skill_dir.rglob("*")
|
||||
if path.is_file() and path.relative_to(skill_dir).parts and path.relative_to(skill_dir).parts[0] == "scripts"
|
||||
]
|
||||
if script_files:
|
||||
warnings.append(f"Archive contains {len(script_files)} script file(s); they will be security-scanned at install time.")
|
||||
|
||||
conflict_custom = (self._host_root / SkillCategory.CUSTOM.value / skill_name / SKILL_MD_FILE).exists()
|
||||
conflict_public = self.public_skill_exists(skill_name)
|
||||
conflict = conflict_custom or conflict_public
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"skill_name": skill_name,
|
||||
"description": str(frontmatter.get("description", "")).strip(),
|
||||
"license": (str(frontmatter["license"]).strip() if frontmatter.get("license") else None),
|
||||
"allowed_tools": allowed_tools,
|
||||
"conflict": conflict,
|
||||
"conflict_kind": ("public" if conflict_public else ("custom" if conflict_custom else None)),
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
async def ainstall_skill_from_archive(
|
||||
self,
|
||||
archive_path: str | Path,
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
require_skill_suffix: bool = True,
|
||||
) -> dict:
|
||||
from deerflow.skills.installer import (
|
||||
SkillAlreadyExistsError,
|
||||
_move_staged_skill_into_reserved_target,
|
||||
_scan_skill_archive_contents_or_raise,
|
||||
resolve_skill_dir_from_archive,
|
||||
safe_extract_skill_archive,
|
||||
)
|
||||
from deerflow.skills.validation import _validate_skill_frontmatter
|
||||
|
||||
logger.info("Installing skill from %s (overwrite=%s)", archive_path, overwrite)
|
||||
|
||||
custom_dir = self._host_root / "custom"
|
||||
custom_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
|
||||
with self._open_skill_archive(archive_path, require_skill_suffix=require_skill_suffix) as zf:
|
||||
safe_extract_skill_archive(zf, tmp_path)
|
||||
|
||||
skill_dir = resolve_skill_dir_from_archive(tmp_path)
|
||||
|
||||
is_valid, message, skill_name = _validate_skill_frontmatter(skill_dir)
|
||||
if not is_valid:
|
||||
raise ValueError(f"Invalid skill: {message}")
|
||||
if not skill_name or "/" in skill_name or "\\" in skill_name or ".." in skill_name:
|
||||
raise ValueError(f"Invalid skill name: {skill_name}")
|
||||
|
||||
if self.public_skill_exists(skill_name):
|
||||
raise SkillAlreadyExistsError(f"Skill '{skill_name}' conflicts with a built-in public skill and cannot be overwritten")
|
||||
|
||||
target = custom_dir / skill_name
|
||||
replaced = False
|
||||
if target.exists():
|
||||
raise SkillAlreadyExistsError(f"Skill '{skill_name}' already exists")
|
||||
if not overwrite:
|
||||
raise SkillAlreadyExistsError(f"Skill '{skill_name}' already exists")
|
||||
shutil.rmtree(target)
|
||||
replaced = True
|
||||
|
||||
await _scan_skill_archive_contents_or_raise(skill_dir, skill_name)
|
||||
|
||||
@@ -152,6 +241,7 @@ class LocalSkillStorage(SkillStorage):
|
||||
"success": True,
|
||||
"skill_name": skill_name,
|
||||
"message": f"Skill '{skill_name}' installed successfully",
|
||||
"overwritten": replaced,
|
||||
}
|
||||
|
||||
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Tests for editing archived scheduled-task run files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.config import paths as paths_module
|
||||
from deerflow.persistence.scheduled_tasks import MemoryScheduledTaskStore
|
||||
from deerflow.runtime.scheduler.service import ScheduledTaskService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(paths_module, "_paths", None)
|
||||
yield tmp_path
|
||||
monkeypatch.setattr(paths_module, "_paths", None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store() -> MemoryScheduledTaskStore:
|
||||
return MemoryScheduledTaskStore()
|
||||
|
||||
|
||||
def _build_service(store: MemoryScheduledTaskStore) -> ScheduledTaskService:
|
||||
service = ScheduledTaskService(app=None, store=store, poll_interval_seconds=60.0)
|
||||
service._element_client = None # noqa: SLF001
|
||||
return service
|
||||
|
||||
|
||||
def _seed_task(store: MemoryScheduledTaskStore, *, task_id: str, user_id: str, published: bool = False) -> None:
|
||||
store._tasks[task_id] = { # noqa: SLF001
|
||||
"task_id": task_id,
|
||||
"user_id": user_id,
|
||||
"scheduler_thread_id": f"thread-{user_id}",
|
||||
"name": "demo",
|
||||
"prompt": "hi",
|
||||
"schedule_text": "0 9 * * *",
|
||||
"cron_expr": "0 9 * * *",
|
||||
"timezone": "UTC",
|
||||
"enabled": True,
|
||||
"next_run_at": datetime.now(UTC),
|
||||
"execution_context": {},
|
||||
"published": published,
|
||||
"published_at": datetime.now(UTC) if published else None,
|
||||
}
|
||||
|
||||
|
||||
async def _seed_run_with_file(
|
||||
store: MemoryScheduledTaskStore,
|
||||
isolated_paths: Path,
|
||||
*,
|
||||
run_id: str,
|
||||
task_id: str,
|
||||
user_id: str,
|
||||
file_name: str,
|
||||
file_text: str,
|
||||
mime_type: str = "text/markdown",
|
||||
) -> dict[str, Any]:
|
||||
archive_dir = isolated_paths / "scheduled-task-runs" / run_id / "files"
|
||||
archive_dir.mkdir(parents=True)
|
||||
file_path = archive_dir / file_name
|
||||
file_path.write_text(file_text, encoding="utf-8")
|
||||
|
||||
file_entry = {
|
||||
"name": file_name,
|
||||
"path": str(file_path),
|
||||
"mime_type": mime_type,
|
||||
"kind": "markdown" if file_name.endswith(".md") else "artifact",
|
||||
"size": file_path.stat().st_size,
|
||||
}
|
||||
result = {"title": "demo", "content": "ignored", "format": "markdown", "files": [file_entry]}
|
||||
|
||||
scheduled_for = datetime.now(UTC)
|
||||
await store.create_run_once(
|
||||
{
|
||||
"id": run_id,
|
||||
"task_id": task_id,
|
||||
"user_id": user_id,
|
||||
"scheduler_thread_id": f"thread-{user_id}",
|
||||
"scheduled_for": scheduled_for,
|
||||
"status": "succeeded",
|
||||
"started_at": scheduled_for,
|
||||
}
|
||||
)
|
||||
await store.update_run(run_id, {"result": result, "finished_at": scheduled_for})
|
||||
return file_entry
|
||||
|
||||
|
||||
# ---------- can_edit flag on get_run ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_run_marks_owner_as_can_edit(store, isolated_paths):
|
||||
_seed_task(store, task_id="t1", user_id="alice", published=True)
|
||||
await _seed_run_with_file(store, isolated_paths, run_id="r1", task_id="t1", user_id="alice", file_name="a.md", file_text="# hi")
|
||||
service = _build_service(store)
|
||||
|
||||
run = await service.get_run("alice", "r1")
|
||||
assert run is not None
|
||||
assert run["can_edit"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_run_marks_subscriber_as_read_only(store, isolated_paths):
|
||||
_seed_task(store, task_id="t1", user_id="alice", published=True)
|
||||
await _seed_run_with_file(store, isolated_paths, run_id="r1", task_id="t1", user_id="alice", file_name="a.md", file_text="# hi")
|
||||
service = _build_service(store)
|
||||
await service.subscribe_task("bob", "t1", notify_element=False)
|
||||
|
||||
run = await service.get_run("bob", "r1")
|
||||
assert run is not None
|
||||
assert run["can_edit"] is False
|
||||
|
||||
|
||||
# ---------- update_run_file ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_owner_can_overwrite_md_file(store, isolated_paths):
|
||||
_seed_task(store, task_id="t1", user_id="alice")
|
||||
file_entry = await _seed_run_with_file(
|
||||
store,
|
||||
isolated_paths,
|
||||
run_id="r1",
|
||||
task_id="t1",
|
||||
user_id="alice",
|
||||
file_name="report.md",
|
||||
file_text="# original",
|
||||
)
|
||||
service = _build_service(store)
|
||||
|
||||
new_text = "# updated\n\nA new paragraph."
|
||||
meta = await service.update_run_file("alice", "r1", 0, new_text)
|
||||
|
||||
assert meta["name"] == "report.md"
|
||||
assert meta["size"] == len(new_text.encode("utf-8"))
|
||||
|
||||
# The on-disk file is overwritten.
|
||||
assert Path(file_entry["path"]).read_text(encoding="utf-8") == new_text
|
||||
|
||||
# The size in the run row is refreshed.
|
||||
run = await service.get_run("alice", "r1")
|
||||
assert run is not None
|
||||
assert run["result"]["files"][0]["size"] == meta["size"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscriber_cannot_edit(store, isolated_paths):
|
||||
_seed_task(store, task_id="t1", user_id="alice", published=True)
|
||||
await _seed_run_with_file(store, isolated_paths, run_id="r1", task_id="t1", user_id="alice", file_name="a.md", file_text="# hi")
|
||||
service = _build_service(store)
|
||||
await service.subscribe_task("bob", "t1", notify_element=False)
|
||||
|
||||
with pytest.raises(PermissionError):
|
||||
await service.update_run_file("bob", "r1", 0, "# evil")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unrelated_user_gets_not_found(store, isolated_paths):
|
||||
_seed_task(store, task_id="t1", user_id="alice")
|
||||
await _seed_run_with_file(store, isolated_paths, run_id="r1", task_id="t1", user_id="alice", file_name="a.md", file_text="# hi")
|
||||
service = _build_service(store)
|
||||
|
||||
with pytest.raises(PermissionError):
|
||||
await service.update_run_file("eve", "r1", 0, "# evil")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_run_returns_filenotfound(store, isolated_paths):
|
||||
service = _build_service(store)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await service.update_run_file("alice", "missing-run", 0, "# x")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_file_index_rejected(store, isolated_paths):
|
||||
_seed_task(store, task_id="t1", user_id="alice")
|
||||
await _seed_run_with_file(store, isolated_paths, run_id="r1", task_id="t1", user_id="alice", file_name="a.md", file_text="# hi")
|
||||
service = _build_service(store)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await service.update_run_file("alice", "r1", 5, "# x")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_text_file_rejected(store, isolated_paths):
|
||||
_seed_task(store, task_id="t1", user_id="alice")
|
||||
await _seed_run_with_file(
|
||||
store,
|
||||
isolated_paths,
|
||||
run_id="r1",
|
||||
task_id="t1",
|
||||
user_id="alice",
|
||||
file_name="diagram.png",
|
||||
file_text="not really a png but stored as such",
|
||||
mime_type="image/png",
|
||||
)
|
||||
service = _build_service(store)
|
||||
|
||||
with pytest.raises(ValueError, match="text-previewable"):
|
||||
await service.update_run_file("alice", "r1", 0, "# x")
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Tests for skill upload validation + install via the storage layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.skills.installer import SkillAlreadyExistsError
|
||||
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
|
||||
|
||||
|
||||
def _make_skill_zip(
|
||||
*,
|
||||
skill_name: str = "demo-skill",
|
||||
description: str = "Use this skill when demoing things.",
|
||||
license_value: str | None = "MIT",
|
||||
allowed_tools: list[str] | None = None,
|
||||
extra_files: dict[str, str] | None = None,
|
||||
include_skill_md: bool = True,
|
||||
bad_yaml: bool = False,
|
||||
nest_under_root: bool = True,
|
||||
) -> bytes:
|
||||
"""Build an in-memory .skill ZIP archive for tests."""
|
||||
buf = io.BytesIO()
|
||||
root = f"{skill_name}/" if nest_under_root else ""
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
if include_skill_md:
|
||||
if bad_yaml:
|
||||
content = "---\nname: ???: bad\n---\nhi"
|
||||
else:
|
||||
lines = ["---", f"name: {skill_name}", f"description: {description}"]
|
||||
if license_value:
|
||||
lines.append(f"license: {license_value}")
|
||||
if allowed_tools:
|
||||
lines.append("allowed-tools:")
|
||||
for tool in allowed_tools:
|
||||
lines.append(f" - {tool}")
|
||||
lines.extend(["---", "# " + skill_name, "Hello world."])
|
||||
content = "\n".join(lines)
|
||||
zf.writestr(f"{root}SKILL.md", content)
|
||||
for rel, body in (extra_files or {}).items():
|
||||
zf.writestr(f"{root}{rel}", body)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage(tmp_path: Path) -> LocalSkillStorage:
|
||||
skills_root = tmp_path / "skills"
|
||||
(skills_root / "public").mkdir(parents=True)
|
||||
(skills_root / "custom").mkdir(parents=True)
|
||||
return LocalSkillStorage(host_path=str(skills_root))
|
||||
|
||||
|
||||
def _write_archive(tmp_path: Path, data: bytes, *, suffix: str = ".skill") -> Path:
|
||||
archive = tmp_path / f"upload{suffix}"
|
||||
archive.write_bytes(data)
|
||||
return archive
|
||||
|
||||
|
||||
# ---------- validate_skill_archive ----------
|
||||
|
||||
|
||||
def test_validate_returns_metadata_and_no_conflict(storage: LocalSkillStorage, tmp_path: Path):
|
||||
archive = _write_archive(
|
||||
tmp_path,
|
||||
_make_skill_zip(allowed_tools=["bash", "read_file"]),
|
||||
)
|
||||
|
||||
result = storage.validate_skill_archive(archive)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["skill_name"] == "demo-skill"
|
||||
assert result["description"] == "Use this skill when demoing things."
|
||||
assert result["license"] == "MIT"
|
||||
assert result["allowed_tools"] == ["bash", "read_file"]
|
||||
assert result["conflict"] is False
|
||||
assert result["conflict_kind"] is None
|
||||
assert result["warnings"] == []
|
||||
|
||||
|
||||
def test_validate_flags_script_files_as_warning(storage: LocalSkillStorage, tmp_path: Path):
|
||||
archive = _write_archive(
|
||||
tmp_path,
|
||||
_make_skill_zip(extra_files={"scripts/hi.py": "print('hi')\n"}),
|
||||
)
|
||||
|
||||
result = storage.validate_skill_archive(archive)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["warnings"] and "script" in result["warnings"][0].lower()
|
||||
|
||||
|
||||
def test_validate_detects_custom_conflict(storage: LocalSkillStorage, tmp_path: Path):
|
||||
# Pre-install one with the same name into custom/
|
||||
target = storage.get_skills_root_path() / "custom" / "demo-skill"
|
||||
target.mkdir(parents=True)
|
||||
(target / "SKILL.md").write_text(
|
||||
"---\nname: demo-skill\ndescription: existing\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
archive = _write_archive(tmp_path, _make_skill_zip())
|
||||
result = storage.validate_skill_archive(archive)
|
||||
|
||||
assert result["conflict"] is True
|
||||
assert result["conflict_kind"] == "custom"
|
||||
|
||||
|
||||
def test_validate_detects_public_conflict(storage: LocalSkillStorage, tmp_path: Path):
|
||||
target = storage.get_skills_root_path() / "public" / "demo-skill"
|
||||
target.mkdir(parents=True)
|
||||
(target / "SKILL.md").write_text(
|
||||
"---\nname: demo-skill\ndescription: existing\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
archive = _write_archive(tmp_path, _make_skill_zip())
|
||||
result = storage.validate_skill_archive(archive)
|
||||
|
||||
assert result["conflict"] is True
|
||||
assert result["conflict_kind"] == "public"
|
||||
|
||||
|
||||
def test_validate_rejects_bad_zip(storage: LocalSkillStorage, tmp_path: Path):
|
||||
archive = _write_archive(tmp_path, b"not a zip at all")
|
||||
with pytest.raises(ValueError, match="not a valid ZIP"):
|
||||
storage.validate_skill_archive(archive)
|
||||
|
||||
|
||||
def test_validate_rejects_missing_skill_md(storage: LocalSkillStorage, tmp_path: Path):
|
||||
archive = _write_archive(tmp_path, _make_skill_zip(include_skill_md=False))
|
||||
with pytest.raises(ValueError, match="SKILL.md"):
|
||||
storage.validate_skill_archive(archive)
|
||||
|
||||
|
||||
def test_validate_rejects_bad_frontmatter(storage: LocalSkillStorage, tmp_path: Path):
|
||||
archive = _write_archive(tmp_path, _make_skill_zip(bad_yaml=True))
|
||||
with pytest.raises(ValueError):
|
||||
storage.validate_skill_archive(archive)
|
||||
|
||||
|
||||
# ---------- ainstall_skill_from_archive (overwrite) ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_writes_to_custom_dir(
|
||||
storage: LocalSkillStorage, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
# Bypass the LLM security scan so the test stays hermetic.
|
||||
async def _noop_scan(*args, **kwargs): # noqa: ANN001, ARG001
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deerflow.skills.installer._scan_skill_archive_contents_or_raise",
|
||||
_noop_scan,
|
||||
)
|
||||
|
||||
archive = _write_archive(tmp_path, _make_skill_zip())
|
||||
result = await storage.ainstall_skill_from_archive(archive, require_skill_suffix=False)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["skill_name"] == "demo-skill"
|
||||
target = storage.get_skills_root_path() / "custom" / "demo-skill" / "SKILL.md"
|
||||
assert target.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_conflict_without_overwrite(
|
||||
storage: LocalSkillStorage, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
async def _noop_scan(*args, **kwargs): # noqa: ANN001, ARG001
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deerflow.skills.installer._scan_skill_archive_contents_or_raise",
|
||||
_noop_scan,
|
||||
)
|
||||
|
||||
archive = _write_archive(tmp_path, _make_skill_zip())
|
||||
await storage.ainstall_skill_from_archive(archive, require_skill_suffix=False)
|
||||
|
||||
with pytest.raises(SkillAlreadyExistsError):
|
||||
await storage.ainstall_skill_from_archive(archive, require_skill_suffix=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_overwrite_replaces_existing(
|
||||
storage: LocalSkillStorage, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
async def _noop_scan(*args, **kwargs): # noqa: ANN001, ARG001
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deerflow.skills.installer._scan_skill_archive_contents_or_raise",
|
||||
_noop_scan,
|
||||
)
|
||||
|
||||
(tmp_path / "v1").mkdir(exist_ok=True)
|
||||
(tmp_path / "v2").mkdir(exist_ok=True)
|
||||
archive_v1 = _write_archive(tmp_path / "v1", _make_skill_zip(description="version one"))
|
||||
archive_v2 = _write_archive(tmp_path / "v2", _make_skill_zip(description="version two"))
|
||||
|
||||
await storage.ainstall_skill_from_archive(archive_v1, require_skill_suffix=False)
|
||||
await storage.ainstall_skill_from_archive(
|
||||
archive_v2, overwrite=True, require_skill_suffix=False
|
||||
)
|
||||
|
||||
skill_md = (
|
||||
storage.get_skills_root_path() / "custom" / "demo-skill" / "SKILL.md"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "version two" in skill_md
|
||||
assert "version one" not in skill_md
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_refuses_to_overwrite_public_skill(
|
||||
storage: LocalSkillStorage, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
async def _noop_scan(*args, **kwargs): # noqa: ANN001, ARG001
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deerflow.skills.installer._scan_skill_archive_contents_or_raise",
|
||||
_noop_scan,
|
||||
)
|
||||
|
||||
public_dir = storage.get_skills_root_path() / "public" / "demo-skill"
|
||||
public_dir.mkdir(parents=True)
|
||||
(public_dir / "SKILL.md").write_text(
|
||||
"---\nname: demo-skill\ndescription: built-in\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
archive = _write_archive(tmp_path, _make_skill_zip())
|
||||
with pytest.raises(SkillAlreadyExistsError, match="public"):
|
||||
await storage.ainstall_skill_from_archive(
|
||||
archive, overwrite=True, require_skill_suffix=False
|
||||
)
|
||||
Reference in new issue
Block a user