341 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|