1248 lines
40 KiB
JavaScript
1248 lines
40 KiB
JavaScript
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;
|