- 后端:项目/运行 API、上下文服务与数据模型 - 前端:Studio 列表、编辑页(R1/R2 布局)、运行页与节点图 - 编辑页顶部:CSS Grid 统一标签行与控件行对齐,项目按钮独立第三行 - Docker 开发配置与文档脚本 Co-authored-by: Cursor <cursoragent@cursor.com>
1013 lines
31 KiB
JavaScript
1013 lines
31 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 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;
|
||
|
||
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, onSelect, runAdvancing }) {
|
||
const current = options[index];
|
||
if (!options.length || !current) return null;
|
||
|
||
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"
|
||
onClick={() => onSelect(opt)}
|
||
disabled={runAdvancing}
|
||
>
|
||
{opt}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="studio-run-icon-btn"
|
||
onClick={onNext}
|
||
disabled={options.length <= 1}
|
||
aria-label="下一题"
|
||
>
|
||
›
|
||
</button>
|
||
</div>
|
||
</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 {
|
||
projects,
|
||
pipeline: projectPipeline,
|
||
workflowVariables: workflowVariablesCatalog,
|
||
runProjectId,
|
||
runs,
|
||
currentRunId,
|
||
currentRun,
|
||
runLoading,
|
||
runCreating,
|
||
runAdvancing,
|
||
runError,
|
||
initStudioRun,
|
||
fetchProject,
|
||
fetchWorkflowVariables,
|
||
setRunProjectId,
|
||
fetchRuns,
|
||
createRun,
|
||
selectRun,
|
||
advanceRun,
|
||
deleteRun,
|
||
renameRun,
|
||
} = 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 [sessionEntered, setSessionEntered] = useState(false);
|
||
const [insertionPopupOpen, setInsertionPopupOpen] = useState(false);
|
||
const [insertionPopupExpanded, setInsertionPopupExpanded] = useState(true);
|
||
const [contextBlockPopup, setContextBlockPopup] = useState(null);
|
||
const [chatInput, setChatInput] = useState('');
|
||
const [toolOptionIndex, setToolOptionIndex] = useState(0);
|
||
const [chatMessages, setChatMessages] = useState([]);
|
||
const [runListExpanded, setRunListExpanded] = useState(false);
|
||
const [renamingId, setRenamingId] = useState(null);
|
||
const [renameValue, setRenameValue] = useState('');
|
||
|
||
useEffect(() => {
|
||
initStudioRun();
|
||
}, [initStudioRun]);
|
||
|
||
useEffect(() => {
|
||
if (runProjectId) {
|
||
fetchProject(runProjectId);
|
||
}
|
||
}, [runProjectId, fetchProject]);
|
||
|
||
useEffect(() => {
|
||
setSessionEntered(false);
|
||
setInsertionPopupOpen(false);
|
||
setContextBlockPopup(null);
|
||
}, [currentRunId, runProjectId]);
|
||
|
||
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 isInitBindActive =
|
||
activeNodeState?.status === 'active' &&
|
||
activeNodeState?.skillId === 'studio.init_bind';
|
||
|
||
const isWorldbookActive =
|
||
activeNodeState?.status === 'active' &&
|
||
activeNodeState?.skillId === 'studio.worldbook_entry';
|
||
|
||
const toolQuestions = useMemo(
|
||
() => activeNodeState?.lastToolResponse?.questions || [],
|
||
[activeNodeState]
|
||
);
|
||
|
||
useEffect(() => {
|
||
setToolOptionIndex(0);
|
||
}, [currentRunId, activeNodeState?.nodeId, toolQuestions.length]);
|
||
|
||
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 || {};
|
||
|
||
useEffect(() => {
|
||
if (!currentRun || !sessionEntered) {
|
||
setChatMessages([]);
|
||
return;
|
||
}
|
||
|
||
const msgs = [
|
||
{
|
||
id: 'sys-welcome',
|
||
role: 'system',
|
||
text: `已开始运行 · 项目 ${currentRun.projectId}`,
|
||
},
|
||
];
|
||
|
||
if (isInitBindActive) {
|
||
msgs.push({
|
||
id: 'sys-init',
|
||
role: 'assistant',
|
||
text: '请先创建角色卡与世界书,完成后将进入创作步骤。',
|
||
});
|
||
} else if (isWorldbookActive) {
|
||
msgs.push({
|
||
id: 'sys-step',
|
||
role: 'assistant',
|
||
text: `当前步骤:${activeNodeState.displayName}。请在下方输入修改意见或与模型对话(R3 接入流式输出)。`,
|
||
});
|
||
} else if (currentRun.status === 'completed') {
|
||
msgs.push({
|
||
id: 'sys-done',
|
||
role: 'assistant',
|
||
text: '本次运行已全部完成。',
|
||
});
|
||
}
|
||
|
||
setChatMessages(msgs);
|
||
}, [currentRun, currentRunId, isInitBindActive, isWorldbookActive, activeNodeState, sessionEntered]);
|
||
|
||
const handleProjectChange = async (e) => {
|
||
const projectId = e.target.value;
|
||
setRunProjectId(projectId);
|
||
setSessionEntered(false);
|
||
await fetchWorkflowVariables(projectId);
|
||
const list = await fetchRuns(projectId);
|
||
if (list[0]?.id) {
|
||
await selectRun(list[0].id);
|
||
}
|
||
};
|
||
|
||
const handleNewRun = async () => {
|
||
if (!runProjectId) return;
|
||
setSessionEntered(false);
|
||
await createRun(runProjectId);
|
||
};
|
||
|
||
const handleSelectRun = async (runId) => {
|
||
setSessionEntered(false);
|
||
await selectRun(runId);
|
||
};
|
||
|
||
const handleEnterSession = () => {
|
||
if (currentRun) {
|
||
setSessionEntered(true);
|
||
}
|
||
};
|
||
|
||
const handleOpenInsertion = () => {
|
||
setInsertionPopupExpanded(true);
|
||
setInsertionPopupOpen(true);
|
||
};
|
||
|
||
const handleInitBindSubmit = async (displayParams) => {
|
||
await advanceRun(displayParams);
|
||
};
|
||
|
||
const handleChatSend = (text) => {
|
||
if (!text || isInitBindActive) return;
|
||
setChatMessages((prev) => [
|
||
...prev,
|
||
{ id: `user-${Date.now()}`, role: 'user', text },
|
||
{
|
||
id: `stub-${Date.now()}`,
|
||
role: 'assistant',
|
||
text: '(R3 占位:模型回复与流式输出尚未接入)',
|
||
},
|
||
]);
|
||
setChatInput('');
|
||
};
|
||
|
||
const handleNextStep = () => {
|
||
window.alert('(R3 占位:确认当前产物后将进入下一步)');
|
||
};
|
||
|
||
const handleDeleteRun = async (runId) => {
|
||
const name = runDisplayName(runs.find((r) => r.id === runId) || {});
|
||
if (!window.confirm(`确定删除运行「${name}」?此操作不可撤销。`)) return;
|
||
if (currentRunId === runId) setSessionEntered(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 handleToolSelect = (option) => {
|
||
setChatMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: `tool-opt-${Date.now()}`,
|
||
role: 'user',
|
||
text: `[选项] ${option}`,
|
||
},
|
||
]);
|
||
};
|
||
|
||
const runListNeedsCollapse = runs.length > RUN_LIST_COLLAPSE_THRESHOLD;
|
||
const inSession = sessionEntered && !!currentRun;
|
||
const showToolCarousel = inSession && isWorldbookActive && toolQuestions.length > 0;
|
||
const showNextStep = inSession && isWorldbookActive;
|
||
|
||
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"
|
||
/>
|
||
);
|
||
};
|
||
|
||
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}
|
||
/>
|
||
<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>
|
||
</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 (isInitBindActive && activePipelineNode) {
|
||
return (
|
||
<div className="studio-run-chat-card">
|
||
<InitBindGuidanceForm
|
||
node={{ ...activePipelineNode, nodeId: activePipelineNode.id }}
|
||
runAdvancing={runAdvancing}
|
||
onSubmit={handleInitBindSubmit}
|
||
compact
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="studio-run-chat-panel">
|
||
<StudioRunChat
|
||
messages={chatMessages}
|
||
inputValue={chatInput}
|
||
onInputChange={setChatInput}
|
||
onSend={handleChatSend}
|
||
disabled={!isWorldbookActive || runAdvancing || isInitBindActive}
|
||
sending={runAdvancing}
|
||
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)
|
||
}
|
||
onSelect={handleToolSelect}
|
||
runAdvancing={runAdvancing}
|
||
/>
|
||
)}
|
||
|
||
{showNextStep && (
|
||
<div className="studio-run-right-section studio-run-right-actions">
|
||
<button
|
||
type="button"
|
||
className="studio-run-next-btn"
|
||
onClick={handleNextStep}
|
||
disabled={runAdvancing}
|
||
>
|
||
下一步
|
||
</button>
|
||
<p className="studio-run-stub-note">确认左侧目前产物无误后进入下一节点</p>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<div className={`studio-run-page${inSession ? ' studio-run-page--session' : ''}`}>
|
||
<header className="studio-run-header">
|
||
<span className="studio-run-header-icon" aria-hidden="true">🚀</span>
|
||
<h1 className="studio-run-header-title">创作运行</h1>
|
||
<div className="studio-run-toolbar">
|
||
<label className="studio-run-label">
|
||
项目
|
||
<select
|
||
className="studio-run-select"
|
||
value={runProjectId || ''}
|
||
onChange={handleProjectChange}
|
||
disabled={runLoading || runCreating || runAdvancing}
|
||
>
|
||
{projects.length === 0 ? (
|
||
<option value="">暂无项目</option>
|
||
) : (
|
||
projects.map((p) => (
|
||
<option key={p.id} value={p.id}>{p.name}</option>
|
||
))
|
||
)}
|
||
</select>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
className="studio-run-new-btn"
|
||
onClick={handleNewRun}
|
||
disabled={!runProjectId || runCreating || runAdvancing}
|
||
>
|
||
{runCreating ? '创建中…' : '新开会话'}
|
||
</button>
|
||
{inSession && (
|
||
<button
|
||
type="button"
|
||
className="studio-run-exit-session-btn"
|
||
onClick={() => setSessionEntered(false)}
|
||
>
|
||
退出会话
|
||
</button>
|
||
)}
|
||
</div>
|
||
</header>
|
||
|
||
{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;
|