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, RenameRunRequest,
RunMessageRequest, RunMessageRequest,
RunRerollRequest, RunRerollRequest,
SaveRunRequest,
StudioProject, StudioProject,
StudioProjectSummary, StudioProjectSummary,
StudioRun, StudioRun,
StudioRunSummary, StudioRunSummary,
SwitchRunNodeRequest,
UpdateStudioProjectRequest, UpdateStudioProjectRequest,
WorkflowTemplateSummary, WorkflowTemplateSummary,
WorkflowVariablesResponse, WorkflowVariablesResponse,
@@ -175,7 +177,7 @@ async def advance_studio_run(
): ):
try: try:
return studio_run_service.advance_run( return studio_run_service.advance_run(
project_id, run_id, display_params=req.displayParams project_id, run_id, display_params=req.displayParams, save_mode=req.saveMode
) )
except FileNotFoundError as e: except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
@@ -190,6 +192,40 @@ async def advance_studio_run(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/projects/{project_id}/runs/{run_id}/save", response_model=StudioRun)
async def save_studio_run(
project_id: str, run_id: str, req: SaveRunRequest
):
try:
return studio_run_service.save_run(project_id, run_id, req.mode)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(
"Failed to save studio run %s/%s: %s", project_id, run_id, e
)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/projects/{project_id}/runs/{run_id}/switch-node", response_model=StudioRun)
async def switch_studio_run_node(
project_id: str, run_id: str, req: SwitchRunNodeRequest
):
try:
return studio_run_service.switch_run_node(project_id, run_id, req.nodeId)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(
"Failed to switch studio run node %s/%s: %s", project_id, run_id, e
)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/projects/{project_id}/runs/{run_id}/message") @router.post("/projects/{project_id}/runs/{run_id}/message")
async def send_studio_run_message( async def send_studio_run_message(
project_id: str, run_id: str, req: RunMessageRequest project_id: str, run_id: str, req: RunMessageRequest

View File

@@ -4,7 +4,7 @@ Studio workflow editor data models.
from __future__ import annotations from __future__ import annotations
from enum import Enum from enum import Enum
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -220,6 +220,15 @@ class StudioRun(BaseModel):
class AdvanceRunRequest(BaseModel): class AdvanceRunRequest(BaseModel):
displayParams: Dict[str, str] = Field(default_factory=dict) displayParams: Dict[str, str] = Field(default_factory=dict)
saveMode: Literal["advance", "append", "overwrite"] = "advance"
class SaveRunRequest(BaseModel):
mode: Literal["incremental", "overwrite"]
class SwitchRunNodeRequest(BaseModel):
nodeId: str = Field(..., min_length=1)
class RunMessageRequest(BaseModel): class RunMessageRequest(BaseModel):

View File

@@ -376,14 +376,115 @@ class StudioRunService:
except OSError as exc: except OSError as exc:
raise ValueError(f"写入世界书失败:{exc}") from exc raise ValueError(f"写入世界书失败:{exc}") from exc
def _overwrite_worldbook_entry(
self,
worldbook_name: str,
node: StudioNode,
draft: Dict[str, Any],
entry_uid: str,
) -> Dict[str, Any]:
entry_payload = self._build_worldbook_entry_payload(node, draft)
try:
return worldbook_service.update_entry(
worldbook_name, entry_uid, entry_payload
)
except FileNotFoundError as exc:
raise ValueError(f"世界书条目不存在或无法更新:{exc}") from exc
except ValueError as exc:
raise ValueError(f"覆盖世界书失败:{exc}") from exc
except OSError as exc:
raise ValueError(f"覆盖世界书失败:{exc}") from exc
@staticmethod
def _node_has_written_entry(state: StudioNodeRunState) -> bool:
draft = state.lastDraft or {}
return bool(draft.get("writtenEntryUid")) or state.status == "completed"
def _save_worldbook_entry_only(
self,
project_id: str,
run_id: str,
run: StudioRun,
node: StudioNode,
state: StudioNodeRunState,
node_id: str,
save_mode: str,
) -> StudioRun:
if not state.lastDraft:
raise ValueError("请先生成并确认当前产物后再保存")
worldbook_name = self._resolve_bound_worldbook_name(project_id, run)
draft = state.lastDraft
if save_mode == "append":
entry_payload = self._build_worldbook_entry_payload(node, draft)
if (draft.get("writtenEntryUid") or "").strip():
try:
wb_data = worldbook_service.get_worldbook(worldbook_name)
entries = wb_data.get("entries") or []
max_order = max(
(int(e.get("order", 0)) for e in entries),
default=0,
)
entry_payload["order"] = max_order + 1
except (TypeError, ValueError):
entry_payload["order"] = int(entry_payload.get("order", 100)) + 1
written_entry = worldbook_service.append_entry(
worldbook_name, entry_payload
)
else:
written_entry = self._write_worldbook_entry_on_advance(
worldbook_name, node, draft
)
elif save_mode == "overwrite":
entry_uid = (draft.get("writtenEntryUid") or "").strip()
if not entry_uid:
raise ValueError(
"尚未写入过世界书条目,请使用「下一步」完成首次导出,或使用「增量保存」"
)
written_entry = self._overwrite_worldbook_entry(
worldbook_name, node, draft, entry_uid
)
else:
raise ValueError(f"不支持的保存模式:{save_mode}")
last_draft = copy.deepcopy(draft)
last_draft["writtenEntryUid"] = written_entry.get("uid")
last_draft["writtenWorldbookName"] = worldbook_name
now = datetime.now().isoformat()
new_node_states: list[StudioNodeRunState] = []
for ns in run.nodeStates:
updated = ns.model_copy()
if ns.nodeId == node_id:
updated.lastDraft = last_draft
new_node_states.append(updated)
updated_run = run.model_copy(
update={
"nodeStates": new_node_states,
"updatedAt": now,
}
)
self._save_run(project_id, run_id, updated_run)
return updated_run
def advance_run( def advance_run(
self, self,
project_id: str, project_id: str,
run_id: str, run_id: str,
display_params: Optional[Dict[str, str]] = None, display_params: Optional[Dict[str, str]] = None,
save_mode: str = "advance",
) -> StudioRun: ) -> StudioRun:
run = self.get_run(project_id, run_id) run = self.get_run(project_id, run_id)
if run.status not in (StudioRunStatus.RUNNING, StudioRunStatus.PENDING): if save_mode in ("append", "overwrite"):
if run.status not in (
StudioRunStatus.RUNNING,
StudioRunStatus.PENDING,
StudioRunStatus.COMPLETED,
):
raise ValueError("运行已结束,无法保存")
elif run.status not in (StudioRunStatus.RUNNING, StudioRunStatus.PENDING):
raise ValueError("运行已结束,无法继续推进") raise ValueError("运行已结束,无法继续推进")
current_node_id = run.currentNodeId current_node_id = run.currentNodeId
@@ -397,7 +498,25 @@ class StudioRunService:
current_state = next( current_state = next(
(s for s in run.nodeStates if s.nodeId == current_node_id), None (s for s in run.nodeStates if s.nodeId == current_node_id), None
) )
if not current_state or current_state.status != "active": if not current_state:
raise ValueError("当前节点状态不存在")
if save_mode in ("append", "overwrite"):
if current_node.skillId != "studio.worldbook_entry":
raise ValueError("当前步骤不支持增量/覆盖保存")
if current_state.status not in ("active", "completed"):
raise ValueError("当前节点不可保存")
return self._save_worldbook_entry_only(
project_id,
run_id,
run,
current_node,
current_state,
current_node_id,
save_mode,
)
if current_state.status != "active":
raise ValueError("当前节点不可执行") raise ValueError("当前节点不可执行")
workflow_variables = dict(run.workflowVariables or {}) workflow_variables = dict(run.workflowVariables or {})
@@ -456,6 +575,80 @@ class StudioRunService:
self._save_run(project_id, run_id, updated_run) self._save_run(project_id, run_id, updated_run)
return updated_run return updated_run
def save_run(
self, project_id: str, run_id: str, mode: str
) -> StudioRun:
"""Save worldbook entry without advancing pipeline (incremental or overwrite)."""
mode_map = {"incremental": "append", "overwrite": "overwrite"}
if mode not in mode_map:
raise ValueError(f"不支持的保存模式:{mode}")
return self.advance_run(
project_id, run_id, save_mode=mode_map[mode]
)
def switch_run_node(
self, project_id: str, run_id: str, node_id: str
) -> StudioRun:
"""Manually focus a pipeline node (e.g. revisit a completed worldbook step)."""
run = self.get_run(project_id, run_id)
if run.status not in (
StudioRunStatus.RUNNING,
StudioRunStatus.PENDING,
StudioRunStatus.COMPLETED,
):
raise ValueError("运行已结束,无法切换节点")
target_node = _find_node(run.pipelineSnapshot, node_id)
if not target_node:
raise ValueError(f"节点不存在:{node_id}")
if not target_node.enabled:
raise ValueError("该节点已禁用,无法切换")
if target_node.skillId != "studio.worldbook_entry":
raise ValueError("仅支持切换到世界书创作步骤")
target_state = next(
(s for s in run.nodeStates if s.nodeId == node_id), None
)
if not target_state:
raise ValueError("节点状态不存在")
if target_state.status not in ("active", "completed"):
raise ValueError("仅可切换到进行中或已完成的步骤")
prev_node_id = run.currentNodeId
now = datetime.now().isoformat()
new_node_states: list[StudioNodeRunState] = []
for state in run.nodeStates:
updated = state.model_copy()
if state.nodeId == node_id:
updated.status = "active"
elif prev_node_id and state.nodeId == prev_node_id and prev_node_id != node_id:
if state.skillId == "studio.worldbook_entry":
if self._node_has_written_entry(state):
updated.status = "completed"
else:
updated.status = "pending"
new_node_states.append(updated)
new_status = (
StudioRunStatus.COMPLETED
if run.status == StudioRunStatus.COMPLETED
and not _next_enabled_node_id(run.pipelineSnapshot, node_id)
else StudioRunStatus.RUNNING
)
updated_run = run.model_copy(
update={
"currentNodeId": node_id,
"nodeStates": new_node_states,
"status": new_status,
"updatedAt": now,
}
)
updated_run = store_context_on_run(updated_run, node_id)
self._save_run(project_id, run_id, updated_run)
return updated_run
async def send_run_message( async def send_run_message(
self, self,
project_id: str, project_id: str,

View File

@@ -69,7 +69,7 @@
"dimensions": [] "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, runError: null,
runSessionEntered: false, runSessionEntered: false,
bindingEditorOpen: false,
bindingEditorHighlightUid: null,
bindingEditorInitialTab: 'character',
bindingEditorSaveNotice: '',
setSelectedNodeId: (id) => set({ selectedNodeId: id }), setSelectedNodeId: (id) => set({ selectedNodeId: id }),
clearSaveMessage: () => set({ saveMessage: null, error: null }), clearSaveMessage: () => set({ saveMessage: null, error: null }),
@@ -480,6 +485,23 @@ const useStudioStore = create((set, get) => ({
setRunSessionEntered: (entered) => set({ runSessionEntered: !!entered }), setRunSessionEntered: (entered) => set({ runSessionEntered: !!entered }),
openBindingEditor: ({ highlightUid = null, tab = 'character', notice = '' } = {}) =>
set({
bindingEditorOpen: true,
bindingEditorHighlightUid: highlightUid,
bindingEditorInitialTab: tab,
bindingEditorSaveNotice: notice,
}),
closeBindingEditor: () =>
set({
bindingEditorOpen: false,
bindingEditorHighlightUid: null,
bindingEditorSaveNotice: '',
}),
clearBindingEditorNotice: () => set({ bindingEditorSaveNotice: '' }),
fetchRuns: async (projectId) => { fetchRuns: async (projectId) => {
if (!projectId) return []; if (!projectId) return [];
set({ runLoading: true, runError: null }); set({ runLoading: true, runError: null });
@@ -558,7 +580,7 @@ const useStudioStore = create((set, get) => ({
return get().fetchRun(projectId, runId); return get().fetchRun(projectId, runId);
}, },
advanceRun: async (displayParams) => { advanceRun: async (displayParams, { saveMode = 'advance' } = {}) => {
const { runProjectId, currentRunId } = get(); const { runProjectId, currentRunId } = get();
if (!runProjectId || !currentRunId) return null; if (!runProjectId || !currentRunId) return null;
set({ runAdvancing: true, runError: null }); set({ runAdvancing: true, runError: null });
@@ -568,7 +590,7 @@ const useStudioStore = create((set, get) => ({
{ {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayParams }), body: JSON.stringify({ displayParams, saveMode }),
} }
); );
if (!res.ok) { if (!res.ok) {
@@ -597,6 +619,91 @@ const useStudioStore = create((set, get) => ({
} }
}, },
switchRunNode: async (nodeId) => {
const { runProjectId, currentRunId } = get();
if (!runProjectId || !currentRunId || !nodeId) return null;
set({ runLoading: true, runError: null });
try {
const res = await fetch(
`/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/switch-node`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nodeId }),
}
);
if (!res.ok) {
let detail = await res.text();
try {
const parsed = JSON.parse(detail);
detail = parsed.detail || detail;
} catch {
/* keep raw */
}
throw new Error(detail || '切换节点失败');
}
const run = await res.json();
set({ currentRun: run, runLoading: false });
return run;
} catch (e) {
set({
runLoading: false,
runError: e.message || '切换节点失败',
});
return null;
}
},
saveWorldbookRun: async (saveMode) => {
const { runProjectId, currentRunId } = get();
if (!runProjectId || !currentRunId) return null;
const mode = saveMode === 'overwrite' ? 'overwrite' : 'incremental';
set({ runAdvancing: true, runError: null });
try {
const res = await fetch(
`/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/save`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode }),
}
);
if (!res.ok) {
let detail = await res.text();
try {
const parsed = JSON.parse(detail);
detail = parsed.detail || detail;
} catch {
/* keep raw text */
}
throw new Error(detail || '保存失败');
}
const run = await res.json();
const nodeState = run.nodeStates?.find((n) => n.nodeId === run.currentNodeId);
const writtenUid = nodeState?.lastDraft?.writtenEntryUid || null;
const notice =
mode === 'overwrite'
? '已覆盖保存到世界书'
: '已增量保存到世界书';
set({
currentRun: run,
runAdvancing: false,
bindingEditorOpen: true,
bindingEditorHighlightUid: writtenUid,
bindingEditorInitialTab: 'worldbook',
bindingEditorSaveNotice: notice,
});
await get().fetchRuns(runProjectId);
return { run, writtenUid };
} catch (e) {
set({
runAdvancing: false,
runError: e.message || '保存失败',
});
return null;
}
},
sendRunMessage: async (content, { stream = false } = {}) => { sendRunMessage: async (content, { stream = false } = {}) => {
const { runProjectId, currentRunId } = get(); const { runProjectId, currentRunId } = get();
if (!runProjectId || !currentRunId) return null; if (!runProjectId || !currentRunId) return null;

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; opacity: 0.85;
} }
.studio-run-graph__node { .studio-run-graph__node.is-selectable {
cursor: pointer; cursor: pointer;
} }
.studio-run-graph__node.is-locked {
cursor: default;
opacity: 0.72;
}
.studio-run-graph__node.is-locked .studio-run-graph__node-bg {
stroke-dasharray: 4 3;
}
.studio-run-graph__node-bg { .studio-run-graph__node-bg {
fill: var(--color-bg-tertiary); fill: var(--color-bg-tertiary);
stroke: var(--color-border-light); stroke: var(--color-border-light);

View File

@@ -25,6 +25,8 @@ function StudioRunNodeGraph({
nodeStates, nodeStates,
currentNodeId, currentNodeId,
variant = 'sidebar', variant = 'sidebar',
onNodeSelect,
canSelectNode,
}) { }) {
const containerRef = useRef(null); const containerRef = useRef(null);
const [view, setView] = useState({ x: 0, y: 0, scale: 1 }); const [view, setView] = useState({ x: 0, y: 0, scale: 1 });
@@ -63,6 +65,22 @@ function StudioRunNodeGraph({
[layout.nodes] [layout.nodes]
); );
const stateMap = useMemo(
() => Object.fromEntries((nodeStates || []).map((s) => [s.nodeId, s])),
[nodeStates]
);
const isNodeSelectable = useCallback(
(nodeId) => {
if (!onNodeSelect) return false;
if (nodeId === currentNodeId) return false;
const state = stateMap[nodeId];
if (canSelectNode) return canSelectNode(state);
return state?.skillId === 'studio.worldbook_entry';
},
[onNodeSelect, currentNodeId, stateMap, canSelectNode]
);
const handleWheel = useCallback((e) => { const handleWheel = useCallback((e) => {
e.preventDefault(); e.preventDefault();
const delta = e.deltaY > 0 ? 0.92 : 1.08; const delta = e.deltaY > 0 ? 0.92 : 1.08;
@@ -94,6 +112,7 @@ function StudioRunNodeGraph({
startX: e.clientX, startX: e.clientX,
startY: e.clientY, startY: e.clientY,
origin: { x: node.x, y: node.y }, origin: { x: node.x, y: node.y },
moved: false,
}); });
}, },
[nodeMap] [nodeMap]
@@ -112,19 +131,34 @@ function StudioRunNodeGraph({
return; return;
} }
if (dragging.type === 'node') { if (dragging.type === 'node') {
}); const dx = e.clientX - dragging.startX;
const dy = e.clientY - dragging.startY;
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
dragging.moved = true;
}
const scaleDx = dx / view.scale;
const scaleDy = dy / view.scale;
setNodeOverrides((prev) => ({ setNodeOverrides((prev) => ({
...prev, ...prev,
[dragging.nodeId]: { [dragging.nodeId]: {
x: dragging.origin.x + scaleDx,
const nodeMap = useMemo( y: dragging.origin.y + scaleDy,
}, },
})); }));
} }
}; };
); const onUp = () => {
if (
dragging?.type === 'node'
&& !dragging.moved
&& onNodeSelect
&& isNodeSelectable(dragging.nodeId)
) {
onNodeSelect(dragging.nodeId);
}
setDragging(null);
};
window.addEventListener('mousemove', onMove); window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp); window.addEventListener('mouseup', onUp);
@@ -132,7 +166,7 @@ function StudioRunNodeGraph({
window.removeEventListener('mousemove', onMove); window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp); window.removeEventListener('mouseup', onUp);
}; };
const delta = e.deltaY > 0 ? 0.92 : 1.08; }, [dragging, view.scale, onNodeSelect, isNodeSelectable]);
useEffect(() => { useEffect(() => {
setNodeOverrides({}); setNodeOverrides({});
@@ -190,11 +224,13 @@ function StudioRunNodeGraph({
/> />
); );
})} })}
}); {positionedNodes.map((node) => {
const selectable = isNodeSelectable(node.id);
return (
<g <g
key={node.id} key={node.id}
transform={`translate(${node.x},${node.y})`} transform={`translate(${node.x},${node.y})`}
[nodeMap] className={`studio-run-graph__node status-${node.status}${selectedId === node.id ? ' is-selected' : ''}${currentNodeId === node.id ? ' is-current' : ''}${selectable ? ' is-selectable' : ' is-locked'}`}
onMouseDown={(e) => handleNodeMouseDown(e, node.id)} onMouseDown={(e) => handleNodeMouseDown(e, node.id)}
> >
<rect <rect
@@ -220,11 +256,12 @@ function StudioRunNodeGraph({
{NODE_STATUS_LABEL[node.status] || node.status} {NODE_STATUS_LABEL[node.status] || node.status}
</text> </text>
</g> </g>
return; );
})}
</g> </g>
</svg> </svg>
<div className="studio-run-graph__hint"> <div className="studio-run-graph__hint">
if (dragging.type === 'node') { 依赖关系树 · 滚轮缩放 · 拖拽平移 · 点击切换步骤
</div> </div>
</div> </div>
); );

View File

@@ -6,7 +6,7 @@ import {
buildWorkflowVariableLabelMap, buildWorkflowVariableLabelMap,
getWorkflowVariableLabel, getWorkflowVariableLabel,
} from './edit/workflowVariableLabels'; } from './edit/workflowVariableLabels';
import { resolveBoundCharacterName, canUndoRun, canRerollRun, canAdvanceNextStep, getRunControls, hasRunControl } from './studioRunUtils'; import { resolveBoundCharacterName, canUndoRun, canRerollRun, canAdvanceNextStep, getRunControls, hasRunControl, shouldShowFirstExport, shouldShowSaveAppendOverwrite, canSaveWorldbookEntry, canSwitchToNode } from './studioRunUtils';
import useCharacterStore from '../../Store/SideBarLeft/CharacterSlice'; import useCharacterStore from '../../Store/SideBarLeft/CharacterSlice';
import StudioContextBlockPopup from './StudioContextBlockPopup'; import StudioContextBlockPopup from './StudioContextBlockPopup';
@@ -499,6 +499,8 @@ function StudioRunPage() {
fetchProject, fetchProject,
selectRun, selectRun,
advanceRun, advanceRun,
saveWorldbookRun,
switchRunNode,
sendRunMessage, sendRunMessage,
undoRun, undoRun,
rerollRun, rerollRun,
@@ -735,10 +737,10 @@ function StudioRunPage() {
}; };
const handleNextStep = async () => { const handleNextStep = async () => {
if (!hasRunControl(activeNodeState?.skillId, 'nextStep', skillTemplates)) return;
if (runAdvancing || runMessaging || !canAdvanceNextStep(activeNodeState)) { if (runAdvancing || runMessaging || !canAdvanceNextStep(activeNodeState)) {
return; return;
} }
clearConfirmPending();
const run = await advanceRun({}); const run = await advanceRun({});
if (run) { if (run) {
setChatInput(''); setChatInput('');
@@ -746,6 +748,41 @@ function StudioRunPage() {
} }
}; };
const handleSaveIncremental = async () => {
if (!hasRunControl(activeNodeState?.skillId, 'incrementalSave', skillTemplates)) return;
if (runAdvancing || runMessaging || !canSaveWorldbookEntry(activeNodeState)) return;
clearConfirmPending();
await saveWorldbookRun('incremental');
};
const handleSaveOverwrite = async () => {
if (!hasRunControl(activeNodeState?.skillId, 'overwriteSave', skillTemplates)) return;
if (runAdvancing || runMessaging || !canSaveWorldbookEntry(activeNodeState)) return;
clearConfirmPending();
await saveWorldbookRun('overwrite');
};
const handleSaveIncrementalClick = () => {
if (runAdvancing || runMessaging || !canSaveWorldbookEntry(activeNodeState)) return;
requestConfirmAction('saveIncremental');
};
const handleSaveOverwriteClick = () => {
if (runAdvancing || runMessaging || !canSaveWorldbookEntry(activeNodeState)) return;
requestConfirmAction('saveOverwrite');
};
const handleGraphNodeSelect = useCallback(
async (nodeId) => {
if (!runSessionEntered || !currentRun || nodeId === currentRun.currentNodeId) return;
const nodeState = currentRun.nodeStates?.find((n) => n.nodeId === nodeId);
if (!canSwitchToNode(nodeState)) return;
clearConfirmPending();
await switchRunNode(nodeId);
},
[runSessionEntered, currentRun, switchRunNode, clearConfirmPending]
);
const handleDeleteRun = async (runId) => { const handleDeleteRun = async (runId) => {
const name = runDisplayName(runs.find((r) => r.id === runId) || {}); const name = runDisplayName(runs.find((r) => r.id === runId) || {});
if (!window.confirm(`确定删除运行「${name}」?此操作不可撤销。`)) return; if (!window.confirm(`确定删除运行「${name}」?此操作不可撤销。`)) return;
@@ -772,7 +809,17 @@ function StudioRunPage() {
const inSession = runSessionEntered && !!currentRun; const inSession = runSessionEntered && !!currentRun;
const showToolCarousel = const showToolCarousel =
inSession && runControls.includes('questions') && toolQuestions.length > 0; inSession && runControls.includes('questions') && toolQuestions.length > 0;
const showNextStep = inSession && runControls.includes('nextStep'); const showNextStep =
inSession && shouldShowFirstExport(activeNodeState);
const showSaveIncremental =
inSession &&
hasRunControl(activeNodeState?.skillId, 'incrementalSave', skillTemplates) &&
shouldShowSaveAppendOverwrite(activeNodeState);
const showSaveOverwrite =
inSession &&
hasRunControl(activeNodeState?.skillId, 'overwriteSave', skillTemplates) &&
shouldShowSaveAppendOverwrite(activeNodeState);
const canSaveEntry = canSaveWorldbookEntry(activeNodeState);
const showUndo = runControls.includes('undo'); const showUndo = runControls.includes('undo');
const showReroll = runControls.includes('reroll'); const showReroll = runControls.includes('reroll');
const canUndo = canUndoRun(activeNodeState); const canUndo = canUndoRun(activeNodeState);
@@ -840,6 +887,8 @@ function StudioRunPage() {
nodeStates={graphNodeStates} nodeStates={graphNodeStates}
currentNodeId={graphCurrentNodeId} currentNodeId={graphCurrentNodeId}
variant="sidebar" variant="sidebar"
onNodeSelect={runSessionEntered ? handleGraphNodeSelect : undefined}
canSelectNode={canSwitchToNode}
/> />
); );
}; };
@@ -1049,6 +1098,39 @@ function StudioRunPage() {
<p className="studio-run-stub-note">确认左侧目前产物无误后进入下一节点</p> <p className="studio-run-stub-note">确认左侧目前产物无误后进入下一节点</p>
</div> </div>
)} )}
{(showSaveIncremental || showSaveOverwrite) && (
<div className="studio-run-right-section studio-run-save-actions">
<h2 className="studio-run-section-title">世界书保存</h2>
<div className="studio-run-turn-actions__row">
{showSaveIncremental ? (
<ConfirmTurnButton
actionKey="saveIncremental"
pendingKey={confirmPending}
label="增量保存"
className="studio-run-turn-btn studio-run-turn-btn--append"
disabled={runAdvancing || runMessaging || !canSaveEntry}
title={canSaveEntry ? '追加为新世界书条目' : '请先生成当前步骤产物'}
onConfirm={handleSaveIncremental}
onRequestConfirm={handleSaveIncrementalClick}
/>
) : null}
{showSaveOverwrite ? (
<ConfirmTurnButton
actionKey="saveOverwrite"
pendingKey={confirmPending}
label="覆盖保存"
className="studio-run-turn-btn studio-run-turn-btn--overwrite"
disabled={runAdvancing || runMessaging || !canSaveEntry}
title={canSaveEntry ? '覆盖上次写入的条目' : '请先生成当前步骤产物'}
onConfirm={handleSaveOverwrite}
onRequestConfirm={handleSaveOverwriteClick}
/>
) : null}
</div>
<p className="studio-run-stub-note">增量保存会新建条目覆盖保存会更新本步骤上次写入的条目切换步骤请使用左侧节点图</p>
</div>
)}
</> </>
); );

