274 lines
10 KiB
TypeScript
274 lines
10 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 { 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>
|
||
);
|
||
}
|