feat(skills): 自我进化前端 — Skills 独立侧边栏 + /skills/evolution 页面
UI 重构:
- settings-dialog.tsx:把 Skills section 从设置弹窗移除
- workspace-nav-chat-list.tsx + WorkspaceRoutes.tsx + SkillsPage.tsx:
Skills 提升为独立侧边栏导航项,/workspace/skills 路由直接渲染
SkillSettingsPage(同事原型已就绪)
- skill-settings-page.tsx:顶部追加 "自我进化" 跳转按钮
自我进化页面:
- /workspace/skills/evolution 子路由 → SkillEvolutionPage 包装
- skill-evolution-panel.tsx:三段式
· 状态卡:启用/暂停徽章 + 累计运行次数 + 上次运行时间/摘要 +
技能计数 (total/active/stale/archived) + 立即执行按钮
· 配置表单:enabled / curator.enabled / interval_hours /
stale_after_days / archive_after_days / moderation_model_name
本地暂存 → 保存调 PUT,热生效
· 日志面板:拉 GET /api/curator/logs,2 秒自动刷新,monospace
展示,warning 黄、error 红
- core/curator/api.ts + hooks.ts + types.ts:TanStack Query 包装
status / config (GET/PUT) / run / logs
i18n:
- types.ts 新增 settings.skillEvolution.* 类型签名
- zh-CN.ts / en-US.ts:状态卡 / 配置表单 / 日志面板文案,
以及 settings.skills.goToEvolution
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9b6b94d919
commit
5c6ac06656
13 files changed
+826
-12
No files matched your search
@@ -5,7 +5,6 @@ import {
|
|||||||
InfoIcon,
|
InfoIcon,
|
||||||
BrainIcon,
|
BrainIcon,
|
||||||
PaletteIcon,
|
PaletteIcon,
|
||||||
SparklesIcon,
|
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
@@ -21,7 +20,6 @@ import { AboutSettingsPage } from "@/components/workspace/settings/about-setting
|
|||||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||||
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
|
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
|
||||||
import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page";
|
import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page";
|
||||||
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
|
|
||||||
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -30,7 +28,6 @@ type SettingsSection =
|
|||||||
| "appearance"
|
| "appearance"
|
||||||
| "memory"
|
| "memory"
|
||||||
| "tools"
|
| "tools"
|
||||||
| "skills"
|
|
||||||
| "notification"
|
| "notification"
|
||||||
| "about";
|
| "about";
|
||||||
|
|
||||||
@@ -70,14 +67,12 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
icon: BrainIcon,
|
icon: BrainIcon,
|
||||||
},
|
},
|
||||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||||
{ id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
|
||||||
// { id: "about", label: t.settings.sections.about, icon: InfoIcon },
|
// { id: "about", label: t.settings.sections.about, icon: InfoIcon },
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
t.settings.sections.appearance,
|
t.settings.sections.appearance,
|
||||||
t.settings.sections.memory,
|
t.settings.sections.memory,
|
||||||
t.settings.sections.tools,
|
t.settings.sections.tools,
|
||||||
t.settings.sections.skills,
|
|
||||||
t.settings.sections.notification,
|
t.settings.sections.notification,
|
||||||
t.settings.sections.about,
|
t.settings.sections.about,
|
||||||
],
|
],
|
||||||
@@ -127,11 +122,6 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
||||||
{activeSection === "memory" && <MemorySettingsPage />}
|
{activeSection === "memory" && <MemorySettingsPage />}
|
||||||
{activeSection === "tools" && <ToolSettingsPage />}
|
{activeSection === "tools" && <ToolSettingsPage />}
|
||||||
{activeSection === "skills" && (
|
|
||||||
<SkillSettingsPage
|
|
||||||
onClose={() => props.onOpenChange?.(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeSection === "notification" && <NotificationSettingsPage />}
|
{activeSection === "notification" && <NotificationSettingsPage />}
|
||||||
{activeSection === "about" && <AboutSettingsPage />}
|
{activeSection === "about" && <AboutSettingsPage />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertCircle, CheckCircle2, FileArchive, Loader2, SparklesIcon, Upload } from "lucide-react";
|
import { AlertCircle, CheckCircle2, FileArchive, Loader2, SparklesIcon, TrendingUpIcon, Upload } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -96,6 +96,17 @@ function SkillSettingsList({
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
onClose?.();
|
||||||
|
router.push("/workspace/skills/evolution");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrendingUpIcon className="size-4" />
|
||||||
|
{t.settings.skills.goToEvolution}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -0,0 +1,454 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
Loader2Icon,
|
||||||
|
PlayIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
SaveIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
useCuratorConfig,
|
||||||
|
useCuratorLogs,
|
||||||
|
useCuratorStatus,
|
||||||
|
useRunCurator,
|
||||||
|
useUpdateCuratorConfig,
|
||||||
|
} from "@/core/curator/hooks";
|
||||||
|
import type { CuratorConfig } from "@/core/curator/types";
|
||||||
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function SkillEvolutionPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const tt = t.settings.skillEvolution;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
|
||||||
|
<header className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{tt.title}</h1>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
{tt.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push("/workspace/skills")}
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="size-4" />
|
||||||
|
{tt.backToSkills}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<StatusCard />
|
||||||
|
<ConfigCard />
|
||||||
|
<LogsCard />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusCard() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const tt = t.settings.skillEvolution.status;
|
||||||
|
const { status, isLoading } = useCuratorStatus();
|
||||||
|
const runCurator = useRunCurator();
|
||||||
|
|
||||||
|
const lastRunDisplay = useMemo(() => {
|
||||||
|
if (!status?.last_run_at) return tt.lastRunNever;
|
||||||
|
try {
|
||||||
|
return new Date(status.last_run_at).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return status.last_run_at;
|
||||||
|
}
|
||||||
|
}, [status?.last_run_at, tt.lastRunNever]);
|
||||||
|
|
||||||
|
const handleRun = async () => {
|
||||||
|
try {
|
||||||
|
await runCurator.mutateAsync();
|
||||||
|
toast.success(tt.runStarted);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{tt.title}</CardTitle>
|
||||||
|
{status && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<Badge variant={status.enabled ? "default" : "secondary"}>
|
||||||
|
{status.enabled ? tt.enabled : tt.disabled}
|
||||||
|
</Badge>
|
||||||
|
{status.paused && (
|
||||||
|
<Badge variant="outline">{tt.paused}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRun}
|
||||||
|
disabled={runCurator.isPending}
|
||||||
|
>
|
||||||
|
{runCurator.isPending ? (
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<PlayIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
{tt.runNow}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t.common.loading}
|
||||||
|
</div>
|
||||||
|
) : status ? (
|
||||||
|
<div className="grid gap-3 text-sm sm:grid-cols-2">
|
||||||
|
<StatRow label={tt.runCount}>
|
||||||
|
{status.run_count}
|
||||||
|
{tt.runCountUnit && (
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
{tt.runCountUnit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</StatRow>
|
||||||
|
<StatRow label={tt.lastRunAt}>{lastRunDisplay}</StatRow>
|
||||||
|
<StatRow label={tt.lastRunSummary} className="sm:col-span-2">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{status.last_run_summary ?? tt.lastRunSummaryEmpty}
|
||||||
|
</span>
|
||||||
|
</StatRow>
|
||||||
|
<StatRow label={tt.skillCounts} className="sm:col-span-2">
|
||||||
|
<span className="text-muted-foreground inline-flex flex-wrap gap-3">
|
||||||
|
<span>
|
||||||
|
{tt.total}: {status.agent_created_skills.total}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{tt.active}: {status.agent_created_skills.active}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{tt.stale}: {status.agent_created_skills.stale}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{tt.archived}: {status.agent_created_skills.archived}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</StatRow>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatRow({
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center gap-2", className)}>
|
||||||
|
<span className="text-muted-foreground min-w-[6rem]">{label}</span>
|
||||||
|
<span className="font-medium">{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigCard() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const tt = t.settings.skillEvolution.config;
|
||||||
|
const { config, isLoading } = useCuratorConfig();
|
||||||
|
const updateConfig = useUpdateCuratorConfig();
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState<CuratorConfig | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (config && !draft) {
|
||||||
|
setDraft(config);
|
||||||
|
}
|
||||||
|
}, [config, draft]);
|
||||||
|
|
||||||
|
if (isLoading || !draft) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{tt.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t.common.loading}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirty =
|
||||||
|
!!config &&
|
||||||
|
JSON.stringify(draft) !== JSON.stringify(config);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
const updated = await updateConfig.mutateAsync(draft);
|
||||||
|
setDraft(updated);
|
||||||
|
toast.success(tt.saveSuccess);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{tt.title}</CardTitle>
|
||||||
|
<CardDescription>{tt.description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<FieldRow
|
||||||
|
label={tt.enabledLabel}
|
||||||
|
helper={tt.enabledHelper}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={draft.enabled}
|
||||||
|
onCheckedChange={(v) => setDraft({ ...draft, enabled: v })}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow
|
||||||
|
label={tt.curatorEnabledLabel}
|
||||||
|
helper={tt.curatorEnabledHelper}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={draft.curator.enabled}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
curator: { ...draft.curator, enabled: v },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow
|
||||||
|
label={tt.intervalHoursLabel}
|
||||||
|
helper={tt.intervalHoursHelper}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
value={draft.curator.interval_hours}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
curator: {
|
||||||
|
...draft.curator,
|
||||||
|
interval_hours: Math.max(1, Number(e.target.value) || 1),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow
|
||||||
|
label={tt.staleAfterDaysLabel}
|
||||||
|
helper={tt.staleAfterDaysHelper}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
value={draft.curator.stale_after_days}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
curator: {
|
||||||
|
...draft.curator,
|
||||||
|
stale_after_days: Math.max(1, Number(e.target.value) || 1),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow
|
||||||
|
label={tt.archiveAfterDaysLabel}
|
||||||
|
helper={tt.archiveAfterDaysHelper}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
value={draft.curator.archive_after_days}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
curator: {
|
||||||
|
...draft.curator,
|
||||||
|
archive_after_days: Math.max(1, Number(e.target.value) || 1),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow
|
||||||
|
label={tt.moderationModelLabel}
|
||||||
|
helper={tt.moderationModelHelper}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
className="max-w-[20rem]"
|
||||||
|
value={draft.moderation_model_name ?? ""}
|
||||||
|
placeholder=""
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
moderation_model_name:
|
||||||
|
e.target.value.trim() === "" ? null : e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!isDirty || updateConfig.isPending}
|
||||||
|
onClick={() => config && setDraft(config)}
|
||||||
|
>
|
||||||
|
{tt.reset}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!isDirty || updateConfig.isPending}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
{updateConfig.isPending ? (
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<SaveIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
{tt.save}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldRow({
|
||||||
|
label,
|
||||||
|
helper,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
helper?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b pb-4 last:border-b-0 last:pb-0">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium">{label}</div>
|
||||||
|
{helper && (
|
||||||
|
<div className="text-muted-foreground mt-1 text-xs">{helper}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogsCard() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const tt = t.settings.skillEvolution.logs;
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
|
const { logs, isLoading, refetch } = useCuratorLogs({ autoRefresh });
|
||||||
|
|
||||||
|
const records = logs?.records ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{tt.title}</CardTitle>
|
||||||
|
<CardDescription>{tt.description}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||||
|
<Switch
|
||||||
|
checked={autoRefresh}
|
||||||
|
onCheckedChange={setAutoRefresh}
|
||||||
|
/>
|
||||||
|
{tt.autoRefresh}
|
||||||
|
</label>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => void refetch()}>
|
||||||
|
<RefreshCwIcon className="size-3.5" />
|
||||||
|
{tt.refresh}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading && records.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t.common.loading}
|
||||||
|
</div>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground text-sm">{tt.empty}</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-muted/30 max-h-[420px] overflow-auto rounded-md border p-3 font-mono text-xs leading-relaxed">
|
||||||
|
{records.map((record, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${record.ts}-${idx}`}
|
||||||
|
className={cn(
|
||||||
|
"flex gap-3",
|
||||||
|
record.level === "warning" && "text-yellow-700 dark:text-yellow-300",
|
||||||
|
record.level === "error" && "text-red-600 dark:text-red-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground shrink-0">
|
||||||
|
{formatTime(record.ts)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground w-14 shrink-0 uppercase">
|
||||||
|
{record.level}
|
||||||
|
</span>
|
||||||
|
<span className="whitespace-pre-wrap break-all">
|
||||||
|
{record.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(ts);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
} catch {
|
||||||
|
return ts;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare } from "lucide-react";
|
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
@@ -48,6 +48,17 @@ export function WorkspaceNavChatList() {
|
|||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={pathname.startsWith("/page/workspace/skills")}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link className="text-sidebar-foreground" href="/page/workspace/skills">
|
||||||
|
<SparklesIcon />
|
||||||
|
<span>技能</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { apiFetch } from "@/core/api";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CuratorConfig,
|
||||||
|
CuratorLogs,
|
||||||
|
CuratorStatus,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export async function getCuratorStatus(): Promise<CuratorStatus> {
|
||||||
|
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/status`);
|
||||||
|
return response.json() as Promise<CuratorStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCuratorConfig(): Promise<CuratorConfig> {
|
||||||
|
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`);
|
||||||
|
return response.json() as Promise<CuratorConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCuratorConfig(
|
||||||
|
payload: CuratorConfig,
|
||||||
|
): Promise<CuratorConfig> {
|
||||||
|
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
return response.json() as Promise<CuratorConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCurator(): Promise<{ triggered: boolean; message: string }> {
|
||||||
|
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/run`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCuratorLogs(): Promise<CuratorLogs> {
|
||||||
|
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/logs`);
|
||||||
|
return response.json() as Promise<CuratorLogs>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getCuratorConfig,
|
||||||
|
getCuratorLogs,
|
||||||
|
getCuratorStatus,
|
||||||
|
runCurator,
|
||||||
|
updateCuratorConfig,
|
||||||
|
} from "./api";
|
||||||
|
import type { CuratorConfig } from "./types";
|
||||||
|
|
||||||
|
const STATUS_KEY = ["curator", "status"] as const;
|
||||||
|
const CONFIG_KEY = ["curator", "config"] as const;
|
||||||
|
const LOGS_KEY = ["curator", "logs"] as const;
|
||||||
|
|
||||||
|
export function useCuratorStatus() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: STATUS_KEY,
|
||||||
|
queryFn: getCuratorStatus,
|
||||||
|
});
|
||||||
|
return { status: query.data, isLoading: query.isLoading, error: query.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCuratorConfig() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: CONFIG_KEY,
|
||||||
|
queryFn: getCuratorConfig,
|
||||||
|
});
|
||||||
|
return { config: query.data, isLoading: query.isLoading, error: query.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateCuratorConfig() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: CuratorConfig) => updateCuratorConfig(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: CONFIG_KEY });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: STATUS_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRunCurator() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => runCurator(),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Logs start from scratch on a new run — refresh so the UI clears
|
||||||
|
// immediately and then picks up the new run_started record.
|
||||||
|
void queryClient.invalidateQueries({ queryKey: LOGS_KEY });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: STATUS_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCuratorLogs(options?: { autoRefresh?: boolean }) {
|
||||||
|
const autoRefresh = options?.autoRefresh ?? true;
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: LOGS_KEY,
|
||||||
|
queryFn: getCuratorLogs,
|
||||||
|
refetchInterval: autoRefresh ? 2000 : false,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
logs: query.data,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
error: query.error,
|
||||||
|
refetch: query.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export interface CuratorSubConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
interval_hours: number;
|
||||||
|
stale_after_days: number;
|
||||||
|
archive_after_days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CuratorConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
moderation_model_name: string | null;
|
||||||
|
curator: CuratorSubConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CuratorStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
run_count: number;
|
||||||
|
last_run_at: string | null;
|
||||||
|
last_run_summary: string | null;
|
||||||
|
paused: boolean;
|
||||||
|
agent_created_skills: {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
stale: number;
|
||||||
|
archived: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CuratorLogRecord {
|
||||||
|
ts: string;
|
||||||
|
level: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CuratorLogs {
|
||||||
|
records: CuratorLogRecord[];
|
||||||
|
run_count: number;
|
||||||
|
last_run_at: string | null;
|
||||||
|
}
|
||||||
@@ -434,6 +434,7 @@ export const enUS: Translations = {
|
|||||||
"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",
|
installSkill: "Install skill",
|
||||||
|
goToEvolution: "Skill evolution",
|
||||||
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 CM Assistant.",
|
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
|
||||||
@@ -461,6 +462,65 @@ export const enUS: Translations = {
|
|||||||
rejectInvalidExt: "Only .zip or .skill files are accepted.",
|
rejectInvalidExt: "Only .zip or .skill files are accepted.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
skillEvolution: {
|
||||||
|
title: "Skill Evolution",
|
||||||
|
description:
|
||||||
|
"Manage the skill curator: let the agent age out, archive, and consolidate its self-created skills in the background.",
|
||||||
|
backToSkills: "Back to skills",
|
||||||
|
status: {
|
||||||
|
title: "Status",
|
||||||
|
enabled: "Enabled",
|
||||||
|
disabled: "Disabled",
|
||||||
|
paused: "Paused",
|
||||||
|
runCount: "Total runs",
|
||||||
|
runCountUnit: "",
|
||||||
|
lastRunAt: "Last run",
|
||||||
|
lastRunNever: "Never",
|
||||||
|
lastRunSummary: "Last summary",
|
||||||
|
lastRunSummaryEmpty: "—",
|
||||||
|
skillCounts: "Skills",
|
||||||
|
active: "Active",
|
||||||
|
stale: "Stale",
|
||||||
|
archived: "Archived",
|
||||||
|
total: "Total",
|
||||||
|
runNow: "Run now",
|
||||||
|
runStarted: "Background run started",
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
title: "Configuration",
|
||||||
|
description:
|
||||||
|
"Saves to skill_evolution.json and hot-reloads — no restart required.",
|
||||||
|
enabledLabel: "Enable skill evolution",
|
||||||
|
enabledHelper:
|
||||||
|
"When off, the agent cannot create or modify skills under custom/.",
|
||||||
|
curatorEnabledLabel: "Curator runs automatically",
|
||||||
|
curatorEnabledHelper:
|
||||||
|
"When off, the curator only runs when triggered from this page.",
|
||||||
|
intervalHoursLabel: "Run interval (hours)",
|
||||||
|
intervalHoursHelper:
|
||||||
|
"Minimum hours between automatic curator runs.",
|
||||||
|
staleAfterDaysLabel: "Stale threshold (days)",
|
||||||
|
staleAfterDaysHelper:
|
||||||
|
"Skills inactive for this many days are flagged stale.",
|
||||||
|
archiveAfterDaysLabel: "Archive threshold (days)",
|
||||||
|
archiveAfterDaysHelper:
|
||||||
|
"Stale skills inactive for this many additional days are archived.",
|
||||||
|
moderationModelLabel: "Moderation model (optional)",
|
||||||
|
moderationModelHelper:
|
||||||
|
"Leave blank to use the default chat model.",
|
||||||
|
save: "Save changes",
|
||||||
|
saveSuccess: "Configuration updated",
|
||||||
|
reset: "Reset",
|
||||||
|
},
|
||||||
|
logs: {
|
||||||
|
title: "Recent run logs",
|
||||||
|
description:
|
||||||
|
"Latest curator run only; cleared on backend restart.",
|
||||||
|
empty: "No logs yet. Clicking 'Run now' will populate this panel.",
|
||||||
|
autoRefresh: "Auto-refresh (2s)",
|
||||||
|
refresh: "Refresh now",
|
||||||
|
},
|
||||||
|
},
|
||||||
notification: {
|
notification: {
|
||||||
title: "Notification",
|
title: "Notification",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ export interface Translations {
|
|||||||
description: string;
|
description: string;
|
||||||
createSkill: string;
|
createSkill: string;
|
||||||
installSkill: string;
|
installSkill: string;
|
||||||
|
goToEvolution: string;
|
||||||
emptyTitle: string;
|
emptyTitle: string;
|
||||||
emptyDescription: string;
|
emptyDescription: string;
|
||||||
emptyButton: string;
|
emptyButton: string;
|
||||||
@@ -379,6 +380,56 @@ export interface Translations {
|
|||||||
rejectInvalidExt: string;
|
rejectInvalidExt: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
skillEvolution: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
backToSkills: string;
|
||||||
|
status: {
|
||||||
|
title: string;
|
||||||
|
enabled: string;
|
||||||
|
disabled: string;
|
||||||
|
paused: string;
|
||||||
|
runCount: string;
|
||||||
|
runCountUnit: string;
|
||||||
|
lastRunAt: string;
|
||||||
|
lastRunNever: string;
|
||||||
|
lastRunSummary: string;
|
||||||
|
lastRunSummaryEmpty: string;
|
||||||
|
skillCounts: string;
|
||||||
|
active: string;
|
||||||
|
stale: string;
|
||||||
|
archived: string;
|
||||||
|
total: string;
|
||||||
|
runNow: string;
|
||||||
|
runStarted: string;
|
||||||
|
};
|
||||||
|
config: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
enabledLabel: string;
|
||||||
|
enabledHelper: string;
|
||||||
|
curatorEnabledLabel: string;
|
||||||
|
curatorEnabledHelper: string;
|
||||||
|
intervalHoursLabel: string;
|
||||||
|
intervalHoursHelper: string;
|
||||||
|
staleAfterDaysLabel: string;
|
||||||
|
staleAfterDaysHelper: string;
|
||||||
|
archiveAfterDaysLabel: string;
|
||||||
|
archiveAfterDaysHelper: string;
|
||||||
|
moderationModelLabel: string;
|
||||||
|
moderationModelHelper: string;
|
||||||
|
save: string;
|
||||||
|
saveSuccess: string;
|
||||||
|
reset: string;
|
||||||
|
};
|
||||||
|
logs: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
empty: string;
|
||||||
|
autoRefresh: string;
|
||||||
|
refresh: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
notification: {
|
notification: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|||||||
@@ -415,6 +415,7 @@ export const zhCN: Translations = {
|
|||||||
description: "管理 Agent Skill 配置和启用状态。",
|
description: "管理 Agent Skill 配置和启用状态。",
|
||||||
createSkill: "新建技能",
|
createSkill: "新建技能",
|
||||||
installSkill: "安装技能",
|
installSkill: "安装技能",
|
||||||
|
goToEvolution: "自我进化",
|
||||||
emptyTitle: "还没有技能",
|
emptyTitle: "还没有技能",
|
||||||
emptyDescription:
|
emptyDescription:
|
||||||
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
|
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
|
||||||
@@ -441,6 +442,58 @@ export const zhCN: Translations = {
|
|||||||
rejectInvalidExt: "只接受 .zip 或 .skill 文件。",
|
rejectInvalidExt: "只接受 .zip 或 .skill 文件。",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
skillEvolution: {
|
||||||
|
title: "自我进化",
|
||||||
|
description:
|
||||||
|
"管理技能 curator:让 agent 在后台对自创建的技能做老化标记、归档和合并建议。",
|
||||||
|
backToSkills: "返回技能",
|
||||||
|
status: {
|
||||||
|
title: "当前状态",
|
||||||
|
enabled: "已启用",
|
||||||
|
disabled: "已停用",
|
||||||
|
paused: "已暂停",
|
||||||
|
runCount: "累计运行",
|
||||||
|
runCountUnit: "次",
|
||||||
|
lastRunAt: "上次运行",
|
||||||
|
lastRunNever: "尚未运行",
|
||||||
|
lastRunSummary: "上次摘要",
|
||||||
|
lastRunSummaryEmpty: "—",
|
||||||
|
skillCounts: "技能",
|
||||||
|
active: "活跃",
|
||||||
|
stale: "陈旧",
|
||||||
|
archived: "已归档",
|
||||||
|
total: "合计",
|
||||||
|
runNow: "立即执行",
|
||||||
|
runStarted: "已触发后台执行",
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
title: "配置",
|
||||||
|
description:
|
||||||
|
"保存后会写入 skill_evolution.json 并热更新,无需重启。",
|
||||||
|
enabledLabel: "启用自我进化",
|
||||||
|
enabledHelper: "关闭后 agent 不会创建或修改 custom/ 下的技能。",
|
||||||
|
curatorEnabledLabel: "curator 自动运行",
|
||||||
|
curatorEnabledHelper: "关闭后只能在此页面手动触发。",
|
||||||
|
intervalHoursLabel: "运行间隔(小时)",
|
||||||
|
intervalHoursHelper: "两次自动运行之间至少经过的小时数。",
|
||||||
|
staleAfterDaysLabel: "陈旧阈值(天)",
|
||||||
|
staleAfterDaysHelper: "技能长时间不活跃后被标记为陈旧。",
|
||||||
|
archiveAfterDaysLabel: "归档阈值(天)",
|
||||||
|
archiveAfterDaysHelper: "标记陈旧后再经过这么多天会被自动归档。",
|
||||||
|
moderationModelLabel: "安全审核模型(可选)",
|
||||||
|
moderationModelHelper: "为空时使用默认主模型。",
|
||||||
|
save: "保存修改",
|
||||||
|
saveSuccess: "配置已更新",
|
||||||
|
reset: "重置",
|
||||||
|
},
|
||||||
|
logs: {
|
||||||
|
title: "最近运行日志",
|
||||||
|
description: "展示最近一次 curator 运行的日志,服务重启会清空。",
|
||||||
|
empty: "暂无日志。点击「立即执行」会写入新的运行记录。",
|
||||||
|
autoRefresh: "自动刷新 (2s)",
|
||||||
|
refresh: "立即刷新",
|
||||||
|
},
|
||||||
|
},
|
||||||
notification: {
|
notification: {
|
||||||
title: "通知",
|
title: "通知",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { SkillEvolutionPanel } from "@/components/workspace/skills/skill-evolution-panel";
|
||||||
|
|
||||||
|
export default function SkillEvolutionPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-auto p-6">
|
||||||
|
<SkillEvolutionPanel />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
|
||||||
|
|
||||||
|
export default function SkillsPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-auto p-6">
|
||||||
|
<SkillSettingsPage />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ import AgentChatPage from "./AgentChatPage";
|
|||||||
import NewAgentPage from "./NewAgentPage";
|
import NewAgentPage from "./NewAgentPage";
|
||||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||||
|
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||||
|
import SkillsPage from "./SkillsPage";
|
||||||
|
|
||||||
function WorkspaceLayout({
|
function WorkspaceLayout({
|
||||||
children,
|
children,
|
||||||
@@ -111,6 +113,22 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="skills"
|
||||||
|
element={
|
||||||
|
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||||
|
<SkillsPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="skills/evolution"
|
||||||
|
element={
|
||||||
|
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||||
|
<SkillEvolutionPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="scheduled"
|
path="scheduled"
|
||||||
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
||||||
|
|||||||
Reference in new issue
Block a user