diff --git a/backend/services/studio_run_service.py b/backend/services/studio_run_service.py index 9642d9c..579aaad 100644 --- a/backend/services/studio_run_service.py +++ b/backend/services/studio_run_service.py @@ -30,6 +30,7 @@ from services.studio_step_respond import ( studio_step_respond, studio_step_respond_stream, ) +from models.converters import WorldBookConverter from services.worldbook_service import worldbook_service logger = logging.getLogger(__name__) @@ -278,6 +279,103 @@ class StudioRunService: } return workflow_variables, last_draft + @staticmethod + def _parse_keyword_field(raw: Any) -> List[str]: + if raw is None: + return [] + if isinstance(raw, list): + return [str(x).strip() for x in raw if str(x).strip()] + text = str(raw).strip() + if not text: + return [] + return [part.strip() for part in text.replace(",", ",").split(",") if part.strip()] + + def _resolve_bound_worldbook_name( + self, project_id: str, run: StudioRun + ) -> str: + for state in run.nodeStates: + if state.skillId != "studio.init_bind" or state.status != "completed": + continue + draft = state.lastDraft or {} + name = (draft.get("worldbookName") or "").strip() + if name: + return name + + try: + project = studio_project_service.get_project(project_id) + worldbook_id = project.meta.worldbookId + except FileNotFoundError: + worldbook_id = None + + if worldbook_id: + for summary in worldbook_service.list_worldbooks(): + wb_name = summary.get("name") + if not wb_name: + continue + try: + data = worldbook_service.get_worldbook(wb_name) + except FileNotFoundError: + continue + if data.get("id") == worldbook_id: + return wb_name + + raise ValueError("未找到绑定的世界书,请先完成「创建并绑定」步骤") + + def _build_worldbook_entry_payload( + self, node: StudioNode, draft: Dict[str, Any] + ) -> Dict[str, Any]: + insertion = (node.config or {}).get("insertion") or {} + content = (draft.get("entryContent") or "").strip() + if not content: + raise ValueError("当前步骤产物为空,请先生成世界书条目内容后再进入下一步") + + activation = (insertion.get("activationType") or "permanent").strip() + position = insertion.get("position", 0) + if isinstance(position, str): + position = WorldBookConverter.POSITION_MAP_ST_TO_INTERNAL.get(position, 0) + + key = self._parse_keyword_field( + draft.get("insertionKey") or insertion.get("key") + ) + keysecondary = self._parse_keyword_field(insertion.get("keysecondary")) + comment = ( + (draft.get("insertionComment") or insertion.get("comment") or "").strip() + or f"Studio · {node.displayName}" + ) + + payload: Dict[str, Any] = { + "content": content, + "comment": comment, + "activationType": activation, + "position": position, + "key": key, + "keysecondary": keysecondary, + "order": insertion.get("order", 100), + "depth": insertion.get("depth", 4), + "probability": insertion.get("probability", 100), + "group": insertion.get("group") or [], + "disable": bool(insertion.get("disable", False)), + } + if activation == "rag" and insertion.get("ragConfig"): + payload["ragConfig"] = insertion["ragConfig"] + return payload + + def _write_worldbook_entry_on_advance( + self, + worldbook_name: str, + node: StudioNode, + draft: Dict[str, Any], + ) -> Dict[str, Any]: + entry_payload = self._build_worldbook_entry_payload(node, draft) + try: + return worldbook_service.append_entry(worldbook_name, entry_payload) + except FileNotFoundError: + raise ValueError(f"世界书「{worldbook_name}」不存在,无法写入条目") + except ValueError as exc: + raise ValueError(f"写入世界书失败:{exc}") from exc + except OSError as exc: + raise ValueError(f"写入世界书失败:{exc}") from exc + def advance_run( self, project_id: str, @@ -314,6 +412,15 @@ class StudioRunService: elif current_node.skillId == "studio.worldbook_entry": if not current_state.lastDraft: raise ValueError("请先生成并确认当前产物后再进入下一步") + worldbook_name = self._resolve_bound_worldbook_name(project_id, run) + written_entry = self._write_worldbook_entry_on_advance( + worldbook_name, + current_node, + current_state.lastDraft, + ) + last_draft = copy.deepcopy(current_state.lastDraft) + last_draft["writtenEntryUid"] = written_entry.get("uid") + last_draft["writtenWorldbookName"] = worldbook_name else: raise NotImplementedError( f"技能「{current_node.skillId}」的执行尚未实现(R2+)" diff --git a/backend/services/worldbook_service.py b/backend/services/worldbook_service.py index 7e4cb8b..de16c97 100644 --- a/backend/services/worldbook_service.py +++ b/backend/services/worldbook_service.py @@ -232,6 +232,32 @@ class WorldBookService: raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'") + @staticmethod + def append_entry(name: str, entry_data: Dict[str, Any]) -> Dict[str, Any]: + """ + 在世界书中追加条目(规范化后写入,与 Chat 侧条目格式一致)。 + + Args: + name: 世界书名称(文件名,不含 .json) + entry_data: 条目字段(content、comment、activationType、position 等) + + Returns: + 写入后的规范化条目 + """ + data = WorldBookService._load_worldbook(name) + if not data: + raise FileNotFoundError(f"Worldbook '{name}' not found") + + if not isinstance(data.get("entries"), list): + data["entries"] = [] + + normalized = WorldBookConverter.normalize_entry(entry_data) + data["entries"].append(normalized) + now = int(datetime.now().timestamp()) + data["updatedAt"] = now + WorldBookService._save_worldbook(name, data) + return normalized + @staticmethod def create_entry(name: str, entry_data: Dict[str, Any]) -> Dict[str, Any]: """ diff --git a/frontend/src/components/Studio/StudioEditPage.css b/frontend/src/components/Studio/StudioEditPage.css index cb046f3..70c280f 100644 --- a/frontend/src/components/Studio/StudioEditPage.css +++ b/frontend/src/components/Studio/StudioEditPage.css @@ -19,12 +19,11 @@ --studio-top-control-h: 2.75rem; display: grid; - grid-template-columns: minmax(200px, 240px) minmax(280px, 1fr) minmax(160px, 220px); - grid-template-rows: var(--studio-top-label-h) var(--studio-top-control-h) auto; + grid-template-columns: minmax(200px, 1fr) minmax(280px, 1.4fr) minmax(160px, 220px); + grid-template-rows: var(--studio-top-label-h) var(--studio-top-control-h); grid-template-areas: 'label-project label-goal label-desc' - 'ctrl-project ctrl-goal ctrl-desc' - 'actions . .'; + 'ctrl-project ctrl-goal ctrl-desc'; align-items: stretch; gap: 6px var(--spacing-md); padding: var(--spacing-md) var(--spacing-lg); @@ -40,7 +39,6 @@ grid-template-areas: 'label-project label-project' 'ctrl-project ctrl-project' - 'actions actions' 'label-goal label-goal' 'ctrl-goal ctrl-goal' 'label-desc label-desc' @@ -89,6 +87,10 @@ .studio-edit-top__control--project { grid-area: ctrl-project; + display: flex; + align-items: stretch; + gap: var(--spacing-xs); + min-width: 0; } .studio-edit-top__control--goal { @@ -100,20 +102,32 @@ } .studio-edit-top__actions { - grid-area: actions; display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; gap: var(--spacing-xs); - padding-top: 2px; + flex-shrink: 0; } .studio-edit-select--project { - width: 100%; + flex: 1; + min-width: 0; min-height: var(--studio-top-control-h); box-sizing: border-box; } +@media (max-width: 960px) { + .studio-edit-top__control--project { + flex-wrap: wrap; + } + + .studio-edit-top__actions { + flex: 1 1 100%; + justify-content: flex-end; + flex-wrap: wrap; + } +} + .studio-edit-textarea--goal { width: 100%; min-height: var(--studio-top-control-h); diff --git a/frontend/src/components/Studio/StudioEditPage.jsx b/frontend/src/components/Studio/StudioEditPage.jsx index b4008e8..1a263b5 100644 --- a/frontend/src/components/Studio/StudioEditPage.jsx +++ b/frontend/src/components/Studio/StudioEditPage.jsx @@ -286,6 +286,33 @@ function StudioEditPage() { ))} +
+ + + +