feat(qa-center): 问答中心左侧栏改造(0616 需求 2-5)

- 多智能体会商:Step1 落地左栏「业务链(public 前3) + 分析记录」;点链=勾选(autoChainResult),第一步澄清后点「进入研讨」才按链执行
- zzJC/zzXD:独立模块路由 /page/strategy/qa/{zzjc,zzxd},常驻左栏(按 agent 标签分组 + 仅本模块会话的聊天记录),点智能体模块内切换不跳页,默认落地配置类型下首个智能体
- zzJC/zzXD admin 设置:左栏齿轮→单选类型(TDesign)配置面板+智能体预览;后端 system_settings.agent_modules 持久化(GET 开放/PUT admin)+ test_agent_modules
- 课题研究:左栏「研究模板(文章类型) + 笔记本」;模板点击预填表单,笔记本点击设为素材来源(selectedNotebookId 上提 WritingFormContext)
- 通用问答:全量模式历史侧栏默认展开

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
fengxiangdebaoziandClaude Opus 4.8 committed 2026-06-16 16:38:22 +08:00
1 parent 8794c51f88
commit f39cffcb9a
18 files changed
+1113 -41

No files matched your search

@@ -330,6 +330,10 @@ Admin-configurable, **access-control-free** categories that published agents/ski
Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `String(64)` NOT NULL default `''`, migration `20260611_01`, idempotent SQL in `column_additions_mysql.sql`). One column = at most one square, so an item can never be "published twice". `''` = unassigned ? resolved to the default square at read/filter time. Writes normalize via `normalize_square_id`: agents on create/update (`square_id` field), skills on publish (`PUT /api/skills/custom/{name}/published` accepts optional `square_id`). Listing filters: `GET /api/agents?square=` and `GET /api/skills?square=` keep only that square's items; the **default** square additionally sweeps in legacy `''` rows (`square_is_default`). Tests: `tests/test_publish_squares.py`. Frontend: `core/system-settings` (`usePublishSquares`/`useUpdatePublishSquares`), admin `PublishSquaresSettingsPage` (embedded in the appearance/settings hub), square picker in the agent edit dialog + per-skill card, and a ??-tab square sub-filter in both galleries.
### Agent Module Tag Config (zzJC / zzXD 按类型聚合)
Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊天记录」). An admin picks which agent **tags** belong to a module; the module's left sidebar then shows only agents under those tags (empty/unconfigured = show all visible agents). Stored in `system_settings.json` → `agent_modules` (`AgentModuleSettings`: `modules: {module_key → [tag_id, ...]}`); pure helpers `get_module_tag_ids` / `set_module_tag_ids` (de-dupe + strip). Endpoints (in `app/gateway/routers/system_settings.py`): `GET /api/system-settings/agent-modules/{key}` (readable by any user — the sidebar needs it to filter), `PUT /api/system-settings/agent-modules/{key}` (admin only, full replacement of the tag-id list). Module keys are frontend-defined (`AGENT_MODULES` in `components/workspace/agents/agent-module-sidebar.tsx`, keyed by the module's landing agent id: `6bf`→`zzjc`, `10d69…`→`zzxd`). Tests: `tests/test_agent_modules.py`. Frontend: `core/system-settings` (`useAgentModuleConfig`/`useUpdateAgentModuleConfig`), admin `AgentModuleSettingsDialog` (gear button in the agent-chat header).
### Portable Column Types (`deerflow/persistence/types.py`)
Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` / `mysql` backends. **All ORM timestamp/JSON columns must use these, not the raw SQLAlchemy types.**
@@ -16,11 +16,12 @@ from deerflow.config.system_settings import (
PromptPrefixSettings,
PublishSquare,
PublishSquaresSettings,
SystemSettings,
get_module_tag_ids,
list_publish_squares,
load_system_settings,
resolve_default_square_id,
save_system_settings,
set_module_tag_ids,
)
logger = logging.getLogger(__name__)
@@ -377,3 +378,35 @@ async def get_log_system(request: Request) -> LogSystemResponse:
await _require_admin(request)
cfg = get_app_config().log_system
return LogSystemResponse(enabled=cfg.enabled, url=cfg.url, label=cfg.label)
class AgentModuleConfigResponse(BaseModel):
"""Selected agent-tag ids for a「按类型聚合」module (zzJC / zzXD)."""
key: str = Field(..., description="Module key, e.g. zzjc / zzxd")
tag_ids: list[str] = Field(default_factory=list, description="Selected agent tag ids")
class AgentModuleConfigUpdateRequest(BaseModel):
tag_ids: list[str] = Field(default_factory=list, description="Full replacement list of selected agent tag ids")
@router.get("/agent-modules/{key}", response_model=AgentModuleConfigResponse)
async def get_agent_module_config(key: str) -> AgentModuleConfigResponse:
"""Return the agent tag ids configured for ``key`` (readable by any user —
the module's left sidebar needs it to filter the agent list)."""
settings = load_system_settings().agent_modules
return AgentModuleConfigResponse(key=key, tag_ids=get_module_tag_ids(settings, key))
@router.put("/agent-modules/{key}", response_model=AgentModuleConfigResponse)
async def update_agent_module_config(
key: str, body: AgentModuleConfigUpdateRequest, request: Request
) -> AgentModuleConfigResponse:
"""Replace the agent tag ids configured for ``key``. Admin only."""
await _require_admin(request)
current = load_system_settings()
updated = set_module_tag_ids(current.agent_modules, key, body.tag_ids)
saved = save_system_settings(current.model_copy(update={"agent_modules": updated}))
return AgentModuleConfigResponse(key=key, tag_ids=get_module_tag_ids(saved.agent_modules, key))
@@ -170,6 +170,37 @@ class LeaderboardSettings(BaseModel):
model_config = ConfigDict(extra="ignore")
class AgentModuleSettings(BaseModel):
"""Admin-configured「按类型聚合」module → selected agent-tag ids.
Keyed by module key (e.g. ``"zzjc"`` / ``"zzxd"``). Each value is the list
of agent **tag ids** whose agents that module's left sidebar shows. A missing
key / empty list means "not configured" — the frontend falls back to showing
all visible agents grouped by tag.
"""
modules: dict[str, list[str]] = Field(
default_factory=dict,
description="module key → selected agent tag ids",
)
model_config = ConfigDict(extra="ignore")
def get_module_tag_ids(settings: AgentModuleSettings, key: str) -> list[str]:
"""Return the configured agent-tag ids for ``key`` (empty when unset)."""
return list(settings.modules.get(key, []))
def set_module_tag_ids(
settings: AgentModuleSettings, key: str, tag_ids: list[str]
) -> AgentModuleSettings:
"""Return a copy with ``key`` set to a de-duplicated, stripped tag-id list."""
cleaned = list(dict.fromkeys([str(t).strip() for t in tag_ids if str(t).strip()]))
modules = dict(settings.modules)
modules[key] = cleaned
return settings.model_copy(update={"modules": modules})
class SystemSettings(BaseModel):
"""Top-level system settings document."""
@@ -179,6 +210,7 @@ class SystemSettings(BaseModel):
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings)
agent_modules: AgentModuleSettings = Field(default_factory=AgentModuleSettings)
model_config = ConfigDict(extra="ignore")
@@ -0,0 +1,60 @@
"""Tests for the admin-configured「按类型聚合」module → agent-tag config.
Backs the zzJC / zzXD module left sidebar (需求 0616 #3/#4): an admin picks
which agent tags belong to the module, persisted in ``system_settings.json``.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from deerflow.config import system_settings as ss
@pytest.fixture
def tmp_runtime_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
"""Redirect ``runtime_home()`` to a per-test tmp dir."""
def fake_home() -> Path:
return tmp_path
monkeypatch.setattr(ss, "runtime_home", fake_home)
return tmp_path
def test_load_returns_empty_modules_when_missing(tmp_runtime_home: Path) -> None:
settings = ss.load_system_settings()
assert settings.agent_modules.modules == {}
assert ss.get_module_tag_ids(settings.agent_modules, "zzjc") == []
def test_set_module_tag_ids_dedupes_and_strips() -> None:
base = ss.AgentModuleSettings()
updated = ss.set_module_tag_ids(base, "zzjc", [" t1 ", "t2", "t1", "", " "])
assert updated.modules["zzjc"] == ["t1", "t2"]
# Original is untouched (returns a copy).
assert base.modules == {}
def test_set_module_tag_ids_keeps_other_modules() -> None:
base = ss.set_module_tag_ids(ss.AgentModuleSettings(), "zzjc", ["t1"])
both = ss.set_module_tag_ids(base, "zzxd", ["t2", "t3"])
assert ss.get_module_tag_ids(both, "zzjc") == ["t1"]
assert ss.get_module_tag_ids(both, "zzxd") == ["t2", "t3"]
def test_save_then_load_roundtrips(tmp_runtime_home: Path) -> None:
current = ss.load_system_settings()
updated = ss.set_module_tag_ids(current.agent_modules, "zzjc", ["tag-a", "tag-b"])
ss.save_system_settings(current.model_copy(update={"agent_modules": updated}))
file_path = tmp_runtime_home / "system_settings.json"
assert file_path.is_file()
saved = json.loads(file_path.read_text(encoding="utf-8"))
assert saved["agent_modules"]["modules"]["zzjc"] == ["tag-a", "tag-b"]
reloaded = ss.load_system_settings()
assert ss.get_module_tag_ids(reloaded.agent_modules, "zzjc") == ["tag-a", "tag-b"]