diff --git a/packages/admin-client/src/features/assessment-config/infrastructure/assessment-config.api.ts b/packages/admin-client/src/features/assessment-config/infrastructure/assessment-config.api.ts index 385b8c3..eb6005d 100644 --- a/packages/admin-client/src/features/assessment-config/infrastructure/assessment-config.api.ts +++ b/packages/admin-client/src/features/assessment-config/infrastructure/assessment-config.api.ts @@ -46,7 +46,7 @@ export interface UpdateDirectiveDto { export interface ChatMessage { role: 'user' | 'assistant'; - content: string; + content: string | import('../../../shared/hooks/useImagePaste').ContentBlock[]; } export interface ChatResponse { diff --git a/packages/admin-client/src/features/assessment-config/presentation/components/DirectiveChatDrawer.tsx b/packages/admin-client/src/features/assessment-config/presentation/components/DirectiveChatDrawer.tsx index 9ea1e93..ead4f85 100644 --- a/packages/admin-client/src/features/assessment-config/presentation/components/DirectiveChatDrawer.tsx +++ b/packages/admin-client/src/features/assessment-config/presentation/components/DirectiveChatDrawer.tsx @@ -1,9 +1,10 @@ import { useState, useEffect, useRef } from 'react'; 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 remarkGfm from 'remark-gfm'; import { useDirectiveChat } from '../../application/useAssessmentConfig'; +import { useImagePaste } from '../../../../shared/hooks/useImagePaste'; import type { ChatMessage } from '../../infrastructure/assessment-config.api'; interface DirectiveChatDrawerProps { @@ -16,6 +17,7 @@ export function DirectiveChatDrawer({ open, onClose }: DirectiveChatDrawerProps) const [inputValue, setInputValue] = useState(''); const chatMutation = useDirectiveChat(); const messagesEndRef = useRef(null); + const { pendingImages, handlePaste, removePendingImage, clearPendingImages, buildContent } = useImagePaste(); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); @@ -25,17 +27,20 @@ export function DirectiveChatDrawer({ open, onClose }: DirectiveChatDrawerProps) if (!open) { setMessages([]); setInputValue(''); + clearPendingImages(); } - }, [open]); + }, [open, clearPendingImages]); const handleSend = async () => { 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]; setMessages(newMessages); setInputValue(''); + clearPendingImages(); try { const result = await chatMutation.mutateAsync(newMessages); @@ -99,11 +104,23 @@ export function DirectiveChatDrawer({ open, onClose }: DirectiveChatDrawerProps) {msg.role === 'assistant' ? (
- {msg.content} + {typeof msg.content === 'string' ? msg.content : ''}
) : ( -
{msg.content}
+
+ {typeof msg.content === 'string' ? msg.content : ( + <> + {msg.content.map((block, bi) => + block.type === 'image' ? ( + uploaded + ) : ( + {block.text} + ) + )} + + )} +
)} ))} @@ -125,6 +142,17 @@ export function DirectiveChatDrawer({ open, onClose }: DirectiveChatDrawerProps) {/* Input area */}
+ {pendingImages.length > 0 && ( +
+ {pendingImages.map((img, idx) => ( +
+ pending + removePendingImage(idx)} style={{ position: 'absolute', top: -6, right: -6, fontSize: 14, color: '#ff4d4f', cursor: 'pointer', background: '#fff', borderRadius: '50%' }} /> +
+ ))} + } color="blue" style={{ height: 22, marginTop: 14 }}>{pendingImages.length} 张图片 +
+ )} setInputValue(e.target.value)} @@ -134,7 +162,8 @@ export function DirectiveChatDrawer({ open, onClose }: DirectiveChatDrawerProps) handleSend(); } }} - placeholder="输入指令..." + onPaste={handlePaste} + placeholder="输入指令...(支持粘贴图片)" autoSize={{ minRows: 1, maxRows: 4 }} disabled={chatMutation.isPending} /> @@ -143,7 +172,7 @@ export function DirectiveChatDrawer({ open, onClose }: DirectiveChatDrawerProps) icon={} onClick={handleSend} loading={chatMutation.isPending} - disabled={!inputValue.trim()} + disabled={!inputValue.trim() && pendingImages.length === 0} className="mt-2" block > diff --git a/packages/admin-client/src/features/collection-config/infrastructure/collection-config.api.ts b/packages/admin-client/src/features/collection-config/infrastructure/collection-config.api.ts index b2e5118..b735414 100644 --- a/packages/admin-client/src/features/collection-config/infrastructure/collection-config.api.ts +++ b/packages/admin-client/src/features/collection-config/infrastructure/collection-config.api.ts @@ -46,7 +46,7 @@ export interface UpdateDirectiveDto { export interface ChatMessage { role: 'user' | 'assistant'; - content: string; + content: string | import('../../../shared/hooks/useImagePaste').ContentBlock[]; } export interface ChatResponse { diff --git a/packages/admin-client/src/features/collection-config/presentation/components/CollectionChatDrawer.tsx b/packages/admin-client/src/features/collection-config/presentation/components/CollectionChatDrawer.tsx index b367840..8d5f21a 100644 --- a/packages/admin-client/src/features/collection-config/presentation/components/CollectionChatDrawer.tsx +++ b/packages/admin-client/src/features/collection-config/presentation/components/CollectionChatDrawer.tsx @@ -1,9 +1,10 @@ import { useState, useEffect, useRef } from 'react'; 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 remarkGfm from 'remark-gfm'; import { useDirectiveChat } from '../../application/useCollectionConfig'; +import { useImagePaste } from '../../../../shared/hooks/useImagePaste'; import type { ChatMessage } from '../../infrastructure/collection-config.api'; interface CollectionChatDrawerProps { @@ -16,6 +17,7 @@ export function CollectionChatDrawer({ open, onClose }: CollectionChatDrawerProp const [inputValue, setInputValue] = useState(''); const chatMutation = useDirectiveChat(); const messagesEndRef = useRef(null); + const { pendingImages, handlePaste, removePendingImage, clearPendingImages, buildContent } = useImagePaste(); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); @@ -25,17 +27,20 @@ export function CollectionChatDrawer({ open, onClose }: CollectionChatDrawerProp if (!open) { setMessages([]); setInputValue(''); + clearPendingImages(); } - }, [open]); + }, [open, clearPendingImages]); const handleSend = async () => { 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]; setMessages(newMessages); setInputValue(''); + clearPendingImages(); try { const result = await chatMutation.mutateAsync(newMessages); @@ -99,11 +104,23 @@ export function CollectionChatDrawer({ open, onClose }: CollectionChatDrawerProp {msg.role === 'assistant' ? (
- {msg.content} + {typeof msg.content === 'string' ? msg.content : ''}
) : ( -
{msg.content}
+
+ {typeof msg.content === 'string' ? msg.content : ( + <> + {msg.content.map((block, bi) => + block.type === 'image' ? ( + uploaded + ) : ( + {block.text} + ) + )} + + )} +
)}
))} @@ -125,6 +142,17 @@ export function CollectionChatDrawer({ open, onClose }: CollectionChatDrawerProp {/* Input area */}
+ {pendingImages.length > 0 && ( +
+ {pendingImages.map((img, idx) => ( +
+ pending + removePendingImage(idx)} style={{ position: 'absolute', top: -6, right: -6, fontSize: 14, color: '#ff4d4f', cursor: 'pointer', background: '#fff', borderRadius: '50%' }} /> +
+ ))} + } color="blue" style={{ height: 22, marginTop: 14 }}>{pendingImages.length} 张图片 +
+ )} setInputValue(e.target.value)} @@ -134,7 +162,8 @@ export function CollectionChatDrawer({ open, onClose }: CollectionChatDrawerProp handleSend(); } }} - placeholder="输入收集指令..." + onPaste={handlePaste} + placeholder="输入收集指令...(支持粘贴图片)" autoSize={{ minRows: 1, maxRows: 4 }} disabled={chatMutation.isPending} /> @@ -143,7 +172,7 @@ export function CollectionChatDrawer({ open, onClose }: CollectionChatDrawerProp icon={} onClick={handleSend} loading={chatMutation.isPending} - disabled={!inputValue.trim()} + disabled={!inputValue.trim() && pendingImages.length === 0} className="mt-2" block > diff --git a/packages/admin-client/src/features/supervisor/infrastructure/supervisor.api.ts b/packages/admin-client/src/features/supervisor/infrastructure/supervisor.api.ts index 98861cb..0733222 100644 --- a/packages/admin-client/src/features/supervisor/infrastructure/supervisor.api.ts +++ b/packages/admin-client/src/features/supervisor/infrastructure/supervisor.api.ts @@ -1,8 +1,9 @@ import api from '../../../shared/utils/api'; +import type { ContentBlock } from '../../../shared/hooks/useImagePaste'; export interface ChatMessage { role: 'user' | 'assistant'; - content: string; + content: string | ContentBlock[]; } export interface SupervisorChatResponse { diff --git a/packages/admin-client/src/features/supervisor/presentation/pages/SupervisorPage.tsx b/packages/admin-client/src/features/supervisor/presentation/pages/SupervisorPage.tsx index 85b95f9..b198a6f 100644 --- a/packages/admin-client/src/features/supervisor/presentation/pages/SupervisorPage.tsx +++ b/packages/admin-client/src/features/supervisor/presentation/pages/SupervisorPage.tsx @@ -8,10 +8,13 @@ import { HeartOutlined, DollarOutlined, ClearOutlined, + CloseCircleOutlined, + PictureOutlined, } from '@ant-design/icons'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { useSupervisorChat } from '../../application/useSupervisor'; +import { useImagePaste } from '../../../../shared/hooks/useImagePaste'; import type { ChatMessage } from '../../infrastructure/supervisor.api'; const { Title, Text } = Typography; @@ -28,19 +31,22 @@ export function SupervisorPage() { const [inputValue, setInputValue] = useState(''); const chatMutation = useSupervisorChat(); const messagesEndRef = useRef(null); + const { pendingImages, handlePaste, removePendingImage, clearPendingImages, buildContent } = useImagePaste(); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); const handleSend = async (text?: string) => { - const content = (text || inputValue).trim(); - if (!content || chatMutation.isPending) return; + const rawText = (text || inputValue).trim(); + if ((!rawText && pendingImages.length === 0) || chatMutation.isPending) return; + const content = text ? text : buildContent(rawText); const userMsg: ChatMessage = { role: 'user', content }; const newMessages = [...messages, userMsg]; setMessages(newMessages); setInputValue(''); + clearPendingImages(); try { const result = await chatMutation.mutateAsync(newMessages); @@ -142,11 +148,28 @@ export function SupervisorPage() { {msg.role === 'assistant' ? (
- {msg.content} + {typeof msg.content === 'string' ? msg.content : ''}
) : ( -
{msg.content}
+
+ {typeof msg.content === 'string' ? msg.content : ( + <> + {msg.content.map((block, bi) => + block.type === 'image' ? ( + uploaded + ) : ( + {block.text} + ) + )} + + )} +
)}
))} @@ -168,6 +191,27 @@ export function SupervisorPage() { {/* Input */}
+ {/* Pending image previews */} + {pendingImages.length > 0 && ( +
+ {pendingImages.map((img, idx) => ( +
+ pending + removePendingImage(idx)} + style={{ position: 'absolute', top: -6, right: -6, fontSize: 16, color: '#ff4d4f', cursor: 'pointer', background: '#fff', borderRadius: '50%' }} + /> +
+ ))} + } color="blue" style={{ height: 24, marginTop: 20 }}> + {pendingImages.length} 张图片 + +
+ )} setInputValue(e.target.value)} @@ -177,7 +221,8 @@ export function SupervisorPage() { handleSend(); } }} - placeholder="输入您想了解的系统信息..." + onPaste={handlePaste} + placeholder="输入您想了解的系统信息...(支持粘贴图片)" autoSize={{ minRows: 1, maxRows: 4 }} disabled={chatMutation.isPending} /> @@ -204,7 +249,7 @@ export function SupervisorPage() { icon={} onClick={() => handleSend()} loading={chatMutation.isPending} - disabled={!inputValue.trim()} + disabled={!inputValue.trim() && pendingImages.length === 0} > 发送 diff --git a/packages/admin-client/src/shared/hooks/useImagePaste.ts b/packages/admin-client/src/shared/hooks/useImagePaste.ts new file mode 100644 index 0000000..910fd2c --- /dev/null +++ b/packages/admin-client/src/shared/hooks/useImagePaste.ts @@ -0,0 +1,78 @@ +import { useState, useCallback } from 'react'; + +export interface PendingImage { + base64: string; + mediaType: string; + preview: string; // data URL for 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([]); + + 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, + }; +} diff --git a/packages/services/conversation-service/src/adapters/inbound/admin-assessment-directive.controller.ts b/packages/services/conversation-service/src/adapters/inbound/admin-assessment-directive.controller.ts index 15cb20f..53019ff 100644 --- a/packages/services/conversation-service/src/adapters/inbound/admin-assessment-directive.controller.ts +++ b/packages/services/conversation-service/src/adapters/inbound/admin-assessment-directive.controller.ts @@ -90,7 +90,7 @@ export class AdminAssessmentDirectiveController { @HttpCode(HttpStatus.OK) async chat( @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); if (!this.chatService) { diff --git a/packages/services/conversation-service/src/adapters/inbound/admin-collection-directive.controller.ts b/packages/services/conversation-service/src/adapters/inbound/admin-collection-directive.controller.ts index cd1c7f1..d0463a8 100644 --- a/packages/services/conversation-service/src/adapters/inbound/admin-collection-directive.controller.ts +++ b/packages/services/conversation-service/src/adapters/inbound/admin-collection-directive.controller.ts @@ -82,7 +82,7 @@ export class AdminCollectionDirectiveController { @HttpCode(HttpStatus.OK) async chat( @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); if (!this.chatService) { diff --git a/packages/services/conversation-service/src/adapters/inbound/admin-supervisor.controller.ts b/packages/services/conversation-service/src/adapters/inbound/admin-supervisor.controller.ts index ce23f09..4aff4d7 100644 --- a/packages/services/conversation-service/src/adapters/inbound/admin-supervisor.controller.ts +++ b/packages/services/conversation-service/src/adapters/inbound/admin-supervisor.controller.ts @@ -46,7 +46,7 @@ export class AdminSupervisorController { @HttpCode(HttpStatus.OK) async chat( @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); diff --git a/packages/services/conversation-service/src/infrastructure/agents/admin/collection-directive-chat.service.ts b/packages/services/conversation-service/src/infrastructure/agents/admin/collection-directive-chat.service.ts index eda6b7f..61ccbc4 100644 --- a/packages/services/conversation-service/src/infrastructure/agents/admin/collection-directive-chat.service.ts +++ b/packages/services/conversation-service/src/infrastructure/agents/admin/collection-directive-chat.service.ts @@ -134,7 +134,7 @@ export class CollectionDirectiveChatService { ) {} async chat( - messages: Array<{ role: 'user' | 'assistant'; content: string }>, + messages: Array<{ role: 'user' | 'assistant'; content: Anthropic.MessageParam['content'] }>, adminId: string, tenantId: string | null, ): Promise { diff --git a/packages/services/conversation-service/src/infrastructure/agents/admin/directive-chat.service.ts b/packages/services/conversation-service/src/infrastructure/agents/admin/directive-chat.service.ts index 2061845..b55fbfc 100644 --- a/packages/services/conversation-service/src/infrastructure/agents/admin/directive-chat.service.ts +++ b/packages/services/conversation-service/src/infrastructure/agents/admin/directive-chat.service.ts @@ -134,7 +134,7 @@ export class DirectiveChatService { ) {} async chat( - messages: Array<{ role: 'user' | 'assistant'; content: string }>, + messages: Array<{ role: 'user' | 'assistant'; content: Anthropic.MessageParam['content'] }>, adminId: string, tenantId: string | null, ): Promise { diff --git a/packages/services/conversation-service/src/infrastructure/agents/admin/system-supervisor-chat.service.ts b/packages/services/conversation-service/src/infrastructure/agents/admin/system-supervisor-chat.service.ts index 53b3970..bde9f91 100644 --- a/packages/services/conversation-service/src/infrastructure/agents/admin/system-supervisor-chat.service.ts +++ b/packages/services/conversation-service/src/infrastructure/agents/admin/system-supervisor-chat.service.ts @@ -134,7 +134,7 @@ export class SystemSupervisorChatService { ) {} async chat( - messages: Array<{ role: 'user' | 'assistant'; content: string }>, + messages: Array<{ role: 'user' | 'assistant'; content: Anthropic.MessageParam['content'] }>, ): Promise { if (!this.anthropic) { return { reply: 'AI 服务不可用,请检查 Anthropic API 配置。' };