Studio: 增量/覆盖保存、节点切换与绑定编辑弹窗

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 23:28:49 +08:00
parent 0f50c98cf3
commit 71e673a2ed
13 changed files with 1504 additions and 294 deletions

View File

@@ -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

View File

@@ -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):

View File

@@ -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,

View File

@@ -69,7 +69,7 @@
"dimensions": []
}
},
"runControls": ["undo", "reroll", "interrupt", "nextStep", "questions"]
"runControls": ["undo", "reroll", "interrupt", "incrementalSave", "overwriteSave", "questions"]
}
]
}

View File

@@ -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;

View 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);
}

View 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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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>
)}
</>
);

View File

@@ -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)
);
}

View File

@@ -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;
}

View File

@@ -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>
</>
);
}