Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server
This commit is contained in:
commit
af7e90c87a
50 files changed
+2184
-321
No files matched your search
@@ -2,9 +2,13 @@
|
|||||||
.vscode/
|
.vscode/
|
||||||
frontend-web/.vscode/
|
frontend-web/.vscode/
|
||||||
|
|
||||||
|
# Local runtime logs and pid files
|
||||||
|
.runtime/
|
||||||
|
|
||||||
# Frontend local/runtime artifacts
|
# Frontend local/runtime artifacts
|
||||||
frontend-web/.env
|
frontend-web/.env
|
||||||
frontend-web/node_modules/
|
frontend-web/node_modules/
|
||||||
|
frontend-web/.pnpm-store/
|
||||||
frontend-web/tsconfig.tsbuildinfo
|
frontend-web/tsconfig.tsbuildinfo
|
||||||
frontend-web/vite-dev.log
|
frontend-web/vite-dev.log
|
||||||
frontend-web/vite-dev.err.log
|
frontend-web/vite-dev.err.log
|
||||||
@@ -51,6 +55,8 @@ offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
|
|||||||
offline-backend-20260512/extensions_config.json
|
offline-backend-20260512/extensions_config.json
|
||||||
offline-backend-20260512/skills/
|
offline-backend-20260512/skills/
|
||||||
offline-backend-20260512/wheelhouse/
|
offline-backend-20260512/wheelhouse/
|
||||||
|
offline-backend-20260512/backend/.deer-flow/data/*.db
|
||||||
|
offline-backend-20260512/backend/.deer-flow/data/*.db-*
|
||||||
offline-backend-20260512/start_backend.sh
|
offline-backend-20260512/start_backend.sh
|
||||||
offline-backend-20260512/stop_backend.sh
|
offline-backend-20260512/stop_backend.sh
|
||||||
offline-backend-20260512/check_backend.sh
|
offline-backend-20260512/check_backend.sh
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>DeerFlow Vite</title>
|
<title>CM 助手</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"docx": "^9.5.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"gsap": "^3.13.0",
|
"gsap": "^3.13.0",
|
||||||
|
|||||||
@@ -674,12 +674,22 @@ export const PromptInput = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!globalDrop) return;
|
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) => {
|
const onDragOver = (e: DragEvent) => {
|
||||||
|
if (isInsideModal(e.target)) return;
|
||||||
if (e.dataTransfer?.types?.includes("Files")) {
|
if (e.dataTransfer?.types?.includes("Files")) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onDrop = (e: DragEvent) => {
|
const onDrop = (e: DragEvent) => {
|
||||||
|
if (isInsideModal(e.target)) return;
|
||||||
if (e.dataTransfer?.types?.includes("Files")) {
|
if (e.dataTransfer?.types?.includes("Files")) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground container mb-8 flex flex-col items-center justify-center text-xs">
|
<div className="text-muted-foreground container mb-8 flex flex-col items-center justify-center text-xs">
|
||||||
<p>Licensed under MIT License</p>
|
<p>Licensed under MIT License</p>
|
||||||
<p>© {year} CM业务中心-问答管理</p>
|
<p>© {year} CM 助手</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { StarFilledIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
|
|
||||||
import Link from "next/link";
|
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 type { Locale } from "@/core/i18n/locale";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { env } from "@/env";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type HeaderProps = {
|
export type HeaderProps = {
|
||||||
@@ -30,11 +25,11 @@ export function Header({ className, homeURL }: HeaderProps) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<a
|
<a
|
||||||
href={homeURL ?? "https://github.com/bytedance/deer-flow"}
|
href={homeURL ?? "/"}
|
||||||
target={isExternalHome ? "_blank" : "_self"}
|
target={isExternalHome ? "_blank" : "_self"}
|
||||||
rel={isExternalHome ? "noopener noreferrer" : undefined}
|
rel={isExternalHome ? "noopener noreferrer" : undefined}
|
||||||
>
|
>
|
||||||
<h1 className="font-serif text-xl">CM业务中心-问答管理</h1>
|
<h1 className="font-serif text-xl">CM 助手</h1>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<nav className="mr-8 ml-auto flex items-center gap-8 text-sm font-medium">
|
<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}
|
{t.home.blog}
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</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" />
|
<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>
|
</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",
|
"Learn Anything",
|
||||||
]}
|
]}
|
||||||
/>{" "}
|
/>{" "}
|
||||||
<div>CM业务中心-问答管理</div>
|
<div>CM 助手</div>
|
||||||
</h1>
|
</h1>
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && (
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && (
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ export default function ProgressiveSkillsAnimation() {
|
|||||||
<div className="border-b border-zinc-800 p-4">
|
<div className="border-b border-zinc-800 p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-3 w-3 rounded-full bg-green-500" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -691,7 +691,7 @@ export default function ProgressiveSkillsAnimation() {
|
|||||||
{/* Chat Input (decorative) */}
|
{/* Chat Input (decorative) */}
|
||||||
<div className="border-t border-zinc-800 p-4">
|
<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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function CaseStudySection({ className }: { className?: string }) {
|
|||||||
<Section
|
<Section
|
||||||
className={className}
|
className={className}
|
||||||
title="Case Studies"
|
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">
|
<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) => (
|
{caseStudies.map((caseStudy) => (
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { GitHubLogoIcon } from "@radix-ui/react-icons";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import { AuroraText } from "@/components/ui/aurora-text";
|
import { AuroraText } from "@/components/ui/aurora-text";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
import { Section } from "../section";
|
import { Section } from "../section";
|
||||||
|
|
||||||
@@ -16,20 +12,9 @@ export function CommunitySection() {
|
|||||||
Join the Community
|
Join the Community
|
||||||
</AuroraText>
|
</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">
|
<div />
|
||||||
<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>
|
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ export function SandboxSection({ className }: { className?: string }) {
|
|||||||
title="Agent Runtime Environment"
|
title="Agent Runtime Environment"
|
||||||
subtitle={
|
subtitle={
|
||||||
<p>
|
<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
|
manage files, and run long tasks — all in a secure Docker-based
|
||||||
sandbox
|
sandbox
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function SkillsSection({ className }: { className?: string }) {
|
|||||||
Agent Skills are loaded progressively — only what's needed, when
|
Agent Skills are loaded progressively — only what's needed, when
|
||||||
it's needed.
|
it's needed.
|
||||||
<br />
|
<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.
|
library.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ export function WhatsNewSection({ className }: { className?: string }) {
|
|||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
className={cn("", className)}
|
className={cn("", className)}
|
||||||
title="CM业务中心-问答管理"
|
title="CM 助手"
|
||||||
subtitle="CM业务中心-问答管理"
|
subtitle="CM 助手"
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-center">
|
<div className="flex w-full items-center justify-center">
|
||||||
<MagicBento data={features} />
|
<MagicBento data={features} />
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||||
|
import { SendIcon } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
ConversationContent,
|
ConversationContent,
|
||||||
} from "@/components/ai-elements/conversation";
|
} from "@/components/ai-elements/conversation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
extractContentFromMessage,
|
extractContentFromMessage,
|
||||||
@@ -40,12 +44,14 @@ export function MessageList({
|
|||||||
thread,
|
thread,
|
||||||
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||||
tokenUsageEnabled = false,
|
tokenUsageEnabled = false,
|
||||||
|
onClarificationSubmit,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
thread: BaseStream<AgentThreadState>;
|
thread: BaseStream<AgentThreadState>;
|
||||||
paddingBottom?: number;
|
paddingBottom?: number;
|
||||||
tokenUsageEnabled?: boolean;
|
tokenUsageEnabled?: boolean;
|
||||||
|
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||||
@@ -77,9 +83,11 @@ export function MessageList({
|
|||||||
if (message && hasContent(message)) {
|
if (message && hasContent(message)) {
|
||||||
return (
|
return (
|
||||||
<div key={group.id} className="w-full">
|
<div key={group.id} className="w-full">
|
||||||
<MarkdownContent
|
<ClarificationCard
|
||||||
content={extractContentFromMessage(message)}
|
fallbackContent={extractContentFromMessage(message)}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
|
message={message}
|
||||||
|
onSubmit={onClarificationSubmit}
|
||||||
rehypePlugins={rehypePlugins}
|
rehypePlugins={rehypePlugins}
|
||||||
/>
|
/>
|
||||||
<MessageTokenUsageList
|
<MessageTokenUsageList
|
||||||
@@ -240,3 +248,148 @@ export function MessageList({
|
|||||||
</Conversation>
|
</Conversation>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClarificationOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClarificationPayload = {
|
||||||
|
question?: string;
|
||||||
|
context?: string | null;
|
||||||
|
clarification_type?: string;
|
||||||
|
options?: ClarificationOption[];
|
||||||
|
allow_custom?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getClarificationPayload(message: unknown): ClarificationPayload | null {
|
||||||
|
if (typeof message !== "object" || message === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const additionalKwargs = Reflect.get(message, "additional_kwargs");
|
||||||
|
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const clarification = Reflect.get(additionalKwargs, "clarification");
|
||||||
|
if (typeof clarification !== "object" || clarification === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return clarification as ClarificationPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClarificationCard({
|
||||||
|
fallbackContent,
|
||||||
|
isLoading,
|
||||||
|
message,
|
||||||
|
onSubmit,
|
||||||
|
rehypePlugins,
|
||||||
|
}: {
|
||||||
|
fallbackContent: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
message: unknown;
|
||||||
|
onSubmit?: (answer: string) => void | Promise<void>;
|
||||||
|
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
|
}) {
|
||||||
|
const payload = getClarificationPayload(message);
|
||||||
|
const [customAnswer, setCustomAnswer] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const options = useMemo(
|
||||||
|
() =>
|
||||||
|
(payload?.options ?? []).filter(
|
||||||
|
(option) => typeof option.label === "string" && option.label.trim(),
|
||||||
|
),
|
||||||
|
[payload?.options],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return (
|
||||||
|
<MarkdownContent
|
||||||
|
content={fallbackContent}
|
||||||
|
isLoading={isLoading}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabled = isLoading || submitting || !onSubmit;
|
||||||
|
|
||||||
|
async function submitAnswer(answer: string) {
|
||||||
|
const trimmed = answer.trim();
|
||||||
|
if (!trimmed || disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSubmit?.(trimmed);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-muted-foreground text-xs font-medium">
|
||||||
|
需要你的确认
|
||||||
|
</div>
|
||||||
|
{payload.context ? (
|
||||||
|
<p className="text-muted-foreground text-sm leading-6">
|
||||||
|
{payload.context}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="text-sm leading-6 font-medium">
|
||||||
|
{payload.question || fallbackContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{options.length > 0 ? (
|
||||||
|
<div className="mt-4 grid gap-2">
|
||||||
|
{options.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.id || option.label}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => void submitAnswer(`我选择:${option.label}`)}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 flex-col items-start gap-1">
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{option.description ? (
|
||||||
|
<span className="text-muted-foreground text-xs font-normal leading-5">
|
||||||
|
{option.description}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{payload.allow_custom !== false ? (
|
||||||
|
<div className="mt-4 flex flex-col gap-2">
|
||||||
|
<Textarea
|
||||||
|
value={customAnswer}
|
||||||
|
onChange={(event) => setCustomAnswer(event.currentTarget.value)}
|
||||||
|
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
|
||||||
|
disabled={disabled}
|
||||||
|
className="min-h-20 resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled || !customAnswer.trim()}
|
||||||
|
onClick={() => void submitAnswer(customAnswer)}
|
||||||
|
>
|
||||||
|
<SendIcon className="h-4 w-4" />
|
||||||
|
发送回答
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -117,14 +117,7 @@ export function RecentChatList() {
|
|||||||
|
|
||||||
const handleShare = useCallback(
|
const handleShare = useCallback(
|
||||||
async (thread: AgentThread) => {
|
async (thread: AgentThread) => {
|
||||||
// Always use Vercel URL for sharing so others can access
|
const shareUrl = `${window.location.origin}${pathOfThread(thread)}`;
|
||||||
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)}`;
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
toast.success(t.clipboard.linkCopied);
|
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).
|
* (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**
|
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.
|
||||||
|
|
||||||
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**.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Core Features
|
## 🚀 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.
|
* **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.
|
* **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.
|
* **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.
|
* **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**
|
CM Assistant is an internal super-agent platform that orchestrates sub-agents,
|
||||||
|
memory, and sandboxes — powered by extensible skills — to research, analyze,
|
||||||
**DeerFlow** (**D**eep **E**xploration and **E**fficient **R**esearch **Flow**) is a community-driven SuperAgent harness that researches, codes, and creates.
|
and create on your behalf.
|
||||||
With the help of sandboxes, memories, tools and skills, it handles
|
|
||||||
different levels of tasks that could take minutes to hours.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌟 GitHub Repository
|
## Core Features
|
||||||
|
|
||||||
Explore DeerFlow on GitHub: [github.com/bytedance/deer-flow](https://github.com/bytedance/deer-flow)
|
* **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.
|
||||||
## 🌐 Official Website
|
* **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.
|
||||||
Visit the official website of DeerFlow: [deerflow.tech](https://deerflow.tech/)
|
* **Long-Term Memory**: Continuously records your profile, top-of-mind notes, and conversation history.
|
||||||
|
|
||||||
## 📧 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.
|
|
||||||
@@ -389,7 +389,7 @@ export function MemorySettingsPage() {
|
|||||||
try {
|
try {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
const exportedMemory = await exportMemory();
|
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)], {
|
const blob = new Blob([JSON.stringify(exportedMemory, null, 2)], {
|
||||||
type: "application/json",
|
type: "application/json",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { SparklesIcon } from "lucide-react";
|
import { AlertCircle, CheckCircle2, FileArchive, Loader2, SparklesIcon, Upload } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
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 { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
EmptyContent,
|
EmptyContent,
|
||||||
@@ -23,9 +32,16 @@ import {
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
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 type { Skill } from "@/core/skills/type";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { SettingsSection } from "./settings-section";
|
import { SettingsSection } from "./settings-section";
|
||||||
|
|
||||||
@@ -58,6 +74,7 @@ function SkillSettingsList({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [filter, setFilter] = useState<string>("public");
|
const [filter, setFilter] = useState<string>("public");
|
||||||
|
const [installOpen, setInstallOpen] = useState(false);
|
||||||
const { mutate: enableSkill } = useEnableSkill();
|
const { mutate: enableSkill } = useEnableSkill();
|
||||||
const filteredSkills = useMemo(
|
const filteredSkills = useMemo(
|
||||||
() => skills.filter((skill) => skill.category === filter),
|
() => skills.filter((skill) => skill.category === filter),
|
||||||
@@ -78,7 +95,16 @@ function SkillSettingsList({
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</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}>
|
<Button size="sm" onClick={handleCreateSkill}>
|
||||||
<SparklesIcon className="size-4" />
|
<SparklesIcon className="size-4" />
|
||||||
{t.settings.skills.createSkill}
|
{t.settings.skills.createSkill}
|
||||||
@@ -110,6 +136,10 @@ function SkillSettingsList({
|
|||||||
</ItemActions>
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
))}
|
))}
|
||||||
|
<InstallSkillDialog
|
||||||
|
open={installOpen}
|
||||||
|
onClose={() => setInstallOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -133,3 +163,265 @@ function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
|||||||
</Empty>
|
</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 { useI18n } from "@/core/i18n/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { GithubIcon } from "./github-icon";
|
|
||||||
import { Tooltip } from "./tooltip";
|
|
||||||
|
|
||||||
export function WorkspaceContainer({
|
export function WorkspaceContainer({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
@@ -90,18 +87,6 @@ export function WorkspaceHeader({
|
|||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export async function checkAgentName(
|
|||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
throw new AgentNameCheckError(
|
throw new AgentNameCheckError(
|
||||||
"Could not reach the DeerFlow backend.",
|
"Could not reach the CM Assistant backend.",
|
||||||
"backend_unreachable",
|
"backend_unreachable",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ export async function checkAgentName(
|
|||||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||||
if (BACKEND_UNAVAILABLE_STATUSES.has(res.status)) {
|
if (BACKEND_UNAVAILABLE_STATUSES.has(res.status)) {
|
||||||
throw new AgentNameCheckError(
|
throw new AgentNameCheckError(
|
||||||
"Could not reach the DeerFlow backend.",
|
"Could not reach the CM Assistant backend.",
|
||||||
"backend_unreachable",
|
"backend_unreachable",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,11 +61,11 @@ export const enUS: Translations = {
|
|||||||
welcome: {
|
welcome: {
|
||||||
greeting: "Hello, again!",
|
greeting: "Hello, again!",
|
||||||
description:
|
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",
|
createYourOwnSkill: "Create Your Own Skill",
|
||||||
createYourOwnSkillDescription:
|
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
|
// Clipboard
|
||||||
@@ -208,13 +208,13 @@ export const enUS: Translations = {
|
|||||||
save: "Save agent",
|
save: "Save agent",
|
||||||
saving: "Saving agent...",
|
saving: "Saving agent...",
|
||||||
saveRequested:
|
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:
|
saveHint:
|
||||||
"You can save this agent at any time from the top-right menu, even if this is only a first draft.",
|
"You can save this agent at any time from the top-right menu, even if this is only a first draft.",
|
||||||
saveCommandMessage:
|
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.",
|
"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:
|
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",
|
more: "More actions",
|
||||||
agentCreated: "Agent created!",
|
agentCreated: "Agent created!",
|
||||||
startChatting: "Start chatting",
|
startChatting: "Start chatting",
|
||||||
@@ -229,13 +229,13 @@ export const enUS: Translations = {
|
|||||||
|
|
||||||
// Workspace
|
// Workspace
|
||||||
workspace: {
|
workspace: {
|
||||||
officialWebsite: "DeerFlow's official website",
|
officialWebsite: "CM Assistant's official website",
|
||||||
githubTooltip: "DeerFlow on Github",
|
githubTooltip: "CM Assistant on Github",
|
||||||
settingsAndMore: "Settings and more",
|
settingsAndMore: "Settings and more",
|
||||||
visitGithub: "DeerFlow on GitHub",
|
visitGithub: "CM Assistant on GitHub",
|
||||||
reportIssue: "Report a issue",
|
reportIssue: "Report a issue",
|
||||||
contactUs: "Contact us",
|
contactUs: "Contact us",
|
||||||
about: "About DeerFlow",
|
about: "About CM Assistant",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Conversation
|
// Conversation
|
||||||
@@ -251,7 +251,7 @@ export const enUS: Translations = {
|
|||||||
|
|
||||||
// Page titles (document title)
|
// Page titles (document title)
|
||||||
pages: {
|
pages: {
|
||||||
appName: "DeerFlow",
|
appName: "CM Assistant",
|
||||||
brandSubtitle: "Smart CM Platform",
|
brandSubtitle: "Smart CM Platform",
|
||||||
chats: "Chats",
|
chats: "Chats",
|
||||||
newChat: "New chat",
|
newChat: "New chat",
|
||||||
@@ -278,7 +278,7 @@ export const enUS: Translations = {
|
|||||||
writeFile: "Write file",
|
writeFile: "Write file",
|
||||||
clickToViewContent: "Click to view file content",
|
clickToViewContent: "Click to view file content",
|
||||||
writeTodos: "Update to-do list",
|
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
|
// Subtasks
|
||||||
@@ -315,7 +315,7 @@ export const enUS: Translations = {
|
|||||||
actions: "Actions",
|
actions: "Actions",
|
||||||
keyboardShortcuts: "Keyboard Shortcuts",
|
keyboardShortcuts: "Keyboard Shortcuts",
|
||||||
keyboardShortcutsDescription:
|
keyboardShortcutsDescription:
|
||||||
"Navigate DeerFlow faster with keyboard shortcuts.",
|
"Navigate CM Assistant faster with keyboard shortcuts.",
|
||||||
openCommandPalette: "Open Command Palette",
|
openCommandPalette: "Open Command Palette",
|
||||||
toggleSidebar: "Toggle Sidebar",
|
toggleSidebar: "Toggle Sidebar",
|
||||||
},
|
},
|
||||||
@@ -323,7 +323,7 @@ export const enUS: Translations = {
|
|||||||
// Settings
|
// Settings
|
||||||
settings: {
|
settings: {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
description: "Adjust how DeerFlow looks and behaves for you.",
|
description: "Adjust how CM Assistant looks and behaves for you.",
|
||||||
sections: {
|
sections: {
|
||||||
appearance: "Appearance",
|
appearance: "Appearance",
|
||||||
memory: "Memory",
|
memory: "Memory",
|
||||||
@@ -335,7 +335,7 @@ export const enUS: Translations = {
|
|||||||
memory: {
|
memory: {
|
||||||
title: "Memory",
|
title: "Memory",
|
||||||
description:
|
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.",
|
empty: "No memory data to display.",
|
||||||
rawJson: "Raw JSON",
|
rawJson: "Raw JSON",
|
||||||
exportButton: "Export memory",
|
exportButton: "Export memory",
|
||||||
@@ -433,20 +433,43 @@ export const enUS: Translations = {
|
|||||||
description:
|
description:
|
||||||
"Manage the configuration and enabled status of the agent skills.",
|
"Manage the configuration and enabled status of the agent skills.",
|
||||||
createSkill: "Create skill",
|
createSkill: "Create skill",
|
||||||
|
installSkill: "Install skill",
|
||||||
emptyTitle: "No agent skill yet",
|
emptyTitle: "No agent skill yet",
|
||||||
emptyDescription:
|
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",
|
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: {
|
notification: {
|
||||||
title: "Notification",
|
title: "Notification",
|
||||||
description:
|
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",
|
requestPermission: "Request notification permission",
|
||||||
deniedHint:
|
deniedHint:
|
||||||
"Notification permission was denied. You can enable it in your browser's site settings to receive completion alerts.",
|
"Notification permission was denied. You can enable it in your browser's site settings to receive completion alerts.",
|
||||||
testButton: "Send test notification",
|
testButton: "Send test notification",
|
||||||
testTitle: "DeerFlow",
|
testTitle: "CM Assistant",
|
||||||
testBody: "This is a test notification.",
|
testBody: "This is a test notification.",
|
||||||
notSupported: "Your browser does not support notifications.",
|
notSupported: "Your browser does not support notifications.",
|
||||||
disableNotification: "Disable notification",
|
disableNotification: "Disable notification",
|
||||||
|
|||||||
@@ -354,9 +354,30 @@ export interface Translations {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
createSkill: string;
|
createSkill: string;
|
||||||
|
installSkill: string;
|
||||||
emptyTitle: string;
|
emptyTitle: string;
|
||||||
emptyDescription: string;
|
emptyDescription: string;
|
||||||
emptyButton: 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: {
|
notification: {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -61,11 +61,11 @@ export const zhCN: Translations = {
|
|||||||
welcome: {
|
welcome: {
|
||||||
greeting: "你好,欢迎回来!",
|
greeting: "你好,欢迎回来!",
|
||||||
description:
|
description:
|
||||||
"欢迎使用 CM业务中心-问答管理",
|
"欢迎使用 CM 助手",
|
||||||
|
|
||||||
createYourOwnSkill: "创建你自己的 Agent SKill",
|
createYourOwnSkill: "创建你自己的 Agent SKill",
|
||||||
createYourOwnSkillDescription:
|
createYourOwnSkillDescription:
|
||||||
"创建你的 Agent Skill 来释放 CM业务中心-问答管理的潜力。通过自定义技能,CM业务中心-问答管理\n可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n网页等作品,几乎可以做任何事情。",
|
"创建你的 Agent Skill 来释放 CM 助手的潜力。通过自定义技能,CM 助手\n可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n网页等作品,几乎可以做任何事情。",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Clipboard
|
// Clipboard
|
||||||
@@ -196,13 +196,13 @@ export const zhCN: Translations = {
|
|||||||
save: "保存智能体",
|
save: "保存智能体",
|
||||||
saving: "正在保存智能体...",
|
saving: "正在保存智能体...",
|
||||||
saveRequested:
|
saveRequested:
|
||||||
"已提交保存请求,CM业务中心-问答管理 正在根据当前对话生成并保存初版智能体。",
|
"已提交保存请求,CM 助手 正在根据当前对话生成并保存初版智能体。",
|
||||||
saveHint:
|
saveHint:
|
||||||
"你可以在右上角的菜单里随时保存这个智能体,就算目前还只是初稿也可以。",
|
"你可以在右上角的菜单里随时保存这个智能体,就算目前还只是初稿也可以。",
|
||||||
saveCommandMessage:
|
saveCommandMessage:
|
||||||
"请现在根据我们目前已经讨论的全部内容保存这个自定义智能体。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
|
"请现在根据我们目前已经讨论的全部内容保存这个自定义智能体。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
|
||||||
agentCreatedPendingRefresh:
|
agentCreatedPendingRefresh:
|
||||||
"智能体已创建,但 CM业务中心-问答管理 暂时还无法读取到它。请稍后刷新当前页面。",
|
"智能体已创建,但 CM 助手 暂时还无法读取到它。请稍后刷新当前页面。",
|
||||||
more: "更多操作",
|
more: "更多操作",
|
||||||
agentCreated: "智能体已创建!",
|
agentCreated: "智能体已创建!",
|
||||||
startChatting: "开始对话",
|
startChatting: "开始对话",
|
||||||
@@ -217,13 +217,13 @@ export const zhCN: Translations = {
|
|||||||
|
|
||||||
// Workspace
|
// Workspace
|
||||||
workspace: {
|
workspace: {
|
||||||
officialWebsite: "访问 CM业务中心-问答管理 官方网站",
|
officialWebsite: "访问 CM 助手 官方网站",
|
||||||
githubTooltip: "访问 CM业务中心-问答管理 的 Github 仓库",
|
githubTooltip: "访问 CM 助手 的 Github 仓库",
|
||||||
settingsAndMore: "设置和更多",
|
settingsAndMore: "设置和更多",
|
||||||
visitGithub: "在 Github 上查看 CM业务中心-问答管理",
|
visitGithub: "在 Github 上查看 CM 助手",
|
||||||
reportIssue: "报告问题",
|
reportIssue: "报告问题",
|
||||||
contactUs: "联系我们",
|
contactUs: "联系我们",
|
||||||
about: "关于 CM业务中心-问答管理",
|
about: "关于 CM 助手",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Conversation
|
// Conversation
|
||||||
@@ -239,7 +239,7 @@ export const zhCN: Translations = {
|
|||||||
|
|
||||||
// Page titles (document title)
|
// Page titles (document title)
|
||||||
pages: {
|
pages: {
|
||||||
appName: "CM业务中心-问答管理",
|
appName: "CM 助手",
|
||||||
brandSubtitle: "Smart CM Platform",
|
brandSubtitle: "Smart CM Platform",
|
||||||
chats: "对话",
|
chats: "对话",
|
||||||
newChat: "新对话",
|
newChat: "新对话",
|
||||||
@@ -265,7 +265,7 @@ export const zhCN: Translations = {
|
|||||||
writeFile: "写入文件",
|
writeFile: "写入文件",
|
||||||
clickToViewContent: "点击查看文件内容",
|
clickToViewContent: "点击查看文件内容",
|
||||||
writeTodos: "更新 To-do 列表",
|
writeTodos: "更新 To-do 列表",
|
||||||
skillInstallTooltip: "安装技能并使其可在 CM业务中心-问答管理 中使用",
|
skillInstallTooltip: "安装技能并使其可在 CM 助手 中使用",
|
||||||
},
|
},
|
||||||
|
|
||||||
uploads: {
|
uploads: {
|
||||||
@@ -300,7 +300,7 @@ export const zhCN: Translations = {
|
|||||||
noResults: "未找到结果。",
|
noResults: "未找到结果。",
|
||||||
actions: "操作",
|
actions: "操作",
|
||||||
keyboardShortcuts: "键盘快捷键",
|
keyboardShortcuts: "键盘快捷键",
|
||||||
keyboardShortcutsDescription: "使用键盘快捷键更快地操作 CM业务中心-问答管理。",
|
keyboardShortcutsDescription: "使用键盘快捷键更快地操作 CM 助手。",
|
||||||
openCommandPalette: "打开命令面板",
|
openCommandPalette: "打开命令面板",
|
||||||
toggleSidebar: "切换侧边栏",
|
toggleSidebar: "切换侧边栏",
|
||||||
},
|
},
|
||||||
@@ -308,7 +308,7 @@ export const zhCN: Translations = {
|
|||||||
// Settings
|
// Settings
|
||||||
settings: {
|
settings: {
|
||||||
title: "设置",
|
title: "设置",
|
||||||
description: "根据你的偏好调整 CM业务中心-问答管理 的界面和行为。",
|
description: "根据你的偏好调整 CM 助手 的界面和行为。",
|
||||||
sections: {
|
sections: {
|
||||||
appearance: "外观",
|
appearance: "外观",
|
||||||
memory: "记忆",
|
memory: "记忆",
|
||||||
@@ -320,7 +320,7 @@ export const zhCN: Translations = {
|
|||||||
memory: {
|
memory: {
|
||||||
title: "记忆",
|
title: "记忆",
|
||||||
description:
|
description:
|
||||||
"CM业务中心-问答管理 会在后台不断从你的对话中自动学习。这些记忆能帮助 CM业务中心-问答管理 更好地理解你,并提供更个性化的体验。",
|
"CM 助手 会在后台不断从你的对话中自动学习。这些记忆能帮助 CM 助手 更好地理解你,并提供更个性化的体验。",
|
||||||
empty: "暂无可展示的记忆数据。",
|
empty: "暂无可展示的记忆数据。",
|
||||||
rawJson: "原始 JSON",
|
rawJson: "原始 JSON",
|
||||||
exportButton: "导出记忆",
|
exportButton: "导出记忆",
|
||||||
@@ -414,20 +414,42 @@ export const zhCN: Translations = {
|
|||||||
title: "技能",
|
title: "技能",
|
||||||
description: "管理 Agent Skill 配置和启用状态。",
|
description: "管理 Agent Skill 配置和启用状态。",
|
||||||
createSkill: "新建技能",
|
createSkill: "新建技能",
|
||||||
|
installSkill: "安装技能",
|
||||||
emptyTitle: "还没有技能",
|
emptyTitle: "还没有技能",
|
||||||
emptyDescription:
|
emptyDescription:
|
||||||
"将你的 Agent Skill 文件夹放在 CM业务中心-问答管理 根目录下的 `/skills/custom` 文件夹中。",
|
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
|
||||||
emptyButton: "创建你的第一个技能",
|
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: {
|
notification: {
|
||||||
title: "通知",
|
title: "通知",
|
||||||
description:
|
description:
|
||||||
"CM业务中心-问答管理 只会在窗口不活跃时发送完成通知,特别适合长时间任务:你可以先去做别的事,完成后会收到提醒。",
|
"CM 助手 只会在窗口不活跃时发送完成通知,特别适合长时间任务:你可以先去做别的事,完成后会收到提醒。",
|
||||||
requestPermission: "请求通知权限",
|
requestPermission: "请求通知权限",
|
||||||
deniedHint:
|
deniedHint:
|
||||||
"通知权限已被拒绝。可在浏览器的网站设置中重新开启,以接收完成提醒。",
|
"通知权限已被拒绝。可在浏览器的网站设置中重新开启,以接收完成提醒。",
|
||||||
testButton: "发送测试通知",
|
testButton: "发送测试通知",
|
||||||
testTitle: "CM业务中心-问答管理",
|
testTitle: "CM 助手",
|
||||||
testBody: "这是一条测试通知。",
|
testBody: "这是一条测试通知。",
|
||||||
notSupported: "当前浏览器不支持通知功能。",
|
notSupported: "当前浏览器不支持通知功能。",
|
||||||
disableNotification: "关闭通知",
|
disableNotification: "关闭通知",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
ScheduledTaskDeliveryProfile,
|
ScheduledTaskDeliveryProfile,
|
||||||
ScheduledTaskResultFileContent,
|
ScheduledTaskResultFileContent,
|
||||||
ScheduledTaskRun,
|
ScheduledTaskRun,
|
||||||
|
ScheduledTaskRunFileUpdateResponse,
|
||||||
ScheduledTaskSubscription,
|
ScheduledTaskSubscription,
|
||||||
SchedulerThread,
|
SchedulerThread,
|
||||||
} from "./types";
|
} 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 {
|
function filenameFromContentDisposition(header: string | null): string | null {
|
||||||
if (!header) return null;
|
if (!header) return null;
|
||||||
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
|
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;
|
result?: ScheduledTaskRunResult | null;
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
updated_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;
|
success: boolean;
|
||||||
skill_name: string;
|
skill_name: string;
|
||||||
message: 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(
|
export async function installSkill(
|
||||||
@@ -61,3 +73,40 @@ export async function installSkill(
|
|||||||
|
|
||||||
return response.json();
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { enableSkill } from "./api";
|
import { enableSkill, installSkillUpload, validateSkillUpload } from "./api";
|
||||||
|
|
||||||
import { loadSkills } from ".";
|
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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ static/browser_history/*.gif
|
|||||||
|
|
||||||
log/
|
log/
|
||||||
log/*
|
log/*
|
||||||
|
.deer-flow/users/
|
||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ class ScheduledTaskSubscribeRequest(BaseModel):
|
|||||||
notify_element: bool = False
|
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:
|
async def _user_id(request: Request) -> str:
|
||||||
user_id = await get_current_user(request)
|
user_id = await get_current_user(request)
|
||||||
if not user_id:
|
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")
|
@router.get("/runs/{run_id}/download")
|
||||||
async def download_scheduled_task_run(run_id: str, request: Request):
|
async def download_scheduled_task_run(run_id: str, request: Request):
|
||||||
service = get_scheduled_task_service()
|
service = get_scheduled_task_service()
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.deps import get_config
|
from app.gateway.deps import get_config
|
||||||
@@ -56,6 +57,20 @@ class SkillInstallResponse(BaseModel):
|
|||||||
success: bool = Field(..., description="Whether the installation was successful")
|
success: bool = Field(..., description="Whether the installation was successful")
|
||||||
skill_name: str = Field(..., description="Name of the installed skill")
|
skill_name: str = Field(..., description="Name of the installed skill")
|
||||||
message: str = Field(..., description="Installation result message")
|
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):
|
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)}")
|
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(
|
@router.post(
|
||||||
"/skills/install",
|
"/skills/install",
|
||||||
response_model=SkillInstallResponse,
|
response_model=SkillInstallResponse,
|
||||||
|
|||||||
+9
-9
@@ -384,7 +384,7 @@ task(description="Oracle Cloud analysis", prompt="...", subagent_type="general-p
|
|||||||
|
|
||||||
SYSTEM_PROMPT_TEMPLATE = """
|
SYSTEM_PROMPT_TEMPLATE = """
|
||||||
<role>
|
<role>
|
||||||
You are {agent_name}, an open-source super agent.
|
You are {agent_name}, an internal super agent.
|
||||||
</role>
|
</role>
|
||||||
|
|
||||||
{soul}
|
{soul}
|
||||||
@@ -517,10 +517,10 @@ Recent breakthroughs in language models have also accelerated progress
|
|||||||
```markdown
|
```markdown
|
||||||
## Executive Summary
|
## Executive Summary
|
||||||
|
|
||||||
DeerFlow is an open-source AI agent framework that gained significant traction in early 2026
|
LangGraph is an open-source workflow orchestration framework for LLM applications
|
||||||
[citation:GitHub Repository](https://github.com/bytedance/deer-flow). The project focuses on
|
[citation:LangGraph Repository](https://github.com/langchain-ai/langgraph). The project
|
||||||
providing a production-ready agent system with sandbox execution and memory management
|
focuses on providing stateful multi-agent graphs with built-in checkpointing and streaming
|
||||||
[citation:DeerFlow Documentation](https://deer-flow.dev/docs).
|
[citation:LangGraph Documentation](https://langchain-ai.github.io/langgraph/).
|
||||||
|
|
||||||
## Key Analysis
|
## Key Analysis
|
||||||
|
|
||||||
@@ -532,8 +532,8 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
|
|||||||
## Sources
|
## Sources
|
||||||
|
|
||||||
### Primary Sources
|
### Primary Sources
|
||||||
- [GitHub Repository](https://github.com/bytedance/deer-flow) - Official source code and documentation
|
- [LangGraph Repository](https://github.com/langchain-ai/langgraph) - Official source code and documentation
|
||||||
- [DeerFlow Documentation](https://deer-flow.dev/docs) - Technical specifications
|
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/) - Technical specifications
|
||||||
|
|
||||||
### Media Coverage
|
### Media Coverage
|
||||||
- [AI Trends 2026](https://techcrunch.com/ai-trends) - Industry analysis
|
- [AI Trends 2026](https://techcrunch.com/ai-trends) - Industry analysis
|
||||||
@@ -545,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
|
- The `[citation:Title](URL)` format is ONLY for inline citations within the report body
|
||||||
- ❌ WRONG: `GitHub 仓库 - 官方源代码和文档` (no URL!)
|
- ❌ WRONG: `GitHub 仓库 - 官方源代码和文档` (no URL!)
|
||||||
- ❌ WRONG in Sources: `[citation:GitHub Repository](url)` (citation prefix is for inline only!)
|
- ❌ 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:**
|
**WORKFLOW for Research Tasks:**
|
||||||
1. Use web_search to find sources → Extract {{title, url, snippet}} from results
|
1. Use web_search to find sources → Extract {{title, url, snippet}} from results
|
||||||
@@ -837,7 +837,7 @@ def apply_prompt_template(
|
|||||||
|
|
||||||
# Format the prompt with dynamic skills and memory
|
# Format the prompt with dynamic skills and memory
|
||||||
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
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),
|
soul=get_agent_soul(storage_agent_id),
|
||||||
skills_section=skills_section,
|
skills_section=skills_section,
|
||||||
deferred_tools_section=deferred_tools_section,
|
deferred_tools_section=deferred_tools_section,
|
||||||
|
|||||||
+55
-16
@@ -67,21 +67,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
|||||||
question = args.get("question", "")
|
question = args.get("question", "")
|
||||||
clarification_type = args.get("clarification_type", "missing_info")
|
clarification_type = args.get("clarification_type", "missing_info")
|
||||||
context = args.get("context")
|
context = args.get("context")
|
||||||
options = args.get("options", [])
|
options = self._normalize_options(args.get("options", []))
|
||||||
|
|
||||||
# Some models (e.g. Qwen3-Max) serialize array parameters as JSON strings
|
|
||||||
# instead of native arrays. Deserialize and normalize so `options`
|
|
||||||
# is always a list for the rendering logic below.
|
|
||||||
if isinstance(options, str):
|
|
||||||
try:
|
|
||||||
options = json.loads(options)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
options = [options]
|
|
||||||
|
|
||||||
if options is None:
|
|
||||||
options = []
|
|
||||||
elif not isinstance(options, list):
|
|
||||||
options = [options]
|
|
||||||
|
|
||||||
# Type-specific icons
|
# Type-specific icons
|
||||||
type_icons = {
|
type_icons = {
|
||||||
@@ -110,10 +96,62 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
|||||||
if options and len(options) > 0:
|
if options and len(options) > 0:
|
||||||
message_parts.append("") # blank line for spacing
|
message_parts.append("") # blank line for spacing
|
||||||
for i, option in enumerate(options, 1):
|
for i, option in enumerate(options, 1):
|
||||||
message_parts.append(f" {i}. {option}")
|
label = option.get("label") or option.get("id") or ""
|
||||||
|
message_parts.append(f" {i}. {label}")
|
||||||
|
|
||||||
return "\n".join(message_parts)
|
return "\n".join(message_parts)
|
||||||
|
|
||||||
|
def _normalize_options(self, raw_options: object) -> list[dict[str, str]]:
|
||||||
|
"""Normalize model-produced options into a stable UI-friendly shape."""
|
||||||
|
options = raw_options
|
||||||
|
|
||||||
|
# Some models serialize array parameters as JSON strings instead of native arrays.
|
||||||
|
if isinstance(options, str):
|
||||||
|
try:
|
||||||
|
options = json.loads(options)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
options = [options]
|
||||||
|
|
||||||
|
if options is None:
|
||||||
|
values: list[object] = []
|
||||||
|
elif isinstance(options, list):
|
||||||
|
values = options
|
||||||
|
else:
|
||||||
|
values = [options]
|
||||||
|
|
||||||
|
normalized: list[dict[str, str]] = []
|
||||||
|
for index, value in enumerate(values, 1):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
raw_label = value.get("label") or value.get("title") or value.get("name") or value.get("value") or value.get("id")
|
||||||
|
label = str(raw_label).strip() if raw_label is not None else ""
|
||||||
|
if not label:
|
||||||
|
continue
|
||||||
|
raw_id = value.get("id") or value.get("value") or f"option-{index}"
|
||||||
|
option = {
|
||||||
|
"id": str(raw_id).strip() or f"option-{index}",
|
||||||
|
"label": label,
|
||||||
|
}
|
||||||
|
description = value.get("description")
|
||||||
|
if description is not None and str(description).strip():
|
||||||
|
option["description"] = str(description).strip()
|
||||||
|
normalized.append(option)
|
||||||
|
else:
|
||||||
|
label = str(value).strip()
|
||||||
|
if label:
|
||||||
|
normalized.append({"id": f"option-{index}", "label": label})
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _build_payload(self, args: dict) -> dict:
|
||||||
|
"""Build structured clarification data for rich clients."""
|
||||||
|
return {
|
||||||
|
"question": str(args.get("question", "")).strip(),
|
||||||
|
"clarification_type": args.get("clarification_type", "missing_info"),
|
||||||
|
"context": args.get("context"),
|
||||||
|
"options": self._normalize_options(args.get("options", [])),
|
||||||
|
"allow_custom": True,
|
||||||
|
}
|
||||||
|
|
||||||
def _handle_clarification(self, request: ToolCallRequest) -> Command:
|
def _handle_clarification(self, request: ToolCallRequest) -> Command:
|
||||||
"""Handle clarification request and return command to interrupt execution.
|
"""Handle clarification request and return command to interrupt execution.
|
||||||
|
|
||||||
@@ -143,6 +181,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
|||||||
content=formatted_message,
|
content=formatted_message,
|
||||||
tool_call_id=tool_call_id,
|
tool_call_id=tool_call_id,
|
||||||
name="ask_clarification",
|
name="ask_clarification",
|
||||||
|
additional_kwargs={"clarification": self._build_payload(args)},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return a Command that:
|
# Return a Command that:
|
||||||
|
|||||||
+71
@@ -252,6 +252,7 @@ class ScheduledTaskService:
|
|||||||
async def get_run(self, user_id: str, run_id: str) -> dict[str, Any] | None:
|
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)
|
run = await self.store.get_run(run_id, user_id)
|
||||||
if run is not None:
|
if run is not None:
|
||||||
|
run["can_edit"] = True
|
||||||
return run
|
return run
|
||||||
# Subscriber visibility: look up the run unfiltered, then verify the
|
# Subscriber visibility: look up the run unfiltered, then verify the
|
||||||
# caller has a subscription on the parent task.
|
# caller has a subscription on the parent task.
|
||||||
@@ -260,8 +261,78 @@ class ScheduledTaskService:
|
|||||||
return None
|
return None
|
||||||
if await self.store.get_subscription(candidate["task_id"], user_id) is None:
|
if await self.store.get_subscription(candidate["task_id"], user_id) is None:
|
||||||
return None
|
return None
|
||||||
|
candidate["can_edit"] = False
|
||||||
return candidate
|
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:
|
async def delete_run(self, user_id: str, run_id: str) -> bool:
|
||||||
deleted = await self.store.delete_run(run_id, user_id)
|
deleted = await self.store.delete_run(run_id, user_id)
|
||||||
if deleted:
|
if deleted:
|
||||||
|
|||||||
+113
-23
@@ -91,41 +91,48 @@ class LocalSkillStorage(SkillStorage):
|
|||||||
tmp_path = Path(tmp_file.name)
|
tmp_path = Path(tmp_file.name)
|
||||||
tmp_path.replace(target)
|
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
|
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)
|
path = Path(archive_path)
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise FileNotFoundError(f"Skill file not found: {archive_path}")
|
raise FileNotFoundError(f"Skill file not found: {archive_path}")
|
||||||
raise ValueError(f"Path is not a file: {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")
|
raise ValueError("File must have .skill extension")
|
||||||
|
|
||||||
custom_dir = self._host_root / "custom"
|
try:
|
||||||
custom_dir.mkdir(parents=True, exist_ok=True)
|
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:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
tmp_path = Path(tmp)
|
tmp_path = Path(tmp)
|
||||||
|
|
||||||
try:
|
with self._open_skill_archive(archive_path, require_skill_suffix=require_skill_suffix) as zf:
|
||||||
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:
|
|
||||||
safe_extract_skill_archive(zf, tmp_path)
|
safe_extract_skill_archive(zf, tmp_path)
|
||||||
|
|
||||||
skill_dir = resolve_skill_dir_from_archive(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:
|
if not skill_name or "/" in skill_name or "\\" in skill_name or ".." in skill_name:
|
||||||
raise ValueError(f"Invalid skill name: {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
|
target = custom_dir / skill_name
|
||||||
|
replaced = False
|
||||||
if target.exists():
|
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)
|
await _scan_skill_archive_contents_or_raise(skill_dir, skill_name)
|
||||||
|
|
||||||
@@ -152,6 +241,7 @@ class LocalSkillStorage(SkillStorage):
|
|||||||
"success": True,
|
"success": True,
|
||||||
"skill_name": skill_name,
|
"skill_name": skill_name,
|
||||||
"message": f"Skill '{skill_name}' installed successfully",
|
"message": f"Skill '{skill_name}' installed successfully",
|
||||||
|
"overwritten": replaced,
|
||||||
}
|
}
|
||||||
|
|
||||||
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
|
def delete_custom_skill(self, name: str, *, history_meta: dict | None = None) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
---
|
||||||
|
name: intent-clarification-policy
|
||||||
|
description: Use this skill to analyze a higher-level task directive before planning. It helps infer the superior intent, assess whether the task is clear enough to proceed, and ask concise clarification questions when key purpose, audience, effect, boundary, coordination, or evaluation details are missing.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Intent Clarification Policy
|
||||||
|
|
||||||
|
Use this skill before drafting a plan for a higher-level task directive, action requirement, special work arrangement, cognitive-domain task, public opinion/information/psychological/influence-related task, or cross-department mission.
|
||||||
|
|
||||||
|
This skill does not draft the full plan. It prepares the intent analysis and decides whether clarification is needed first.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Restate the task briefly to avoid misreading.
|
||||||
|
2. Assess clarity across seven dimensions.
|
||||||
|
3. Assign a clarity level: A, B, C, or D.
|
||||||
|
4. If level A or B, produce a concise intent analysis.
|
||||||
|
5. If level C or D, call `ask_clarification` before planning.
|
||||||
|
|
||||||
|
## Clarity Dimensions
|
||||||
|
|
||||||
|
Check whether the task clearly states:
|
||||||
|
|
||||||
|
- Goal: what higher-level purpose or end state is expected.
|
||||||
|
- Object: who or what the task is directed toward.
|
||||||
|
- Effect: what cognitive, attitude, behavior, posture, or environment change is desired.
|
||||||
|
- Time window: when results are expected.
|
||||||
|
- Boundary: legal, policy, ethical, authorization, escalation, geographic, and resource limits.
|
||||||
|
- Coordination: who leads, supports, approves, or assesses.
|
||||||
|
- Evaluation: how completion and effect will be judged.
|
||||||
|
|
||||||
|
## Clarity Levels
|
||||||
|
|
||||||
|
- **A - Highly clear**: goal, object, effect, time, and boundary are mostly clear. Proceed to intent analysis.
|
||||||
|
- **B - Basically clear**: core purpose is clear, but some boundary or metric details are missing. Provide tentative analysis and list a few follow-up questions.
|
||||||
|
- **C - Clearly vague**: only a broad direction is given; object, effect, or boundary is missing. Ask clarification first.
|
||||||
|
- **D - Not enough context**: original task text or background is absent. Ask the user to provide the task material.
|
||||||
|
|
||||||
|
## Intent Analysis Mode
|
||||||
|
|
||||||
|
Use this mode for level A or B tasks.
|
||||||
|
|
||||||
|
Start with:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
上级任务可概括为:
|
||||||
|
在【时间/阶段】内,围绕【背景/事件】,面向【目标对象】,通过【主要行动方向】,达成【期望效果】,服务于【上级目标】。
|
||||||
|
```
|
||||||
|
|
||||||
|
Then analyze only the points supported by the task text:
|
||||||
|
|
||||||
|
- Superior purpose: why the task was assigned and what higher objective it serves.
|
||||||
|
- Task nature: shaping, deterrence, stabilization, protection, weakening, exposure, support, or assessment.
|
||||||
|
- Target objects: primary objects, secondary objects, protected groups, and possible unintended affected groups.
|
||||||
|
- Expected effects: desired changes in cognition, attitude, judgment, behavior, posture, or environment.
|
||||||
|
- Action boundaries: authorization, policy, legal, ethical, timing, geography, and resource constraints.
|
||||||
|
- Evaluation method: indicators, feedback channels, acceptance criteria, or effect assessment.
|
||||||
|
|
||||||
|
If the task is cognitive-domain related, identify one or more task types:
|
||||||
|
|
||||||
|
- Shaping: influence the narrative or perception environment in advance.
|
||||||
|
- Deterrence: affect an opponent's cost-benefit judgment.
|
||||||
|
- Stabilization: reduce panic, misunderstanding, uncertainty, or loss of trust.
|
||||||
|
- Protection: resist false information, psychological pressure, or cognitive manipulation.
|
||||||
|
- Weakening: reduce an opponent's credibility, mobilization, or influence capacity.
|
||||||
|
- Exposure: reveal facts, attribute responsibility, or strengthen credibility.
|
||||||
|
- Support: support military, diplomatic, public affairs, cyber, or civil action.
|
||||||
|
- Assessment: monitor the cognitive environment or action effect.
|
||||||
|
|
||||||
|
Summarize inferred intent with:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 上级意图推断
|
||||||
|
|
||||||
|
上级希望在【时间窗口】内,围绕【事件/议题/背景】,通过【本级行动方向】,影响【主要对象】对【关键问题】的理解、判断或行为,使其从【当前状态】转向【期望状态】,从而支撑【上级战略/战役/行动/业务目标】。
|
||||||
|
|
||||||
|
本任务的核心不是单纯完成【具体动作】,而是通过该动作形成【认知效果/行为效果/环境效果】。
|
||||||
|
```
|
||||||
|
|
||||||
|
## Clarification Mode
|
||||||
|
|
||||||
|
Use this mode for level C or D tasks. Do not draft a full plan yet.
|
||||||
|
|
||||||
|
Diagnose missing information in these categories:
|
||||||
|
|
||||||
|
- Purpose unclear: higher-level objective or desired end state is missing.
|
||||||
|
- Object unclear: primary audience, affected group, area, platform, or stakeholder is missing.
|
||||||
|
- Effect unclear: expected cognitive, attitude, behavior, or environment change is missing.
|
||||||
|
- Means unclear: allowed and prohibited action methods are missing.
|
||||||
|
- Boundary unclear: legal, policy, ethical, authorization, or escalation limits are missing.
|
||||||
|
- Coordination unclear: lead, support, approval, and assessment roles are missing.
|
||||||
|
- Evaluation unclear: completion criteria and effect metrics are missing.
|
||||||
|
|
||||||
|
Ask the minimum number of questions needed to proceed. Prefer one main question with 2-4 options. If several key fields are missing, ask for the most blocking one first.
|
||||||
|
|
||||||
|
Use `ask_clarification`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ask_clarification(
|
||||||
|
question="一个用户可以快速回答的具体问题。",
|
||||||
|
clarification_type="missing_info",
|
||||||
|
context="简要说明为什么这个信息会影响后续计划拟制。",
|
||||||
|
options=["选项一", "选项二", "选项三"],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Choose `clarification_type`:
|
||||||
|
|
||||||
|
- `missing_info`: required task material or key detail is absent.
|
||||||
|
- `ambiguous_requirement`: the directive has multiple plausible interpretations.
|
||||||
|
- `approach_choice`: several valid task directions exist and user preference matters.
|
||||||
|
- `risk_confirmation`: the action may be sensitive, irreversible, escalatory, or policy-relevant.
|
||||||
|
- `suggestion`: you recommend a framing and need user approval.
|
||||||
|
|
||||||
|
## High-Value Questions
|
||||||
|
|
||||||
|
Use or adapt these questions when needed:
|
||||||
|
|
||||||
|
- 上级下发该任务主要服务于什么目标?
|
||||||
|
- 任务完成后,上级希望看到什么状态变化?
|
||||||
|
- 本任务主要面向哪些对象?
|
||||||
|
- 是否有不能影响、不能触碰或需要特别保护的对象?
|
||||||
|
- 希望目标对象在认知、态度、判断或行为上发生什么变化?
|
||||||
|
- 该效果是短期即时效果,还是长期环境塑造效果?
|
||||||
|
- 本级在任务中是主责、支援、协同、评估,还是审批角色?
|
||||||
|
- 有哪些法律、政策、授权、伦理或升级风险边界?
|
||||||
|
- 如何判断任务完成,如何评估实际效果?
|
||||||
|
|
||||||
|
## Style Rules
|
||||||
|
|
||||||
|
- Use Chinese when the user writes Chinese.
|
||||||
|
- Be concise and structured.
|
||||||
|
- Do not invent unsupported intent; label uncertain points as assumptions.
|
||||||
|
- Do not proceed to full planning when clarity is C or D.
|
||||||
|
- For sensitive influence, public opinion, psychological, or cognitive-domain work, keep analysis lawful, ethical, defensive, and policy-bounded.
|
||||||
@@ -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
|
||||||
|
)
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
BACKEND_DIR="${ROOT_DIR}/offline-backend-20260512/backend"
|
||||||
|
FRONTEND_DIR="${ROOT_DIR}/frontend-web"
|
||||||
|
RUNTIME_DIR="${ROOT_DIR}/.runtime"
|
||||||
|
LOG_DIR="${RUNTIME_DIR}/logs"
|
||||||
|
PID_DIR="${RUNTIME_DIR}/pids"
|
||||||
|
|
||||||
|
BACKEND_HOST="${BACKEND_HOST:-0.0.0.0}"
|
||||||
|
BACKEND_PORT="${BACKEND_PORT:-8001}"
|
||||||
|
FRONTEND_HOST="${FRONTEND_HOST:-0.0.0.0}"
|
||||||
|
FRONTEND_PORT="${FRONTEND_PORT:-5174}"
|
||||||
|
|
||||||
|
detect_host_ip() {
|
||||||
|
if command -v hostname >/dev/null 2>&1; then
|
||||||
|
hostname -I 2>/dev/null | awk '{print $1}'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
HOST_IP="${HOST_IP:-$(detect_host_ip)}"
|
||||||
|
HOST_IP="${HOST_IP:-127.0.0.1}"
|
||||||
|
|
||||||
|
BACKEND_PUBLIC_BASE_URL="${BACKEND_PUBLIC_BASE_URL:-http://${HOST_IP}:${BACKEND_PORT}}"
|
||||||
|
FRONTEND_PUBLIC_BASE_URL="${FRONTEND_PUBLIC_BASE_URL:-http://${HOST_IP}:${FRONTEND_PORT}}"
|
||||||
|
|
||||||
|
GATEWAY_CORS_ORIGINS="${GATEWAY_CORS_ORIGINS:-http://localhost:${FRONTEND_PORT},http://127.0.0.1:${FRONTEND_PORT},http://${HOST_IP}:${FRONTEND_PORT}}"
|
||||||
|
|
||||||
|
mkdir -p "${LOG_DIR}" "${PID_DIR}"
|
||||||
|
|
||||||
|
is_running() {
|
||||||
|
local pid_file="$1"
|
||||||
|
[[ -f "${pid_file}" ]] || return 1
|
||||||
|
|
||||||
|
local pid
|
||||||
|
pid="$(cat "${pid_file}" 2>/dev/null || true)"
|
||||||
|
[[ -n "${pid}" ]] || return 1
|
||||||
|
kill -0 "${pid}" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_by_pid_file() {
|
||||||
|
local name="$1"
|
||||||
|
local pid_file="$2"
|
||||||
|
|
||||||
|
if ! [[ -f "${pid_file}" ]]; then
|
||||||
|
echo "${name} is not running (no pid file)."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local pid
|
||||||
|
pid="$(cat "${pid_file}" 2>/dev/null || true)"
|
||||||
|
if [[ -z "${pid}" ]] || ! kill -0 "${pid}" >/dev/null 2>&1; then
|
||||||
|
echo "${name} is not running (stale pid file removed)."
|
||||||
|
rm -f "${pid_file}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Stopping ${name} (pid ${pid})..."
|
||||||
|
# Services are started with setsid, so the pid is also the process group id.
|
||||||
|
# Stop the whole group to avoid leaving Vite/esbuild/uvicorn child processes.
|
||||||
|
kill "-${pid}" >/dev/null 2>&1 || true
|
||||||
|
kill "${pid}" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
for _ in {1..30}; do
|
||||||
|
if ! kill -0 "${pid}" >/dev/null 2>&1; then
|
||||||
|
rm -f "${pid_file}"
|
||||||
|
echo "${name} stopped."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "${name} did not stop gracefully; killing..."
|
||||||
|
kill -9 "-${pid}" >/dev/null 2>&1 || true
|
||||||
|
kill -9 "${pid}" >/dev/null 2>&1 || true
|
||||||
|
rm -f "${pid_file}"
|
||||||
|
echo "${name} stopped."
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_by_pattern() {
|
||||||
|
local name="$1"
|
||||||
|
local pattern="$2"
|
||||||
|
|
||||||
|
if ! command -v pgrep >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local pids
|
||||||
|
pids="$(pgrep -f "${pattern}" || true)"
|
||||||
|
if [[ -z "${pids}" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Stopping ${name} processes matching: ${pattern}"
|
||||||
|
while IFS= read -r pid; do
|
||||||
|
[[ -n "${pid}" ]] || continue
|
||||||
|
[[ "${pid}" != "$$" ]] || continue
|
||||||
|
kill "${pid}" >/dev/null 2>&1 || true
|
||||||
|
done <<<"${pids}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
"${SCRIPT_DIR}/stop-all.sh"
|
||||||
|
"${SCRIPT_DIR}/start-all.sh"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
"${SCRIPT_DIR}/stop-backend.sh"
|
||||||
|
"${SCRIPT_DIR}/start-backend.sh"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
"${SCRIPT_DIR}/stop-frontend.sh"
|
||||||
|
"${SCRIPT_DIR}/start-frontend.sh"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
"${SCRIPT_DIR}/start-backend.sh"
|
||||||
|
"${SCRIPT_DIR}/start-frontend.sh"
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh"
|
||||||
|
|
||||||
|
PID_FILE="${PID_DIR}/backend.pid"
|
||||||
|
LOG_FILE="${LOG_DIR}/backend.log"
|
||||||
|
|
||||||
|
if is_running "${PID_FILE}"; then
|
||||||
|
echo "Backend is already running (pid $(cat "${PID_FILE}"))."
|
||||||
|
echo "URL: http://${HOST_IP}:${BACKEND_PORT}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "${PID_FILE}"
|
||||||
|
|
||||||
|
echo "Starting backend..."
|
||||||
|
echo "Log: ${LOG_FILE}"
|
||||||
|
|
||||||
|
setsid bash -c '
|
||||||
|
cd "$0" || exit 1
|
||||||
|
exec env \
|
||||||
|
GATEWAY_CORS_ORIGINS="$1" \
|
||||||
|
PYTHONPATH=. \
|
||||||
|
uv run uvicorn app.gateway.app:app \
|
||||||
|
--host "$2" \
|
||||||
|
--port "$3" \
|
||||||
|
--reload
|
||||||
|
' "${BACKEND_DIR}" "${GATEWAY_CORS_ORIGINS}" "${BACKEND_HOST}" "${BACKEND_PORT}" \
|
||||||
|
>"${LOG_FILE}" 2>&1 &
|
||||||
|
|
||||||
|
echo "$!" >"${PID_FILE}"
|
||||||
|
echo "Backend started (pid $(cat "${PID_FILE}"))."
|
||||||
|
echo "URL: http://${HOST_IP}:${BACKEND_PORT}"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh"
|
||||||
|
|
||||||
|
PID_FILE="${PID_DIR}/frontend.pid"
|
||||||
|
LOG_FILE="${LOG_DIR}/frontend.log"
|
||||||
|
|
||||||
|
if is_running "${PID_FILE}"; then
|
||||||
|
echo "Frontend is already running (pid $(cat "${PID_FILE}"))."
|
||||||
|
echo "URL: ${FRONTEND_PUBLIC_BASE_URL}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "${PID_FILE}"
|
||||||
|
|
||||||
|
echo "Starting frontend..."
|
||||||
|
echo "Log: ${LOG_FILE}"
|
||||||
|
|
||||||
|
setsid bash -c '
|
||||||
|
cd "$0" || exit 1
|
||||||
|
exec env \
|
||||||
|
VITE_BACKEND_BASE_URL="$1" \
|
||||||
|
VITE_LANGGRAPH_BASE_URL="$1/api" \
|
||||||
|
pnpm exec vite \
|
||||||
|
--host "$2" \
|
||||||
|
--port "$3"
|
||||||
|
' "${FRONTEND_DIR}" "${BACKEND_PUBLIC_BASE_URL}" "${FRONTEND_HOST}" "${FRONTEND_PORT}" \
|
||||||
|
>"${LOG_FILE}" 2>&1 &
|
||||||
|
|
||||||
|
echo "$!" >"${PID_FILE}"
|
||||||
|
echo "Frontend started (pid $(cat "${PID_FILE}"))."
|
||||||
|
echo "URL: ${FRONTEND_PUBLIC_BASE_URL}"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh"
|
||||||
|
|
||||||
|
print_status() {
|
||||||
|
local name="$1"
|
||||||
|
local pid_file="$2"
|
||||||
|
local url="$3"
|
||||||
|
local pattern="$4"
|
||||||
|
|
||||||
|
if is_running "${pid_file}"; then
|
||||||
|
echo "${name}: running (pid $(cat "${pid_file}"))"
|
||||||
|
echo " ${url}"
|
||||||
|
elif command -v pgrep >/dev/null 2>&1 && pgrep -f "${pattern}" >/dev/null 2>&1; then
|
||||||
|
echo "${name}: running (unmanaged by scripts)"
|
||||||
|
echo " ${url}"
|
||||||
|
else
|
||||||
|
echo "${name}: stopped"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
print_status "Backend" "${PID_DIR}/backend.pid" "http://${HOST_IP}:${BACKEND_PORT}" "${BACKEND_DIR}.*uvicorn app.gateway.app:app.*--port ${BACKEND_PORT}"
|
||||||
|
print_status "Frontend" "${PID_DIR}/frontend.pid" "${FRONTEND_PUBLIC_BASE_URL}" "${FRONTEND_DIR}.*vite.*--port ${FRONTEND_PORT}"
|
||||||
|
echo "Logs: ${LOG_DIR}"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
"${SCRIPT_DIR}/stop-frontend.sh"
|
||||||
|
"${SCRIPT_DIR}/stop-backend.sh"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh"
|
||||||
|
|
||||||
|
stop_by_pid_file "Backend" "${PID_DIR}/backend.pid"
|
||||||
|
stop_by_pattern "Backend" "${BACKEND_DIR}.*uvicorn app.gateway.app:app.*--port ${BACKEND_PORT}"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh"
|
||||||
|
|
||||||
|
stop_by_pid_file "Frontend" "${PID_DIR}/frontend.pid"
|
||||||
|
stop_by_pattern "Frontend" "${FRONTEND_DIR}.*vite.*--port ${FRONTEND_PORT}"
|
||||||
Reference in new issue
Block a user