feat(admin): add multimodal image paste support to all admin chat interfaces
支持管理员在3个管理聊天界面(系统总监、评估指令、收集指令)中通过 粘贴板粘贴图片,实现与管理Agent的多模态对话。 **新增文件:** - `shared/hooks/useImagePaste.ts`: 共享 hook,处理剪贴板图片粘贴、 base64 转换、待发送图片管理、多模态内容块构建 **后端改动 (conversation-service):** - 3个管理聊天服务 (system-supervisor-chat, directive-chat, collection-directive-chat): chat() 方法参数类型从 `content: string` 改为 `content: Anthropic.MessageParam['content']`,支持接收图片块 - 3个管理控制器 (admin-supervisor, admin-assessment-directive, admin-collection-directive): DTO content 类型改为 `any` 以透传 前端发送的多模态内容 **前端改动 (admin-client):** - 3个 API 类型文件: ChatMessage.content 类型扩展为 `string | ContentBlock[]` - SupervisorPage: 集成 useImagePaste hook,添加 onPaste 处理、 待发送图片预览(64x64 缩略图+删除按钮)、消息中图片渲染 - DirectiveChatDrawer: 同上,48x48 缩略图适配 Drawer 宽度 - CollectionChatDrawer: 同上 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3b6e1586b7
commit
1f6d473649
|
|
@ -46,7 +46,7 @@ export interface UpdateDirectiveDto {
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string | import('../../../shared/hooks/useImagePaste').ContentBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatResponse {
|
export interface ChatResponse {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Drawer, Button, Input, Tag, Spin } from 'antd';
|
import { Drawer, Button, Input, Tag, Spin } from 'antd';
|
||||||
import { RobotOutlined, SendOutlined } from '@ant-design/icons';
|
import { RobotOutlined, SendOutlined, CloseCircleOutlined, PictureOutlined } from '@ant-design/icons';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { useDirectiveChat } from '../../application/useAssessmentConfig';
|
import { useDirectiveChat } from '../../application/useAssessmentConfig';
|
||||||
|
import { useImagePaste } from '../../../../shared/hooks/useImagePaste';
|
||||||
import type { ChatMessage } from '../../infrastructure/assessment-config.api';
|
import type { ChatMessage } from '../../infrastructure/assessment-config.api';
|
||||||
|
|
||||||
interface DirectiveChatDrawerProps {
|
interface DirectiveChatDrawerProps {
|
||||||
|
|
@ -16,6 +17,7 @@ export function DirectiveChatDrawer({ open, onClose }: DirectiveChatDrawerProps)
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const chatMutation = useDirectiveChat();
|
const chatMutation = useDirectiveChat();
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { pendingImages, handlePaste, removePendingImage, clearPendingImages, buildContent } = useImagePaste();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
|
@ -25,17 +27,20 @@ export function DirectiveChatDrawer({ open, onClose }: DirectiveChatDrawerProps)
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
|
clearPendingImages();
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open, clearPendingImages]);
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
const text = inputValue.trim();
|
const text = inputValue.trim();
|
||||||
if (!text || chatMutation.isPending) return;
|
if ((!text && pendingImages.length === 0) || chatMutation.isPending) return;
|
||||||
|
|
||||||
const userMsg: ChatMessage = { role: 'user', content: text };
|
const content = buildContent(text);
|
||||||
|
const userMsg: ChatMessage = { role: 'user', content };
|
||||||
const newMessages = [...messages, userMsg];
|
const newMessages = [...messages, userMsg];
|
||||||
setMessages(newMessages);
|
setMessages(newMessages);
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
|
clearPendingImages();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await chatMutation.mutateAsync(newMessages);
|
const result = await chatMutation.mutateAsync(newMessages);
|
||||||
|
|
@ -99,11 +104,23 @@ export function DirectiveChatDrawer({ open, onClose }: DirectiveChatDrawerProps)
|
||||||
{msg.role === 'assistant' ? (
|
{msg.role === 'assistant' ? (
|
||||||
<div className="supervisor-markdown">
|
<div className="supervisor-markdown">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
{msg.content}
|
{typeof msg.content === 'string' ? msg.content : ''}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="whitespace-pre-wrap">{msg.content}</div>
|
<div className="whitespace-pre-wrap">
|
||||||
|
{typeof msg.content === 'string' ? msg.content : (
|
||||||
|
<>
|
||||||
|
{msg.content.map((block, bi) =>
|
||||||
|
block.type === 'image' ? (
|
||||||
|
<img key={bi} src={`data:${block.source.media_type};base64,${block.source.data}`} alt="uploaded" style={{ maxWidth: 160, maxHeight: 160, borderRadius: 4, display: 'block', marginBottom: 4 }} />
|
||||||
|
) : (
|
||||||
|
<span key={bi}>{block.text}</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -125,6 +142,17 @@ export function DirectiveChatDrawer({ open, onClose }: DirectiveChatDrawerProps)
|
||||||
|
|
||||||
{/* Input area */}
|
{/* Input area */}
|
||||||
<div style={{ borderTop: '1px solid #f0f0f0', padding: 12 }}>
|
<div style={{ borderTop: '1px solid #f0f0f0', padding: 12 }}>
|
||||||
|
{pendingImages.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||||
|
{pendingImages.map((img, idx) => (
|
||||||
|
<div key={idx} style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
|
<img src={img.preview} alt="pending" style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 4, border: '1px solid #d9d9d9' }} />
|
||||||
|
<CloseCircleOutlined onClick={() => removePendingImage(idx)} style={{ position: 'absolute', top: -6, right: -6, fontSize: 14, color: '#ff4d4f', cursor: 'pointer', background: '#fff', borderRadius: '50%' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Tag icon={<PictureOutlined />} color="blue" style={{ height: 22, marginTop: 14 }}>{pendingImages.length} 张图片</Tag>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
|
@ -134,7 +162,8 @@ export function DirectiveChatDrawer({ open, onClose }: DirectiveChatDrawerProps)
|
||||||
handleSend();
|
handleSend();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="输入指令..."
|
onPaste={handlePaste}
|
||||||
|
placeholder="输入指令...(支持粘贴图片)"
|
||||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||||
disabled={chatMutation.isPending}
|
disabled={chatMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
@ -143,7 +172,7 @@ export function DirectiveChatDrawer({ open, onClose }: DirectiveChatDrawerProps)
|
||||||
icon={<SendOutlined />}
|
icon={<SendOutlined />}
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
loading={chatMutation.isPending}
|
loading={chatMutation.isPending}
|
||||||
disabled={!inputValue.trim()}
|
disabled={!inputValue.trim() && pendingImages.length === 0}
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
block
|
block
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export interface UpdateDirectiveDto {
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string | import('../../../shared/hooks/useImagePaste').ContentBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatResponse {
|
export interface ChatResponse {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Drawer, Button, Input, Tag, Spin } from 'antd';
|
import { Drawer, Button, Input, Tag, Spin } from 'antd';
|
||||||
import { RobotOutlined, SendOutlined } from '@ant-design/icons';
|
import { RobotOutlined, SendOutlined, CloseCircleOutlined, PictureOutlined } from '@ant-design/icons';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { useDirectiveChat } from '../../application/useCollectionConfig';
|
import { useDirectiveChat } from '../../application/useCollectionConfig';
|
||||||
|
import { useImagePaste } from '../../../../shared/hooks/useImagePaste';
|
||||||
import type { ChatMessage } from '../../infrastructure/collection-config.api';
|
import type { ChatMessage } from '../../infrastructure/collection-config.api';
|
||||||
|
|
||||||
interface CollectionChatDrawerProps {
|
interface CollectionChatDrawerProps {
|
||||||
|
|
@ -16,6 +17,7 @@ export function CollectionChatDrawer({ open, onClose }: CollectionChatDrawerProp
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const chatMutation = useDirectiveChat();
|
const chatMutation = useDirectiveChat();
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { pendingImages, handlePaste, removePendingImage, clearPendingImages, buildContent } = useImagePaste();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
|
@ -25,17 +27,20 @@ export function CollectionChatDrawer({ open, onClose }: CollectionChatDrawerProp
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
|
clearPendingImages();
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open, clearPendingImages]);
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
const text = inputValue.trim();
|
const text = inputValue.trim();
|
||||||
if (!text || chatMutation.isPending) return;
|
if ((!text && pendingImages.length === 0) || chatMutation.isPending) return;
|
||||||
|
|
||||||
const userMsg: ChatMessage = { role: 'user', content: text };
|
const content = buildContent(text);
|
||||||
|
const userMsg: ChatMessage = { role: 'user', content };
|
||||||
const newMessages = [...messages, userMsg];
|
const newMessages = [...messages, userMsg];
|
||||||
setMessages(newMessages);
|
setMessages(newMessages);
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
|
clearPendingImages();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await chatMutation.mutateAsync(newMessages);
|
const result = await chatMutation.mutateAsync(newMessages);
|
||||||
|
|
@ -99,11 +104,23 @@ export function CollectionChatDrawer({ open, onClose }: CollectionChatDrawerProp
|
||||||
{msg.role === 'assistant' ? (
|
{msg.role === 'assistant' ? (
|
||||||
<div className="supervisor-markdown">
|
<div className="supervisor-markdown">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
{msg.content}
|
{typeof msg.content === 'string' ? msg.content : ''}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="whitespace-pre-wrap">{msg.content}</div>
|
<div className="whitespace-pre-wrap">
|
||||||
|
{typeof msg.content === 'string' ? msg.content : (
|
||||||
|
<>
|
||||||
|
{msg.content.map((block, bi) =>
|
||||||
|
block.type === 'image' ? (
|
||||||
|
<img key={bi} src={`data:${block.source.media_type};base64,${block.source.data}`} alt="uploaded" style={{ maxWidth: 160, maxHeight: 160, borderRadius: 4, display: 'block', marginBottom: 4 }} />
|
||||||
|
) : (
|
||||||
|
<span key={bi}>{block.text}</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -125,6 +142,17 @@ export function CollectionChatDrawer({ open, onClose }: CollectionChatDrawerProp
|
||||||
|
|
||||||
{/* Input area */}
|
{/* Input area */}
|
||||||
<div style={{ borderTop: '1px solid #f0f0f0', padding: 12 }}>
|
<div style={{ borderTop: '1px solid #f0f0f0', padding: 12 }}>
|
||||||
|
{pendingImages.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||||
|
{pendingImages.map((img, idx) => (
|
||||||
|
<div key={idx} style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
|
<img src={img.preview} alt="pending" style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 4, border: '1px solid #d9d9d9' }} />
|
||||||
|
<CloseCircleOutlined onClick={() => removePendingImage(idx)} style={{ position: 'absolute', top: -6, right: -6, fontSize: 14, color: '#ff4d4f', cursor: 'pointer', background: '#fff', borderRadius: '50%' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Tag icon={<PictureOutlined />} color="blue" style={{ height: 22, marginTop: 14 }}>{pendingImages.length} 张图片</Tag>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
|
@ -134,7 +162,8 @@ export function CollectionChatDrawer({ open, onClose }: CollectionChatDrawerProp
|
||||||
handleSend();
|
handleSend();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="输入收集指令..."
|
onPaste={handlePaste}
|
||||||
|
placeholder="输入收集指令...(支持粘贴图片)"
|
||||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||||
disabled={chatMutation.isPending}
|
disabled={chatMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
@ -143,7 +172,7 @@ export function CollectionChatDrawer({ open, onClose }: CollectionChatDrawerProp
|
||||||
icon={<SendOutlined />}
|
icon={<SendOutlined />}
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
loading={chatMutation.isPending}
|
loading={chatMutation.isPending}
|
||||||
disabled={!inputValue.trim()}
|
disabled={!inputValue.trim() && pendingImages.length === 0}
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
block
|
block
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import api from '../../../shared/utils/api';
|
import api from '../../../shared/utils/api';
|
||||||
|
import type { ContentBlock } from '../../../shared/hooks/useImagePaste';
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string | ContentBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SupervisorChatResponse {
|
export interface SupervisorChatResponse {
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,13 @@ import {
|
||||||
HeartOutlined,
|
HeartOutlined,
|
||||||
DollarOutlined,
|
DollarOutlined,
|
||||||
ClearOutlined,
|
ClearOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
PictureOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { useSupervisorChat } from '../../application/useSupervisor';
|
import { useSupervisorChat } from '../../application/useSupervisor';
|
||||||
|
import { useImagePaste } from '../../../../shared/hooks/useImagePaste';
|
||||||
import type { ChatMessage } from '../../infrastructure/supervisor.api';
|
import type { ChatMessage } from '../../infrastructure/supervisor.api';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
@ -28,19 +31,22 @@ export function SupervisorPage() {
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const chatMutation = useSupervisorChat();
|
const chatMutation = useSupervisorChat();
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { pendingImages, handlePaste, removePendingImage, clearPendingImages, buildContent } = useImagePaste();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
const handleSend = async (text?: string) => {
|
const handleSend = async (text?: string) => {
|
||||||
const content = (text || inputValue).trim();
|
const rawText = (text || inputValue).trim();
|
||||||
if (!content || chatMutation.isPending) return;
|
if ((!rawText && pendingImages.length === 0) || chatMutation.isPending) return;
|
||||||
|
|
||||||
|
const content = text ? text : buildContent(rawText);
|
||||||
const userMsg: ChatMessage = { role: 'user', content };
|
const userMsg: ChatMessage = { role: 'user', content };
|
||||||
const newMessages = [...messages, userMsg];
|
const newMessages = [...messages, userMsg];
|
||||||
setMessages(newMessages);
|
setMessages(newMessages);
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
|
clearPendingImages();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await chatMutation.mutateAsync(newMessages);
|
const result = await chatMutation.mutateAsync(newMessages);
|
||||||
|
|
@ -142,11 +148,28 @@ export function SupervisorPage() {
|
||||||
{msg.role === 'assistant' ? (
|
{msg.role === 'assistant' ? (
|
||||||
<div className="supervisor-markdown">
|
<div className="supervisor-markdown">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
{msg.content}
|
{typeof msg.content === 'string' ? msg.content : ''}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.7 }}>{msg.content}</div>
|
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.7 }}>
|
||||||
|
{typeof msg.content === 'string' ? msg.content : (
|
||||||
|
<>
|
||||||
|
{msg.content.map((block, bi) =>
|
||||||
|
block.type === 'image' ? (
|
||||||
|
<img
|
||||||
|
key={bi}
|
||||||
|
src={`data:${block.source.media_type};base64,${block.source.data}`}
|
||||||
|
alt="uploaded"
|
||||||
|
style={{ maxWidth: 200, maxHeight: 200, borderRadius: 4, display: 'block', marginBottom: 4 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span key={bi}>{block.text}</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -168,6 +191,27 @@ export function SupervisorPage() {
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<div style={{ borderTop: '1px solid #f0f0f0', padding: 12 }}>
|
<div style={{ borderTop: '1px solid #f0f0f0', padding: 12 }}>
|
||||||
|
{/* Pending image previews */}
|
||||||
|
{pendingImages.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||||
|
{pendingImages.map((img, idx) => (
|
||||||
|
<div key={idx} style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
|
<img
|
||||||
|
src={img.preview}
|
||||||
|
alt="pending"
|
||||||
|
style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 4, border: '1px solid #d9d9d9' }}
|
||||||
|
/>
|
||||||
|
<CloseCircleOutlined
|
||||||
|
onClick={() => removePendingImage(idx)}
|
||||||
|
style={{ position: 'absolute', top: -6, right: -6, fontSize: 16, color: '#ff4d4f', cursor: 'pointer', background: '#fff', borderRadius: '50%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Tag icon={<PictureOutlined />} color="blue" style={{ height: 24, marginTop: 20 }}>
|
||||||
|
{pendingImages.length} 张图片
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
|
@ -177,7 +221,8 @@ export function SupervisorPage() {
|
||||||
handleSend();
|
handleSend();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="输入您想了解的系统信息..."
|
onPaste={handlePaste}
|
||||||
|
placeholder="输入您想了解的系统信息...(支持粘贴图片)"
|
||||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||||
disabled={chatMutation.isPending}
|
disabled={chatMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
@ -204,7 +249,7 @@ export function SupervisorPage() {
|
||||||
icon={<SendOutlined />}
|
icon={<SendOutlined />}
|
||||||
onClick={() => handleSend()}
|
onClick={() => handleSend()}
|
||||||
loading={chatMutation.isPending}
|
loading={chatMutation.isPending}
|
||||||
disabled={!inputValue.trim()}
|
disabled={!inputValue.trim() && pendingImages.length === 0}
|
||||||
>
|
>
|
||||||
发送
|
发送
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface PendingImage {
|
||||||
|
base64: string;
|
||||||
|
mediaType: string;
|
||||||
|
preview: string; // data URL for <img> display
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentBlock =
|
||||||
|
| { type: 'text'; text: string }
|
||||||
|
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string } };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for handling clipboard image paste in admin chat interfaces.
|
||||||
|
* Manages pending images and builds multimodal content blocks for Claude API.
|
||||||
|
*/
|
||||||
|
export function useImagePaste() {
|
||||||
|
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||||
|
|
||||||
|
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (!file) continue;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const dataUrl = reader.result as string;
|
||||||
|
const base64 = dataUrl.split(',')[1];
|
||||||
|
const mediaType = file.type;
|
||||||
|
setPendingImages(prev => [...prev, { base64, mediaType, preview: dataUrl }]);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
break; // one image per paste
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removePendingImage = useCallback((index: number) => {
|
||||||
|
setPendingImages(prev => prev.filter((_, i) => i !== index));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearPendingImages = useCallback(() => {
|
||||||
|
setPendingImages([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build message content: plain string if text-only, array of blocks if images attached.
|
||||||
|
*/
|
||||||
|
const buildContent = useCallback((text: string): string | ContentBlock[] => {
|
||||||
|
if (pendingImages.length === 0) return text;
|
||||||
|
|
||||||
|
const blocks: ContentBlock[] = [];
|
||||||
|
for (const img of pendingImages) {
|
||||||
|
blocks.push({
|
||||||
|
type: 'image',
|
||||||
|
source: { type: 'base64', media_type: img.mediaType, data: img.base64 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
blocks.push({ type: 'text', text });
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}, [pendingImages]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pendingImages,
|
||||||
|
handlePaste,
|
||||||
|
removePendingImage,
|
||||||
|
clearPendingImages,
|
||||||
|
buildContent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -90,7 +90,7 @@ export class AdminAssessmentDirectiveController {
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async chat(
|
async chat(
|
||||||
@Headers('authorization') auth: string,
|
@Headers('authorization') auth: string,
|
||||||
@Body() dto: { messages: Array<{ role: 'user' | 'assistant'; content: string }> },
|
@Body() dto: { messages: Array<{ role: 'user' | 'assistant'; content: any }> },
|
||||||
) {
|
) {
|
||||||
const admin = this.verifyAdmin(auth);
|
const admin = this.verifyAdmin(auth);
|
||||||
if (!this.chatService) {
|
if (!this.chatService) {
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ export class AdminCollectionDirectiveController {
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async chat(
|
async chat(
|
||||||
@Headers('authorization') auth: string,
|
@Headers('authorization') auth: string,
|
||||||
@Body() dto: { messages: Array<{ role: 'user' | 'assistant'; content: string }> },
|
@Body() dto: { messages: Array<{ role: 'user' | 'assistant'; content: any }> },
|
||||||
) {
|
) {
|
||||||
const admin = this.verifyAdmin(auth);
|
const admin = this.verifyAdmin(auth);
|
||||||
if (!this.chatService) {
|
if (!this.chatService) {
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export class AdminSupervisorController {
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async chat(
|
async chat(
|
||||||
@Headers('authorization') auth: string,
|
@Headers('authorization') auth: string,
|
||||||
@Body() dto: { messages: Array<{ role: 'user' | 'assistant'; content: string }> },
|
@Body() dto: { messages: Array<{ role: 'user' | 'assistant'; content: any }> },
|
||||||
) {
|
) {
|
||||||
this.verifyAdmin(auth);
|
this.verifyAdmin(auth);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ export class CollectionDirectiveChatService {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async chat(
|
async chat(
|
||||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
|
messages: Array<{ role: 'user' | 'assistant'; content: Anthropic.MessageParam['content'] }>,
|
||||||
adminId: string,
|
adminId: string,
|
||||||
tenantId: string | null,
|
tenantId: string | null,
|
||||||
): Promise<CollectionChatResult> {
|
): Promise<CollectionChatResult> {
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ export class DirectiveChatService {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async chat(
|
async chat(
|
||||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
|
messages: Array<{ role: 'user' | 'assistant'; content: Anthropic.MessageParam['content'] }>,
|
||||||
adminId: string,
|
adminId: string,
|
||||||
tenantId: string | null,
|
tenantId: string | null,
|
||||||
): Promise<ChatResult> {
|
): Promise<ChatResult> {
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ export class SystemSupervisorChatService {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async chat(
|
async chat(
|
||||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
|
messages: Array<{ role: 'user' | 'assistant'; content: Anthropic.MessageParam['content'] }>,
|
||||||
): Promise<SupervisorChatResult> {
|
): Promise<SupervisorChatResult> {
|
||||||
if (!this.anthropic) {
|
if (!this.anthropic) {
|
||||||
return { reply: 'AI 服务不可用,请检查 Anthropic API 配置。' };
|
return { reply: 'AI 服务不可用,请检查 Anthropic API 配置。' };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue