Studio: 增量/覆盖保存、节点切换与绑定编辑弹窗
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,10 +12,12 @@ from models.studio_models import (
|
|||||||
RenameRunRequest,
|
RenameRunRequest,
|
||||||
RunMessageRequest,
|
RunMessageRequest,
|
||||||
RunRerollRequest,
|
RunRerollRequest,
|
||||||
|
SaveRunRequest,
|
||||||
StudioProject,
|
StudioProject,
|
||||||
StudioProjectSummary,
|
StudioProjectSummary,
|
||||||
StudioRun,
|
StudioRun,
|
||||||
StudioRunSummary,
|
StudioRunSummary,
|
||||||
|
SwitchRunNodeRequest,
|
||||||
UpdateStudioProjectRequest,
|
UpdateStudioProjectRequest,
|
||||||
WorkflowTemplateSummary,
|
WorkflowTemplateSummary,
|
||||||
WorkflowVariablesResponse,
|
WorkflowVariablesResponse,
|
||||||
@@ -175,7 +177,7 @@ async def advance_studio_run(
|
|||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
return studio_run_service.advance_run(
|
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:
|
except FileNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(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))
|
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")
|
@router.post("/projects/{project_id}/runs/{run_id}/message")
|
||||||
async def send_studio_run_message(
|
async def send_studio_run_message(
|
||||||
project_id: str, run_id: str, req: RunMessageRequest
|
project_id: str, run_id: str, req: RunMessageRequest
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Studio workflow editor data models.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -220,6 +220,15 @@ class StudioRun(BaseModel):
|
|||||||
|
|
||||||
class AdvanceRunRequest(BaseModel):
|
class AdvanceRunRequest(BaseModel):
|
||||||
displayParams: Dict[str, str] = Field(default_factory=dict)
|
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):
|
class RunMessageRequest(BaseModel):
|
||||||
|
|||||||
@@ -376,14 +376,115 @@ class StudioRunService:
|
|||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise ValueError(f"写入世界书失败:{exc}") from 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(
|
def advance_run(
|
||||||
self,
|
self,
|
||||||
project_id: str,
|
project_id: str,
|
||||||
run_id: str,
|
run_id: str,
|
||||||
display_params: Optional[Dict[str, str]] = None,
|
display_params: Optional[Dict[str, str]] = None,
|
||||||
|
save_mode: str = "advance",
|
||||||
) -> StudioRun:
|
) -> StudioRun:
|
||||||
run = self.get_run(project_id, run_id)
|
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("运行已结束,无法继续推进")
|
raise ValueError("运行已结束,无法继续推进")
|
||||||
|
|
||||||
current_node_id = run.currentNodeId
|
current_node_id = run.currentNodeId
|
||||||
@@ -397,7 +498,25 @@ class StudioRunService:
|
|||||||
current_state = next(
|
current_state = next(
|
||||||
(s for s in run.nodeStates if s.nodeId == current_node_id), None
|
(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("当前节点不可执行")
|
raise ValueError("当前节点不可执行")
|
||||||
|
|
||||||
workflow_variables = dict(run.workflowVariables or {})
|
workflow_variables = dict(run.workflowVariables or {})
|
||||||
@@ -456,6 +575,80 @@ class StudioRunService:
|
|||||||
self._save_run(project_id, run_id, updated_run)
|
self._save_run(project_id, run_id, updated_run)
|
||||||
return 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(
|
async def send_run_message(
|
||||||
self,
|
self,
|
||||||
project_id: str,
|
project_id: str,
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
"dimensions": []
|
"dimensions": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"runControls": ["undo", "reroll", "interrupt", "nextStep", "questions"]
|
"runControls": ["undo", "reroll", "interrupt", "incrementalSave", "overwriteSave", "questions"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,11 @@ const useStudioStore = create((set, get) => ({
|
|||||||
runError: null,
|
runError: null,
|
||||||
runSessionEntered: false,
|
runSessionEntered: false,
|
||||||
|
|
||||||
|
bindingEditorOpen: false,
|
||||||
|
bindingEditorHighlightUid: null,
|
||||||
|
bindingEditorInitialTab: 'character',
|
||||||
|
bindingEditorSaveNotice: '',
|
||||||
|
|
||||||
setSelectedNodeId: (id) => set({ selectedNodeId: id }),
|
setSelectedNodeId: (id) => set({ selectedNodeId: id }),
|
||||||
clearSaveMessage: () => set({ saveMessage: null, error: null }),
|
clearSaveMessage: () => set({ saveMessage: null, error: null }),
|
||||||
|
|
||||||
@@ -480,6 +485,23 @@ const useStudioStore = create((set, get) => ({
|
|||||||
|
|
||||||
setRunSessionEntered: (entered) => set({ runSessionEntered: !!entered }),
|
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) => {
|
fetchRuns: async (projectId) => {
|
||||||
if (!projectId) return [];
|
if (!projectId) return [];
|
||||||
set({ runLoading: true, runError: null });
|
set({ runLoading: true, runError: null });
|
||||||
@@ -558,7 +580,7 @@ const useStudioStore = create((set, get) => ({
|
|||||||
return get().fetchRun(projectId, runId);
|
return get().fetchRun(projectId, runId);
|
||||||
},
|
},
|
||||||
|
|
||||||
advanceRun: async (displayParams) => {
|
advanceRun: async (displayParams, { saveMode = 'advance' } = {}) => {
|
||||||
const { runProjectId, currentRunId } = get();
|
const { runProjectId, currentRunId } = get();
|
||||||
if (!runProjectId || !currentRunId) return null;
|
if (!runProjectId || !currentRunId) return null;
|
||||||
set({ runAdvancing: true, runError: null });
|
set({ runAdvancing: true, runError: null });
|
||||||
@@ -568,7 +590,7 @@ const useStudioStore = create((set, get) => ({
|
|||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ displayParams }),
|
body: JSON.stringify({ displayParams, saveMode }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
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 } = {}) => {
|
sendRunMessage: async (content, { stream = false } = {}) => {
|
||||||
const { runProjectId, currentRunId } = get();
|
const { runProjectId, currentRunId } = get();
|
||||||
if (!runProjectId || !currentRunId) return null;
|
if (!runProjectId || !currentRunId) return null;
|
||||||
|
|||||||
195
frontend/src/components/Studio/StudioBindingEditorPopup.css
Normal file
195
frontend/src/components/Studio/StudioBindingEditorPopup.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
387
frontend/src/components/Studio/StudioBindingEditorPopup.jsx
Normal file
387
frontend/src/components/Studio/StudioBindingEditorPopup.jsx
Normal file
@@ -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 (
|
||||||
|
<StudioInsertionPopup
|
||||||
|
open={open}
|
||||||
|
title={title}
|
||||||
|
defaultExpanded
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<div className="studio-binding-editor">
|
||||||
|
<div className="studio-binding-editor__tabs" role="tablist">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === 'character'}
|
||||||
|
className={`studio-binding-editor__tab${tab === 'character' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setTab('character')}
|
||||||
|
>
|
||||||
|
角色卡
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === 'worldbook'}
|
||||||
|
className={`studio-binding-editor__tab${tab === 'worldbook' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setTab('worldbook')}
|
||||||
|
>
|
||||||
|
世界书
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveHint ? (
|
||||||
|
<p className="studio-binding-editor__hint studio-binding-editor__hint--success">
|
||||||
|
{saveHint}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="studio-binding-editor__status">加载中…</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="studio-binding-editor__status studio-binding-editor__status--error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : tab === 'character' ? (
|
||||||
|
<div className="studio-binding-editor__panel">
|
||||||
|
{!character || !characterForm ? (
|
||||||
|
<p className="studio-binding-editor__empty">暂无绑定角色卡</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="studio-binding-editor__hero">
|
||||||
|
{character.avatar ? (
|
||||||
|
<img
|
||||||
|
src={character.avatar}
|
||||||
|
alt=""
|
||||||
|
className="studio-binding-editor__avatar"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="studio-binding-editor__avatar studio-binding-editor__avatar--placeholder">
|
||||||
|
🎭
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h3 className="studio-binding-editor__name">{character.name}</h3>
|
||||||
|
{worldbookName ? (
|
||||||
|
<p className="studio-binding-editor__meta">世界书:{worldbookName}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{CHARACTER_FIELDS.map(({ key, label, rows }) => (
|
||||||
|
<label key={key} className="studio-binding-editor__field">
|
||||||
|
<span className="studio-binding-editor__field-label">{label}</span>
|
||||||
|
<textarea
|
||||||
|
className="studio-binding-editor__textarea"
|
||||||
|
rows={rows}
|
||||||
|
value={characterForm[key] || ''}
|
||||||
|
onChange={(e) => handleCharacterFieldChange(key, e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="studio-binding-editor__save-btn"
|
||||||
|
onClick={handleSaveCharacter}
|
||||||
|
disabled={characterSaving}
|
||||||
|
>
|
||||||
|
{characterSaving ? '保存中…' : '保存角色卡'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="studio-binding-editor__panel">
|
||||||
|
{!worldbookName ? (
|
||||||
|
<p className="studio-binding-editor__empty">暂无绑定世界书</p>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<p className="studio-binding-editor__empty">
|
||||||
|
世界书「{worldbookName}」暂无条目
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="studio-binding-editor__entries">
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const expanded = expandedEntryUid === entry.uid;
|
||||||
|
const highlighted = highlightEntryUid === entry.uid;
|
||||||
|
const form = entryForms[entry.uid] || {
|
||||||
|
comment: entry.comment || '',
|
||||||
|
content: entry.content || '',
|
||||||
|
key: Array.isArray(entry.key) ? entry.key.join(', ') : '',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={entry.uid}
|
||||||
|
ref={highlighted ? highlightRef : null}
|
||||||
|
className={`studio-binding-editor__entry${highlighted ? ' is-highlighted' : ''}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="studio-binding-editor__entry-head"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedEntryUid(expanded ? null : entry.uid)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="studio-binding-editor__entry-title">
|
||||||
|
{entryLabel(entry)}
|
||||||
|
</span>
|
||||||
|
<span className="studio-binding-editor__entry-meta">
|
||||||
|
{entry.activationType || '—'} · order {entry.order ?? 0}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{expanded ? (
|
||||||
|
<div className="studio-binding-editor__entry-body">
|
||||||
|
<label className="studio-binding-editor__field">
|
||||||
|
<span className="studio-binding-editor__field-label">备注</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="studio-binding-editor__input"
|
||||||
|
value={form.comment}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleEntryFieldChange(entry.uid, 'comment', e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="studio-binding-editor__field">
|
||||||
|
<span className="studio-binding-editor__field-label">关键词</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="studio-binding-editor__input"
|
||||||
|
value={form.key}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleEntryFieldChange(entry.uid, 'key', e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="逗号分隔"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="studio-binding-editor__field">
|
||||||
|
<span className="studio-binding-editor__field-label">内容</span>
|
||||||
|
<textarea
|
||||||
|
className="studio-binding-editor__textarea"
|
||||||
|
rows={6}
|
||||||
|
value={form.content}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleEntryFieldChange(entry.uid, 'content', e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="studio-binding-editor__save-btn"
|
||||||
|
onClick={() => handleSaveEntry(entry.uid)}
|
||||||
|
disabled={entrySavingUid === entry.uid}
|
||||||
|
>
|
||||||
|
{entrySavingUid === entry.uid ? '保存中…' : '保存条目'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</StudioInsertionPopup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StudioBindingEditorPopup;
|
||||||
@@ -30,10 +30,19 @@
|
|||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
.studio-run-graph__node {
|
.studio-run-graph__node.is-selectable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.studio-run-graph__node.is-locked {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-run-graph__node.is-locked .studio-run-graph__node-bg {
|
||||||
|
stroke-dasharray: 4 3;
|
||||||
|
}
|
||||||
|
|
||||||
.studio-run-graph__node-bg {
|
.studio-run-graph__node-bg {
|
||||||
fill: var(--color-bg-tertiary);
|
fill: var(--color-bg-tertiary);
|
||||||
stroke: var(--color-border-light);
|
stroke: var(--color-border-light);
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ function StudioRunNodeGraph({
|
|||||||
nodeStates,
|
nodeStates,
|
||||||
currentNodeId,
|
currentNodeId,
|
||||||
variant = 'sidebar',
|
variant = 'sidebar',
|
||||||
|
onNodeSelect,
|
||||||
|
canSelectNode,
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const [view, setView] = useState({ x: 0, y: 0, scale: 1 });
|
const [view, setView] = useState({ x: 0, y: 0, scale: 1 });
|
||||||
@@ -63,6 +65,22 @@ function StudioRunNodeGraph({
|
|||||||
[layout.nodes]
|
[layout.nodes]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const stateMap = useMemo(
|
||||||
|
() => Object.fromEntries((nodeStates || []).map((s) => [s.nodeId, s])),
|
||||||
|
[nodeStates]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isNodeSelectable = useCallback(
|
||||||
|
(nodeId) => {
|
||||||
|
if (!onNodeSelect) return false;
|
||||||
|
if (nodeId === currentNodeId) return false;
|
||||||
|
const state = stateMap[nodeId];
|
||||||
|
if (canSelectNode) return canSelectNode(state);
|
||||||
|
return state?.skillId === 'studio.worldbook_entry';
|
||||||
|
},
|
||||||
|
[onNodeSelect, currentNodeId, stateMap, canSelectNode]
|
||||||
|
);
|
||||||
|
|
||||||
const handleWheel = useCallback((e) => {
|
const handleWheel = useCallback((e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const delta = e.deltaY > 0 ? 0.92 : 1.08;
|
const delta = e.deltaY > 0 ? 0.92 : 1.08;
|
||||||
@@ -94,6 +112,7 @@ function StudioRunNodeGraph({
|
|||||||
startX: e.clientX,
|
startX: e.clientX,
|
||||||
startY: e.clientY,
|
startY: e.clientY,
|
||||||
origin: { x: node.x, y: node.y },
|
origin: { x: node.x, y: node.y },
|
||||||
|
moved: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[nodeMap]
|
[nodeMap]
|
||||||
@@ -112,19 +131,34 @@ function StudioRunNodeGraph({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (dragging.type === 'node') {
|
if (dragging.type === 'node') {
|
||||||
});
|
const dx = e.clientX - dragging.startX;
|
||||||
|
const dy = e.clientY - dragging.startY;
|
||||||
|
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
|
||||||
|
dragging.moved = true;
|
||||||
|
}
|
||||||
|
const scaleDx = dx / view.scale;
|
||||||
|
const scaleDy = dy / view.scale;
|
||||||
setNodeOverrides((prev) => ({
|
setNodeOverrides((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[dragging.nodeId]: {
|
[dragging.nodeId]: {
|
||||||
|
x: dragging.origin.x + scaleDx,
|
||||||
const nodeMap = useMemo(
|
y: dragging.origin.y + scaleDy,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
);
|
const onUp = () => {
|
||||||
|
if (
|
||||||
|
dragging?.type === 'node'
|
||||||
|
&& !dragging.moved
|
||||||
|
&& onNodeSelect
|
||||||
|
&& isNodeSelectable(dragging.nodeId)
|
||||||
|
) {
|
||||||
|
onNodeSelect(dragging.nodeId);
|
||||||
|
}
|
||||||
|
setDragging(null);
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener('mousemove', onMove);
|
window.addEventListener('mousemove', onMove);
|
||||||
window.addEventListener('mouseup', onUp);
|
window.addEventListener('mouseup', onUp);
|
||||||
@@ -132,7 +166,7 @@ function StudioRunNodeGraph({
|
|||||||
window.removeEventListener('mousemove', onMove);
|
window.removeEventListener('mousemove', onMove);
|
||||||
window.removeEventListener('mouseup', onUp);
|
window.removeEventListener('mouseup', onUp);
|
||||||
};
|
};
|
||||||
const delta = e.deltaY > 0 ? 0.92 : 1.08;
|
}, [dragging, view.scale, onNodeSelect, isNodeSelectable]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNodeOverrides({});
|
setNodeOverrides({});
|
||||||
@@ -190,11 +224,13 @@ function StudioRunNodeGraph({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
});
|
{positionedNodes.map((node) => {
|
||||||
|
const selectable = isNodeSelectable(node.id);
|
||||||
|
return (
|
||||||
<g
|
<g
|
||||||
key={node.id}
|
key={node.id}
|
||||||
transform={`translate(${node.x},${node.y})`}
|
transform={`translate(${node.x},${node.y})`}
|
||||||
[nodeMap]
|
className={`studio-run-graph__node status-${node.status}${selectedId === node.id ? ' is-selected' : ''}${currentNodeId === node.id ? ' is-current' : ''}${selectable ? ' is-selectable' : ' is-locked'}`}
|
||||||
onMouseDown={(e) => handleNodeMouseDown(e, node.id)}
|
onMouseDown={(e) => handleNodeMouseDown(e, node.id)}
|
||||||
>
|
>
|
||||||
<rect
|
<rect
|
||||||
@@ -220,11 +256,12 @@ function StudioRunNodeGraph({
|
|||||||
{NODE_STATUS_LABEL[node.status] || node.status}
|
{NODE_STATUS_LABEL[node.status] || node.status}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
return;
|
);
|
||||||
|
})}
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
<div className="studio-run-graph__hint">
|
<div className="studio-run-graph__hint">
|
||||||
if (dragging.type === 'node') {
|
依赖关系树 · 滚轮缩放 · 拖拽平移 · 点击切换步骤
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
buildWorkflowVariableLabelMap,
|
buildWorkflowVariableLabelMap,
|
||||||
getWorkflowVariableLabel,
|
getWorkflowVariableLabel,
|
||||||
} from './edit/workflowVariableLabels';
|
} from './edit/workflowVariableLabels';
|
||||||
import { resolveBoundCharacterName, canUndoRun, canRerollRun, canAdvanceNextStep, getRunControls, hasRunControl } from './studioRunUtils';
|
import { resolveBoundCharacterName, canUndoRun, canRerollRun, canAdvanceNextStep, getRunControls, hasRunControl, shouldShowFirstExport, shouldShowSaveAppendOverwrite, canSaveWorldbookEntry, canSwitchToNode } from './studioRunUtils';
|
||||||
import useCharacterStore from '../../Store/SideBarLeft/CharacterSlice';
|
import useCharacterStore from '../../Store/SideBarLeft/CharacterSlice';
|
||||||
|
|
||||||
import StudioContextBlockPopup from './StudioContextBlockPopup';
|
import StudioContextBlockPopup from './StudioContextBlockPopup';
|
||||||
@@ -499,6 +499,8 @@ function StudioRunPage() {
|
|||||||
fetchProject,
|
fetchProject,
|
||||||
selectRun,
|
selectRun,
|
||||||
advanceRun,
|
advanceRun,
|
||||||
|
saveWorldbookRun,
|
||||||
|
switchRunNode,
|
||||||
sendRunMessage,
|
sendRunMessage,
|
||||||
undoRun,
|
undoRun,
|
||||||
rerollRun,
|
rerollRun,
|
||||||
@@ -735,10 +737,10 @@ function StudioRunPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleNextStep = async () => {
|
const handleNextStep = async () => {
|
||||||
if (!hasRunControl(activeNodeState?.skillId, 'nextStep', skillTemplates)) return;
|
|
||||||
if (runAdvancing || runMessaging || !canAdvanceNextStep(activeNodeState)) {
|
if (runAdvancing || runMessaging || !canAdvanceNextStep(activeNodeState)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
clearConfirmPending();
|
||||||
const run = await advanceRun({});
|
const run = await advanceRun({});
|
||||||
if (run) {
|
if (run) {
|
||||||
setChatInput('');
|
setChatInput('');
|
||||||
@@ -746,6 +748,41 @@ function StudioRunPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveIncremental = async () => {
|
||||||
|
if (!hasRunControl(activeNodeState?.skillId, 'incrementalSave', skillTemplates)) return;
|
||||||
|
if (runAdvancing || runMessaging || !canSaveWorldbookEntry(activeNodeState)) return;
|
||||||
|
clearConfirmPending();
|
||||||
|
await saveWorldbookRun('incremental');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveOverwrite = async () => {
|
||||||
|
if (!hasRunControl(activeNodeState?.skillId, 'overwriteSave', skillTemplates)) return;
|
||||||
|
if (runAdvancing || runMessaging || !canSaveWorldbookEntry(activeNodeState)) return;
|
||||||
|
clearConfirmPending();
|
||||||
|
await saveWorldbookRun('overwrite');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveIncrementalClick = () => {
|
||||||
|
if (runAdvancing || runMessaging || !canSaveWorldbookEntry(activeNodeState)) return;
|
||||||
|
requestConfirmAction('saveIncremental');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveOverwriteClick = () => {
|
||||||
|
if (runAdvancing || runMessaging || !canSaveWorldbookEntry(activeNodeState)) return;
|
||||||
|
requestConfirmAction('saveOverwrite');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGraphNodeSelect = useCallback(
|
||||||
|
async (nodeId) => {
|
||||||
|
if (!runSessionEntered || !currentRun || nodeId === currentRun.currentNodeId) return;
|
||||||
|
const nodeState = currentRun.nodeStates?.find((n) => n.nodeId === nodeId);
|
||||||
|
if (!canSwitchToNode(nodeState)) return;
|
||||||
|
clearConfirmPending();
|
||||||
|
await switchRunNode(nodeId);
|
||||||
|
},
|
||||||
|
[runSessionEntered, currentRun, switchRunNode, clearConfirmPending]
|
||||||
|
);
|
||||||
|
|
||||||
const handleDeleteRun = async (runId) => {
|
const handleDeleteRun = async (runId) => {
|
||||||
const name = runDisplayName(runs.find((r) => r.id === runId) || {});
|
const name = runDisplayName(runs.find((r) => r.id === runId) || {});
|
||||||
if (!window.confirm(`确定删除运行「${name}」?此操作不可撤销。`)) return;
|
if (!window.confirm(`确定删除运行「${name}」?此操作不可撤销。`)) return;
|
||||||
@@ -772,7 +809,17 @@ function StudioRunPage() {
|
|||||||
const inSession = runSessionEntered && !!currentRun;
|
const inSession = runSessionEntered && !!currentRun;
|
||||||
const showToolCarousel =
|
const showToolCarousel =
|
||||||
inSession && runControls.includes('questions') && toolQuestions.length > 0;
|
inSession && runControls.includes('questions') && toolQuestions.length > 0;
|
||||||
const showNextStep = inSession && runControls.includes('nextStep');
|
const showNextStep =
|
||||||
|
inSession && shouldShowFirstExport(activeNodeState);
|
||||||
|
const showSaveIncremental =
|
||||||
|
inSession &&
|
||||||
|
hasRunControl(activeNodeState?.skillId, 'incrementalSave', skillTemplates) &&
|
||||||
|
shouldShowSaveAppendOverwrite(activeNodeState);
|
||||||
|
const showSaveOverwrite =
|
||||||
|
inSession &&
|
||||||
|
hasRunControl(activeNodeState?.skillId, 'overwriteSave', skillTemplates) &&
|
||||||
|
shouldShowSaveAppendOverwrite(activeNodeState);
|
||||||
|
const canSaveEntry = canSaveWorldbookEntry(activeNodeState);
|
||||||
const showUndo = runControls.includes('undo');
|
const showUndo = runControls.includes('undo');
|
||||||
const showReroll = runControls.includes('reroll');
|
const showReroll = runControls.includes('reroll');
|
||||||
const canUndo = canUndoRun(activeNodeState);
|
const canUndo = canUndoRun(activeNodeState);
|
||||||
@@ -840,6 +887,8 @@ function StudioRunPage() {
|
|||||||
nodeStates={graphNodeStates}
|
nodeStates={graphNodeStates}
|
||||||
currentNodeId={graphCurrentNodeId}
|
currentNodeId={graphCurrentNodeId}
|
||||||
variant="sidebar"
|
variant="sidebar"
|
||||||
|
onNodeSelect={runSessionEntered ? handleGraphNodeSelect : undefined}
|
||||||
|
canSelectNode={canSwitchToNode}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1049,6 +1098,39 @@ function StudioRunPage() {
|
|||||||
<p className="studio-run-stub-note">确认左侧目前产物无误后进入下一节点</p>
|
<p className="studio-run-stub-note">确认左侧目前产物无误后进入下一节点</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(showSaveIncremental || showSaveOverwrite) && (
|
||||||
|
<div className="studio-run-right-section studio-run-save-actions">
|
||||||
|
<h2 className="studio-run-section-title">世界书保存</h2>
|
||||||
|
<div className="studio-run-turn-actions__row">
|
||||||
|
{showSaveIncremental ? (
|
||||||
|
<ConfirmTurnButton
|
||||||
|
actionKey="saveIncremental"
|
||||||
|
pendingKey={confirmPending}
|
||||||
|
label="增量保存"
|
||||||
|
className="studio-run-turn-btn studio-run-turn-btn--append"
|
||||||
|
disabled={runAdvancing || runMessaging || !canSaveEntry}
|
||||||
|
title={canSaveEntry ? '追加为新世界书条目' : '请先生成当前步骤产物'}
|
||||||
|
onConfirm={handleSaveIncremental}
|
||||||
|
onRequestConfirm={handleSaveIncrementalClick}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{showSaveOverwrite ? (
|
||||||
|
<ConfirmTurnButton
|
||||||
|
actionKey="saveOverwrite"
|
||||||
|
pendingKey={confirmPending}
|
||||||
|
label="覆盖保存"
|
||||||
|
className="studio-run-turn-btn studio-run-turn-btn--overwrite"
|
||||||
|
disabled={runAdvancing || runMessaging || !canSaveEntry}
|
||||||
|
title={canSaveEntry ? '覆盖上次写入的条目' : '请先生成当前步骤产物'}
|
||||||
|
onConfirm={handleSaveOverwrite}
|
||||||
|
onRequestConfirm={handleSaveOverwriteClick}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="studio-run-stub-note">增量保存会新建条目;覆盖保存会更新本步骤上次写入的条目。切换步骤请使用左侧节点图。</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,27 @@ export function resolveBoundCharacterName({ boundCharacterVar, characterId, char
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve bound worldbook display name from workflow variable and/or project meta. */
|
||||||
|
export function resolveBoundWorldbookName({
|
||||||
|
boundWorldbookVar,
|
||||||
|
worldbookId,
|
||||||
|
worldBooks = [],
|
||||||
|
}) {
|
||||||
|
if (boundWorldbookVar) {
|
||||||
|
const nameMatch = String(boundWorldbookVar).match(/名称[::]\s*(.+?)(?:\n|$)/);
|
||||||
|
if (nameMatch?.[1]?.trim()) {
|
||||||
|
return nameMatch[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (worldbookId && worldBooks.length > 0) {
|
||||||
|
const byId = worldBooks.find((wb) => wb.id === worldbookId);
|
||||||
|
if (byId?.name) return byId.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Index of the last user message in stepMessages, or -1. */
|
/** Index of the last user message in stepMessages, or -1. */
|
||||||
export function findLastUserMessageIndex(stepMessages = []) {
|
export function findLastUserMessageIndex(stepMessages = []) {
|
||||||
for (let i = stepMessages.length - 1; i >= 0; i -= 1) {
|
for (let i = stepMessages.length - 1; i >= 0; i -= 1) {
|
||||||
@@ -37,13 +58,54 @@ export function canRerollRun(activeNodeState) {
|
|||||||
return findLastUserMessageIndex(activeNodeState?.stepMessages) >= 0;
|
return findLastUserMessageIndex(activeNodeState?.stepMessages) >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the user may advance to the next pipeline node (R5). */
|
/** Whether the user may advance to the next pipeline node (first export). */
|
||||||
export function canAdvanceNextStep(activeNodeState) {
|
export function canAdvanceNextStep(activeNodeState) {
|
||||||
return Boolean(activeNodeState?.lastDraft);
|
return Boolean(activeNodeState?.lastDraft) && !nodeHasWrittenEntry(activeNodeState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Node has been written to worldbook at least once. */
|
||||||
|
export function nodeHasWrittenEntry(activeNodeState) {
|
||||||
|
if (!activeNodeState) return false;
|
||||||
|
if (activeNodeState.status === 'completed') return true;
|
||||||
|
return Boolean(activeNodeState.lastDraft?.writtenEntryUid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show 下一步 (first export + advance). */
|
||||||
|
export function shouldShowNextStep(activeNodeState) {
|
||||||
|
return !nodeHasWrittenEntry(activeNodeState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show 增量保存 / 覆盖保存 after first write or on revisit. */
|
||||||
|
export function shouldShowSaveAppendOverwrite(activeNodeState) {
|
||||||
|
return nodeHasWrittenEntry(activeNodeState) && Boolean(activeNodeState?.lastDraft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether append/overwrite save is allowed (needs draft content). */
|
||||||
|
export function canSaveWorldbookEntry(activeNodeState) {
|
||||||
|
return Boolean(activeNodeState?.lastDraft?.entryContent?.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a pipeline node may be manually selected on the run graph. */
|
||||||
|
export function canSwitchToNode(nodeState) {
|
||||||
|
if (!nodeState || nodeState.skillId !== 'studio.worldbook_entry') return false;
|
||||||
|
return nodeState.status === 'active' || nodeState.status === 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
const RUN_CONTROL_ALIASES = {
|
||||||
|
incrementalSave: ['incrementalSave', 'saveAppend'],
|
||||||
|
overwriteSave: ['overwriteSave', 'saveOverwrite'],
|
||||||
|
nextStep: ['nextStep'],
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_RUN_CONTROLS = {
|
const DEFAULT_RUN_CONTROLS = {
|
||||||
'studio.worldbook_entry': ['undo', 'reroll', 'interrupt', 'nextStep', 'questions'],
|
'studio.worldbook_entry': [
|
||||||
|
'undo',
|
||||||
|
'reroll',
|
||||||
|
'interrupt',
|
||||||
|
'incrementalSave',
|
||||||
|
'overwriteSave',
|
||||||
|
'questions',
|
||||||
|
],
|
||||||
'studio.init_bind': [],
|
'studio.init_bind': [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,6 +117,20 @@ export function getRunControls(skillId, skillTemplates = []) {
|
|||||||
return DEFAULT_RUN_CONTROLS[skillId] || [];
|
return DEFAULT_RUN_CONTROLS[skillId] || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasRunControl(skillId, control, skillTemplates = []) {
|
function controlMatches(runControls, control) {
|
||||||
return getRunControls(skillId, skillTemplates).includes(control);
|
const aliases = RUN_CONTROL_ALIASES[control] || [control];
|
||||||
|
return aliases.some((id) => runControls.includes(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasRunControl(skillId, control, skillTemplates = []) {
|
||||||
|
const runControls = getRunControls(skillId, skillTemplates);
|
||||||
|
return controlMatches(runControls, control);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First export uses 下一步 even when not listed in runControls template. */
|
||||||
|
export function shouldShowFirstExport(activeNodeState) {
|
||||||
|
return (
|
||||||
|
activeNodeState?.skillId === 'studio.worldbook_entry'
|
||||||
|
&& shouldShowNextStep(activeNodeState)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,17 @@
|
|||||||
max-width: 180px;
|
max-width: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.studio-run-topbar-character--clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.15s ease, background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studio-run-topbar-character--clickable:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg-tertiary));
|
||||||
|
}
|
||||||
|
|
||||||
.studio-run-topbar-character-icon {
|
.studio-run-topbar-character-icon {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -89,3 +100,19 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.studio-run-save-toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 56px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: var(--z-toast-item, 20100);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: color-mix(in srgb, #22c55e 18%, var(--color-bg-secondary));
|
||||||
|
border: 1px solid #22c55e;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
import StudioBindingEditorPopup from '../../../Studio/StudioBindingEditorPopup';
|
||||||
|
import {
|
||||||
|
resolveBoundCharacterName,
|
||||||
|
resolveBoundWorldbookName,
|
||||||
|
} from '../../../Studio/studioRunUtils';
|
||||||
import useStudioStore from '../../../../Store/Studio/StudioSlice';
|
import useStudioStore from '../../../../Store/Studio/StudioSlice';
|
||||||
import useCharacterStore from '../../../../Store/SideBarLeft/CharacterSlice';
|
import useCharacterStore from '../../../../Store/SideBarLeft/CharacterSlice';
|
||||||
import { resolveBoundCharacterName } from '../../../Studio/studioRunUtils';
|
import useWorldBookStore from '../../../../Store/SideBarLeft/WorldBookSlice';
|
||||||
|
|
||||||
import './StudioRunControls.css';
|
import './StudioRunControls.css';
|
||||||
|
|
||||||
@@ -16,8 +21,15 @@ function StudioRunControls() {
|
|||||||
runCreating,
|
runCreating,
|
||||||
runAdvancing,
|
runAdvancing,
|
||||||
runSessionEntered,
|
runSessionEntered,
|
||||||
|
bindingEditorOpen,
|
||||||
|
bindingEditorHighlightUid,
|
||||||
|
bindingEditorInitialTab,
|
||||||
|
bindingEditorSaveNotice,
|
||||||
setRunProjectId,
|
setRunProjectId,
|
||||||
setRunSessionEntered,
|
setRunSessionEntered,
|
||||||
|
openBindingEditor,
|
||||||
|
closeBindingEditor,
|
||||||
|
clearBindingEditorNotice,
|
||||||
fetchWorkflowVariables,
|
fetchWorkflowVariables,
|
||||||
fetchRuns,
|
fetchRuns,
|
||||||
selectRun,
|
selectRun,
|
||||||
@@ -25,6 +37,7 @@ function StudioRunControls() {
|
|||||||
} = useStudioStore();
|
} = useStudioStore();
|
||||||
|
|
||||||
const characters = useCharacterStore((s) => s.characters);
|
const characters = useCharacterStore((s) => s.characters);
|
||||||
|
const worldBooks = useWorldBookStore((s) => s.worldBooks);
|
||||||
|
|
||||||
const boundCharacterName = useMemo(() => {
|
const boundCharacterName = useMemo(() => {
|
||||||
return resolveBoundCharacterName({
|
return resolveBoundCharacterName({
|
||||||
@@ -34,6 +47,14 @@ function StudioRunControls() {
|
|||||||
});
|
});
|
||||||
}, [currentRun?.workflowVariables, meta?.characterId, characters]);
|
}, [currentRun?.workflowVariables, meta?.characterId, characters]);
|
||||||
|
|
||||||
|
const boundWorldbookName = useMemo(() => {
|
||||||
|
return resolveBoundWorldbookName({
|
||||||
|
boundWorldbookVar: currentRun?.workflowVariables?.['workflow.boundWorldbook'],
|
||||||
|
worldbookId: meta?.worldbookId,
|
||||||
|
worldBooks,
|
||||||
|
});
|
||||||
|
}, [currentRun?.workflowVariables, meta?.worldbookId, worldBooks]);
|
||||||
|
|
||||||
const handleProjectChange = useCallback(
|
const handleProjectChange = useCallback(
|
||||||
async (e) => {
|
async (e) => {
|
||||||
const projectId = e.target.value;
|
const projectId = e.target.value;
|
||||||
@@ -60,9 +81,19 @@ function StudioRunControls() {
|
|||||||
await createRun(runProjectId);
|
await createRun(runProjectId);
|
||||||
}, [runProjectId, setRunSessionEntered, createRun]);
|
}, [runProjectId, setRunSessionEntered, createRun]);
|
||||||
|
|
||||||
|
const handleOpenBindingEditor = useCallback(() => {
|
||||||
|
clearBindingEditorNotice();
|
||||||
|
openBindingEditor({ tab: 'character' });
|
||||||
|
}, [openBindingEditor, clearBindingEditorNotice]);
|
||||||
|
|
||||||
|
const handleCloseBindingEditor = useCallback(() => {
|
||||||
|
closeBindingEditor();
|
||||||
|
}, [closeBindingEditor]);
|
||||||
|
|
||||||
const inSession = runSessionEntered && !!currentRun;
|
const inSession = runSessionEntered && !!currentRun;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="studio-run-topbar-controls">
|
<div className="studio-run-topbar-controls">
|
||||||
<label className="studio-run-topbar-label">
|
<label className="studio-run-topbar-label">
|
||||||
<span className="studio-run-topbar-label-text">项目</span>
|
<span className="studio-run-topbar-label-text">项目</span>
|
||||||
@@ -95,12 +126,17 @@ function StudioRunControls() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{boundCharacterName ? (
|
{boundCharacterName ? (
|
||||||
<div className="studio-run-topbar-character" title="绑定角色卡">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="studio-run-topbar-character studio-run-topbar-character--clickable"
|
||||||
|
title="编辑绑定的角色卡与世界书"
|
||||||
|
onClick={handleOpenBindingEditor}
|
||||||
|
>
|
||||||
<span className="studio-run-topbar-character-icon" aria-hidden="true">
|
<span className="studio-run-topbar-character-icon" aria-hidden="true">
|
||||||
🎭
|
🎭
|
||||||
</span>
|
</span>
|
||||||
<span className="studio-run-topbar-character-name">{boundCharacterName}</span>
|
<span className="studio-run-topbar-character-name">{boundCharacterName}</span>
|
||||||
</div>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{inSession ? (
|
{inSession ? (
|
||||||
@@ -113,6 +149,22 @@ function StudioRunControls() {
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<StudioBindingEditorPopup
|
||||||
|
open={bindingEditorOpen}
|
||||||
|
characterName={boundCharacterName}
|
||||||
|
worldbookName={boundWorldbookName}
|
||||||
|
highlightEntryUid={bindingEditorHighlightUid}
|
||||||
|
initialTab={bindingEditorInitialTab}
|
||||||
|
onClose={handleCloseBindingEditor}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{bindingEditorSaveNotice && bindingEditorOpen ? (
|
||||||
|
<div className="studio-run-save-toast" role="status">
|
||||||
|
{bindingEditorSaveNotice}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user