aura-web/src/pages/chat/components/ChatInput.tsx

274 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { ArrowUpOutlined, BookOutlined, CloseOutlined, DownOutlined, PaperClipOutlined } from '@ant-design/icons';
import { Button, Image as AntImage, Input, Select, Tag, Tooltip, Upload, Popover } from 'antd';
import type { ChatAttachment } from '../../../api';
import type { Agent } from '../../../api/agents';
import { HistoryIcon, NewChatIcon } from '../../../components/icons';
import { useState, useRef, useEffect } from 'react';
export default function ChatInput(props: {
input: string;
setInput: (v: string) => void;
sending: boolean;
attachments: ChatAttachment[];
setAttachments: (updater: (prev: ChatAttachment[]) => ChatAttachment[]) => ChatAttachment[];
imageUrls: string[];
setImageUrls: (updater: (prev: string[]) => string[]) => void;
onSend: () => void;
onStop: () => void;
onAttach: (files: File[]) => void;
onOpenTpl: () => void;
modelOptions: Array<{ value: string; label: string }>;
activeModelValue: string;
onChangeModel: (modelId: string) => void;
onOpenHistory: () => void;
onNewSession: () => void;
agentList: Agent[];
onInsertMention: (agentName: string) => void;
}) {
const {
input,
setInput,
sending,
attachments,
setAttachments,
imageUrls,
setImageUrls,
onSend,
onStop,
onAttach,
onOpenTpl,
modelOptions,
activeModelValue,
onChangeModel,
onOpenHistory,
onNewSession,
agentList,
onInsertMention
} = props;
const [showMentionPopover, setShowMentionPopover] = useState(false);
const [mentionQuery, setMentionQuery] = useState('');
const [mentionPos, setMentionPos] = useState<{ top: number; left: number } | null>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// 检测 @ 触发提及选择
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setInput(value);
const cursorPos = e.target.selectionStart ?? value.length;
const textBeforeCursor = value.slice(0, cursorPos);
const atIndex = textBeforeCursor.lastIndexOf('@');
if (atIndex !== -1 && (atIndex === 0 || /\s$/.test(textBeforeCursor.slice(0, atIndex)))) {
const query = textBeforeCursor.slice(atIndex + 1);
if (!query.includes(' ')) {
setMentionQuery(query);
// 计算位置
const textarea = e.target;
const rect = textarea.getBoundingClientRect();
setMentionPos({ top: rect.bottom, left: rect.left + 10 });
setShowMentionPopover(true);
return;
}
}
setShowMentionPopover(false);
};
const filteredAgents = agentList.filter(a =>
a.name.toLowerCase().includes(mentionQuery.toLowerCase())
);
const handleSelectAgent = (agent: Agent) => {
const textarea = inputRef.current;
if (!textarea) return;
const value = input;
const cursorPos = textarea.selectionStart ?? value.length;
const textBefore = value.slice(0, cursorPos);
const atIndex = textBefore.lastIndexOf('@');
if (atIndex === -1) {
setShowMentionPopover(false);
return;
}
const newValue = value.slice(0, atIndex) + `@${agent.name} ` + value.slice(cursorPos);
setInput(newValue);
setShowMentionPopover(false);
requestAnimationFrame(() => {
textarea.focus();
const newCursor = atIndex + agent.name.length + 2;
textarea.setSelectionRange(newCursor, newCursor);
});
};
return (
<div className="chat-input-wrapper">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
{attachments.map((a, i) => (
<Tag key={i} color="blue" closable style={{ borderRadius: 6, padding: '4px 8px' }} onClose={() => setAttachments((arr) => arr.filter((_, j) => j !== i))}>
📎 {a.name}
</Tag>
))}
{imageUrls.map((u, i) => (
<div key={i} style={{ position: 'relative' }}>
<AntImage src={u} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid var(--color-border)' }} />
<Button
size="small"
type="primary"
shape="circle"
icon={<CloseOutlined style={{ fontSize: 8 }} />}
style={{ position: 'absolute', top: -6, right: -6, width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setImageUrls((arr) => arr.filter((_, j) => j !== i))}
/>
</div>
))}
</div>
<div className="chat-input-card-wrap">
<div className="chat-input-actions">
<Tooltip title="历史记录">
<Button size="small" type="text" className="chat-input-action-btn" icon={<HistoryIcon />} onClick={onOpenHistory}>
</Button>
</Tooltip>
<Tooltip title="新增会话">
<Button size="small" type="text" className="chat-input-action-btn chat-input-action-btn-primary" icon={<NewChatIcon />} onClick={onNewSession}>
</Button>
</Tooltip>
</div>
<div className="chat-input-card">
<div className="chat-input-stack">
<Input.TextArea
ref={inputRef}
value={input}
onChange={handleInputChange}
placeholder="问我任何问题... 输入 @ 可 @其他智能体"
autoSize={{ minRows: 3, maxRows: 10 }}
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
if ((e as any).isComposing) return;
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart ?? input.length;
const end = el.selectionEnd ?? input.length;
const next = input.slice(0, start) + '\n' + input.slice(end);
setInput(next);
requestAnimationFrame(() => {
el.selectionStart = el.selectionEnd = start + 1;
});
return;
}
if (!e.shiftKey && !e.altKey) {
e.preventDefault();
onSend();
}
}}
className="chat-input-textarea"
disabled={sending}
/>
{showMentionPopover && mentionPos && (
<div
className="mention-popover"
style={{
position: 'fixed',
top: mentionPos.top,
left: mentionPos.left,
zIndex: 10000,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 6,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
maxHeight: 200,
overflowY: 'auto',
minWidth: 150
}}
>
{filteredAgents.length === 0 ? (
<div style={{ padding: 8, color: 'var(--color-text-tertiary)' }}>
</div>
) : (
filteredAgents.map(agent => (
<div
key={agent.id}
className="mention-item"
onClick={() => handleSelectAgent(agent)}
style={{
padding: '6px 10px',
cursor: 'pointer',
borderBottom: '1px solid var(--color-border)'
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--color-fill-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--color-text)' }}>
{agent.name}
</div>
{agent.description && (
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
{agent.description.slice(0, 30)}
{agent.description.length > 30 ? '...' : ''}
</div>
)}
</div>
))
)}
</div>
)}
<div className="chat-input-toolbar">
<div className="chat-input-toolbar-left">
<Select
value={activeModelValue || undefined}
className="chat-model-select"
popupMatchSelectWidth={false}
options={modelOptions}
suffixIcon={<DownOutlined className="chat-model-select-arrow" />}
placeholder="选择模型"
onChange={onChangeModel}
/>
<Upload
className="chat-upload"
multiple
beforeUpload={(_f, files) => {
onAttach(files as File[]);
return false;
}}
showUploadList={false}
accept=".txt,.md,.markdown,.json,.csv,.pdf,.docx,.html,.htm,image/png,image/jpeg,image/webp,image/gif"
>
<Button type="text" className="chat-tool-button" icon={<PaperClipOutlined style={{ fontSize: 18 }} />} />
</Upload>
<Button type="text" className="chat-tool-button" icon={<BookOutlined style={{ fontSize: 18 }} />} onClick={onOpenTpl} />
</div>
{sending ? (
<Button danger shape="circle" onClick={onStop} icon={<span className="chat-stop-icon" />} className="chat-send-button" />
) : (
<Button
type="primary"
shape="circle"
onClick={onSend}
icon={<ArrowUpOutlined />}
disabled={!input.trim()}
className="chat-send-button chat-send-button-primary"
/>
)}
</div>
</div>
</div>
</div>
<div className="chat-disclaimer">AI </div>
</div>
);
}