Studio: 增量/覆盖保存、节点切换与绑定编辑弹窗
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
"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,
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.studio-run-graph__node {
|
||||
.studio-run-graph__node.is-selectable {
|
||||
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 {
|
||||
fill: var(--color-bg-tertiary);
|
||||
stroke: var(--color-border-light);
|
||||
|
||||
@@ -1,233 +1,270 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
|
||||
|
||||
import { buildGraphLayout } from './edit/variableUtils';
|
||||
|
||||
|
||||
|
||||
import './StudioRunNodeGraph.css';
|
||||
|
||||
|
||||
|
||||
const NODE_STATUS_LABEL = {
|
||||
|
||||
pending: '待执行',
|
||||
|
||||
active: '进行中',
|
||||
|
||||
completed: '已完成',
|
||||
|
||||
skipped: '已跳过',
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
function defaultEdgePath(from, to, nodeW, nodeH) {
|
||||
|
||||
const x1 = from.x + nodeW / 2;
|
||||
|
||||
const y1 = from.y + nodeH;
|
||||
|
||||
const x2 = to.x + nodeW / 2;
|
||||
|
||||
const y2 = to.y;
|
||||
|
||||
const dy = Math.max(24, (y2 - y1) * 0.45);
|
||||
|
||||
return `M ${x1} ${y1} C ${x1} ${y1 + dy}, ${x2} ${y2 - dy}, ${x2} ${y2}`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function StudioRunNodeGraph({
|
||||
|
||||
pipeline,
|
||||
|
||||
nodeStates,
|
||||
|
||||
currentNodeId,
|
||||
|
||||
variant = 'sidebar',
|
||||
|
||||
}) {
|
||||
|
||||
const containerRef = useRef(null);
|
||||
|
||||
const [view, setView] = useState({ x: 0, y: 0, scale: 1 });
|
||||
|
||||
const [dragging, setDragging] = useState(null);
|
||||
|
||||
const [selectedId, setSelectedId] = useState(currentNodeId);
|
||||
|
||||
const [nodeOverrides, setNodeOverrides] = useState({});
|
||||
|
||||
|
||||
|
||||
const isCenter = variant === 'center';
|
||||
|
||||
|
||||
|
||||
const layout = useMemo(
|
||||
|
||||
() => buildGraphLayout(pipeline, nodeStates, { vertical: true }),
|
||||
|
||||
[pipeline, nodeStates]
|
||||
|
||||
);
|
||||
|
||||
|
||||
|
||||
const { nodeW, nodeH } = layout;
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
setSelectedId(currentNodeId);
|
||||
|
||||
}, [currentNodeId]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const el = containerRef.current;
|
||||
|
||||
if (!el || !layout.width) return;
|
||||
|
||||
const cw = el.clientWidth || 200;
|
||||
|
||||
const ch = el.clientHeight || 160;
|
||||
|
||||
const scale = Math.min(1, (cw - 16) / layout.width, (ch - 16) / layout.height);
|
||||
|
||||
setView({
|
||||
|
||||
x: (cw - layout.width * scale) / 2,
|
||||
|
||||
y: Math.max(8, (ch - layout.height * scale) / 2),
|
||||
|
||||
scale: Math.max(0.45, scale),
|
||||
|
||||
});
|
||||
|
||||
}, [layout.width, layout.height, pipeline, variant]);
|
||||
|
||||
|
||||
|
||||
const nodeMap = useMemo(
|
||||
|
||||
() => Object.fromEntries(layout.nodes.map((n) => [n.id, n])),
|
||||
|
||||
[layout.nodes]
|
||||
|
||||
);
|
||||
|
||||
|
||||
|
||||
const handleWheel = useCallback((e) => {
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const delta = e.deltaY > 0 ? 0.92 : 1.08;
|
||||
|
||||
setView((v) => ({
|
||||
|
||||
...v,
|
||||
|
||||
scale: Math.min(2.5, Math.max(0.35, v.scale * delta)),
|
||||
|
||||
}));
|
||||
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
const handleBgMouseDown = useCallback(
|
||||
|
||||
(e) => {
|
||||
|
||||
if (e.button !== 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
setDragging({ type: 'pan', startX: e.clientX, startY: e.clientY, origin: { ...view } });
|
||||
|
||||
},
|
||||
|
||||
[view]
|
||||
|
||||
);
|
||||
|
||||
|
||||
|
||||
const handleNodeMouseDown = useCallback(
|
||||
|
||||
(e, nodeId) => {
|
||||
|
||||
if (e.button !== 0) return;
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
setSelectedId(nodeId);
|
||||
|
||||
const node = nodeMap[nodeId];
|
||||
|
||||
if (!node) return;
|
||||
|
||||
setDragging({
|
||||
|
||||
type: 'node',
|
||||
|
||||
nodeId,
|
||||
|
||||
startX: e.clientX,
|
||||
|
||||
startY: e.clientY,
|
||||
|
||||
origin: { x: node.x, y: node.y },
|
||||
|
||||
});
|
||||
|
||||
},
|
||||
|
||||
[nodeMap]
|
||||
|
||||
);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (!dragging) return undefined;
|
||||
|
||||
|
||||
|
||||
const onMove = (e) => {
|
||||
|
||||
if (dragging.type === 'pan') {
|
||||
|
||||
setView((v) => ({
|
||||
|
||||
...v,
|
||||
|
||||
x: dragging.origin.x + (e.clientX - dragging.startX),
|
||||
|
||||
y: dragging.origin.y + (e.clientY - dragging.startY),
|
||||
|
||||
}));
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
if (dragging.type === 'node') {
|
||||
|
||||
const dx = (e.clientX - dragging.startX) / view.scale;
|
||||
|
||||
const dy = (e.clientY - dragging.startY) / view.scale;
|
||||
|
||||
setNodeOverrides((prev) => ({
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { buildGraphLayout } from './edit/variableUtils';
|
||||
|
||||
import './StudioRunNodeGraph.css';
|
||||
|
||||
const NODE_STATUS_LABEL = {
|
||||
pending: '待执行',
|
||||
active: '进行中',
|
||||
completed: '已完成',
|
||||
skipped: '已跳过',
|
||||
};
|
||||
|
||||
function defaultEdgePath(from, to, nodeW, nodeH) {
|
||||
const x1 = from.x + nodeW / 2;
|
||||
const y1 = from.y + nodeH;
|
||||
const x2 = to.x + nodeW / 2;
|
||||
const y2 = to.y;
|
||||
const dy = Math.max(24, (y2 - y1) * 0.45);
|
||||
return `M ${x1} ${y1} C ${x1} ${y1 + dy}, ${x2} ${y2 - dy}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
function StudioRunNodeGraph({
|
||||
pipeline,
|
||||
nodeStates,
|
||||
currentNodeId,
|
||||
variant = 'sidebar',
|
||||
onNodeSelect,
|
||||
canSelectNode,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const [view, setView] = useState({ x: 0, y: 0, scale: 1 });
|
||||
const [dragging, setDragging] = useState(null);
|
||||
const [selectedId, setSelectedId] = useState(currentNodeId);
|
||||
const [nodeOverrides, setNodeOverrides] = useState({});
|
||||
|
||||
const isCenter = variant === 'center';
|
||||
|
||||
const layout = useMemo(
|
||||
() => buildGraphLayout(pipeline, nodeStates, { vertical: true }),
|
||||
[pipeline, nodeStates]
|
||||
);
|
||||
|
||||
const { nodeW, nodeH } = layout;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedId(currentNodeId);
|
||||
}, [currentNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el || !layout.width) return;
|
||||
const cw = el.clientWidth || 200;
|
||||
const ch = el.clientHeight || 160;
|
||||
const scale = Math.min(1, (cw - 16) / layout.width, (ch - 16) / layout.height);
|
||||
setView({
|
||||
x: (cw - layout.width * scale) / 2,
|
||||
y: Math.max(8, (ch - layout.height * scale) / 2),
|
||||
scale: Math.max(0.45, scale),
|
||||
});
|
||||
}, [layout.width, layout.height, pipeline, variant]);
|
||||
|
||||
const nodeMap = useMemo(
|
||||
() => Object.fromEntries(layout.nodes.map((n) => [n.id, n])),
|
||||
[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) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.92 : 1.08;
|
||||
setView((v) => ({
|
||||
...v,
|
||||
scale: Math.min(2.5, Math.max(0.35, v.scale * delta)),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleBgMouseDown = useCallback(
|
||||
(e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
setDragging({ type: 'pan', startX: e.clientX, startY: e.clientY, origin: { ...view } });
|
||||
},
|
||||
[view]
|
||||
);
|
||||
|
||||
const handleNodeMouseDown = useCallback(
|
||||
(e, nodeId) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
setSelectedId(nodeId);
|
||||
const node = nodeMap[nodeId];
|
||||
if (!node) return;
|
||||
setDragging({
|
||||
type: 'node',
|
||||
nodeId,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origin: { x: node.x, y: node.y },
|
||||
moved: false,
|
||||
});
|
||||
},
|
||||
[nodeMap]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return undefined;
|
||||
|
||||
const onMove = (e) => {
|
||||
if (dragging.type === 'pan') {
|
||||
setView((v) => ({
|
||||
...v,
|
||||
x: dragging.origin.x + (e.clientX - dragging.startX),
|
||||
y: dragging.origin.y + (e.clientY - dragging.startY),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
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) => ({
|
||||
...prev,
|
||||
[dragging.nodeId]: {
|
||||
x: dragging.origin.x + scaleDx,
|
||||
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('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [dragging, view.scale, onNodeSelect, isNodeSelectable]);
|
||||
|
||||
useEffect(() => {
|
||||
setNodeOverrides({});
|
||||
}, [pipeline, nodeStates]);
|
||||
|
||||
const positionedNodes = layout.nodes.map((n) => ({
|
||||
...n,
|
||||
...(nodeOverrides[n.id] || {}),
|
||||
}));
|
||||
|
||||
const posMap = Object.fromEntries(positionedNodes.map((n) => [n.id, n]));
|
||||
|
||||
const pathFn = layout.edgePath || defaultEdgePath;
|
||||
|
||||
if (!layout.nodes.length) {
|
||||
return <div className="studio-run-graph-empty">暂无节点</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`studio-run-graph${isCenter ? ' studio-run-graph--center' : ''}`}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleBgMouseDown}
|
||||
>
|
||||
<svg
|
||||
className="studio-run-graph__svg"
|
||||
width="100%"
|
||||
height="100%"
|
||||
aria-label="节点依赖进度图"
|
||||
>
|
||||
<g transform={`translate(${view.x},${view.y}) scale(${view.scale})`}>
|
||||
<defs>
|
||||
<marker
|
||||
id="studio-run-graph-arrow"
|
||||
markerWidth="8"
|
||||
markerHeight="8"
|
||||
refX="4"
|
||||
refY="4"
|
||||
orient="auto"
|
||||
>
|
||||
<path d="M0,0 L0,8 L8,4 z" fill="var(--color-text-muted)" />
|
||||
</marker>
|
||||
</defs>
|
||||
{layout.edges.map(({ from, to }) => {
|
||||
const a = posMap[from];
|
||||
const b = posMap[to];
|
||||
if (!a || !b) return null;
|
||||
return (
|
||||
<path
|
||||
key={`${from}-${to}`}
|
||||
className="studio-run-graph__edge"
|
||||
d={pathFn(a, b, nodeW, nodeH)}
|
||||
markerEnd="url(#studio-run-graph-arrow)"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{positionedNodes.map((node) => {
|
||||
const selectable = isNodeSelectable(node.id);
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
transform={`translate(${node.x},${node.y})`}
|
||||
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)}
|
||||
>
|
||||
<rect
|
||||
className="studio-run-graph__node-bg"
|
||||
width={nodeW}
|
||||
height={nodeH}
|
||||
rx="8"
|
||||
/>
|
||||
<text
|
||||
className="studio-run-graph__node-label"
|
||||
x={nodeW / 2}
|
||||
y={nodeH / 2 - 6}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{node.label.length > 8 ? `${node.label.slice(0, 7)}…` : node.label}
|
||||
</text>
|
||||
<text
|
||||
className="studio-run-graph__node-status"
|
||||
x={nodeW / 2}
|
||||
y={nodeH / 2 + 12}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{NODE_STATUS_LABEL[node.status] || node.status}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
<div className="studio-run-graph__hint">
|
||||
依赖关系树 · 滚轮缩放 · 拖拽平移 · 点击切换步骤
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StudioRunNodeGraph;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
buildWorkflowVariableLabelMap,
|
||||
getWorkflowVariableLabel,
|
||||
} 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 StudioContextBlockPopup from './StudioContextBlockPopup';
|
||||
@@ -499,6 +499,8 @@ function StudioRunPage() {
|
||||
fetchProject,
|
||||
selectRun,
|
||||
advanceRun,
|
||||
saveWorldbookRun,
|
||||
switchRunNode,
|
||||
sendRunMessage,
|
||||
undoRun,
|
||||
rerollRun,
|
||||
@@ -735,10 +737,10 @@ function StudioRunPage() {
|
||||
};
|
||||
|
||||
const handleNextStep = async () => {
|
||||
if (!hasRunControl(activeNodeState?.skillId, 'nextStep', skillTemplates)) return;
|
||||
if (runAdvancing || runMessaging || !canAdvanceNextStep(activeNodeState)) {
|
||||
return;
|
||||
}
|
||||
clearConfirmPending();
|
||||
const run = await advanceRun({});
|
||||
if (run) {
|
||||
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 name = runDisplayName(runs.find((r) => r.id === runId) || {});
|
||||
if (!window.confirm(`确定删除运行「${name}」?此操作不可撤销。`)) return;
|
||||
@@ -772,7 +809,17 @@ function StudioRunPage() {
|
||||
const inSession = runSessionEntered && !!currentRun;
|
||||
const showToolCarousel =
|
||||
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 showReroll = runControls.includes('reroll');
|
||||
const canUndo = canUndoRun(activeNodeState);
|
||||
@@ -840,6 +887,8 @@ function StudioRunPage() {
|
||||
nodeStates={graphNodeStates}
|
||||
currentNodeId={graphCurrentNodeId}
|
||||
variant="sidebar"
|
||||
onNodeSelect={runSessionEntered ? handleGraphNodeSelect : undefined}
|
||||
canSelectNode={canSwitchToNode}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1049,6 +1098,39 @@ function StudioRunPage() {
|
||||
<p className="studio-run-stub-note">确认左侧目前产物无误后进入下一节点</p>
|
||||
</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;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function findLastUserMessageIndex(stepMessages = []) {
|
||||
for (let i = stepMessages.length - 1; i >= 0; i -= 1) {
|
||||
@@ -37,13 +58,54 @@ export function canRerollRun(activeNodeState) {
|
||||
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) {
|
||||
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 = {
|
||||
'studio.worldbook_entry': ['undo', 'reroll', 'interrupt', 'nextStep', 'questions'],
|
||||
'studio.worldbook_entry': [
|
||||
'undo',
|
||||
'reroll',
|
||||
'interrupt',
|
||||
'incrementalSave',
|
||||
'overwriteSave',
|
||||
'questions',
|
||||
],
|
||||
'studio.init_bind': [],
|
||||
};
|
||||
|
||||
@@ -55,6 +117,20 @@ export function getRunControls(skillId, skillTemplates = []) {
|
||||
return DEFAULT_RUN_CONTROLS[skillId] || [];
|
||||
}
|
||||
|
||||
export function hasRunControl(skillId, control, skillTemplates = []) {
|
||||
return getRunControls(skillId, skillTemplates).includes(control);
|
||||
function controlMatches(runControls, 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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
@@ -89,3 +100,19 @@
|
||||
text-overflow: ellipsis;
|
||||
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 StudioBindingEditorPopup from '../../../Studio/StudioBindingEditorPopup';
|
||||
import {
|
||||
resolveBoundCharacterName,
|
||||
resolveBoundWorldbookName,
|
||||
} from '../../../Studio/studioRunUtils';
|
||||
import useStudioStore from '../../../../Store/Studio/StudioSlice';
|
||||
import useCharacterStore from '../../../../Store/SideBarLeft/CharacterSlice';
|
||||
import { resolveBoundCharacterName } from '../../../Studio/studioRunUtils';
|
||||
import useWorldBookStore from '../../../../Store/SideBarLeft/WorldBookSlice';
|
||||
|
||||
import './StudioRunControls.css';
|
||||
|
||||
@@ -16,8 +21,15 @@ function StudioRunControls() {
|
||||
runCreating,
|
||||
runAdvancing,
|
||||
runSessionEntered,
|
||||
bindingEditorOpen,
|
||||
bindingEditorHighlightUid,
|
||||
bindingEditorInitialTab,
|
||||
bindingEditorSaveNotice,
|
||||
setRunProjectId,
|
||||
setRunSessionEntered,
|
||||
openBindingEditor,
|
||||
closeBindingEditor,
|
||||
clearBindingEditorNotice,
|
||||
fetchWorkflowVariables,
|
||||
fetchRuns,
|
||||
selectRun,
|
||||
@@ -25,6 +37,7 @@ function StudioRunControls() {
|
||||
} = useStudioStore();
|
||||
|
||||
const characters = useCharacterStore((s) => s.characters);
|
||||
const worldBooks = useWorldBookStore((s) => s.worldBooks);
|
||||
|
||||
const boundCharacterName = useMemo(() => {
|
||||
return resolveBoundCharacterName({
|
||||
@@ -34,6 +47,14 @@ function StudioRunControls() {
|
||||
});
|
||||
}, [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(
|
||||
async (e) => {
|
||||
const projectId = e.target.value;
|
||||
@@ -60,59 +81,90 @@ function StudioRunControls() {
|
||||
await createRun(runProjectId);
|
||||
}, [runProjectId, setRunSessionEntered, createRun]);
|
||||
|
||||
const handleOpenBindingEditor = useCallback(() => {
|
||||
clearBindingEditorNotice();
|
||||
openBindingEditor({ tab: 'character' });
|
||||
}, [openBindingEditor, clearBindingEditorNotice]);
|
||||
|
||||
const handleCloseBindingEditor = useCallback(() => {
|
||||
closeBindingEditor();
|
||||
}, [closeBindingEditor]);
|
||||
|
||||
const inSession = runSessionEntered && !!currentRun;
|
||||
|
||||
return (
|
||||
<div className="studio-run-topbar-controls">
|
||||
<label className="studio-run-topbar-label">
|
||||
<span className="studio-run-topbar-label-text">项目</span>
|
||||
<select
|
||||
className="studio-run-topbar-select"
|
||||
value={runProjectId || ''}
|
||||
onChange={handleProjectChange}
|
||||
disabled={runLoading || runCreating || runAdvancing}
|
||||
aria-label="选择 Studio 项目"
|
||||
>
|
||||
{projects.length === 0 ? (
|
||||
<option value="">暂无项目</option>
|
||||
) : (
|
||||
projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<>
|
||||
<div className="studio-run-topbar-controls">
|
||||
<label className="studio-run-topbar-label">
|
||||
<span className="studio-run-topbar-label-text">项目</span>
|
||||
<select
|
||||
className="studio-run-topbar-select"
|
||||
value={runProjectId || ''}
|
||||
onChange={handleProjectChange}
|
||||
disabled={runLoading || runCreating || runAdvancing}
|
||||
aria-label="选择 Studio 项目"
|
||||
>
|
||||
{projects.length === 0 ? (
|
||||
<option value="">暂无项目</option>
|
||||
) : (
|
||||
projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="studio-run-topbar-btn studio-run-topbar-btn--primary"
|
||||
onClick={handleNewRun}
|
||||
disabled={!runProjectId || runCreating || runAdvancing}
|
||||
>
|
||||
{runCreating ? '创建中…' : '新开会话'}
|
||||
</button>
|
||||
|
||||
{boundCharacterName ? (
|
||||
<div className="studio-run-topbar-character" title="绑定角色卡">
|
||||
<span className="studio-run-topbar-character-icon" aria-hidden="true">
|
||||
🎭
|
||||
</span>
|
||||
<span className="studio-run-topbar-character-name">{boundCharacterName}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{inSession ? (
|
||||
<button
|
||||
type="button"
|
||||
className="studio-run-topbar-btn studio-run-topbar-btn--ghost"
|
||||
onClick={() => setRunSessionEntered(false)}
|
||||
className="studio-run-topbar-btn studio-run-topbar-btn--primary"
|
||||
onClick={handleNewRun}
|
||||
disabled={!runProjectId || runCreating || runAdvancing}
|
||||
>
|
||||
退出会话
|
||||
{runCreating ? '创建中…' : '新开会话'}
|
||||
</button>
|
||||
|
||||
{boundCharacterName ? (
|
||||
<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>
|
||||
<span className="studio-run-topbar-character-name">{boundCharacterName}</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{inSession ? (
|
||||
<button
|
||||
type="button"
|
||||
className="studio-run-topbar-btn studio-run-topbar-btn--ghost"
|
||||
onClick={() => setRunSessionEntered(false)}
|
||||
>
|
||||
退出会话
|
||||
</button>
|
||||
) : null}
|
||||
</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}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user