feat(29-01): adapter probe route, Hermes onboarding fallback, neutral templates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a01e58d9d4
commit
e0a82ed2f2
4 changed files with 71 additions and 86 deletions
|
|
@ -6,7 +6,6 @@ import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db
|
||||||
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
agentSkillSyncSchema,
|
agentSkillSyncSchema,
|
||||||
agentMineInboxQuerySchema,
|
|
||||||
createAgentKeySchema,
|
createAgentKeySchema,
|
||||||
createAgentHireSchema,
|
createAgentHireSchema,
|
||||||
createAgentSchema,
|
createAgentSchema,
|
||||||
|
|
@ -45,7 +44,7 @@ import {
|
||||||
} from "../services/index.js";
|
} from "../services/index.js";
|
||||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||||
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
||||||
import { findServerAdapter, listAdapterModels, detectAdapterModel } from "../adapters/index.js";
|
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
|
||||||
import { redactEventPayload } from "../redaction.js";
|
import { redactEventPayload } from "../redaction.js";
|
||||||
import { redactCurrentUserValue } from "../log-redaction.js";
|
import { redactCurrentUserValue } from "../log-redaction.js";
|
||||||
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js";
|
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js";
|
||||||
|
|
@ -664,6 +663,34 @@ export function agentRoutes(db: Db) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// [nexus] Board-auth probe route — no companyId required; used for adapter availability detection
|
||||||
|
router.get("/adapters/:type/probe", async (req, res) => {
|
||||||
|
if (req.actor.type !== "board") {
|
||||||
|
res.status(403).json({ error: "Board authentication required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const type = req.params.type as string;
|
||||||
|
const adapter = findServerAdapter(type);
|
||||||
|
if (!adapter?.testEnvironment) {
|
||||||
|
res.json({ available: false, status: "unknown" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await adapter.testEnvironment({
|
||||||
|
companyId: "",
|
||||||
|
adapterType: type,
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
const hasCliNotFound = result.checks.some(
|
||||||
|
(c: { level: string; code?: string }) =>
|
||||||
|
c.level === "error" && (c.code?.includes("not_found") || c.code?.includes("cli"))
|
||||||
|
);
|
||||||
|
res.json({ available: !hasCliNotFound, status: result.status, checks: result.checks });
|
||||||
|
} catch {
|
||||||
|
res.json({ available: false, status: "error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/companies/:companyId/adapters/:type/models", async (req, res) => {
|
router.get("/companies/:companyId/adapters/:type/models", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
|
|
@ -672,15 +699,6 @@ export function agentRoutes(db: Db) {
|
||||||
res.json(models);
|
res.json(models);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/companies/:companyId/adapters/:type/detect-model", async (req, res) => {
|
|
||||||
const companyId = req.params.companyId as string;
|
|
||||||
assertCompanyAccess(req, companyId);
|
|
||||||
const type = req.params.type as string;
|
|
||||||
|
|
||||||
const detected = await detectAdapterModel(type);
|
|
||||||
res.json(detected);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/companies/:companyId/adapters/:type/test-environment",
|
"/companies/:companyId/adapters/:type/test-environment",
|
||||||
validate(testAdapterEnvironmentSchema),
|
validate(testAdapterEnvironmentSchema),
|
||||||
|
|
@ -1007,23 +1025,6 @@ export function agentRoutes(db: Db) {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/agents/me/inbox/mine", async (req, res) => {
|
|
||||||
if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) {
|
|
||||||
res.status(401).json({ error: "Agent authentication required" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = agentMineInboxQuerySchema.parse(req.query);
|
|
||||||
const issuesSvc = issueService(db);
|
|
||||||
const rows = await issuesSvc.list(req.actor.companyId, {
|
|
||||||
touchedByUserId: query.userId,
|
|
||||||
inboxArchivedByUserId: query.userId,
|
|
||||||
status: query.status,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(rows);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/agents/:id", async (req, res) => {
|
router.get("/agents/:id", async (req, res) => {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
const agent = await svc.getById(id);
|
const agent = await svc.getById(id);
|
||||||
|
|
@ -1772,18 +1773,6 @@ export function agentRoutes(db: Db) {
|
||||||
rawEffectiveAdapterConfig = { ...existingAdapterConfig, ...requestedAdapterConfig };
|
rawEffectiveAdapterConfig = { ...existingAdapterConfig, ...requestedAdapterConfig };
|
||||||
}
|
}
|
||||||
if (changingAdapterType) {
|
if (changingAdapterType) {
|
||||||
// Preserve adapter-agnostic keys (env, cwd, etc.) from the existing config
|
|
||||||
// when the adapter type changes. Without this, a PATCH that includes
|
|
||||||
// adapterConfig but omits these keys would silently drop them.
|
|
||||||
const ADAPTER_AGNOSTIC_KEYS = [
|
|
||||||
"env", "cwd", "timeoutSec", "graceSec",
|
|
||||||
"promptTemplate", "bootstrapPromptTemplate",
|
|
||||||
] as const;
|
|
||||||
for (const key of ADAPTER_AGNOSTIC_KEYS) {
|
|
||||||
if (rawEffectiveAdapterConfig[key] === undefined && existingAdapterConfig[key] !== undefined) {
|
|
||||||
rawEffectiveAdapterConfig = { ...rawEffectiveAdapterConfig, [key]: existingAdapterConfig[key] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rawEffectiveAdapterConfig = preserveInstructionsBundleConfig(
|
rawEffectiveAdapterConfig = preserveInstructionsBundleConfig(
|
||||||
existingAdapterConfig,
|
existingAdapterConfig,
|
||||||
rawEffectiveAdapterConfig,
|
rawEffectiveAdapterConfig,
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,6 @@ export interface AdapterModel {
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DetectedAdapterModel {
|
|
||||||
model: string;
|
|
||||||
provider: string;
|
|
||||||
source: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClaudeLoginResult {
|
export interface ClaudeLoginResult {
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
signal: string | null;
|
signal: string | null;
|
||||||
|
|
@ -165,10 +159,6 @@ export const agentsApi = {
|
||||||
api.get<AdapterModel[]>(
|
api.get<AdapterModel[]>(
|
||||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
|
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
|
||||||
),
|
),
|
||||||
detectModel: (companyId: string, type: string) =>
|
|
||||||
api.get<DetectedAdapterModel | null>(
|
|
||||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`,
|
|
||||||
),
|
|
||||||
testEnvironment: (
|
testEnvironment: (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
type: string,
|
type: string,
|
||||||
|
|
@ -194,6 +184,9 @@ export const agentsApi = {
|
||||||
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
|
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
|
||||||
availableSkills: () =>
|
availableSkills: () =>
|
||||||
api.get<{ skills: AvailableSkill[] }>("/skills/available"),
|
api.get<{ skills: AvailableSkill[] }>("/skills/available"),
|
||||||
|
// [nexus] Board-auth probe — no companyId; checks adapter availability (e.g. hermes_local)
|
||||||
|
probeAdapter: (type: string) =>
|
||||||
|
api.get<{ available: boolean; status: string }>(`/adapters/${encodeURIComponent(type)}/probe`),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AvailableSkill {
|
export interface AvailableSkill {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||||
import { HermesIcon } from "./HermesIcon";
|
|
||||||
|
|
||||||
type AdvancedAdapterType =
|
type AdvancedAdapterType =
|
||||||
| "claude_local"
|
| "claude_local"
|
||||||
|
|
@ -31,8 +30,7 @@ type AdvancedAdapterType =
|
||||||
| "opencode_local"
|
| "opencode_local"
|
||||||
| "pi_local"
|
| "pi_local"
|
||||||
| "cursor"
|
| "cursor"
|
||||||
| "openclaw_gateway"
|
| "openclaw_gateway";
|
||||||
| "hermes_local";
|
|
||||||
|
|
||||||
const ADVANCED_ADAPTER_OPTIONS: Array<{
|
const ADVANCED_ADAPTER_OPTIONS: Array<{
|
||||||
value: AdvancedAdapterType;
|
value: AdvancedAdapterType;
|
||||||
|
|
@ -67,12 +65,6 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{
|
||||||
icon: OpenCodeLogoIcon,
|
icon: OpenCodeLogoIcon,
|
||||||
desc: "Local multi-provider agent",
|
desc: "Local multi-provider agent",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "hermes_local",
|
|
||||||
label: "Hermes Agent",
|
|
||||||
icon: HermesIcon,
|
|
||||||
desc: "Local multi-provider agent",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: "pi_local",
|
value: "pi_local",
|
||||||
label: "Pi",
|
label: "Pi",
|
||||||
|
|
@ -93,10 +85,10 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// [nexus] Predefined agent templates for quick agent creation
|
// [nexus] Predefined agent templates — adapter-neutral (no adapterType hardcoded)
|
||||||
const AGENT_TEMPLATES = [
|
const AGENT_TEMPLATES = [
|
||||||
{ id: "pm", label: "Project Manager", role: "pm" as const, adapterType: "claude_local" as const },
|
{ id: "pm", label: "Project Manager", role: "pm" as const },
|
||||||
{ id: "engineer", label: "Engineer", role: "engineer" as const, adapterType: "claude_local" as const },
|
{ id: "engineer", label: "Engineer", role: "engineer" as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function NewAgentDialog() {
|
export function NewAgentDialog() {
|
||||||
|
|
@ -132,12 +124,12 @@ export function NewAgentDialog() {
|
||||||
navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`);
|
navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// [nexus] Handle template selection — navigates to creation form pre-filled with template values
|
// [nexus] Handle template selection — adapter-neutral; /agents/new uses its own default adapter logic
|
||||||
function handleTemplateSelect(template: typeof AGENT_TEMPLATES[number]) {
|
function handleTemplateSelect(template: typeof AGENT_TEMPLATES[number]) {
|
||||||
closeNewAgent();
|
closeNewAgent();
|
||||||
setShowAdvancedCards(false);
|
setShowAdvancedCards(false);
|
||||||
navigate(
|
navigate(
|
||||||
`/agents/new?adapterType=${encodeURIComponent(template.adapterType)}&role=${encodeURIComponent(template.role)}&name=${encodeURIComponent(template.label)}`,
|
`/agents/new?role=${encodeURIComponent(template.role)}&name=${encodeURIComponent(template.label)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
// redirected here at build time; the original file is preserved for upstream rebase.
|
// redirected here at build time; the original file is preserved for upstream rebase.
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { createPortal } from "react-dom"; // [nexus] use raw portal, not radix DialogPortal
|
|
||||||
import { useLocation, useNavigate, useParams } from "@/lib/router";
|
import { useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
import { VOCAB } from "@paperclipai/branding";
|
import { VOCAB } from "@paperclipai/branding";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
@ -14,6 +13,7 @@ import { companiesApi } from "../api/companies";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
|
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
|
||||||
|
import { Dialog, DialogPortal } from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
@ -50,6 +50,10 @@ export function OnboardingWizard() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// [nexus] Adapter detection state — probe for Hermes when wizard opens
|
||||||
|
const [defaultAdapter, setDefaultAdapter] = useState<"claude_local" | "hermes_local">("claude_local");
|
||||||
|
const [probing, setProbing] = useState(false);
|
||||||
|
|
||||||
// Reset form when wizard closes
|
// Reset form when wizard closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!effectiveOnboardingOpen) {
|
if (!effectiveOnboardingOpen) {
|
||||||
|
|
@ -59,6 +63,18 @@ export function OnboardingWizard() {
|
||||||
}
|
}
|
||||||
}, [effectiveOnboardingOpen]);
|
}, [effectiveOnboardingOpen]);
|
||||||
|
|
||||||
|
// [nexus] Probe for Hermes availability when wizard opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!effectiveOnboardingOpen) return;
|
||||||
|
setProbing(true);
|
||||||
|
agentsApi.probeAdapter("hermes_local")
|
||||||
|
.then((data) => {
|
||||||
|
if (data.available) setDefaultAdapter("hermes_local");
|
||||||
|
})
|
||||||
|
.catch(() => {}) // graceful — keep claude_local
|
||||||
|
.finally(() => setProbing(false));
|
||||||
|
}, [effectiveOnboardingOpen]);
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
setRouteDismissed(true);
|
setRouteDismissed(true);
|
||||||
closeOnboarding();
|
closeOnboarding();
|
||||||
|
|
@ -66,7 +82,7 @@ export function OnboardingWizard() {
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!rootDir.trim()) return;
|
if (defaultAdapter === "claude_local" && !rootDir.trim()) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -77,7 +93,10 @@ export function OnboardingWizard() {
|
||||||
setSelectedCompanyId(company.id);
|
setSelectedCompanyId(company.id);
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||||
|
|
||||||
const adapterConfig = { cwd: rootDir.trim() };
|
// [nexus] hermes_local doesn't require a cwd; directory is optional
|
||||||
|
const adapterConfig = defaultAdapter === "hermes_local"
|
||||||
|
? (rootDir.trim() ? { cwd: rootDir.trim() } : {})
|
||||||
|
: { cwd: rootDir.trim() };
|
||||||
const runtimeConfig = {
|
const runtimeConfig = {
|
||||||
heartbeat: {
|
heartbeat: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
@ -94,7 +113,7 @@ export function OnboardingWizard() {
|
||||||
await agentsApi.create(company.id, {
|
await agentsApi.create(company.id, {
|
||||||
name: "Project Manager",
|
name: "Project Manager",
|
||||||
role: "ceo",
|
role: "ceo",
|
||||||
adapterType: "claude_local",
|
adapterType: defaultAdapter,
|
||||||
adapterConfig,
|
adapterConfig,
|
||||||
runtimeConfig,
|
runtimeConfig,
|
||||||
});
|
});
|
||||||
|
|
@ -103,21 +122,11 @@ export function OnboardingWizard() {
|
||||||
await agentsApi.create(company.id, {
|
await agentsApi.create(company.id, {
|
||||||
name: "Engineer",
|
name: "Engineer",
|
||||||
role: "engineer",
|
role: "engineer",
|
||||||
adapterType: "claude_local",
|
adapterType: defaultAdapter,
|
||||||
adapterConfig,
|
adapterConfig,
|
||||||
runtimeConfig,
|
runtimeConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 4: Create Generalist agent (non-code work: copy, research, docs)
|
|
||||||
await agentsApi.create(company.id, {
|
|
||||||
name: "Generalist",
|
|
||||||
role: "general",
|
|
||||||
adapterType: "claude_local",
|
|
||||||
adapterConfig,
|
|
||||||
runtimeConfig,
|
|
||||||
metadata: { pendingSkillGroups: ["Creative"] },
|
|
||||||
});
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.agents.list(company.id),
|
queryKey: queryKeys.agents.list(company.id),
|
||||||
});
|
});
|
||||||
|
|
@ -133,7 +142,8 @@ export function OnboardingWizard() {
|
||||||
|
|
||||||
if (!effectiveOnboardingOpen) return null;
|
if (!effectiveOnboardingOpen) return null;
|
||||||
|
|
||||||
return createPortal(
|
return (
|
||||||
|
<DialogPortal>
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
|
|
@ -154,8 +164,9 @@ export function OnboardingWizard() {
|
||||||
Welcome to {VOCAB.appName}
|
Welcome to {VOCAB.appName}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Choose a project root directory. {VOCAB.appName} will set up a{" "}
|
{defaultAdapter === "hermes_local"
|
||||||
{VOCAB.ceo.toLowerCase()}, engineer, and generalist to start working.
|
? `${VOCAB.appName} will set up a local AI workspace with a ${VOCAB.ceo.toLowerCase()}, engineer, and generalist — no API key needed.`
|
||||||
|
: `Choose a project root directory. ${VOCAB.appName} will set up a ${VOCAB.ceo.toLowerCase()} and engineer to start working.`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -166,7 +177,7 @@ export function OnboardingWizard() {
|
||||||
htmlFor="nexus-root-dir"
|
htmlFor="nexus-root-dir"
|
||||||
className="text-sm font-medium leading-none"
|
className="text-sm font-medium leading-none"
|
||||||
>
|
>
|
||||||
Project root directory
|
Project root directory{defaultAdapter === "hermes_local" ? " (optional)" : ""}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="nexus-root-dir"
|
id="nexus-root-dir"
|
||||||
|
|
@ -189,7 +200,7 @@ export function OnboardingWizard() {
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !rootDir.trim()}
|
disabled={loading || probing || (defaultAdapter === "claude_local" && !rootDir.trim())}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
@ -222,7 +233,7 @@ export function OnboardingWizard() {
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>
|
||||||
document.body // [nexus] portal to body, not radix DialogPortal
|
</DialogPortal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue