{((isCE ? ['private'] : ['public', 'private']) as KBTabKey[]).map((tab) => {
diff --git a/src/frontend/src/components/catalog/McpPage.tsx b/src/frontend/src/components/catalog/McpPage.tsx
index 278e846a..a91c1311 100644
--- a/src/frontend/src/components/catalog/McpPage.tsx
+++ b/src/frontend/src/components/catalog/McpPage.tsx
@@ -48,7 +48,7 @@ function McpIcon({ id, icon }: { id: string; icon?: string }) {
);
}
-export function McpPage({ embedded = false, onDetailChange }: { embedded?: boolean; onDetailChange?: (hasDetail: boolean) => void }) {
+export function McpPage({ embedded = false }: { embedded?: boolean }) {
const {
catalog,
panel,
@@ -57,8 +57,8 @@ export function McpPage({ embedded = false, onDetailChange }: { embedded?: boole
toggleItem,
} = useCatalogStore();
const { title: mcpTitle, subtitle: mcpSubtitle } = usePanelHeader('mcp', {
- title: 'MCP工具库',
- subtitle: '管理 MCP 工具服务,并查看其作用范围与可靠性影响。',
+ title: '连接器',
+ subtitle: '管理 MCP 连接器服务,并查看其作用范围与可靠性影响。',
});
const fetchCatalog = useCatalogStore((s) => s.fetchCatalog);
@@ -208,11 +208,6 @@ export function McpPage({ embedded = false, onDetailChange }: { embedded?: boole
void toggleItem('mcp', id, enabled);
};
- // Notify parent of detail state changes (covers all code paths including useEffect resets)
- useEffect(() => {
- onDetailChange?.(!!selectedId);
- }, [selectedId, onDetailChange]);
-
const openDetail = useCallback((id: string) => {
setNavDir('detail');
setSelectedId(id);
diff --git a/src/frontend/src/components/catalog/PluginsPage.tsx b/src/frontend/src/components/catalog/PluginsPage.tsx
index 2ff8dcb3..9c6c3a8d 100644
--- a/src/frontend/src/components/catalog/PluginsPage.tsx
+++ b/src/frontend/src/components/catalog/PluginsPage.tsx
@@ -25,11 +25,6 @@ import type {
PluginImportReport, PluginRequiredSecret, PluginSkillComponent, PluginMcpComponent,
} from '../../types';
-interface PluginsPageProps {
- embedded?: boolean;
- onDetailChange?: (detail: boolean) => void;
-}
-
type Level = 'list' | 'plugin' | 'component';
type SelectedComponent =
| { kind: 'skill'; data: PluginSkillComponent }
@@ -53,7 +48,7 @@ function sourceLabel(source?: string): string | null {
return null;
}
-export function PluginsPage({ onDetailChange }: PluginsPageProps) {
+export function PluginsPage() {
const fetchCatalog = useCatalogStore((s) => s.fetchCatalog);
const { manageQuery, setManageQuery, panelEntryNonce } = useCatalogStore();
const canImportPlugin = useAuthStore((s) => s.authUser?.can_import_plugin === true);
@@ -84,8 +79,6 @@ export function PluginsPage({ onDetailChange }: PluginsPageProps) {
const [report, setReport] = useState<{ name: string; report: PluginImportReport } | null>(null);
const importInputRef = useRef
(null);
- useEffect(() => { onDetailChange?.(level !== 'list'); }, [level, onDetailChange]);
-
const refresh = useCallback(async () => {
setLoading(true);
try {
diff --git a/src/frontend/src/components/catalog/SkillsPage.tsx b/src/frontend/src/components/catalog/SkillsPage.tsx
index c519df4c..aa86cfdc 100644
--- a/src/frontend/src/components/catalog/SkillsPage.tsx
+++ b/src/frontend/src/components/catalog/SkillsPage.tsx
@@ -47,7 +47,7 @@ function saveSkillsDetailState(id: string | null, kind: 'skills' | 'agents') {
window.localStorage.setItem(SKILLS_DETAIL_KIND_STORAGE_KEY, kind);
}
-export function SkillsPage({ embedded = false, onDetailChange }: { embedded?: boolean; onDetailChange?: (hasDetail: boolean) => void }) {
+export function SkillsPage({ embedded = false }: { embedded?: boolean }) {
const {
catalog,
panel,
@@ -475,11 +475,6 @@ export function SkillsPage({ embedded = false, onDetailChange }: { embedded?: bo
void toggleItem(kind as 'skills' | 'agents' | 'mcp' | 'kb', id, enabled);
};
- // Notify parent of detail state changes (covers all code paths including useEffect resets)
- useEffect(() => {
- onDetailChange?.(!!selectedId);
- }, [selectedId, onDetailChange]);
-
const openDetail = useCallback((id: string, kind: 'skills' | 'agents') => {
setNavDir('detail');
setSelectedId(id);
diff --git a/src/frontend/src/components/catalog/abilityTabs.ts b/src/frontend/src/components/catalog/abilityTabs.ts
new file mode 100644
index 00000000..e246b3e3
--- /dev/null
+++ b/src/frontend/src/components/catalog/abilityTabs.ts
@@ -0,0 +1,19 @@
+import type { AbilityTabKey } from '../../types';
+import { t } from '../../i18n';
+
+/**
+ * 能力中心的四类能力,**唯一真源**。
+ *
+ * 两处消费同一张表,避免类别列表在导航与页面之间分叉:
+ * - `components/sidebar/items.ts` → `LAYOUT_ITEMS.ability_center.children`(侧边栏二级导航 / 用户菜单子菜单)
+ * - `components/catalog/AbilityCenterPage.tsx` → 四个 pane 的渲染顺序
+ *
+ * 只放 key + 文案,不放 JSX:`items.ts` 属于侧边栏基础设施,不应该反向依赖能力中心的组件。
+ * pane 的映射留在 `AbilityCenterPage` 里,用 `Record` 拿到穷尽性检查。
+ */
+export const ABILITY_TABS: ReadonlyArray<{ key: AbilityTabKey; label: string }> = [
+ { key: 'agents', label: t('智能体') },
+ { key: 'skills', label: t('技能') },
+ { key: 'mcp', label: t('连接器') },
+ { key: 'plugins', label: t('插件') },
+];
diff --git a/src/frontend/src/components/chat/ChatArea.tsx b/src/frontend/src/components/chat/ChatArea.tsx
index bb42cd6a..5f665473 100644
--- a/src/frontend/src/components/chat/ChatArea.tsx
+++ b/src/frontend/src/components/chat/ChatArea.tsx
@@ -323,8 +323,7 @@ export function ChatArea({
type="button"
className="jx-siteHeroTopBtn"
onClick={() => {
- useChatStore.getState().setSitesListRequested(true);
- useCatalogStore.getState().setPanel('lab');
+ useCatalogStore.getState().setPanel('sites');
}}
>
{t('我的站点')}
diff --git a/src/frontend/src/components/docs/AppCenterPanel.tsx b/src/frontend/src/components/docs/AppCenterPanel.tsx
index c9a53824..9e8e01d7 100644
--- a/src/frontend/src/components/docs/AppCenterPanel.tsx
+++ b/src/frontend/src/components/docs/AppCenterPanel.tsx
@@ -1,14 +1,14 @@
-import { useEffect, useMemo, useState } from 'react';
-import { useChatStore, useCatalogStore, useAuthStore, useAutomationStore } from '../../stores';
+import { useMemo } from 'react';
+import { useChatStore, useCatalogStore, useAuthStore } from '../../stores';
import { staggerStyle } from '../../utils/motionTokens';
import { t } from '../../i18n';
import { useAppConfig, usePanelHeader } from '../../hooks/usePageConfig';
import { BUILTIN_APPS, type AppItem } from '../../stores/pageConfigStore';
-import { AutomationPanel } from '../lab/AutomationPanel';
+// 「自动化」已从应用中心拆出,成为侧边栏一级入口「定时任务」(LAYOUT_ITEMS.automation → panel 'automation')。
+// BUILTIN_APPS 里仍保留 automation 项,因为它同时是 allowed_apps 权限位的载体,只是不再在这里陈列。
const PLAN_MODE_ITEM: AppItem =
BUILTIN_APPS.find((a) => a.id === 'plan_mode') ?? BUILTIN_APPS[0];
-const AUTOMATION_ITEM: AppItem | undefined = BUILTIN_APPS.find((a) => a.id === 'automation');
const BATCH_RUNNER_ITEM: AppItem | undefined = BUILTIN_APPS.find((a) => a.id === 'batch_runner');
const PLACEHOLDER_ITEM: AppItem = {
@@ -32,29 +32,18 @@ export default function AppCenterPanel() {
const { setPanel } = useCatalogStore();
const ssoToken = useAuthStore((s) => s.authUser?.sso_token ?? null);
const allowedApps = useAuthStore((s) => s.authUser?.allowed_apps ?? null);
- const automationSelectedTaskId = useAutomationStore((s) => s.selectedTaskId);
const appConfig = useAppConfig();
const { title, subtitle } = usePanelHeader('app_center', {
title: '应用中心',
subtitle: '基于 AI 能力的场景化智能应用',
});
- // If an automation task is already selected when opening the app center (jumped in from the sidebar), go straight into the automation sub-panel
- const [subPanel, setSubPanel] = useState(
- automationSelectedTaskId ? 'automation' : null,
- );
-
- useEffect(() => {
- if (automationSelectedTaskId) setSubPanel('automation');
- }, [automationSelectedTaskId]);
-
const items = useMemo(() => {
const allowedSet = Array.isArray(allowedApps) ? new Set(allowedApps) : null;
const isAllowed = (id: string) => allowedSet === null || allowedSet.has(id);
const result: AppItem[] = [];
if (isAllowed(PLAN_MODE_ITEM.id)) result.push(PLAN_MODE_ITEM);
- if (AUTOMATION_ITEM && isAllowed(AUTOMATION_ITEM.id)) result.push(AUTOMATION_ITEM);
if (BATCH_RUNNER_ITEM && isAllowed(BATCH_RUNNER_ITEM.id)) result.push(BATCH_RUNNER_ITEM);
for (const app of appConfig.apps) {
@@ -79,10 +68,6 @@ export default function AppCenterPanel() {
setPanel('chat');
return;
}
- if (app.id === 'automation') {
- setSubPanel('automation');
- return;
- }
if (app.id === 'batch_runner') {
enterChatMode('batch');
setPanel('chat');
@@ -91,10 +76,6 @@ export default function AppCenterPanel() {
openExternal(app.url);
};
- if (subPanel === 'automation') {
- return setSubPanel(null)} />;
- }
-
return (
diff --git a/src/frontend/src/components/lab/AutomationCreateModal.tsx b/src/frontend/src/components/lab/AutomationCreateModal.tsx
index 7754297c..ac6a3000 100644
--- a/src/frontend/src/components/lab/AutomationCreateModal.tsx
+++ b/src/frontend/src/components/lab/AutomationCreateModal.tsx
@@ -12,6 +12,8 @@ interface Props {
open: boolean;
onClose: () => void;
onCreated: () => void;
+ /** 从「推荐任务」卡片进入时的预填值(名称 / 提示词 / cron)。空则走原来的空白表单。 */
+ preset?: { name: string; prompt: string; cron: string } | null;
}
function defaultSchedule(): ScheduleValue {
@@ -30,7 +32,7 @@ function toPlanStepData(plan: Plan): PlanStepData[] {
}));
}
-export function AutomationCreateModal({ open, onClose, onCreated }: Props) {
+export function AutomationCreateModal({ open, onClose, onCreated, preset = null }: Props) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [taskType, setTaskType] = useState<'prompt' | 'plan'>('prompt');
@@ -51,6 +53,14 @@ export function AutomationCreateModal({ open, onClose, onCreated }: Props) {
listChannelConversations().then(setConvs).catch(() => { /* Fail silently when there are no channel conversations */ });
}, [open]);
+ // 推荐任务预填:弹窗打开时把示例灌进表单(destroyOnClose 会重建 Form,所以要在 open 后再 set)。
+ useEffect(() => {
+ if (!open || !preset) return;
+ setTaskType('prompt');
+ setSchedule({ schedule_type: 'recurring', cron_expression: preset.cron });
+ form.setFieldsValue({ name: preset.name, prompt: preset.prompt });
+ }, [open, preset, form]);
+
const loadPlans = async () => {
if (plansLoaded) return;
try {
@@ -123,7 +133,7 @@ export function AutomationCreateModal({ open, onClose, onCreated }: Props) {
conversation_id: target?.conversation_id,
});
- message.success(t('自动化任务创建成功'));
+ message.success(t('定时任务创建成功'));
resetAll();
onCreated();
} catch (e: unknown) {
@@ -139,7 +149,7 @@ export function AutomationCreateModal({ open, onClose, onCreated }: Props) {
return (
= {
expired: t('已过期'),
};
-const RUN_STATUS_CLASS: Record = {
- // The running-state dot carries .jx-anim-ripple for a spreading ripple (motion.css primitive; color/multiplier in automation.css)
- running: 'is-running jx-anim-ripple',
- success: 'is-success',
- failed: 'is-failed',
-};
-
const SCHEDULE_TYPE_LABEL: Record = {
recurring: t('周期执行'),
once: t('单次执行'),
@@ -557,7 +550,7 @@ export function AutomationDetailPage({ taskId, onBack }: Props) {
{runs.map((run) => (
diff --git a/src/frontend/src/components/lab/AutomationPanel.tsx b/src/frontend/src/components/lab/AutomationPanel.tsx
index eda89ded..5b4ff691 100644
--- a/src/frontend/src/components/lab/AutomationPanel.tsx
+++ b/src/frontend/src/components/lab/AutomationPanel.tsx
@@ -1,23 +1,40 @@
-import { useEffect } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
-import { Button, Empty } from 'antd';
-import { PlusOutlined, ArrowLeftOutlined } from '@ant-design/icons';
-import { useAutomationStore } from '../../stores';
-import type { AutomationTask } from '../../types';
+import { Button, Empty, Input, Select, Tooltip } from 'antd';
+import {
+ PlusOutlined, LeftOutlined, RightOutlined,
+ ClockCircleOutlined, SearchOutlined, ReloadOutlined,
+} from '@ant-design/icons';
+import { useAutomationStore, useCatalogStore } from '../../stores';
+import { usePanelHeader } from '../../hooks/usePageConfig';
import { useDelayedFlag } from '../../hooks';
+import { getAutomationRuns } from '../../api';
+import type { AutomationRun, AutomationTask } from '../../types';
import { EASE } from '../../utils/motionTokens';
+import { formatShortDateTime } from '../../utils/date';
import { AutomationCard } from './AutomationCard';
import { AutomationCreateModal } from './AutomationCreateModal';
import { AutomationDetailPage } from './AutomationDetailPage';
import { AutomationListSkeleton } from './AutomationSkeleton';
+import { AUTOMATION_PRESETS, type AutomationPreset } from './automationPresets';
+import { cronToHumanReadable, RUN_STATUS_CLASS, RUN_STATUS_LABEL } from './automationUtils';
import '../../styles/automation.css';
import { t } from '../../i18n';
-interface Props {
- onBack: () => void;
+type AutomationTab = 'tasks' | 'runs';
+type SortKey = 'created_desc' | 'created_asc' | 'next_run';
+
+/** 聚合执行记录一次最多展示多少条——后端只有「按任务查 runs」的接口,合并后可能很长。 */
+const RUNS_DISPLAY_LIMIT = 50;
+/** 每个任务取多少条最近执行记录参与合并。 */
+const RUNS_PER_TASK = 10;
+
+/** 执行记录列表项:run 本身不带任务名,聚合展示时要把所属任务带上。 */
+interface AggregatedRun extends AutomationRun {
+ taskName: string;
}
-export function AutomationPanel({ onBack }: Props) {
+export function AutomationPanel() {
const {
tasks,
loading,
@@ -27,14 +44,66 @@ export function AutomationPanel({ onBack }: Props) {
setCreateModalOpen,
setSelectedTaskId,
} = useAutomationStore();
+ const panel = useCatalogStore((s) => s.panel);
+ const panelEntryNonce = useCatalogStore((s) => s.panelEntryNonce);
+
+ const { title, subtitle } = usePanelHeader('automation', {
+ title: '定时任务',
+ subtitle: '按计划自动执行任务,也可随时手动触发。在任意对话中描述你想定期做的事,即可快速创建',
+ });
+
+ const [tab, setTab] = useState('tasks');
+ const [keyword, setKeyword] = useState('');
+ const [sortKey, setSortKey] = useState('created_desc');
+ const [pendingPreset, setPendingPreset] = useState(null);
useEffect(() => {
void fetchTasks();
}, [fetchTasks]);
+ // 重新进入本面板时回到列表——与 AgentPanel / SkillsPage / McpPage 用的是同一套
+ // `panelEntryNonce` 约定,这样侧边栏(以及任何别的导航入口)不必知道本功能的存在。
+ useEffect(() => {
+ if (panel !== 'automation') return;
+ setSelectedTaskId(null);
+ }, [panel, panelEntryNonce, setSelectedTaskId]);
+
const showListSkeleton = useDelayedFlag(loading && tasks.length === 0);
- // ── Detail view ──
+ const openCreate = useCallback((preset: AutomationPreset | null) => {
+ setPendingPreset(preset);
+ setCreateModalOpen(true);
+ }, [setCreateModalOpen]);
+
+ // 过滤 + 排序 + 分组一次算完:三个状态桶此前是三次独立 filter,每次按键都全量重扫。
+ const { active: activeTasks, paused: pausedTasks, other: otherTasks, total } = useMemo(() => {
+ const q = keyword.trim().toLowerCase();
+ const filtered = q
+ ? tasks.filter((task) => (
+ (task.name || '').toLowerCase().includes(q)
+ || (task.description || '').toLowerCase().includes(q)
+ || (task.prompt || '').toLowerCase().includes(q)
+ ))
+ : tasks;
+ const sorted = [...filtered].sort((a, b) => {
+ if (sortKey === 'created_asc') return (a.created_at || '').localeCompare(b.created_at || '');
+ if (sortKey === 'next_run') {
+ // 没有下次执行时间(手动/已停用)的排在最后
+ if (!a.next_run_at) return b.next_run_at ? 1 : 0;
+ if (!b.next_run_at) return -1;
+ return a.next_run_at.localeCompare(b.next_run_at);
+ }
+ return (b.created_at || '').localeCompare(a.created_at || '');
+ });
+ return {
+ active: sorted.filter((task) => task.status === 'active'),
+ paused: sorted.filter((task) => task.status === 'paused'),
+ other: sorted.filter((task) => !['active', 'paused'].includes(task.status)),
+ total: sorted.length,
+ };
+ }, [tasks, keyword, sortKey]);
+
+ // ── 详情页(替换整页,保持原有行为) ──
if (selectedTaskId) {
return (
t.status === 'active');
- const pausedTasks = tasks.filter((t) => t.status === 'paused');
- const otherTasks = tasks.filter((t) => !['active', 'paused'].includes(t.status));
-
- // Task-card add/remove animation: popLayout + layout, key=task_id;
- // when polling/fetchTasks replaces the whole group, the stable key prevents remounts; on deletion the remaining cards smoothly fill in.
- // initial=false: the first-screen list does not play an entrance animation, only subsequent add/remove animations.
+ // 任务卡片增删动画:popLayout + layout,key=task_id;轮询整组替换时稳定 key 避免重挂载。
const renderTaskCards = (list: AutomationTask[]) => (
{list.map((task) => (
@@ -72,74 +134,334 @@ export function AutomationPanel({ onBack }: Props) {
);
+ const renderTasksTab = () => {
+ if (showListSkeleton) return ;
+ if (loading && tasks.length === 0) return null;
+ if (total === 0) {
+ const searching = !!keyword.trim();
+ return (
+
+
}
+ description={(
+ <>
+
+ {searching ? t('没有匹配的定时任务') : t('暂无定时任务')}
+
+
+ {searching ? t('换个关键词试试') : t('创建定时任务来自动执行周期性 AI 工作')}
+
+ >
+ )}
+ >
+ {!searching && (
+
} onClick={() => openCreate(null)}>
+ {t('新建定时任务')}
+
+ )}
+
+
+ );
+ }
+ return (
+ <>
+ {activeTasks.length > 0 && (
+
+
{t('运行中')}
+ {renderTaskCards(activeTasks)}
+
+ )}
+ {pausedTasks.length > 0 && (
+
+
{t('已暂停')}
+ {renderTaskCards(pausedTasks)}
+
+ )}
+ {otherTasks.length > 0 && (
+
+
{t('已完成 / 已停用')}
+ {renderTaskCards(otherTasks)}
+
+ )}
+ >
+ );
+ };
+
return (
-
-
}
- onClick={onBack}
- style={{ marginRight: 4 }}
+
+
{title}
+ {subtitle ?
{subtitle}
: null}
+
+
+
}
+ placeholder={t('搜索定时任务…')}
+ value={keyword}
+ onChange={(e) => setKeyword(e.target.value)}
/>
-
-
{t('自动化')}
-
{t('设置定时或周期性 AI 任务,到时间后自动执行')}
-
+
} onClick={() => openCreate(null)}>
+ {t('新建定时任务')}
+
-
}
- onClick={() => setCreateModalOpen(true)}
- >
- {t('创建自动化')}
-
- {showListSkeleton ? (
-
- ) : loading && tasks.length === 0 ? null : tasks.length === 0 ? (
-
-
-
- ) : (
- <>
- {activeTasks.length > 0 && (
-
-
{t('运行中')}
- {renderTaskCards(activeTasks)}
-
- )}
- {pausedTasks.length > 0 && (
-
-
{t('已暂停')}
- {renderTaskCards(pausedTasks)}
-
- )}
- {otherTasks.length > 0 && (
-
-
{t('已完成 / 已停用')}
- {renderTaskCards(otherTasks)}
-
- )}
- >
- )}
+
+
+
+
+ {([
+ { key: 'tasks' as const, label: t('我的定时任务') },
+ { key: 'runs' as const, label: t('执行记录') },
+ ]).map((item) => (
+
+ ))}
+
+ {tab === 'tasks' && (
+
+ )}
+
+
+ {/* 两个 Tab 都常驻挂载、用 CSS 显隐切换:来回切 Tab 不该把已经拉好的执行记录丢掉重拉。 */}
+
{renderTasksTab()}
+
+
+
setCreateModalOpen(false)}
+ preset={pendingPreset
+ ? { name: pendingPreset.title, prompt: pendingPreset.prompt, cron: pendingPreset.cron }
+ : null}
+ onClose={() => {
+ setCreateModalOpen(false);
+ setPendingPreset(null);
+ }}
onCreated={() => {
setCreateModalOpen(false);
+ setPendingPreset(null);
void fetchTasks();
}}
/>
);
}
+
+/** 推荐任务:横向卡片轮播,点卡片把示例预填进创建弹窗。 */
+function PresetCarousel({ onPick }: { onPick: (preset: AutomationPreset) => void }) {
+ const trackRef = useRef
(null);
+ const rafRef = useRef(null);
+ const [edges, setEdges] = useState({ atStart: true, atEnd: false });
+
+ // 平滑滚动期间 scroll 事件按帧率连发,每次都读 scrollLeft/clientWidth/scrollWidth 会强制
+ // 同步布局。合并到一个 rAF 里,一次滚动动画只测量若干次、且只有真正翻面时才 setState。
+ const syncEdges = useCallback(() => {
+ if (rafRef.current !== null) return;
+ rafRef.current = window.requestAnimationFrame(() => {
+ rafRef.current = null;
+ const el = trackRef.current;
+ if (!el) return;
+ const next = {
+ atStart: el.scrollLeft <= 1,
+ atEnd: el.scrollLeft + el.clientWidth >= el.scrollWidth - 1,
+ };
+ setEdges((prev) => (
+ prev.atStart === next.atStart && prev.atEnd === next.atEnd ? prev : next
+ ));
+ });
+ }, []);
+
+ useEffect(() => {
+ syncEdges();
+ window.addEventListener('resize', syncEdges);
+ return () => {
+ window.removeEventListener('resize', syncEdges);
+ if (rafRef.current !== null) window.cancelAnimationFrame(rafRef.current);
+ };
+ }, [syncEdges]);
+
+ const scrollBy = (dir: -1 | 1) => {
+ const el = trackRef.current;
+ if (!el) return;
+ el.scrollBy({ left: dir * Math.max(el.clientWidth - 80, 240), behavior: 'smooth' });
+ };
+
+ return (
+
+
+
{t('推荐任务')}
+
+
+
+
+
+
+ {AUTOMATION_PRESETS.map((preset) => (
+
+ ))}
+
+
+ );
+}
+
+/**
+ * 执行记录:后端只有「按任务查 runs」的接口,这里按任务并发拉取后在前端合并排序。
+ *
+ * 因此这是个 N 次请求的聚合,代价随任务数线性增长,展示上也只能是「每个任务最近 N 条」的并集
+ * 而非真正的全局最近 N 条。任务量再大就该在后端加一个 `GET /v1/automations/runs` 聚合接口
+ * (runs join tasks,按 started_at 倒序 limit),这里改成一次请求。
+ */
+function RunsTab({ tasks, onOpenTask }: { tasks: AutomationTask[]; onOpenTask: (id: string) => void }) {
+ const [runs, setRuns] = useState([]);
+ const [loading, setLoading] = useState(false);
+ // 依赖任务**集合**而非数组引用:fetchTasks 每次都 set 一个新数组,直接依赖 tasks
+ // 会让轮询/刷新触发一整轮 N 次重复请求。
+ const taskSig = useMemo(() => tasks.map((task) => task.task_id).join('|'), [tasks]);
+ const tasksRef = useRef(tasks);
+ tasksRef.current = tasks;
+
+ const reload = useCallback(async () => {
+ const list = tasksRef.current;
+ if (list.length === 0) {
+ setRuns([]);
+ return;
+ }
+ setLoading(true);
+ try {
+ const results = await Promise.all(
+ list.map(async (task) => {
+ try {
+ const items = await getAutomationRuns(task.task_id, RUNS_PER_TASK);
+ return items.map((run) => ({ ...run, taskName: task.name || t('未命名任务') }));
+ } catch {
+ // 单个任务拉取失败不影响整体列表
+ return [] as AggregatedRun[];
+ }
+ }),
+ );
+ const merged = results.flat();
+ merged.sort((a, b) => (b.started_at || '').localeCompare(a.started_at || ''));
+ setRuns(merged.slice(0, RUNS_DISPLAY_LIMIT));
+ } finally {
+ setLoading(false);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [taskSig]);
+
+ useEffect(() => {
+ void reload();
+ }, [reload]);
+
+ const showSkeleton = useDelayedFlag(loading && runs.length === 0);
+
+ if (showSkeleton) return ;
+ if (loading && runs.length === 0) return null;
+
+ if (runs.length === 0) {
+ return (
+
+
}
+ description={(
+ <>
+
{t('暂无执行记录')}
+
{t('定时任务执行后,这里会汇总每一次的运行结果')}
+ >
+ )}
+ />
+
+ );
+ }
+
+ return (
+
+
+ {t('共 {n} 条', { n: runs.length })}
+
+ } loading={loading} onClick={() => void reload()} />
+
+
+ {runs.map((run) => (
+
onOpenTask(run.task_id)}
+ >
+
+
+
+ {run.taskName}
+ {RUN_STATUS_LABEL[run.status] || run.status}
+
+ {run.result_summary && (
+
{run.result_summary}
+ )}
+ {run.error_message && (
+
{run.error_message}
+ )}
+
+
+ {formatShortDateTime(run.started_at, '—')}
+ {typeof run.duration_ms === 'number' && (
+ {t('{n} 秒', { n: Math.round(run.duration_ms / 1000) })}
+ )}
+
+
+ ))}
+
+ );
+}
diff --git a/src/frontend/src/components/lab/LabPanel.tsx b/src/frontend/src/components/lab/LabPanel.tsx
index 6b4830a4..c2987bdb 100644
--- a/src/frontend/src/components/lab/LabPanel.tsx
+++ b/src/frontend/src/components/lab/LabPanel.tsx
@@ -1,10 +1,9 @@
import { useState } from 'react';
-import { useAuthStore, useChatStore } from '../../stores';
+import { useAuthStore } from '../../stores';
import { staggerStyle } from '../../utils/motionTokens';
import { usePanelHeader } from '../../hooks/usePageConfig';
import { EDITION_LAB_ITEMS, SkillDistillPanel } from '../../labEdition';
import LoopPanel from '../loop/LoopPanel';
-import { SitesPanel } from '../sites';
import { t } from '../../i18n';
const LAB_ITEMS = [
@@ -16,13 +15,7 @@ const LAB_ITEMS = [
description: t('让智能体自我推进的长时运行任务:按可验证目标(环境验证)反复迭代、自我修正,达标或触预算即停'),
enabled: true,
},
- {
- id: 'sites',
- name: t('站点'),
- icon: '/home/new-icons/more.svg',
- description: t('在对话里描述需求,AI 生成完整网站并一键发布,由平台托管、凭链接即可访问,支持随时对话迭代更新'),
- enabled: true,
- },
+ // 「站点」已从实验室提升为侧边栏一级入口(LAYOUT_ITEMS.sites → panel 'sites'),此处不再重复陈列。
{
id: 'coming_soon',
name: t('更多实验'),
@@ -33,19 +26,13 @@ const LAB_ITEMS = [
];
export default function LabPanel() {
- const [subPanel, setSubPanel] = useState<'skill_distill' | 'autonomous_loop' | 'sites' | null>(null);
+ const [subPanel, setSubPanel] = useState<'skill_distill' | 'autonomous_loop' | null>(null);
const labEnabled = useAuthStore((s) => s.authUser?.lab_enabled);
- const sitesListRequested = useChatStore((s) => s.sitesListRequested);
- const setSitesListRequested = useChatStore((s) => s.setSitesListRequested);
const { title: labTitle, subtitle: labSubtitle } = usePanelHeader('lab', {
title: '实验室',
subtitle: 'AI 能力实验性应用',
});
- // When arriving from the main chat "My Sites" entry (sitesListRequested), go straight to the sites list management page,
- // no local state needed —— decided directly at render time, and the flag is cleared on exit.
- const showSites = subPanel === 'sites' || sitesListRequested;
-
// Defensive permission check: if the admin has disabled this user's lab permission, show a no-permission notice inside the panel.
// A default of undefined is treated as enabled (backward compatible).
if (labEnabled === false) {
@@ -66,24 +53,12 @@ export default function LabPanel() {
setSubPanel('skill_distill');
} else if (id === 'autonomous_loop') {
setSubPanel('autonomous_loop');
- } else if (id === 'sites') {
- // Clicking "Sites" first enters the sites management page (list / versions / forms / KV);
- // the site-creation entry is the "Create" button at the top-right of the sites page, clicking it jumps to a main-chat site-building session.
- setSubPanel('sites');
}
};
- const backToLab = () => {
- setSubPanel(null);
- if (sitesListRequested) setSitesListRequested(false);
- };
-
if (subPanel === 'skill_distill') {
return setSubPanel(null)} />;
}
- if (showSites) {
- return ;
- }
if (subPanel === 'autonomous_loop') {
return (
diff --git a/src/frontend/src/components/lab/automationPresets.ts b/src/frontend/src/components/lab/automationPresets.ts
new file mode 100644
index 00000000..33af5704
--- /dev/null
+++ b/src/frontend/src/components/lab/automationPresets.ts
@@ -0,0 +1,57 @@
+import { t } from '../../i18n';
+
+/**
+ * 「推荐任务」示例卡片。
+ *
+ * 纯前端常量——后端没有推荐任务/模板的概念(`scheduled_tasks` 只存用户自己建的任务),
+ * 点卡片走的是普通的创建流程:把这里的 prompt / cron / name 预填进创建弹窗,
+ * 用户确认后照常 `POST /v1/automations`。所以增删示例不需要动后端。
+ */
+export interface AutomationPreset {
+ id: string;
+ /** 卡片标题,同时作为创建时的默认任务名 */
+ title: string;
+ /** 卡片正文,同时作为定时执行的提示词 */
+ prompt: string;
+ /** 5 段式 cron,交给 cronToHumanReadable 渲染成「每天 09:00」这类文案 */
+ cron: string;
+}
+
+export const AUTOMATION_PRESETS: AutomationPreset[] = [
+ {
+ id: 'daily_briefing',
+ title: t('每日行业简报'),
+ prompt: t('检索过去 24 小时内本行业的重要新闻、政策与竞品动态,按「事实 / 影响 / 建议关注」三段式整理成一份不超过 800 字的简报,并标注信息来源链接。'),
+ cron: '0 9 * * *',
+ },
+ {
+ id: 'work_daily_report',
+ title: t('工作日报汇总'),
+ prompt: t('汇总我今天的工作内容,按「已完成事项(含量化结果)/ 进行中事项(含阻塞点)/ 明日计划」三部分输出,语气简洁、条目化,可直接粘贴到日报系统。'),
+ cron: '0 20 * * 1-5',
+ },
+ {
+ id: 'weekly_summary',
+ title: t('每周工作周报'),
+ prompt: t('回顾本周的会话记录与产出文件,生成一份周报:本周关键进展、遇到的问题与解决方式、下周重点计划。每部分不超过 5 条。'),
+ cron: '0 17 * * 5',
+ },
+ {
+ id: 'competitor_watch',
+ title: t('竞品动态监测'),
+ prompt: t('搜索我关注的竞品在最近一周的产品发布、融资、人事与公开演讲信息,逐条给出时间、来源与一句话要点,并在末尾总结值得警惕的两个趋势。'),
+ cron: '0 10 * * 1',
+ },
+ {
+ id: 'inbox_digest',
+ title: t('知识库新增速览'),
+ prompt: t('检查我的知识库在过去一天新增或更新的文档,逐份给出 3 句话摘要与关键结论,最后列出需要我进一步确认的问题。'),
+ cron: '0 9 * * *',
+ },
+ {
+ id: 'meeting_reminder',
+ title: t('周会材料准备'),
+ prompt: t('提前梳理本周周会需要的材料:本周关键数据变化、上周遗留事项的进展、需要在会上拍板的决策点,输出成一页会议提纲。'),
+ cron: '0 14 * * 1',
+ },
+];
diff --git a/src/frontend/src/components/lab/automationUtils.ts b/src/frontend/src/components/lab/automationUtils.ts
index 5b943a25..fd4ccdf5 100644
--- a/src/frontend/src/components/lab/automationUtils.ts
+++ b/src/frontend/src/components/lab/automationUtils.ts
@@ -17,6 +17,15 @@ export const RUN_STATUS_LABEL: Record
= {
failed: t('失败'),
};
+/** Status modifier classes for the run dot (`.jx-automation-runDot`), shared by the task
+ * detail page and the aggregated run list. The running state additionally carries the
+ * `.jx-anim-ripple` motion primitive; colour/spread live in automation.css. */
+export const RUN_STATUS_CLASS: Record = {
+ running: 'is-running jx-anim-ripple',
+ success: 'is-success',
+ failed: 'is-failed',
+};
+
/** Convert a 5-field cron expression to a human-readable Chinese string. */
export function cronToHumanReadable(cron: string): string {
const parts = cron.trim().split(/\s+/);
diff --git a/src/frontend/src/components/myspace/MySpacePanel.tsx b/src/frontend/src/components/myspace/MySpacePanel.tsx
index 0d1c8256..4b045c0f 100644
--- a/src/frontend/src/components/myspace/MySpacePanel.tsx
+++ b/src/frontend/src/components/myspace/MySpacePanel.tsx
@@ -36,6 +36,7 @@ import { EASE, SPRING } from '../../utils/motionTokens';
import { DropOverlay } from '../common/DropOverlay';
import type { UploadProgress } from '../common/UploadProgressBar';
import { UploadProgressBar } from '../common/UploadProgressBar';
+import { CatalogPanel } from '../catalog';
import { ShareRecordsPage } from '../share';
import { DocumentList } from './DocumentList';
import { FavoriteList } from './FavoriteList';
@@ -49,6 +50,9 @@ import {
const TABS: Array<{ key: MySpaceTab; label: string }> = [
{ key: 'assets', label: t('文件资产') },
+ // 知识库原本是侧边栏一级入口,现在收进我的空间,紧挨「文件资产」——文件资产里的文档本来就是
+ // 「可按需加入私有知识库」的上游,两者放一起更顺。面板复用 CatalogPanel(embedded 模式去掉页头)。
+ { key: 'kb', label: t('知识库') },
{ key: 'favorites', label: t('会话收藏') },
{ key: 'shares', label: t('分享记录') },
{ key: 'notifications', label: t('消息通知') },
@@ -416,6 +420,7 @@ export function MySpacePanel() {
const currentHasMore = tab === 'favorites' ? favHasMore : hasMore;
const tabDescriptions: Record = {
assets: t('汇集与AI会话过程中上传或生成的各类文档与图片,可按需加入你创建的私有知识库'),
+ kb: t('浏览与管理知识库、查看文档列表,并支持文档内检索'),
favorites: t('集中管理你收藏的重要会话与自动化任务,方便快速回看与继续交流'),
shares: t('查看并管理已生成的分享链接与有效状态,查看浏览量'),
notifications: t('查看自动化任务执行结果通知,及时了解任务完成状态'),
@@ -573,13 +578,16 @@ export function MySpacePanel() {
)}
+ {/* 知识库 Tab 由 CatalogPanel 自管分页,别让文件资产的无限滚动跟着触发 loadMore */}
{
- if (tab === 'shares' || tab === 'notifications') return;
+ if (tab === 'shares' || tab === 'notifications' || tab === 'kb') return;
const element = event.currentTarget;
if (element.scrollHeight - element.scrollTop - element.clientHeight < 100) void loadMore();
}}>
- {tab === 'notifications' ? (
+ {tab === 'kb' ? (
+
+ ) : tab === 'notifications' ? (
) : tab === 'shares' ? (
diff --git a/src/frontend/src/components/sidebar/Sidebar.tsx b/src/frontend/src/components/sidebar/Sidebar.tsx
index 94791441..2a3c73f5 100644
--- a/src/frontend/src/components/sidebar/Sidebar.tsx
+++ b/src/frontend/src/components/sidebar/Sidebar.tsx
@@ -22,6 +22,7 @@ import { LAYOUT_ITEMS } from './items';
import { DEFAULT_SIDEBAR_ITEMS, DEFAULT_MENU_ITEMS } from '../../utils/pageConfigDefaults';
import { buildSidebarChatItems } from '../../utils/history';
import { resolveAvatarUrl } from '../../utils/avatar';
+import { loadJsonPref, saveJsonPref } from '../../storage';
import { getAutomationRuns } from '../../api';
import type { ChatItem, PanelKey } from '../../types';
import { HELP_DOCUMENTATION_URL, IS_COMMUNITY_EDITION_BUILD } from '../../edition';
@@ -44,6 +45,11 @@ interface SidebarProjectGroup {
const { Sider } = Layout;
+// 导航分组折叠偏好(key = LAYOUT_ITEMS 的 key)。与「侧栏是否收起」同属浏览器级 UI 偏好,
+// 不按账号隔离;读写复用 storage.ts 的通用 JSON 偏好 helper。
+const NAV_COLLAPSED_KEY = 'hugagent_ui_nav_collapsed_v1';
+const loadCollapsedNavGroups = () => loadJsonPref>(NAV_COLLAPSED_KEY, {});
+
// Chat list item add/remove animation: enter 0.22s float-up expand / exit 0.18s height collapse (items below smoothly reposition via layout).
const HISTORY_ITEM_ENTER = { duration: 0.22, ease: EASE.brandOut };
const HISTORY_ITEM_EXIT = { duration: 0.18, ease: EASE.exit };
@@ -94,6 +100,8 @@ export function Sidebar({
const sidebarLayoutKeys = usePageConfig('navigation.sidebar_items', DEFAULT_SIDEBAR_ITEMS);
const menuLayoutKeys = usePageConfig('navigation.menu_items', DEFAULT_MENU_ITEMS);
const { panel } = useCatalogStore();
+ const abilityTab = useCatalogStore((s) => s.abilityTab);
+ const setAbilityTab = useCatalogStore((s) => s.setAbilityTab);
const notifUnreadCount = useMySpaceStore((s) => s.notifUnreadCount);
const sidebarTasks = useAutomationChatStore((s) => s.sidebarTasks);
const sidebarPrefs = useAutomationChatStore((s) => s.sidebarPrefs);
@@ -105,7 +113,6 @@ export function Sidebar({
const toggleSidebarPinned = useAutomationChatStore((s) => s.toggleSidebarPinned);
const toggleSidebarFavorite = useAutomationChatStore((s) => s.toggleSidebarFavorite);
const updateAutomationTask = useAutomationStore((s) => s.updateTask);
- const setAutomationSelectedTaskId = useAutomationStore((s) => s.setSelectedTaskId);
// ── Projects section data: project list + the currently open project (for highlighting) ──
const projects = useProjectStore((s) => s.list);
const currentProjectId = useProjectStore((s) => s.currentProjectId);
@@ -159,6 +166,16 @@ export function Sidebar({
const toggleProjectCollapsed = (projectId: string) => {
setCollapsedProjects((prev) => ({ ...prev, [projectId]: !prev[projectId] }));
};
+ // 带二级列表的导航分组(目前是「能力中心」)的折叠状态,默认展开。
+ // 与历史分组不同,这个是导航偏好——写 localStorage,刷新后保持用户上次的选择。
+ const [collapsedNavGroups, setCollapsedNavGroups] = useState>(loadCollapsedNavGroups);
+ const toggleNavGroupCollapsed = (key: string) => {
+ setCollapsedNavGroups((prev) => {
+ const next = { ...prev, [key]: !prev[key] };
+ saveJsonPref(NAV_COLLAPSED_KEY, next);
+ return next;
+ });
+ };
const historyList = useMemo(
() => buildSidebarChatItems(store, sidebarTasks, sidebarPrefs),
@@ -482,10 +499,22 @@ export function Sidebar({
) : (
),
- onClick: () => {
- if (key === 'lab') setAutomationSelectedTaskId(null);
- onSetPanel(meta.targetPanel);
- },
+ // 有二级项的入口在菜单里渲染成子菜单,否则被放进用户菜单时这些类别就无从进入。
+ // 子菜单父项的 onClick 在 antd 里不会触发,所以两者二选一。
+ ...(meta.children?.length
+ ? {
+ children: meta.children.map((child) => ({
+ key: `${key}-${child.key}`,
+ label: child.label,
+ onClick: () => {
+ setAbilityTab(child.key);
+ onSetPanel(meta.targetPanel);
+ },
+ })),
+ }
+ : {
+ onClick: () => onSetPanel(meta.targetPanel),
+ }),
})),
...(!IS_COMMUNITY_EDITION_BUILD && (authUser?.can_system_config || authUser?.can_content_manage)
? [{ type: 'divider' as const }]
@@ -613,7 +642,6 @@ export function Sidebar({
className={`jx-miniRailBtn${active ? ' active' : ''}`}
aria-label={meta.label}
onClick={() => {
- if (key === 'lab') setAutomationSelectedTaskId(null);
onSetPanel(meta.targetPanel);
}}
>
@@ -682,24 +710,67 @@ export function Sidebar({
{/* Primary nav menu */}
{visibleSidebarItems.length > 0 && (
- {visibleSidebarItems.map(({ key, meta }) => (
-
)}
diff --git a/src/frontend/src/components/sidebar/items.ts b/src/frontend/src/components/sidebar/items.ts
index d1906310..4f06c99b 100644
--- a/src/frontend/src/components/sidebar/items.ts
+++ b/src/frontend/src/components/sidebar/items.ts
@@ -1,23 +1,44 @@
-import type { PanelKey } from '../../types';
+import type { AbilityTabKey, PanelKey } from '../../types';
+import { ABILITY_TABS } from '../catalog/abilityTabs';
import { t } from '../../i18n';
+/** 侧边栏二级导航项:目前只有「能力中心」用到,点了切页内展示的能力类别。
+ * 只有一个消费者,所以 key 直接收窄到 AbilityTabKey、点击直接调 setAbilityTab——
+ * 不为单一用例造一套通用的子导航协议。 */
+export interface LayoutSubItemMeta {
+ key: AbilityTabKey;
+ label: string;
+}
+
export interface LayoutItemMeta {
label: string;
icon: string;
targetPanel: PanelKey;
activePanels?: PanelKey[];
requiresLab?: boolean;
+ /** 有 children 的项在侧边栏里展开成「一级标题 + 二级列表」(参考设计里的「扩展」样式) */
+ children?: readonly LayoutSubItemMeta[];
}
+// 「知识库」不再是侧边栏一级入口——已并入「我的空间」的顶部 Tab(MySpacePanel TABS);
+// 「子智能体」也不再单列——已成为「能力中心」的二级导航项。两者的 PanelKey 仍保留可用,
+// 但从这里移除后,即便后台页面配置里还存着旧 key,itemVisible() 也会因 meta 为空而过滤掉。
export const LAYOUT_ITEMS: Record = {
- agents: { label: t('子智能体'), icon: '/home/sub-agent.svg', targetPanel: 'agents', activePanels: ['agents'] },
- kb: { label: t('知识库'), icon: '/home/knowledge.svg', targetPanel: 'kb', activePanels: ['kb'] },
- app_center: { label: t('应用中心'), icon: '/home/app-center.svg', targetPanel: 'app_center', activePanels: ['app_center'] },
- projects: { label: t('项目'), icon: '/home/projects.svg', targetPanel: 'projects', activePanels: ['projects', 'project_detail'] },
- my_space: { label: t('我的空间'), icon: '/home/my-space.svg', targetPanel: 'my_space', activePanels: ['my_space'] },
- settings: { label: t('设置'), icon: '/home/settings.svg', targetPanel: 'settings' },
- ability_center: { label: t('能力中心'), icon: '/home/capability.svg', targetPanel: 'ability_center' },
- lab: { label: t('实验室'), icon: '/home/new-icons/lab.svg', targetPanel: 'lab', requiresLab: true },
+ ability_center: {
+ label: t('能力中心'),
+ icon: '/home/capability.svg',
+ targetPanel: 'ability_center',
+ activePanels: ['ability_center'],
+ // 类别列表的真源在 catalog/abilityTabs.ts,与页面里的 pane 顺序共用一张表
+ children: ABILITY_TABS,
+ },
+ app_center: { label: t('应用中心'), icon: '/home/app-center.svg', targetPanel: 'app_center', activePanels: ['app_center'] },
+ automation: { label: t('定时任务'), icon: '/home/schedule.svg', targetPanel: 'automation', activePanels: ['automation'] },
+ sites: { label: t('站点'), icon: '/home/sites.svg', targetPanel: 'sites', activePanels: ['sites'], requiresLab: true },
+ projects: { label: t('项目'), icon: '/home/projects.svg', targetPanel: 'projects', activePanels: ['projects', 'project_detail'] },
+ my_space: { label: t('我的空间'), icon: '/home/my-space.svg', targetPanel: 'my_space', activePanels: ['my_space'] },
+ settings: { label: t('设置'), icon: '/home/settings.svg', targetPanel: 'settings' },
+ lab: { label: t('实验室'), icon: '/home/new-icons/lab.svg', targetPanel: 'lab', requiresLab: true },
};
export const LAYOUT_ITEM_KEYS = Object.keys(LAYOUT_ITEMS);
diff --git a/src/frontend/src/components/sites/SitesPanel.tsx b/src/frontend/src/components/sites/SitesPanel.tsx
index 108d63e1..10f5476f 100644
--- a/src/frontend/src/components/sites/SitesPanel.tsx
+++ b/src/frontend/src/components/sites/SitesPanel.tsx
@@ -3,7 +3,6 @@ import {
Button, Empty, Form, Input, Modal, Popconfirm, Select, Table, Tabs, Tag, message,
} from 'antd';
import {
- ArrowLeftOutlined,
AppstoreOutlined,
DeleteOutlined,
EditOutlined,
@@ -46,10 +45,6 @@ import { usePluginStore } from '../../stores/pluginStore';
import { t } from '../../i18n';
import '../../styles/sites.css';
-interface SitesPanelProps {
- onBack: () => void;
-}
-
/** Enter a "site" building session in the main chat: reuse the main chat input (with attachments/projects/+ menu),
* and auto-activate the installed "site" plugin (injecting the site-builder skill + site_publish tool). Site-building
* is purely plugin-gated — if not installed, guide the user to Capability Center → plugin install rather than forcing
@@ -404,7 +399,7 @@ function SiteManageModal({
);
}
-export function SitesPanel({ onBack }: SitesPanelProps) {
+export function SitesPanel() {
const [sites, setSites] = useState([]);
const [loading, setLoading] = useState(true);
const [managing, setManaging] = useState(null);
@@ -463,12 +458,9 @@ export function SitesPanel({ onBack }: SitesPanelProps) {
return (
-
-
} onClick={onBack} style={{ marginRight: 4 }} />
-
-
{t('站点')}
-
{t('将你的想法变成真实网站')}
-
+
+
{t('站点')}
+
{t('将你的想法变成真实网站')}
{t('创建')}
diff --git a/src/frontend/src/i18n/en/lab.ts b/src/frontend/src/i18n/en/lab.ts
index f18d4329..70fc1c9c 100644
--- a/src/frontend/src/i18n/en/lab.ts
+++ b/src/frontend/src/i18n/en/lab.ts
@@ -252,4 +252,38 @@ export const LAB_DICT: Record
= {
'配置智能体名称、角色设定、绑定工具与技能': 'Configure agent name, role prompt, tools and skills',
'保存更改': 'Save Changes',
'清空': 'Clear',
+
+ // ── 定时任务页(原「自动化」,已拆为侧边栏一级入口) ──
+ '新建定时任务': 'New Scheduled Task',
+ '定时任务创建成功': 'Scheduled task created',
+ '搜索定时任务…': 'Search scheduled tasks…',
+ '定时任务视图': 'Scheduled task views',
+ '我的定时任务': 'My Scheduled Tasks',
+ '按创建时间倒序': 'Newest first',
+ '按创建时间正序': 'Oldest first',
+ '按下次执行时间': 'By next run time',
+ '没有匹配的定时任务': 'No matching scheduled tasks',
+ '暂无定时任务': 'No scheduled tasks yet',
+ '换个关键词试试': 'Try a different keyword',
+ '创建定时任务来自动执行周期性 AI 工作': 'Create a scheduled task to run recurring AI work automatically',
+ '定时任务执行后,这里会汇总每一次的运行结果': 'Once tasks run, every execution result is summarised here',
+ '未命名任务': 'Untitled task',
+ '{n} 秒': '{n}s',
+
+ // ── 推荐任务轮播 ──
+ '推荐任务': 'Recommended Tasks',
+ '上一组': 'Previous',
+ '下一组': 'Next',
+ '每日行业简报': 'Daily Industry Briefing',
+ '检索过去 24 小时内本行业的重要新闻、政策与竞品动态,按「事实 / 影响 / 建议关注」三段式整理成一份不超过 800 字的简报,并标注信息来源链接。': 'Search the past 24 hours for major industry news, policy changes and competitor moves, then write a briefing of at most 800 words structured as Facts / Impact / What to watch, with source links.',
+ '工作日报汇总': 'Daily Work Report',
+ '汇总我今天的工作内容,按「已完成事项(含量化结果)/ 进行中事项(含阻塞点)/ 明日计划」三部分输出,语气简洁、条目化,可直接粘贴到日报系统。': 'Summarise what I did today in three sections — Completed (with metrics) / In progress (with blockers) / Plan for tomorrow — concise and bulleted, ready to paste into a reporting tool.',
+ '每周工作周报': 'Weekly Report',
+ '回顾本周的会话记录与产出文件,生成一份周报:本周关键进展、遇到的问题与解决方式、下周重点计划。每部分不超过 5 条。': 'Review this week\'s conversations and output files, then produce a weekly report: key progress, problems and how they were solved, and next week\'s priorities. At most 5 bullets per section.',
+ '竞品动态监测': 'Competitor Watch',
+ '搜索我关注的竞品在最近一周的产品发布、融资、人事与公开演讲信息,逐条给出时间、来源与一句话要点,并在末尾总结值得警惕的两个趋势。': 'Search the past week for product launches, funding, personnel changes and public talks from the competitors I follow. List each with date, source and a one-line takeaway, then close with the two trends worth watching.',
+ '知识库新增速览': 'Knowledge Base Digest',
+ '检查我的知识库在过去一天新增或更新的文档,逐份给出 3 句话摘要与关键结论,最后列出需要我进一步确认的问题。': 'Check which documents in my knowledge base were added or updated in the last day. Give a three-sentence summary and key conclusion for each, then list the open questions that need my confirmation.',
+ '周会材料准备': 'Weekly Meeting Prep',
+ '提前梳理本周周会需要的材料:本周关键数据变化、上周遗留事项的进展、需要在会上拍板的决策点,输出成一页会议提纲。': 'Prepare the material for this week\'s meeting: key metric movements, progress on last week\'s open items, and the decisions that need to be made — as a one-page agenda.',
};
diff --git a/src/frontend/src/i18n/en/myspace.ts b/src/frontend/src/i18n/en/myspace.ts
index 8c8ca412..27cebcd7 100644
--- a/src/frontend/src/i18n/en/myspace.ts
+++ b/src/frontend/src/i18n/en/myspace.ts
@@ -189,4 +189,7 @@ export const MYSPACE_DICT: Record = {
'下载原文件': 'Download original file',
'松开,上传到当前文件夹': 'Release to upload to the current folder',
'松开,上传到本项目': 'Release to upload to this project',
+
+ // ── 知识库 Tab(并入我的空间) ──
+ '浏览与管理知识库、查看文档列表,并支持文档内检索': 'Browse and manage knowledge bases, view document lists, and search within documents',
};
diff --git a/src/frontend/src/i18n/en/panels.ts b/src/frontend/src/i18n/en/panels.ts
index eebdffc9..f2ed33ab 100644
--- a/src/frontend/src/i18n/en/panels.ts
+++ b/src/frontend/src/i18n/en/panels.ts
@@ -427,4 +427,8 @@ export const PANELS_DICT: Record = {
'本轮调用的既有能力': 'Existing capabilities used this turn',
'能力入库': 'Capability stored',
'未通过': 'Not passed',
+
+ // ── 侧边栏导航改版:定时任务 / 站点 一级入口,能力中心四 Tab ──
+ '连接器': 'Connectors',
+ '定时任务': 'Scheduled Tasks',
};
diff --git a/src/frontend/src/storage.ts b/src/frontend/src/storage.ts
index 37ecf15d..e28677a6 100755
--- a/src/frontend/src/storage.ts
+++ b/src/frontend/src/storage.ts
@@ -148,6 +148,31 @@ if (typeof window !== 'undefined') {
});
}
+/**
+ * 读写一个「JSON 对象」型 localStorage 偏好项。
+ *
+ * 这个 guard + try/JSON.parse + 类型兜底的组合此前在 catalogStore / uiStore /
+ * automationChatStore 里各抄了一遍;新的偏好项直接用这两个函数,别再抄第五份。
+ * 需要按账号隔离的数据请先用 {@link userScopedKey} 包一下 key。
+ */
+export function loadJsonPref(key: string, fallback: T): T {
+ if (typeof window === 'undefined') return fallback;
+ try {
+ const raw = window.localStorage.getItem(key);
+ const parsed = raw ? JSON.parse(raw) : null;
+ return parsed && typeof parsed === 'object' ? (parsed as T) : fallback;
+ } catch {
+ return fallback;
+ }
+}
+
+export function saveJsonPref(key: string, value: object) {
+ if (typeof window === 'undefined') return;
+ try {
+ window.localStorage.setItem(key, JSON.stringify(value));
+ } catch { /* localStorage unavailable (private mode / quota) */ }
+}
+
export function loadCatalog(): Catalog {
try {
const raw = localStorage.getItem(ENABLE_KEY);
diff --git a/src/frontend/src/stores/catalogStore.ts b/src/frontend/src/stores/catalogStore.ts
index b06f3417..d260f2e0 100644
--- a/src/frontend/src/stores/catalogStore.ts
+++ b/src/frontend/src/stores/catalogStore.ts
@@ -1,5 +1,5 @@
import { create } from 'zustand';
-import type { Catalog, PanelKey } from '../types';
+import type { AbilityTabKey, Catalog, PanelKey } from '../types';
import { getCatalog, updateCatalogItem } from '../api';
import { loadCatalog, saveCatalog } from '../storage';
@@ -7,10 +7,13 @@ const PANEL_STORAGE_KEY = 'hugagent_active_panel';
// Keep in sync with authStore.LOGIN_LANDING_KEY (inlined to avoid a circular import).
const LOGIN_LANDING_KEY = 'hugagent_login_landing';
+// 可以从 localStorage 恢复的面板。'skills' / 'agents' / 'mcp' 已并入能力中心的二级导航、
+// 不再是独立面板,故不在此列——残留的旧值会退回 'chat'。
const VALID_PANELS: readonly PanelKey[] = [
- 'chat', 'skills', 'agents', 'mcp', 'kb', 'docs',
+ 'chat', 'kb', 'docs',
'app_center', 'settings', 'share_records', 'my_space',
'ability_center', 'lab', 'projects', 'project_detail',
+ 'automation', 'sites',
] as const;
function loadActivePanel(): PanelKey {
@@ -45,12 +48,19 @@ interface CatalogState {
manageQuery: string;
/** Selected catalog item id */
selectedId: string | null;
+ /** 能力中心当前展开的能力类别——由侧边栏的二级导航项驱动,页面本身不再有 Tab 栏 */
+ abilityTab: AbilityTabKey;
+ /** 本次会话中访问过的能力类别。能力中心据此只挂载访问过的 pane——四个 pane 各自会在挂载时
+ * 拉一份列表,全挂等于一次打出四份请求。累积在这里而不是页面内部:切换动作发生在侧边栏,
+ * 放在 setAbilityTab 里是唯一不需要靠 effect 追赶的位置。 */
+ visitedAbilityTabs: readonly AbilityTabKey[];
setCatalog: (catalog: Catalog) => void;
setCatalogLoading: (v: boolean) => void;
setPanel: (panel: PanelKey) => void;
setManageQuery: (query: string) => void;
setSelectedId: (id: string | null) => void;
+ setAbilityTab: (tab: AbilityTabKey) => void;
/** Fetch catalog from backend, merge with localStorage enabled state */
fetchCatalog: () => Promise;
@@ -65,6 +75,8 @@ export const useCatalogStore = create((set, get) => ({
panelEntryNonce: 0,
manageQuery: '',
selectedId: null,
+ abilityTab: 'agents',
+ visitedAbilityTabs: ['agents'],
setCatalog: (catalog) => {
set({ catalog });
@@ -82,6 +94,13 @@ export const useCatalogStore = create((set, get) => ({
},
setManageQuery: (query) => set({ manageQuery: query }),
setSelectedId: (id) => set({ selectedId: id }),
+ setAbilityTab: (tab) => set((state) => ({
+ abilityTab: tab,
+ manageQuery: '',
+ visitedAbilityTabs: state.visitedAbilityTabs.includes(tab)
+ ? state.visitedAbilityTabs
+ : [...state.visitedAbilityTabs, tab],
+ })),
fetchCatalog: async () => {
try {
diff --git a/src/frontend/src/stores/chatStore.ts b/src/frontend/src/stores/chatStore.ts
index 14124c4a..4294f7fe 100644
--- a/src/frontend/src/stores/chatStore.ts
+++ b/src/frontend/src/stores/chatStore.ts
@@ -224,9 +224,6 @@ interface ChatState {
* and editing happen inside the project folder; messages are sent with project_id
* automatically); opts.title is used as the chat/project display name. */
enterSiteMode: (opts?: { projectId?: string; projectName?: string; title?: string }) => boolean;
- /** Request to open the "My Sites" list (top-right of the site-building page → Lab SitesPanel list). */
- sitesListRequested: boolean;
- setSitesListRequested: (v: boolean) => void;
setEditingMessageTs: (ts: number | null) => void;
bumpSessionLoadEpoch: () => void;
/** Truncate messages from the given timestamp (inclusive) */
@@ -695,9 +692,6 @@ export const useChatStore = create((set, get) => ({
return !!sitesPlugin;
},
- sitesListRequested: false,
- setSitesListRequested: (v) => set({ sitesListRequested: v }),
-
newChat: () => {
const id = nowId('chat');
saveCurrentChatId(get().currentUserId, id);
diff --git a/src/frontend/src/styles/automation.css b/src/frontend/src/styles/automation.css
index db28ebec..d2a8150a 100644
--- a/src/frontend/src/styles/automation.css
+++ b/src/frontend/src/styles/automation.css
@@ -360,7 +360,7 @@
.jx-automation-detail-runRow:last-child { border-bottom: none; }
.jx-automation-detail-runRow:hover { background: #F7F9FC; }
-.jx-automation-detail-runRow-dot {
+.jx-automation-runDot {
width: 8px;
height: 8px;
border-radius: 50%;
@@ -368,13 +368,13 @@
}
/* 运行中扩散涟漪:组件层挂 .jx-anim-ripple(motion.css 原语,compositor-only),
这里只调色与扩散倍数(8px 圆点 ×2.5 ≈ 原 6px box-shadow 扩散幅度) */
-.jx-automation-detail-runRow-dot.is-running {
+.jx-automation-runDot.is-running {
background: #126DFF;
--ripple-color: var(--color-primary);
--ripple-scale: 2.5;
}
-.jx-automation-detail-runRow-dot.is-success { background: #02B589; }
-.jx-automation-detail-runRow-dot.is-failed { background: #FC5D5D; }
+.jx-automation-runDot.is-success { background: #02B589; }
+.jx-automation-runDot.is-failed { background: #FC5D5D; }
.jx-automation-detail-runRow-time {
flex-shrink: 0;
@@ -685,3 +685,290 @@
height: 14px;
border-radius: 6px;
}
+
+/* ══════════════════════════════════════════════════════════════
+ 定时任务页(侧边栏一级面板):页头工具区 / 推荐任务轮播 /
+ 我的定时任务·执行记录 Tab / 空态 / 聚合执行记录列表
+ ══════════════════════════════════════════════════════════════ */
+
+.jx-automation-headerRight {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.jx-automation-search {
+ width: 240px;
+ border-radius: var(--radius-sm);
+}
+
+/* ── 推荐任务轮播 ── */
+
+.jx-automation-presets {
+ margin: 4px 0 28px;
+}
+
+.jx-automation-presetsHead {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+}
+
+.jx-automation-presetsTitle {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--color-text);
+}
+
+.jx-automation-presetsNav {
+ display: flex;
+ gap: 8px;
+}
+
+.jx-automation-presetsArrow {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ border: 1px solid var(--color-border);
+ background: #fff;
+ color: var(--color-text-tertiary);
+ font-size: 12px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: background .15s, color .15s, border-color .15s;
+}
+
+.jx-automation-presetsArrow:hover:not(:disabled) {
+ background: var(--color-bg-gray);
+ color: var(--color-text);
+}
+
+.jx-automation-presetsArrow:disabled {
+ opacity: .4;
+ cursor: default;
+}
+
+.jx-automation-presetsTrack {
+ display: flex;
+ gap: 16px;
+ overflow-x: auto;
+ scroll-snap-type: x proximity;
+ padding-bottom: 4px;
+ /* 轨道自身不显示滚动条,靠左右箭头翻页 */
+ scrollbar-width: none;
+}
+
+.jx-automation-presetsTrack::-webkit-scrollbar {
+ display: none;
+}
+
+.jx-automation-presetCard {
+ flex: 0 0 300px;
+ scroll-snap-align: start;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ text-align: left;
+ padding: 18px 20px;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--color-border);
+ background: #fff;
+ cursor: pointer;
+ /* 位移/时长交给 motion.css 的 .jx-card-lift 原语(含 reduced-motion 降级),这里只管配色 */
+}
+
+.jx-automation-presetCard:hover {
+ border-color: var(--color-primary);
+ box-shadow: 0 4px 16px rgba(18, 109, 255, .08);
+}
+
+.jx-automation-presetCard-title {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--color-text);
+}
+
+.jx-automation-presetCard-desc {
+ font-size: 13px;
+ line-height: 1.6;
+ color: var(--color-text-tertiary);
+ /* 卡片高度对齐:正文最多 3 行 */
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.jx-automation-presetCard-cron {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ align-self: flex-start;
+ margin-top: 4px;
+ padding: 4px 10px;
+ border-radius: var(--radius-xs);
+ background: var(--color-bg-gray);
+ font-size: 12px;
+ color: var(--color-text-secondary);
+}
+
+/* ── 列表区 Tab ── */
+
+.jx-automation-listHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 16px;
+}
+
+.jx-automation-tabs {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+}
+
+.jx-automation-tab {
+ appearance: none;
+ border: none;
+ background: transparent;
+ padding: 0;
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--color-text-placeholder);
+ cursor: pointer;
+ transition: color .15s;
+}
+
+.jx-automation-tab:hover {
+ color: var(--color-text-secondary);
+}
+
+.jx-automation-tab.active {
+ color: var(--color-text);
+}
+
+.jx-automation-sort {
+ min-width: 150px;
+}
+
+/* ── 空态 ── */
+
+.jx-automation-empty {
+ border: 1px dashed var(--color-border);
+ border-radius: var(--radius-md);
+ padding: 56px 24px;
+}
+
+.jx-automation-emptyTitle {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--color-text-secondary);
+ margin-bottom: 4px;
+}
+
+.jx-automation-emptyDesc {
+ font-size: 13px;
+ color: var(--color-text-placeholder);
+ margin-bottom: 12px;
+}
+
+/* ── 聚合执行记录 ── */
+
+.jx-automation-runListHead {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 8px;
+}
+
+.jx-automation-runListCount {
+ font-size: 12px;
+ color: var(--color-text-placeholder);
+}
+
+.jx-automation-runRow {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 14px 16px;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--color-border);
+ background: #fff;
+ margin-bottom: 8px;
+ cursor: pointer;
+}
+
+.jx-automation-runRow:hover {
+ border-color: var(--color-primary);
+ box-shadow: 0 2px 10px rgba(18, 109, 255, .06);
+}
+
+/* 圆点复用上面 detail 列表的 .jx-automation-runDot(含 is-running 的涟漪配色),
+ 这里只补一点上边距让它对齐多行文案的首行。 */
+.jx-automation-runRow > .jx-automation-runDot { margin-top: 6px; }
+
+.jx-automation-runMain {
+ flex: 1;
+ min-width: 0;
+}
+
+.jx-automation-runTitle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.jx-automation-runTaskName {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--color-text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.jx-automation-runStatus {
+ font-size: 12px;
+ color: var(--color-text-tertiary);
+ flex-shrink: 0;
+}
+
+.jx-automation-runSummary {
+ margin-top: 4px;
+ font-size: 13px;
+ line-height: 1.6;
+ color: var(--color-text-tertiary);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.jx-automation-runError {
+ margin-top: 4px;
+ font-size: 12px;
+ color: var(--color-error);
+}
+
+.jx-automation-runMeta {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 4px;
+ flex-shrink: 0;
+ font-size: 12px;
+ color: var(--color-text-placeholder);
+}
+
+.jx-automation-runDuration {
+ font-variant-numeric: tabular-nums;
+}
+
+@media (max-width: 768px) {
+ .jx-automation-headerRight { gap: 8px; }
+ .jx-automation-search { width: 140px; }
+ .jx-automation-presetCard { flex-basis: 260px; }
+}
diff --git a/src/frontend/src/styles/catalog.css b/src/frontend/src/styles/catalog.css
index 54ed3d5f..36e484e9 100644
--- a/src/frontend/src/styles/catalog.css
+++ b/src/frontend/src/styles/catalog.css
@@ -188,77 +188,15 @@
}
/* ── 能力中心 ── */
+/* 类别切换已移到左侧边栏的二级导航,页面内不再有 Tab 栏 ——
+ 顶部不留给 Tab 的那段留白,直接由 pane 自己的页头顶到最上面。 */
.jx-abilityCenter{
min-height:calc(100dvh - 98px);
display:flex;
flex-direction:column;
- padding:18px 20px 28px;
- gap:16px;
+ padding:0 20px 28px;
box-sizing:border-box;
}
-.jx-abilityCenterHeader{
- flex-shrink:0;
-}
-.jx-abilityCenterHero{
- display:flex;
- justify-content:center;
- padding-top:2px;
-}
-.jx-abilityCenterTabs{
- display:inline-flex;
- align-items:center;
- gap:0;
- width:fit-content;
- max-width:100%;
- margin:0 auto;
- padding:7px 12px;
- background:#FFFFFF;
- border:1px solid rgba(15,23,42,.08);
- border-radius:999px;
- box-shadow:0 8px 22px rgba(15,23,42,.035);
-}
-.jx-abilityCenterTab{
- appearance:none;
- border:none;
- background:transparent;
- border-radius:999px;
- padding:5px 16px;
- font-size:15px;
- font-weight:500;
- color:rgba(15,23,42,.64);
- cursor:pointer;
- transition:color .18s ease;
- display:inline-flex;
- align-items:center;
- gap:7px;
- white-space:nowrap;
-}
-.jx-abilityCenterTab:hover{
- color:#111827;
-}
-.jx-abilityCenterTab.active{
- color:#111827;
- font-weight:600;
-}
-.jx-abilityCenterTabLabel{
- line-height:1;
- display:inline-flex;
- align-items:baseline;
- gap:4px;
-}
-.jx-abilityCenterTabArrow{
- font-size:17px;
- line-height:1;
- font-weight:400;
- transform:translateY(-.5px);
- opacity:.72;
-}
-.jx-abilityCenterDivider{
- width:1px;
- height:22px;
- background:rgba(15,23,42,.10);
- margin:0 2px;
-}
.jx-abilityCenterBody{
min-height:0;
flex:1;
@@ -278,28 +216,7 @@
@media (max-width: 768px){
.jx-abilityCenter{
- padding:12px 12px 24px;
- gap:12px;
- }
- .jx-abilityCenterTabs{
- width:100%;
- padding:5px 6px;
- box-sizing:border-box;
- }
- .jx-abilityCenterTab{
- flex:1 1 0;
- min-width:0;
- justify-content:center;
- padding:7px 5px;
- font-size:13px;
- }
- .jx-abilityCenterDivider{
- height:18px;
- margin:0;
- }
- .jx-abilityCenterTabLabel{
- min-width:0;
- white-space:nowrap;
+ padding:0 12px 24px;
}
.jx-abilityCenter .jx-sk-page,
.jx-abilityCenter .jx-mcp-page{
@@ -1693,12 +1610,6 @@
padding: 20px 0 60px;
}
-/* Ability center: when detail is shown, remove extra top gap */
-.jx-abilityCenter--detail {
- padding-top: 0;
- gap: 0;
-}
-
/* ══════════════════════════════════════════════════════════════════
Agent Page — pixel-matched to UI design spec screenshot
══════════════════════════════════════════════════════════════════ */
@@ -2665,6 +2576,15 @@
background:transparent;
}
+/* 内嵌在「我的空间 → 知识库」Tab 里:外层 shell 已经限宽 980 并自带滚动,
+ 这里去掉自身的限宽/左右内边距/整屏最小高度,跟其它 Tab 的内容左右缘对齐。 */
+.jx-kbView--embedded{
+ min-height:0;
+ max-width:none;
+ margin:0;
+ padding:4px 0 24px;
+}
+
.jx-kbHero{
background:#fff;
border:1px solid #E3E6EA;
diff --git a/src/frontend/src/styles/mobile.css b/src/frontend/src/styles/mobile.css
index dacf8ee1..b95a3c11 100644
--- a/src/frontend/src/styles/mobile.css
+++ b/src/frontend/src/styles/mobile.css
@@ -11,10 +11,6 @@
/* Apple mobile refinement: skill detail identity row + project workspaces */
@media (max-width:768px){
- .jx-abilityCenter--detail{
- padding:10px 16px 48px;
- }
-
.jx-abilityCenter .jx-sk-detailPage{
width:100%;
max-width:none;
@@ -1498,33 +1494,6 @@
padding:10px 16px 48px;
}
- .jx-abilityCenterTabs{
- padding:5px;
- border:1px solid rgba(255,255,255,.86);
- border-radius:22px;
- background:var(--jx-mobile-material);
- box-shadow:0 9px 28px rgba(42,61,94,.065),inset 0 0 0 .5px rgba(15,23,42,.04);
- backdrop-filter:saturate(180%) blur(22px);
- -webkit-backdrop-filter:saturate(180%) blur(22px);
- }
-
- .jx-abilityCenterTab{
- min-height:38px;
- border-radius:17px;
- color:#667085;
- font-size:13px;
- }
-
- .jx-abilityCenterTab.active{
- background:rgba(255,255,255,.92);
- color:#101828;
- box-shadow:0 3px 10px rgba(42,61,94,.07);
- }
-
- .jx-abilityCenterDivider{
- opacity:.55;
- }
-
.jx-abilityCenter .jx-sk-title,
.jx-abilityCenter .jx-mcp-title{
color:var(--jx-mobile-label);
diff --git a/src/frontend/src/styles/sidebar.css b/src/frontend/src/styles/sidebar.css
index ef7f3212..b1d01b5a 100644
--- a/src/frontend/src/styles/sidebar.css
+++ b/src/frontend/src/styles/sidebar.css
@@ -187,8 +187,8 @@
.jx-logo{ width:40px; height:40px; flex-shrink:0; }
.jx-logoImg{ width:100%; height:100%; object-fit:contain; display:block; }
.jx-brand-text{ flex:1; min-width:0; display:flex; flex-direction:column; justify-content:center; min-height:40px; }
-.jx-brand-title{ font-weight:800; font-size:20px; line-height:1.2; color:#1e293b; }
-.jx-brand-sub{ font-size:12px; color:rgba(15,23,42,.45); margin-top:1px; }
+.jx-brand-title{ font-weight:800; font-size:18px; line-height:1.2; color:#1e293b; }
+.jx-brand-sub{ font-size:11px; color:rgba(15,23,42,.45); margin-top:1px; }
/* Search button */
.jx-searchBtn{
@@ -221,7 +221,7 @@
border-radius:100px;
background:#fff;
color:#126DFF;
- font-size:14px;
+ font-size:13px;
font-weight:600;
display:flex;
align-items:center;
@@ -268,7 +268,7 @@
border:none;
background:transparent;
width:248px;
- height:36px;
+ height:34px;
display:flex;
align-items:center;
gap:8px;
@@ -276,7 +276,7 @@
padding:0 12px 0 4px;
border-radius:12px;
cursor:pointer;
- font-size:14px;
+ font-size:13px;
font-weight:400;
color:#262626;
text-align:left;
@@ -289,7 +289,69 @@
color:#262626;
font-weight:400;
}
-.jx-navItemIcon{ width:20px; height:20px; flex-shrink:0; opacity:.7; }
+.jx-navItemIcon{ width:18px; height:18px; flex-shrink:0; opacity:.7; }
+
+/* ══════════════════════════════════════════
+ 带二级列表的导航分组(能力中心 → 智能体/技能/连接器/插件)
+ ══════════════════════════════════════════ */
+.jx-navGroup{ display:flex; flex-direction:column; gap:2px; }
+
+/* 二级列表:左侧一条竖线做层级引导线。
+ 线的 x = 一级行 padding-left 4 + 图标半宽 9 = 13px,正对父级图标中心。 */
+.jx-navSubList{
+ position:relative;
+ display:flex;
+ flex-direction:column;
+ gap:2px;
+ padding-top:2px;
+}
+.jx-navSubList::before{
+ content:"";
+ position:absolute;
+ left:13px;
+ /* 上端从父行底缘起、下端收到最后一项的中线,避免竖线戳出列表 */
+ top:0;
+ bottom:16px;
+ width:2px;
+ background:rgba(15,23,42,.10);
+ border-radius:2px;
+ pointer-events:none;
+}
+
+/* 二级项:纯文字无图标,左缘与一级项的文字对齐(4 + 图标 18 + gap 8 = 30) */
+.jx-navSubItem{
+ appearance:none;
+ border:none;
+ background:transparent;
+ position:relative;
+ width:248px;
+ height:32px;
+ display:flex;
+ align-items:center;
+ padding:0 12px 0 30px;
+ border-radius:12px;
+ cursor:pointer;
+ font-size:13px;
+ font-weight:400;
+ color:rgba(15,23,42,.62);
+ text-align:left;
+ transition:background .15s, color .15s, transform .12s;
+}
+.jx-navSubItem:hover{ background:#EBEDEE; color:#262626; }
+.jx-navSubItem:active{ transform:scale(.98); transition-duration:.06s; }
+.jx-navSubItem.active{ background:#EBEDEE; color:#262626; font-weight:500; }
+/* 选中项把引导线对应的那一节染成主色,指明"线上的哪一节是当前项"。
+ left/width 与 .jx-navSubList::before 完全一致,正好盖在竖线上。 */
+.jx-navSubItem.active::before{
+ content:"";
+ position:absolute;
+ left:13px;
+ top:7px;
+ bottom:7px;
+ width:2px;
+ border-radius:2px;
+ background:var(--color-primary, #126DFF);
+}
.jx-navItem:hover .jx-navItemIcon{ opacity:1; }
.jx-navItem.active .jx-navItemIcon{ opacity:1; }
@@ -352,7 +414,7 @@
.jx-historyGroupHeader:hover{ color:rgba(15,23,42,.72); }
.jx-historyGroupHeader:focus-visible{ outline:2px solid rgba(18,109,255,.32); outline-offset:0; }
.jx-historyGroupTitle{
- font-size:12px;
+ font-size:11px;
font-weight:500;
color:inherit;
user-select:none;
@@ -382,14 +444,17 @@
该范式解决(fr 不需要知道具体像素高)。
内层 min-height:0 + overflow:hidden 是 grid 行收缩生效的前提;
折叠完成后用 visibility 兜底摘除 tab 焦点(延迟到塌缩动画结束才隐藏)。 */
-.jx-historyGroupExpand{ --expand-dur:var(--motion-duration-normal); }
-.jx-historyGroupExpand > .jx-historyGroupList{
+.jx-historyGroupExpand,
+.jx-navGroupExpand{ --expand-dur:var(--motion-duration-normal); }
+.jx-historyGroupExpand > .jx-historyGroupList,
+.jx-navGroupExpand > .jx-navSubList{
min-height:0;
overflow:hidden;
visibility:visible;
transition:visibility 0s;
}
-.jx-historyGroup--collapsed .jx-historyGroupExpand > .jx-historyGroupList{
+.jx-historyGroup--collapsed .jx-historyGroupExpand > .jx-historyGroupList,
+.jx-navGroup--collapsed .jx-navGroupExpand > .jx-navSubList{
visibility:hidden;
transition-delay:var(--expand-dur, .25s);
}
@@ -425,14 +490,14 @@
padding:0; color:inherit; text-align:left; cursor:pointer;
}
.jx-projectRowIcon{
- font-size:14px;
+ font-size:13px;
color:rgba(15,23,42,.55);
flex-shrink:0;
transition:color .15s;
}
.jx-projectRowName{
flex:1; min-width:0;
- font-size:13px; font-weight:500; color:#262626;
+ font-size:12px; font-weight:500; color:#262626;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
transition:color .15s;
}
@@ -520,13 +585,13 @@
justify-content:center;
margin-right:4px;
color:rgba(15,23,42,.45);
- font-size:11px;
+ font-size:10px;
opacity:.9;
transition:color .15s ease;
}
.jx-historyItem:hover .jx-historyPinIcon{ color:rgba(15,23,42,.72); }
.jx-historyTitle{
- font-size:13px; font-weight:500; color:#262626;
+ font-size:12px; font-weight:500; color:#262626;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
flex:1;
min-width:0;
@@ -537,7 +602,7 @@
/* ── History type icons (agent / code / plan) ── */
.jx-historyTypeIcon{
flex-shrink:0;
- font-size:13px;
+ font-size:12px;
margin-right:5px;
color:rgba(15,23,42,.38);
transition:color .15s;
@@ -599,7 +664,7 @@
animation:jx-kf-renameIn var(--motion-duration-fast) var(--motion-ease-standard) both;
}
.jx-historyEditInput .ant-input{ border-radius:8px; }
-.jx-historyEmpty{ padding:20px 8px; font-size:12px; color:rgba(15,23,42,.40); text-align:center; }
+.jx-historyEmpty{ padding:20px 8px; font-size:11px; color:rgba(15,23,42,.40); text-align:center; }
/* 关键词高亮:被 utils/highlight.ts 用于侧栏 + 搜索弹窗的高亮 span */
.jx-searchHighlight{ color:var(--color-primary); font-weight:600; }
@@ -618,7 +683,7 @@
flex:1; min-width:0;
display:flex; align-items:center; gap:6px;
padding:6px 4px; border-radius:8px; background:transparent;
- cursor:pointer; font-size:13px; font-weight:500; color:#262626;
+ cursor:pointer; font-size:12px; font-weight:500; color:#262626;
text-align:left; transition:background .15s;
}
.jx-userInfoBtn:hover{ background:rgba(0,0,0,.04); }
@@ -638,7 +703,7 @@
border-radius:10px; padding:4px;
box-shadow:0 8px 24px rgba(15,23,42,.12); border:1px solid rgba(15,23,42,.08); min-width:140px;
}
-.jx-chatItemMenu.ant-dropdown .ant-dropdown-menu-item{ border-radius:6px; padding:7px 10px; font-size:13px; }
+.jx-chatItemMenu.ant-dropdown .ant-dropdown-menu-item{ border-radius:6px; padding:7px 10px; font-size:12px; }
.jx-chatItemMenu.ant-dropdown .ant-dropdown-menu-item-divider{ margin:4px 0; background:rgba(15,23,42,.08); }
.jx-settingsMenu.ant-dropdown .ant-dropdown-menu{
@@ -646,7 +711,7 @@
box-shadow:0 8px 32px rgba(15,23,42,.14); border:1px solid rgba(15,23,42,.08); min-width:160px;
}
.jx-settingsMenu.ant-dropdown .ant-dropdown-menu-item{
- border-radius:8px; padding:8px 12px; font-size:13px; color:rgba(15,23,42,.82);
+ border-radius:8px; padding:8px 12px; font-size:12px; color:rgba(15,23,42,.82);
}
.jx-settingsMenu.ant-dropdown .ant-dropdown-menu-item:hover{ background:rgba(18,109,255,.06); color:#126DFF; }
.jx-settingsMenu.ant-dropdown .ant-dropdown-menu-item-divider{ margin:4px 0; background:rgba(15,23,42,.08); }
diff --git a/src/frontend/src/types.ts b/src/frontend/src/types.ts
index 8defff41..795fd1dc 100755
--- a/src/frontend/src/types.ts
+++ b/src/frontend/src/types.ts
@@ -12,7 +12,10 @@ import type {
export type { MarketVisibilityValue } from './editionMarketplaceTypes';
-export type PanelKey = 'chat' | 'skills' | 'agents' | 'mcp' | 'kb' | 'docs' | 'app_center' | 'settings' | 'share_records' | 'my_space' | 'ability_center' | 'lab' | 'projects' | 'project_detail';
+export type PanelKey = 'chat' | 'skills' | 'agents' | 'mcp' | 'kb' | 'docs' | 'app_center' | 'settings' | 'share_records' | 'my_space' | 'ability_center' | 'lab' | 'projects' | 'project_detail' | 'automation' | 'sites';
+
+/** 能力中心的四类能力。选中项由侧边栏的二级导航驱动,所以状态放在 catalogStore 而非页面内部。 */
+export type AbilityTabKey = 'agents' | 'skills' | 'mcp' | 'plugins';
// ─── Projects (Claude-style workspaces) ─────────────────────────────────────
@@ -1123,7 +1126,7 @@ export interface SystemConfigGroup {
// ── My Space types ─────────────────────────────────────────────────────────
-export type MySpaceTab = 'assets' | 'favorites' | 'shares' | 'notifications';
+export type MySpaceTab = 'assets' | 'kb' | 'favorites' | 'shares' | 'notifications';
// ── Automation types ────────────────────────────────────────────
export type AutomationTaskType = 'prompt' | 'plan';
diff --git a/src/frontend/src/utils/pageConfigDefaults.ts b/src/frontend/src/utils/pageConfigDefaults.ts
index ed24c32e..46c5becd 100644
--- a/src/frontend/src/utils/pageConfigDefaults.ts
+++ b/src/frontend/src/utils/pageConfigDefaults.ts
@@ -48,8 +48,16 @@ export interface PageConfig {
// "Projects" no longer appears in the main navigation by default: browsing/entering projects goes through the sidebar "Projects" group, and selecting/creating goes through the input-box
// project dropdown. LAYOUT_ITEMS still keeps the projects definition, and the backend page config can add it back as needed.
-export const DEFAULT_SIDEBAR_ITEMS = ['agents', 'kb', 'app_center', 'my_space'];
-export const DEFAULT_MENU_ITEMS = ['settings', 'ability_center', 'lab'];
+// ⚠️ 这两个数组只是**出厂默认值**,不是最终形态——运行时以 DB 里的
+// `page_config.navigation.sidebar_items / menu_items` 为准(见 Sidebar.tsx 的 usePageConfig)。
+// 管理员随时可在 /config →「页面配置」里把任意入口在「侧边栏 / 用户菜单 / 隐藏」三栏间拖动、
+// 调序、或一键「恢复默认」回到这里的取值。新增入口只要进 LAYOUT_ITEMS,就自动出现在那个编排界面里。
+//
+// 当前默认的取舍:能力中心从底部菜单提到一级导航(其四类能力做成二级导航);定时任务从应用中心、
+// 站点从实验室各自拆出来成一级导航;知识库并入我的空间的 Tab、子智能体并入能力中心的二级导航,
+// 因此都不再单列;应用中心与项目反向下沉到用户下拉菜单,主导航只留高频入口。
+export const DEFAULT_SIDEBAR_ITEMS = ['ability_center', 'automation', 'sites', 'my_space'];
+export const DEFAULT_MENU_ITEMS = ['settings', 'app_center', 'projects', 'lab'];
export const DEFAULT_PAGE_CONFIG: PageConfig = {
branding: {
@@ -67,7 +75,7 @@ export const DEFAULT_PAGE_CONFIG: PageConfig = {
ability_center: '能力中心',
skills: '技能库',
agents: '子智能体',
- mcp: 'MCP工具库',
+ mcp: '连接器',
kb: '知识库',
docs: '更新记录',
app_center: '应用中心',
@@ -76,12 +84,14 @@ export const DEFAULT_PAGE_CONFIG: PageConfig = {
my_space: '我的空间',
projects: '项目',
project_detail: '项目详情',
+ automation: '定时任务',
+ sites: '站点',
},
panel_subtitles: {
- ability_center: '智能体基础能力管理,包含技能库以及MCP工具库',
+ ability_center: '智能体基础能力管理,包含智能体、技能、连接器与插件',
skills: '启用/停用技能,并查看详细介绍、输入输出与示例。',
agents: '选择与启用子智能体,并查看其职责边界与路由提示。',
- mcp: '管理 MCP 工具服务,并查看其作用范围与可靠性影响。',
+ mcp: '管理 MCP 连接器服务,并查看其作用范围与可靠性影响。',
kb: '浏览知识库、查看文档列表,并支持文档内检索。',
docs: '查看功能更新、能力中心与平台说明。',
app_center: '基于 AI 能力的场景化智能应用',
@@ -90,6 +100,8 @@ export const DEFAULT_PAGE_CONFIG: PageConfig = {
my_space: '',
projects: '把对话、文件和指令打包成专属工作空间',
project_detail: '',
+ automation: '按计划自动执行任务,也可随时手动触发。在任意对话中描述你想定期做的事,即可快速创建',
+ sites: '在对话里描述需求,AI 生成完整网站并一键发布,由平台托管、凭链接即可访问',
},
admin_header: {
title: 'HugAgentOS — 后台管理',