diff --git a/src/components/AgentAssistantWidget.vue b/src/components/AgentAssistantWidget.vue index e87aeddd..317b1aa6 100644 --- a/src/components/AgentAssistantWidget.vue +++ b/src/components/AgentAssistantWidget.vue @@ -8,6 +8,7 @@ import { useAuthStore, useUserStore } from '@/stores' type AgentMessageRole = 'user' | 'assistant' type AgentMessageStatus = 'idle' | 'streaming' | 'done' | 'error' type AgentAttachmentKind = 'audio' | 'file' | 'image' +type AgentChoiceStatus = 'pending' | 'selected' | 'expired' interface AgentToolCall { id: string @@ -24,6 +25,21 @@ interface AgentMessageAttachment { size?: number } +interface AgentChoiceButton { + label: string + callback_data: string +} + +interface AgentChoiceCard { + id: string + title?: string + prompt: string + buttons: AgentChoiceButton[] + status: AgentChoiceStatus + selected_label?: string + selected_value?: string +} + interface AgentChatMessage { id: string role: AgentMessageRole @@ -32,16 +48,43 @@ interface AgentChatMessage { status: AgentMessageStatus tools: AgentToolCall[] attachments: AgentMessageAttachment[] + choices: AgentChoiceCard[] } interface AgentStreamEvent { - type: 'start' | 'delta' | 'tool' | 'attachment' | 'done' | 'error' + type: 'start' | 'delta' | 'tool' | 'attachment' | 'choice' | 'done' | 'error' attachment?: AgentMessageAttachment + choice?: Omit content?: string message?: string session_id?: string } +interface AgentPendingAttachment { + id: string + file: File + kind: AgentAttachmentKind + name: string + mime_type: string + size: number + preview_url?: string +} + +interface AgentOutgoingFile { + ref: string + name?: string + mime_type?: string + size?: number + local_path?: string + status?: string +} + +interface PreparedAgentAttachments { + images: string[] + files: AgentOutgoingFile[] + userAttachments: AgentMessageAttachment[] +} + const { t } = useI18n() const display = useDisplay() const authStore = useAuthStore() @@ -59,10 +102,12 @@ const sending = ref(false) const streamError = ref('') const messageListRef = ref(null) const inputRef = ref(null) +const fileInputRef = ref(null) +const pendingAttachments = ref([]) let abortController: AbortController | null = null const md = new MarkdownIt({ - html: false, + html: true, breaks: true, linkify: true, typographer: true, @@ -75,7 +120,9 @@ md.use(mdLinkAttributes, { }, }) -const canSend = computed(() => inputText.value.trim().length > 0 && !sending.value) +const canSend = computed( + () => (inputText.value.trim().length > 0 || pendingAttachments.value.length > 0) && !sending.value, +) // 窄屏下直接全屏,避免聊天内容被压成半屏窄栏。 const drawerWidth = computed(() => (display.mdAndDown.value ? '100vw' : '30rem')) const hasMessages = computed(() => messages.value.length > 0) @@ -99,6 +146,7 @@ function normalizeStoredMessages(value: unknown) { return value.slice(-MAX_PERSISTED_MESSAGES).map(message => ({ ...message, attachments: Array.isArray(message.attachments) ? message.attachments : [], + choices: Array.isArray(message.choices) ? message.choices : [], tools: Array.isArray(message.tools) ? message.tools : [], })) as AgentChatMessage[] } @@ -158,14 +206,20 @@ function syncInputHeight() { }) } -function addMessage(role: AgentMessageRole, content: string, status: AgentMessageStatus = 'idle') { +function addMessage( + role: AgentMessageRole, + content: string, + status: AgentMessageStatus = 'idle', + attachments: AgentMessageAttachment[] = [], +) { const message: AgentChatMessage = { id: createId(role), role, content, createdAt: Date.now(), status, - attachments: [], + attachments, + choices: [], tools: [], } messages.value.push(message) @@ -202,6 +256,14 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe assistantMessage.attachments.push(event.attachment) } break + case 'choice': + if (event.choice?.id) { + assistantMessage.choices.push({ + ...event.choice, + status: 'pending', + }) + } + break case 'done': if (assistantMessage.status !== 'error') { assistantMessage.status = 'done' @@ -279,7 +341,7 @@ function getAttachmentName(attachment: AgentMessageAttachment) { return attachment.name || (attachment.kind === 'image' ? 'image' : 'attachment') } -function getAttachmentIcon(attachment: AgentMessageAttachment) { +function getAttachmentIcon(attachment: { kind: AgentAttachmentKind }) { if (attachment.kind === 'audio') return 'mdi-volume-high' if (attachment.kind === 'image') return 'mdi-image-outline' return 'mdi-file-outline' @@ -292,6 +354,113 @@ function formatAttachmentSize(size?: number) { return `${(size / 1024 / 1024).toFixed(1)} MB` } +function getFileKind(file: File): AgentAttachmentKind { + if (file.type.startsWith('image/')) return 'image' + if (file.type.startsWith('audio/')) return 'audio' + return 'file' +} + +function openFilePicker() { + fileInputRef.value?.click() +} + +function handleFileSelection(event: Event) { + const input = event.target as HTMLInputElement + const files = Array.from(input.files || []) + const nextAttachments = files.map(file => { + const kind = getFileKind(file) + + return { + id: createId('attachment'), + file, + kind, + name: file.name, + mime_type: file.type || 'application/octet-stream', + size: file.size, + preview_url: kind === 'image' ? URL.createObjectURL(file) : undefined, + } + }) + + pendingAttachments.value.push(...nextAttachments) + input.value = '' +} + +function removePendingAttachment(id: string) { + const attachment = pendingAttachments.value.find(item => item.id === id) + if (attachment?.preview_url) URL.revokeObjectURL(attachment.preview_url) + pendingAttachments.value = pendingAttachments.value.filter(item => item.id !== id) +} + +function clearPendingAttachments() { + pendingAttachments.value.forEach(item => { + if (item.preview_url) URL.revokeObjectURL(item.preview_url) + }) + pendingAttachments.value = [] +} + +function readFileAsDataUrl(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result || '')) + reader.onerror = () => reject(reader.error || new Error(t('agentAssistant.uploadFailed'))) + reader.readAsDataURL(file) + }) +} + +async function uploadAgentAttachment(file: File) { + const formData = new FormData() + formData.append('file', file) + formData.append('session_id', sessionId.value) + + const response = await fetch(resolveApiUrl('message/agent/upload'), { + method: 'POST', + headers: { + ...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}), + }, + body: formData, + credentials: 'include', + }) + + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim()) + + const result = await response.json() + if (!result?.success) throw new Error(result?.message || t('agentAssistant.uploadFailed')) + + return result.data as AgentMessageAttachment & AgentOutgoingFile +} + +async function prepareAgentAttachments(items: AgentPendingAttachment[]): Promise { + const images: string[] = [] + const files: AgentOutgoingFile[] = [] + const userAttachments: AgentMessageAttachment[] = [] + + for (const item of items) { + const imageDataUrl = item.kind === 'image' ? await readFileAsDataUrl(item.file) : '' + const uploaded = await uploadAgentAttachment(item.file) + const displayAttachment: AgentMessageAttachment = { + kind: item.kind, + url: item.kind === 'image' ? imageDataUrl : uploaded.url, + download_url: uploaded.download_url || uploaded.url, + name: item.name, + mime_type: item.mime_type, + size: item.size, + } + + if (imageDataUrl) images.push(imageDataUrl) + files.push({ + ref: uploaded.ref || uploaded.url, + name: uploaded.name || item.name, + mime_type: uploaded.mime_type || item.mime_type, + size: uploaded.size || item.size, + local_path: uploaded.local_path, + status: uploaded.status || 'ready', + }) + userAttachments.push(displayAttachment) + } + + return { images, files, userAttachments } +} + function getVisibleViewportHeight() { if (typeof window === 'undefined') return '100dvh' @@ -305,18 +474,20 @@ function syncDrawerViewportHeight() { drawerViewportHeight.value = getVisibleViewportHeight() } -async function sendMessage() { - const text = inputText.value.trim() - if (!text || sending.value) return +async function streamAgentMessage( + text: string, + images: string[] = [], + files: AgentOutgoingFile[] = [], + userAttachments: AgentMessageAttachment[] = [], + echoUser = true, +) { + const content = text.trim() + if (!content && !images.length && !files.length) return - streamError.value = '' - inputText.value = '' - syncInputHeight() - addMessage('user', text, 'done') + if (echoUser) addMessage('user', content, 'done', userAttachments) const assistantMessage = addMessage('assistant', '', 'streaming') abortController = new AbortController() - sending.value = true try { const response = await fetch(resolveApiUrl('message/agent/stream'), { @@ -326,8 +497,10 @@ async function sendMessage() { ...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}), }, body: JSON.stringify({ - text, + text: content, session_id: sessionId.value, + images, + files, }), credentials: 'include', signal: abortController.signal, @@ -354,13 +527,74 @@ async function sendMessage() { streamError.value = assistantMessage.content markToolsDone(assistantMessage) } finally { - sending.value = false abortController = null persistState() scrollToBottom() } } +async function sendMessage() { + const text = inputText.value.trim() + const attachments = [...pendingAttachments.value] + if ((!text && !attachments.length) || sending.value) return + + streamError.value = '' + inputText.value = '' + clearPendingAttachments() + syncInputHeight() + sending.value = true + + try { + const prepared = await prepareAgentAttachments(attachments) + await streamAgentMessage(text, prepared.images, prepared.files, prepared.userAttachments) + } catch (error: any) { + streamError.value = error?.message || t('agentAssistant.uploadFailed') + addMessage('assistant', streamError.value, 'error') + } finally { + sending.value = false + } +} + +async function handleChoiceClick(choice: AgentChoiceCard, button: AgentChoiceButton) { + if (sending.value || choice.status !== 'pending') return + + sending.value = true + streamError.value = '' + + try { + const response = await fetch(resolveApiUrl('message/agent/callback'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}), + }, + body: JSON.stringify({ + session_id: sessionId.value, + callback_data: button.callback_data, + }), + credentials: 'include', + }) + + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim()) + + const result = await response.json() + if (!result?.success) throw new Error(result?.message || t('agentAssistant.choiceExpired')) + + choice.status = 'selected' + choice.selected_label = result.data?.feedback?.selected_label || button.label + choice.selected_value = result.data?.feedback?.selected_value || result.data?.message + persistState() + + await streamAgentMessage(String(result.data?.message || ''), [], [], [], false) + } catch (error: any) { + choice.status = 'expired' + streamError.value = error?.message || t('agentAssistant.choiceExpired') + persistState() + } finally { + sending.value = false + } +} + function stopGeneration() { abortController?.abort() } @@ -370,6 +604,7 @@ function startNewSession() { sessionId.value = createSessionId() messages.value = [] streamError.value = '' + clearPendingAttachments() persistState() } @@ -433,6 +668,7 @@ onMounted(() => { }) onScopeDispose(clearAgentAssistantOpenState) +onScopeDispose(clearPendingAttachments) onScopeDispose(() => { if (typeof window === 'undefined') return @@ -531,6 +767,35 @@ onScopeDispose(() => { v-html="renderMarkdown(message.content)" /> +
+
+
{{ choice.title }}
+
{{ choice.prompt }}
+
+ + {{ t('agentAssistant.choiceSelected', { option: choice.selected_label }) }} +
+
+ + {{ t('agentAssistant.choiceExpired') }} +
+
+ + {{ button.label }} + +
+
+
+
{
@@ -601,7 +871,48 @@ onScopeDispose(() => { {{ streamError }} +
+
+ + +
+ {{ attachment.name }} + {{ formatAttachmentSize(attachment.size) || attachment.mime_type }} +
+ + + +
+
+ + + +