From 71e673a2ed7ca4f383299e7639c9733d9ed8f83f Mon Sep 17 00:00:00 2001 From: moranzhi Date: Sun, 31 May 2026 23:28:49 +0800 Subject: [PATCH] =?UTF-8?q?Studio:=20=E5=A2=9E=E9=87=8F/=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E3=80=81=E8=8A=82=E7=82=B9=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E4=B8=8E=E7=BB=91=E5=AE=9A=E7=BC=96=E8=BE=91=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- backend/api/routes/studioRoute.py | 38 +- backend/models/studio_models.py | 11 +- backend/services/studio_run_service.py | 197 ++++++- data/agent/skill_templates.json | 2 +- frontend/src/Store/Studio/StudioSlice.jsx | 111 +++- .../Studio/StudioBindingEditorPopup.css | 195 +++++++ .../Studio/StudioBindingEditorPopup.jsx | 387 ++++++++++++++ .../components/Studio/StudioRunNodeGraph.css | 11 +- .../components/Studio/StudioRunNodeGraph.jsx | 503 ++++++++++-------- .../src/components/Studio/StudioRunPage.jsx | 88 ++- .../src/components/Studio/studioRunUtils.js | 86 ++- .../StudioRunControls/StudioRunControls.css | 27 + .../StudioRunControls/StudioRunControls.jsx | 142 +++-- 13 files changed, 1504 insertions(+), 294 deletions(-) create mode 100644 frontend/src/components/Studio/StudioBindingEditorPopup.css create mode 100644 frontend/src/components/Studio/StudioBindingEditorPopup.jsx diff --git a/backend/api/routes/studioRoute.py b/backend/api/routes/studioRoute.py index a583a43..1a158ba 100644 --- a/backend/api/routes/studioRoute.py +++ b/backend/api/routes/studioRoute.py @@ -12,10 +12,12 @@ from models.studio_models import ( RenameRunRequest, RunMessageRequest, RunRerollRequest, + SaveRunRequest, StudioProject, StudioProjectSummary, StudioRun, StudioRunSummary, + SwitchRunNodeRequest, UpdateStudioProjectRequest, WorkflowTemplateSummary, WorkflowVariablesResponse, @@ -175,7 +177,7 @@ async def advance_studio_run( ): try: return studio_run_service.advance_run( - project_id, run_id, display_params=req.displayParams + project_id, run_id, display_params=req.displayParams, save_mode=req.saveMode ) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -190,6 +192,40 @@ async def advance_studio_run( raise HTTPException(status_code=500, detail=str(e)) +@router.post("/projects/{project_id}/runs/{run_id}/save", response_model=StudioRun) +async def save_studio_run( + project_id: str, run_id: str, req: SaveRunRequest +): + try: + return studio_run_service.save_run(project_id, run_id, req.mode) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error( + "Failed to save studio run %s/%s: %s", project_id, run_id, e + ) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/projects/{project_id}/runs/{run_id}/switch-node", response_model=StudioRun) +async def switch_studio_run_node( + project_id: str, run_id: str, req: SwitchRunNodeRequest +): + try: + return studio_run_service.switch_run_node(project_id, run_id, req.nodeId) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error( + "Failed to switch studio run node %s/%s: %s", project_id, run_id, e + ) + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/projects/{project_id}/runs/{run_id}/message") async def send_studio_run_message( project_id: str, run_id: str, req: RunMessageRequest diff --git a/backend/models/studio_models.py b/backend/models/studio_models.py index 97ac1bc..87d9286 100644 --- a/backend/models/studio_models.py +++ b/backend/models/studio_models.py @@ -4,7 +4,7 @@ Studio workflow editor data models. from __future__ import annotations from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Literal, Optional from pydantic import BaseModel, Field @@ -220,6 +220,15 @@ class StudioRun(BaseModel): class AdvanceRunRequest(BaseModel): displayParams: Dict[str, str] = Field(default_factory=dict) + saveMode: Literal["advance", "append", "overwrite"] = "advance" + + +class SaveRunRequest(BaseModel): + mode: Literal["incremental", "overwrite"] + + +class SwitchRunNodeRequest(BaseModel): + nodeId: str = Field(..., min_length=1) class RunMessageRequest(BaseModel): diff --git a/backend/services/studio_run_service.py b/backend/services/studio_run_service.py index 579aaad..d009a42 100644 --- a/backend/services/studio_run_service.py +++ b/backend/services/studio_run_service.py @@ -376,14 +376,115 @@ class StudioRunService: except OSError as exc: raise ValueError(f"写入世界书失败:{exc}") from exc + def _overwrite_worldbook_entry( + self, + worldbook_name: str, + node: StudioNode, + draft: Dict[str, Any], + entry_uid: str, + ) -> Dict[str, Any]: + entry_payload = self._build_worldbook_entry_payload(node, draft) + try: + return worldbook_service.update_entry( + worldbook_name, entry_uid, entry_payload + ) + except FileNotFoundError as exc: + raise ValueError(f"世界书条目不存在或无法更新:{exc}") from exc + except ValueError as exc: + raise ValueError(f"覆盖世界书失败:{exc}") from exc + except OSError as exc: + raise ValueError(f"覆盖世界书失败:{exc}") from exc + + @staticmethod + def _node_has_written_entry(state: StudioNodeRunState) -> bool: + draft = state.lastDraft or {} + return bool(draft.get("writtenEntryUid")) or state.status == "completed" + + def _save_worldbook_entry_only( + self, + project_id: str, + run_id: str, + run: StudioRun, + node: StudioNode, + state: StudioNodeRunState, + node_id: str, + save_mode: str, + ) -> StudioRun: + if not state.lastDraft: + raise ValueError("请先生成并确认当前产物后再保存") + + worldbook_name = self._resolve_bound_worldbook_name(project_id, run) + draft = state.lastDraft + + if save_mode == "append": + entry_payload = self._build_worldbook_entry_payload(node, draft) + if (draft.get("writtenEntryUid") or "").strip(): + try: + wb_data = worldbook_service.get_worldbook(worldbook_name) + entries = wb_data.get("entries") or [] + max_order = max( + (int(e.get("order", 0)) for e in entries), + default=0, + ) + entry_payload["order"] = max_order + 1 + except (TypeError, ValueError): + entry_payload["order"] = int(entry_payload.get("order", 100)) + 1 + written_entry = worldbook_service.append_entry( + worldbook_name, entry_payload + ) + else: + written_entry = self._write_worldbook_entry_on_advance( + worldbook_name, node, draft + ) + elif save_mode == "overwrite": + entry_uid = (draft.get("writtenEntryUid") or "").strip() + if not entry_uid: + raise ValueError( + "尚未写入过世界书条目,请使用「下一步」完成首次导出,或使用「增量保存」" + ) + written_entry = self._overwrite_worldbook_entry( + worldbook_name, node, draft, entry_uid + ) + else: + raise ValueError(f"不支持的保存模式:{save_mode}") + + last_draft = copy.deepcopy(draft) + last_draft["writtenEntryUid"] = written_entry.get("uid") + last_draft["writtenWorldbookName"] = worldbook_name + + now = datetime.now().isoformat() + new_node_states: list[StudioNodeRunState] = [] + for ns in run.nodeStates: + updated = ns.model_copy() + if ns.nodeId == node_id: + updated.lastDraft = last_draft + new_node_states.append(updated) + + updated_run = run.model_copy( + update={ + "nodeStates": new_node_states, + "updatedAt": now, + } + ) + self._save_run(project_id, run_id, updated_run) + return updated_run + def advance_run( self, project_id: str, run_id: str, display_params: Optional[Dict[str, str]] = None, + save_mode: str = "advance", ) -> StudioRun: run = self.get_run(project_id, run_id) - if run.status not in (StudioRunStatus.RUNNING, StudioRunStatus.PENDING): + if save_mode in ("append", "overwrite"): + if run.status not in ( + StudioRunStatus.RUNNING, + StudioRunStatus.PENDING, + StudioRunStatus.COMPLETED, + ): + raise ValueError("运行已结束,无法保存") + elif run.status not in (StudioRunStatus.RUNNING, StudioRunStatus.PENDING): raise ValueError("运行已结束,无法继续推进") current_node_id = run.currentNodeId @@ -397,7 +498,25 @@ class StudioRunService: current_state = next( (s for s in run.nodeStates if s.nodeId == current_node_id), None ) - if not current_state or current_state.status != "active": + if not current_state: + raise ValueError("当前节点状态不存在") + + if save_mode in ("append", "overwrite"): + if current_node.skillId != "studio.worldbook_entry": + raise ValueError("当前步骤不支持增量/覆盖保存") + if current_state.status not in ("active", "completed"): + raise ValueError("当前节点不可保存") + return self._save_worldbook_entry_only( + project_id, + run_id, + run, + current_node, + current_state, + current_node_id, + save_mode, + ) + + if current_state.status != "active": raise ValueError("当前节点不可执行") workflow_variables = dict(run.workflowVariables or {}) @@ -456,6 +575,80 @@ class StudioRunService: self._save_run(project_id, run_id, updated_run) return updated_run + def save_run( + self, project_id: str, run_id: str, mode: str + ) -> StudioRun: + """Save worldbook entry without advancing pipeline (incremental or overwrite).""" + mode_map = {"incremental": "append", "overwrite": "overwrite"} + if mode not in mode_map: + raise ValueError(f"不支持的保存模式:{mode}") + return self.advance_run( + project_id, run_id, save_mode=mode_map[mode] + ) + + def switch_run_node( + self, project_id: str, run_id: str, node_id: str + ) -> StudioRun: + """Manually focus a pipeline node (e.g. revisit a completed worldbook step).""" + run = self.get_run(project_id, run_id) + if run.status not in ( + StudioRunStatus.RUNNING, + StudioRunStatus.PENDING, + StudioRunStatus.COMPLETED, + ): + raise ValueError("运行已结束,无法切换节点") + + target_node = _find_node(run.pipelineSnapshot, node_id) + if not target_node: + raise ValueError(f"节点不存在:{node_id}") + if not target_node.enabled: + raise ValueError("该节点已禁用,无法切换") + if target_node.skillId != "studio.worldbook_entry": + raise ValueError("仅支持切换到世界书创作步骤") + + target_state = next( + (s for s in run.nodeStates if s.nodeId == node_id), None + ) + if not target_state: + raise ValueError("节点状态不存在") + if target_state.status not in ("active", "completed"): + raise ValueError("仅可切换到进行中或已完成的步骤") + + prev_node_id = run.currentNodeId + now = datetime.now().isoformat() + new_node_states: list[StudioNodeRunState] = [] + + for state in run.nodeStates: + updated = state.model_copy() + if state.nodeId == node_id: + updated.status = "active" + elif prev_node_id and state.nodeId == prev_node_id and prev_node_id != node_id: + if state.skillId == "studio.worldbook_entry": + if self._node_has_written_entry(state): + updated.status = "completed" + else: + updated.status = "pending" + new_node_states.append(updated) + + new_status = ( + StudioRunStatus.COMPLETED + if run.status == StudioRunStatus.COMPLETED + and not _next_enabled_node_id(run.pipelineSnapshot, node_id) + else StudioRunStatus.RUNNING + ) + + updated_run = run.model_copy( + update={ + "currentNodeId": node_id, + "nodeStates": new_node_states, + "status": new_status, + "updatedAt": now, + } + ) + updated_run = store_context_on_run(updated_run, node_id) + self._save_run(project_id, run_id, updated_run) + return updated_run + async def send_run_message( self, project_id: str, diff --git a/data/agent/skill_templates.json b/data/agent/skill_templates.json index 0b94ccc..43dc025 100644 --- a/data/agent/skill_templates.json +++ b/data/agent/skill_templates.json @@ -69,7 +69,7 @@ "dimensions": [] } }, - "runControls": ["undo", "reroll", "interrupt", "nextStep", "questions"] + "runControls": ["undo", "reroll", "interrupt", "incrementalSave", "overwriteSave", "questions"] } ] } diff --git a/frontend/src/Store/Studio/StudioSlice.jsx b/frontend/src/Store/Studio/StudioSlice.jsx index 6ae62ef..b944e7d 100644 --- a/frontend/src/Store/Studio/StudioSlice.jsx +++ b/frontend/src/Store/Studio/StudioSlice.jsx @@ -165,6 +165,11 @@ const useStudioStore = create((set, get) => ({ runError: null, runSessionEntered: false, + bindingEditorOpen: false, + bindingEditorHighlightUid: null, + bindingEditorInitialTab: 'character', + bindingEditorSaveNotice: '', + setSelectedNodeId: (id) => set({ selectedNodeId: id }), clearSaveMessage: () => set({ saveMessage: null, error: null }), @@ -480,6 +485,23 @@ const useStudioStore = create((set, get) => ({ setRunSessionEntered: (entered) => set({ runSessionEntered: !!entered }), + openBindingEditor: ({ highlightUid = null, tab = 'character', notice = '' } = {}) => + set({ + bindingEditorOpen: true, + bindingEditorHighlightUid: highlightUid, + bindingEditorInitialTab: tab, + bindingEditorSaveNotice: notice, + }), + + closeBindingEditor: () => + set({ + bindingEditorOpen: false, + bindingEditorHighlightUid: null, + bindingEditorSaveNotice: '', + }), + + clearBindingEditorNotice: () => set({ bindingEditorSaveNotice: '' }), + fetchRuns: async (projectId) => { if (!projectId) return []; set({ runLoading: true, runError: null }); @@ -558,7 +580,7 @@ const useStudioStore = create((set, get) => ({ return get().fetchRun(projectId, runId); }, - advanceRun: async (displayParams) => { + advanceRun: async (displayParams, { saveMode = 'advance' } = {}) => { const { runProjectId, currentRunId } = get(); if (!runProjectId || !currentRunId) return null; set({ runAdvancing: true, runError: null }); @@ -568,7 +590,7 @@ const useStudioStore = create((set, get) => ({ { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ displayParams }), + body: JSON.stringify({ displayParams, saveMode }), } ); if (!res.ok) { @@ -597,6 +619,91 @@ const useStudioStore = create((set, get) => ({ } }, + switchRunNode: async (nodeId) => { + const { runProjectId, currentRunId } = get(); + if (!runProjectId || !currentRunId || !nodeId) return null; + set({ runLoading: true, runError: null }); + try { + const res = await fetch( + `/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/switch-node`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nodeId }), + } + ); + if (!res.ok) { + let detail = await res.text(); + try { + const parsed = JSON.parse(detail); + detail = parsed.detail || detail; + } catch { + /* keep raw */ + } + throw new Error(detail || '切换节点失败'); + } + const run = await res.json(); + set({ currentRun: run, runLoading: false }); + return run; + } catch (e) { + set({ + runLoading: false, + runError: e.message || '切换节点失败', + }); + return null; + } + }, + + saveWorldbookRun: async (saveMode) => { + const { runProjectId, currentRunId } = get(); + if (!runProjectId || !currentRunId) return null; + const mode = saveMode === 'overwrite' ? 'overwrite' : 'incremental'; + set({ runAdvancing: true, runError: null }); + try { + const res = await fetch( + `/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/save`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode }), + } + ); + if (!res.ok) { + let detail = await res.text(); + try { + const parsed = JSON.parse(detail); + detail = parsed.detail || detail; + } catch { + /* keep raw text */ + } + throw new Error(detail || '保存失败'); + } + const run = await res.json(); + const nodeState = run.nodeStates?.find((n) => n.nodeId === run.currentNodeId); + const writtenUid = nodeState?.lastDraft?.writtenEntryUid || null; + const notice = + mode === 'overwrite' + ? '已覆盖保存到世界书' + : '已增量保存到世界书'; + set({ + currentRun: run, + runAdvancing: false, + bindingEditorOpen: true, + bindingEditorHighlightUid: writtenUid, + bindingEditorInitialTab: 'worldbook', + bindingEditorSaveNotice: notice, + }); + await get().fetchRuns(runProjectId); + return { run, writtenUid }; + } catch (e) { + set({ + runAdvancing: false, + runError: e.message || '保存失败', + }); + return null; + } + }, + sendRunMessage: async (content, { stream = false } = {}) => { const { runProjectId, currentRunId } = get(); if (!runProjectId || !currentRunId) return null; diff --git a/frontend/src/components/Studio/StudioBindingEditorPopup.css b/frontend/src/components/Studio/StudioBindingEditorPopup.css new file mode 100644 index 0000000..b33efa2 --- /dev/null +++ b/frontend/src/components/Studio/StudioBindingEditorPopup.css @@ -0,0 +1,195 @@ +.studio-binding-editor { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + min-height: 240px; + min-width: min(92vw, 420px); +} + +.studio-binding-editor__tabs { + display: flex; + gap: 4px; + padding-bottom: var(--spacing-xs); + border-bottom: 1px solid var(--color-border-light); +} + +.studio-binding-editor__tab { + flex: 1; + padding: 4px 8px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-muted); + font-size: 0.72rem; + cursor: pointer; +} + +.studio-binding-editor__tab.is-active { + border-color: var(--color-border); + background: var(--color-bg-tertiary); + color: var(--color-text-primary); + font-weight: 600; +} + +.studio-binding-editor__panel { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + max-height: min(55vh, 480px); + overflow: auto; +} + +.studio-binding-editor__status, +.studio-binding-editor__empty, +.studio-binding-editor__hint { + margin: 0; + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.studio-binding-editor__status--error { + color: #f5576c; +} + +.studio-binding-editor__hint--success { + color: #22c55e; + font-weight: 500; +} + +.studio-binding-editor__hero { + display: flex; + gap: var(--spacing-sm); + align-items: center; +} + +.studio-binding-editor__avatar { + width: 48px; + height: 48px; + border-radius: var(--radius-sm); + object-fit: cover; + flex-shrink: 0; + border: 1px solid var(--color-border-light); +} + +.studio-binding-editor__avatar--placeholder { + display: flex; + align-items: center; + justify-content: center; + background: var(--color-bg-tertiary); + font-size: 1.2rem; +} + +.studio-binding-editor__name { + margin: 0; + font-size: 0.9rem; + color: var(--color-text-primary); +} + +.studio-binding-editor__meta { + margin: 2px 0 0; + font-size: 0.68rem; + color: var(--color-text-muted); +} + +.studio-binding-editor__field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.studio-binding-editor__field-label { + font-size: 0.68rem; + font-weight: 600; + color: var(--color-text-muted); +} + +.studio-binding-editor__input, +.studio-binding-editor__textarea { + width: 100%; + padding: var(--spacing-xs); + border-radius: var(--radius-sm); + border: 1px solid var(--color-border-light); + background: var(--color-bg-tertiary); + color: var(--color-text-primary); + font-size: 0.72rem; + line-height: 1.45; + resize: vertical; + box-sizing: border-box; +} + +.studio-binding-editor__save-btn { + align-self: flex-start; + padding: 4px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 12%, transparent); + color: var(--color-text-primary); + font-size: 0.72rem; + cursor: pointer; +} + +.studio-binding-editor__save-btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-accent) 20%, transparent); +} + +.studio-binding-editor__save-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.studio-binding-editor__entries { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.studio-binding-editor__entry { + border: 1px solid var(--color-border-light); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.studio-binding-editor__entry.is-highlighted { + border-color: #22c55e; + box-shadow: 0 0 0 1px color-mix(in srgb, #22c55e 35%, transparent); +} + +.studio-binding-editor__entry-head { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: var(--spacing-xs) var(--spacing-sm); + border: none; + background: var(--color-bg-tertiary); + cursor: pointer; + text-align: left; +} + +.studio-binding-editor__entry-head:hover { + background: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg-tertiary)); +} + +.studio-binding-editor__entry-title { + font-size: 0.78rem; + color: var(--color-text-primary); + font-weight: 500; +} + +.studio-binding-editor__entry-meta { + font-size: 0.65rem; + color: var(--color-text-muted); +} + +.studio-binding-editor__entry-body { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + padding: var(--spacing-sm); + border-top: 1px solid var(--color-border-light); + background: var(--color-bg-secondary); +} diff --git a/frontend/src/components/Studio/StudioBindingEditorPopup.jsx b/frontend/src/components/Studio/StudioBindingEditorPopup.jsx new file mode 100644 index 0000000..654aeaa --- /dev/null +++ b/frontend/src/components/Studio/StudioBindingEditorPopup.jsx @@ -0,0 +1,387 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import useCharacterStore from '../../Store/SideBarLeft/CharacterSlice'; +import StudioInsertionPopup from './StudioInsertionPopup'; + +import './StudioBindingEditorPopup.css'; + +const CHARACTER_FIELDS = [ + { key: 'description', label: '描述', rows: 5 }, + { key: 'personality', label: '性格', rows: 3 }, + { key: 'scenario', label: '场景', rows: 3 }, + { key: 'first_mes', label: '开场白', rows: 4 }, + { key: 'mes_example', label: '对话示例', rows: 4 }, +]; + +function entryLabel(entry) { + if (entry.comment?.trim()) return entry.comment.trim(); + if (Array.isArray(entry.key) && entry.key.length) return entry.key.join(', '); + return '未命名条目'; +} + +function StudioBindingEditorPopup({ + open, + characterName, + worldbookName, + highlightEntryUid = null, + initialTab = 'character', + onClose, +}) { + const updateCharacter = useCharacterStore((s) => s.updateCharacter); + const fetchCharacters = useCharacterStore((s) => s.fetchCharacters); + + const [tab, setTab] = useState(initialTab); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [saveHint, setSaveHint] = useState(''); + const [character, setCharacter] = useState(null); + const [characterForm, setCharacterForm] = useState(null); + const [characterSaving, setCharacterSaving] = useState(false); + const [entries, setEntries] = useState([]); + const [expandedEntryUid, setExpandedEntryUid] = useState(null); + const [entryForms, setEntryForms] = useState({}); + const [entrySavingUid, setEntrySavingUid] = useState(null); + const highlightRef = useRef(null); + + const loadData = useCallback(async () => { + if (!characterName && !worldbookName) return; + setLoading(true); + setError(''); + try { + const tasks = []; + + if (characterName) { + tasks.push( + fetch(`/api/characters/${encodeURIComponent(characterName)}`) + .then(async (res) => { + if (!res.ok) throw new Error('加载角色卡失败'); + return res.json(); + }) + .then((data) => { + setCharacter(data); + setCharacterForm({ + description: data.description || '', + personality: data.personality || '', + scenario: data.scenario || '', + first_mes: data.first_mes || '', + mes_example: data.mes_example || '', + }); + }) + ); + } else { + setCharacter(null); + setCharacterForm(null); + } + + if (worldbookName) { + tasks.push( + fetch( + `/api/worldbooks/${encodeURIComponent(worldbookName)}/entries?page=1&page_size=100` + ) + .then(async (res) => { + if (!res.ok) throw new Error('加载世界书条目失败'); + return res.json(); + }) + .then((data) => { + const list = data.entries || []; + setEntries(list); + const forms = {}; + list.forEach((entry) => { + forms[entry.uid] = { + comment: entry.comment || '', + content: entry.content || '', + key: Array.isArray(entry.key) ? entry.key.join(', ') : (entry.key || ''), + }; + }); + setEntryForms(forms); + }) + ); + } else { + setEntries([]); + setEntryForms({}); + } + + await Promise.all(tasks); + } catch (e) { + setError(e.message || '加载绑定资源失败'); + } finally { + setLoading(false); + } + }, [characterName, worldbookName]); + + useEffect(() => { + if (open) { + setTab(initialTab); + setSaveHint(''); + setExpandedEntryUid(highlightEntryUid || null); + loadData(); + } + }, [open, initialTab, highlightEntryUid, loadData]); + + useEffect(() => { + if (!open || !highlightEntryUid) return undefined; + const timer = setTimeout(() => { + highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 200); + return () => clearTimeout(timer); + }, [open, highlightEntryUid, entries.length, loading]); + + const handleCharacterFieldChange = (key, value) => { + setCharacterForm((prev) => ({ ...prev, [key]: value })); + setSaveHint(''); + }; + + const handleSaveCharacter = async () => { + if (!characterName || !character || !characterForm) return; + setCharacterSaving(true); + setError(''); + try { + await updateCharacter(characterName, { + ...character, + ...characterForm, + }); + await fetchCharacters(); + setSaveHint('角色卡已保存'); + setTimeout(() => setSaveHint(''), 2500); + } catch (e) { + setError(e.message || '保存角色卡失败'); + } finally { + setCharacterSaving(false); + } + }; + + const handleEntryFieldChange = (uid, key, value) => { + setEntryForms((prev) => ({ + ...prev, + [uid]: { ...prev[uid], [key]: value }, + })); + setSaveHint(''); + }; + + const handleSaveEntry = async (uid) => { + if (!worldbookName) return; + const form = entryForms[uid]; + if (!form) return; + setEntrySavingUid(uid); + setError(''); + try { + const keyList = form.key + .split(/[,,]/) + .map((s) => s.trim()) + .filter(Boolean); + const res = await fetch( + `/api/worldbooks/${encodeURIComponent(worldbookName)}/entries/${encodeURIComponent(uid)}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + comment: form.comment, + content: form.content, + key: keyList, + }), + } + ); + if (!res.ok) { + const detail = await res.text(); + throw new Error(detail || '保存条目失败'); + } + setSaveHint('世界书条目已保存'); + setTimeout(() => setSaveHint(''), 2500); + await loadData(); + setExpandedEntryUid(uid); + } catch (e) { + setError(e.message || '保存条目失败'); + } finally { + setEntrySavingUid(null); + } + }; + + const title = characterName ? `绑定编辑 · ${characterName}` : '绑定编辑'; + + return ( + + {() => ( +
+
+ + +
+ + {saveHint ? ( +

+ {saveHint} +

+ ) : null} + + {loading ? ( +

加载中…

+ ) : error ? ( +

+ {error} +

+ ) : tab === 'character' ? ( +
+ {!character || !characterForm ? ( +

暂无绑定角色卡

+ ) : ( + <> +
+ {character.avatar ? ( + + ) : ( +
+ 🎭 +
+ )} +
+

{character.name}

+ {worldbookName ? ( +

世界书:{worldbookName}

+ ) : null} +
+
+ {CHARACTER_FIELDS.map(({ key, label, rows }) => ( +