254 lines
8.5 KiB
JavaScript
254 lines
8.5 KiB
JavaScript
import React, { useEffect, useRef, useState } from 'react';
|
||
|
||
import MarkdownRenderer from '../shared/MarkdownRenderer';
|
||
import { findLastUserMessageIndex } from './studioRunUtils';
|
||
|
||
import './StudioRunChat.css';
|
||
|
||
const RENDER_MODES = ['none', 'markdown', 'html'];
|
||
const RENDER_MODE_LABELS = {
|
||
none: '📄 纯文本',
|
||
html: '🌐 HTML',
|
||
markdown: '📝 Markdown',
|
||
};
|
||
|
||
function renderBody(text, renderMode, isUser) {
|
||
if (!text) return null;
|
||
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({
|
||
stepMessages = [],
|
||
thinking,
|
||
evaluation,
|
||
pendingUserText,
|
||
inputValue,
|
||
onInputChange,
|
||
onSend,
|
||
disabled = false,
|
||
sending = false,
|
||
streamOutput = false,
|
||
onStreamOutputChange,
|
||
placeholder = '输入消息…',
|
||
}) {
|
||
const containerRef = useRef(null);
|
||
const focusAnchorRef = useRef(null);
|
||
const optionsRef = useRef(null);
|
||
const [showOptions, setShowOptions] = useState(false);
|
||
const [renderMode, setRenderMode] = useState('markdown');
|
||
|
||
const lastUserIdx = findLastUserMessageIndex(stepMessages);
|
||
const historyMessages =
|
||
lastUserIdx > 0
|
||
? stepMessages.slice(0, lastUserIdx).filter((msg) => msg.role === 'user')
|
||
: [];
|
||
const lastUserMessage =
|
||
lastUserIdx >= 0 ? stepMessages[lastUserIdx] : pendingUserText
|
||
? { role: 'user', content: pendingUserText }
|
||
: null;
|
||
|
||
const summaryText = evaluation || null;
|
||
|
||
const hasFocusContent =
|
||
thinking || summaryText || pendingUserText || sending || lastUserMessage;
|
||
|
||
useEffect(() => {
|
||
const container = containerRef.current;
|
||
const anchor = focusAnchorRef.current;
|
||
if (!container || !anchor) return;
|
||
container.scrollTop = Math.max(0, anchor.offsetTop - container.offsetTop);
|
||
}, [stepMessages, thinking, evaluation, pendingUserText, sending]);
|
||
|
||
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]);
|
||
};
|
||
|
||
return (
|
||
<div className="studio-run-chat">
|
||
<div className="studio-run-chat-messages" ref={containerRef}>
|
||
{historyMessages.length > 0 && (
|
||
<div className="studio-run-chat-history" aria-hidden="true">
|
||
{historyMessages.map((msg) => (
|
||
<div
|
||
key={msg.id}
|
||
className="studio-run-chat-history-item role-user"
|
||
>
|
||
<span className="studio-run-chat-history-role">你</span>
|
||
<span className="studio-run-chat-history-text">{msg.content}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div ref={focusAnchorRef} className="studio-run-chat-focus">
|
||
{!hasFocusContent ? (
|
||
<div className="studio-run-chat-empty">
|
||
暂无本轮对话,在下方输入开始交流
|
||
</div>
|
||
) : (
|
||
<>
|
||
{lastUserMessage && !pendingUserText && (
|
||
<div className="studio-run-chat-last-user">
|
||
<span className="studio-run-chat-last-user-label">你</span>
|
||
<span className="studio-run-chat-last-user-text">
|
||
{lastUserMessage.content}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{pendingUserText && (
|
||
<div className="studio-run-chat-last-user studio-run-chat-last-user--pending">
|
||
<span className="studio-run-chat-last-user-label">你</span>
|
||
<span className="studio-run-chat-last-user-text">
|
||
{pendingUserText}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{sending && !thinking && (
|
||
<div className="studio-run-chat-thinking studio-run-chat-thinking--pending">
|
||
<span className="studio-run-chat-block-label">思考</span>
|
||
<span className="studio-run-chat-pending">正在等待模型回复…</span>
|
||
</div>
|
||
)}
|
||
|
||
{thinking && (
|
||
<div className="studio-run-chat-thinking">
|
||
<span className="studio-run-chat-block-label">思考</span>
|
||
<div className="studio-run-chat-block-body">
|
||
{renderBody(thinking, renderMode, false)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{summaryText && (
|
||
<div className="studio-run-chat-summary">
|
||
<span className="studio-run-chat-block-label">评价与修改建议</span>
|
||
<div className="studio-run-chat-block-body">
|
||
{renderBody(summaryText, renderMode, false)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</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 className="studio-run-chat-options-title">功能</div>
|
||
<label className="studio-run-chat-option-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={streamOutput}
|
||
onChange={(e) => onStreamOutputChange?.(e.target.checked)}
|
||
/>
|
||
<span className="studio-run-chat-checkmark" />
|
||
<span className="studio-run-chat-option-label">流式输出</span>
|
||
</label>
|
||
</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;
|