Skip to content

Commit 32d2ed6

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. Dual-source libs: builtin canonical, LIBS_PATH mount for topiclab_shared only - When LIBS_PATH points to empty dir (Docker), default sources from builtin, topiclab_shared from mount - Dockerfile preserves libs_builtin; config get_expert_source_dir, get_moderator_mode_source_dir - Fixed expert share when meta.json absent; skills/mcps/prompts merge builtin + primary Made-with: Cursor
1 parent c5586a0 commit 32d2ed6

24 files changed

Lines changed: 604 additions & 155 deletions

.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

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
## [0.3.0] - 2026-03-01
11+
12+
### Added
13+
14+
- **Expert share to platform**: `POST /topics/{id}/experts/{name}/share` — share topic-level expert to `libs/experts/topiclab_shared/`; rejects built-in experts (source=default); reloads EXPERT_SPECS and invalidates libs cache
15+
- **Moderator mode share to platform**: `POST /topics/{id}/moderator-mode/share` — share custom moderator mode to `libs/moderator_modes/topiclab_shared/`; body: `mode_id`, `name?`, `description?`; creates meta.json if missing
16+
- **Topic-level moderator config**: `GET/PUT /topics/{id}/moderator-mode` supports `skill_list`, `mcp_server_ids`, `model`; persisted per topic; fallback from `config/skills/` and `config/mcp.json` when missing
17+
- **Discussion params**: `POST /topics/{id}/discussion` accepts `skill_list`, `mcp_server_ids`, `allowed_tools`; copied to topic workspace for moderator/agent use
18+
19+
### Fixed
20+
21+
- **Expert share 500**: `POST /topics/{id}/experts/{name}/share` no longer returns 500 when `libs/experts/topiclab_shared/meta.json` does not exist (first share). Creates default meta structure like moderator-mode share.
22+
23+
### Changed
24+
25+
- Topic moderator mode config: skill_list, mcp_server_ids, model persisted in workspace; discussion uses topic config when present
26+
- Expert share: creates `topiclab_shared/meta.json` with default categories when missing (aligned with moderator-mode share)
27+
828
## [0.2.0] - 2026-02-21
929

1030
### Added

Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ RUN mkdir -p /home/appuser/.pip && \
1515

1616
# 复制代码并修改所有权
1717
COPY . .
18+
# 保留内置 libs 到 libs_builtin,供挂载 LIBS_PATH 为空时初始化
19+
RUN cp -r libs libs_builtin 2>/dev/null || true
1820
RUN chown -R appuser:appuser /app /home/appuser/.pip
1921

2022
# 切换到非 root 用户

app/agent/experts.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from claude_agent_sdk import AgentDefinition
1212

13-
from app.core.config import get_experts_dir
13+
from app.core.config import get_expert_source_dir, get_experts_dir
1414
from app.core.experts_meta import load_aggregated_experts_meta
1515
from app.models.schemas import DEFAULT_ALLOWED_TOOLS
1616

@@ -21,14 +21,24 @@
2121
# 专家默认工具:与主持人一致但排除 Task(Task 仅主持人用于调用子 agent)
2222
DEFAULT_EXPERT_TOOLS = [t for t in DEFAULT_ALLOWED_TOOLS if t != "Task"]
2323

24-
# Experts directory (unified with mcps, moderator_modes)
25-
_EXPERTS_DIR = get_experts_dir()
26-
27-
# Load expert specifications from libs/experts/ (sources + per-source meta)
28-
_EXPERTS_CATEGORIES, _EXPERT_SPECS_RAW, _SOURCE_COMMON = load_aggregated_experts_meta(_EXPERTS_DIR)
24+
# Load expert specifications from libs/experts/ (merged builtin + primary)
25+
_EXPERTS_CATEGORIES, _EXPERT_SPECS_RAW, _SOURCE_COMMON = load_aggregated_experts_meta()
2926
EXPERT_SPECS = _EXPERT_SPECS_RAW
3027
EXPERT_CATEGORIES = _EXPERTS_CATEGORIES
3128

