Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 46 additions & 25 deletions src/backend/core/content/content_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand All @@ -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": "请输入您的问题…",
Expand Down Expand Up @@ -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"},
]


Expand All @@ -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
Expand Down
168 changes: 111 additions & 57 deletions src/backend/tests/api/test_page_config_backfill.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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"]
4 changes: 4 additions & 0 deletions src/frontend/public/home/schedule.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions src/frontend/public/home/sites.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading