693 lines
29 KiB
TypeScript
693 lines
29 KiB
TypeScript
import { ArrowUp, ChevronLeft, Clock3, Command, Download, Eye, FileText, Mic, Paperclip, Pencil, Plus, RefreshCw, RotateCcw, Square, Trash2, X } from 'lucide-react';
|
|
import type { Components } from 'react-markdown';
|
|
import { memo, type ChangeEventHandler, type KeyboardEventHandler, type RefObject } from 'react';
|
|
|
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
|
import nanobotLogo from '../../../assets/nanobot-logo.png';
|
|
import type { ChatMessage } from '../../../types/bot';
|
|
import { normalizeAssistantMessageText, normalizeUserMessageText } from '../messageParser';
|
|
import { normalizeDashboardAttachmentPath } from '../shared/workspaceMarkdown';
|
|
import type { StagedSubmissionDraft } from '../types';
|
|
import { formatDateInputValue, workspaceFileAction } from '../utils';
|
|
import { DashboardConversationMessages } from './DashboardConversationMessages';
|
|
import './DashboardChatPanel.css';
|
|
|
|
interface DashboardChatPanelLabels {
|
|
badReply: string;
|
|
botDisabledHint: string;
|
|
botStarting: string;
|
|
botStopping: string;
|
|
chatDisabled: string;
|
|
close: string;
|
|
controlCommandsHide: string;
|
|
controlCommandsShow: string;
|
|
copyPrompt: string;
|
|
copyReply: string;
|
|
deleteMessage: string;
|
|
disabledPlaceholder: string;
|
|
download: string;
|
|
editPrompt: string;
|
|
fileNotPreviewable: string;
|
|
goodReply: string;
|
|
inputPlaceholder: string;
|
|
interrupt: string;
|
|
noConversation: string;
|
|
previewTitle: string;
|
|
stagedSubmissionAttachmentCount: (count: number) => string;
|
|
stagedSubmissionEmpty: string;
|
|
stagedSubmissionRestore: string;
|
|
stagedSubmissionRemove: string;
|
|
quoteReply: string;
|
|
quotedReplyLabel: string;
|
|
send: string;
|
|
thinking: string;
|
|
uploadFile: string;
|
|
uploadingFile: string;
|
|
user: string;
|
|
voiceStart: string;
|
|
voiceStop: string;
|
|
voiceTranscribing: string;
|
|
you: string;
|
|
}
|
|
|
|
interface DashboardChatPanelProps {
|
|
conversation: ChatMessage[];
|
|
isZh: boolean;
|
|
labels: DashboardChatPanelLabels;
|
|
chatScrollRef: RefObject<HTMLDivElement | null>;
|
|
onChatScroll: () => void;
|
|
expandedProgressByKey: Record<string, boolean>;
|
|
expandedUserByKey: Record<string, boolean>;
|
|
deletingMessageIdMap: Record<number, boolean>;
|
|
feedbackSavingByMessageId: Record<number, boolean>;
|
|
markdownComponents: Components;
|
|
workspaceDownloadExtensionSet: ReadonlySet<string>;
|
|
onToggleProgressExpand: (key: string) => void;
|
|
onToggleUserExpand: (key: string) => void;
|
|
onEditUserPrompt: (text: string) => void;
|
|
onCopyUserPrompt: (text: string) => Promise<void> | void;
|
|
onDeleteConversationMessage: (message: ChatMessage) => Promise<void> | void;
|
|
onOpenWorkspacePath: (path: string) => Promise<void> | void;
|
|
onSubmitAssistantFeedback: (message: ChatMessage, direction: 'up' | 'down') => Promise<void> | void;
|
|
onQuoteAssistantReply: (message: ChatMessage) => void;
|
|
onCopyAssistantReply: (text: string) => Promise<void> | void;
|
|
isThinking: boolean;
|
|
canChat: boolean;
|
|
isChatEnabled: boolean;
|
|
speechEnabled: boolean;
|
|
selectedBotEnabled: boolean;
|
|
selectedBotControlState?: 'starting' | 'stopping' | 'enabling' | 'disabling';
|
|
quotedReply: { text: string } | null;
|
|
onClearQuotedReply: () => void;
|
|
stagedSubmissions: StagedSubmissionDraft[];
|
|
onRestoreStagedSubmission: (stagedSubmissionId: string) => void;
|
|
onRemoveStagedSubmission: (stagedSubmissionId: string) => void;
|
|
pendingAttachments: string[];
|
|
onRemovePendingAttachment: (path: string) => void;
|
|
attachmentUploadPercent: number | null;
|
|
isUploadingAttachments: boolean;
|
|
filePickerRef: RefObject<HTMLInputElement | null>;
|
|
allowedAttachmentExtensions: string[];
|
|
onPickAttachments: ChangeEventHandler<HTMLInputElement>;
|
|
controlCommandPanelOpen: boolean;
|
|
controlCommandPanelRef: RefObject<HTMLDivElement | null>;
|
|
onToggleControlCommandPanel: () => void;
|
|
activeControlCommand: string;
|
|
canSendControlCommand: boolean;
|
|
isInterrupting: boolean;
|
|
onSendControlCommand: (command: '/restart' | '/new') => Promise<void> | void;
|
|
onInterruptExecution: () => Promise<void> | void;
|
|
chatDateTriggerRef: RefObject<HTMLButtonElement | null>;
|
|
hasSelectedBot: boolean;
|
|
chatDateJumping: boolean;
|
|
onToggleChatDatePicker: () => void;
|
|
chatDatePickerOpen: boolean;
|
|
chatDatePanelPosition: { bottom: number; right: number } | null;
|
|
chatDateValue: string;
|
|
onChatDateValueChange: (value: string) => void;
|
|
onCloseChatDatePicker: () => void;
|
|
onJumpConversationToDate: () => Promise<void> | void;
|
|
command: string;
|
|
onCommandChange: (value: string) => void;
|
|
composerTextareaRef: RefObject<HTMLTextAreaElement | null>;
|
|
onComposerKeyDown: KeyboardEventHandler<HTMLTextAreaElement>;
|
|
isVoiceRecording: boolean;
|
|
isVoiceTranscribing: boolean;
|
|
isCompactMobile: boolean;
|
|
voiceCountdown: number;
|
|
onVoiceInput: () => Promise<void> | void;
|
|
onTriggerPickAttachments: () => Promise<void> | void;
|
|
submitActionMode: 'interrupt' | 'send' | 'stage';
|
|
onSubmitAction: () => Promise<void> | void;
|
|
}
|
|
|
|
interface DashboardChatTranscriptProps {
|
|
conversation: ChatMessage[];
|
|
isZh: boolean;
|
|
labels: DashboardChatPanelLabels;
|
|
chatScrollRef: RefObject<HTMLDivElement | null>;
|
|
onChatScroll: () => void;
|
|
expandedProgressByKey: Record<string, boolean>;
|
|
expandedUserByKey: Record<string, boolean>;
|
|
deletingMessageIdMap: Record<number, boolean>;
|
|
feedbackSavingByMessageId: Record<number, boolean>;
|
|
markdownComponents: Components;
|
|
workspaceDownloadExtensionSet: ReadonlySet<string>;
|
|
onToggleProgressExpand: (key: string) => void;
|
|
onToggleUserExpand: (key: string) => void;
|
|
onEditUserPrompt: (text: string) => void;
|
|
onCopyUserPrompt: (text: string) => Promise<void> | void;
|
|
onDeleteConversationMessage: (message: ChatMessage) => Promise<void> | void;
|
|
onOpenWorkspacePath: (path: string) => Promise<void> | void;
|
|
onSubmitAssistantFeedback: (message: ChatMessage, direction: 'up' | 'down') => Promise<void> | void;
|
|
onQuoteAssistantReply: (message: ChatMessage) => void;
|
|
onCopyAssistantReply: (text: string) => Promise<void> | void;
|
|
isThinking: boolean;
|
|
}
|
|
|
|
const MemoizedChatTranscript = memo(function MemoizedChatTranscript({
|
|
conversation,
|
|
isZh,
|
|
labels,
|
|
chatScrollRef,
|
|
onChatScroll,
|
|
expandedProgressByKey,
|
|
expandedUserByKey,
|
|
deletingMessageIdMap,
|
|
feedbackSavingByMessageId,
|
|
markdownComponents,
|
|
workspaceDownloadExtensionSet,
|
|
onToggleProgressExpand,
|
|
onToggleUserExpand,
|
|
onEditUserPrompt,
|
|
onCopyUserPrompt,
|
|
onDeleteConversationMessage,
|
|
onOpenWorkspacePath,
|
|
onSubmitAssistantFeedback,
|
|
onQuoteAssistantReply,
|
|
onCopyAssistantReply,
|
|
isThinking,
|
|
}: DashboardChatTranscriptProps) {
|
|
return (
|
|
<div className="ops-chat-scroll" ref={chatScrollRef} onScroll={onChatScroll}>
|
|
{conversation.length === 0 ? (
|
|
<div className="ops-chat-empty">
|
|
{labels.noConversation}
|
|
</div>
|
|
) : (
|
|
<DashboardConversationMessages
|
|
conversation={conversation}
|
|
isZh={isZh}
|
|
labels={{
|
|
badReply: labels.badReply,
|
|
copyPrompt: labels.copyPrompt,
|
|
copyReply: labels.copyReply,
|
|
deleteMessage: labels.deleteMessage,
|
|
download: labels.download,
|
|
editPrompt: labels.editPrompt,
|
|
fileNotPreviewable: labels.fileNotPreviewable,
|
|
goodReply: labels.goodReply,
|
|
previewTitle: labels.previewTitle,
|
|
quoteReply: labels.quoteReply,
|
|
quotedReplyLabel: labels.quotedReplyLabel,
|
|
user: labels.user,
|
|
you: labels.you,
|
|
}}
|
|
expandedProgressByKey={expandedProgressByKey}
|
|
expandedUserByKey={expandedUserByKey}
|
|
deletingMessageIdMap={deletingMessageIdMap}
|
|
feedbackSavingByMessageId={feedbackSavingByMessageId}
|
|
markdownComponents={markdownComponents}
|
|
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
|
|
onToggleProgressExpand={onToggleProgressExpand}
|
|
onToggleUserExpand={onToggleUserExpand}
|
|
onEditUserPrompt={onEditUserPrompt}
|
|
onCopyUserPrompt={onCopyUserPrompt}
|
|
onDeleteConversationMessage={onDeleteConversationMessage}
|
|
onOpenWorkspacePath={onOpenWorkspacePath}
|
|
onSubmitAssistantFeedback={onSubmitAssistantFeedback}
|
|
onQuoteAssistantReply={onQuoteAssistantReply}
|
|
onCopyAssistantReply={onCopyAssistantReply}
|
|
/>
|
|
)}
|
|
|
|
{isThinking ? (
|
|
<div className="ops-chat-row is-assistant">
|
|
<div className="ops-chat-item is-assistant">
|
|
<div className="ops-avatar bot" title="Nanobot">
|
|
<img src={nanobotLogo} alt="Nanobot" />
|
|
</div>
|
|
<div className="ops-thinking-bubble">
|
|
<div className="ops-thinking-cloud">
|
|
<span className="dot" />
|
|
<span className="dot" />
|
|
<span className="dot" />
|
|
</div>
|
|
<div className="ops-thinking-text">{labels.thinking}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div />
|
|
</div>
|
|
);
|
|
}, (prev, next) => (
|
|
prev.conversation === next.conversation
|
|
&& prev.isZh === next.isZh
|
|
&& prev.isThinking === next.isThinking
|
|
&& prev.chatScrollRef === next.chatScrollRef
|
|
&& prev.expandedProgressByKey === next.expandedProgressByKey
|
|
&& prev.expandedUserByKey === next.expandedUserByKey
|
|
&& prev.deletingMessageIdMap === next.deletingMessageIdMap
|
|
&& prev.feedbackSavingByMessageId === next.feedbackSavingByMessageId
|
|
&& prev.markdownComponents === next.markdownComponents
|
|
&& prev.workspaceDownloadExtensionSet === next.workspaceDownloadExtensionSet
|
|
&& prev.labels.badReply === next.labels.badReply
|
|
&& prev.labels.copyPrompt === next.labels.copyPrompt
|
|
&& prev.labels.copyReply === next.labels.copyReply
|
|
&& prev.labels.deleteMessage === next.labels.deleteMessage
|
|
&& prev.labels.download === next.labels.download
|
|
&& prev.labels.editPrompt === next.labels.editPrompt
|
|
&& prev.labels.fileNotPreviewable === next.labels.fileNotPreviewable
|
|
&& prev.labels.goodReply === next.labels.goodReply
|
|
&& prev.labels.noConversation === next.labels.noConversation
|
|
&& prev.labels.previewTitle === next.labels.previewTitle
|
|
&& prev.labels.quoteReply === next.labels.quoteReply
|
|
&& prev.labels.quotedReplyLabel === next.labels.quotedReplyLabel
|
|
&& prev.labels.thinking === next.labels.thinking
|
|
&& prev.labels.user === next.labels.user
|
|
&& prev.labels.you === next.labels.you
|
|
));
|
|
|
|
export function DashboardChatPanel({
|
|
conversation,
|
|
isZh,
|
|
labels,
|
|
chatScrollRef,
|
|
onChatScroll,
|
|
expandedProgressByKey,
|
|
expandedUserByKey,
|
|
deletingMessageIdMap,
|
|
feedbackSavingByMessageId,
|
|
markdownComponents,
|
|
workspaceDownloadExtensionSet,
|
|
onToggleProgressExpand,
|
|
onToggleUserExpand,
|
|
onEditUserPrompt,
|
|
onCopyUserPrompt,
|
|
onDeleteConversationMessage,
|
|
onOpenWorkspacePath,
|
|
onSubmitAssistantFeedback,
|
|
onQuoteAssistantReply,
|
|
onCopyAssistantReply,
|
|
isThinking,
|
|
canChat,
|
|
isChatEnabled,
|
|
speechEnabled,
|
|
selectedBotEnabled,
|
|
selectedBotControlState,
|
|
quotedReply,
|
|
onClearQuotedReply,
|
|
stagedSubmissions,
|
|
onRestoreStagedSubmission,
|
|
onRemoveStagedSubmission,
|
|
pendingAttachments,
|
|
onRemovePendingAttachment,
|
|
attachmentUploadPercent,
|
|
isUploadingAttachments,
|
|
filePickerRef,
|
|
allowedAttachmentExtensions,
|
|
onPickAttachments,
|
|
controlCommandPanelOpen,
|
|
controlCommandPanelRef,
|
|
onToggleControlCommandPanel,
|
|
activeControlCommand,
|
|
canSendControlCommand,
|
|
isInterrupting,
|
|
onSendControlCommand,
|
|
onInterruptExecution,
|
|
chatDateTriggerRef,
|
|
hasSelectedBot,
|
|
chatDateJumping,
|
|
onToggleChatDatePicker,
|
|
chatDatePickerOpen,
|
|
chatDatePanelPosition,
|
|
chatDateValue,
|
|
onChatDateValueChange,
|
|
onCloseChatDatePicker,
|
|
onJumpConversationToDate,
|
|
command,
|
|
onCommandChange,
|
|
composerTextareaRef,
|
|
onComposerKeyDown,
|
|
isVoiceRecording,
|
|
isVoiceTranscribing,
|
|
isCompactMobile,
|
|
voiceCountdown,
|
|
onVoiceInput,
|
|
onTriggerPickAttachments,
|
|
submitActionMode,
|
|
onSubmitAction,
|
|
}: DashboardChatPanelProps) {
|
|
const showInterruptSubmitAction = submitActionMode === 'interrupt';
|
|
const hasComposerDraft = Boolean(String(command || '').trim()) || pendingAttachments.length > 0 || Boolean(quotedReply);
|
|
return (
|
|
<div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}>
|
|
<MemoizedChatTranscript
|
|
conversation={conversation}
|
|
isZh={isZh}
|
|
labels={labels}
|
|
chatScrollRef={chatScrollRef}
|
|
onChatScroll={onChatScroll}
|
|
expandedProgressByKey={expandedProgressByKey}
|
|
expandedUserByKey={expandedUserByKey}
|
|
deletingMessageIdMap={deletingMessageIdMap}
|
|
feedbackSavingByMessageId={feedbackSavingByMessageId}
|
|
markdownComponents={markdownComponents}
|
|
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
|
|
onToggleProgressExpand={onToggleProgressExpand}
|
|
onToggleUserExpand={onToggleUserExpand}
|
|
onEditUserPrompt={onEditUserPrompt}
|
|
onCopyUserPrompt={onCopyUserPrompt}
|
|
onDeleteConversationMessage={onDeleteConversationMessage}
|
|
onOpenWorkspacePath={onOpenWorkspacePath}
|
|
onSubmitAssistantFeedback={onSubmitAssistantFeedback}
|
|
onQuoteAssistantReply={onQuoteAssistantReply}
|
|
onCopyAssistantReply={onCopyAssistantReply}
|
|
isThinking={isThinking}
|
|
/>
|
|
|
|
<div className="ops-chat-dock">
|
|
{stagedSubmissions.length > 0 ? (
|
|
<div className="ops-staged-submission-queue" aria-live="polite">
|
|
{stagedSubmissions.map((stagedSubmission, index) => (
|
|
<div key={stagedSubmission.id} className="ops-staged-submission-item">
|
|
<span className="ops-staged-submission-index mono">{index + 1}</span>
|
|
<div className="ops-staged-submission-body">
|
|
<div className="ops-staged-submission-text">
|
|
{normalizeUserMessageText(stagedSubmission.command) || labels.stagedSubmissionEmpty}
|
|
</div>
|
|
{(stagedSubmission.quotedReply || stagedSubmission.attachments.length > 0) ? (
|
|
<div className="ops-staged-submission-meta">
|
|
{stagedSubmission.quotedReply ? (
|
|
<span className="ops-staged-submission-pill">{labels.quotedReplyLabel}</span>
|
|
) : null}
|
|
{stagedSubmission.attachments.length > 0 ? (
|
|
<span className="ops-staged-submission-pill">
|
|
{labels.stagedSubmissionAttachmentCount(stagedSubmission.attachments.length)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="ops-staged-submission-actions">
|
|
<button
|
|
type="button"
|
|
className="ops-staged-submission-icon-btn"
|
|
onClick={() => onRestoreStagedSubmission(stagedSubmission.id)}
|
|
aria-label={labels.stagedSubmissionRestore}
|
|
title={labels.stagedSubmissionRestore}
|
|
>
|
|
<Pencil size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ops-staged-submission-icon-btn"
|
|
onClick={() => onRemoveStagedSubmission(stagedSubmission.id)}
|
|
aria-label={labels.stagedSubmissionRemove}
|
|
title={labels.stagedSubmissionRemove}
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
{(quotedReply || pendingAttachments.length > 0) ? (
|
|
<div className="ops-chat-top-context">
|
|
{quotedReply ? (
|
|
<div className="ops-composer-quote" aria-live="polite">
|
|
<div className="ops-composer-quote-head">
|
|
<span>{labels.quotedReplyLabel}</span>
|
|
<button
|
|
type="button"
|
|
className="ops-chat-inline-action ops-no-tip-icon-btn"
|
|
onClick={onClearQuotedReply}
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
<div className="ops-composer-quote-text">{normalizeAssistantMessageText(quotedReply.text)}</div>
|
|
</div>
|
|
) : null}
|
|
{pendingAttachments.length > 0 ? (
|
|
<div className="ops-pending-files">
|
|
{pendingAttachments.map((path) => (
|
|
<span key={path} className="ops-pending-chip mono">
|
|
{(() => {
|
|
const filePath = normalizeDashboardAttachmentPath(path);
|
|
const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet);
|
|
const filename = filePath.split('/').pop() || filePath;
|
|
return (
|
|
<a
|
|
className="ops-attach-link mono ops-pending-open"
|
|
href="#"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
void onOpenWorkspacePath(filePath);
|
|
}}
|
|
>
|
|
{fileAction === 'download' ? (
|
|
<Download size={12} className="ops-attach-link-icon" />
|
|
) : fileAction === 'preview' ? (
|
|
<Eye size={12} className="ops-attach-link-icon" />
|
|
) : (
|
|
<FileText size={12} className="ops-attach-link-icon" />
|
|
)}
|
|
<span className="ops-attach-link-name">{filename}</span>
|
|
</a>
|
|
);
|
|
})()}
|
|
<button
|
|
type="button"
|
|
className="ops-chat-inline-action ops-no-tip-icon-btn"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onRemovePendingAttachment(path);
|
|
}}
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
{isUploadingAttachments ? (
|
|
<div className="ops-upload-progress" aria-live="polite">
|
|
<div className={`ops-upload-progress-track ${attachmentUploadPercent === null ? 'is-indeterminate' : ''}`}>
|
|
<div
|
|
className="ops-upload-progress-fill"
|
|
style={{ width: `${Math.max(3, Number(attachmentUploadPercent ?? 24))}%` }}
|
|
/>
|
|
</div>
|
|
<span className="ops-upload-progress-text mono">
|
|
{attachmentUploadPercent === null
|
|
? labels.uploadingFile
|
|
: `${labels.uploadingFile} ${attachmentUploadPercent}%`}
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
<div className="ops-composer">
|
|
<input
|
|
ref={filePickerRef}
|
|
type="file"
|
|
multiple
|
|
accept={allowedAttachmentExtensions.length > 0 ? allowedAttachmentExtensions.join(',') : undefined}
|
|
onChange={onPickAttachments}
|
|
className="ops-hidden-file-input"
|
|
/>
|
|
<div className={`ops-composer-shell ${controlCommandPanelOpen ? 'is-command-open' : ''}`}>
|
|
<div className="ops-composer-float-controls" ref={controlCommandPanelRef}>
|
|
<div className={`ops-control-command-drawer ${controlCommandPanelOpen ? 'is-open' : ''}`}>
|
|
<button
|
|
type="button"
|
|
className="ops-control-command-chip"
|
|
disabled={!canSendControlCommand || Boolean(activeControlCommand) || isInterrupting}
|
|
onClick={() => void onSendControlCommand('/restart')}
|
|
aria-label="/restart"
|
|
title="/restart"
|
|
>
|
|
{activeControlCommand === '/restart' ? <RefreshCw size={11} className="animate-spin" /> : <RotateCcw size={11} />}
|
|
<span className="mono">/restart</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ops-control-command-chip"
|
|
disabled={!canSendControlCommand || Boolean(activeControlCommand) || isInterrupting}
|
|
onClick={() => void onSendControlCommand('/new')}
|
|
aria-label="/new"
|
|
title="/new"
|
|
>
|
|
{activeControlCommand === '/new' ? <RefreshCw size={11} className="animate-spin" /> : <Plus size={11} />}
|
|
<span className="mono">/new</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ops-control-command-chip"
|
|
disabled={!hasSelectedBot || !canChat || Boolean(activeControlCommand) || isInterrupting}
|
|
onClick={() => void onInterruptExecution()}
|
|
aria-label="/stop"
|
|
title="/stop"
|
|
>
|
|
{isInterrupting ? <RefreshCw size={11} className="animate-spin" /> : <Square size={11} />}
|
|
<span className="mono">/stop</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ops-control-command-chip"
|
|
ref={chatDateTriggerRef}
|
|
disabled={!hasSelectedBot || chatDateJumping}
|
|
onClick={onToggleChatDatePicker}
|
|
aria-label={isZh ? '按日期定位对话' : 'Jump to date'}
|
|
title={isZh ? '按日期定位对话' : 'Jump to date'}
|
|
>
|
|
{chatDateJumping ? <RefreshCw size={11} className="animate-spin" /> : <Clock3 size={11} />}
|
|
<span className="mono">/time</span>
|
|
</button>
|
|
</div>
|
|
{chatDatePickerOpen ? (
|
|
<div
|
|
className="ops-control-date-panel"
|
|
style={chatDatePanelPosition ? { bottom: chatDatePanelPosition.bottom, right: chatDatePanelPosition.right } : undefined}
|
|
>
|
|
<label className="ops-control-date-label">
|
|
<span>{isZh ? '选择日期' : 'Select date'}</span>
|
|
<input
|
|
className="input ops-control-date-input"
|
|
type="date"
|
|
value={chatDateValue}
|
|
max={formatDateInputValue(Date.now())}
|
|
onChange={(event) => onChatDateValueChange(event.target.value)}
|
|
/>
|
|
</label>
|
|
<div className="ops-control-date-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary btn-sm"
|
|
onClick={onCloseChatDatePicker}
|
|
>
|
|
{isZh ? '取消' : 'Cancel'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sm"
|
|
disabled={chatDateJumping || !chatDateValue}
|
|
onClick={() => void onJumpConversationToDate()}
|
|
>
|
|
{chatDateJumping ? <RefreshCw size={14} className="animate-spin" /> : null}
|
|
<span className={chatDateJumping ? 'ops-control-date-submit-label' : undefined}>
|
|
{isZh ? '跳转' : 'Jump'}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className={`ops-control-command-toggle ${controlCommandPanelOpen ? 'is-open' : ''}`}
|
|
onClick={onToggleControlCommandPanel}
|
|
aria-label={controlCommandPanelOpen ? labels.controlCommandsHide : labels.controlCommandsShow}
|
|
title={controlCommandPanelOpen ? labels.controlCommandsHide : labels.controlCommandsShow}
|
|
>
|
|
{controlCommandPanelOpen ? <Command size={12} /> : <ChevronLeft size={13} />}
|
|
</button>
|
|
</div>
|
|
<textarea
|
|
ref={composerTextareaRef}
|
|
className="input ops-composer-input"
|
|
value={command}
|
|
onChange={(event) => onCommandChange(event.target.value)}
|
|
onKeyDown={onComposerKeyDown}
|
|
disabled={!canChat || isVoiceRecording || isVoiceTranscribing}
|
|
placeholder={canChat ? labels.inputPlaceholder : labels.disabledPlaceholder}
|
|
/>
|
|
<div className="ops-composer-tools-right">
|
|
{(isVoiceRecording || isVoiceTranscribing) ? (
|
|
<div className="ops-voice-inline" aria-live="polite">
|
|
<div className={`ops-voice-wave ${isVoiceRecording ? 'is-live' : ''} ${isCompactMobile ? 'is-mobile' : 'is-desktop'}`}>
|
|
{Array.from({ length: isCompactMobile ? 1 : 5 }).map((_, segmentIdx) => (
|
|
<div key={`vw-segment-${segmentIdx}`} className="ops-voice-wave-segment">
|
|
{Array.from({ length: isCompactMobile ? 28 : 18 }).map((_, idx) => {
|
|
const delayIndex = isCompactMobile ? idx : (segmentIdx * 18) + idx;
|
|
return (
|
|
<i
|
|
key={`vw-inline-${segmentIdx}-${idx}`}
|
|
style={{ animationDelay: `${(delayIndex % 14) * 0.06}s` }}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="ops-voice-countdown mono">
|
|
{isVoiceRecording ? `${voiceCountdown}s` : labels.voiceTranscribing}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<button
|
|
className={`ops-composer-inline-btn ${isVoiceRecording ? 'is-recording' : ''}`}
|
|
disabled={!canChat || !speechEnabled || isVoiceTranscribing}
|
|
onClick={() => void onVoiceInput()}
|
|
aria-label={isVoiceRecording ? labels.voiceStop : labels.voiceStart}
|
|
title={isVoiceTranscribing ? labels.voiceTranscribing : isVoiceRecording ? labels.voiceStop : labels.voiceStart}
|
|
>
|
|
{isVoiceTranscribing ? (
|
|
<RefreshCw size={16} className="animate-spin" />
|
|
) : isVoiceRecording ? (
|
|
<Square size={16} />
|
|
) : (
|
|
<Mic size={16} />
|
|
)}
|
|
</button>
|
|
<LucentIconButton
|
|
className="ops-composer-inline-btn"
|
|
disabled={!canChat || isUploadingAttachments || isVoiceRecording || isVoiceTranscribing}
|
|
onClick={() => void onTriggerPickAttachments()}
|
|
tooltip={isUploadingAttachments ? labels.uploadingFile : labels.uploadFile}
|
|
aria-label={isUploadingAttachments ? labels.uploadingFile : labels.uploadFile}
|
|
>
|
|
<Paperclip size={16} className={isUploadingAttachments ? 'animate-spin' : ''} />
|
|
</LucentIconButton>
|
|
<button
|
|
className={`ops-composer-submit-btn ${showInterruptSubmitAction ? 'is-interrupt' : ''}`}
|
|
disabled={
|
|
submitActionMode === 'interrupt'
|
|
? isInterrupting
|
|
: (
|
|
submitActionMode === 'stage'
|
|
? (
|
|
isVoiceRecording
|
|
|| isVoiceTranscribing
|
|
|| !hasComposerDraft
|
|
)
|
|
: (
|
|
!isChatEnabled
|
|
|| isVoiceRecording
|
|
|| isVoiceTranscribing
|
|
|| !hasComposerDraft
|
|
)
|
|
)
|
|
}
|
|
onClick={() => void onSubmitAction()}
|
|
aria-label={showInterruptSubmitAction ? labels.interrupt : labels.send}
|
|
title={showInterruptSubmitAction ? labels.interrupt : labels.send}
|
|
>
|
|
{showInterruptSubmitAction ? <Square size={15} /> : <ArrowUp size={18} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{!canChat ? (
|
|
<div className="ops-chat-disabled-mask">
|
|
<div className="ops-chat-disabled-card">
|
|
{selectedBotControlState === 'starting'
|
|
? labels.botStarting
|
|
: selectedBotControlState === 'stopping'
|
|
? labels.botStopping
|
|
: !selectedBotEnabled
|
|
? labels.botDisabledHint
|
|
: labels.chatDisabled}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|