import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import useAppLayoutStore from '../../Store/AppLayoutSlice';
import useStudioStore from '../../Store/Studio/StudioSlice';
import {
buildWorkflowVariableLabelMap,
getWorkflowVariableLabel,
} from './edit/workflowVariableLabels';
import { resolveBoundCharacterName, canUndoRun, canRerollRun, canAdvanceNextStep, getRunControls, hasRunControl, shouldShowFirstExport, shouldShowSaveAppendOverwrite, canSaveWorldbookEntry, canSwitchToNode, isInitBindCompleted } from './studioRunUtils';
import useCharacterStore from '../../Store/SideBarLeft/CharacterSlice';
import StudioContextBlockPopup from './StudioContextBlockPopup';
import StudioInsertionPopup from './StudioInsertionPopup';
import StudioRunChat from './StudioRunChat';
import StudioRunNodeGraph from './StudioRunNodeGraph';
import './StudioRunPage.css';
const RUN_STATUS_LABEL = {
pending: '待开始',
running: '运行中',
paused: '已暂停',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
};
const RUN_LIST_COLLAPSE_THRESHOLD = 4;
const RUN_ITEM_HEIGHT = 56;
const CONFIRM_ACTION_TIMEOUT_MS = 4000;
function ConfirmTurnButton({
actionKey,
pendingKey,
label,
className,
disabled,
title,
onConfirm,
onRequestConfirm,
}) {
const isPending = pendingKey === actionKey;
return (
);
}
function formatRunTime(iso) {
if (!iso) return '';
try {
const d = new Date(iso);
return d.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return iso;
}
}
function runDisplayName(run) {
if (run.title?.trim()) return run.title.trim();
return formatRunTime(run.createdAt) || '未命名运行';
}
function InitBindGuidanceForm({ node, runAdvancing, onSubmit, compact = false }) {
const displayParams = node?.displayParams || [];
const [formValues, setFormValues] = useState({});
const [fieldErrors, setFieldErrors] = useState({});
useEffect(() => {
const initial = {};
displayParams.forEach((dp) => {
initial[dp.key] = '';
});
setFormValues(initial);
setFieldErrors({});
}, [node?.nodeId, displayParams]);
const handleChange = (key, value) => {
setFormValues((prev) => ({ ...prev, [key]: value }));
setFieldErrors((prev) => ({ ...prev, [key]: null }));
};
const handleSubmit = (e) => {
e.preventDefault();
const errors = {};
displayParams.forEach((dp) => {
if (dp.required && !(formValues[dp.key] || '').trim()) {
errors[dp.key] = `请填写${dp.label}`;
}
});
if (Object.keys(errors).length > 0) {
setFieldErrors(errors);
return;
}
onSubmit(formValues);
};
if (displayParams.length === 0) {
return
当前步骤未定义引导字段
;
}
return (
);
}
function ToolOptionsCarousel({ options, index, onPrev, onNext, runAdvancing }) {
const current = options[index];
const [selectedOption, setSelectedOption] = useState(null);
const [copyHint, setCopyHint] = useState('');
useEffect(() => {
setSelectedOption(null);
setCopyHint('');
}, [index, current?.question]);
if (!options.length || !current) return null;
const handleSelect = (opt) => {
setSelectedOption(opt);
setCopyHint('');
};
const handleCopy = async () => {
if (!selectedOption) return;
try {
await navigator.clipboard.writeText(selectedOption);
setCopyHint('已复制到剪贴板');
setTimeout(() => setCopyHint(''), 2000);
} catch {
setCopyHint('复制失败,请手动选择文本');
}
};
return (
模型提问
{index + 1} / {options.length}
{current.question}
{(current.options || []).map((opt) => (
))}
{selectedOption ? (
复制以下内容到输入框
{copyHint ? (
{copyHint}
) : null}
) : null}
);
}
function PromptBlocksDebug({ blocks, onBlockClick }) {
const [open, setOpen] = useState(false);
if (!blocks?.length) return null;
return (
{open && (
{blocks.map((block) => (
))}
)}
);
}
function RunListItem({
run,
selected,
onSelect,
onDelete,
renamingId,
renameValue,
onRenameChange,
onRenameSubmit,
onRenameCancel,
onStartRename,
}) {
const isRenaming = renamingId === run.id;
return (
{isRenaming ? (
) : (
<>
>
)}
);
}
function ProductPreview({ activeNodeState, isWorldbookActive, compact = false }) {
let productPreview = '暂无产物';
if (activeNodeState?.lastDraft) {
const draft = activeNodeState.lastDraft;
for (const key of ['entryContent', 'content', 'text', 'body']) {
if (typeof draft[key] === 'string' && draft[key].trim()) {
productPreview = draft[key];
break;
}
}
if (productPreview === '暂无产物') {
productPreview = JSON.stringify(draft, null, 2);
}
} else if (isWorldbookActive) {
productPreview = '(等待模型生成条目草稿…)';
}
return (
目前产物
{activeNodeState?.displayName && (
{activeNodeState.displayName}
)}
{productPreview}
);
}
function WorkflowVariablesPanel({ variables, labelMap }) {
const entries = Object.entries(variables || {});
if (entries.length === 0) return null;
return (
工作流变量
{entries.map(([key, value]) => (
- {getWorkflowVariableLabel(key, labelMap)}
- {typeof value === 'string' ? value : JSON.stringify(value)}
))}
);
}
function CurrentNodePanel({ activeNodeState, graphCurrentNodeId, graphPipeline }) {
const nodeName =
activeNodeState?.displayName ||
graphPipeline?.nodes?.find((n) => n.id === graphCurrentNodeId)?.displayName ||
'—';
const nodeStatus = activeNodeState?.status;
return (
当前节点
- 步骤
- {nodeName}
{nodeStatus && (
- 状态
- {RUN_STATUS_LABEL[nodeStatus] || nodeStatus}
)}
{activeNodeState?.skillId && (
- 技能
- {activeNodeState.skillId}
)}
);
}
function InsertionSidebar({ insertionPreview, onOpenEntry }) {
if (!insertionPreview) {
return (
);
}
return (
插入内容
);
}
function StudioRunPage() {
const {
pipeline: projectPipeline,
workflowVariables: workflowVariablesCatalog,
meta,
runProjectId,
runs,
currentRunId,
currentRun,
runLoading,
runAdvancing,
runMessaging,
runStreamingThinking,
runError,
runSessionEntered,
setRunSessionEntered,
initStudioRun,
fetchProject,
selectRun,
advanceRun,
saveWorldbookRun,
switchRunNode,
sendRunMessage,
undoRun,
rerollRun,
interruptRun,
deleteRun,
renameRun,
skillTemplates,
} = useStudioStore();
const sidebarMode = useAppLayoutStore((s) => s.sidebarMode);
const isSidebarHovered = useAppLayoutStore((s) => s.isSidebarHovered);
const setSidebarHovered = useAppLayoutStore((s) => s.setSidebarHovered);
const hoverTimeoutRef = useRef(null);
const leaveTimeoutRef = useRef(null);
const handleLeftMouseEnter = useCallback(() => {
if (sidebarMode !== 'smart') return;
if (leaveTimeoutRef.current) clearTimeout(leaveTimeoutRef.current);
hoverTimeoutRef.current = setTimeout(() => {
setSidebarHovered(true);
}, 400);
}, [sidebarMode, setSidebarHovered]);
const handleLeftMouseLeave = useCallback(() => {
if (sidebarMode !== 'smart') return;
if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current);
leaveTimeoutRef.current = setTimeout(() => {
setSidebarHovered(false);
}, 250);
}, [sidebarMode, setSidebarHovered]);
useEffect(() => {
return () => {
if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current);
if (leaveTimeoutRef.current) clearTimeout(leaveTimeoutRef.current);
};
}, []);
const variableLabelMap = useMemo(
() => buildWorkflowVariableLabelMap(workflowVariablesCatalog),
[workflowVariablesCatalog]
);
const [insertionPopupOpen, setInsertionPopupOpen] = useState(false);
const [insertionPopupExpanded, setInsertionPopupExpanded] = useState(true);
const [contextBlockPopup, setContextBlockPopup] = useState(null);
const [chatInput, setChatInput] = useState('');
const [streamOutput, setStreamOutput] = useState(false);
const [toolOptionIndex, setToolOptionIndex] = useState(0);
const [runListExpanded, setRunListExpanded] = useState(false);
const [renamingId, setRenamingId] = useState(null);
const [renameValue, setRenameValue] = useState('');
const [confirmPending, setConfirmPending] = useState(null);
const confirmTimeoutRef = useRef(null);
useEffect(() => {
initStudioRun();
}, [initStudioRun]);
useEffect(() => {
if (runProjectId) {
fetchProject(runProjectId);
}
}, [runProjectId, fetchProject]);
useEffect(() => {
setRunSessionEntered(false);
setInsertionPopupOpen(false);
setContextBlockPopup(null);
}, [currentRunId, runProjectId, setRunSessionEntered]);
const activeNodeState = useMemo(() => {
if (!currentRun?.currentNodeId) return null;
return currentRun.nodeStates?.find((n) => n.nodeId === currentRun.currentNodeId);
}, [currentRun]);
const activePipelineNode = useMemo(() => {
if (!currentRun?.currentNodeId) return null;
return currentRun.pipelineSnapshot?.nodes?.find(
(n) => n.id === currentRun.currentNodeId
);
}, [currentRun]);
const isInitBindPending =
activeNodeState?.status === 'active' &&
activeNodeState?.skillId === 'studio.init_bind' &&
!isInitBindCompleted(activeNodeState);
const runControls = useMemo(
() => getRunControls(activeNodeState?.skillId, skillTemplates),
[activeNodeState?.skillId, skillTemplates]
);
const isWorldbookActive =
activeNodeState?.status === 'active' &&
hasRunControl(activeNodeState?.skillId, 'interrupt', skillTemplates);
const toolQuestions = useMemo(
() => activeNodeState?.lastToolResponse?.questions || [],
[activeNodeState]
);
useEffect(() => {
setToolOptionIndex(0);
}, [currentRunId, activeNodeState?.nodeId, toolQuestions.length]);
const clearConfirmPending = useCallback(() => {
if (confirmTimeoutRef.current) {
clearTimeout(confirmTimeoutRef.current);
confirmTimeoutRef.current = null;
}
setConfirmPending(null);
}, []);
useEffect(() => {
clearConfirmPending();
}, [currentRunId, activeNodeState?.nodeId, clearConfirmPending]);
useEffect(() => {
if (!confirmPending) return undefined;
const handleClickOutside = (event) => {
if (!event.target.closest('.studio-run-turn-btn')) {
clearConfirmPending();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [confirmPending, clearConfirmPending]);
useEffect(() => {
return () => {
if (confirmTimeoutRef.current) clearTimeout(confirmTimeoutRef.current);
};
}, []);
const requestConfirmAction = useCallback((actionKey) => {
if (confirmPending === actionKey) {
clearConfirmPending();
return true;
}
if (confirmTimeoutRef.current) clearTimeout(confirmTimeoutRef.current);
setConfirmPending(actionKey);
confirmTimeoutRef.current = setTimeout(() => {
setConfirmPending(null);
confirmTimeoutRef.current = null;
}, CONFIRM_ACTION_TIMEOUT_MS);
return false;
}, [confirmPending, clearConfirmPending]);
const graphPipeline = currentRun?.pipelineSnapshot || projectPipeline;
const graphNodeStates = currentRun?.nodeStates || [];
const graphCurrentNodeId = currentRun?.currentNodeId || null;
const insertionPreview = useMemo(() => {
const ins = activePipelineNode?.config?.insertion;
if (!ins) return null;
return {
key: ins.key || '(未设置关键词)',
comment: ins.comment || '',
position: ins.position,
activationType: ins.activationType,
keysecondary: ins.keysecondary || '',
};
}, [activePipelineNode]);
const promptBlocks = currentRun?.lastPromptBlocks || [];
const runWorkflowVariables = currentRun?.workflowVariables || {};
const stepMessages = activeNodeState?.stepMessages || [];
const lastToolResponse = activeNodeState?.lastToolResponse;
const characters = useCharacterStore((s) => s.characters);
const boundCharacterName = useMemo(
() =>
resolveBoundCharacterName({
boundCharacterVar: runWorkflowVariables['workflow.boundCharacter'],
characterId: meta?.characterId,
characters,
}),
[runWorkflowVariables, meta?.characterId, characters]
);
const handleOpenInsertion = () => {
setInsertionPopupExpanded(true);
setInsertionPopupOpen(true);
};
const handleInitBindSubmit = async (displayParams) => {
await advanceRun(displayParams);
};
const handleSelectRun = async (runId) => {
setRunSessionEntered(false);
await selectRun(runId);
};
const handleEnterSession = () => {
if (currentRun) {
setRunSessionEntered(true);
}
};
const handleChatSend = async (text) => {
if (!text || isInitBindPending || !isWorldbookActive || runMessaging) return;
setChatInput('');
const run = await sendRunMessage(text, { stream: streamOutput });
if (!run) {
/* error surfaced via runError */
}
};
const handleUndo = async () => {
if (!hasRunControl(activeNodeState?.skillId, 'undo', skillTemplates)) return;
if (runMessaging || !canUndoRun(activeNodeState)) return;
clearConfirmPending();
await undoRun();
};
const handleReroll = async () => {
if (!hasRunControl(activeNodeState?.skillId, 'reroll', skillTemplates)) return;
if (runMessaging || !canRerollRun(activeNodeState)) return;
clearConfirmPending();
await rerollRun({ stream: streamOutput });
};
const handleUndoClick = () => {
if (runAdvancing || runMessaging || !canUndo) return;
requestConfirmAction('undo');
};
const handleRerollClick = () => {
if (runAdvancing || runMessaging || !canReroll) return;
requestConfirmAction('reroll');
};
const handleNextStep = async () => {
if (runAdvancing || runMessaging || !canAdvanceNextStep(activeNodeState)) {
return;
}
clearConfirmPending();
const run = await advanceRun({});
if (run) {
setChatInput('');
setToolOptionIndex(0);
}
};
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 (!currentRun || nodeId === currentRun.currentNodeId) return;
if (runLoading || runAdvancing || runMessaging) return;
const nodeState = currentRun.nodeStates?.find((n) => n.nodeId === nodeId);
if (!canSwitchToNode(nodeState)) return;
clearConfirmPending();
const run = await switchRunNode(nodeId);
if (run) {
setChatInput('');
setToolOptionIndex(0);
}
},
[
currentRun,
runLoading,
runAdvancing,
runMessaging,
switchRunNode,
clearConfirmPending,
]
);
const handleDeleteRun = async (runId) => {
const name = runDisplayName(runs.find((r) => r.id === runId) || {});
if (!window.confirm(`确定删除运行「${name}」?此操作不可撤销。`)) return;
if (currentRunId === runId) setRunSessionEntered(false);
await deleteRun(runId);
};
const handleStartRename = (run) => {
setRenamingId(run.id);
setRenameValue(run.title?.trim() || runDisplayName(run));
};
const handleRenameSubmit = async (runId) => {
const title = renameValue.trim();
if (!title) return;
const ok = await renameRun(runId, title);
if (ok) {
setRenamingId(null);
setRenameValue('');
}
};
const runListNeedsCollapse = runs.length > RUN_LIST_COLLAPSE_THRESHOLD;
const inSession = runSessionEntered && !!currentRun;
const showToolCarousel =
inSession && runControls.includes('questions') && toolQuestions.length > 0;
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);
const canReroll = canRerollRun(activeNodeState);
const canNextStep = canAdvanceNextStep(activeNodeState);
const showTurnActions = inSession && (showUndo || showReroll);
const canInterrupt = runControls.includes('interrupt');
const renderRunList = () => (
<>
{runLoading && runs.length === 0 ? (
加载中…
) : runs.length === 0 ? (
暂无会话记录,点击「新开会话」开始
) : (
<>
{runs.map((run) => (
{
setRenamingId(null);
setRenameValue('');
}}
onStartRename={handleStartRename}
/>
))}
{runListNeedsCollapse && (
)}
>
)}
>
);
const renderNodeProgress = () => {
if (!graphPipeline) {
return 暂无流水线数据
;
}
return (
);
};
const renderLeftBrowseView = () => (
节点进度
基于各步骤上文引用(inputs.ref)生成的依赖顺序。
{renderNodeProgress()}
);
const renderLeftSessionView = () => (
节点进度
点击已完成、进行中或已有草稿的步骤可切换当前焦点。
{renderNodeProgress()}
);
const renderRunPreviewCenter = () => {
if (!runProjectId) {
return 请先选择项目
;
}
if (!currentRun) {
return (
在左侧选择或新建会话,此处将预览运行内容与节点状态。
);
}
return (
{runDisplayName(currentRun)}
{RUN_STATUS_LABEL[currentRun.status] || currentRun.status}
创建 {formatRunTime(currentRun.createdAt)}
{boundCharacterName ? (
角色卡:{boundCharacterName}
) : null}
{promptBlocks.length > 0 && (
)}
);
};
const renderSessionCenter = () => {
if (isInitBindPending && activePipelineNode) {
return (
);
}
if (
activeNodeState?.skillId === 'studio.init_bind' &&
isInitBindCompleted(activeNodeState)
) {
return (
创建并绑定步骤已完成。
绑定信息见左侧「目前产物」。
);
}
return (
);
};
const renderRightSidebar = () => (
<>
{showToolCarousel && (
setToolOptionIndex((i) => (i - 1 + toolQuestions.length) % toolQuestions.length)
}
onNext={() =>
setToolOptionIndex((i) => (i + 1) % toolQuestions.length)
}
runAdvancing={runAdvancing || runMessaging}
/>
)}
{showTurnActions && (
回合控制
{showUndo ? (
) : null}
{showReroll ? (
) : null}
)}
{showNextStep && (
)}
{(showSaveIncremental || showSaveOverwrite) && (
世界书保存
{showSaveIncremental ? (
) : null}
{showSaveOverwrite ? (
) : null}
增量保存会新建条目;覆盖保存会更新本步骤上次写入的条目。切换步骤请使用左侧节点图。
)}
>
);
return (
{runError && (
{runError}
)}
{inSession ? renderSessionCenter() : renderRunPreviewCenter()}
setContextBlockPopup(null)}
/>
setInsertionPopupOpen(false)}
>
{({ expanded }) =>
expanded ? (
{JSON.stringify(insertionPreview, null, 2)}
) : (
关键词
{insertionPreview?.key}
{insertionPreview?.comment ? (
<>
备注
{insertionPreview.comment}
>
) : null}
位置 / 激活
{insertionPreview?.position} · {insertionPreview?.activationType}
)
}
);
}
export default StudioRunPage;