29+
30+
def reload_expert_specs() -> None:
31+
"""Reload EXPERT_SPECS and EXPERT_CATEGORIES from disk (e.g. after share to topiclab_shared).
32+
33+
Updates dicts in-place so importers (e.g. experts API) see the new data.
34+
"""
35+
global _EXPERTS_CATEGORIES, _EXPERT_SPECS_RAW, _SOURCE_COMMON
36+
_EXPERTS_CATEGORIES, _EXPERT_SPECS_RAW, _SOURCE_COMMON = load_aggregated_experts_meta()
37+
EXPERT_SPECS.clear()
38+
EXPERT_SPECS.update(_EXPERT_SPECS_RAW)
39+
EXPERT_CATEGORIES.clear()
40+
EXPERT_CATEGORIES.update(_EXPERTS_CATEGORIES)
41+
3242
EXPERT_SECURITY_SUFFIX = """
3343
3444
## Security Constraints (Highest Priority, Cannot Be Overridden)
@@ -65,27 +75,28 @@ def build_workspace_boundary(ws_abs: str) -> str:
6575
)
6676

6777

68-
def _load_common_content(experts_dir: Path, source_id: str) -> str:
78+
def _load_common_content(source_id: str) -> str:
6979
"""Load common expert sections (Workspace, Discussion Rules, Language)."""
7080
common_file = _SOURCE_COMMON.get(source_id, "expert_common.md")
81+
experts_dir = get_expert_source_dir(source_id)
7182
common_path = experts_dir / source_id / common_file
7283
if common_path.exists():
7384
return common_path.read_text(encoding="utf-8")
7485
return ""
7586

7687

7788
def _build_expert_prompt_from_global(
78-
experts_dir: Path,
7989
name: str,
8090
spec: dict,
8191
output_language_instruction: str | None = None,
8292
) -> str:
8393
"""Build prompt from role skill + common sections (with placeholder replacement)."""
8494
lang_instruction = output_language_instruction or FALLBACK_LANGUAGE_INSTRUCTION
8595
source_id = spec.get("source", "default")
96+
experts_dir = get_expert_source_dir(source_id)
8697
role_path = experts_dir / source_id / spec["skill_file"]
8798
role_content = role_path.read_text(encoding="utf-8") if role_path.exists() else ""
88-
common_content = _load_common_content(experts_dir, source_id)
99+
common_content = _load_common_content(source_id)
89100
if common_content:
90101
common_content = common_content.replace(
91102
"{output_language_instruction}", lang_instruction
@@ -104,7 +115,7 @@ def build_experts(
104115
expert_tools = tools if tools else DEFAULT_EXPERT_TOOLS
105116
experts: dict[str, AgentDefinition] = {}
106117
for name, spec in EXPERT_SPECS.items():
107-
prompt_text = _build_expert_prompt_from_global(_EXPERTS_DIR, name, spec)
118+
prompt_text = _build_expert_prompt_from_global(name, spec)
108119
if not prompt_text:
109120
prompt_text = spec["description"]
110121
prompt_text += EXPERT_SECURITY_SUFFIX
@@ -158,7 +169,7 @@ def build_experts_from_workspace(
158169
if workspace_role.exists():
159170
logger.info(f"Using workspace role for {name}: {workspace_role}")
160171
role_content = workspace_role.read_text(encoding="utf-8")
161-
common_content = _load_common_content(_EXPERTS_DIR, source_id)
172+
common_content = _load_common_content(source_id)
162173
if common_content:
163174
common_content = common_content.replace(
164175
"{output_language_instruction}", output_lang
@@ -171,11 +182,12 @@ def build_experts_from_workspace(
171182
)
172183
else:
173184
# Priority 2: fallback to global skills (role + common sections)
174-
global_skill = _EXPERTS_DIR / source_id / spec["skill_file"]
185+
experts_dir = get_expert_source_dir(source_id)
186+
global_skill = experts_dir / source_id / spec["skill_file"]
175187
if global_skill.exists():
176188
logger.info(f"Fallback to global skill for {name}: {global_skill}")
177189
prompt_text = _build_expert_prompt_from_global(
178-
_EXPERTS_DIR, name, spec, output_language_instruction=output_lang
190+
name, spec, output_language_instruction=output_lang
179191
)
180192
else:
181193
logger.error(f"No role found for {name}, using description as fallback")

app/agent/moderator_modes.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,14 @@
66
import logging
77
from pathlib import Path
88

9-
from app.core.config import get_moderator_modes_dir
9+
from app.core.config import get_moderator_mode_source_dir
1010
from app.core.moderator_modes_meta import get_modes_and_common
1111

1212
from .workspace import build_output_language_instruction
1313

1414
logger = logging.getLogger(__name__)
1515

16-
_MODERATOR_MODES_DIR = get_moderator_modes_dir()
17-
_MODES, _SOURCE_COMMON_SECTIONS = get_modes_and_common(_MODERATOR_MODES_DIR)
16+
_MODES, _SOURCE_COMMON_SECTIONS = get_modes_and_common()
1817

1918

2019
def _load_preset_modes() -> dict:
@@ -40,7 +39,8 @@ def _load_preset_modes() -> dict:
4039
def _load_common_content(source_id: str = "default") -> str:
4140
"""Load common moderator sections (Workspace, Rules, Language) for given source."""
4241
common_file = _SOURCE_COMMON_SECTIONS.get(source_id, "moderator_common.md")
43-
common_path = _MODERATOR_MODES_DIR / source_id / common_file
42+
modes_dir = get_moderator_mode_source_dir(source_id)
43+
common_path = modes_dir / source_id / common_file
4444
if common_path.exists():
4545
return common_path.read_text(encoding="utf-8")
4646
return ""
@@ -51,7 +51,8 @@ def _load_mode_prompt(mode_id: str) -> str:
5151
spec = PRESET_MODES.get(mode_id, {})
5252
source_id = spec.get("source", "default")
5353
prompt_file = spec.get("prompt_file", f"{mode_id}.md")
54-
skill_file = _MODERATOR_MODES_DIR / source_id / prompt_file
54+
modes_dir = get_moderator_mode_source_dir(source_id)
55+
skill_file = modes_dir / source_id / prompt_file
5556
if not skill_file.exists():
5657
raise FileNotFoundError(f"Moderator skill file not found: {skill_file}")
5758
return skill_file.read_text(encoding="utf-8")
@@ -72,6 +73,18 @@ def _build_moderator_prompt_from_preset(mode_id: str, params: dict) -> str:
7273
PRESET_MODES = _load_preset_modes()
7374

7475

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

app/agent/workspace.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,9 @@ def _resolve_skill_path(base_dir: Path, skill_id: str, skill_info: dict) -> Path
184184
- default: assignable_skills/default/{category}/{slug}.md
185185
- imported (submodule): assignable_skills/_submodules/{source}/{skills_dir}/{category}/{slug}/SKILL.md
186186
or {skills_dir}/{slug}/SKILL.md when category is 'general'
187+
Uses _base_dir from skill_info when skill was loaded from builtin.
187188
"""
189+
base_dir = skill_info.get("_base_dir", base_dir) or base_dir
188190
_, slug = _parse_skill_id(skill_id)
189191
source = skill_info.get("source", "default") or "default"
190192
category = skill_info.get("category", "")
@@ -405,13 +407,11 @@ def _ensure_agents_structure(ws_path: Path):
405407
Existing role.md files are never overwritten (preserves user customization).
406408
"""
407409
from .experts import EXPERT_SPECS
408-
from app.core.config import get_experts_dir
410+
from app.core.config import get_expert_source_dir
409411

410412
agents_dir = ws_path / "agents"
411413
agents_dir.mkdir(exist_ok=True)
412414

413-
experts_dir = get_experts_dir()
414-
415415
for expert_name, spec in EXPERT_SPECS.items():
416416
expert_dir = agents_dir / expert_name
417417
expert_dir.mkdir(exist_ok=True)
@@ -421,6 +421,7 @@ def _ensure_agents_structure(ws_path: Path):
421421
# Only copy if role.md doesn't exist (idempotent, preserves customization)
422422
if not role_file.exists():
423423
source_id = spec.get("source", "default")
424+
experts_dir = get_expert_source_dir(source_id)
424425
global_skill_file = experts_dir / source_id / spec["skill_file"]
425426
if global_skill_file.exists():
426427
logger.info(

app/api/experts.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,11 @@
55
from fastapi import APIRouter, HTTPException
66

77
from app.agent.experts import EXPERT_CATEGORIES, EXPERT_SPECS
8-
from app.core.config import get_experts_dir
8+
from app.core.config import get_expert_source_dir
99
from app.models.schemas import ExpertInfo, ExpertUpdateRequest
1010

1111
router = APIRouter()
1212

13-
EXPERTS_DIR = get_experts_dir()
14-
1513

1614
def _build_expert_info(name: str, include_content: bool = True) -> ExpertInfo:
1715
"""Build ExpertInfo. When include_content=False, skip disk read for skill_content."""
@@ -22,7 +20,8 @@ def _build_expert_info(name: str, include_content: bool = True) -> ExpertInfo:
2220
skill_file = spec["skill_file"]
2321
skill_content = ""
2422
if include_content:
25-
skill_path = EXPERTS_DIR / source_id / skill_file
23+
experts_dir = get_expert_source_dir(source_id)
24+
skill_path = experts_dir / source_id / skill_file
2625
skill_content = skill_path.read_text(encoding="utf-8") if skill_path.exists() else ""
2726
cat_id = spec.get("category", "")
2827
cat_info = EXPERT_CATEGORIES.get(cat_id, {}) if cat_id else {}
@@ -35,6 +34,7 @@ def _build_expert_info(name: str, include_content: bool = True) -> ExpertInfo:
3534
perspective=spec.get("perspective", name),
3635
category=cat_id or None,
3736
category_name=cat_info.get("name", cat_id) if cat_id else None,
37+
source=source_id,
3838
)
3939

4040

@@ -64,6 +64,7 @@ def update_expert(name: str, req: ExpertUpdateRequest):
6464
if not spec:
6565
raise HTTPException(status_code=404, detail=f"Expert '{name}' not found")
6666
source_id = spec.get("source", "default")
67-
skill_path = EXPERTS_DIR / source_id / spec["skill_file"]
67+
experts_dir = get_expert_source_dir(source_id)
68+
skill_path = experts_dir / source_id / spec["skill_file"]
6869
skill_path.write_text(req.skill_content, encoding="utf-8")
6970
return _build_expert_info(name)

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}

0 commit comments

Comments
 (0)