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

341 lines
13 KiB
TypeScript

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 { TextAreaRef } from 'antd/es/input/TextArea';
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[]) => void;
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;
showActions?: boolean;
}) {
const {
input,
setInput,
sending,
attachments,
setAttachments,
imageUrls,
setImageUrls,
onSend,
onStop,
onAttach,
onOpenTpl,
modelOptions,
activeModelValue,
onChangeModel,
onOpenHistory,
onNewSession,
agentList,
onInsertMention,
showActions = true
} = props;
const [showMentionPopover, setShowMentionPopover] = useState(false);
const [mentionQuery, setMentionQuery] = useState('');
const [mentionPos, setMentionPos] = useState<{ top: number; left: number } | null>(null);
const inputRef = useRef<TextAreaRef>(null);
const getCaretPos = (textarea: HTMLTextAreaElement, caretIndex: number) => {
const rect = textarea.getBoundingClientRect();
const cs = window.getComputedStyle(textarea);
const div = document.createElement('div');
div.style.position = 'absolute';
div.style.visibility = 'hidden';
div.style.top = '0';
div.style.left = '-9999px';
div.style.whiteSpace = 'pre-wrap';
div.style.wordWrap = 'break-word';
div.style.overflow = 'hidden';
div.style.boxSizing = cs.boxSizing;
div.style.width = rect.width + 'px';
div.style.fontFamily = cs.fontFamily;
div.style.fontSize = cs.fontSize;
div.style.fontWeight = cs.fontWeight;
div.style.fontStyle = cs.fontStyle;
div.style.letterSpacing = cs.letterSpacing;
div.style.textTransform = cs.textTransform;
div.style.lineHeight = cs.lineHeight;
div.style.padding = cs.padding;
div.style.border = cs.border;
div.style.tabSize = (cs as any).tabSize || '8';
const before = textarea.value.slice(0, caretIndex).replace(/ /g, '\u00a0');
div.textContent = before;
const span = document.createElement('span');
span.textContent = '\u200b';
div.appendChild(span);
document.body.appendChild(div);
const divRect = div.getBoundingClientRect();
const spanRect = span.getBoundingClientRect();
document.body.removeChild(div);
const lineHeight = Number.parseFloat(cs.lineHeight) || Number.parseFloat(cs.fontSize) * 1.2;
return {
top: rect.top + (spanRect.top - divRect.top) - textarea.scrollTop,
left: rect.left + (spanRect.left - divRect.left) - textarea.scrollLeft,
lineHeight
};
};
// 检测 @ 触发提及选择
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('@');
console.log('[@mention] input change:', {
value,
cursorPos,
textBeforeCursor,
atIndex,
agentListLength: agentList.length,
agentList: agentList.map(a => ({ id: a.id, name: a.name }))
});
if (atIndex !== -1 && (atIndex === 0 || /\s$/.test(textBeforeCursor.slice(0, atIndex)))) {
const query = textBeforeCursor.slice(atIndex + 1);
console.log('[@mention] matched @ at position:', { atIndex, query });
if (!query.includes(' ')) {
setMentionQuery(query);
// 计算位置 - Ant Design Input.TextArea 需要从 resizableTextArea 获取实际 DOM
requestAnimationFrame(() => {
const textarea = inputRef.current?.resizableTextArea?.textArea;
if (!textarea) {
console.log('[@mention] cannot get textarea DOM from inputRef:', inputRef.current);
return;
}
const caret = getCaretPos(textarea, cursorPos);
const top = Math.min(window.innerHeight - 8, caret.top + caret.lineHeight + 8);
const left = Math.min(window.innerWidth - 160, Math.max(8, caret.left));
setMentionPos({ top, left });
console.log('[@mention] show popover:', { top, left, query });
setShowMentionPopover(true);
});
return;
}
}
setShowMentionPopover(false);
};
const filteredAgents = agentList.filter(a =>
a.name.toLowerCase().includes(mentionQuery.toLowerCase())
);
console.log('[@mention] filtered:', { mentionQuery, count: filteredAgents.length, agents: filteredAgents.map(a => a.name) });
const handleSelectAgent = (agent: Agent) => {
const textarea = inputRef.current?.resizableTextArea?.textArea;
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">
{showActions && <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>
);
}