Files
SillyTavern_replica/frontend/src/components/Studio/StudioRunChat.jsx

254 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;