Files
SillyTavern_replica/frontend/src/components/Studio/StudioRunPage.jsx
moranzhi fa6907fb8d feat(studio): 新增 Studio 工作流编辑/运行页,优化顶部三栏对齐
- 后端:项目/运行 API、上下文服务与数据模型
- 前端:Studio 列表、编辑页(R1/R2 布局)、运行页与节点图
- 编辑页顶部:CSS Grid 统一标签行与控件行对齐,项目按钮独立第三行
- Docker 开发配置与文档脚本

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:24:57 +08:00

1013 lines
31 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 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;