View File

@@ -19,6 +19,27 @@ export function resolveBoundCharacterName({ boundCharacterVar, characterId, char
return null; return null;
} }
/** Resolve bound worldbook display name from workflow variable and/or project meta. */
export function resolveBoundWorldbookName({
boundWorldbookVar,
worldbookId,
worldBooks = [],
}) {
if (boundWorldbookVar) {
const nameMatch = String(boundWorldbookVar).match(/名称[:]\s*(.+?)(?:\n|$)/);
if (nameMatch?.[1]?.trim()) {
return nameMatch[1].trim();
}
}
if (worldbookId && worldBooks.length > 0) {
const byId = worldBooks.find((wb) => wb.id === worldbookId);
if (byId?.name) return byId.name;
}
return null;
}
/** Index of the last user message in stepMessages, or -1. */ /** Index of the last user message in stepMessages, or -1. */
export function findLastUserMessageIndex(stepMessages = []) { export function findLastUserMessageIndex(stepMessages = []) {
for (let i = stepMessages.length - 1; i >= 0; i -= 1) { for (let i = stepMessages.length - 1; i >= 0; i -= 1) {
@@ -37,13 +58,54 @@ export function canRerollRun(activeNodeState) {
return findLastUserMessageIndex(activeNodeState?.stepMessages) >= 0; return findLastUserMessageIndex(activeNodeState?.stepMessages) >= 0;
} }
/** Whether the user may advance to the next pipeline node (R5). */ /** Whether the user may advance to the next pipeline node (first export). */
export function canAdvanceNextStep(activeNodeState) { export function canAdvanceNextStep(activeNodeState) {
return Boolean(activeNodeState?.lastDraft); return Boolean(activeNodeState?.lastDraft) && !nodeHasWrittenEntry(activeNodeState);
} }
/** Node has been written to worldbook at least once. */
export function nodeHasWrittenEntry(activeNodeState) {
if (!activeNodeState) return false;
if (activeNodeState.status === 'completed') return true;
return Boolean(activeNodeState.lastDraft?.writtenEntryUid);
}
/** Show 下一步 (first export + advance). */
export function shouldShowNextStep(activeNodeState) {
return !nodeHasWrittenEntry(activeNodeState);
}
/** Show 增量保存 / 覆盖保存 after first write or on revisit. */
export function shouldShowSaveAppendOverwrite(activeNodeState) {
return nodeHasWrittenEntry(activeNodeState) && Boolean(activeNodeState?.lastDraft);
}
/** Whether append/overwrite save is allowed (needs draft content). */
export function canSaveWorldbookEntry(activeNodeState) {
return Boolean(activeNodeState?.lastDraft?.entryContent?.trim());
}
/** Whether a pipeline node may be manually selected on the run graph. */
export function canSwitchToNode(nodeState) {
if (!nodeState || nodeState.skillId !== 'studio.worldbook_entry') return false;
return nodeState.status === 'active' || nodeState.status === 'completed';
}
const RUN_CONTROL_ALIASES = {
incrementalSave: ['incrementalSave', 'saveAppend'],
overwriteSave: ['overwriteSave', 'saveOverwrite'],
nextStep: ['nextStep'],
};
const DEFAULT_RUN_CONTROLS = { const DEFAULT_RUN_CONTROLS = {
'studio.worldbook_entry': ['undo', 'reroll', 'interrupt', 'nextStep', 'questions'], 'studio.worldbook_entry': [
'undo',
'reroll',
'interrupt',
'incrementalSave',
'overwriteSave',
'questions',
],
'studio.init_bind': [], 'studio.init_bind': [],
}; };
@@ -55,6 +117,20 @@ export function getRunControls(skillId, skillTemplates = []) {
return DEFAULT_RUN_CONTROLS[skillId] || []; return DEFAULT_RUN_CONTROLS[skillId] || [];
} }
export function hasRunControl(skillId, control, skillTemplates = []) { function controlMatches(runControls, control) {
return getRunControls(skillId, skillTemplates).includes(control); const aliases = RUN_CONTROL_ALIASES[control] || [control];
return aliases.some((id) => runControls.includes(id));
}
export function hasRunControl(skillId, control, skillTemplates = []) {
const runControls = getRunControls(skillId, skillTemplates);
return controlMatches(runControls, control);
}
/** First export uses 下一步 even when not listed in runControls template. */
export function shouldShowFirstExport(activeNodeState) {
return (
activeNodeState?.skillId === 'studio.worldbook_entry'
&& shouldShowNextStep(activeNodeState)
);
} }

View File

@@ -76,6 +76,17 @@
max-width: 180px; max-width: 180px;
} }
.studio-run-topbar-character--clickable {
cursor: pointer;
font-family: inherit;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.studio-run-topbar-character--clickable:hover {
border-color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg-tertiary));
}
.studio-run-topbar-character-icon { .studio-run-topbar-character-icon {
font-size: 0.85rem; font-size: 0.85rem;
flex-shrink: 0; flex-shrink: 0;
@@ -89,3 +100,19 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.studio-run-save-toast {
position: fixed;
top: 56px;
left: 50%;
transform: translateX(-50%);
z-index: var(--z-toast-item, 20100);
padding: 8px 16px;
border-radius: var(--radius-md);
background: color-mix(in srgb, #22c55e 18%, var(--color-bg-secondary));
border: 1px solid #22c55e;
color: var(--color-text-primary);
font-size: 0.78rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
pointer-events: none;
}

View File

@@ -1,8 +1,13 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import StudioBindingEditorPopup from '../../../Studio/StudioBindingEditorPopup';
import {
resolveBoundCharacterName,
resolveBoundWorldbookName,
} from '../../../Studio/studioRunUtils';
import useStudioStore from '../../../../Store/Studio/StudioSlice'; import useStudioStore from '../../../../Store/Studio/StudioSlice';
import useCharacterStore from '../../../../Store/SideBarLeft/CharacterSlice'; import useCharacterStore from '../../../../Store/SideBarLeft/CharacterSlice';
import { resolveBoundCharacterName } from '../../../Studio/studioRunUtils'; import useWorldBookStore from '../../../../Store/SideBarLeft/WorldBookSlice';
import './StudioRunControls.css'; import './StudioRunControls.css';
@@ -16,8 +21,15 @@ function StudioRunControls() {
runCreating, runCreating,
runAdvancing, runAdvancing,
runSessionEntered, runSessionEntered,
bindingEditorOpen,
bindingEditorHighlightUid,
bindingEditorInitialTab,
bindingEditorSaveNotice,
setRunProjectId, setRunProjectId,
setRunSessionEntered, setRunSessionEntered,
openBindingEditor,
closeBindingEditor,
clearBindingEditorNotice,
fetchWorkflowVariables, fetchWorkflowVariables,
fetchRuns, fetchRuns,
selectRun, selectRun,
@@ -25,6 +37,7 @@ function StudioRunControls() {
} = useStudioStore(); } = useStudioStore();
const characters = useCharacterStore((s) => s.characters); const characters = useCharacterStore((s) => s.characters);
const worldBooks = useWorldBookStore((s) => s.worldBooks);
const boundCharacterName = useMemo(() => { const boundCharacterName = useMemo(() => {
return resolveBoundCharacterName({ return resolveBoundCharacterName({
@@ -34,6 +47,14 @@ function StudioRunControls() {
}); });
}, [currentRun?.workflowVariables, meta?.characterId, characters]); }, [currentRun?.workflowVariables, meta?.characterId, characters]);
const boundWorldbookName = useMemo(() => {
return resolveBoundWorldbookName({
boundWorldbookVar: currentRun?.workflowVariables?.['workflow.boundWorldbook'],
worldbookId: meta?.worldbookId,
worldBooks,
});
}, [currentRun?.workflowVariables, meta?.worldbookId, worldBooks]);
const handleProjectChange = useCallback( const handleProjectChange = useCallback(
async (e) => { async (e) => {
const projectId = e.target.value; const projectId = e.target.value;
@@ -60,9 +81,19 @@ function StudioRunControls() {
await createRun(runProjectId); await createRun(runProjectId);
}, [runProjectId, setRunSessionEntered, createRun]); }, [runProjectId, setRunSessionEntered, createRun]);
const handleOpenBindingEditor = useCallback(() => {
clearBindingEditorNotice();
openBindingEditor({ tab: 'character' });
}, [openBindingEditor, clearBindingEditorNotice]);
const handleCloseBindingEditor = useCallback(() => {
closeBindingEditor();
}, [closeBindingEditor]);
const inSession = runSessionEntered && !!currentRun; const inSession = runSessionEntered && !!currentRun;
return ( return (
<>
<div className="studio-run-topbar-controls"> <div className="studio-run-topbar-controls">
<label className="studio-run-topbar-label"> <label className="studio-run-topbar-label">
<span className="studio-run-topbar-label-text">项目</span> <span className="studio-run-topbar-label-text">项目</span>
@@ -95,12 +126,17 @@ function StudioRunControls() {
</button> </button>
{boundCharacterName ? ( {boundCharacterName ? (
<div className="studio-run-topbar-character" title="绑定角色卡"> <button
type="button"
className="studio-run-topbar-character studio-run-topbar-character--clickable"
title="编辑绑定的角色卡与世界书"
onClick={handleOpenBindingEditor}
>
<span className="studio-run-topbar-character-icon" aria-hidden="true"> <span className="studio-run-topbar-character-icon" aria-hidden="true">
🎭 🎭
</span> </span>
<span className="studio-run-topbar-character-name">{boundCharacterName}</span> <span className="studio-run-topbar-character-name">{boundCharacterName}</span>
</div> </button>
) : null} ) : null}
{inSession ? ( {inSession ? (
@@ -113,6 +149,22 @@ function StudioRunControls() {
</button> </button>
) : null} ) : null}
</div> </div>
<StudioBindingEditorPopup
open={bindingEditorOpen}
characterName={boundCharacterName}
worldbookName={boundWorldbookName}
highlightEntryUid={bindingEditorHighlightUid}
initialTab={bindingEditorInitialTab}
onClose={handleCloseBindingEditor}
/>
{bindingEditorSaveNotice && bindingEditorOpen ? (
<div className="studio-run-save-toast" role="status">
{bindingEditorSaveNotice}
</div>
) : null}
</>
); );
} }