diff --git a/src/backend/core/content/content_blocks.py b/src/backend/core/content/content_blocks.py index 12591506..b7ea3102 100644 --- a/src/backend/core/content/content_blocks.py +++ b/src/backend/core/content/content_blocks.py @@ -66,23 +66,27 @@ class ContentSnapshotError(ValueError): "ability_center": "能力中心", "skills": "技能库", "agents": "子智能体", - "mcp": "MCP工具库", + "mcp": "连接器", "kb": "知识库", "docs": "更新记录", "app_center": "应用中心", + "automation": "定时任务", + "sites": "站点", "projects": "项目", "lab": "实验室", "settings": "系统设置", "my_space": "我的空间", }, "panel_subtitles": { - "ability_center": "智能体基础能力管理,包含技能库以及MCP工具库", + "ability_center": "智能体基础能力管理,包含智能体、技能、连接器与插件", "skills": "启用/停用技能,并查看详细介绍、输入输出与示例。", "agents": "选择与启用子智能体,并查看其职责边界与路由提示。", - "mcp": "管理 MCP 工具服务,并查看其作用范围与可靠性影响。", + "mcp": "管理 MCP 连接器服务,并查看其作用范围与可靠性影响。", "kb": "浏览知识库、查看文档列表,并支持文档内检索。", "docs": "查看功能更新、能力中心与平台说明。", "app_center": "基于 AI 能力的场景化智能应用", + "automation": "按计划自动执行任务,也可随时手动触发。在任意对话中描述你想定期做的事,即可快速创建", + "sites": "在对话里描述需求,AI 生成完整网站并一键发布,由平台托管、凭链接即可访问", "projects": "把对话、文件和指令打包成专属工作空间", "lab": "AI 能力实验性应用", "settings": "", @@ -102,8 +106,11 @@ class ContentSnapshotError(ValueError): "config_label": "系统配置", "apidoc_label": "接口文档", }, - "sidebar_items": ["agents", "kb", "app_center", "projects", "my_space"], - "menu_items": ["settings", "ability_center", "lab"], + # 与前端 utils/pageConfigDefaults.ts 的 DEFAULT_SIDEBAR_ITEMS / DEFAULT_MENU_ITEMS 保持一致。 + # 这只是新装环境的出厂值——已存在的部署以 DB 现有取值为准,由 backfill_navigation_entries + # 补齐新增条目,管理员随后可在 /config「页面配置」里自由编排。 + "sidebar_items": ["ability_center", "automation", "sites", "my_space"], + "menu_items": ["settings", "app_center", "projects", "lab"], }, "texts": { "input_placeholder": "请输入您的问题…", @@ -333,14 +340,21 @@ def enforce_ce_branding(db: Session) -> bool: # turned off app_center) and avoids overwriting branding fields in reverse. # - Each entry carries an insert_after anchor; if the anchor is absent, degrade to # appending at the end. +# - ``bucket`` picks which list the entry is seeded into ("sidebar" / "menu"). +# - **Presence is checked across BOTH lists.** An operator who moved an entry from the +# sidebar into the user menu (or vice versa) has not "lost" it — re-inserting it into +# the bucket we happen to prefer would silently undo their placement on every restart +# and leave the entry duplicated in two buckets. Only a genuinely absent key is seeded. +# (Deliberately hidden entries live in neither list, so they *do* come back — that is +# the documented trade-off of this safety net; use the admin UI to hide them again.) +# - Titles/subtitles are **not** repeated here — they are read from DEFAULT_PAGE_CONFIG above, +# so panel copy has exactly one home and a fresh install can never disagree with a +# backfilled one. # - On failure, quietly return False; never block startup. _NAV_BACKFILL_ENTRIES: list[dict[str, Any]] = [ - { - "key": "projects", - "insert_after": "app_center", - "panel_title": "项目", - "panel_subtitle": "把对话、文件和指令打包成专属工作空间", - }, + {"key": "projects", "bucket": "menu", "insert_after": "app_center"}, + {"key": "automation", "bucket": "sidebar", "insert_after": "ability_center"}, + {"key": "sites", "bucket": "sidebar", "insert_after": "automation"}, ] @@ -360,30 +374,37 @@ def backfill_navigation_entries(db: Session) -> int: if not isinstance(nav, dict): return 0 # malformed payload — leave it alone, admin UI will surface + default_nav = DEFAULT_PAGE_CONFIG["navigation"] + changed = 0 for entry in _NAV_BACKFILL_ENTRIES: key = entry["key"] - items = nav.get("sidebar_items") - if isinstance(items, list) and key not in items: + bucket_field = "menu_items" if entry["bucket"] == "menu" else "sidebar_items" + items = nav.get(bucket_field) + # Present in *either* list counts as placed: the operator may have moved the entry + # between buckets on purpose, and re-seeding it would duplicate it across both. + already_placed = any( + key in value + for field in ("sidebar_items", "menu_items") + if isinstance(value := nav.get(field), list) + ) + if isinstance(items, list) and not already_placed: anchor = entry.get("insert_after") new_items = list(items) if anchor and anchor in new_items: - idx = new_items.index(anchor) + 1 - new_items.insert(idx, key) + new_items.insert(new_items.index(anchor) + 1, key) else: new_items.append(key) - nav["sidebar_items"] = new_items + nav[bucket_field] = new_items changed += 1 - titles = nav.get("panel_titles") - if isinstance(titles, dict) and key not in titles and entry.get("panel_title"): - titles[key] = entry["panel_title"] - changed += 1 - - subtitles = nav.get("panel_subtitles") - if isinstance(subtitles, dict) and key not in subtitles and entry.get("panel_subtitle"): - subtitles[key] = entry["panel_subtitle"] - changed += 1 + # Copy comes from DEFAULT_PAGE_CONFIG so it is never re-typed here. + for field in ("panel_titles", "panel_subtitles"): + target = nav.get(field) + text = default_nav[field].get(key) + if isinstance(target, dict) and key not in target and text: + target[key] = text + changed += 1 if changed: payload["navigation"] = nav diff --git a/src/backend/tests/api/test_page_config_backfill.py b/src/backend/tests/api/test_page_config_backfill.py index 8ee21bb8..08bf462f 100644 --- a/src/backend/tests/api/test_page_config_backfill.py +++ b/src/backend/tests/api/test_page_config_backfill.py @@ -1,9 +1,12 @@ """Unit test for ``backfill_navigation_entries``. -Verifies the three states: -1. Row missing → no-op, returns 0 -2. Row exists but missing 'projects' → adds to sidebar_items/panel_titles/panel_subtitles -3. Row already up-to-date → idempotent, returns 0 +Covers: +1. Row missing / malformed → no-op, returns 0 +2. Missing entries → seeded into their declared bucket (sidebar_items or menu_items) + plus panel_titles / panel_subtitles +3. Already up-to-date → idempotent, returns 0, custom copy preserved +4. Anchor absent → degrade to appending at the end +5. Entry parked in the *other* bucket → treated as present, never duplicated """ from __future__ import annotations @@ -24,83 +27,134 @@ def _make_db(row): return db +def _nav(sidebar: list[str], menu: list[str], titles=None, subtitles=None) -> dict: + """Build a navigation payload that already contains every whitelisted entry except + the ones the individual test wants to exercise, so each test isolates one behaviour.""" + return { + "navigation": { + "sidebar_items": sidebar, + "menu_items": menu, + "panel_titles": titles if titles is not None else {}, + "panel_subtitles": subtitles if subtitles is not None else {}, + }, + } + + +# Every key in _NAV_BACKFILL_ENTRIES; used to build "steady state" fixtures. +_ALL_TITLES = { + "projects": "项目", + "automation": "定时任务", + "sites": "站点", +} +_ALL_SUBTITLES = {k: f"{k} sub" for k in _ALL_TITLES} + + def test_no_row_returns_zero(): db = _make_db(None) assert backfill_navigation_entries(db) == 0 db.commit.assert_not_called() -def test_full_backfill_when_all_three_missing(): - row = _make_row({ - "navigation": { - "sidebar_items": ["agents", "kb", "app_center", "my_space"], - "panel_titles": {"app_center": "应用中心"}, - "panel_subtitles": {"app_center": "..."}, - }, - }) +def test_malformed_payload_returns_zero(): + row = _make_row({"navigation": "not_a_dict"}) + db = _make_db(row) + assert backfill_navigation_entries(db) == 0 + db.commit.assert_not_called() + + +def test_seeds_missing_entries_into_their_declared_bucket(): + """A legacy row that predates all three entries gets each one seeded into the bucket + its whitelist entry declares — projects into the user menu, automation/sites into the + sidebar — each next to its anchor.""" + row = _make_row(_nav( + sidebar=["ability_center", "my_space"], + menu=["settings", "app_center", "lab"], + )) db = _make_db(row) changed = backfill_navigation_entries(db) - # 1 sidebar insert + 1 title + 1 subtitle = 3 fields - assert changed == 3 + # 3 entries × (list + title + subtitle) = 9 fields + assert changed == 9 nav = row.payload["navigation"] - # Inserted after 'app_center' - assert nav["sidebar_items"] == ["agents", "kb", "app_center", "projects", "my_space"] - assert nav["panel_titles"]["projects"] == "项目" - assert nav["panel_subtitles"]["projects"] == "把对话、文件和指令打包成专属工作空间" + # projects → menu_items, right after its 'app_center' anchor + assert nav["menu_items"] == ["settings", "app_center", "projects", "lab"] + # automation after 'ability_center', then sites after 'automation' + assert nav["sidebar_items"] == ["ability_center", "automation", "sites", "my_space"] + assert nav["panel_titles"]["automation"] == "定时任务" + assert nav["panel_subtitles"]["sites"].startswith("在对话里描述需求") db.commit.assert_called_once() def test_idempotent_when_already_present(): - row = _make_row({ - "navigation": { - "sidebar_items": ["agents", "kb", "app_center", "projects", "my_space"], - "panel_titles": {"projects": "Custom Title"}, - "panel_subtitles": {"projects": "Custom Sub"}, - }, - }) + row = _make_row(_nav( + sidebar=["ability_center", "automation", "sites", "my_space"], + menu=["settings", "app_center", "projects", "lab"], + titles={**_ALL_TITLES, "projects": "Custom Title"}, + subtitles={**_ALL_SUBTITLES, "projects": "Custom Sub"}, + )) db = _make_db(row) - changed = backfill_navigation_entries(db) - assert changed == 0 + assert backfill_navigation_entries(db) == 0 db.commit.assert_not_called() - # Don't overwrite custom values + # Never overwrite operator-customised copy assert row.payload["navigation"]["panel_titles"]["projects"] == "Custom Title" def test_anchor_missing_falls_back_to_append(): - row = _make_row({ - "navigation": { - "sidebar_items": ["agents", "kb"], # no app_center anchor - "panel_titles": {}, - "panel_subtitles": {}, - }, - }) + """'automation' anchors after 'ability_center'; with the anchor gone it appends.""" + row = _make_row(_nav( + sidebar=["my_space"], # no 'ability_center' anchor + menu=["settings", "app_center", "projects", "lab"], + titles=dict(_ALL_TITLES), + subtitles=dict(_ALL_SUBTITLES), + )) db = _make_db(row) changed = backfill_navigation_entries(db) - assert changed == 3 - # Appended at end because 'app_center' not found - assert row.payload["navigation"]["sidebar_items"] == ["agents", "kb", "projects"] + # only automation + sites lists mutate (titles/subtitles already present) + assert changed == 2 + # automation appended, then sites lands after its 'automation' anchor + assert row.payload["navigation"]["sidebar_items"] == ["my_space", "automation", "sites"] + + +def test_partial_backfill_when_only_list_missing(): + row = _make_row(_nav( + sidebar=["ability_center", "automation", "sites", "my_space"], + menu=["settings", "app_center", "lab"], # projects missing here only + titles=dict(_ALL_TITLES), + subtitles=dict(_ALL_SUBTITLES), + )) + db = _make_db(row) + assert backfill_navigation_entries(db) == 1 + nav = row.payload["navigation"] + assert nav["menu_items"] == ["settings", "app_center", "projects", "lab"] + assert nav["panel_titles"]["projects"] == "项目" -def test_malformed_payload_returns_zero(): - row = _make_row({"navigation": "not_a_dict"}) +def test_entry_moved_to_other_bucket_is_left_alone(): + """Regression: an operator who moved 'projects' out of the user menu and into the + sidebar (or the reverse) must not have it re-seeded — that would resurrect it in the + bucket the whitelist prefers and leave it duplicated in both on every restart.""" + row = _make_row(_nav( + sidebar=["ability_center", "automation", "sites", "projects", "my_space"], + menu=["settings", "app_center", "lab"], # projects deliberately not here + titles=dict(_ALL_TITLES), + subtitles=dict(_ALL_SUBTITLES), + )) db = _make_db(row) assert backfill_navigation_entries(db) == 0 db.commit.assert_not_called() - - -def test_partial_backfill_when_only_sidebar_missing(): - row = _make_row({ - "navigation": { - "sidebar_items": ["agents", "kb", "app_center", "my_space"], - "panel_titles": {"projects": "项目"}, # already has - "panel_subtitles": {"projects": "已有"}, # already has - }, - }) - db = _make_db(row) - changed = backfill_navigation_entries(db) - # Only the sidebar list mutated - assert changed == 1 nav = row.payload["navigation"] - assert "projects" in nav["sidebar_items"] - assert nav["panel_titles"]["projects"] == "项目" - assert nav["panel_subtitles"]["projects"] == "已有" + assert "projects" not in nav["menu_items"] + assert nav["sidebar_items"].count("projects") == 1 + + +def test_sidebar_entry_moved_into_menu_is_left_alone(): + """Same protection in the other direction: 'automation' declares bucket=sidebar, but + an operator may have tucked it into the user menu.""" + row = _make_row(_nav( + sidebar=["ability_center", "sites", "my_space"], + menu=["settings", "app_center", "projects", "automation", "lab"], + titles=dict(_ALL_TITLES), + subtitles=dict(_ALL_SUBTITLES), + )) + db = _make_db(row) + assert backfill_navigation_entries(db) == 0 + assert "automation" not in row.payload["navigation"]["sidebar_items"] diff --git a/src/frontend/public/home/schedule.svg b/src/frontend/public/home/schedule.svg new file mode 100644 index 00000000..b63ffc0e --- /dev/null +++ b/src/frontend/public/home/schedule.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/frontend/public/home/sites.svg b/src/frontend/public/home/sites.svg new file mode 100644 index 00000000..0c75d0af --- /dev/null +++ b/src/frontend/public/home/sites.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 8f5116cf..386a6a71 100755 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -23,10 +23,11 @@ import { CollapseHeight } from './components/common/CollapseHeight'; import { Sidebar, SearchModal } from './components/sidebar'; import { ChatArea, PromptHubPanel } from './components/chat'; import { ToolResultPanel } from './components/tool'; -import { CatalogPanel, AbilityCenterPage, SkillsPage, McpPage } from './components/catalog'; -import { AgentPanel } from './components/agent'; +import { CatalogPanel, AbilityCenterPage } from './components/catalog'; import { DocsPanel, AppCenterPanel } from './components/docs'; import LabPanel from './components/lab/LabPanel'; +import { AutomationPanel } from './components/lab/AutomationPanel'; +import { SitesPanel } from './components/sites'; import { MySpacePanel } from './components/myspace'; import { ProjectsPanel, ProjectDetailPanel } from './components/projects'; import { useProjectStore } from './stores/projectStore'; @@ -576,7 +577,11 @@ export default function App() { }; const handleCapabilityClick = (capabilityId: string) => { - if (capabilityId === 'knowledge') setPanelSafe('kb'); + // 知识库已并入「我的空间」的 Tab,首页快捷入口直接落到那个 Tab + if (capabilityId === 'knowledge') { + setMySpaceTab('kb'); + setPanelSafe('my_space'); + } }; // ── Derived header text (for non-chat panels) ── @@ -584,19 +589,9 @@ export default function App() { const panelSubtitles = pageConfig.navigation.panel_subtitles; const hint = panelSubtitles[panel as string] ?? ''; - // Whether to show the header: only for non-chat panels, or chat panels with messages - const showHeader = panel !== 'chat' - && panel !== 'settings' - && panel !== 'skills' - && panel !== 'mcp' - && panel !== 'agents' - && panel !== 'my_space' - && panel !== 'ability_center' - && panel !== 'app_center' - && panel !== 'projects' - && panel !== 'project_detail' - && panel !== 'kb' - && panel !== 'lab'; + // 顶部通栏标题只有这两个面板还在用——其余面板都自带页头。 + // 写成正面枚举而不是逐个 `panel !== 'x'` 的否定链:新增面板默认不显示,不必回来补一行。 + const showHeader = panel === 'docs' || panel === 'share_records'; const showChatHeader = panel === 'chat' && !isEmptyChat; const showAuthSkeleton = useDelayedFlag(authChecking); @@ -754,7 +749,7 @@ export default function App() { 💡 {recommendBannerText.trim() || t('推荐用法:优先使用知识库检索可提升可引用性与结果可靠性。')} - setPanelSafe('kb')}>{t('前往知识库 >')} + handleCapabilityClick('knowledge')}>{t('前往知识库 >')} -