Codex
deploy: CodexMobile Relay
90f0300
Raw
History Blame Contribute Delete
10.7 kB
import { useEffect, useRef, useState } from 'react';
import { ArrowUp, Check, ChevronDown, FileText, Headphones, Image, Loader2, Mic, Paperclip, Plus, Square, Trash2 } from 'lucide-react';
import { formatBytes, permissionLabel, PERMISSION_OPTIONS, REASONING_OPTIONS, reasoningLabel, shortModelName } from '../app-core-utils.js';
import { useVoiceInputRecorder } from './useVoiceInputRecorder.js';
export function Composer({
input,
setInput,
onSubmit,
running,
onAbort,
models,
selectedModel,
onSelectModel,
selectedReasoningEffort,
onSelectReasoningEffort,
permissionMode,
onSelectPermission,
attachments,
onUploadFiles,
onRemoveAttachment,
onRateLimit,
uploading,
onVoiceSubmit,
onOpenVoiceDialog,
voiceDialogActive,
disabled,
disabledReason,
actionDisabledReasons = {},
backgroundInert = false
}) {
const textareaRef = useRef(null);
const imageInputRef = useRef(null);
const fileInputRef = useRef(null);
const [openMenu, setOpenMenu] = useState(null);
const hasInput = input.trim().length > 0 || attachments.length > 0;
const modelList = models?.length ? models : [{ value: selectedModel || 'gpt-5.5', label: selectedModel || 'gpt-5.5' }];
const selectedModelLabel = modelList.find((model) => model.value === selectedModel)?.label || selectedModel || 'gpt-5.5';
const {
voiceState,
voiceError,
voiceRecording,
voiceTranscribing,
voiceSending,
toggleVoiceInput
} = useVoiceInputRecorder({
onVoiceSubmit,
onRateLimit,
onBeforeStart: () => setOpenMenu(null)
});
const uploadDisabledReason = actionDisabledReasons.upload || disabledReason;
const sendDisabledReason = actionDisabledReasons.send || disabledReason;
const voiceDisabledReason = actionDisabledReasons.voice || disabledReason;
const voiceDialogDisabledReason = actionDisabledReasons.voiceDialog || disabledReason;
const inertProps = backgroundInert ? { inert: '' } : {};
const stopOnly = running && !hasInput;
const sendButtonDisabled = uploading || (stopOnly ? false : Boolean(sendDisabledReason) || !hasInput);
const actionNotice = [
sendDisabledReason && `发送:${sendDisabledReason}`,
uploadDisabledReason && `上传:${uploadDisabledReason}`,
voiceDisabledReason && `语音:${voiceDisabledReason}`,
voiceDialogDisabledReason && `对话:${voiceDialogDisabledReason}`
].filter(Boolean)[0] || '';
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
textarea.style.height = '0px';
textarea.style.height = `${Math.min(textarea.scrollHeight, 132)}px`;
}, [input]);
const submit = (event) => {
event.preventDefault();
submitMessage();
};
const submitMessage = () => {
if (running && !hasInput) {
onAbort();
return;
}
if (sendButtonDisabled) {
return;
}
onSubmit();
setOpenMenu(null);
};
const handleKeyDown = (event) => {
const composing = event.isComposing || event.nativeEvent?.isComposing;
const modified = event.shiftKey || event.altKey || event.metaKey || event.ctrlKey;
if (event.key !== 'Enter' || modified || composing) {
return;
}
event.preventDefault();
submitMessage();
};
function toggleMenu(name) {
setOpenMenu((current) => (current === name ? null : name));
}
function handleFiles(event, kind) {
const files = Array.from(event.target.files || []);
if (files.length) {
onUploadFiles(files, kind);
}
event.target.value = '';
setOpenMenu(null);
}
return (
<form className="composer-wrap" onSubmit={submit} {...inertProps}>
<input
ref={imageInputRef}
className="file-input"
type="file"
accept="image/*"
multiple
onChange={(event) => handleFiles(event, 'image')}
/>
<input
ref={fileInputRef}
className="file-input"
type="file"
multiple
onChange={(event) => handleFiles(event, 'file')}
/>
{openMenu === 'attach' ? (
<div className="composer-menu attach-menu">
<button type="button" onClick={() => imageInputRef.current?.click()} disabled={Boolean(uploadDisabledReason)}>
<Image size={17} />
相册
</button>
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={Boolean(uploadDisabledReason)}>
<FileText size={17} />
文件
</button>
</div>
) : null}
{openMenu === 'permission' ? (
<div className="composer-menu permission-menu">
{PERMISSION_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={`${permissionMode === option.value ? 'is-selected' : ''} ${option.danger ? 'is-danger' : ''}`}
onClick={() => {
onSelectPermission(option.value);
setOpenMenu(null);
}}
>
{permissionMode === option.value ? <Check size={16} /> : <span className="menu-spacer" />}
{option.label}
</button>
))}
</div>
) : null}
{openMenu === 'model' ? (
<div className="composer-menu model-menu">
<div className="menu-section-label">智能</div>
{REASONING_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={selectedReasoningEffort === option.value ? 'is-selected' : ''}
onClick={() => {
onSelectReasoningEffort(option.value);
setOpenMenu(null);
}}
>
{selectedReasoningEffort === option.value ? <Check size={16} /> : <span className="menu-spacer" />}
<span>{option.label}</span>
</button>
))}
<div className="menu-divider" />
<div className="menu-section-label">模型</div>
{modelList.map((model) => (
<button
key={model.value}
type="button"
className={selectedModel === model.value ? 'is-selected' : ''}
onClick={() => {
onSelectModel(model.value);
setOpenMenu(null);
}}
>
{selectedModel === model.value ? <Check size={16} /> : <span className="menu-spacer" />}
<span>{model.label}</span>
</button>
))}
</div>
) : null}
{voiceState !== 'idle' || voiceError ? (
<div className={`voice-popover ${voiceError ? 'is-error' : ''}`}>
<Mic size={14} />
<span>{voiceError || (voiceSending ? '正在发送...' : voiceTranscribing ? '正在转写...' : '正在录音...')}</span>
</div>
) : null}
{actionNotice ? (
<div className="composer-notice" role="status" aria-live="polite">
{actionNotice}
</div>
) : null}
<div className="composer">
{attachments.length ? (
<div className="attachment-tray">
{attachments.map((attachment) => (
<span key={attachment.id} className="attachment-chip">
<Paperclip size={14} />
<span>{attachment.name}</span>
<small>{formatBytes(attachment.size)}</small>
<button type="button" onClick={() => onRemoveAttachment(attachment.id)} aria-label="移除附件">
<Trash2 size={13} />
</button>
</span>
))}
</div>
) : null}
<textarea
ref={textareaRef}
rows={1}
value={input}
onChange={(event) => setInput(event.target.value)}
name="message"
autoComplete="off"
aria-label="消息"
placeholder={disabledReason || '给 Codex 发送消息...'}
disabled={disabled}
onKeyDown={handleKeyDown}
/>
<div className="composer-controls">
<div className="control-left">
<button
type="button"
className="ghost-icon"
aria-label="添加"
onClick={() => toggleMenu('attach')}
disabled={uploading || Boolean(uploadDisabledReason)}
title={uploadDisabledReason || undefined}
>
<Plus size={21} />
</button>
<button type="button" className="permission-pill" onClick={() => toggleMenu('permission')}>
{permissionLabel(permissionMode)}
<ChevronDown size={15} />
</button>
</div>
<div className="control-right">
<button type="button" className="model-select" onClick={() => toggleMenu('model')}>
{shortModelName(selectedModelLabel)} {reasoningLabel(selectedReasoningEffort)}
<ChevronDown size={15} />
</button>
<button
type="button"
className={`dialog-button ${voiceDialogActive ? 'is-active' : ''}`}
onClick={onOpenVoiceDialog}
disabled={Boolean(voiceDialogDisabledReason)}
title={voiceDialogDisabledReason || undefined}
aria-label="语音对话"
>
<Headphones size={16} />
<span>对话</span>
</button>
<button
type="button"
className={`voice-button ${voiceRecording ? 'is-recording' : ''} ${voiceTranscribing ? 'is-transcribing' : ''} ${voiceSending ? 'is-sending' : ''}`}
onClick={toggleVoiceInput}
disabled={voiceTranscribing || voiceSending || Boolean(voiceDisabledReason)}
title={voiceDisabledReason || undefined}
aria-label={voiceRecording ? '停止语音输入' : voiceSending ? '正在发送语音' : '开始语音输入'}
>
{voiceTranscribing || voiceSending ? <Loader2 className="spin" size={16} /> : <Mic size={17} />}
</button>
<button
type="submit"
className={`send-button ${running ? 'is-running' : ''}`}
disabled={sendButtonDisabled}
title={sendDisabledReason || undefined}
aria-label={running && !hasInput ? '停止生成' : uploading ? '正在上传' : '发送消息'}
>
{running && !hasInput ? <Square size={16} /> : uploading ? <Loader2 className="spin" size={16} /> : <ArrowUp size={19} />}
</button>
</div>
</div>
</div>
</form>
);
}