deepdata: core product code (backend, frontend, runtime, deepflow module)
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { queryMCPServerMetadata } from "~/core/api";
|
||||
import {
|
||||
MCPConfigSchema,
|
||||
type MCPServerMetadata,
|
||||
type SimpleMCPServerMetadata,
|
||||
type SimpleSSEMCPServerMetadata,
|
||||
type SimpleStdioMCPServerMetadata,
|
||||
} from "~/core/mcp";
|
||||
|
||||
export function AddMCPServerDialog({
|
||||
onAdd,
|
||||
}: {
|
||||
onAdd?: (servers: MCPServerMetadata[]) => void;
|
||||
}) {
|
||||
const t = useTranslations("settings");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const [validationError, setValidationError] = useState<string | null>("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleChange = useCallback((value: string) => {
|
||||
setInput(value);
|
||||
if (!value.trim()) {
|
||||
setValidationError(null);
|
||||
return;
|
||||
}
|
||||
setValidationError(null);
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!("mcpServers" in parsed)) {
|
||||
setValidationError("Missing `mcpServers` in JSON");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setValidationError(t("invalidJson"));
|
||||
return;
|
||||
}
|
||||
const result = MCPConfigSchema.safeParse(JSON.parse(value));
|
||||
if (!result.success) {
|
||||
if (result.error.errors[0]) {
|
||||
const error = result.error.errors[0];
|
||||
if (error.code === "invalid_union") {
|
||||
if (error.unionErrors[0]?.errors[0]) {
|
||||
setValidationError(error.unionErrors[0].errors[0].message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
const errorMessage =
|
||||
result.error.errors[0]?.message ?? t("validationFailed");
|
||||
setValidationError(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = Object.keys(result.data.mcpServers);
|
||||
if (keys.length === 0) {
|
||||
setValidationError(t("missingServerName"));
|
||||
return;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleAdd = useCallback(async () => {
|
||||
abortControllerRef.current = new AbortController();
|
||||
const config = MCPConfigSchema.parse(JSON.parse(input));
|
||||
setInput(JSON.stringify(config, null, 2));
|
||||
const addingServers: SimpleMCPServerMetadata[] = [];
|
||||
for (const [key, server] of Object.entries(config.mcpServers)) {
|
||||
if ("command" in server) {
|
||||
const metadata: SimpleStdioMCPServerMetadata = {
|
||||
transport: "stdio",
|
||||
name: key,
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
env: server.env,
|
||||
};
|
||||
addingServers.push(metadata);
|
||||
} else if ("url" in server) {
|
||||
const metadata: SimpleSSEMCPServerMetadata = {
|
||||
transport: server.transport,
|
||||
name: key,
|
||||
url: server.url,
|
||||
};
|
||||
addingServers.push(metadata);
|
||||
}
|
||||
}
|
||||
setProcessing(true);
|
||||
|
||||
const results: MCPServerMetadata[] = [];
|
||||
let processingServer: string | null = null;
|
||||
try {
|
||||
setError(null);
|
||||
for (const server of addingServers) {
|
||||
processingServer = server.name;
|
||||
const metadata = await queryMCPServerMetadata(server, abortControllerRef.current.signal);
|
||||
results.push({ ...metadata, name: server.name, enabled: true });
|
||||
}
|
||||
if (results.length > 0) {
|
||||
onAdd?.(results);
|
||||
}
|
||||
setInput("");
|
||||
setOpen(false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (e instanceof Error && e.name === 'AbortError') {
|
||||
setError(`Request was cancelled`);
|
||||
} else {
|
||||
setError(`Failed to add server: ${processingServer}`);
|
||||
}
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, [input, onAdd]);
|
||||
|
||||
const handleAbort = () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">{t("addServers")}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("addNewMCPServers")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
{t("mcpConfigDescription")}
|
||||
<br />
|
||||
{t("pasteConfigBelow")}
|
||||
</DialogDescription>
|
||||
|
||||
<main>
|
||||
<Textarea
|
||||
className="h-[360px] break-all sm:max-w-[510px]"
|
||||
placeholder={
|
||||
'Example:\n\n{\n "mcpServers": {\n "My Server": {\n "command": "python",\n "args": [\n "-m", "mcp_server"\n ],\n "env": {\n "API_KEY": "YOUR_API_KEY"\n }\n }\n }\n}'
|
||||
}
|
||||
value={input}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex h-10 w-full items-center justify-between gap-2">
|
||||
<div className="text-destructive flex-grow overflow-hidden text-sm">
|
||||
{validationError ?? error}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
{t("cancel", { defaultValue: "Cancel" })}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-24"
|
||||
type="submit"
|
||||
disabled={!input.trim() || !!validationError || processing}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
{processing && <Loader2 className="animate-spin" />}
|
||||
{t("add")}
|
||||
</Button>
|
||||
{
|
||||
processing && (
|
||||
<Button variant="destructive" onClick={handleAbort}>Abort</Button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import type { MCPServerMetadata } from "~/core/mcp";
|
||||
import { MCPConfigSchema } from "~/core/mcp";
|
||||
|
||||
export function EditMCPServerDialog({
|
||||
server,
|
||||
onSave,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
server: MCPServerMetadata;
|
||||
onSave: (config: string) => Promise<boolean>;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const t = useTranslations("settings.mcp");
|
||||
const commonT = useTranslations("common");
|
||||
const [config, setConfig] = useState(
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
[server.name]: server.transport === 'stdio'
|
||||
? {
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
env: server.env,
|
||||
}
|
||||
: {
|
||||
transport: server.transport,
|
||||
url: server.url,
|
||||
headers: server.headers,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const handleChange = useCallback((value: string) => {
|
||||
setConfig(value);
|
||||
setValidationError(null);
|
||||
|
||||
if (!value.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!("mcpServers" in parsed)) {
|
||||
setValidationError("Missing `mcpServers` in JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = MCPConfigSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
if (result.error.errors[0]) {
|
||||
const error = result.error.errors[0];
|
||||
if (error.code === "invalid_union") {
|
||||
if (error.unionErrors[0]?.errors[0]) {
|
||||
setValidationError(error.unionErrors[0].errors[0].message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setValidationError(error.message || t("validationFailed"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const keys = Object.keys(parsed.mcpServers);
|
||||
if (keys.length === 0) {
|
||||
setValidationError(t("missingServerName"));
|
||||
}
|
||||
} catch {
|
||||
setValidationError(t("invalidJson"));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setProcessing(true);
|
||||
try {
|
||||
const success = await onSave(config);
|
||||
if (success) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save server configuration:', error);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
}, [config, onSave, onOpenChange]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("editServer")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("editServerDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Textarea
|
||||
className="h-[360px] font-mono text-sm"
|
||||
value={config}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
/>
|
||||
{validationError && (
|
||||
<div className="text-sm text-red-500 mt-2">
|
||||
{validationError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full justify-between items-center">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("editServerNote")}
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={processing}
|
||||
>
|
||||
{commonT("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={processing || !!validationError}
|
||||
>
|
||||
{processing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{commonT("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { Settings } from "lucide-react";
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Tooltip } from "~/components/deepflow/tooltip";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Tabs, TabsContent } from "~/components/ui/tabs";
|
||||
import { useReplay } from "~/core/replay";
|
||||
import {
|
||||
type SettingsState,
|
||||
changeSettings,
|
||||
saveSettings,
|
||||
useSettingsStore,
|
||||
} from "~/core/store";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { SETTINGS_TABS } from "../tabs";
|
||||
|
||||
export function SettingsDialog() {
|
||||
const t = useTranslations('settings');
|
||||
const tCommon = useTranslations('common');
|
||||
const { isReplay } = useReplay();
|
||||
const [activeTabId, setActiveTabId] = useState(SETTINGS_TABS[0]!.id);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [settings, setSettings] = useState(useSettingsStore.getState());
|
||||
const [changes, setChanges] = useState<Partial<SettingsState>>({});
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(newChanges: Partial<SettingsState>) => {
|
||||
setTimeout(() => {
|
||||
if (open) {
|
||||
setChanges((prev) => ({
|
||||
...prev,
|
||||
...newChanges,
|
||||
}));
|
||||
}
|
||||
}, 0);
|
||||
},
|
||||
[open],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (Object.keys(changes).length > 0) {
|
||||
const newSettings: SettingsState = {
|
||||
...settings,
|
||||
...changes,
|
||||
};
|
||||
setSettings(newSettings);
|
||||
setChanges({});
|
||||
changeSettings(newSettings);
|
||||
saveSettings();
|
||||
}
|
||||
setOpen(false);
|
||||
}, [settings, changes]);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
setSettings(useSettingsStore.getState());
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setChanges({});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
handleOpen();
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
}, [open, handleOpen, handleClose]);
|
||||
|
||||
const mergedSettings = useMemo<SettingsState>(() => {
|
||||
return {
|
||||
...settings,
|
||||
...changes,
|
||||
};
|
||||
}, [settings, changes]);
|
||||
|
||||
if (isReplay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Tooltip title={tCommon('settings')}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</Tooltip>
|
||||
<DialogContent className="sm:max-w-[850px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Tabs value={activeTabId}>
|
||||
<div className="flex h-120 w-full overflow-auto border-y">
|
||||
<ul className="flex w-50 shrink-0 border-r p-1">
|
||||
<div className="size-full">
|
||||
{SETTINGS_TABS.map((tab) => (
|
||||
<li
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
"hover:accent-foreground hover:bg-accent mb-1 flex h-8 w-full cursor-pointer items-center gap-1.5 rounded px-2",
|
||||
activeTabId === tab.id &&
|
||||
"!bg-primary !text-primary-foreground",
|
||||
)}
|
||||
onClick={() => setActiveTabId(tab.id)}
|
||||
>
|
||||
<tab.icon size={16} />
|
||||
<span>{tab.label}</span>
|
||||
{tab.badge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-muted-foreground text-muted-foreground ml-auto px-1 py-0 text-xs",
|
||||
activeTabId === tab.id &&
|
||||
"border-primary-foreground text-primary-foreground",
|
||||
)}
|
||||
>
|
||||
{tab.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
<div className="min-w-0 flex-grow">
|
||||
<div
|
||||
id="settings-content-scrollable"
|
||||
className="size-full overflow-auto p-4"
|
||||
>
|
||||
{SETTINGS_TABS.map((tab) => (
|
||||
<TabsContent key={tab.id} value={tab.id}>
|
||||
<tab.component
|
||||
settings={mergedSettings}
|
||||
onChange={handleTabChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button className="w-24" type="submit" onClick={handleSave}>
|
||||
{tCommon('save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { BadgeInfo } from "lucide-react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
|
||||
import { Markdown } from "~/components/deepflow/markdown";
|
||||
|
||||
import aboutEn from "./about-en.md";
|
||||
import aboutZh from "./about-zh.md";
|
||||
import type { Tab } from "./types";
|
||||
|
||||
export const AboutTab: Tab = () => {
|
||||
const locale = useLocale();
|
||||
//const t = useTranslations("settings.about");
|
||||
|
||||
const aboutContent = locale === "zh" ? aboutZh : aboutEn;
|
||||
|
||||
return <Markdown>{aboutContent}</Markdown>;
|
||||
};
|
||||
AboutTab.icon = BadgeInfo;
|
||||
AboutTab.displayName = "About";
|
||||
@@ -0,0 +1,232 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Settings } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import type { SettingsState } from "~/core/store";
|
||||
|
||||
import type { Tab } from "./types";
|
||||
|
||||
const generalFormSchema = z.object({
|
||||
autoAcceptedPlan: z.boolean(),
|
||||
enableClarification: z.boolean(),
|
||||
maxClarificationRounds: z.number().min(1, {
|
||||
message: "Max clarification rounds must be at least 1.",
|
||||
}),
|
||||
maxPlanIterations: z.number().min(1, {
|
||||
message: "Max plan iterations must be at least 1.",
|
||||
}),
|
||||
maxStepNum: z.number().min(1, {
|
||||
message: "Max step number must be at least 1.",
|
||||
}),
|
||||
maxSearchResults: z.number().min(1, {
|
||||
message: "Max search results must be at least 1.",
|
||||
}),
|
||||
// Others
|
||||
enableBackgroundInvestigation: z.boolean(),
|
||||
enableDeepThinking: z.boolean(),
|
||||
reportStyle: z.enum(["academic", "popular_science", "news", "social_media","strategic_investment"]),
|
||||
});
|
||||
|
||||
export const GeneralTab: Tab = ({
|
||||
settings,
|
||||
onChange,
|
||||
}: {
|
||||
settings: SettingsState;
|
||||
onChange: (changes: Partial<SettingsState>) => void;
|
||||
}) => {
|
||||
const t = useTranslations("settings.general");
|
||||
const generalSettings = useMemo(() => settings.general, [settings]);
|
||||
const form = useForm<z.infer<typeof generalFormSchema>>({
|
||||
resolver: zodResolver(generalFormSchema, undefined, undefined),
|
||||
defaultValues: generalSettings,
|
||||
mode: "all",
|
||||
reValidateMode: "onBlur",
|
||||
});
|
||||
|
||||
const currentSettings = form.watch();
|
||||
useEffect(() => {
|
||||
let hasChanges = false;
|
||||
for (const key in currentSettings) {
|
||||
if (
|
||||
currentSettings[key as keyof typeof currentSettings] !==
|
||||
settings.general[key as keyof SettingsState["general"]]
|
||||
) {
|
||||
hasChanges = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasChanges) {
|
||||
onChange({ general: currentSettings });
|
||||
}
|
||||
}, [currentSettings, onChange, settings]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header>
|
||||
<h1 className="text-lg font-medium">{t("title")}</h1>
|
||||
</header>
|
||||
<main>
|
||||
<Form {...form}>
|
||||
<form className="space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="autoAcceptedPlan"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="autoAcceptedPlan"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<Label className="text-sm" htmlFor="autoAcceptedPlan">
|
||||
{t("autoAcceptPlan")}
|
||||
</Label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableClarification"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="enableClarification"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<Label className="text-sm" htmlFor="enableClarification">
|
||||
{t("enableClarification")} {field.value ? "(On)" : "(Off)"}
|
||||
</Label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.watch("enableClarification") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxClarificationRounds"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("maxClarificationRounds")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-60"
|
||||
type="number"
|
||||
defaultValue={field.value}
|
||||
min={1}
|
||||
onChange={(event) =>
|
||||
field.onChange(parseInt(event.target.value || "1"))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("maxClarificationRoundsDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxPlanIterations"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("maxPlanIterations")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-60"
|
||||
type="number"
|
||||
defaultValue={field.value}
|
||||
min={1}
|
||||
onChange={(event) =>
|
||||
field.onChange(parseInt(event.target.value || "0"))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("maxPlanIterationsDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxStepNum"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("maxStepsOfPlan")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-60"
|
||||
type="number"
|
||||
defaultValue={field.value}
|
||||
min={1}
|
||||
onChange={(event) =>
|
||||
field.onChange(parseInt(event.target.value || "0"))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t("maxStepsDescription")}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxSearchResults"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("maxSearchResults")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-60"
|
||||
type="number"
|
||||
defaultValue={field.value}
|
||||
min={1}
|
||||
onChange={(event) =>
|
||||
field.onChange(parseInt(event.target.value || "0"))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("maxSearchResultsDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
GeneralTab.displayName = "General";
|
||||
GeneralTab.icon = Settings;
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { Settings, type LucideIcon } from "lucide-react";
|
||||
|
||||
import { AboutTab } from "./about-tab";
|
||||
import { GeneralTab } from "./general-tab";
|
||||
import { MCPTab } from "./mcp-tab";
|
||||
|
||||
export const SETTINGS_TABS = [GeneralTab, MCPTab, AboutTab].map((tab) => {
|
||||
const name = tab.displayName ?? tab.name;
|
||||
return {
|
||||
...tab,
|
||||
id: name.replace(/Tab$/, "").toLocaleLowerCase(),
|
||||
label: name.replace(/Tab$/, ""),
|
||||
icon: (tab.icon ?? <Settings />) as LucideIcon,
|
||||
component: tab,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Blocks, Edit2, PencilRuler, RefreshCw, Trash } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { Tooltip } from "~/components/deepflow/tooltip";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { queryMCPServerMetadata } from "~/core/api";
|
||||
import type { MCPServerMetadata } from "~/core/mcp";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { AddMCPServerDialog } from "../dialogs/add-mcp-server-dialog";
|
||||
import { EditMCPServerDialog } from "../dialogs/edit-mcp-server-dialog";
|
||||
|
||||
import type { Tab } from "./types";
|
||||
|
||||
export const MCPTab: Tab = ({ settings, onChange }) => {
|
||||
const t = useTranslations("settings.mcp");
|
||||
const [servers, setServers] = useState<MCPServerMetadata[]>(
|
||||
settings.mcp.servers,
|
||||
);
|
||||
const [newlyAdded, setNewlyAdded] = useState(false);
|
||||
const [editingServer, setEditingServer] = useState<MCPServerMetadata | null>(null);
|
||||
const handleAddServers = useCallback(
|
||||
(servers: MCPServerMetadata[]) => {
|
||||
const merged = mergeServers(settings.mcp.servers, servers);
|
||||
setServers(merged);
|
||||
onChange({ ...settings, mcp: { ...settings.mcp, servers: merged } });
|
||||
setNewlyAdded(true);
|
||||
setTimeout(() => {
|
||||
setNewlyAdded(false);
|
||||
}, 1000);
|
||||
setTimeout(() => {
|
||||
document.getElementById("settings-content-scrollable")?.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, 100);
|
||||
},
|
||||
[onChange, settings],
|
||||
);
|
||||
const handleDeleteServer = useCallback(
|
||||
(name: string) => {
|
||||
const merged = settings.mcp.servers.filter(
|
||||
(server) => server.name !== name,
|
||||
);
|
||||
setServers(merged);
|
||||
onChange({ ...settings, mcp: { ...settings.mcp, servers: merged } });
|
||||
},
|
||||
[onChange, settings],
|
||||
);
|
||||
|
||||
const handleEditServer = useCallback(async (config: string) => {
|
||||
if (!editingServer) return false;
|
||||
|
||||
try {
|
||||
const parsedConfig = JSON.parse(config) as { mcpServers?: Record<string, MCPServerMetadata> };
|
||||
|
||||
if (!parsedConfig.mcpServers || typeof parsedConfig.mcpServers !== 'object') {
|
||||
console.error('Invalid configuration format: mcpServers not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
const serverEntries = Object.entries(parsedConfig.mcpServers);
|
||||
if (serverEntries.length === 0) {
|
||||
console.error('No server configuration found in mcpServers');
|
||||
return false;
|
||||
}
|
||||
|
||||
const firstEntry = serverEntries[0];
|
||||
if (!firstEntry) {
|
||||
console.error('Failed to get server configuration');
|
||||
return false;
|
||||
}
|
||||
|
||||
const [serverName, serverConfig] = firstEntry;
|
||||
|
||||
// Update the server configuration
|
||||
const updatedServers = settings.mcp.servers.map(server =>
|
||||
server.name === editingServer.name
|
||||
? {
|
||||
...server,
|
||||
...serverConfig,
|
||||
name: serverName, // Allow renaming the server
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: server
|
||||
);
|
||||
|
||||
setServers(updatedServers);
|
||||
onChange({ ...settings, mcp: { ...settings.mcp, servers: updatedServers } });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update server configuration:', error);
|
||||
return false;
|
||||
}
|
||||
}, [editingServer, onChange, settings]);
|
||||
|
||||
const handleRefreshServers = useCallback(async (serverName?: string) => {
|
||||
try {
|
||||
// Create a new array with the updated server
|
||||
const updatedServers = await Promise.all(
|
||||
settings.mcp.servers.map(async (server) => {
|
||||
// Skip if this is not the server we want to refresh
|
||||
if (serverName && server.name !== serverName) {
|
||||
return server;
|
||||
}
|
||||
|
||||
// Skip disabled servers unless explicitly requested
|
||||
if (!server.enabled && server.name !== serverName) {
|
||||
return server;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the latest metadata
|
||||
const metadata = await queryMCPServerMetadata(server);
|
||||
|
||||
// Create a new server object with preserved properties
|
||||
return {
|
||||
...server, // Keep all existing properties
|
||||
...metadata, // Apply metadata updates
|
||||
name: server.name, // Ensure name is preserved
|
||||
enabled: server.enabled, // Preserve the enabled state
|
||||
createdAt: server.createdAt, // Keep the original creation time
|
||||
updatedAt: Date.now(), // Update the last updated time
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh server ${server.name}:`, error);
|
||||
// Return the original server if refresh fails
|
||||
return server;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Update the servers list
|
||||
setServers(updatedServers);
|
||||
onChange({ ...settings, mcp: { ...settings.mcp, servers: updatedServers } });
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh MCP servers:', error);
|
||||
}
|
||||
}, [onChange, settings]);
|
||||
|
||||
const handleToggleServer = useCallback(
|
||||
async (name: string, enabled: boolean) => {
|
||||
const merged = settings.mcp.servers.map((server) =>
|
||||
server.name === name ? { ...server, enabled } : server,
|
||||
);
|
||||
setServers(merged);
|
||||
onChange({ ...settings, mcp: { ...settings.mcp, servers: merged } });
|
||||
|
||||
// Refresh server metadata when enabling a server
|
||||
if (enabled) {
|
||||
try {
|
||||
const server = merged.find(s => s.name === name);
|
||||
if (server) {
|
||||
const metadata = await queryMCPServerMetadata(server);
|
||||
const updatedServers = merged.map(s =>
|
||||
s.name === name
|
||||
? {
|
||||
...s,
|
||||
...metadata,
|
||||
name: s.name,
|
||||
enabled: true,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: s
|
||||
);
|
||||
setServers(updatedServers);
|
||||
onChange({ ...settings, mcp: { ...settings.mcp, servers: updatedServers } });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh server ${name}:`, error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onChange, settings],
|
||||
);
|
||||
|
||||
const animationProps = {
|
||||
initial: { backgroundColor: "gray" },
|
||||
animate: { backgroundColor: "transparent" },
|
||||
transition: { duration: 1 },
|
||||
style: {
|
||||
transition: "background-color 1s ease-out",
|
||||
},
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-lg font-medium">{t("title")}</h1>
|
||||
<AddMCPServerDialog onAdd={handleAddServers} />
|
||||
</div>
|
||||
<div className="text-muted-foreground markdown text-sm">
|
||||
{t("description")}
|
||||
<a
|
||||
className="ml-1"
|
||||
target="_blank"
|
||||
href="https://modelcontextprotocol.io/"
|
||||
>
|
||||
{t("learnMore")}
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<ul id="mcp-servers-list" className="flex flex-col gap-4">
|
||||
{servers.map((server) => {
|
||||
const isNew =
|
||||
server.createdAt &&
|
||||
server.createdAt > Date.now() - 1000 * 60 * 60 * 1;
|
||||
return (
|
||||
<motion.li
|
||||
className={
|
||||
"!bg-card group relative overflow-hidden rounded-lg border pb-2 shadow duration-300"
|
||||
}
|
||||
key={server.name}
|
||||
{...(isNew && newlyAdded && animationProps)}
|
||||
>
|
||||
<div className="absolute top-3 right-2">
|
||||
<Tooltip title={t("enableDisable")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="airplane-mode"
|
||||
checked={server.enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
void handleToggleServer(server.name, checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="absolute top-1 right-12 flex gap-1 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<Tooltip title={t("editServer")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingServer(server);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("refreshServer")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleRefreshServers(server.name);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("deleteServer")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteServer(server.name);
|
||||
}}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start px-4 py-2",
|
||||
!server.enabled && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mb-2 flex items-center gap-2",
|
||||
!server.enabled && "opacity-70",
|
||||
)}
|
||||
>
|
||||
<div className="text-lg font-medium">{server.name}</div>
|
||||
{!server.enabled && (
|
||||
<div className="bg-primary text-primary-foreground h-fit rounded px-1.5 py-0.5 text-xs">
|
||||
{t("disabled")}
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-primary text-primary-foreground h-fit rounded px-1.5 py-0.5 text-xs">
|
||||
{server.transport}
|
||||
</div>
|
||||
{isNew && (
|
||||
<div className="bg-primary text-primary-foreground h-fit rounded px-1.5 py-0.5 text-xs">
|
||||
{t("new")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ul
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-2",
|
||||
!server.enabled && "opacity-70",
|
||||
)}
|
||||
>
|
||||
<PencilRuler size={16} />
|
||||
{server.tools.map((tool) => (
|
||||
<li
|
||||
key={tool.name}
|
||||
className="text-muted-foreground border-muted-foreground w-fit rounded-md border px-2"
|
||||
>
|
||||
<Tooltip key={tool.name} title={tool.description}>
|
||||
<div className="w-fit text-sm">{tool.name}</div>
|
||||
</Tooltip>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</motion.li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</main>
|
||||
{editingServer && (
|
||||
<EditMCPServerDialog
|
||||
server={editingServer}
|
||||
open={!!editingServer}
|
||||
onOpenChange={(open) => !open && setEditingServer(null)}
|
||||
onSave={async (config) => {
|
||||
const success = await handleEditServer(config);
|
||||
if (success) {
|
||||
setEditingServer(null);
|
||||
}
|
||||
return success;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
MCPTab.icon = Blocks;
|
||||
MCPTab.displayName = "MCP";
|
||||
MCPTab.badge = "Beta";
|
||||
MCPTab.displayName = "MCP";
|
||||
|
||||
function mergeServers(
|
||||
existing: MCPServerMetadata[],
|
||||
added: MCPServerMetadata[],
|
||||
): MCPServerMetadata[] {
|
||||
const serverMap = new Map(existing.map((server) => [server.name, server]));
|
||||
|
||||
for (const addedServer of added) {
|
||||
addedServer.createdAt = Date.now();
|
||||
addedServer.updatedAt = Date.now();
|
||||
serverMap.set(addedServer.name, addedServer);
|
||||
}
|
||||
|
||||
const result = Array.from(serverMap.values());
|
||||
result.sort((a, b) => b.createdAt - a.createdAt);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { FunctionComponent } from "react";
|
||||
|
||||
import type { SettingsState } from "~/core/store";
|
||||
|
||||
export type Tab = FunctionComponent<{
|
||||
settings: SettingsState;
|
||||
onChange: (changes: Partial<SettingsState>) => void;
|
||||
}> & {
|
||||
displayName?: string;
|
||||
icon?: LucideIcon;
|
||||
badge?: string;
|
||||
};
|
||||
Reference in New Issue
Block a user