feat(studio): 新增 Studio 工作流编辑/运行页,优化顶部三栏对齐
- 后端:项目/运行 API、上下文服务与数据模型 - 前端:Studio 列表、编辑页(R1/R2 布局)、运行页与节点图 - 编辑页顶部:CSS Grid 统一标签行与控件行对齐,项目按钮独立第三行 - Docker 开发配置与文档脚本 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
18
frontend/src/components/Studio/StudioContextBlockPopup.css
Normal file
18
frontend/src/components/Studio/StudioContextBlockPopup.css
Normal 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;
|
||||
}
|
||||
33
frontend/src/components/Studio/StudioContextBlockPopup.jsx
Normal file
33
frontend/src/components/Studio/StudioContextBlockPopup.jsx
Normal 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;
|
||||
1184
frontend/src/components/Studio/StudioEditPage.css
Normal file
1184
frontend/src/components/Studio/StudioEditPage.css
Normal file
File diff suppressed because it is too large
Load Diff
795
frontend/src/components/Studio/StudioEditPage.jsx
Normal file
795
frontend/src/components/Studio/StudioEditPage.jsx
Normal 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;
|
||||
107
frontend/src/components/Studio/StudioInsertionPopup.css
Normal file
107
frontend/src/components/Studio/StudioInsertionPopup.css
Normal 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;
|
||||
}
|
||||
95
frontend/src/components/Studio/StudioInsertionPopup.jsx
Normal file
95
frontend/src/components/Studio/StudioInsertionPopup.jsx
Normal 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;
|
||||
70
frontend/src/components/Studio/StudioPage.css
Normal file
70
frontend/src/components/Studio/StudioPage.css
Normal 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;
|
||||
}
|
||||
34
frontend/src/components/Studio/StudioPage.jsx
Normal file
34
frontend/src/components/Studio/StudioPage.jsx
Normal 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;
|
||||
|
||||
240
frontend/src/components/Studio/StudioRunChat.css
Normal file
240
frontend/src/components/Studio/StudioRunChat.css
Normal 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;
|
||||
}
|
||||
171
frontend/src/components/Studio/StudioRunChat.jsx
Normal file
171
frontend/src/components/Studio/StudioRunChat.jsx
Normal 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;
|
||||
89
frontend/src/components/Studio/StudioRunNodeGraph.css
Normal file
89
frontend/src/components/Studio/StudioRunNodeGraph.css
Normal 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;
|
||||
}
|
||||
233
frontend/src/components/Studio/StudioRunNodeGraph.jsx
Normal file
233
frontend/src/components/Studio/StudioRunNodeGraph.jsx
Normal 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) => ({
|
||||
1164
frontend/src/components/Studio/StudioRunPage.css
Normal file
1164
frontend/src/components/Studio/StudioRunPage.css
Normal file
File diff suppressed because it is too large
Load Diff
1012
frontend/src/components/Studio/StudioRunPage.jsx
Normal file
1012
frontend/src/components/Studio/StudioRunPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
15
frontend/src/components/Studio/edit/FieldLabel.jsx
Normal file
15
frontend/src/components/Studio/edit/FieldLabel.jsx
Normal 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>;
|
||||
}
|
||||
83
frontend/src/components/Studio/edit/ScoringDimensions.jsx
Normal file
83
frontend/src/components/Studio/edit/ScoringDimensions.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
frontend/src/components/Studio/edit/VariableChips.jsx
Normal file
146
frontend/src/components/Studio/edit/VariableChips.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
frontend/src/components/Studio/edit/WorldbookInsertion.jsx
Normal file
163
frontend/src/components/Studio/edit/WorldbookInsertion.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
347
frontend/src/components/Studio/edit/variableUtils.js
Normal file
347
frontend/src/components/Studio/edit/variableUtils.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
1
frontend/src/components/Studio/index.js
Normal file
1
frontend/src/components/Studio/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './StudioPage';
|
||||
Reference in New Issue
Block a user