feat(studio): 新增 Studio 工作流编辑/运行页,优化顶部三栏对齐

- 后端:项目/运行 API、上下文服务与数据模型
- 前端:Studio 列表、编辑页(R1/R2 布局)、运行页与节点图
- 编辑页顶部:CSS Grid 统一标签行与控件行对齐,项目按钮独立第三行
- Docker 开发配置与文档脚本

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 21:24:57 +08:00
parent bc130d98f4
commit fa6907fb8d
48 changed files with 8795 additions and 20 deletions

View File

@@ -0,0 +1,18 @@
.studio-context-block-popup__content {
margin: 0;
padding: var(--spacing-sm);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-light);
background: var(--color-bg-tertiary);
white-space: pre-wrap;
word-break: break-word;
font-size: 0.72rem;
line-height: 1.45;
max-height: none;
}
.studio-context-block-popup__preview {
display: flex;
flex-direction: column;
gap: 4px;
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
import StudioInsertionPopup from './StudioInsertionPopup';
import './StudioContextBlockPopup.css';
function StudioContextBlockPopup({ open, block, onClose }) {
if (!block) return null;
return (
<StudioInsertionPopup
open={open}
title={block.label}
defaultExpanded
onClose={onClose}
>
{({ expanded }) =>
expanded ? (
<pre className="studio-context-block-popup__content">
{block.content}
</pre>
) : (
<div className="studio-context-block-popup__preview">
<span className="studio-insertion-popup__field-label">来源</span>
<span className="studio-insertion-popup__field-value">{block.source}</span>
</div>
)
}
</StudioInsertionPopup>
);
}
export default StudioContextBlockPopup;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,795 @@
import React, { useEffect, useMemo, useState } from 'react';
import useStudioStore from '../../Store/Studio/StudioSlice';
import FieldLabel from './edit/FieldLabel';
import VariableChips from './edit/VariableChips';
import {
DEFAULT_THINKING_PROMPT,
orderNodesByIds,
sortNodesByLogic,
} from './edit/variableUtils';
import WorldbookInsertion from './edit/WorldbookInsertion';
import ScoringDimensions from './edit/ScoringDimensions';
import './StudioEditPage.css';
/*
* Node detail layout: primary config center + secondary fixed edge rail.
* Industry refs: n8n (Parameters center + Settings sidebar), Retool inspector,
* Figma right properties rail — main editing in fluid center, refs in ~300px rail.
*/
const DEFAULT_TEMPLATE_ID = 'builtin.studio.example';
const WORKFLOW_GOAL_TIP =
'此内容会在运行时被注入到 LLM 系统提示中,作为工作流整体目标,供各步骤参考。';
const WORKFLOW_DESC_TIP =
'仅供人类阅读的简短说明,不会写入 LLM 提示。';
const NODE_SORT_STORAGE_KEY = 'studio-node-sort-mode';
function getStoredSortMode(projectId) {
if (!projectId || typeof sessionStorage === 'undefined') return 'manual';
return sessionStorage.getItem(`${NODE_SORT_STORAGE_KEY}:${projectId}`) || 'manual';
}
function storeSortMode(projectId, mode) {
if (!projectId || typeof sessionStorage === 'undefined') return;
sessionStorage.setItem(`${NODE_SORT_STORAGE_KEY}:${projectId}`, mode);
}
function getSkillTemplate(skillTemplates, skillId) {
return skillTemplates.find((t) => t.skillId === skillId);
}
function StudioEditPage() {
const {
projects,
workflowTemplates,
currentProjectId,
meta,
pipeline,
skillTemplates,
niches,
selectedNodeId,
loading,
saving,
error,
saveMessage,
setPipelineLocal,
setMetaLocal,
setSelectedNodeId,
updateNode,
updateNodeConfig,
addNode,
removeNode,
fetchProject,
savePipeline,
createProject,
deleteProject,
renameProject,
updateProjectMeta,
clearSaveMessage,
} = useStudioStore();
const [dragIndex, setDragIndex] = useState(null);
const [showAddNode, setShowAddNode] = useState(false);
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
const [showRenameProjectModal, setShowRenameProjectModal] = useState(false);
const [newNodeSkillId, setNewNodeSkillId] = useState('studio.worldbook_entry');
const [newNodeName, setNewNodeName] = useState('');
const [newProjectName, setNewProjectName] = useState('');
const [newProjectTemplateId, setNewProjectTemplateId] = useState(DEFAULT_TEMPLATE_ID);
const [renameProjectName, setRenameProjectName] = useState('');
const [nodeSortMode, setNodeSortMode] = useState('manual');
const [manualOrder, setManualOrder] = useState([]);
useEffect(() => {
if (!currentProjectId) return;
setNodeSortMode(getStoredSortMode(currentProjectId));
}, [currentProjectId]);
useEffect(() => {
if (!pipeline?.nodes) {
setManualOrder([]);
return;
}
setManualOrder(pipeline.nodes.map((n) => n.id));
}, [currentProjectId, pipeline?.nodes?.length]);
const displayNodes = useMemo(() => {
const nodes = pipeline?.nodes || [];
if (nodeSortMode === 'logic') {
return sortNodesByLogic(pipeline);
}
return orderNodesByIds(nodes, manualOrder);
}, [pipeline, nodeSortMode, manualOrder]);
const handleSortModeChange = (mode) => {
setNodeSortMode(mode);
if (currentProjectId) storeSortMode(currentProjectId, mode);
if (mode === 'manual' && pipeline?.nodes) {
setManualOrder(pipeline.nodes.map((n) => n.id));
}
};
const selectedNode = pipeline?.nodes?.find((n) => n.id === selectedNodeId) ?? null;
const selectedTpl = selectedNode
? getSkillTemplate(skillTemplates, selectedNode.skillId)
: null;
const isWorldbook = selectedNode?.skillId === 'studio.worldbook_entry';
const insertion = selectedNode?.config?.insertion || {};
const scoring = selectedNode?.config?.scoring || { enabled: true, dimensions: [] };
const dimensions = scoring.dimensions || [];
const templateOptions =
workflowTemplates.length > 0
? workflowTemplates
: [{ id: DEFAULT_TEMPLATE_ID, name: '世界书条目创建' }];
const handleProjectChange = async (e) => {
const id = e.target.value;
if (id) await fetchProject(id);
};
const openNewProjectModal = () => {
setNewProjectName('');
setNewProjectTemplateId(templateOptions[0]?.id || DEFAULT_TEMPLATE_ID);
setShowNewProjectModal(true);
};
const openRenameProjectModal = () => {
setRenameProjectName(meta?.name || '');
setShowRenameProjectModal(true);
};
const handleCreateProject = async (e) => {
e.preventDefault();
const name = newProjectName.trim() || '新项目';
const result = await createProject(name, newProjectTemplateId);
if (result) {
setShowNewProjectModal(false);
setNewProjectName('');
}
};
const handleRenameProject = async (e) => {
e.preventDefault();
if (!currentProjectId) return;
const name = renameProjectName.trim();
if (!name) return;
const result = await renameProject(currentProjectId, name);
if (result) {
setShowRenameProjectModal(false);
}
};
const handleDeleteProject = async () => {
if (!currentProjectId || !meta) return;
const confirmed = window.confirm(
`确定删除项目「${meta.name}」?\n\n将同时删除该项目下的所有运行记录此操作不可撤销。`
);
if (!confirmed) return;
await deleteProject(currentProjectId);
};
const handleDescriptionBlur = () => {
if (!currentProjectId || !meta) return;
updateProjectMeta(currentProjectId, { description: meta.description ?? '' });
};
const handleDragStart = (index) => {
if (nodeSortMode !== 'manual') return;
setDragIndex(index);
};
const handleDragOver = (e, index) => {
if (nodeSortMode !== 'manual') return;
e.preventDefault();
if (dragIndex === null || dragIndex === index) return;
const ids = displayNodes.map((n) => n.id);
const nextIds = [...ids];
const [moved] = nextIds.splice(dragIndex, 1);
nextIds.splice(index, 0, moved);
const nodeMap = Object.fromEntries((pipeline.nodes || []).map((n) => [n.id, n]));
const reordered = nextIds.map((id) => nodeMap[id]).filter(Boolean);
setPipelineLocal({ ...pipeline, nodes: reordered });
setManualOrder(nextIds);
setDragIndex(index);
};
const handleDragEnd = () => setDragIndex(null);
const toggleInputRef = (nodeId, ref, label) => {
const node = pipeline.nodes.find((n) => n.id === nodeId);
if (!node) return;
const exists = (node.inputs || []).some((i) => i.ref === ref);
let inputs;
if (exists) {
inputs = (node.inputs || []).filter((i) => i.ref !== ref);
} else {
inputs = [...(node.inputs || []), { ref, label }];
}
updateNode(nodeId, { inputs });
};
const updateInsertion = (field, value) => {
if (!selectedNode) return;
const next = { ...(selectedNode.config?.insertion || {}), [field]: value };
updateNodeConfig(selectedNode.id, { insertion: next });
};
const updateRagConfig = (field, value) => {
if (!selectedNode) return;
const ragConfig = {
...(insertion.ragConfig || {}),
[field]: value,
};
updateInsertion('ragConfig', ragConfig);
};
const updateScoring = (field, value) => {
if (!selectedNode) return;
const next = { ...(selectedNode.config?.scoring || {}), [field]: value };
updateNodeConfig(selectedNode.id, { scoring: next });
};
const addDimension = () => {
const id = `dim-${Date.now()}`;
updateScoring('dimensions', [
...dimensions,
{ id, name: '', criteria: '' },
]);
};
const updateDimension = (index, field, value) => {
const next = dimensions.map((d, i) =>
i === index ? { ...d, [field]: value } : d
);
updateScoring('dimensions', next);
};
const removeDimension = (index) => {
updateScoring(
'dimensions',
dimensions.filter((_, i) => i !== index)
);
};
if (loading && !pipeline) {
return <div className="studio-edit-loading">加载中</div>;
}
return (
<div className="studio-edit-page">
<header className="studio-edit-top">
<div className="studio-edit-top__label studio-edit-top__label--project">
<span className="studio-edit-top__label-text">项目</span>
</div>
<div className="studio-edit-top__label studio-edit-top__label--goal">
<FieldLabel tip={WORKFLOW_GOAL_TIP}>工作流目标</FieldLabel>
</div>
<div className="studio-edit-top__label studio-edit-top__label--desc">
<FieldLabel tip={WORKFLOW_DESC_TIP}>工作流简介</FieldLabel>
</div>
<div className="studio-edit-top__control studio-edit-top__control--project">
<select
className="studio-edit-select studio-edit-select--project"
value={currentProjectId || ''}
onChange={handleProjectChange}
aria-label="选择项目"
>
{projects.length === 0 && <option value="">无项目</option>}
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div className="studio-edit-top__control studio-edit-top__control--goal">
<textarea
className="studio-edit-textarea studio-edit-textarea--goal"
rows={2}
value={pipeline?.workflowGoal ?? ''}
onChange={(e) =>
setPipelineLocal({ ...pipeline, workflowGoal: e.target.value })
}
placeholder="描述本 Studio 项目要完成的整体设计目标…"
aria-label="工作流目标"
/>
</div>
<div className="studio-edit-top__control studio-edit-top__control--desc">
<input
type="text"
className="studio-edit-input studio-edit-input--desc"
value={meta?.description ?? ''}
onChange={(e) => setMetaLocal({ description: e.target.value })}
onBlur={handleDescriptionBlur}
placeholder="简短备注"
maxLength={120}
disabled={!currentProjectId || saving}
aria-label="工作流简介"
/>
</div>
<div className="studio-edit-top__actions">
<button
type="button"
className="studio-edit-btn studio-edit-btn-danger studio-edit-btn-sm"
onClick={handleDeleteProject}
disabled={!currentProjectId || loading || saving}
title="删除当前项目及其运行记录"
>
删除项目
</button>
<button
type="button"
className="studio-edit-btn studio-edit-btn-sm"
onClick={openRenameProjectModal}
disabled={!currentProjectId || loading || saving}
>
改名
</button>
<button
type="button"
className="studio-edit-btn studio-edit-btn-sm"
onClick={openNewProjectModal}
disabled={loading}
>
新建项目
</button>
</div>
</header>
{(error || saveMessage) && (
<div
className={`studio-edit-banner ${error ? 'error' : 'success'}`}
role="status"
>
{error || saveMessage}
<button type="button" className="studio-edit-banner-close" onClick={clearSaveMessage}>
×
</button>
</div>
)}
<div className="studio-edit-main">
<aside className="studio-edit-nodes">
<div className="studio-edit-nodes-header">
<h2 className="studio-edit-subtitle">节点列表</h2>
<div className="studio-edit-nodes-header__actions">
<div className="studio-edit-sort-toggle" role="group" aria-label="节点排序">
<button
type="button"
className={`studio-edit-sort-btn${nodeSortMode === 'manual' ? ' active' : ''}`}
onClick={() => handleSortModeChange('manual')}
title="按拖拽/保存顺序排列"
>
手动
</button>
<button
type="button"
className={`studio-edit-sort-btn${nodeSortMode === 'logic' ? ' active' : ''}`}
onClick={() => handleSortModeChange('logic')}
title="按引用依赖拓扑排序"
>
逻辑
</button>
</div>
<button
type="button"
className="studio-edit-btn studio-edit-btn-sm"
onClick={() => setShowAddNode(!showAddNode)}
>
添加节点
</button>
</div>
</div>
{showAddNode && (
<div className="studio-edit-add-node">
<select
className="studio-edit-select"
value={newNodeSkillId}
onChange={(e) => setNewNodeSkillId(e.target.value)}
>
{skillTemplates.map((t) => (
<option key={t.skillId} value={t.skillId}>
{t.displayName}
</option>
))}
</select>
<input
type="text"
className="studio-edit-input"
placeholder="展示名"
value={newNodeName}
onChange={(e) => setNewNodeName(e.target.value)}
/>
<button
type="button"
className="studio-edit-btn studio-edit-btn-sm"
onClick={() => {
addNode(
newNodeSkillId,
newNodeName ||
getSkillTemplate(skillTemplates, newNodeSkillId)?.displayName
);
setNewNodeName('');
setShowAddNode(false);
}}
>
确认
</button>
</div>
)}
<ul className="studio-edit-node-list">
{displayNodes.map((node, index) => {
const tpl = getSkillTemplate(skillTemplates, node.skillId);
const isManualSort = nodeSortMode === 'manual';
return (
<li
key={node.id}
className={`studio-edit-node-item ${
selectedNodeId === node.id ? 'selected' : ''
} ${!node.enabled ? 'disabled' : ''}${!isManualSort ? ' no-drag' : ''}`}
draggable={isManualSort}
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
onClick={() => setSelectedNodeId(node.id)}
>
{isManualSort ? (
<span className="studio-edit-drag-handle" title="拖拽排序">
</span>
) : (
<span className="studio-edit-drag-handle studio-edit-drag-handle--muted" title="逻辑排序模式下不可拖拽">
</span>
)} <div className="studio-edit-node-text">
<span className="studio-edit-node-title">{node.displayName}</span>
<span className="studio-edit-node-skill">
{tpl?.displayName || node.skillId}
</span>
</div>
<label
className="studio-edit-switch"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={node.enabled}
onChange={(e) =>
updateNode(node.id, { enabled: e.target.checked })
}
/>
<span>启用</span>
</label>
</li>
);
})}
</ul>
</aside>
<section className="studio-edit-detail">
{!selectedNode ? (
<p className="studio-edit-empty">选择左侧节点进行编辑</p>
) : (
<div className="studio-edit-detail-inner">
<div className="studio-edit-meta-strip">
<label className="studio-edit-meta-strip__field">
<span className="studio-edit-field-label">展示名</span>
<input
type="text"
className="studio-edit-input"
value={selectedNode.displayName}
onChange={(e) =>
updateNode(selectedNode.id, { displayName: e.target.value })
}
/>
</label>
<label className="studio-edit-meta-strip__field">
<span className="studio-edit-field-label">模板类型</span>
<input
type="text"
className="studio-edit-input"
readOnly
value={selectedTpl?.displayName || selectedNode.skillId}
/>
</label>
{isWorldbook && niches.length > 0 && (
<label className="studio-edit-meta-strip__field">
<span className="studio-edit-field-label">
<FieldLabel tip="选择预设领域可自动填充步骤目标建议">
预设领域
</FieldLabel>
</span>
<select
className="studio-edit-select"
value={selectedNode.niche ?? ''}
onChange={(e) => {
const nicheId = e.target.value || null;
const niche = niches.find((n) => n.id === nicheId);
updateNode(selectedNode.id, { niche: nicheId });
if (niche?.suggestedStepGoal && !selectedNode.config?.stepGoal) {
updateNodeConfig(selectedNode.id, {
stepGoal: niche.suggestedStepGoal,
});
}
}}
>
<option value=""></option>
{niches.map((n) => (
<option key={n.id} value={n.id}>
{n.label}
</option>
))}
</select>
</label>
)}
{selectedTpl?.artifacts?.length > 0 && (
<span className="studio-edit-meta-strip__hint">
产物
{selectedTpl.artifacts.map((a) => a.displayName).join('、')}
</span>
)}
</div>
<div className="studio-edit-detail-body">
<div className="studio-edit-primary">
<div className="studio-edit-primary-card">
<span className="studio-edit-primary-card__badge">主要设置</span>
{isWorldbook ? (
<>
<label className="studio-edit-field studio-edit-field--primary">
<span className="studio-edit-field-label studio-edit-field-label--primary">
<FieldLabel tip="本步骤要产出的内容目标,会注入模型系统提示">
步骤目标
</FieldLabel>
</span>
<textarea
className="studio-edit-textarea studio-edit-textarea--primary"
rows={5}
value={selectedNode.config?.stepGoal ?? ''}
onChange={(e) =>
updateNodeConfig(selectedNode.id, {
stepGoal: e.target.value,
})
}
placeholder="描述本步骤要产出的内容…"
/>
</label>
<label className="studio-edit-field studio-edit-field--primary">
<span className="studio-edit-field-label studio-edit-field-label--primary">
<FieldLabel tip="引导模型在本步骤中的思考方式、关注点与推理路径">
思考提示词
</FieldLabel>
</span>
<textarea
className="studio-edit-textarea studio-edit-textarea--primary"
rows={5}
value={selectedNode.config?.thinkingPrompt ?? ''}
onChange={(e) =>
updateNodeConfig(selectedNode.id, {
thinkingPrompt: e.target.value,
})
}
placeholder="引导模型如何思考本步骤…"
/>
{!selectedNode.config?.thinkingPrompt && (
<button
type="button"
className="studio-edit-btn studio-edit-btn-sm studio-edit-think-default"
onClick={() =>
updateNodeConfig(selectedNode.id, {
thinkingPrompt: DEFAULT_THINKING_PROMPT,
})
}
>
填入默认思考流程模板
</button>
)}
</label>
<ScoringDimensions
scoring={scoring}
dimensions={dimensions}
onUpdateScoring={updateScoring}
onUpdateDimension={updateDimension}
onRemoveDimension={removeDimension}
onAddDimension={addDimension}
/>
</>
) : selectedNode.skillId === 'studio.init_bind' ? (
<>
<p className="studio-edit-hint">
运行时在引导区填写编辑页可预览字段定义
</p>
<ul className="studio-edit-dp-list studio-edit-dp-list--compact">
{(selectedNode.displayParams || []).map((dp) => (
<li key={dp.key}>
{dp.label} ({dp.key})
{dp.required ? ' · 必填' : ''}
</li>
))}
</ul>
</>
) : null}
</div>
</div>
<aside className="studio-edit-edge-rail">
{isWorldbook ? (
<>
<div className="studio-edit-edge-card">
<VariableChips
variant="compact"
pipeline={pipeline}
selectedNode={selectedNode}
onToggleRef={(ref, label) =>
toggleInputRef(selectedNode.id, ref, label)
}
/>
</div>
<div className="studio-edit-edge-card">
<WorldbookInsertion
insertion={insertion}
onUpdateInsertion={updateInsertion}
onUpdateRagConfig={updateRagConfig}
/>
</div>
</>
) : (
<div className="studio-edit-edge-card studio-edit-edge-card--empty">
<p className="studio-edit-edge-empty-tip">创建步骤无需引用变量</p>
</div>
)}
</aside>
</div>
<div className="studio-edit-detail-footer">
<button
type="button"
className="studio-edit-btn studio-edit-btn-danger"
onClick={() => {
if (window.confirm(`删除节点「${selectedNode.displayName}」?`)) {
removeNode(selectedNode.id);
}
}}
>
删除节点
</button>
<button
type="button"
className="studio-edit-btn studio-edit-btn-primary"
onClick={savePipeline}
disabled={saving || !currentProjectId}
>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
)}
</section>
</div>
{showNewProjectModal && (
<div
className="studio-modal-overlay"
role="presentation"
onClick={() => setShowNewProjectModal(false)}
>
<div
className="studio-modal"
role="dialog"
aria-labelledby="new-project-title"
onClick={(e) => e.stopPropagation()}
>
<h2 id="new-project-title" className="studio-modal-title">
新建项目
</h2>
<form onSubmit={handleCreateProject}>
<label className="studio-edit-label-block">
项目名称
<input
type="text"
className="studio-edit-input"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="例如:帝国骑士维尔"
autoFocus
required
/>
</label>
<label className="studio-edit-label-block">
选择工作流模板
<select
className="studio-edit-select"
value={newProjectTemplateId}
onChange={(e) => setNewProjectTemplateId(e.target.value)}
>
{templateOptions.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</label>
<div className="studio-modal-actions">
<button
type="button"
className="studio-edit-btn"
onClick={() => setShowNewProjectModal(false)}
>
取消
</button>
<button
type="submit"
className="studio-edit-btn studio-edit-btn-primary"
disabled={loading}
>
{loading ? '创建中…' : '创建'}
</button>
</div>
</form>
</div>
</div>
)}
{showRenameProjectModal && (
<div
className="studio-modal-overlay"
role="presentation"
onClick={() => setShowRenameProjectModal(false)}
>
<div
className="studio-modal"
role="dialog"
aria-labelledby="rename-project-title"
onClick={(e) => e.stopPropagation()}
>
<h2 id="rename-project-title" className="studio-modal-title">
改名
</h2>
<form onSubmit={handleRenameProject}>
<label className="studio-edit-label-block">
项目名称
<input
type="text"
className="studio-edit-input"
value={renameProjectName}
onChange={(e) => setRenameProjectName(e.target.value)}
autoFocus
required
maxLength={120}
/>
</label>
<div className="studio-modal-actions">
<button
type="button"
className="studio-edit-btn"
onClick={() => setShowRenameProjectModal(false)}
>
取消
</button>
<button
type="submit"
className="studio-edit-btn studio-edit-btn-primary"
disabled={saving}
>
{saving ? '保存中…' : '确定'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
export default StudioEditPage;

View File

@@ -0,0 +1,107 @@
.studio-insertion-popup {
position: fixed;
z-index: 1200;
width: 280px;
max-width: calc(100vw - 32px);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
pointer-events: auto;
}
.studio-insertion-popup.is-expanded {
width: min(420px, calc(100vw - 32px));
max-height: min(70vh, 520px);
display: flex;
flex-direction: column;
}
.studio-insertion-popup__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
border-bottom: 1px solid var(--color-border-light);
background: var(--color-bg-tertiary);
border-radius: var(--radius-md) var(--radius-md) 0 0;
cursor: grab;
user-select: none;
}
.studio-insertion-popup__header:active {
cursor: grabbing;
}
.studio-insertion-popup__title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-primary);
}
.studio-insertion-popup__actions {
display: flex;
align-items: center;
gap: 4px;
}
.studio-insertion-popup__btn {
padding: 2px 8px;
border: 1px solid var(--color-border-light);
border-radius: var(--radius-sm);
background: var(--color-bg-secondary);
font-size: 0.65rem;
color: var(--color-text-muted);
cursor: pointer;
}
.studio-insertion-popup__btn:hover {
border-color: var(--color-accent);
color: var(--color-text-primary);
}
.studio-insertion-popup__btn--close {
font-size: 1rem;
line-height: 1;
padding: 0 6px;
}
.studio-insertion-popup__body {
padding: var(--spacing-sm);
overflow: auto;
font-size: 0.75rem;
}
.studio-insertion-popup.is-expanded .studio-insertion-popup__body {
flex: 1;
min-height: 0;
}
.studio-insertion-popup__preview {
display: flex;
flex-direction: column;
gap: 4px;
}
.studio-insertion-popup__field-label {
font-size: 0.65rem;
color: var(--color-text-muted);
}
.studio-insertion-popup__field-value {
font-size: 0.8rem;
color: var(--color-text-primary);
line-height: 1.4;
}
.studio-insertion-popup__full {
margin: 0;
padding: var(--spacing-sm);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-light);
background: var(--color-bg-tertiary);
white-space: pre-wrap;
font-size: 0.72rem;
max-height: none;
}

View File

@@ -0,0 +1,95 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import './StudioInsertionPopup.css';
const DEFAULT_POS = { x: 72, y: 96 };
function StudioInsertionPopup({ open, title, onClose, defaultExpanded = false, children }) {
const popupRef = useRef(null);
const dragRef = useRef(null);
const [pos, setPos] = useState(DEFAULT_POS);
const [expanded, setExpanded] = useState(defaultExpanded);
useEffect(() => {
if (open) {
setExpanded(defaultExpanded);
setPos(DEFAULT_POS);
}
}, [open, defaultExpanded]);
const handleDragStart = useCallback((e) => {
if (e.button !== 0) return;
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const origin = { ...pos };
const onMove = (ev) => {
setPos({
x: origin.x + (ev.clientX - startX),
y: origin.y + (ev.clientY - startY),
});
};
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
dragRef.current = null;
};
dragRef.current = { startX, startY, origin };
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}, [pos]);
useEffect(() => {
return () => {
if (dragRef.current) {
window.removeEventListener('mousemove', () => {});
window.removeEventListener('mouseup', () => {});
}
};
}, []);
if (!open) return null;
return (
<div
ref={popupRef}
className={`studio-insertion-popup${expanded ? ' is-expanded' : ''}`}
style={{ left: pos.x, top: pos.y }}
role="dialog"
aria-label={title}
>
<div
className="studio-insertion-popup__header"
onMouseDown={handleDragStart}
>
<span className="studio-insertion-popup__title">{title}</span>
<div className="studio-insertion-popup__actions">
<button
type="button"
className="studio-insertion-popup__btn"
onClick={() => setExpanded((v) => !v)}
title={expanded ? '收起详情' : '展开详情'}
>
{expanded ? '收起' : '展开'}
</button>
<button
type="button"
className="studio-insertion-popup__btn studio-insertion-popup__btn--close"
onClick={onClose}
aria-label="关闭"
>
×
</button>
</div>
</div>
<div className="studio-insertion-popup__body">
{children({ expanded })}
</div>
</div>
);
}
export default StudioInsertionPopup;

View File

@@ -0,0 +1,70 @@
.studio-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--color-bg-primary);
color: var(--color-text-primary);
}
.studio-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-lg);
padding: var(--spacing-md) var(--spacing-lg);
border-bottom: 1px solid var(--color-border-light);
background: var(--color-bg-secondary);
flex-shrink: 0;
}
.studio-header-title {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.studio-header-icon {
font-size: 1.25rem;
}
.studio-title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.studio-tabs {
display: flex;
gap: var(--spacing-xs);
}
.studio-tab {
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
cursor: pointer;
font-size: 0.875rem;
transition: background var(--transition-fast), color var(--transition-fast);
}
.studio-tab:hover {
color: var(--color-text-primary);
border-color: var(--color-border-focus);
}
.studio-tab.active {
background: var(--color-accent-light);
border-color: var(--color-accent);
color: var(--color-accent);
}
.studio-body {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,34 @@
import React, { useEffect } from 'react';
import useAppLayoutStore from '../../Store/AppLayoutSlice';
/**
* 兼容旧路由studio → studio_edit
*/
function StudioPage() {
const setActivePage = useAppLayoutStore((s) => s.setActivePage);
useEffect(() => {
setActivePage('studio_edit');
}, [setActivePage]);
return null;
}
export default StudioPage;

View File

@@ -0,0 +1,240 @@
.studio-run-chat {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.studio-run-chat-messages {
flex: 1;
overflow-y: auto;
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
min-height: 0;
}
.studio-run-chat-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.studio-run-chat-message {
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius-md);
line-height: 1.6;
}
.studio-run-chat-message.role-user {
align-self: flex-end;
max-width: 85%;
background: linear-gradient(to right, rgba(102, 126, 234, 0.08), rgba(102, 126, 234, 0.15));
border-left: 3px solid var(--color-accent);
}
.studio-run-chat-message.role-assistant,
.studio-run-chat-message.role-system {
align-self: stretch;
background: rgba(255, 255, 255, 0.6);
}
.studio-run-chat-message-role {
display: block;
font-size: 0.7rem;
color: var(--color-text-muted);
margin-bottom: var(--spacing-xs);
}
.studio-run-chat-message-body {
font-size: 0.95rem;
}
.studio-run-chat-plain {
white-space: pre-wrap;
}
/* Input area — aligned with ChatBox */
.studio-run-chat-input-wrapper {
position: sticky;
bottom: 0;
left: 0;
right: 0;
flex-shrink: 0;
padding: var(--spacing-md) var(--spacing-lg);
border-top: 1px solid var(--color-border-light);
background-color: var(--color-bg-primary);
z-index: var(--z-divider);
}
.studio-run-chat-input-container {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.2s ease;
}
.studio-run-chat-input-container:focus-within {
border-color: var(--color-accent);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.studio-run-chat-options-wrapper {
position: relative;
flex-shrink: 0;
display: flex;
align-items: center;
}
.studio-run-chat-options-toggle {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: var(--radius-md);
color: var(--color-text-muted);
font-size: 1.3rem;
line-height: 1;
cursor: pointer;
transition: all 0.2s ease;
}
.studio-run-chat-options-toggle:hover {
background-color: rgba(102, 126, 234, 0.08);
color: var(--color-accent);
}
.studio-run-chat-options-toggle.active {
background-color: rgba(102, 126, 234, 0.12);
color: var(--color-accent);
}
.studio-run-chat-options {
position: absolute;
bottom: calc(100% + var(--spacing-sm));
left: 0;
min-width: 220px;
padding: var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg-elevated);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: var(--z-dropdown-menu);
backdrop-filter: blur(10px);
}
.studio-run-chat-options-title {
font-size: 0.7rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: var(--spacing-xs) var(--spacing-sm);
margin-bottom: var(--spacing-xs);
}
.studio-run-chat-render-btn {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
background: transparent;
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 0.85rem;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
}
.studio-run-chat-render-btn:hover {
background-color: rgba(102, 126, 234, 0.08);
border-color: var(--color-accent);
color: var(--color-accent);
}
.studio-run-chat-input-area {
flex: 1;
min-width: 0;
position: relative;
display: flex;
align-items: center;
}
.studio-run-chat-textarea {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border-radius: var(--radius-md);
background-color: transparent;
color: var(--color-text-primary);
font-family: inherit;
font-size: 0.95rem;
resize: none;
overflow-y: auto;
min-height: 36px;
max-height: 300px;
line-height: 1.6;
display: block;
height: auto;
field-sizing: content;
}
.studio-run-chat-textarea:focus {
outline: none;
background-color: rgba(102, 126, 234, 0.03);
}
.studio-run-chat-textarea::placeholder {
color: var(--color-text-muted);
opacity: 0.5;
}
.studio-run-chat-send {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: none;
border-radius: var(--radius-md);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
font-size: 1.3rem;
line-height: 1;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.2);
align-self: center;
}
.studio-run-chat-send:hover:not(:disabled) {
background: linear-gradient(135deg, #5568d3 0%, #6a3f8f 100%);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
}
.studio-run-chat-send:active:not(:disabled) {
transform: translateY(0);
}
.studio-run-chat-send:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
}

View File

@@ -0,0 +1,171 @@
import React, { useEffect, useRef, useState } from 'react';
import MarkdownRenderer from '../shared/MarkdownRenderer';
import './StudioRunChat.css';
const RENDER_MODES = ['none', 'markdown', 'html'];
const RENDER_MODE_LABELS = {
none: '📄 纯文本',
html: '🌐 HTML',
markdown: '📝 Markdown',
};
function renderMessageBody(text, renderMode, isUser) {
if (renderMode === 'html' && !isUser) {
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(text);
if (hasHtmlTags) {
return <div dangerouslySetInnerHTML={{ __html: text }} />;
}
return <div className="studio-run-chat-plain">{text}</div>;
}
if (renderMode === 'markdown') {
return <MarkdownRenderer content={text} />;
}
return <div className="studio-run-chat-plain">{text}</div>;
}
function StudioRunChat({
messages,
inputValue,
onInputChange,
onSend,
disabled = false,
placeholder = '输入消息…',
sending = false,
}) {
const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null);
const optionsRef = useRef(null);
const [showOptions, setShowOptions] = useState(false);
const [renderMode, setRenderMode] = useState('markdown');
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
const handleClickOutside = (event) => {
if (
optionsRef.current &&
!optionsRef.current.contains(event.target) &&
!event.target.closest('.studio-run-chat-options-toggle')
) {
setShowOptions(false);
}
};
if (showOptions) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showOptions]);
const handleInputHeight = (e) => {
const textarea = e.target;
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(textarea.scrollHeight, 300)}px`;
};
const handleSubmit = (e) => {
e.preventDefault();
if (disabled || sending || !inputValue.trim()) return;
onSend(inputValue.trim());
const textarea = e.target.querySelector('.studio-run-chat-textarea');
if (textarea) textarea.style.height = 'auto';
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!disabled && !sending && inputValue.trim()) {
onSend(inputValue.trim());
e.target.style.height = 'auto';
}
}
};
const cycleRenderMode = () => {
const idx = RENDER_MODES.indexOf(renderMode);
setRenderMode(RENDER_MODES[(idx + 1) % RENDER_MODES.length]);
};
const roleLabel = (role) => {
if (role === 'user') return '你';
if (role === 'system') return '系统';
return '助手';
};
return (
<div className="studio-run-chat">
<div className="studio-run-chat-messages" ref={messagesContainerRef}>
{messages.length === 0 ? (
<div className="studio-run-chat-empty">暂无对话在下方输入开始交流</div>
) : (
messages.map((msg) => (
<div key={msg.id} className={`studio-run-chat-message role-${msg.role}`}>
<span className="studio-run-chat-message-role">{roleLabel(msg.role)}</span>
<div className="studio-run-chat-message-body">
{renderMessageBody(msg.text, renderMode, msg.role === 'user')}
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
<form className="studio-run-chat-input-wrapper" onSubmit={handleSubmit}>
<div className="studio-run-chat-input-container">
<div className="studio-run-chat-options-wrapper" ref={optionsRef}>
<button
type="button"
className={`studio-run-chat-options-toggle${showOptions ? ' active' : ''}`}
title="展开选项"
onClick={() => setShowOptions((v) => !v)}
>
{showOptions ? '×' : '≡'}
</button>
{showOptions && (
<div className="studio-run-chat-options">
<div className="studio-run-chat-options-title">显示</div>
<button
type="button"
className="studio-run-chat-render-btn"
onClick={cycleRenderMode}
title={`当前:${RENDER_MODE_LABELS[renderMode]}`}
>
{RENDER_MODE_LABELS[renderMode]}
</button>
</div>
)}
</div>
<div className="studio-run-chat-input-area">
<textarea
className="studio-run-chat-textarea"
rows={1}
value={inputValue}
onChange={(e) => {
onInputChange(e.target.value);
handleInputHeight(e);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled || sending}
aria-label="输入消息"
/>
</div>
<button
type="submit"
className="studio-run-chat-send"
disabled={disabled || sending || !inputValue.trim()}
aria-label="发送"
title="发送"
>
{sending ? '…' : '>'}
</button>
</div>
</form>
</div>
);
}
export default StudioRunChat;

View File

@@ -0,0 +1,89 @@
.studio-run-graph {
position: relative;
height: 240px;
min-height: 200px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-light);
background: var(--color-bg-secondary);
overflow: hidden;
cursor: grab;
}
.studio-run-graph--center {
flex: 1;
min-height: 280px;
height: auto;
}
.studio-run-graph:active {
cursor: grabbing;
}
.studio-run-graph__svg {
display: block;
}
.studio-run-graph__edge {
fill: none;
stroke: var(--color-border);
stroke-width: 1.5;
opacity: 0.85;
}
.studio-run-graph__node {
cursor: pointer;
}
.studio-run-graph__node-bg {
fill: var(--color-bg-tertiary);
stroke: var(--color-border-light);
stroke-width: 1.5;
}
.studio-run-graph__node.status-active .studio-run-graph__node-bg {
stroke: var(--color-accent);
fill: rgba(var(--color-accent-rgb, 59, 130, 246), 0.1);
}
.studio-run-graph__node.status-completed .studio-run-graph__node-bg {
stroke: #22c55e;
}
.studio-run-graph__node.is-selected .studio-run-graph__node-bg {
stroke-width: 2.5;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.12));
}
.studio-run-graph__node.is-current .studio-run-graph__node-bg {
stroke: var(--color-accent);
}
.studio-run-graph__node-label {
font-size: 12px;
font-weight: 600;
fill: var(--color-text-primary);
pointer-events: none;
}
.studio-run-graph__node-status {
font-size: 10px;
fill: var(--color-text-muted);
pointer-events: none;
}
.studio-run-graph__hint {
position: absolute;
bottom: 4px;
right: 6px;
font-size: 0.6rem;
color: var(--color-text-muted);
opacity: 0.75;
pointer-events: none;
}
.studio-run-graph-empty {
padding: var(--spacing-md);
font-size: 0.75rem;
color: var(--color-text-muted);
text-align: center;
}

View File

@@ -0,0 +1,233 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { buildGraphLayout } from './edit/variableUtils';
import './StudioRunNodeGraph.css';
const NODE_STATUS_LABEL = {
pending: '待执行',
active: '进行中',
completed: '已完成',
skipped: '已跳过',
};
function defaultEdgePath(from, to, nodeW, nodeH) {
const x1 = from.x + nodeW / 2;
const y1 = from.y + nodeH;
const x2 = to.x + nodeW / 2;
const y2 = to.y;
const dy = Math.max(24, (y2 - y1) * 0.45);
return `M ${x1} ${y1} C ${x1} ${y1 + dy}, ${x2} ${y2 - dy}, ${x2} ${y2}`;
}
function StudioRunNodeGraph({
pipeline,
nodeStates,
currentNodeId,
variant = 'sidebar',
}) {
const containerRef = useRef(null);
const [view, setView] = useState({ x: 0, y: 0, scale: 1 });
const [dragging, setDragging] = useState(null);
const [selectedId, setSelectedId] = useState(currentNodeId);
const [nodeOverrides, setNodeOverrides] = useState({});
const isCenter = variant === 'center';
const layout = useMemo(
() => buildGraphLayout(pipeline, nodeStates, { vertical: true }),
[pipeline, nodeStates]
);
const { nodeW, nodeH } = layout;
useEffect(() => {
setSelectedId(currentNodeId);
}, [currentNodeId]);
useEffect(() => {
const el = containerRef.current;
if (!el || !layout.width) return;
const cw = el.clientWidth || 200;
const ch = el.clientHeight || 160;
const scale = Math.min(1, (cw - 16) / layout.width, (ch - 16) / layout.height);
setView({
x: (cw - layout.width * scale) / 2,
y: Math.max(8, (ch - layout.height * scale) / 2),
scale: Math.max(0.45, scale),
});
}, [layout.width, layout.height, pipeline, variant]);
const nodeMap = useMemo(
() => Object.fromEntries(layout.nodes.map((n) => [n.id, n])),
[layout.nodes]
);
const handleWheel = useCallback((e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.92 : 1.08;
setView((v) => ({
...v,
scale: Math.min(2.5, Math.max(0.35, v.scale * delta)),
}));
}, []);
const handleBgMouseDown = useCallback(
(e) => {
if (e.button !== 0) return;
e.preventDefault();
setDragging({ type: 'pan', startX: e.clientX, startY: e.clientY, origin: { ...view } });
},
[view]
);
const handleNodeMouseDown = useCallback(
(e, nodeId) => {
if (e.button !== 0) return;
e.stopPropagation();
setSelectedId(nodeId);
const node = nodeMap[nodeId];
if (!node) return;
setDragging({
type: 'node',
nodeId,
startX: e.clientX,
startY: e.clientY,
origin: { x: node.x, y: node.y },
});
},
[nodeMap]
);
useEffect(() => {
if (!dragging) return undefined;
const onMove = (e) => {
if (dragging.type === 'pan') {
setView((v) => ({
...v,
x: dragging.origin.x + (e.clientX - dragging.startX),
y: dragging.origin.y + (e.clientY - dragging.startY),
}));
return;
}
if (dragging.type === 'node') {
const dx = (e.clientX - dragging.startX) / view.scale;
const dy = (e.clientY - dragging.startY) / view.scale;
setNodeOverrides((prev) => ({

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
import React from 'react';
/** Label with optional dotted underline + native title tooltip for field hints. */
export default function FieldLabel({ children, tip }) {
if (tip) {
return (
<span className="studio-field-label" title={tip}>
<span className="studio-field-label__text studio-field-label__text--tip">
{children}
</span>
</span>
);
}
return <span className="studio-field-label">{children}</span>;
}

View File

@@ -0,0 +1,83 @@
import React from 'react';
import FieldLabel from './FieldLabel';
export default function ScoringDimensions({
scoring,
dimensions,
onUpdateScoring,
onUpdateDimension,
onRemoveDimension,
onAddDimension,
}) {
return (
<div className="studio-scoring-block">
<h3 className="studio-scoring-block__title">
<FieldLabel tip="定义评分维度及修改时的参考标准,用于循环迭代直至满意">
评判维度与如何完善
</FieldLabel>
</h3>
<label className="studio-edit-switch-row studio-edit-switch-row--compact">
<input
type="checkbox"
checked={scoring.enabled ?? true}
onChange={(e) => onUpdateScoring('enabled', e.target.checked)}
/>
启用评分
</label>
<div className="studio-dim-list">
{dimensions.map((dim, index) => (
<div key={dim.id || index} className="studio-dim-card">
<div className="studio-dim-card__fields">
<label className="studio-edit-field">
<span className="studio-edit-field-label">维度名称</span>
<input
type="text"
className="studio-edit-input"
value={dim.name}
onChange={(e) =>
onUpdateDimension(index, 'name', e.target.value)
}
placeholder="例如:真实性"
/>
</label>
<label className="studio-edit-field">
<span className="studio-edit-field-label">
<FieldLabel tip="说明如何改进,并作为评判参考">
如何完善含评判参考
</FieldLabel>
</span>
<textarea
className="studio-edit-textarea studio-edit-textarea--compact"
rows={2}
value={dim.criteria}
onChange={(e) =>
onUpdateDimension(index, 'criteria', e.target.value)
}
placeholder="说明如何改进,并作为评判参考…"
/>
</label>
</div>
<button
type="button"
className="studio-dim-card__remove"
title="删除此维度"
onClick={() => onRemoveDimension(index)}
>
删除
</button>
</div>
))}
</div>
<button
type="button"
className="studio-edit-btn-ghost"
onClick={onAddDimension}
>
+ 新增维度
</button>
</div>
);
}

View File

@@ -0,0 +1,146 @@
import React, { useState } from 'react';
import FieldLabel from './FieldLabel';
import {
AUTO_INJECTED_CONTEXT_ITEMS,
buildStepOutputRef,
detectReferenceCycles,
dynamicChipLabel,
dynamicTooltip,
formatCycleWarning,
getSelectableStepOutputRefs,
stepOutputLabel,
} from './variableUtils';
function AutoInjectedItem({ item }) {
const [showPopover, setShowPopover] = useState(false);
return (
<span
className="studio-var-auto-chip"
onMouseEnter={() => setShowPopover(true)}
onMouseLeave={() => setShowPopover(false)}
onFocus={() => setShowPopover(true)}
onBlur={() => setShowPopover(false)}
tabIndex={0}
>
<span className="studio-var-auto-chip__label">{item.label}</span>
{showPopover && (
<span className="studio-var-auto-chip__popover" role="tooltip">
{item.description}
</span>
)}
</span>
);
}
function VariableSwitch({ label, selected, onToggle, tooltip }) {
return (
<label
className={`studio-var-switch ${selected ? 'studio-var-switch--on' : ''}`}
title={tooltip}
>
<span className="studio-var-switch__label">{label}</span>
<span className="studio-var-switch__track" aria-hidden="true">
<input
type="checkbox"
className="studio-var-switch__input"
checked={selected}
onChange={onToggle}
/>
<span className="studio-var-switch__thumb" />
</span>
</label>
);
}
export default function VariableChips({
pipeline,
selectedNode,
onToggleRef,
variant = 'default',
}) {
const isCompact = variant === 'compact';
const [autoExpanded, setAutoExpanded] = useState(false);
const previousSteps = getSelectableStepOutputRefs(pipeline, selectedNode);
const cycleWarning = formatCycleWarning(detectReferenceCycles(pipeline), pipeline);
const isChecked = (ref) =>
(selectedNode.inputs || []).some((i) => i.ref === ref);
return (
<div className={`studio-var-panel ${isCompact ? 'studio-var-panel--compact' : ''}`}>
<h3 className="studio-var-panel__title">
<FieldLabel tip="引用前序步骤的世界书条目;核心目的、思考流程等由系统自动注入">
上文引用
</FieldLabel>
</h3>
{cycleWarning && (
<p className="studio-var-cycle-warn" role="alert">
{cycleWarning}
</p>
)}
<div className="studio-var-group studio-var-group--auto">
<button
type="button"
className="studio-var-auto-toggle"
onClick={() => setAutoExpanded((v) => !v)}
aria-expanded={autoExpanded}
>
<span className="studio-var-group__title">系统自动注入</span>
<span className="studio-var-auto-toggle__meta">
{autoExpanded
? '收起'
: `${AUTO_INJECTED_CONTEXT_ITEMS.length} 项已默认注入`}
</span>
<span className="studio-var-auto-toggle__chevron" aria-hidden="true">
{autoExpanded ? '▾' : '▸'}
</span>
</button>
{autoExpanded ? (
<div className="studio-var-auto-list">
{AUTO_INJECTED_CONTEXT_ITEMS.map((item) => (
<AutoInjectedItem key={item.id} item={item} />
))}
</div>
) : (
<p className="studio-edit-hint studio-var-auto-hint">
运行时自动附带无需手动开启展开可查看各项含义
</p>
)}
</div>
<div className="studio-var-group">
<h4 className="studio-var-group__title">前序步骤产物</h4>
<p className="studio-edit-hint studio-var-manual-hint">
开关开启后将把对应步骤的最终世界书条目注入本步上下文
</p>
<div className={`studio-var-grid ${isCompact ? 'studio-var-grid--compact' : ''}`}>
{previousSteps.length === 0 ? (
<p className="studio-edit-hint studio-var-empty">暂无可引用的前序世界书步骤</p>
) : (
previousSteps.map((node) => {
const ref = buildStepOutputRef(node.id);
const checked = isChecked(ref);
const label = dynamicChipLabel(ref, pipeline, stepOutputLabel(node.displayName));
return (
<VariableSwitch
key={ref}
label={label}
selected={checked}
tooltip={dynamicTooltip(ref, pipeline)}
onToggle={() =>
onToggleRef(ref, stepOutputLabel(node.displayName))
}
/>
);
})
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,163 @@
import React from 'react';
import FieldLabel from './FieldLabel';
const POSITION_OPTIONS = [
{ value: 0, label: '角色定义之后' },
{ value: 1, label: '角色定义之前' },
{ value: 2, label: '示例对话之前' },
{ value: 3, label: '示例对话之后' },
{ value: 4, label: '系统提示/作者注释' },
{ value: 5, label: '作为系统消息' },
{ value: 6, label: '深度插入' },
{ value: 7, label: '宏替换' },
];
const ACTIVATION_OPTIONS = [
{ value: 'permanent', label: '永久激活' },
{ value: 'keyword', label: '关键词触发' },
{ value: 'rag', label: 'RAG 检索' },
];
export default function WorldbookInsertion({
insertion,
onUpdateInsertion,
onUpdateRagConfig,
}) {
const activation = insertion.activationType ?? 'permanent';
return (
<div className="studio-wb-insert">
<h3 className="studio-wb-insert__title">世界书插入</h3>
<div className="studio-wb-insert__fields">
<label className="studio-edit-field">
<span className="studio-edit-field-label">
<FieldLabel tip="条目写入角色卡时的深度位置,影响模型读取上下文的顺序">
插入位置
</FieldLabel>
</span>
<select
className="studio-edit-select"
value={insertion.position ?? 1}
onChange={(e) =>
onUpdateInsertion('position', Number(e.target.value))
}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<label className="studio-edit-field">
<span className="studio-edit-field-label">
<FieldLabel tip="永久激活始终注入;关键词/RAG 仅在匹配时注入">
激活方式
</FieldLabel>
</span>
<select
className="studio-edit-select"
value={activation}
onChange={(e) =>
onUpdateInsertion('activationType', e.target.value)
}
>
{ACTIVATION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
{activation === 'keyword' && (
<>
<label className="studio-edit-field">
<span className="studio-edit-field-label">
<FieldLabel tip="主关键词用于触发本条目进入上下文">主关键词</FieldLabel>
</span>
<input
type="text"
className="studio-edit-input"
value={insertion.key ?? ''}
onChange={(e) => onUpdateInsertion('key', e.target.value)}
placeholder="触发本条目所需主词"
/>
</label>
<label className="studio-edit-field">
<span className="studio-edit-field-label">
<FieldLabel tip="可选,辅助匹配多个相关词">次要关键词</FieldLabel>
</span>
<input
type="text"
className="studio-edit-input"
value={insertion.keysecondary ?? ''}
onChange={(e) =>
onUpdateInsertion('keysecondary', e.target.value)
}
placeholder="可选,辅助匹配"
/>
</label>
</>
)}
{activation === 'rag' && (
<>
<label className="studio-edit-field">
<span className="studio-edit-field-label">RAG ID</span>
<input
type="text"
className="studio-edit-input"
value={insertion.ragConfig?.libraryId ?? ''}
onChange={(e) =>
onUpdateRagConfig('libraryId', e.target.value)
}
/>
</label>
<label className="studio-edit-field">
<span className="studio-edit-field-label">相似度阈值</span>
<input
type="number"
step="0.01"
min="0"
max="1"
className="studio-edit-input"
value={insertion.ragConfig?.threshold ?? 0.5}
onChange={(e) =>
onUpdateRagConfig('threshold', Number(e.target.value))
}
/>
</label>
<label className="studio-edit-field">
<span className="studio-edit-field-label">最大条数</span>
<input
type="number"
min="1"
className="studio-edit-input"
value={insertion.ragConfig?.maxEntries ?? 3}
onChange={(e) =>
onUpdateRagConfig('maxEntries', Number(e.target.value))
}
/>
</label>
</>
)}
<label className="studio-edit-field">
<span className="studio-edit-field-label">
<FieldLabel tip="写入世界书条目的说明注释,便于在编辑器中识别">备注</FieldLabel>
</span>
<input
type="text"
className="studio-edit-input"
value={insertion.comment ?? ''}
onChange={(e) => onUpdateInsertion('comment', e.target.value)}
placeholder="写入世界书条目的说明注释"
/>
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,347 @@
/** Resolve node displayName from dynamic ref like `aesthetic.output`. */
export function nodeDisplayNameFromRef(ref, pipeline) {
const nodes = pipeline?.nodes || [];
const node = nodes.find((n) => ref.startsWith(`${n.id}.`));
return node?.displayName || ref.split('.')[0];
}
/** Extract pipeline node id from a step output ref (`nodeId.output`). */
export function parseNodeRef(ref) {
if (!ref || typeof ref !== 'string') return null;
const m = ref.match(/^([^.]+)\.output$/);
return m ? m[1] : null;
}
/** Locale-aware display name compare (Chinese + numeric tie-break). */
export function compareNodeDisplayNames(a, b) {
const nameA = a?.displayName || '';
const nameB = b?.displayName || '';
return nameA.localeCompare(nameB, 'zh-CN', {
numeric: true,
sensitivity: 'base',
});
}
/**
* Topological layer sort by `inputs[].ref` dependencies.
* Same layer → display name (zh-CN localeCompare).
*/
export function sortNodesByLogic(pipeline) {
const nodes = pipeline?.nodes || [];
if (nodes.length <= 1) return [...nodes];
const edges = buildNodeDependencyEdges(pipeline);
const depth = Object.fromEntries(nodes.map((n) => [n.id, 0]));
const pipelineIndex = Object.fromEntries(nodes.map((n, i) => [n.id, i]));
let changed = true;
while (changed) {
changed = false;
edges.forEach(({ from, to }) => {
const next = (depth[from] || 0) + 1;
if (next > (depth[to] || 0)) {
depth[to] = next;
changed = true;
}
});
}
const layers = {};
nodes.forEach((n) => {
const d = depth[n.id] || 0;
if (!layers[d]) layers[d] = [];
layers[d].push(n);
});
const sorted = [];
Object.keys(layers)
.map(Number)
.sort((a, b) => a - b)
.forEach((d) => {
layers[d].sort((a, b) => {
const byName = compareNodeDisplayNames(a, b);
if (byName !== 0) return byName;
return (pipelineIndex[a.id] ?? 0) - (pipelineIndex[b.id] ?? 0);
});
sorted.push(...layers[d]);
});
return sorted;
}
/** Order node ids by manual pipeline order snapshot. */
export function orderNodesByIds(nodes, orderIds) {
const orderMap = Object.fromEntries((orderIds || []).map((id, i) => [id, i]));
return [...(nodes || [])].sort((a, b) => {
const ia = orderMap[a.id] ?? Number.MAX_SAFE_INTEGER;
const ib = orderMap[b.id] ?? Number.MAX_SAFE_INTEGER;
if (ia !== ib) return ia - ib;
return compareNodeDisplayNames(a, b);
});
}
/** Dependency edges derived from `inputs[].ref` between pipeline nodes. */
export function buildNodeDependencyEdges(pipeline) {
const nodes = pipeline?.nodes || [];
const nodeIds = new Set(nodes.map((n) => n.id));
const edges = [];
const edgeKeys = new Set();
nodes.forEach((node) => {
(node.inputs || []).forEach((inp) => {
const src = parseNodeRef(inp.ref);
if (!src || src === node.id || !nodeIds.has(src)) return;
const key = `${src}->${node.id}`;
if (edgeKeys.has(key)) return;
edgeKeys.add(key);
edges.push({ from: src, to: node.id });
});
});
return edges;
}
/** True if adding `fromNodeId → toNodeId` would close a dependency cycle. */
export function wouldCreateDependencyCycle(pipeline, toNodeId, fromNodeId) {
if (!toNodeId || !fromNodeId || toNodeId === fromNodeId) return true;
const edges = buildNodeDependencyEdges(pipeline);
const visited = new Set();
const stack = [toNodeId];
while (stack.length) {
const cur = stack.pop();
if (cur === fromNodeId) return true;
if (visited.has(cur)) continue;
visited.add(cur);
edges.filter((e) => e.from === cur).forEach((e) => stack.push(e.to));
}
return false;
}
/** Find dependency cycles in the pipeline (node id paths). */
export function detectReferenceCycles(pipeline) {
const nodes = pipeline?.nodes || [];
const nodeIds = nodes.map((n) => n.id);
const adj = Object.fromEntries(nodeIds.map((id) => [id, []]));
buildNodeDependencyEdges(pipeline).forEach(({ from, to }) => {
adj[from].push(to);
});
const cycles = [];
const visited = new Set();
const stack = new Set();
const path = [];
const dfs = (nodeId) => {
visited.add(nodeId);
stack.add(nodeId);
path.push(nodeId);
(adj[nodeId] || []).forEach((next) => {
if (!visited.has(next)) {
dfs(next);
} else if (stack.has(next)) {
const start = path.indexOf(next);
if (start >= 0) {
cycles.push([...path.slice(start), next]);
}
}
});
path.pop();
stack.delete(nodeId);
};
nodeIds.forEach((id) => {
if (!visited.has(id)) dfs(id);
});
return cycles;
}
export function formatCycleWarning(cycles, pipeline) {
if (!cycles.length) return null;
const nameOf = (id) =>
pipeline?.nodes?.find((n) => n.id === id)?.displayName || id;
const first = cycles[0];
const chain = first.map(nameOf).join(' → ');
return `检测到循环引用:${chain}`;
}
/** Worldbook steps that may be referenced without creating a cycle. */
export function getSelectableStepOutputRefs(pipeline, selectedNode) {
if (!selectedNode?.id) return [];
return (pipeline?.nodes || []).filter(
(n) =>
n.enabled !== false &&
n.skillId === 'studio.worldbook_entry' &&
n.id !== selectedNode.id &&
!wouldCreateDependencyCycle(pipeline, selectedNode.id, n.id)
);
}
export function buildStepOutputRef(nodeId) {
return `${nodeId}.output`;
}
export function stepOutputLabel(displayName) {
return `${displayName} · 世界书条目`;
}
export function dynamicChipLabel(ref, pipeline, fallbackLabel) {
const name = nodeDisplayNameFromRef(ref, pipeline);
if (name && name !== ref.split('.')[0]) return stepOutputLabel(name);
return fallbackLabel || stepOutputLabel(name);
}
export function dynamicTooltip(ref, pipeline) {
const name = nodeDisplayNameFromRef(ref, pipeline);
return `引用前序步骤「${name}」的最终世界书条目 · ${ref}`;
}
export const DEFAULT_THINKING_PROMPT = `====== 思考流程 ======
Step1: 简短确认任务性质(新设计/修改)
Step2: 阅读绑定角色/世界书与上文引用
Step3: 按步骤目标起草世界书条目
Step4: 对照评价维度自检并优化表述
(核心目的、评价标准与优化建议由系统在运行时自动注入,无需在此填写)`;
export const AUTO_INJECTED_CONTEXT_ITEMS = [
{
id: 'currentProduct',
label: '目前产物',
description:
'当前步骤已生成的世界书条目草稿或最新版本,供模型在迭代修改时对照与延续。',
},
{
id: 'thinkingFlow',
label: '思考流程',
description:
'本步骤配置的 thinkingPrompt引导模型按既定步骤推理与自检。',
},
{
id: 'coreGoal',
label: '核心目的',
description:
'本步骤的 stepGoal步骤目标明确本步要产出的内容与边界。',
},
{
id: 'scoringCriteria',
label: '评价标准与优化建议',
description:
'本步骤启用的 scoring 评价维度及准则,用于模型自检与优化表述。',
},
];
export const AUTO_INJECTED_CONTEXT_LABELS = AUTO_INJECTED_CONTEXT_ITEMS.map(
(item) => item.label
);
const NODE_W = 128;
const NODE_H = 52;
const GAP_X = 48;
const GAP_Y = 64;
const PAD = 24;
function verticalEdgePath(from, to) {
const x1 = from.x + NODE_W / 2;
const y1 = from.y + NODE_H;
const x2 = to.x + NODE_W / 2;
const y2 = to.y;
const dy = Math.max(24, (y2 - y1) * 0.45);
return `M ${x1} ${y1} C ${x1} ${y1 + dy}, ${x2} ${y2 - dy}, ${x2} ${y2}`;
}
/** Layout nodes as a vertical dependency tree for SVG progress graphs. */
export function buildGraphLayout(pipeline, nodeStates, options = {}) {
const vertical = options.vertical !== false;
const nodes = (pipeline?.nodes || []).filter((n) => n.enabled !== false);
const stateMap = Object.fromEntries(
(nodeStates || []).map((s) => [s.nodeId, s])
);
const pipelineIndex = Object.fromEntries(
nodes.map((n, i) => [n.id, i])
);
const edges = buildNodeDependencyEdges(pipeline);
const depth = {};
nodes.forEach((n) => {
depth[n.id] = 0;
});
let changed = true;
while (changed) {
changed = false;
edges.forEach(({ from, to }) => {
const next = (depth[from] || 0) + 1;
if (next > (depth[to] || 0)) {
depth[to] = next;
changed = true;
}
});
}
const layers = {};
nodes.forEach((n) => {
const d = depth[n.id] || 0;
if (!layers[d]) layers[d] = [];
layers[d].push(n);
});
Object.values(layers).forEach((layer) => {
layer.sort((a, b) => (pipelineIndex[a.id] ?? 0) - (pipelineIndex[b.id] ?? 0));
});
const positions = {};
Object.keys(layers)
.map(Number)
.sort((a, b) => a - b)
.forEach((d) => {
const layer = layers[d];
layer.forEach((node, col) => {
if (vertical) {
positions[node.id] = {
x: PAD + col * (NODE_W + GAP_X),
y: PAD + d * (NODE_H + GAP_Y),
};
} else {
positions[node.id] = {
x: PAD + d * (NODE_W + GAP_X),
y: PAD + col * (NODE_H + GAP_Y),
};
}
});
});
const maxLayer = Math.max(0, ...Object.keys(layers).map(Number));
const maxCols = Math.max(1, ...Object.values(layers).map((l) => l.length));
const width = vertical
? PAD * 2 + maxCols * NODE_W + (maxCols - 1) * GAP_X
: PAD * 2 + (maxLayer + 1) * NODE_W + maxLayer * GAP_X;
const height = vertical
? PAD * 2 + (maxLayer + 1) * NODE_H + maxLayer * GAP_Y
: PAD * 2 + maxCols * NODE_H + (maxCols - 1) * GAP_Y;
return {
nodes: nodes.map((n) => ({
id: n.id,
label: n.displayName,
status: stateMap[n.id]?.status || 'pending',
x: positions[n.id]?.x ?? PAD,
y: positions[n.id]?.y ?? PAD,
})),
edges,
width,
height,
nodeW: NODE_W,
nodeH: NODE_H,
edgePath: vertical ? verticalEdgePath : null,
};
}

View File

@@ -0,0 +1,37 @@
/**
* Backend workflow variable ref → Chinese display label.
* Mirrors data/agent/workflow_variables.json builtIn entries.
*/
export const BUILTIN_WORKFLOW_VARIABLE_LABELS = {
'workflow.goal': '工作流目标文本',
'workflow.boundWorldbook': '绑定世界书摘要',
'workflow.boundCharacter': '绑定角色卡摘要',
};
/** Build ref → label map from /api/studio/variables response. */
export function buildWorkflowVariableLabelMap(workflowVariables) {
const map = { ...BUILTIN_WORKFLOW_VARIABLE_LABELS };
const builtIn = workflowVariables?.builtIn || [];
const dynamic = workflowVariables?.dynamic || [];
[...builtIn, ...dynamic].forEach((item) => {
if (item?.ref && item?.label) {
map[item.ref] = item.label;
}
});
return map;
}
/**
* Resolve Chinese label for a workflow variable key shown in run preview.
* @param {string} ref - e.g. workflow.goal or nodeId.output
* @param {Record<string, string>} [labelMap] - optional map from buildWorkflowVariableLabelMap
*/
export function getWorkflowVariableLabel(ref, labelMap = BUILTIN_WORKFLOW_VARIABLE_LABELS) {
if (!ref) return '';
if (labelMap[ref]) return labelMap[ref];
const outputMatch = ref.match(/^([^.]+)\.output$/);
if (outputMatch) {
return `${outputMatch[1]} · 世界书条目`;
}
return ref;
}

View File

@@ -0,0 +1 @@
export { default } from './StudioPage';