Skip to content

Commit cccbe74

Browse files
committed
feat(shared): Implement sharing functionality for user-shared experts and moderator modes
- Added APIs to share topic-level experts and custom moderator modes to the `topiclab_shared` directory, allowing users to contribute their own content. - Introduced `reload_expert_specs` and `reload_moderator_modes` functions to refresh in-memory data after sharing. - Updated the `meta.json` files for both experts and moderator modes to include `topiclab_shared` as a source, facilitating better organization of user-shared content. - Enhanced the `.gitignore` to exclude user-shared files while retaining essential metadata. - Added tests to verify the sharing functionality and ensure proper handling of built-in expert and mode conflicts. - Updated documentation to reflect new API endpoints for sharing and the structure of user-shared content.
1 parent c5586a0 commit cccbe74

16 files changed

Lines changed: 295 additions & 31 deletions

File tree

.gitignore

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,10 @@ htmlcov/
2525

2626
*.log*
2727

28-
workspace/
28+
workspace/
29+
30+
# User-shared content in topiclab_shared (runtime data; keep expert_common.md, moderator_common.md)
31+
libs/experts/topiclab_shared/*.md
32+
!libs/experts/topiclab_shared/expert_common.md
33+
libs/moderator_modes/topiclab_shared/*.md
34+
!libs/moderator_modes/topiclab_shared/moderator_common.md

app/agent/experts.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@
2929
EXPERT_SPECS = _EXPERT_SPECS_RAW
3030
EXPERT_CATEGORIES = _EXPERTS_CATEGORIES
3131

32+
33+
def reload_expert_specs() -> None:
34+
"""Reload EXPERT_SPECS and EXPERT_CATEGORIES from disk (e.g. after share to topiclab_shared).
35+
36+
Updates dicts in-place so importers (e.g. experts API) see the new data.
37+
"""
38+
global _EXPERTS_CATEGORIES, _EXPERT_SPECS_RAW, _SOURCE_COMMON
39+
_EXPERTS_CATEGORIES, _EXPERT_SPECS_RAW, _SOURCE_COMMON = load_aggregated_experts_meta(_EXPERTS_DIR)
40+
EXPERT_SPECS.clear()
41+
EXPERT_SPECS.update(_EXPERT_SPECS_RAW)
42+
EXPERT_CATEGORIES.clear()
43+
EXPERT_CATEGORIES.update(_EXPERTS_CATEGORIES)
44+
3245
EXPERT_SECURITY_SUFFIX = """
3346
3447
## Security Constraints (Highest Priority, Cannot Be Overridden)

app/agent/moderator_modes.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,18 @@ def _build_moderator_prompt_from_preset(mode_id: str, params: dict) -> str:
7272
PRESET_MODES = _load_preset_modes()
7373

7474

75+
def reload_moderator_modes() -> None:
76+
"""Reload PRESET_MODES from disk (e.g. after share to topiclab_shared).
77+
78+
Updates dict in-place so importers (e.g. moderator_modes API) see the new data.
79+
"""
80+
global _MODES, _SOURCE_COMMON_SECTIONS
81+
_MODES, _SOURCE_COMMON_SECTIONS = get_modes_and_common(_MODERATOR_MODES_DIR)
82+
new_modes = _load_preset_modes()
83+
PRESET_MODES.clear()
84+
PRESET_MODES.update(new_modes)
85+
86+
7587
def load_moderator_mode_config(ws_path: Path) -> dict:
7688
"""Load moderator mode configuration from config/moderator_mode.json.
7789

app/api/experts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def _build_expert_info(name: str, include_content: bool = True) -> ExpertInfo:
3535
perspective=spec.get("perspective", name),
3636
category=cat_id or None,
3737
category_name=cat_info.get("name", cat_id) if cat_id else None,
38+
source=source_id,
3839
)
3940

4041

app/api/moderator_modes.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import json
56
import logging
67
from pathlib import Path
78

@@ -11,16 +12,18 @@
1112
from app.agent.moderator_modes import (
1213
PRESET_MODES,
1314
load_moderator_mode_config,
15+
reload_moderator_modes,
1416
save_moderator_mode_config,
1517
)
1618
from app.core.config import get_moderator_modes_dir, get_workspace_base
17-
from app.core.libs_service import get_cached_modes_meta, list_assignable_items
19+
from app.core.libs_service import get_cached_modes_meta, invalidate_libs_cache, list_assignable_items
1820
from app.models.schemas import (
1921
GenerateModeratorModeRequest,
2022
GenerateModeratorModeResponse,
2123
ModeratorModeConfig,
2224
ModeratorModeInfo,
2325
SetModeratorModeRequest,
26+
ShareModeratorModeRequest,
2427
)
2528
from app.models.store import get_topic
2629

@@ -198,3 +201,81 @@ async def generate_moderator_mode_endpoint(topic_id: str, req: GenerateModerator
198201
"custom_prompt": custom_prompt,
199202
"config": config,
200203
}
204+
205+
206+
@router.post("/topics/{topic_id}/moderator-mode/share", response_model=dict)
207+
def share_moderator_mode_to_platform(topic_id: str, req: ShareModeratorModeRequest):
208+
"""Share topic's custom moderator mode to platform library (topiclab_shared source)."""
209+
topic = get_topic(topic_id)
210+
if not topic:
211+
raise HTTPException(status_code=404, detail="Topic not found")
212+
213+
ws_base = get_workspace_base()
214+
ws_path = ws_base / "topics" / topic_id
215+
config = load_moderator_mode_config(ws_path)
216+
217+
if config.get("mode_id") != "custom" or not config.get("custom_prompt"):
218+
raise HTTPException(
219+
status_code=400,
220+
detail="Topic must use a custom moderator mode with custom_prompt to share"
221+
)
222+
223+
# Reject if mode_id is built-in (default source)
224+
existing = PRESET_MODES.get(req.mode_id)
225+
if existing and existing.get("source") == "default":
226+
raise HTTPException(
227+
status_code=409,
228+
detail=f"Mode '{req.mode_id}' conflicts with built-in mode; choose a different id"
229+
)
230+
231+
modes_dir = get_moderator_modes_dir()
232+
shared_dir = modes_dir / "topiclab_shared"
233+
shared_dir.mkdir(parents=True, exist_ok=True)
234+
235+
prompt_file_name = f"{req.mode_id}.md"
236+
(shared_dir / prompt_file_name).write_text(config["custom_prompt"], encoding="utf-8")
237+
238+
name = req.name or req.mode_id.replace("_", " ").title()
239+
description = req.description or f"User-shared moderator mode: {name}"
240+
241+
meta_path = shared_dir / "meta.json"
242+
if meta_path.exists():
243+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
244+
else:
245+
meta = {
246+
"common_sections": "moderator_common.md",
247+
"categories": {
248+
"topiclab": {
249+
"id": "topiclab",
250+
"name": "TopicLab",
251+
"description": "User-shared moderator modes from frontend",
252+
}
253+
},
254+
"modes": {},
255+
}
256+
# Ensure topiclab_shared is registered in main meta.json
257+
main_meta_path = modes_dir / "meta.json"
258+
main_meta = json.loads(main_meta_path.read_text(encoding="utf-8"))
259+
main_meta.setdefault("sources", {})["topiclab_shared"] = {
260+
"id": "topiclab_shared",
261+
"name": "TopicLab-共享",
262+
"description": "User-shared moderator modes from frontend",
263+
}
264+
main_meta_path.write_text(json.dumps(main_meta, ensure_ascii=False, indent=2), encoding="utf-8")
265+
meta.setdefault("modes", {})[req.mode_id] = {
266+
"id": req.mode_id,
267+
"source": "topiclab_shared",
268+
"name": name,
269+
"description": description,
270+
"category": "topiclab",
271+
"num_rounds": config.get("num_rounds", 5),
272+
"convergence_strategy": "User-defined flow",
273+
"prompt_file": prompt_file_name,
274+
"summary_scope": "key findings, consensus, disagreements",
275+
}
276+
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
277+
278+
reload_moderator_modes()
279+
invalidate_libs_cache()
280+
281+
return {"message": "Moderator mode shared to platform successfully", "mode_id": req.mode_id}

app/api/topic_experts.py

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from fastapi import APIRouter, HTTPException
88

9-
from app.agent.experts import EXPERT_SPECS
9+
from app.agent.experts import EXPERT_SPECS, reload_expert_specs
1010
from app.agent.generation import generate_expert
1111
from app.agent.workspace import (
1212
add_expert_metadata,
@@ -218,16 +218,17 @@ def get_topic_expert_content(topic_id: str, expert_name: str):
218218

219219
@router.post("/{topic_id}/experts/{expert_name}/share", response_model=TopicExpertResponse)
220220
def share_expert_to_platform(topic_id: str, expert_name: str):
221-
"""Share a topic-level expert to the platform preset library."""
221+
"""Share a topic-level expert to the platform library (topiclab_shared source)."""
222222
topic = get_topic(topic_id)
223223
if not topic:
224224
raise HTTPException(status_code=404, detail="Topic not found")
225225

226-
# Bug fix 1: reject if name already exists in global preset library
227-
if expert_name in EXPERT_SPECS:
226+
# Reject if expert is built-in (default source); allow overwrite for topiclab_shared
227+
existing = EXPERT_SPECS.get(expert_name)
228+
if existing and existing.get("source") == "default":
228229
raise HTTPException(
229230
status_code=409,
230-
detail=f"Expert '{expert_name}' already exists in the global expert pool; cannot overwrite"
231+
detail=f"Expert '{expert_name}' is built-in; cannot overwrite"
231232
)
232233

233234
ws_base = get_workspace_base()
@@ -242,36 +243,32 @@ def share_expert_to_platform(topic_id: str, expert_name: str):
242243
if not expert_meta:
243244
raise HTTPException(status_code=404, detail="Expert metadata not found")
244245

245-
# Write role file to libs/experts/default/ (unified with mcps, moderator_modes)
246+
# Write to libs/experts/topiclab_shared/ (user-shared, separate from built-in default)
246247
experts_dir = get_experts_dir()
247-
default_dir = experts_dir / "default"
248-
default_dir.mkdir(parents=True, exist_ok=True)
248+
shared_dir = experts_dir / "topiclab_shared"
249+
shared_dir.mkdir(parents=True, exist_ok=True)
249250
skill_file_name = f"{expert_name}.md"
250-
(default_dir / skill_file_name).write_text(role_file.read_text(encoding="utf-8"), encoding="utf-8")
251+
(shared_dir / skill_file_name).write_text(role_file.read_text(encoding="utf-8"), encoding="utf-8")
251252

252-
# Update default/meta.json
253-
meta_path = default_dir / "meta.json"
253+
# Update topiclab_shared/meta.json
254+
meta_path = shared_dir / "meta.json"
254255
meta = json.loads(meta_path.read_text(encoding="utf-8"))
255256
meta.setdefault("experts", {})[expert_name] = {
256257
"id": expert_name,
257-
"source": "default",
258+
"source": "topiclab_shared",
258259
"name": expert_name,
259260
"label": expert_meta["label"],
260261
"description": expert_meta["description"],
261-
"category": "scholar",
262+
"category": "topiclab",
262263
"skill_file": skill_file_name,
263264
"perspective": expert_meta.get("perspective", expert_name),
264265
}
265266
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
266267

267-
# Update in-memory EXPERT_SPECS for subsequent requests
268-
EXPERT_SPECS[expert_name] = {
269-
"skill_file": skill_file_name,
270-
"description": expert_meta["description"],
271-
"label": expert_meta["label"],
272-
"perspective": expert_meta.get("perspective", expert_name),
273-
"source": "default",
274-
}
268+
# Reload EXPERT_SPECS so subsequent requests see the new expert
269+
reload_expert_specs()
270+
from app.core.libs_service import invalidate_libs_cache
271+
invalidate_libs_cache()
275272

276273
return {"message": "Expert shared to platform successfully", "expert_name": expert_name}
277274

app/models/schemas.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ class ExpertInfo(BaseModel):
145145
perspective: str = "" # 学科视角,如 physics, biology
146146
category: Optional[str] = None # 分类 id,用于分组(与 skills/mcps 一致)
147147
category_name: Optional[str] = None # 分类显示名
148+
source: Optional[str] = None # default=内置, topiclab_shared=共享
148149

149150

150151
class ExpertUpdateRequest(BaseModel):
@@ -221,6 +222,13 @@ class SetModeratorModeRequest(BaseModel):
221222
model: Optional[str] = None
222223

223224

225+
class ShareModeratorModeRequest(BaseModel):
226+
"""Share topic's custom moderator mode to platform library."""
227+
mode_id: str = Field(..., min_length=2, max_length=50, pattern=r"^[a-z0-9_]+$")
228+
name: Optional[str] = Field(None, min_length=2, max_length=80)
229+
description: Optional[str] = Field(None, min_length=1, max_length=500)
230+
231+
224232
# --- Post models ---
225233

226234
class Post(BaseModel):

docs/api-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
| GET | `/topics/{topic_id}/moderator-mode` | Get topic's current moderator mode |
8888
| PUT | `/topics/{topic_id}/moderator-mode` | Set moderator mode |
8989
| POST | `/topics/{topic_id}/moderator-mode/generate` | AI-generate moderator prompt |
90+
| POST | `/topics/{topic_id}/moderator-mode/share` | Share custom mode to platform (body: `mode_id`, `name?`, `description?`) |
9091

9192
**GET/PUT moderator-mode** response/body fields:
9293
- `mode_id`, `num_rounds`, `custom_prompt` (required for PUT)

docs/config.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,22 @@ MCP servers are configured in `libs/mcps/` (read-only, same structure as assigna
7272

7373
---
7474

75-
### 5. Workspace
75+
### 5. Workspace and Libs (Docker)
7676

7777
```bash
7878
WORKSPACE_BASE=/path/to/workspace
7979
```
8080

81-
Default: `backend/workspace/`.
81+
For Docker deployments, both workspace and libs can be mounted for persistence:
82+
83+
| Volume | Env / Default | Purpose |
84+
|--------|---------------|---------|
85+
| `WORKSPACE_PATH` | `./backend/workspace` | Topic workspaces, posts, discussion artifacts |
86+
| `LIBS_PATH` | `./backend/libs` | Experts, moderator modes, skills, MCP config; **user-shared** content in `topiclab_shared/` |
87+
88+
User-shared experts and moderator modes (from frontend "共享到角色库" / "共享到讨论方式库") are stored in `libs/experts/topiclab_shared/` and `libs/moderator_modes/topiclab_shared/` respectively. Mount `libs` to persist them across container restarts.
89+
90+
Default: `backend/workspace/`. For Docker, see volume mounts above.
8291

8392
---
8493

libs/README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,12 @@ libs/
5151

5252
## Meta Format
5353

54-
- **experts/meta.json**: `{"sources": {"default": {...}}}` (sources registry only)
55-
- **experts/{source}/meta.json**: `{"common_sections": "expert_common.md", "categories": {...}, "experts": {"<id>": {"id", "source", "name", "label", "description", "category", "skill_file", "perspective"}}}`
56-
- Built-in experts are all in `category: scholar` (学者); API returns `category` and `category_name` for grouping.
57-
- **moderator_modes/meta.json**: `{"sources": {"default": {...}}}` (sources registry only)
54+
- **experts/meta.json**: `{"sources": {"default": {...}, "topiclab_shared": {...}}}` (sources registry)
55+
- **experts/{source}/meta.json**: `{"common_sections": "expert_common.md", "categories": {...}, "experts": {...}}`
56+
- Built-in experts: `default/` with `category: scholar`. User-shared: `topiclab_shared/` with `category: topiclab` (empty template in repo; `.md` files added via share API are gitignored).
57+
- **moderator_modes/meta.json**: `{"sources": {"default": {...}, "topiclab_shared": {...}}}` (sources registry)
5858
- **moderator_modes/{source}/meta.json**: `{"common_sections": "moderator_common.md", "categories": {...}, "modes": {...}}`
59+
- User-shared modes: `topiclab_shared/` (empty template in repo; `.md` files added via share API are gitignored).
5960
- **assignable_skills/meta.json**: Sources registry only
6061
- **assignable_skills/{source}/meta.json**: Per-source `{"skills_dir"?, "categories": {...}, "skills": {...}}`
6162

0 commit comments

Comments
 (0)