Files
SillyTavern_replica/frontend/src/components/Studio/StudioRunPage.jsx

1248 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<button
type="button"
className={`${className}${isPending ? ' is-confirm-pending' : ''}`}
onClick={() => {
if (isPending) {
onConfirm();
} else {
onRequestConfirm(actionKey);
}
}}
disabled={disabled && !isPending}
title={isPending ? '再次点击确认操作' : title}
>
{isPending ? '确认' : label}
</button>
);
}
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 <div className="studio-run-empty">当前步骤未定义引导字段</div>;
}
return (
<form
className={`studio-run-guidance-form${compact ? ' studio-run-guidance-form--compact' : ''}`}
onSubmit={handleSubmit}
>
<p className="studio-run-hint">
填写以下信息后将创建角色卡与世界书并绑定到当前 Studio 项目
</p>
{displayParams.map((dp) => (
<label key={dp.key} className="studio-run-form-field">
<span className="studio-run-form-label">
{dp.label}
{dp.required ? <span className="studio-run-required">*</span> : null}
</span>
<input
type="text"
className={`studio-run-form-input${fieldErrors[dp.key] ? ' has-error' : ''}`}
value={formValues[dp.key] || ''}
onChange={(ev) => handleChange(dp.key, ev.target.value)}
placeholder={dp.placeholder || ''}
disabled={runAdvancing}
/>
{fieldErrors[dp.key] ? (
<span className="studio-run-field-error">{fieldErrors[dp.key]}</span>
) : null}
</label>
))}
<button type="submit" className="studio-run-submit-btn" disabled={runAdvancing}>
{runAdvancing ? '创建中…' : '创建并绑定'}
</button>
</form>
);
}
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 (
<div className="studio-run-tool-carousel">
<div className="studio-run-tool-carousel__head">
<span className="studio-run-tool-carousel__label">模型提问</span>
<span className="studio-run-tool-carousel__pager">
{index + 1} / {options.length}
</span>
</div>
<p className="studio-run-tool-carousel__question">{current.question}</p>
<div className="studio-run-tool-carousel__nav">
<button
type="button"
className="studio-run-icon-btn"
onClick={onPrev}
disabled={options.length <= 1}
aria-label="上一题"
>
</button>
<div className="studio-run-tool-carousel__options">
{(current.options || []).map((opt) => (
<button
key={opt}
type="button"
className={`studio-run-tool-option${selectedOption === opt ? ' is-selected' : ''}`}
onClick={() => handleSelect(opt)}
disabled={runAdvancing}
>
{opt}
</button>
))}
</div>
<button
type="button"
className="studio-run-icon-btn"
onClick={onNext}
disabled={options.length <= 1}
aria-label="下一题"
>
</button>
</div>
{selectedOption ? (
<div className="studio-run-tool-copy">
<div className="studio-run-tool-copy__head">
<span className="studio-run-tool-copy__label">复制以下内容到输入框</span>
<button
type="button"
className="studio-run-tool-copy__btn"
onClick={handleCopy}
>
复制
</button>
</div>
<textarea
className="studio-run-tool-copy__textarea"
readOnly
value={selectedOption}
rows={3}
aria-label="选项内容"
/>
{copyHint ? (
<span className="studio-run-tool-copy__hint">{copyHint}</span>
) : null}
</div>
) : null}
</div>
);
}
function PromptBlocksDebug({ blocks, onBlockClick }) {
const [open, setOpen] = useState(false);
if (!blocks?.length) return null;
return (
<div className="studio-run-prompt-debug">
<button
type="button"
className="studio-run-prompt-debug__toggle"
onClick={() => setOpen((v) => !v)}
>
{open ? '▼' : '▶'} 上下文块调试 · {blocks.length}
</button>
{open && (
<div className="studio-run-prompt-debug__list">
{blocks.map((block) => (
<button
key={block.id}
type="button"
className="studio-run-prompt-debug__item"
onClick={() => onBlockClick?.(block)}
>
<span className="studio-run-prompt-debug__label">{block.label}</span>
<span className="studio-run-prompt-debug__source">{block.source}</span>
</button>
))}
</div>
)}
</div>
);
}
function RunListItem({
run,
selected,
onSelect,
onDelete,
renamingId,
renameValue,
onRenameChange,
onRenameSubmit,
onRenameCancel,
onStartRename,
}) {
const isRenaming = renamingId === run.id;
return (
<li className="studio-run-list-row">
{isRenaming ? (
<form
className="studio-run-rename-form"
onSubmit={(e) => {
e.preventDefault();
onRenameSubmit(run.id);
}}
>
<input
className="studio-run-rename-input"
value={renameValue}
onChange={(e) => onRenameChange(e.target.value)}
autoFocus
maxLength={120}
/>
<div className="studio-run-rename-actions">
<button type="submit" className="studio-run-list-action" title="保存">
</button>
<button
type="button"
className="studio-run-list-action"
onClick={onRenameCancel}
title="取消"
>
</button>
</div>
</form>
) : (
<>
<button
type="button"
className={`studio-run-list-item${selected ? ' selected' : ''}`}
onClick={() => onSelect(run.id)}
>
<span className="studio-run-list-name">{runDisplayName(run)}</span>
<span className={`studio-run-status-badge status-${run.status}`}>
{RUN_STATUS_LABEL[run.status] || run.status}
</span>
</button>
<div className="studio-run-list-item-actions">
<button
type="button"
className="studio-run-list-action"
title="重命名"
onClick={(e) => {
e.stopPropagation();
onStartRename(run);
}}
>
</button>
<button
type="button"
className="studio-run-list-action studio-run-list-action--danger"
title="删除"
onClick={(e) => {
e.stopPropagation();
onDelete(run.id);
}}
>
🗑
</button>
</div>
</>
)}
</li>
);
}
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 (
<div className={`studio-run-product-main${compact ? ' studio-run-product-main--compact' : ''}`}>
<div className="studio-run-product-main__head">
<h2 className="studio-run-section-title">目前产物</h2>
{activeNodeState?.displayName && (
<span className="studio-run-product-main__step">
{activeNodeState.displayName}
</span>
)}
</div>
<pre className="studio-run-product-main__body">{productPreview}</pre>
</div>
);
}
function WorkflowVariablesPanel({ variables, labelMap }) {
const entries = Object.entries(variables || {});
if (entries.length === 0) return null;
return (
<section className="studio-run-preview-section">
<h2 className="studio-run-section-title">工作流变量</h2>
<dl className="studio-run-variables">
{entries.map(([key, value]) => (
<div key={key} className="studio-run-variables__row">
<dt title={key}>{getWorkflowVariableLabel(key, labelMap)}</dt>
<dd>{typeof value === 'string' ? value : JSON.stringify(value)}</dd>
</div>
))}
</dl>
</section>
);
}
function CurrentNodePanel({ activeNodeState, graphCurrentNodeId, graphPipeline }) {
const nodeName =
activeNodeState?.displayName ||
graphPipeline?.nodes?.find((n) => n.id === graphCurrentNodeId)?.displayName ||
'—';
const nodeStatus = activeNodeState?.status;
return (
<section className="studio-run-preview-section">
<h2 className="studio-run-section-title">当前节点</h2>
<dl className="studio-run-overview studio-run-overview--inline">
<div>
<dt>步骤</dt>
<dd>{nodeName}</dd>
</div>
{nodeStatus && (
<div>
<dt>状态</dt>
<dd>{RUN_STATUS_LABEL[nodeStatus] || nodeStatus}</dd>
</div>
)}
{activeNodeState?.skillId && (
<div>
<dt>技能</dt>
<dd>{activeNodeState.skillId}</dd>
</div>
)}
</dl>
</section>
);
}
function InsertionSidebar({ insertionPreview, onOpenEntry }) {
if (!insertionPreview) {
return (
<section className="studio-run-right-section">
<h2 className="studio-run-section-title">插入内容</h2>
<p className="studio-run-right-placeholder">当前步骤无世界书插入配置</p>
</section>
);
}
return (
<section className="studio-run-right-section">
<h2 className="studio-run-section-title">插入内容</h2>
<button
type="button"
className="studio-run-insertion-entry"
onClick={() => onOpenEntry(insertionPreview)}
title={insertionPreview.comment || insertionPreview.key}
>
<span className="studio-run-insertion-entry__key">{insertionPreview.key}</span>
{insertionPreview.comment ? (
<span className="studio-run-insertion-entry__meta">{insertionPreview.comment}</span>
) : null}
</button>
</section>
);
}
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 ? (
<div className="studio-run-empty">加载中</div>
) : runs.length === 0 ? (
<div className="studio-run-empty">暂无会话记录点击新开会话开始</div>
) : (
<>
<ul
className={`studio-run-list${runListNeedsCollapse && !runListExpanded ? ' is-collapsed' : ''}`}
style={
runListNeedsCollapse && !runListExpanded
? { maxHeight: `${RUN_ITEM_HEIGHT * 3.5}px` }
: undefined
}
>
{runs.map((run) => (
<RunListItem
key={run.id}
run={run}
selected={currentRunId === run.id}
onSelect={handleSelectRun}
onDelete={handleDeleteRun}
renamingId={renamingId}
renameValue={renameValue}
onRenameChange={setRenameValue}
onRenameSubmit={handleRenameSubmit}
onRenameCancel={() => {
setRenamingId(null);
setRenameValue('');
}}
onStartRename={handleStartRename}
/>
))}
</ul>
{runListNeedsCollapse && (
<button
type="button"
className="studio-run-list-fold"
onClick={() => setRunListExpanded((v) => !v)}
>
{runListExpanded ? '收起列表' : `展开全部(共 ${runs.length} 条)`}
</button>
)}
</>
)}
</>
);
const renderNodeProgress = () => {
if (!graphPipeline) {
return <div className="studio-run-empty">暂无流水线数据</div>;
}
return (
<StudioRunNodeGraph
pipeline={graphPipeline}
nodeStates={graphNodeStates}
currentNodeId={graphCurrentNodeId}
variant="sidebar"
onNodeSelect={currentRun ? handleGraphNodeSelect : undefined}
canSelectNode={canSwitchToNode}
/>
);
};
const renderLeftBrowseView = () => (
<div className="studio-run-left-stack">
<section className="studio-run-left-stack__sessions">
<h2 className="studio-run-section-title">会话列表</h2>
{renderRunList()}
</section>
<section className="studio-run-left-stack__progress">
<h2 className="studio-run-section-title">节点进度</h2>
<p className="studio-run-hint studio-run-hint--inline">
基于各步骤上文引用inputs.ref生成的依赖顺序
</p>
{renderNodeProgress()}
</section>
</div>
);
const renderLeftSessionView = () => (
<div className="studio-run-left-session">
<ProductPreview
activeNodeState={activeNodeState}
isWorldbookActive={isWorldbookActive}
/>
<section className="studio-run-left-session__graph">
<h2 className="studio-run-section-title">节点进度</h2>
<p className="studio-run-hint studio-run-hint--inline">
点击已完成进行中或已有草稿的步骤可切换当前焦点
</p>
{renderNodeProgress()}
</section>
<PromptBlocksDebug
blocks={promptBlocks}
onBlockClick={setContextBlockPopup}
/>
</div>
);
const renderRunPreviewCenter = () => {
if (!runProjectId) {
return <div className="studio-run-chat-empty">请先选择项目</div>;
}
if (!currentRun) {
return (
<div className="studio-run-preview-empty">
<p className="studio-run-hint">在左侧选择或新建会话此处将预览运行内容与节点状态</p>
</div>
);
}
return (
<div className="studio-run-preview">
<header className="studio-run-preview__header">
<div className="studio-run-preview__title-row">
<h2 className="studio-run-preview__title">{runDisplayName(currentRun)}</h2>
<span className={`studio-run-status-badge status-${currentRun.status}`}>
{RUN_STATUS_LABEL[currentRun.status] || currentRun.status}
</span>
</div>
<div className="studio-run-preview__meta">
<span>创建 {formatRunTime(currentRun.createdAt)}</span>
{boundCharacterName ? (
<span className="studio-run-preview__character">
角色卡{boundCharacterName}
</span>
) : null}
</div>
<button
type="button"
className="studio-run-enter-btn studio-run-enter-btn--header"
onClick={handleEnterSession}
disabled={runLoading || runAdvancing}
>
进入会话
</button>
</header>
<div className="studio-run-preview__body">
<CurrentNodePanel
activeNodeState={activeNodeState}
graphCurrentNodeId={graphCurrentNodeId}
graphPipeline={graphPipeline}
/>
<ProductPreview
activeNodeState={activeNodeState}
isWorldbookActive={isWorldbookActive}
compact
/>
<WorkflowVariablesPanel
variables={runWorkflowVariables}
labelMap={variableLabelMap}
/>
{promptBlocks.length > 0 && (
<section className="studio-run-preview-section">
<PromptBlocksDebug
blocks={promptBlocks}
onBlockClick={setContextBlockPopup}
/>
</section>
)}
</div>
</div>
);
};
const renderSessionCenter = () => {
if (isInitBindPending && activePipelineNode) {
return (
<div className="studio-run-chat-card">
<InitBindGuidanceForm
node={{ ...activePipelineNode, nodeId: activePipelineNode.id }}
runAdvancing={runAdvancing}
onSubmit={handleInitBindSubmit}
compact
/>
</div>
);
}
if (
activeNodeState?.skillId === 'studio.init_bind' &&
isInitBindCompleted(activeNodeState)
) {
return (
<div className="studio-run-chat-empty">
<p>创建并绑定步骤已完成</p>
<p className="studio-run-hint">绑定信息见左侧目前产物</p>
</div>
);
}
return (
<div className="studio-run-chat-panel">
<StudioRunChat
stepMessages={stepMessages}
thinking={runStreamingThinking ?? lastToolResponse?.thinking}
evaluation={runStreamingThinking ? null : lastToolResponse?.evaluation}
inputValue={chatInput}
onInputChange={setChatInput}
onSend={handleChatSend}
onInterrupt={canInterrupt ? interruptRun : undefined}
canInterrupt={canInterrupt}
disabled={!isWorldbookActive || runAdvancing || isInitBindPending}
sending={runMessaging}
streamOutput={streamOutput}
onStreamOutputChange={setStreamOutput}
placeholder={
isWorldbookActive
? '输入修改意见或与模型对话…'
: currentRun?.status === 'completed'
? '运行已完成'
: '等待当前步骤…'
}
/>
</div>
);
};
const renderRightSidebar = () => (
<>
<InsertionSidebar
insertionPreview={insertionPreview}
onOpenEntry={handleOpenInsertion}
/>
{showToolCarousel && (
<ToolOptionsCarousel
options={toolQuestions}
index={toolOptionIndex}
onPrev={() =>
setToolOptionIndex((i) => (i - 1 + toolQuestions.length) % toolQuestions.length)
}
onNext={() =>
setToolOptionIndex((i) => (i + 1) % toolQuestions.length)
}
runAdvancing={runAdvancing || runMessaging}
/>
)}
{showTurnActions && (
<div className="studio-run-right-section studio-run-turn-actions">
<h2 className="studio-run-section-title">回合控制</h2>
<div className="studio-run-turn-actions__row">
{showUndo ? (
<ConfirmTurnButton
actionKey="undo"
pendingKey={confirmPending}
label="回退"
className="studio-run-turn-btn studio-run-turn-btn--undo"
disabled={runAdvancing || runMessaging || !canUndo}
title={canUndo ? '回退到上一回合' : '无可回退的回合'}
onConfirm={handleUndo}
onRequestConfirm={handleUndoClick}
/>
) : null}
{showReroll ? (
<ConfirmTurnButton
actionKey="reroll"
pendingKey={confirmPending}
label="重roll"
className="studio-run-turn-btn studio-run-turn-btn--reroll"
disabled={runAdvancing || runMessaging || !canReroll}
title={canReroll ? '用相同输入重新生成' : '尚无用户消息'}
onConfirm={handleReroll}
onRequestConfirm={handleRerollClick}
/>
) : null}
</div>
</div>
)}
{showNextStep && (
<div className="studio-run-right-section studio-run-right-actions">
<button
type="button"
className="studio-run-next-btn"
onClick={handleNextStep}
disabled={runAdvancing || runMessaging || !canNextStep}
title={canNextStep ? '确认当前产物并进入下一节点' : '请先生成当前步骤产物'}
>
下一步
</button>
<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>
)}
</>
);
return (
<div className={`studio-run-page${inSession ? ' studio-run-page--session' : ''}`}>
{runError && (
<div className="studio-run-banner error" role="alert">{runError}</div>
)}
<div
className={`studio-run-body studio-run-sidebar-${sidebarMode}${
sidebarMode === 'smart' && isSidebarHovered ? ' studio-run-sidebar-expanded' : ''
}${inSession ? ' in-session' : ''}`}
>
<aside
className={`studio-run-left sidebar-mode-${sidebarMode}${
sidebarMode === 'smart' && isSidebarHovered ? ' sidebar-expanded' : ''
}`}
aria-label="左栏"
onMouseEnter={handleLeftMouseEnter}
onMouseLeave={handleLeftMouseLeave}
>
<div className="studio-run-left-content">
{inSession ? renderLeftSessionView() : renderLeftBrowseView()}
</div>
</aside>
<main className="studio-run-center" aria-label={inSession ? '对话' : '运行预览'}>
{inSession ? renderSessionCenter() : renderRunPreviewCenter()}
</main>
<aside className="studio-run-right" aria-label="控制与插入">
{currentRun ? renderRightSidebar() : (
<p className="studio-run-right-placeholder">选择会话后显示插入内容与控制项</p>
)}
</aside>
</div>
<StudioContextBlockPopup
open={!!contextBlockPopup}
block={contextBlockPopup}
onClose={() => setContextBlockPopup(null)}
/>
<StudioInsertionPopup
open={insertionPopupOpen && !!insertionPreview}
title="插入内容"
defaultExpanded={insertionPopupExpanded}
onClose={() => setInsertionPopupOpen(false)}
>
{({ expanded }) =>
expanded ? (
<pre className="studio-insertion-popup__full">
{JSON.stringify(insertionPreview, null, 2)}
</pre>
) : (
<div className="studio-insertion-popup__preview">
<span className="studio-insertion-popup__field-label">关键词</span>
<span className="studio-insertion-popup__field-value">
{insertionPreview?.key}
</span>
{insertionPreview?.comment ? (
<>
<span className="studio-insertion-popup__field-label">备注</span>
<span className="studio-insertion-popup__field-value">
{insertionPreview.comment}
</span>
</>
) : null}
<span className="studio-insertion-popup__field-label">位置 / 激活</span>
<span className="studio-insertion-popup__field-value">
{insertionPreview?.position} · {insertionPreview?.activationType}
</span>
</div>
)
}
</StudioInsertionPopup>
</div>
);
}
export default StudioRunPage;