From fbf8dbbe86d2c72a669c4dd7320a0191318faee6 Mon Sep 17 00:00:00 2001 From: renjianbo <263303411@qq.com> Date: Sat, 4 Jul 2026 14:45:55 +0800 Subject: [PATCH] fix: Android null-safety Gson crash + AgentChat full UX overhaul (Round 1-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android: - Add NullSafeStringAdapter to GsonBuilder globally to prevent null String injection into non-nullable Kotlin fields - Add defensive null-safe mappings in AgentRepository and all UI Text() calls Frontend AgentChat (4 rounds): - Round 1: Stop generation button, server-side session history sync, smart scroll - Round 2: Full markdown rendering (marked), file/image upload with drag-drop, message edit/delete - Round 3: TTS speech (browser SpeechSynthesis), voice input (SpeechRecognition), empty state guidance, model/temperature selector - Round 4: Extract shared chat core — useMarkdown, useFileUpload, ChatMessageBubble, ChatInputArea modules — merge AgentChat.vue and AgentChatPreview.vue divergent code Co-Authored-By: Claude Opus 4.6 --- .../data/repository/AgentRepository.kt | 12 +- .../java/com/tiangong/aiagent/di/AppModule.kt | 24 + .../aiagent/ui/agents/AgentListScreen.kt | 7 +- .../tiangong/aiagent/ui/chat/ChatScreen.kt | 60 +- .../ui/chat/components/MessageBubble.kt | 4 +- .../ui/chat/components/StreamingText.kt | 45 +- .../aiagent/ui/chat/components/ThinkTrace.kt | 5 +- .../aiagent/ui/market/MarketScreen.kt | 8 +- .../aiagent/ui/memory/MemoryManageScreen.kt | 2 +- frontend/src/components/AgentChatPreview.vue | 954 +++--------------- frontend/src/components/ChatInputArea.vue | 167 +++ frontend/src/components/ChatMessageBubble.vue | 205 ++++ frontend/src/composables/useFileUpload.ts | 174 ++++ frontend/src/composables/useMarkdown.ts | 37 + frontend/src/views/AgentChat.vue | 570 ++++++++--- 15 files changed, 1280 insertions(+), 994 deletions(-) create mode 100644 frontend/src/components/ChatInputArea.vue create mode 100644 frontend/src/components/ChatMessageBubble.vue create mode 100644 frontend/src/composables/useFileUpload.ts create mode 100644 frontend/src/composables/useMarkdown.ts diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/repository/AgentRepository.kt b/android/app/src/main/java/com/tiangong/aiagent/data/repository/AgentRepository.kt index 5b49233..5c968dd 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/repository/AgentRepository.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/repository/AgentRepository.kt @@ -23,9 +23,9 @@ class AgentRepository @Inject constructor( val agents = response.body()?.map { dto -> Agent( id = dto.id, - name = dto.name, + name = dto.name ?: "", description = dto.description ?: "", - status = dto.status, + status = dto.status ?: "published", userId = dto.userId, version = dto.version, createdAt = dto.createdAt, @@ -48,9 +48,9 @@ class AgentRepository @Inject constructor( Result.success( Agent( id = dto.id, - name = dto.name, + name = dto.name ?: "", description = dto.description ?: "", - status = dto.status, + status = dto.status ?: "published", userId = dto.userId, version = dto.version, createdAt = dto.createdAt, @@ -148,9 +148,9 @@ class AgentRepository @Inject constructor( private fun com.tiangong.aiagent.data.remote.dto.AgentResponse.toDomain() = Agent( id = id, - name = name, + name = name ?: "", description = description ?: "", - status = status, + status = status ?: "published", userId = userId, version = version, createdAt = createdAt, diff --git a/android/app/src/main/java/com/tiangong/aiagent/di/AppModule.kt b/android/app/src/main/java/com/tiangong/aiagent/di/AppModule.kt index e4145a3..4b4f415 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/di/AppModule.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/di/AppModule.kt @@ -6,6 +6,10 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.google.gson.Gson import com.google.gson.GsonBuilder +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.JsonWriter import com.tiangong.aiagent.BuildConfig import com.tiangong.aiagent.data.local.AppDatabase import com.tiangong.aiagent.data.local.CredentialStore @@ -33,9 +37,29 @@ object AppModule { @Singleton fun provideGson(): Gson { return GsonBuilder() + .registerTypeAdapter(String::class.java, NullSafeStringAdapter()) .create() } + /** + * Prevents Gson from injecting null into Kotlin non-nullable String fields. + * Gson's UnsafeAllocator bypasses Kotlin constructors, so null strings from + * JSON can crash Compose Text() with NullPointerException in SpannableString. + */ + private class NullSafeStringAdapter : TypeAdapter() { + override fun write(out: JsonWriter, value: String?) { + out.value(value ?: "") + } + + override fun read(reader: JsonReader): String { + if (reader.peek() == JsonToken.NULL) { + reader.nextNull() + return "" + } + return reader.nextString() + } + } + @Provides @Singleton fun provideLoggingInterceptor(): HttpLoggingInterceptor { diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/agents/AgentListScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/agents/AgentListScreen.kt index 541a989..a198bee 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/agents/AgentListScreen.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/agents/AgentListScreen.kt @@ -262,7 +262,7 @@ fun AgentListItem( ) { Box(contentAlignment = Alignment.Center) { Text( - text = agent.name.take(1), + text = (agent.name ?: "AI").take(1), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimaryContainer @@ -275,7 +275,7 @@ fun AgentListItem( Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = agent.name, + text = agent.name ?: "未知", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold ) @@ -293,6 +293,7 @@ fun AgentListItem( "running" -> "运行中" "draft" -> "草稿" "stopped" -> "已停止" + null -> "未知" else -> agent.status }, modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), @@ -300,7 +301,7 @@ fun AgentListItem( ) } } - if (agent.description.isNotEmpty()) { + if (!agent.description.isNullOrEmpty()) { Spacer(modifier = Modifier.height(4.dp)) Text( text = agent.description, diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt index 560e5b1..b70ffae 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatScreen.kt @@ -5,6 +5,8 @@ import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -19,7 +21,9 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -110,6 +114,10 @@ fun ChatScreen( var lastLoadMoreTime by remember { mutableStateOf(0L) } + // Detect if user has scrolled away from the bottom (for "scroll to bottom" FAB) + val canScrollForward by remember { derivedStateOf { listState.canScrollForward } } + val showScrollToBottom = canScrollForward && uiState.messages.isNotEmpty() + LaunchedEffect(isAtTop) { val now = System.currentTimeMillis() if (isAtTop && uiState.hasMoreMessages && !uiState.isLoadingMore && initialScrollDone @@ -126,6 +134,7 @@ fun ChatScreen( } val context = androidx.compose.ui.platform.LocalContext.current + val haptic = LocalHapticFeedback.current // Image picker (multi-select, max 9) val imagePickerLauncher = rememberLauncherForActivityResult( @@ -197,6 +206,26 @@ fun ChatScreen( } ) { Scaffold( + floatingActionButton = { + if (showScrollToBottom) { + FloatingActionButton( + onClick = { + scope.launch { + listState.animateScrollToItem(uiState.messages.size - 1) + } + }, + modifier = Modifier.size(40.dp), + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary + ) { + Icon( + Icons.Default.KeyboardArrowDown, + contentDescription = "回到底部", + modifier = Modifier.size(24.dp) + ) + } + } + }, topBar = { TopAppBar( navigationIcon = { @@ -247,7 +276,10 @@ fun ChatScreen( horizontalArrangement = Arrangement.Center ) { FilledTonalButton( - onClick = { viewModel.stopGeneration() }, + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.stopGeneration() + }, colors = ButtonDefaults.filledTonalButtonColors( containerColor = MaterialTheme.colorScheme.errorContainer ) @@ -327,9 +359,19 @@ fun ChatScreen( } } + val sendEnabled = inputText.isNotBlank() && !uiState.isLoading && !uiState.isStreaming && !uiState.isOffline + val sendColor by animateColorAsState( + targetValue = if (sendEnabled) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurfaceVariant, + animationSpec = tween(200), + label = "sendColor" + ) IconButton( onClick = { if (inputText.isNotBlank()) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) val text = inputText inputText = "" if (uiState.editingMessageId != null) { @@ -339,14 +381,12 @@ fun ChatScreen( } } }, - enabled = inputText.isNotBlank() && !uiState.isLoading && !uiState.isStreaming && !uiState.isOffline + enabled = sendEnabled ) { Icon( imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = "发送", - tint = if (inputText.isNotBlank() && !uiState.isLoading && !uiState.isStreaming && !uiState.isOffline) - MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant + tint = sendColor ) } } @@ -472,7 +512,10 @@ fun ChatScreen( ) } FilledTonalButton( - onClick = { viewModel.approveToolCall() }, + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.approveToolCall() + }, modifier = Modifier.padding(end = 8.dp) ) { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) @@ -480,7 +523,10 @@ fun ChatScreen( Text("允许") } OutlinedButton( - onClick = { viewModel.rejectToolCall() } + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.rejectToolCall() + } ) { Icon(Icons.Default.Close, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(4.dp)) diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/MessageBubble.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/MessageBubble.kt index f6c3ed4..4569f91 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/MessageBubble.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/MessageBubble.kt @@ -1,5 +1,6 @@ package com.tiangong.aiagent.ui.chat.components +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -35,7 +36,8 @@ fun MessageBubble(message: Message) { Column( modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp, horizontal = 12.dp), + .padding(vertical = 4.dp, horizontal = 12.dp) + .animateContentSize(), horizontalAlignment = alignment ) { if (!isUser && !isTool) { diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/StreamingText.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/StreamingText.kt index 13fd2e4..197f51d 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/StreamingText.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/StreamingText.kt @@ -1,15 +1,18 @@ package com.tiangong.aiagent.ui.chat.components -import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.* +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.tiangong.aiagent.util.MarkdownRenderer @Composable @@ -18,7 +21,7 @@ fun StreamingText( modifier: Modifier = Modifier ) { if (content.isEmpty()) { - // Show thinking indicator + // Animated thinking indicator with pulsing dots Row( modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically @@ -28,13 +31,19 @@ fun StreamingText( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.width(4.dp)) - // Simple dot animation - Text( - text = "▌", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary - ) + Spacer(modifier = Modifier.width(6.dp)) + repeat(3) { index -> + val alpha by animateDotAlpha(index) + Box( + modifier = Modifier + .padding(horizontal = 1.5.dp) + .size(5.dp) + .clip(CircleShape) + .background( + MaterialTheme.colorScheme.primary.copy(alpha = alpha) + ) + ) + } } } else { Surface( @@ -51,3 +60,17 @@ fun StreamingText( } } } + +@Composable +private fun animateDotAlpha(index: Int): State { + val transition = rememberInfiniteTransition(label = "dot_$index") + return transition.animateFloat( + initialValue = 0.2f, + targetValue = 1.0f, + animationSpec = infiniteRepeatable( + animation = tween(400, easing = LinearEasing, delayMillis = index * 200), + repeatMode = RepeatMode.Reverse + ), + label = "dot_alpha_$index" + ) +} diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/ThinkTrace.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/ThinkTrace.kt index 2152364..1b355a3 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/ThinkTrace.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/components/ThinkTrace.kt @@ -3,6 +3,7 @@ package com.tiangong.aiagent.ui.chat.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.core.spring import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons @@ -73,8 +74,8 @@ fun ThinkTracePanel( // Expanded content AnimatedVisibility( visible = expanded, - enter = expandVertically(), - exit = shrinkVertically() + enter = expandVertically(spring(dampingRatio = 0.65f, stiffness = 300f)), + exit = shrinkVertically(spring(dampingRatio = 0.8f, stiffness = 300f)) ) { Column( modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 10.dp) diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/market/MarketScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/market/MarketScreen.kt index 72ee542..74b6fbf 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/market/MarketScreen.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/market/MarketScreen.kt @@ -270,7 +270,7 @@ private fun AgentCard( ) { Column(modifier = Modifier.padding(12.dp)) { Text( - agent.name, + agent.name ?: "", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, maxLines = 1, @@ -351,7 +351,7 @@ private fun AgentListItem( ) { Column(modifier = Modifier.weight(1f)) { Text( - agent.name, + agent.name ?: "", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, maxLines = 1, @@ -422,7 +422,7 @@ private fun TemplateCard( ) { Column(modifier = Modifier.weight(1f)) { Text( - template.name, + template.name ?: "", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold ) @@ -482,7 +482,7 @@ private fun SceneTemplateCard( ) { Column(modifier = Modifier.weight(1f)) { Text( - template.name, + template.name ?: "", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold ) diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/memory/MemoryManageScreen.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/memory/MemoryManageScreen.kt index b90b68d..2fddfd2 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/memory/MemoryManageScreen.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/memory/MemoryManageScreen.kt @@ -486,7 +486,7 @@ private fun KnowledgeEntitiesTab( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = item.name, + text = item.name ?: "", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold ) diff --git a/frontend/src/components/AgentChatPreview.vue b/frontend/src/components/AgentChatPreview.vue index c920d60..f746a4c 100644 --- a/frontend/src/components/AgentChatPreview.vue +++ b/frontend/src/components/AgentChatPreview.vue @@ -12,7 +12,7 @@ - +
@@ -21,7 +21,7 @@
- +
- - -
+ - -
-
-
+ -
- -
- - {{ a.filename }} -
-
-
-
- {{ formatTime(message.timestamp) }} - - - -
-
- -
- + + + + +
@@ -103,7 +67,7 @@
- +
+ + -
- -
- - - -
-
-
- -
- -
-
- -
-
{{ a.filename }}
-
-
-
-
-
- {{ a.previewText ? '内容预览' : '已上传' }} · {{ a.filename }} -
-
{{ a.previewText }}
-
{{ a.previewNote }}
-
-
- - -
+ + @@ -253,15 +151,19 @@ import { UserFilled, Delete, Loading, - Paperclip, - Microphone, - Promotion, + Headset, Document, - Headset } from '@element-plus/icons-vue' +import ChatMessageBubble from '@/components/ChatMessageBubble.vue' +import ChatInputArea from '@/components/ChatInputArea.vue' +import { useFileUpload } from '@/composables/useFileUpload' +import type { PendingAttachment, MessageAttachment } from '@/composables/useFileUpload' import api, { WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS } from '@/api' import { useUserStore } from '@/stores/user' +// ---- Shared file upload ---- +const { pendingAttachments, fileInputRef, inputDragOver, uploadFiles, removeAttachment, clearAttachments, buildAttachmentContext, onFilesSelected, handleAttachFile } = useFileUpload() + interface Message { role: 'user' | 'agent' content: string @@ -269,27 +171,6 @@ interface Message { attachments?: MessageAttachment[] } -interface PendingAttachment { - relative_path: string - filename: string - size?: number - content_type?: string - /** 图片:本地 blob URL,用于缩略图(移除或发送后 revoke) */ - thumbUrl?: string - /** 浏览器本地读取的纯文本预览(发送后也会写入气泡) */ - previewText?: string - /** 非文本类:简短说明 */ - previewNote?: string -} - -interface MessageAttachment { - relative_path: string - filename: string - thumbUrl?: string - isImage?: boolean - content_type?: string -} - const props = defineProps<{ agentId?: string agentName?: string @@ -310,25 +191,28 @@ const inputMessage = ref('') const loading = ref(false) const historyLoading = ref(false) const messagesContainer = ref() -const fileInputRef = ref(null) -const pendingAttachments = ref([]) -/** 已发送、待轮询结束后再 revoke 的附件(含 thumbUrl) */ -const blobsPendingRevokeAfterRun = ref(null) -/** 拖放图片到输入区时高亮 */ -const inputDragOver = ref(false) +let pollingInterval: any = null +let replyAdded = false + +// Image preview const imagePreviewVisible = ref(false) const imagePreviewSrc = ref('') const imagePreviewTitle = ref('') -let pollingInterval: any = null -let replyAdded = false // 标志位:防止重复添加回复 +const blobsPendingRevokeAfterRun = ref(null) -/** 设计器预览对话本地镜像(同浏览器同 Agent 在接口不可用或尚未落库时仍可恢复) */ +function openImagePreview(a: MessageAttachment) { + if (!a.thumbUrl) return + imagePreviewSrc.value = a.thumbUrl + imagePreviewTitle.value = a.filename + imagePreviewVisible.value = true +} + +/** 设计器预览对话本地镜像 */ const DESIGN_CHAT_STORAGE_PREFIX = 'agent_design_chat_v1' const MAX_PERSIST_MESSAGES = 200 let persistTimer: ReturnType | null = null let loadHistoryGeneration = 0 -/** 本地缓存按「登录用户 + Agent」分桶,跨设备以服务端为准时仍避免同机多账号串缓存 */ function designChatStorageKey(agentId: string) { return `${DESIGN_CHAT_STORAGE_PREFIX}_${agentId}_${getPreviewContextUserId(agentId)}` } @@ -358,9 +242,10 @@ function readDesignChatFromStorage(agentId: string): Message[] { relative_path: String(a.relative_path), filename: String(a.filename), isImage: !!a.isImage, - content_type: a.content_type ? String(a.content_type) : undefined + content_type: a.content_type ? String(a.content_type) : undefined, + thumbUrl: a.thumbUrl ? String(a.thumbUrl) : undefined, })) - : undefined + : undefined, })) } catch { return [] @@ -378,8 +263,9 @@ function writeDesignChatToStorage(agentId: string, list: Message[]) { relative_path: a.relative_path, filename: a.filename, isImage: !!a.isImage, - content_type: a.content_type - })) + content_type: a.content_type, + thumbUrl: a.thumbUrl, + })), })) let raw = JSON.stringify(compact) if (raw.length > 450_000) { @@ -408,26 +294,19 @@ function clearDesignChatStorage(agentId: string) { } } -/** - * 预览/执行写入 input_data.user_id: - * - 已登录:用账号 id,换设备同一账号可拉同一套历史 - * - 未登录:退回浏览器级 preview_ 会话(仅本机) - */ function getPreviewContextUserId(agentId: string): string { const uid = userStore.user?.id if (uid) return String(uid) return getPreviewSessionUserId(agentId) } -/** 本轮发送前已有对话(不含即将追加的当前用户句),供后端注入 LLM 上下文 */ function conversationHistoryPayloadBeforeNewUserTurn(): Array<{ role: string; content: string }> { return messages.value.map((m) => ({ role: m.role === 'user' ? 'user' : 'assistant', - content: typeof m.content === 'string' ? m.content : String(m.content ?? '') + content: typeof m.content === 'string' ? m.content : String(m.content ?? ''), })) } -/** 未登录时的浏览器会话 id(见 agent记忆实现方案.md) */ function getPreviewSessionUserId(agentId: string): string { const key = `agent_preview_uid_${agentId}` const newId = () => @@ -442,7 +321,6 @@ function getPreviewSessionUserId(agentId: string): string { } return id } catch { - // 勿用 Date.now() 作唯一后缀:每次刷新会换 ID,历史接口永远对不上 try { let id = sessionStorage.getItem(key) if (!id) { @@ -456,14 +334,13 @@ function getPreviewSessionUserId(agentId: string): string { } } -/** 从服务端恢复本会话(preview_user_id 与 input_data.user_id 一致)的已完成对话 */ async function loadChatHistory() { if (!props.agentId) return const gen = ++loadHistoryGeneration const aid = props.agentId historyLoading.value = true - // 先读本地镜像,刷新/再次进入页面时立刻有内容(不闪空) + // 先读本地镜像 const cached = readDesignChatFromStorage(aid) if (cached.length > 0) { messages.value = cached @@ -493,18 +370,18 @@ async function loadChatHistory() { content_type: a.content_type, isImage: !!a.content_type?.startsWith('image/') || - /\.(png|jpe?g|gif|webp|bmp|tiff?)$/i.test(a.filename || a.relative_path) + /\.(png|jpe?g|gif|webp|bmp|tiff?)$/i.test(a.filename || a.relative_path), })) next.push({ role: 'user', content: t.user_text || ' ', timestamp: Number.isFinite(base) ? base - 400 : Date.now() - 400, - attachments: msgAttachments.length ? msgAttachments : undefined + attachments: msgAttachments.length ? msgAttachments : undefined, }) next.push({ role: 'agent', content: t.agent_text || '', - timestamp: Number.isFinite(base) ? base : Date.now() + timestamp: Number.isFinite(base) ? base : Date.now(), }) } return next @@ -541,11 +418,9 @@ async function loadChatHistory() { } merge(await fetchHist(contextId)) - // 登录前产生的记录多为 preview_xxx;登录后 context 变为账号 id,必须合并本机仍保存的 preview 会话,否则会只剩极少数条 if (userStore.user?.id && browserPreviewId !== contextId) { merge(await fetchHist(browserPreviewId)) } - // 两次仍为空(例如新浏览器只有库里旧 preview、且本地无该 preview id):再拉本 Agent 最近完成记录(设计页需 read 权限) if (byExe.size === 0) { merge(await fetchHist(undefined)) } @@ -555,11 +430,7 @@ async function loadChatHistory() { ) if (gen !== loadHistoryGeneration) return - - // 用户已在发消息/轮询中:不要用历史接口覆盖当前界面,避免吞掉刚发的内容 - if (loading.value) { - return - } + if (loading.value) return if (Array.isArray(turns) && turns.length > 0) { const next = applyServerTurns(turns) @@ -568,10 +439,8 @@ async function loadChatHistory() { await hydrateMessageAttachmentThumbs(messages.value) writeDesignChatToStorage(aid, next) } - // 服务端无记录时保留上面已展示的本地镜像(若有) } catch (e) { console.warn('加载对话历史失败', e) - // 网络/404/401:保留本地镜像 } finally { if (gen === loadHistoryGeneration) { historyLoading.value = false @@ -580,24 +449,10 @@ async function loadChatHistory() { } } -function handleAttachFile() { - fileInputRef.value?.click() -} - -function isImageFile(file: File): boolean { - if (file.type && file.type.startsWith('image/')) return true - const ext = file.name.split('.').pop()?.toLowerCase() || '' - return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tif', 'tiff'].includes(ext) -} - function revokeAttachmentBlobs(items: PendingAttachment[]) { for (const a of items) { if (a.thumbUrl) { - try { - URL.revokeObjectURL(a.thumbUrl) - } catch { - /* ignore */ - } + try { URL.revokeObjectURL(a.thumbUrl) } catch { /* ignore */ } } } } @@ -606,11 +461,7 @@ function revokeMessageAttachmentBlobs(items: Message[]) { for (const m of items) { for (const a of m.attachments || []) { if (a.thumbUrl) { - try { - URL.revokeObjectURL(a.thumbUrl) - } catch { - /* ignore */ - } + try { URL.revokeObjectURL(a.thumbUrl) } catch { /* ignore */ } } } } @@ -626,7 +477,7 @@ async function fetchAttachmentThumb(relativePath: string): Promise/g, '>') - .replace(/"/g, '"') -} - -function readLocalTextPreview(file: File): Promise { - const m = file.name.match(/\.([^.]+)$/) - const ext = (m?.[1] || '').toLowerCase() - if (!TEXT_PREVIEW_EXTS.has(ext)) return Promise.resolve(null) - if (file.size > PREVIEW_MAX_FILE_BYTES) return Promise.resolve(null) - return new Promise((resolve) => { - const r = new FileReader() - r.onload = () => { - let t = String(r.result ?? '') - if (t.length > PREVIEW_MAX_CHARS) { - t = - t.slice(0, PREVIEW_MAX_CHARS) + - `\n\n…(仅展示前 ${PREVIEW_MAX_CHARS} 字,完整内容请发送后由助手读取文件)` - } - resolve(t) - } - r.onerror = () => resolve(null) - r.readAsText(file, 'UTF-8') - }) -} - -/** 与回形针选择、拖放共用:上传到预览区并加入待发送列表 */ -async function uploadPreviewFiles(fileList: File[]) { - if (!fileList.length) return - if (!props.agentId) { - ElMessage.warning('请先选中要预览的智能体后再上传附件') - return - } - const uploadedNames: string[] = [] - for (const file of fileList) { - const fd = new FormData() - fd.append('file', file) - try { - const res = await api.post( - '/api/v1/uploads/preview', - fd - ) - const d = res.data as PendingAttachment - if (d?.relative_path) { - const previewText = (await readLocalTextPreview(file)) || undefined - let thumbUrl: string | undefined - if (isImageFile(file)) { - thumbUrl = URL.createObjectURL(file) - } - const entry: PendingAttachment = { - relative_path: d.relative_path, - filename: d.filename || file.name, - size: d.size, - content_type: (res.data as any)?.content_type, - thumbUrl, - previewText, - previewNote: previewText - ? undefined - : '发送后由智能体通过 file_read 读取(支持 PDF、Word、Excel、图片 OCR 等)' - } - pendingAttachments.value.push(entry) - uploadedNames.push(d.filename || file.name) - } else { - ElMessage.warning(`「${file.name}」上传未返回有效路径,请重试`) - } - } catch { - /* 错误提示由 api 拦截器统一处理 */ - } - } - if (uploadedNames.length === 1) { - ElNotification.success({ - title: '附件上传成功', - message: `「${uploadedNames[0]}」已加入待发送区,发送后智能体可通过 file_read 读取。`, - duration: 5000, - offset: 72 - }) - } else if (uploadedNames.length > 1) { - ElNotification.success({ - title: '附件上传成功', - message: `共 ${uploadedNames.length} 个文件已加入待发送区:${uploadedNames.join('、')}`, - duration: 5500, - offset: 72 - }) - } -} - -async function onFileInputChange(ev: Event) { - const input = ev.target as HTMLInputElement - const files = input.files - input.value = '' - if (!files?.length) return - await uploadPreviewFiles(Array.from(files)) -} - -function onInputDragEnter(e: DragEvent) { - if (!props.agentId || loading.value) return - e.preventDefault() - if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy' - inputDragOver.value = true -} - -function onInputDragLeave(e: DragEvent) { - e.preventDefault() - const el = e.currentTarget as HTMLElement - const rel = e.relatedTarget as Node | null - if (rel && el.contains(rel)) return - inputDragOver.value = false -} - -function onInputDragOver(e: DragEvent) { - if (!props.agentId || loading.value) return - e.preventDefault() - if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy' -} - -async function onInputDrop(e: DragEvent) { - e.preventDefault() - inputDragOver.value = false - if (!props.agentId || loading.value) { - if (!props.agentId) ElMessage.warning('请先选中要预览的智能体后再拖入图片') - return - } - const dt = e.dataTransfer - if (!dt?.files?.length) return - const list = Array.from(dt.files) - const images = list.filter((f) => isImageFile(f)) - if (images.length === 0) { - ElMessage.warning('请拖入图片文件(如 png、jpg、jpeg、webp、gif 等)') - return - } - if (images.length < list.length) { - ElMessage.info('已忽略非图片文件,仅添加图片附件') - } - await uploadPreviewFiles(images) -} - // 发送消息 const handleSendMessage = async () => { if ((!inputMessage.value.trim() && pendingAttachments.value.length === 0) || loading.value || !props.agentId) @@ -853,25 +511,8 @@ const handleSendMessage = async () => { : '' const mergedForModel = (userMessage || '(用户上传了附件,请阅读并回答。)') + attachHint - let userBubble = userMessage - if (attachSnap.length) { - const lines = attachSnap.map((a) => `📎 ${a.filename}`) - let head = userBubble ? `${userBubble}\n${lines.join('\n')}` : lines.join('\n') - const textPreviews = attachSnap - .filter((a) => a.previewText?.trim()) - .map( - (a) => - `\n—— ${a.filename} 原文预览 ——\n${escapeHtml(a.previewText!.trim())}` - ) - if (textPreviews.length) { - head += `\n${textPreviews.join('\n')}` - } - userBubble = head - } - const conversation_history = conversationHistoryPayloadBeforeNewUserTurn() - // 添加用户消息 const userAttachments: MessageAttachment[] = attachSnap.map((a) => ({ relative_path: a.relative_path, filename: a.filename, @@ -880,18 +521,18 @@ const handleSendMessage = async () => { isImage: !!a.thumbUrl || Boolean(a.content_type?.startsWith('image/')) || - /\.(png|jpe?g|gif|webp|bmp|tiff?)$/i.test(a.filename) + /\.(png|jpe?g|gif|webp|bmp|tiff?)$/i.test(a.filename), })) + messages.value.push({ role: 'user', - content: userBubble || '(附件)', + content: userMessage || '(附件)', timestamp: Date.now(), - attachments: userAttachments.length ? userAttachments : undefined + attachments: userAttachments.length ? userAttachments : undefined, }) - - // 滚动到底部 + scrollToBottom() - + // 发送到Agent loading.value = true try { @@ -905,19 +546,16 @@ const handleSendMessage = async () => { user_id: getPreviewContextUserId(props.agentId), attachments: attachSnap, conversation_history, - memory: { conversation_history } - } + memory: { conversation_history }, + }, }, { timeout: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS } ) - + const execution = response.data blobsPendingRevokeAfterRun.value = attachSnap.length ? attachSnap : null - - // 重置标志位 replyAdded = false - - // 轮询执行状态 + const checkStatus = async () => { const finishBlobs = () => { if (blobsPendingRevokeAfterRun.value) { @@ -929,46 +567,29 @@ const handleSendMessage = async () => { } } try { - // 如果已经添加过回复,直接返回,避免重复添加 - if (replyAdded) { - return - } - - // 获取详细执行状态(包含节点执行信息) + if (replyAdded) return + const statusResponse = await api.get(`/api/v1/executions/${execution.id}/status`, { - timeout: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS + timeout: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS, }) const status = statusResponse.data - - // 将执行状态传递给父组件,用于显示工作流动画 emit('execution-status', status) - - // 获取执行详情(用于提取输出结果) + const execResponse = await api.get(`/api/v1/executions/${execution.id}`, { - timeout: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS + timeout: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS, }) const exec = execResponse.data - + if (exec.status === 'completed') { - // 防止重复添加:如果已经添加过回复,直接返回 - if (replyAdded) { - return - } - - // 标记已添加回复 + if (replyAdded) return replyAdded = true - - // 提取Agent回复 + let agentReply = '' if (exec.output_data) { - // 优先从 result 字段获取(工作流执行结果) if (exec.output_data.result) { - // result 字段应该是纯文本字符串(End节点的输出) - if (typeof exec.output_data.result === 'string') { - agentReply = exec.output_data.result - } else { - agentReply = String(exec.output_data.result) - } + agentReply = typeof exec.output_data.result === 'string' + ? exec.output_data.result + : String(exec.output_data.result) } else if (typeof exec.output_data === 'string') { agentReply = exec.output_data } else if (exec.output_data.output) { @@ -981,67 +602,52 @@ const handleSendMessage = async () => { agentReply = JSON.stringify(exec.output_data, null, 2) } } - + messages.value.push({ role: 'agent', content: agentReply || '执行完成', - timestamp: Date.now() + timestamp: Date.now(), }) - + loading.value = false scrollToBottom() - - // 延迟清除执行状态,让用户能看到最终的执行结果 + setTimeout(() => { emit('execution-status', null) - }, 3000) // 3秒后清除 - + }, 3000) + if (pollingInterval) { clearInterval(pollingInterval) pollingInterval = null } finishBlobs() } else if (exec.status === 'failed') { - // 防止重复添加:如果已经添加过回复,直接返回 - if (replyAdded) { - return - } - - // 标记已添加回复 + if (replyAdded) return replyAdded = true - + messages.value.push({ role: 'agent', content: `执行失败: ${exec.error_message || '未知错误'}`, - timestamp: Date.now() + timestamp: Date.now(), }) loading.value = false scrollToBottom() - - // 延迟清除执行状态,让用户能看到失败节点的状态 + setTimeout(() => { emit('execution-status', null) - }, 5000) // 5秒后清除 - + }, 5000) + if (pollingInterval) { clearInterval(pollingInterval) pollingInterval = null } finishBlobs() - } else { - // 继续轮询(pending 或 running 状态) - // 不需要做任何操作,等待下次轮询 } } catch (error: any) { - // 防止重复添加:如果已经添加过回复,直接返回 - if (replyAdded) { - return - } - - // 标记已添加回复 + if (replyAdded) return replyAdded = true finishBlobs() - + const st = error.response?.status const msg401 = st === 401 @@ -1050,26 +656,22 @@ const handleSendMessage = async () => { messages.value.push({ role: 'agent', content: `获取执行结果失败:${msg401}`, - timestamp: Date.now() + timestamp: Date.now(), }) loading.value = false scrollToBottom() - - // 清除执行状态 + emit('execution-status', null) - + if (pollingInterval) { clearInterval(pollingInterval) pollingInterval = null } } } - - // 使用 setInterval 进行轮询,每500毫秒检查一次(更频繁,能捕获快速执行的节点) + pollingInterval = setInterval(checkStatus, 500) - // 立即执行一次 checkStatus() - } catch (error: any) { console.error('发送消息失败:', error) if (attachSnap.length) { @@ -1081,7 +683,7 @@ const handleSendMessage = async () => { messages.value.push({ role: 'agent', content: `发送失败: ${error.response?.data?.detail || error.message}`, - timestamp: Date.now() + timestamp: Date.now(), }) loading.value = false scrollToBottom() @@ -1089,13 +691,11 @@ const handleSendMessage = async () => { } } -// 预设问题点击 const handlePresetQuestion = (question: string) => { inputMessage.value = question handleSendMessage() } -// 清空对话 const handleClearChat = () => { revokeAttachmentBlobs([...pendingAttachments.value]) revokeMessageAttachmentBlobs(messages.value) @@ -1107,16 +707,13 @@ const handleClearChat = () => { messages.value = [] if (props.agentId) clearDesignChatStorage(props.agentId) ElMessage.info('已清空预览对话(含本机缓存);刷新后不再恢复本条记录') - // 清除执行状态 emit('execution-status', null) - // 清除轮询 if (pollingInterval) { clearInterval(pollingInterval) pollingInterval = null } } -// 滚动到底部 const scrollToBottom = () => { nextTick(() => { if (messagesContainer.value) { @@ -1125,10 +722,6 @@ const scrollToBottom = () => { }) } -/** - * 知你等工作流约定「自然语言 + 末尾单行 JSON(intent/reply/user_profile)」。 - * 聊天区若原样展示会重复;展示前去掉末尾 JSON 行(可连续多行);若整段只有 JSON 则用 reply 作为正文。 - */ const stripOneTrailingWorkflowJsonLine = (raw: string): string => { if (!raw || typeof raw !== 'string') return '' const t = raw.trimEnd() @@ -1160,13 +753,11 @@ const stripTrailingWorkflowJsonLine = (raw: string): string => { const stripDsmlLines = (raw: string): string => { if (!raw || typeof raw !== 'string') return '' - // 过滤协议泄露行:<|DSML|...> / <|DSML|...> const lines = raw.split(/\r?\n/) const filtered = lines.filter((line) => !/[<]\s*[||]DSML[||]/i.test(line)) return filtered.join('\n').trim() } -// 格式化消息(支持简单的Markdown) const formatMessage = (content: string) => { if (!content) return '' const noProtocol = stripDsmlLines(content) @@ -1174,13 +765,6 @@ const formatMessage = (content: string) => { return display.replace(/\n/g, '
') } -// 格式化时间 -const formatTime = (timestamp: number) => { - const date = new Date(timestamp) - return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) -} - - // ─── 语音录制 ───────────────────────────────────────── const isRecording = ref(false) @@ -1191,12 +775,10 @@ let audioChunks: Blob[] = [] const handleVoiceInput = async () => { if (isRecording.value) { - // 停止录制 stopRecording() return } - // 开始录制 try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') @@ -1213,7 +795,6 @@ const handleVoiceInput = async () => { } mediaRecorder.onstop = async () => { - // 释放麦克风 stream.getTracks().forEach((t) => t.stop()) if (recordingInterval) { clearInterval(recordingInterval) @@ -1226,7 +807,6 @@ const handleVoiceInput = async () => { const ext = mimeType.includes('webm') ? 'webm' : 'm4a' const filename = `voice_${Date.now()}.${ext}` - // 上传音频文件 try { const formData = new FormData() formData.append('file', audioBlob, filename) @@ -1236,7 +816,6 @@ const handleVoiceInput = async () => { const relativePath = uploadResp.data?.relative_path || uploadResp.data?.file_path || '' if (relativePath) { - // 将音频作为附件添加到待发送列表 pendingAttachments.value.push({ relative_path: relativePath, filename, @@ -1244,7 +823,6 @@ const handleVoiceInput = async () => { previewNote: '🎤 语音录制 · 发送后 Agent 可调用 speech_to_text 转为文字', }) - // 同时在输入框填入提示 inputMessage.value = inputMessage.value ? `${inputMessage.value}\n[语音消息: ${filename},请用 speech_to_text 转录后回答]` : `[语音消息: ${filename},请用 speech_to_text 转录后回答]` @@ -1263,10 +841,8 @@ const handleVoiceInput = async () => { isRecording.value = true recordingTimer.value = 0 - // 计时器 recordingInterval = setInterval(() => { recordingTimer.value++ - // 最长录制 60 秒 if (recordingTimer.value >= 60) { stopRecording() } @@ -1296,7 +872,6 @@ const speakingMessageIndex = ref(null) let currentAudio: HTMLAudioElement | null = null const speakMessage = async (content: string, index: number) => { - // 如果正在朗读同一条消息,则停止 if (speakingMessageIndex.value === index && currentAudio) { currentAudio.pause() currentAudio = null @@ -1304,13 +879,11 @@ const speakMessage = async (content: string, index: number) => { return } - // 停止之前的朗读 if (currentAudio) { currentAudio.pause() currentAudio = null } - // 提取纯文本(去除 HTML 标签) const plainText = content.replace(/<[^>]*>/g, '').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&') if (!plainText.trim()) { @@ -1334,21 +907,18 @@ const speakMessage = async (content: string, index: number) => { { timeout: 120000 } ) - // 轮询获取执行结果 const executionId = resp.data.id const pollForAudio = async () => { try { const statusResp = await api.get(`/api/v1/executions/${executionId}/status`) const exec = statusResp.data if (exec.status === 'completed') { - // 从结果中提取音频文件路径 const outputData = exec.output_data || exec.result || {} const resultStr = typeof outputData === 'string' ? outputData : JSON.stringify(outputData) const pathMatch = resultStr.match(/output_path["\s:]+([^\s",}]+\.mp3)/) || resultStr.match(/([/\w]+\.mp3)/) if (pathMatch) { const mp3Path = pathMatch[1] - // 通过预览接口获取音频 const audioResp = await api.get('/api/v1/uploads/preview', { params: { path: mp3Path }, responseType: 'blob', @@ -1367,14 +937,11 @@ const speakMessage = async (content: string, index: number) => { } await currentAudio.play() } else { - // 直接调 TTS API(备选方案) await fallbackTTS(plainText) } } else if (exec.status === 'failed') { - // 执行失败,使用备选方案 await fallbackTTS(plainText) } else { - // 仍在执行中,继续轮询(最多等 30 秒) setTimeout(() => pollForAudio(), 2000) } } catch { @@ -1382,7 +949,6 @@ const speakMessage = async (content: string, index: number) => { } } - // 等待 1 秒后开始轮询 setTimeout(() => pollForAudio(), 1000) } catch (err: any) { console.error('TTS 请求失败:', err) @@ -1392,7 +958,6 @@ const speakMessage = async (content: string, index: number) => { } const fallbackTTS = async (text: string) => { - // 使用浏览器内置 Web Speech API 作为备选 try { if ('speechSynthesis' in window) { const plainText = text.replace(/<[^>]*>/g, '').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').substring(0, 500) @@ -1415,18 +980,10 @@ const fallbackTTS = async (text: string) => { } } -// 换行 -const handleNewLine = () => { - // Shift+Enter 换行,不需要特殊处理 -} - -// 关闭节点测试结果 const handleCloseNodeTest = () => { - // 通过事件通知父组件清除测试结果 - // 这里暂时不做处理,由父组件自动清除 + // 由父组件清除 } -// 监听消息变化,自动滚动 + 持久化到本机(设计器刷新可恢复) watch( messages, () => { @@ -1446,7 +1003,6 @@ watch( { immediate: true } ) -// 登录/登出后 user_id 语义变化,需重拉历史与本机缓存键 watch( () => userStore.user?.id, (id, prev) => { @@ -1455,7 +1011,6 @@ watch( } ) -// 从 bfcache 返回时补拉历史(部分浏览器前进/后退不重建组件) function onPageShow(ev: PageTransitionEvent) { if (ev.persisted && props.agentId) { loadChatHistory() @@ -1467,7 +1022,6 @@ onActivated(() => { if (props.agentId) loadChatHistory() }) -// 组件卸载时清理轮询 onUnmounted(() => { window.removeEventListener('pageshow', onPageShow as EventListener) if (pollingInterval) { @@ -1480,7 +1034,6 @@ onUnmounted(() => { revokeAttachmentBlobs(blobsPendingRevokeAfterRun.value) blobsPendingRevokeAfterRun.value = null } - // 清除执行状态 emit('execution-status', null) }) @@ -1529,8 +1082,8 @@ onUnmounted(() => { align-items: flex-start; } -.user-message { - flex-direction: row-reverse; +.agent-message { + /* Legacy: used by opening message and loading state */ } .message-content { @@ -1540,10 +1093,6 @@ onUnmounted(() => { gap: 4px; } -.user-message .message-content { - align-items: flex-end; -} - .message-bubble { padding: 10px 14px; border-radius: 12px; @@ -1558,11 +1107,6 @@ onUnmounted(() => { border: 1px solid #e4e7ed; } -.user-message .message-bubble { - background: #409eff; - color: white; -} - .message-bubble.loading { display: flex; align-items: center; @@ -1570,66 +1114,6 @@ onUnmounted(() => { color: #909399; } -.message-time { - font-size: 12px; - color: #909399; - padding: 0 4px; -} - -.message-attachments { - display: flex; - flex-wrap: nowrap; - overflow-x: auto; - overflow-y: hidden; - max-width: min(70%, 420px); - gap: 8px; - margin-top: 6px; - padding-bottom: 2px; -} - -.message-attachments.is-user { - justify-content: flex-end; -} - -.message-attachments.is-agent { - justify-content: flex-start; -} - -.message-attachment-item { - max-width: 220px; - flex: 0 0 auto; -} - -.message-attachment-image { - width: 120px; - height: 120px; - object-fit: cover; - border-radius: 8px; - border: 1px solid #e4e7ed; - background: #fff; - display: block; - cursor: zoom-in; -} - -.message-attachment-file { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 8px; - border: 1px solid #e4e7ed; - border-radius: 8px; - font-size: 12px; - background: #fff; - color: #606266; - max-width: 220px; -} - -.message-attachment-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - .image-preview-dialog-body { display: flex; justify-content: center; @@ -1712,196 +1196,4 @@ onUnmounted(() => { margin: 0; border: 1px solid #e4e7ed; } - -.chat-input-area { - background: white; - border-top: 1px solid #e4e7ed; - padding: 12px; - border-radius: 0; - transition: background-color 0.15s ease, box-shadow 0.15s ease; -} - -.chat-input-area.is-drag-over { - background: #ecf5ff; - box-shadow: inset 0 0 0 2px #409eff; -} - -.input-toolbar { - margin-bottom: 8px; -} - -.input-footer { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 8px; -} - -.disclaimer { - font-size: 12px; - color: #909399; -} - -.input-actions { - display: flex; - gap: 8px; - align-items: center; -} - -:deep(.el-textarea__inner) { - border: 1px solid #dcdfe6; - border-radius: 8px; - resize: none; -} - -.hidden-file-input { - display: none; -} - -.attachment-thumb-strip { - display: flex; - flex-wrap: wrap; - gap: 10px; - margin-bottom: 10px; - min-height: 0; -} - -.attachment-thumb-item { - position: relative; - width: 76px; - flex-shrink: 0; - text-align: center; -} - -.attachment-thumb-remove { - position: absolute; - top: -6px; - right: -6px; - z-index: 2; - width: 20px; - height: 20px; - padding: 0; - border: none; - border-radius: 50%; - background: rgba(0, 0, 0, 0.55); - color: #fff; - font-size: 14px; - line-height: 1; - cursor: pointer; -} - -.attachment-thumb-remove:hover { - background: #f56c6c; -} - -.attachment-thumb-img-wrap { - width: 72px; - height: 72px; - border-radius: 8px; - overflow: hidden; - border: 1px solid #e4e7ed; - background: #fafafa; - margin: 0 auto; -} - -.attachment-thumb-img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; -} - -.attachment-thumb-file-placeholder { - width: 72px; - height: 72px; - margin: 0 auto; - display: flex; - align-items: center; - justify-content: center; - border-radius: 8px; - border: 1px solid #e4e7ed; - background: linear-gradient(145deg, #f5f7fa, #ebeef5); - color: #409eff; -} - -.attachment-thumb-caption { - margin-top: 4px; - font-size: 11px; - color: #606266; - line-height: 1.2; - max-width: 76px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.attachment-preview-stack { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 10px; - max-height: 220px; - overflow-y: auto; -} - -.attachment-preview-card { - border: 1px solid #e4e7ed; - border-radius: 8px; - background: #fafafa; - padding: 8px 10px; -} - -.attachment-preview-title { - font-size: 12px; - color: #606266; - margin-bottom: 6px; - font-weight: 600; -} - -.attachment-preview-body { - margin: 0; - font-size: 13px; - line-height: 1.5; - white-space: pre-wrap; - word-break: break-word; - color: #303133; - max-height: 160px; - overflow-y: auto; -} - -.attachment-preview-note { - font-size: 12px; - color: #909399; - line-height: 1.45; -} - -/* ─── 语音录制按钮 ─────────────────────────────── */ - -.recording-btn { - animation: pulse-rec 1.2s ease-in-out infinite; -} - -.recording-timer { - font-size: 11px; - font-weight: 600; - color: #f56c6c; - margin-left: 2px; -} - -@keyframes pulse-rec { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.5; transform: scale(1.15); } -} - -/* ─── TTS 朗读按钮 ─────────────────────────────── */ - -.tts-play-btn { - vertical-align: middle; - margin-left: 4px; - color: #909399; -} - -.tts-play-btn:hover { - color: #409eff; -} diff --git a/frontend/src/components/ChatInputArea.vue b/frontend/src/components/ChatInputArea.vue new file mode 100644 index 0000000..8e03052 --- /dev/null +++ b/frontend/src/components/ChatInputArea.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/frontend/src/components/ChatMessageBubble.vue b/frontend/src/components/ChatMessageBubble.vue new file mode 100644 index 0000000..b5c9b9d --- /dev/null +++ b/frontend/src/components/ChatMessageBubble.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/frontend/src/composables/useFileUpload.ts b/frontend/src/composables/useFileUpload.ts new file mode 100644 index 0000000..a529fea --- /dev/null +++ b/frontend/src/composables/useFileUpload.ts @@ -0,0 +1,174 @@ +/** + * Shared file upload composable for chat components. + * Handles upload to /api/v1/uploads/preview, thumbnail generation, text preview, + * and drag-and-drop state management. + */ +import { ref } from 'vue' +import type { Ref } from 'vue' +import api from '@/api' + +export interface PendingAttachment { + relative_path: string + filename: string + size?: number + content_type?: string + thumbUrl?: string // local blob URL for image thumbnails + previewText?: string // local text preview for small text files + previewNote?: string // human-readable note for non-text files +} + +export interface MessageAttachment { + relative_path: string + filename: string + thumbUrl?: string + isImage?: boolean + content_type?: string +} + +const TEXT_PREVIEW_EXTS = new Set([ + 'txt', 'md', 'markdown', 'csv', 'json', 'jsonl', 'log', 'yaml', 'yml', + 'py', 'ts', 'js', 'mjs', 'cjs', 'vue', 'html', 'htm', 'xml', 'css', + 'sql', 'sh', 'bat', 'ps1', 'ini', 'cfg', 'properties', 'rst', 'tex', + 'gitignore', 'env', +]) + +const PREVIEW_MAX_FILE_BYTES = 512 * 1024 +const PREVIEW_MAX_CHARS = 12000 + +export function useFileUpload() { + const pendingAttachments: Ref = ref([]) + const fileInputRef: Ref = ref(null) + const inputDragOver = ref(false) + + function isImageFile(file: File): boolean { + if (file.type?.startsWith('image/')) return true + const ext = file.name.split('.').pop()?.toLowerCase() || '' + return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tif', 'tiff'].includes(ext) + } + + function isValidExtForTextPreview(filename: string): boolean { + const m = filename.match(/\.([^.]+)$/) + const ext = (m?.[1] || '').toLowerCase() + return TEXT_PREVIEW_EXTS.has(ext) + } + + async function readLocalTextPreview(file: File): Promise { + if (!isValidExtForTextPreview(file.name)) return undefined + if (file.size > PREVIEW_MAX_FILE_BYTES) return undefined + return new Promise((resolve) => { + const r = new FileReader() + r.onload = () => { + let t = String(r.result ?? '') + if (t.length > PREVIEW_MAX_CHARS) { + t = t.slice(0, PREVIEW_MAX_CHARS) + + `\n\n...(仅展示前 ${PREVIEW_MAX_CHARS} 字,完整内容请发送后由助手读取文件)` + } + resolve(t) + } + r.onerror = () => resolve(undefined) + r.readAsText(file, 'UTF-8') + }) + } + + async function uploadFiles(fileList: File[]): Promise { + const uploaded: PendingAttachment[] = [] + for (const file of fileList) { + const fd = new FormData() + fd.append('file', file) + try { + const res = await api.post( + '/api/v1/uploads/preview', fd + ) + const d = res.data as any + if (d?.relative_path) { + const previewText = await readLocalTextPreview(file) + let thumbUrl: string | undefined + if (isImageFile(file)) { + thumbUrl = URL.createObjectURL(file) + } + const entry: PendingAttachment = { + relative_path: d.relative_path, + filename: d.filename || file.name, + size: d.size || file.size, + content_type: d.content_type, + thumbUrl, + previewText: previewText || undefined, + previewNote: previewText + ? undefined + : '发送后由智能体通过 file_read 读取(支持 PDF、Word、Excel、图片 OCR 等)', + } + pendingAttachments.value.push(entry) + uploaded.push(entry) + } + } catch { + /* handled by api interceptor */ + } + } + return uploaded + } + + function removeAttachment(index: number) { + const cur = pendingAttachments.value[index] + if (cur?.thumbUrl) { + try { URL.revokeObjectURL(cur.thumbUrl) } catch { /* ignore */ } + } + pendingAttachments.value.splice(index, 1) + } + + function clearAttachments() { + for (const a of pendingAttachments.value) { + if (a.thumbUrl) { + try { URL.revokeObjectURL(a.thumbUrl) } catch { /* ignore */ } + } + } + pendingAttachments.value = [] + } + + function buildAttachmentContext(): string { + if (pendingAttachments.value.length === 0) return '' + const lines: string[] = ['\n\n--- 附件 ---'] + for (const a of pendingAttachments.value) { + lines.push(`- 文件: ${a.filename} (路径: ${a.relative_path})`) + if (a.previewText) { + const truncated = a.previewText.length > 2000 + ? a.previewText.slice(0, 2000) + '\n...(已截断)' + : a.previewText + lines.push(` 内容预览:\n\`\`\`\n${truncated}\n\`\`\``) + } + } + return lines.join('\n') + } + + function onDragLeave(e: DragEvent) { + const target = e.currentTarget as HTMLElement + if (!target.contains(e.relatedTarget as Node)) { + inputDragOver.value = false + } + } + + async function onDrop(e: DragEvent): Promise { + inputDragOver.value = false + const files = e.dataTransfer?.files + if (files?.length) return uploadFiles(Array.from(files)) + return [] + } + + async function onFilesSelected(e: Event): Promise { + const input = e.target as HTMLInputElement + const files = input.files + input.value = '' // Reset so the same file can be re-selected + if (files?.length) return uploadFiles(Array.from(files)) + return [] + } + + function handleAttachFile() { + fileInputRef.value?.click() + } + + return { + pendingAttachments, fileInputRef, inputDragOver, + uploadFiles, removeAttachment, clearAttachments, buildAttachmentContext, + onDragLeave, onDrop, onFilesSelected, handleAttachFile, + isImageFile, + } +} diff --git a/frontend/src/composables/useMarkdown.ts b/frontend/src/composables/useMarkdown.ts new file mode 100644 index 0000000..2ad5362 --- /dev/null +++ b/frontend/src/composables/useMarkdown.ts @@ -0,0 +1,37 @@ +/** + * Shared Markdown rendering — extracts common renderMarkdown from both chat components. + */ +import { marked } from 'marked' + +let configured = false + +function ensureConfigured() { + if (configured) return + marked.setOptions({ + breaks: true, + gfm: true, + }) + configured = true +} + +export function useMarkdown() { + ensureConfigured() + + function renderMarkdown(text: string): string { + if (!text) return '' + try { + const raw = marked.parse(text) as string + // Add target="_blank" to links for safety + return raw.replace(//g, '>') + .replace(/\n/g, '
') + } + } + + return { renderMarkdown } +} diff --git a/frontend/src/views/AgentChat.vue b/frontend/src/views/AgentChat.vue index 7e876fb..9f1af4e 100644 --- a/frontend/src/views/AgentChat.vue +++ b/frontend/src/views/AgentChat.vue @@ -21,6 +21,31 @@ {{ a.name }} + + + + + + 新对话 + + +
+ {{ s.title || '未命名对话' }} + {{ s.message_count }} 条消息 · {{ relativeTimeText(s.updated_at) }} +
+
+
@@ -41,20 +66,54 @@
-
- -

选择一个 Agent 开始对话

-

配置多个 Agent 后发送消息进行编排对话

-

Agent 可以使用内置工具帮你完成任务

+ +
+ + +
-
-
- -
-
- -
+ + + + - - + + + +
@@ -160,12 +223,49 @@
-
- - - {{ loading ? '处理中...' : '发送' }} - -
+ + + + @@ -224,14 +324,27 @@