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,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;