feat(studio): 新增 Studio 工作流编辑/运行页,优化顶部三栏对齐
- 后端:项目/运行 API、上下文服务与数据模型 - 前端:Studio 列表、编辑页(R1/R2 布局)、运行页与节点图 - 编辑页顶部:CSS Grid 统一标签行与控件行对齐,项目按钮独立第三行 - Docker 开发配置与文档脚本 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user