fix: Android null-safety Gson crash + AgentChat full UX overhaul (Round 1-4)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<String>() {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Float> {
|
||||
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"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
167
frontend/src/components/ChatInputArea.vue
Normal file
167
frontend/src/components/ChatInputArea.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div class="chat-input" :class="{ 'drag-over': dragOver }">
|
||||
<input
|
||||
ref="fileInputEl"
|
||||
type="file"
|
||||
class="hidden-file-input"
|
||||
multiple
|
||||
:accept="acceptTypes"
|
||||
@change="$emit('filesSelected', $event)"
|
||||
/>
|
||||
|
||||
<!-- Attachment preview strip -->
|
||||
<div v-if="attachments.length > 0" class="attach-strip">
|
||||
<div v-for="(a, i) in attachments" :key="`att-${i}`" class="attach-item">
|
||||
<button type="button" class="attach-remove" @click="$emit('removeAttachment', i)">×</button>
|
||||
<img v-if="a.thumbUrl" :src="a.thumbUrl" class="attach-thumb" :alt="a.filename" />
|
||||
<div v-else class="attach-file-icon">
|
||||
<el-icon :size="22"><Document /></el-icon>
|
||||
</div>
|
||||
<div class="attach-name" :title="a.filename">{{ a.filename }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Textarea -->
|
||||
<el-input
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@keydown.enter.exact.prevent="$emit('send')"
|
||||
@keydown.shift.enter.exact="onShiftEnter"
|
||||
/>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="input-footer">
|
||||
<span v-if="disclaimer" class="disclaimer">{{ disclaimer }}</span>
|
||||
<div class="input-actions">
|
||||
<!-- Attach file button -->
|
||||
<el-button text size="small" :disabled="disabled" @click="$emit('attachFile')" title="上传文件">
|
||||
<el-icon :size="20"><Paperclip /></el-icon>
|
||||
</el-button>
|
||||
|
||||
<!-- Voice input button -->
|
||||
<template v-if="voiceSupported">
|
||||
<el-button v-if="!isListening" text size="small" :disabled="disabled" @click="$emit('startVoice')" title="语音输入">
|
||||
<el-icon :size="20"><Microphone /></el-icon>
|
||||
</el-button>
|
||||
<el-button v-else text size="small" type="danger" @click="$emit('stopVoice')" class="recording-btn" title="停止录音">
|
||||
<el-icon :size="20"><VideoPause /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
<span v-if="isListening" class="recording-hint">正在聆听...</span>
|
||||
|
||||
<!-- Extra buttons slot -->
|
||||
<slot name="extraActions" />
|
||||
|
||||
<!-- Send / Stop -->
|
||||
<el-button v-if="showStop" type="danger" @click="$emit('stop')" class="send-btn">
|
||||
<el-icon style="margin-right:4px"><VideoPause /></el-icon>停止
|
||||
</el-button>
|
||||
<el-button v-else type="primary" @click="$emit('send')" :disabled="!canSend" class="send-btn">
|
||||
发送
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { Paperclip, Microphone, VideoPause, Document } from '@element-plus/icons-vue'
|
||||
import type { PendingAttachment } from '@/composables/useFileUpload'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
loading: boolean
|
||||
disabled: boolean
|
||||
attachments: PendingAttachment[]
|
||||
dragOver: boolean
|
||||
showStop: boolean
|
||||
isListening: boolean
|
||||
voiceSupported: boolean
|
||||
placeholder?: string
|
||||
disclaimer?: string
|
||||
acceptTypes?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
send: []
|
||||
stop: []
|
||||
attachFile: []
|
||||
startVoice: []
|
||||
stopVoice: []
|
||||
filesSelected: [event: Event]
|
||||
removeAttachment: [index: number]
|
||||
}>()
|
||||
|
||||
const fileInputEl = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const canSend = computed(() => {
|
||||
if (props.loading || props.disabled) return false
|
||||
if (props.modelValue.trim()) return true
|
||||
if (props.attachments.length > 0) return true
|
||||
return false
|
||||
})
|
||||
|
||||
// Expose fileInputEl for parent to trigger click
|
||||
defineExpose({ fileInputEl })
|
||||
|
||||
function onShiftEnter(e: Event) {
|
||||
// Browser's default textarea behavior handles Shift+Enter (inserts newline)
|
||||
// This handler just prevents the @keydown.enter.exact from also firing
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
.chat-input.drag-over {
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-radius: 8px;
|
||||
outline: 2px dashed var(--el-color-primary);
|
||||
}
|
||||
.hidden-file-input { display: none; }
|
||||
.send-btn { min-width: 100px; }
|
||||
|
||||
/* Attachment strip */
|
||||
.attach-strip { display: flex; gap: 8px; overflow-x: auto; padding: 4px 0; }
|
||||
.attach-item { position: relative; width: 64px; flex-shrink: 0; text-align: center; }
|
||||
.attach-remove {
|
||||
position: absolute; top: -6px; right: -6px; width: 18px; height: 18px;
|
||||
border: 2px solid #fff; border-radius: 50%; background: var(--el-color-danger);
|
||||
color: #fff; font-size: 12px; line-height: 14px; cursor: pointer; z-index: 2;
|
||||
padding: 0; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.attach-thumb {
|
||||
width: 56px; height: 56px; object-fit: cover; border-radius: 6px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
.attach-file-icon {
|
||||
width: 56px; height: 56px; display: flex; align-items: center; justify-content: center;
|
||||
background: var(--el-fill-color); border-radius: 6px;
|
||||
border: 1px solid var(--el-border-color-light); color: var(--el-text-color-secondary);
|
||||
}
|
||||
.attach-name {
|
||||
font-size: 10px; color: var(--el-text-color-secondary); margin-top: 2px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 64px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.input-footer { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||
.disclaimer { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.input-actions { display: flex; gap: 8px; align-items: center; justify-content: flex-end; }
|
||||
|
||||
/* Recording */
|
||||
.recording-btn { animation: pulse-rec 1.5s infinite; }
|
||||
.recording-hint { font-size: 13px; color: var(--el-color-danger); font-weight: 500; }
|
||||
@keyframes pulse-rec { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
</style>
|
||||
205
frontend/src/components/ChatMessageBubble.vue
Normal file
205
frontend/src/components/ChatMessageBubble.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="msg-bubble" :class="[role, status === 'error' ? 'error' : '']">
|
||||
<div class="msg-avatar">
|
||||
<slot name="avatar">
|
||||
<el-avatar :size="36" :icon="role === 'user' ? UserFilled : Promotion" />
|
||||
</slot>
|
||||
</div>
|
||||
<div class="msg-body">
|
||||
<!-- Extra content before bubble (tool calls, thinking trace, orchestrate result) -->
|
||||
<slot name="extra" />
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="msg-text" v-html="contentHtml"></div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<div v-if="attachments?.length" class="msg-attachments" :class="attachmentsClass">
|
||||
<div v-for="(a, ai) in attachments" :key="`att-${ai}`" class="msg-attachment-item">
|
||||
<img
|
||||
v-if="a.isImage && a.thumbUrl"
|
||||
:src="a.thumbUrl"
|
||||
:alt="a.filename"
|
||||
class="msg-attachment-img"
|
||||
@click="$emit('previewImage', a)"
|
||||
/>
|
||||
<div v-else class="msg-attachment-file">
|
||||
<el-icon :size="14"><Document /></el-icon>
|
||||
<span class="msg-attachment-name">{{ a.filename }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meta line -->
|
||||
<div class="msg-meta">
|
||||
<span class="msg-time">{{ formatTime(timestamp) }}</span>
|
||||
<span v-if="iterations" class="msg-iterations">{{ iterations }} 步 · {{ toolCallsMade }} 次工具调用</span>
|
||||
<span class="msg-actions">
|
||||
<slot name="actions" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { UserFilled, Promotion, Document } from '@element-plus/icons-vue'
|
||||
import type { MessageAttachment } from '@/composables/useFileUpload'
|
||||
|
||||
const props = defineProps<{
|
||||
role: 'user' | 'assistant'
|
||||
contentHtml: string
|
||||
timestamp: number
|
||||
status?: string
|
||||
attachments?: MessageAttachment[]
|
||||
iterations?: number
|
||||
toolCallsMade?: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
previewImage: [attachment: MessageAttachment]
|
||||
}>()
|
||||
|
||||
const attachmentsClass = computed(() => (props.role === 'user' ? 'is-user' : 'is-agent'))
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.msg-bubble {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
max-width: 88%;
|
||||
}
|
||||
.msg-bubble.user {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.msg-bubble.assistant {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.msg-body {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--el-fill-color-light);
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.msg-bubble.user .msg-body {
|
||||
background: var(--el-color-primary-light-8);
|
||||
}
|
||||
.msg-bubble.error .msg-body {
|
||||
border: 1px solid var(--el-color-danger-light-5);
|
||||
}
|
||||
|
||||
/* Content markdown styles */
|
||||
.msg-text :deep(pre) {
|
||||
background: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.msg-text :deep(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.msg-text :deep(code) {
|
||||
background: var(--el-fill-color-darker);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
|
||||
}
|
||||
.msg-text :deep(h1) { font-size: 1.4em; font-weight: 700; margin: 12px 0 6px; }
|
||||
.msg-text :deep(h2) { font-size: 1.2em; font-weight: 700; margin: 10px 0 4px; }
|
||||
.msg-text :deep(h3) { font-size: 1.1em; font-weight: 600; margin: 8px 0 4px; }
|
||||
.msg-text :deep(ul), .msg-text :deep(ol) { padding-left: 20px; margin: 4px 0; }
|
||||
.msg-text :deep(li) { margin: 2px 0; }
|
||||
.msg-text :deep(blockquote) {
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
margin: 8px 0;
|
||||
padding: 4px 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
.msg-text :deep(table) { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 13px; }
|
||||
.msg-text :deep(th), .msg-text :deep(td) { border: 1px solid var(--el-border-color); padding: 6px 10px; text-align: left; }
|
||||
.msg-text :deep(th) { background: var(--el-fill-color); font-weight: 600; }
|
||||
.msg-text :deep(a) { color: var(--el-color-primary); text-decoration: underline; }
|
||||
.msg-text :deep(hr) { border: none; border-top: 1px solid var(--el-border-color-light); margin: 12px 0; }
|
||||
.msg-text :deep(p) { margin: 4px 0; }
|
||||
.msg-text :deep(img) { max-width: 100%; border-radius: 8px; margin: 6px 0; }
|
||||
|
||||
/* Attachments */
|
||||
.msg-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;
|
||||
}
|
||||
.msg-attachments.is-user { justify-content: flex-end; }
|
||||
.msg-attachments.is-agent { justify-content: flex-start; }
|
||||
.msg-attachment-item { max-width: 220px; flex: 0 0 auto; }
|
||||
.msg-attachment-img {
|
||||
width: 120px; height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e4e7ed;
|
||||
background: #fff;
|
||||
display: block;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.msg-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;
|
||||
}
|
||||
.msg-attachment-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Meta */
|
||||
.msg-meta {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.msg-iterations { color: var(--el-color-info); }
|
||||
.msg-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.msg-body:hover .msg-actions { opacity: 1; }
|
||||
</style>
|
||||
174
frontend/src/composables/useFileUpload.ts
Normal file
174
frontend/src/composables/useFileUpload.ts
Normal file
@@ -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<PendingAttachment[]> = ref([])
|
||||
const fileInputRef: Ref<HTMLInputElement | null> = 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<string | undefined> {
|
||||
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<PendingAttachment[]> {
|
||||
const uploaded: PendingAttachment[] = []
|
||||
for (const file of fileList) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
try {
|
||||
const res = await api.post<PendingAttachment & { content_type?: string }>(
|
||||
'/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<PendingAttachment[]> {
|
||||
inputDragOver.value = false
|
||||
const files = e.dataTransfer?.files
|
||||
if (files?.length) return uploadFiles(Array.from(files))
|
||||
return []
|
||||
}
|
||||
|
||||
async function onFilesSelected(e: Event): Promise<PendingAttachment[]> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
37
frontend/src/composables/useMarkdown.ts
Normal file
37
frontend/src/composables/useMarkdown.ts
Normal file
@@ -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(/<a /g, '<a target="_blank" rel="noopener" ')
|
||||
} catch {
|
||||
// Fallback: escape HTML and preserve newlines
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>')
|
||||
}
|
||||
}
|
||||
|
||||
return { renderMarkdown }
|
||||
}
|
||||
@@ -21,6 +21,31 @@
|
||||
<span>{{ a.name }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
|
||||
<!-- Session selector -->
|
||||
<el-select
|
||||
v-model="currentSessionId"
|
||||
placeholder="对话记录"
|
||||
style="width: 200px"
|
||||
:disabled="!currentAgentId"
|
||||
@change="switchSession"
|
||||
clearable
|
||||
>
|
||||
<el-option label="+ 新对话" value="__new__">
|
||||
<span style="color: var(--el-color-primary); font-weight: 500">+ 新对话</span>
|
||||
</el-option>
|
||||
<el-option
|
||||
v-for="s in sessions"
|
||||
:key="s.session_id"
|
||||
:label="s.title || '未命名对话'"
|
||||
:value="s.session_id"
|
||||
>
|
||||
<div style="display:flex;flex-direction:column">
|
||||
<span class="session-option-title">{{ s.title || '未命名对话' }}</span>
|
||||
<span class="session-option-meta">{{ s.message_count }} 条消息 · {{ relativeTimeText(s.updated_at) }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<!-- 编排模式:模式选择 + Agent 数 -->
|
||||
@@ -41,20 +66,54 @@
|
||||
</div>
|
||||
|
||||
<div class="chat-messages" ref="messagesRef">
|
||||
<div v-if="displayMessages.length === 0" class="chat-empty">
|
||||
<el-icon :size="48"><ChatLineSquare /></el-icon>
|
||||
<p v-if="chatMode === 'single'">选择一个 Agent 开始对话</p>
|
||||
<p v-else>配置多个 Agent 后发送消息进行编排对话</p>
|
||||
<p class="hint">Agent 可以使用内置工具帮你完成任务</p>
|
||||
<!-- Rich empty state -->
|
||||
<div v-if="displayMessages.length === 0 && !loading" class="chat-empty">
|
||||
<template v-if="chatMode === 'single' && agent">
|
||||
<el-avatar :size="56" icon="Promotion" style="background:var(--el-color-primary-light-5);margin-bottom:8px" />
|
||||
<h3 class="welcome-title">{{ agent.name }}</h3>
|
||||
<p v-if="agent.description" class="welcome-desc">{{ agent.description }}</p>
|
||||
<div class="welcome-tags">
|
||||
<el-tag size="small" type="info" v-if="agent.version">v{{ agent.version }}</el-tag>
|
||||
<el-tag size="small" :type="agent.status === 'published' ? 'success' : 'warning'">{{ agent.status === 'published' ? '已发布' : agent.status }}</el-tag>
|
||||
</div>
|
||||
<p class="hint">可以问我任何问题,或者试试下面的示例:</p>
|
||||
<div class="preset-questions">
|
||||
<div class="preset-q" v-for="(q, qi) in getPresetQuestions()" :key="qi" @click="sendPresetQuestion(q)">
|
||||
{{ q }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="chatMode === 'single'">
|
||||
<el-icon :size="48"><ChatLineSquare /></el-icon>
|
||||
<p>选择一个智能体开始对话</p>
|
||||
<p class="hint">从上方下拉框中选择已创建的 Agent</p>
|
||||
<div class="agent-quick-list" v-if="agents.length > 0">
|
||||
<el-button v-for="a in agents.slice(0, 6)" :key="a.id" size="small" @click="currentAgentId = a.id; switchAgent()" style="margin:4px">
|
||||
{{ a.name }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-icon :size="48"><ChatLineSquare /></el-icon>
|
||||
<p>配置多个 Agent 后发送消息进行编排对话</p>
|
||||
<p class="hint">支持辩论、路由、顺序、流水线四种编排模式</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-for="(msg, i) in displayMessages" :key="i" class="message" :class="[msg.role, msg.status === 'error' ? 'error' : '']">
|
||||
<div class="message-avatar">
|
||||
<el-avatar :size="36" :icon="msg.role === 'user' ? UserFilled : Promotion" />
|
||||
</div>
|
||||
<div class="message-bubble">
|
||||
<!-- 编排模式:显示每个 Agent 的独立输出 -->
|
||||
<div v-if="msg.orchestrateResult" class="orchestrate-result">
|
||||
<!-- Messages using shared ChatMessageBubble -->
|
||||
<ChatMessageBubble
|
||||
v-for="(msg, i) in displayMessages"
|
||||
:key="i"
|
||||
:role="msg.role"
|
||||
:content-html="renderMarkdown(msg.orchestrateResult?.final_answer || msg.content)"
|
||||
:timestamp="msg.timestamp"
|
||||
:status="msg.status"
|
||||
:iterations="msg.iterations"
|
||||
:tool-calls-made="msg.tool_calls_made"
|
||||
>
|
||||
<!-- Orchestrate result -->
|
||||
<template v-if="msg.orchestrateResult" #extra>
|
||||
<div class="orchestrate-result">
|
||||
<div class="orch-header">
|
||||
<el-tag size="small" type="info">{{ msg.orchestrateResult.mode }}</el-tag>
|
||||
<span class="orch-agent-count">{{ msg.orchestrateResult.steps.length }} 个 Agent</span>
|
||||
@@ -85,72 +144,76 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 单 Agent 模式:原有内容 -->
|
||||
<template v-if="!msg.orchestrateResult">
|
||||
<div class="message-text" v-html="renderMarkdown(msg.content)"></div>
|
||||
<div v-if="msg.tool_calls && msg.tool_calls.length > 0" class="tool-calls">
|
||||
<div class="tool-calls-header">
|
||||
<el-icon><Tools /></el-icon> 工具调用 ({{ msg.tool_calls.length }})
|
||||
</div>
|
||||
<div v-for="(tc, j) in msg.tool_calls" :key="j" class="tool-call-item">
|
||||
<span class="tool-name">{{ tc.function?.name || '?' }}</span>
|
||||
<el-tag size="small" type="info">{{ safeParseArgCount(tc.function?.arguments) }} 个参数</el-tag>
|
||||
</div>
|
||||
<!-- Tool calls + thinking trace for non-orchestrate messages -->
|
||||
<template v-if="!msg.orchestrateResult" #extra>
|
||||
<div v-if="msg.tool_calls && msg.tool_calls.length > 0" class="tool-calls">
|
||||
<div class="tool-calls-header">
|
||||
<el-icon><Tools /></el-icon> 工具调用 ({{ msg.tool_calls.length }})
|
||||
</div>
|
||||
<div v-for="(tc, j) in msg.tool_calls" :key="j" class="tool-call-item">
|
||||
<span class="tool-name">{{ tc.function?.name || '?' }}</span>
|
||||
<el-tag size="small" type="info">{{ safeParseArgCount(tc.function?.arguments) }} 个参数</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 思考链 -->
|
||||
<div v-if="msg.steps && msg.steps.length > 0" class="thinking-trace">
|
||||
<div class="trace-header" @click="toggleTrace(msg)">
|
||||
<el-icon><CaretRight :style="{ transform: msg._traceOpen ? 'rotate(90deg)' : '' }" /></el-icon>
|
||||
<span>思考链 ({{ msg.steps.length }} 步)</span>
|
||||
</div>
|
||||
<div v-show="msg._traceOpen" class="trace-steps">
|
||||
<div v-for="(step, si) in msg.steps" :key="si" class="trace-step" :class="'step-' + step.type">
|
||||
<div class="step-icon">
|
||||
<el-icon v-if="step.type === 'think'"><ChatDotSquare /></el-icon>
|
||||
<el-icon v-else-if="step.type === 'tool_result'"><Tools /></el-icon>
|
||||
<el-icon v-else><Select /></el-icon>
|
||||
<div v-if="msg.steps && msg.steps.length > 0" class="thinking-trace">
|
||||
<div class="trace-header" @click="toggleTrace(msg)">
|
||||
<el-icon><CaretRight :style="{ transform: msg._traceOpen ? 'rotate(90deg)' : '' }" /></el-icon>
|
||||
<span>思考链 ({{ msg.steps.length }} 步)</span>
|
||||
</div>
|
||||
<div v-show="msg._traceOpen" class="trace-steps">
|
||||
<div v-for="(step, si) in msg.steps" :key="si" class="trace-step" :class="'step-' + step.type">
|
||||
<div class="step-icon">
|
||||
<el-icon v-if="step.type === 'think'"><ChatDotSquare /></el-icon>
|
||||
<el-icon v-else-if="step.type === 'tool_result'"><Tools /></el-icon>
|
||||
<el-icon v-else><Select /></el-icon>
|
||||
</div>
|
||||
<div class="step-body">
|
||||
<div class="step-header">
|
||||
<span class="step-tag" :class="'tag-' + step.type">{{ {think:'思考',tool_result:'工具结果',final:'最终回答'}[step.type] || step.type }}</span>
|
||||
<span class="step-iter">#{{ step.iteration }}</span>
|
||||
<span v-if="step.tool_name" class="step-tool-name">{{ step.tool_name }}</span>
|
||||
</div>
|
||||
<div class="step-body">
|
||||
<div class="step-header">
|
||||
<span class="step-tag" :class="'tag-' + step.type">{{ {think:'思考',tool_result:'工具结果',final:'最终回答'}[step.type] || step.type }}</span>
|
||||
<span class="step-iter">#{{ step.iteration }}</span>
|
||||
<span v-if="step.tool_name" class="step-tool-name">{{ step.tool_name }}</span>
|
||||
</div>
|
||||
<div v-if="step.content" class="step-content" v-html="renderMarkdown(step.content)"></div>
|
||||
<div v-if="step.reasoning" class="step-reasoning">
|
||||
<div class="reasoning-header">推理过程</div>
|
||||
<div class="reasoning-text">{{ step.reasoning }}</div>
|
||||
</div>
|
||||
<div v-if="step.tool_input && Object.keys(step.tool_input).length" class="step-tool-input">
|
||||
<div class="reasoning-header">参数</div>
|
||||
<pre>{{ JSON.stringify(step.tool_input, null, 2) }}</pre>
|
||||
</div>
|
||||
<div v-if="step.tool_result" class="step-tool-result">
|
||||
<div class="reasoning-header">结果</div>
|
||||
<pre>{{ step.tool_result }}</pre>
|
||||
</div>
|
||||
<div v-if="step.content" class="step-content" v-html="renderMarkdown(step.content)"></div>
|
||||
<div v-if="step.reasoning" class="step-reasoning">
|
||||
<div class="reasoning-header">推理过程</div>
|
||||
<div class="reasoning-text">{{ step.reasoning }}</div>
|
||||
</div>
|
||||
<div v-if="step.tool_input && Object.keys(step.tool_input).length" class="step-tool-input">
|
||||
<div class="reasoning-header">参数</div>
|
||||
<pre>{{ JSON.stringify(step.tool_input, null, 2) }}</pre>
|
||||
</div>
|
||||
<div v-if="step.tool_result" class="step-tool-result">
|
||||
<div class="reasoning-header">结果</div>
|
||||
<pre>{{ step.tool_result }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="message-meta">
|
||||
{{ msg.role === 'user' ? '用户' : 'Agent' }} · {{ relativeTime(msg.timestamp) }}
|
||||
<span v-if="msg.iterations" class="meta-iterations">· {{ msg.iterations }} 步 · {{ msg.tool_calls_made }} 次工具调用</span>
|
||||
<span class="meta-actions">
|
||||
<el-button v-if="msg.role === 'assistant'" link size="small" @click="copyMessage(msg)" title="复制">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
</el-button>
|
||||
<el-button v-if="msg.status === 'error'" link type="danger" size="small" @click="retryMessage(i)" title="重试">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Actions slot -->
|
||||
<template #actions>
|
||||
<el-button v-if="msg.role === 'assistant' && msg.content" link size="small" @click="isSpeaking ? stopSpeak() : speakMessage(msg.orchestrateResult?.final_answer || msg.content)" :title="isSpeaking ? '停止朗读' : '朗读'">
|
||||
<el-icon><Headset v-if="!isSpeaking" /><VideoPause v-else /></el-icon>
|
||||
</el-button>
|
||||
<el-button v-if="msg.role === 'user'" link size="small" @click="editMessage(i)" title="编辑">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
<el-button v-if="msg.role === 'assistant'" link size="small" @click="copyMessage(msg)" title="复制">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
</el-button>
|
||||
<el-button v-if="msg.status === 'error'" link type="danger" size="small" @click="retryMessage(i)" title="重试">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
<el-button link size="small" @click="deleteMessage(i)" title="删除">
|
||||
<el-icon style="color:var(--el-color-danger)"><Delete /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</ChatMessageBubble>
|
||||
|
||||
<div v-if="loading && !streamingActive" class="message assistant">
|
||||
<div class="message-avatar"><el-avatar :size="36" icon="Promotion" /></div>
|
||||
@@ -160,12 +223,49 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-input">
|
||||
<el-input v-model="inputMessage" type="textarea" :rows="3" placeholder="输入你的问题..." @keydown.enter.exact.prevent="sendMessage" :disabled="loading" />
|
||||
<el-button type="primary" @click="sendMessage" :loading="loading" :disabled="!inputMessage.trim()" class="send-btn">
|
||||
{{ loading ? '处理中...' : '发送' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<!-- Shared ChatInputArea -->
|
||||
<ChatInputArea
|
||||
ref="inputAreaRef"
|
||||
v-model="inputMessage"
|
||||
:loading="loading"
|
||||
:disabled="loading || (chatMode === 'single' && !currentAgentId)"
|
||||
:attachments="pendingAttachments"
|
||||
:drag-over="inputDragOver"
|
||||
:show-stop="loading"
|
||||
:is-listening="isListening"
|
||||
:voice-supported="voiceSupported"
|
||||
placeholder="输入你的问题... Enter 发送,Shift+Enter 换行 支持拖拽或粘贴图片/文件"
|
||||
@send="sendMessage"
|
||||
@stop="stopGeneration"
|
||||
@attach-file="handleAttachFile"
|
||||
@start-voice="startListening"
|
||||
@stop-voice="stopListening"
|
||||
@files-selected="onFilesSelected"
|
||||
@remove-attachment="removeAttachment"
|
||||
>
|
||||
<template #extraActions>
|
||||
<el-popover v-if="chatMode === 'single'" placement="top" :width="260" trigger="click">
|
||||
<template #reference>
|
||||
<el-button text size="small" title="对话设置"><el-icon :size="18"><Setting /></el-icon></el-button>
|
||||
</template>
|
||||
<div class="chat-settings-popover">
|
||||
<div style="margin-bottom:8px;font-size:13px;font-weight:500">模型</div>
|
||||
<el-select v-model="chatModel" size="small" style="width:100%">
|
||||
<el-option label="DeepSeek V4 Pro" value="deepseek-v4-pro" />
|
||||
<el-option label="DeepSeek V4 Flash" value="deepseek-v4-flash" />
|
||||
<el-option label="GPT-4o" value="gpt-4o" />
|
||||
<el-option label="GPT-4o Mini" value="gpt-4o-mini" />
|
||||
<el-option label="Claude Opus 4.6" value="claude-opus-4-6" />
|
||||
<el-option label="Claude Sonnet 4.6" value="claude-sonnet-4-6" />
|
||||
</el-select>
|
||||
<div style="margin:10px 0 4px;font-size:13px;font-weight:500">Temperature: {{ chatTemperature.toFixed(1) }}</div>
|
||||
<el-slider v-model="chatTemperature" :min="0" :max="2" :step="0.1" size="small" show-stops />
|
||||
<div style="margin:10px 0 4px;font-size:13px;font-weight:500">最大迭代</div>
|
||||
<el-input-number v-model="chatMaxIterations" :min="1" :max="50" size="small" style="width:100%" />
|
||||
</div>
|
||||
</el-popover>
|
||||
</template>
|
||||
</ChatInputArea>
|
||||
|
||||
<!-- 编排 Agent 编辑器 -->
|
||||
<el-dialog v-model="showOrchestrateEditor" title="编排 Agent 配置" width="700px" @closed="saveState">
|
||||
@@ -224,14 +324,27 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ChatLineSquare, UserFilled, Promotion, Tools, CaretRight, ChatDotSquare, Select, DocumentCopy, Refresh } from '@element-plus/icons-vue'
|
||||
import { ChatLineSquare, UserFilled, Promotion, Tools, CaretRight, ChatDotSquare, Select, DocumentCopy, Refresh, VideoPause, Edit, Delete, Headset, Setting } from '@element-plus/icons-vue'
|
||||
import MainLayout from '@/components/MainLayout.vue'
|
||||
import ChatMessageBubble from '@/components/ChatMessageBubble.vue'
|
||||
import ChatInputArea from '@/components/ChatInputArea.vue'
|
||||
import api from '@/api'
|
||||
import type { Agent } from '@/stores/agent'
|
||||
import { useMarkdown } from '@/composables/useMarkdown'
|
||||
import { useFileUpload } from '@/composables/useFileUpload'
|
||||
import { useTTS } from '@/composables/useTTS'
|
||||
import { useSpeechRecognition } from '@/composables/useSpeechRecognition'
|
||||
|
||||
// Shared composables
|
||||
const { renderMarkdown } = useMarkdown()
|
||||
const { pendingAttachments, fileInputRef, inputDragOver, uploadFiles, removeAttachment, clearAttachments, buildAttachmentContext, onDragLeave, onDrop, onFilesSelected, handleAttachFile } = useFileUpload()
|
||||
const { isSpeaking, speak: ttsSpeak, stop: ttsStop } = useTTS()
|
||||
const { isListening, transcript, supported: voiceSupported, start: voiceStart, stop: voiceStop } = useSpeechRecognition()
|
||||
|
||||
// TTS & Voice
|
||||
interface AgentStep {
|
||||
iteration: number; type: string; content: string
|
||||
tool_name?: string; tool_input?: Record<string, any>; tool_result?: string; reasoning?: string
|
||||
@@ -257,7 +370,7 @@ interface OrchestrateAgentForm {
|
||||
const STORAGE_KEY = 'agent_chat_state'
|
||||
|
||||
interface ChatState {
|
||||
messages: Record<string, ChatMessage[]> // keyed by agentId / '__bare__' / '__orchestrate__'
|
||||
messages: Record<string, ChatMessage[]>
|
||||
sessionId: Record<string, string>
|
||||
currentAgentId: string
|
||||
chatMode: 'single' | 'orchestrate'
|
||||
@@ -297,12 +410,10 @@ function loadState(): ChatState | null {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
// TTL 过期检查:24小时后自动清除聊天记录
|
||||
if (parsed._savedAt && Date.now() - parsed._savedAt > 24 * 3600 * 1000) {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
return null
|
||||
}
|
||||
// 兼容旧格式:旧版本 messages 是数组,迁移为 Record
|
||||
if (Array.isArray(parsed.messages)) {
|
||||
const oldSessionId = parsed.sessionId || ''
|
||||
parsed.messages = { '__bare__': parsed.messages }
|
||||
@@ -319,15 +430,33 @@ const messages = ref<Record<string, ChatMessage[]>>({})
|
||||
const inputMessage = ref('')
|
||||
const loading = ref(false)
|
||||
const streamingActive = ref(false)
|
||||
const abortController = ref<AbortController | null>(null)
|
||||
const inputAreaRef = ref<InstanceType<typeof ChatInputArea> | null>(null)
|
||||
// 工具审批状态
|
||||
const showApprovalDialog = ref(false)
|
||||
const approvalId = ref('')
|
||||
const approvalToolName = ref('')
|
||||
const approvalArgs = ref<Record<string, any>>({})
|
||||
const messagesRef = ref<HTMLElement | null>(null)
|
||||
const isNearBottom = ref(true)
|
||||
const sessionId = ref<Record<string, string>>({})
|
||||
const agent = ref<Agent | null>(null)
|
||||
|
||||
// 会话历史
|
||||
interface SessionInfo {
|
||||
session_id: string; title?: string; last_message?: string
|
||||
message_count: number; is_pinned: boolean
|
||||
created_at?: string; updated_at?: string
|
||||
}
|
||||
const sessions = ref<SessionInfo[]>([])
|
||||
const currentSessionId = ref('')
|
||||
const sessionsLoading = ref(false)
|
||||
|
||||
// 对话设置
|
||||
const chatModel = ref('deepseek-v4-pro')
|
||||
const chatTemperature = ref(0.8)
|
||||
const chatMaxIterations = ref(15)
|
||||
|
||||
const currentAgentKey = computed(() => {
|
||||
if (chatMode.value === 'orchestrate') return '__orchestrate__'
|
||||
return currentAgentId.value || '__bare__'
|
||||
@@ -364,6 +493,13 @@ function addOrchestrateAgent() {
|
||||
watch(chatMode, saveState)
|
||||
watch(orchestrateMode, saveState)
|
||||
|
||||
function handleScroll() {
|
||||
if (!messagesRef.value) return
|
||||
const el = messagesRef.value
|
||||
const threshold = 60
|
||||
isNearBottom.value = (el.scrollHeight - el.scrollTop - el.clientHeight) < threshold
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAgents()
|
||||
|
||||
@@ -374,7 +510,6 @@ onMounted(async () => {
|
||||
chatMode.value = saved.chatMode
|
||||
orchestrateMode.value = saved.orchestrateMode
|
||||
orchestrateAgents.value = saved.orchestrateAgents
|
||||
// 恢复展开状态
|
||||
for (const arr of Object.values(messages.value)) {
|
||||
arr.forEach(m => { if (m.steps?.length) m._traceOpen = false })
|
||||
}
|
||||
@@ -388,7 +523,14 @@ onMounted(async () => {
|
||||
await switchAgent()
|
||||
}
|
||||
|
||||
nextTick(scrollToBottom)
|
||||
nextTick(() => {
|
||||
messagesRef.value?.addEventListener('scroll', handleScroll, { passive: true })
|
||||
scrollToBottom()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
messagesRef.value?.removeEventListener('scroll', handleScroll)
|
||||
})
|
||||
|
||||
async function loadAgents() {
|
||||
@@ -397,10 +539,11 @@ async function loadAgents() {
|
||||
}
|
||||
|
||||
async function switchAgent() {
|
||||
if (!currentAgentId.value) { agent.value = null; saveState(); nextTick(scrollToBottom); return }
|
||||
if (!currentAgentId.value) { agent.value = null; currentSessionId.value = ''; sessions.value = []; saveState(); nextTick(scrollToBottom); return }
|
||||
try {
|
||||
const resp = await api.get(`/api/v1/agents/${currentAgentId.value}`)
|
||||
agent.value = resp.data
|
||||
await loadSessions()
|
||||
saveState()
|
||||
nextTick(scrollToBottom)
|
||||
} catch {
|
||||
@@ -409,14 +552,139 @@ async function switchAgent() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
if (!currentAgentId.value) return
|
||||
sessionsLoading.value = true
|
||||
try {
|
||||
const resp = await api.get(`/api/v1/agent-chat/${currentAgentId.value}/sessions?limit=50`)
|
||||
sessions.value = resp.data.sessions || []
|
||||
} catch { /* 静默失败 */ }
|
||||
finally { sessionsLoading.value = false }
|
||||
}
|
||||
|
||||
async function switchSession(sid: string) {
|
||||
if (sid === '__new__') {
|
||||
newSession()
|
||||
return
|
||||
}
|
||||
currentSessionId.value = sid
|
||||
const key = currentAgentKey.value
|
||||
sessionId.value[key] = sid
|
||||
sessionsLoading.value = true
|
||||
try {
|
||||
const resp = await api.get(`/api/v1/agent-chat/${currentAgentId.value}/sessions/${sid}/messages?limit=200`)
|
||||
const serverMessages = resp.data.messages || []
|
||||
messages.value[key] = serverMessages.map((m: any) => ({
|
||||
role: m.role === 'user' ? 'user' : 'assistant',
|
||||
content: m.content || '',
|
||||
timestamp: m.created_at ? new Date(m.created_at).getTime() : Date.now(),
|
||||
tool_calls: m.tool_name ? [{ function: { name: m.tool_name, arguments: m.tool_input } }] : [],
|
||||
}))
|
||||
} catch {
|
||||
ElMessage.warning('加载对话历史失败')
|
||||
}
|
||||
finally { sessionsLoading.value = false; saveState(); nextTick(scrollToBottom) }
|
||||
}
|
||||
|
||||
function newSession() {
|
||||
const key = currentAgentKey.value
|
||||
messages.value[key] = []
|
||||
sessionId.value[key] = ''
|
||||
currentSessionId.value = ''
|
||||
saveState()
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
function getPresetQuestions(): string[] {
|
||||
const name = agent.value?.name || ''
|
||||
const desc = agent.value?.description || ''
|
||||
const combined = `${name} ${desc}`.toLowerCase()
|
||||
const questions: string[] = []
|
||||
if (combined.includes('代码') || combined.includes('编程') || combined.includes('开发')) {
|
||||
questions.push('帮我写一个 Python 脚本来处理 CSV 文件', '解释一下这段代码的作用和潜在问题', '如何设计一个 RESTful API 接口?')
|
||||
} else if (combined.includes('写作') || combined.includes('文案') || combined.includes('文章')) {
|
||||
questions.push('帮我写一篇关于 AI 发展的文章大纲', '润色这段文字,让它更加流畅易读', '给我几个吸引眼球的标题方案')
|
||||
} else if (combined.includes('分析') || combined.includes('数据') || combined.includes('报告')) {
|
||||
questions.push('分析这组数据的趋势和异常点', '帮我总结这份报告的核心要点', '对比 A 和 B 两个方案的优劣')
|
||||
} else {
|
||||
questions.push(`介绍一下你的能力和可以使用的工具`, '帮我分析一个复杂问题', '搜索并整理最新的相关信息')
|
||||
}
|
||||
return questions
|
||||
}
|
||||
|
||||
function sendPresetQuestion(q: string) {
|
||||
inputMessage.value = q
|
||||
nextTick(() => sendMessage())
|
||||
}
|
||||
|
||||
// TTS
|
||||
function speakMessage(text: string) {
|
||||
if (!text) return
|
||||
ttsSpeak(text)
|
||||
}
|
||||
function stopSpeak() {
|
||||
ttsStop()
|
||||
}
|
||||
|
||||
// Voice input
|
||||
function startListening() {
|
||||
voiceStart('zh-CN')
|
||||
}
|
||||
function stopListening() {
|
||||
voiceStop()
|
||||
if (transcript.value) {
|
||||
inputMessage.value = (inputMessage.value + ' ' + transcript.value).trim()
|
||||
transcript.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
watch(transcript, (val) => {
|
||||
if (val && isListening.value) { /* display during recording */ }
|
||||
})
|
||||
|
||||
function relativeTimeText(isoStr?: string): string {
|
||||
if (!isoStr) return ''
|
||||
const diff = Date.now() - new Date(isoStr).getTime()
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
|
||||
return `${Math.floor(diff / 86400000)} 天前`
|
||||
}
|
||||
|
||||
// onDragLeave handled by ChatInputArea + parent wiring
|
||||
// The drag events from useFileUpload can be applied directly since ChatInputArea emits events
|
||||
function onInputDragEnter(e: DragEvent) {
|
||||
if (loading.value || (chatMode.value === 'single' && !currentAgentId.value)) return
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||
inputDragOver.value = true
|
||||
}
|
||||
|
||||
async function onInputDrop(e: DragEvent) {
|
||||
inputDragOver.value = false
|
||||
if (chatMode.value === 'single' && !currentAgentId.value) {
|
||||
ElMessage.warning('请先选择智能体再上传文件')
|
||||
return
|
||||
}
|
||||
const files = e.dataTransfer?.files
|
||||
if (files?.length) await uploadFiles(Array.from(files))
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = inputMessage.value.trim()
|
||||
if (!text || loading.value) return
|
||||
const hasAttachments = pendingAttachments.value.length > 0
|
||||
if (!text && !hasAttachments) return
|
||||
if (loading.value) return
|
||||
|
||||
const attachCtx = buildAttachmentContext()
|
||||
const fullText = text + attachCtx
|
||||
|
||||
const key = currentAgentKey.value
|
||||
if (!messages.value[key]) messages.value[key] = []
|
||||
messages.value[key].push({ role: 'user', content: text, timestamp: Date.now() })
|
||||
messages.value[key].push({ role: 'user', content: fullText, timestamp: Date.now() })
|
||||
inputMessage.value = ''
|
||||
pendingAttachments.value = []
|
||||
inputDragOver.value = false
|
||||
loading.value = true
|
||||
scrollToBottom()
|
||||
|
||||
@@ -443,12 +711,12 @@ async function sendMessage() {
|
||||
? `/api/v1/agent-chat/${currentAgentId.value}/stream`
|
||||
: '/api/v1/agent-chat/bare/stream'
|
||||
|
||||
// 尝试 SSE 流式(带超时控制)
|
||||
let usedStreaming = false
|
||||
let placeholderIdx = -1
|
||||
streamingActive.value = false
|
||||
const abortController = new AbortController()
|
||||
const streamTimeout = setTimeout(() => abortController.abort(), 300000) // 5分钟超时,适应复杂 Agent 任务
|
||||
abortController.value = new AbortController()
|
||||
const streamTimeout = setTimeout(() => abortController.value?.abort(), 300000)
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token') || ''
|
||||
const resp = await fetch(streamEndpoint, {
|
||||
@@ -457,14 +725,19 @@ async function sendMessage() {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ message: text, session_id: sessId || undefined }),
|
||||
signal: abortController.signal,
|
||||
body: JSON.stringify({
|
||||
message: text,
|
||||
session_id: sessId || undefined,
|
||||
model: chatModel.value || undefined,
|
||||
temperature: chatTemperature.value,
|
||||
max_iterations: chatMaxIterations.value,
|
||||
}),
|
||||
signal: abortController.value.signal,
|
||||
})
|
||||
|
||||
if (resp.ok && resp.body) {
|
||||
usedStreaming = true
|
||||
|
||||
// 创建占位消息,流式更新
|
||||
const msg: ChatMessage = {
|
||||
role: 'assistant', content: '', timestamp: Date.now(),
|
||||
steps: [], _traceOpen: true, iterations: 0, tool_calls_made: 0,
|
||||
@@ -498,7 +771,6 @@ async function sendMessage() {
|
||||
try {
|
||||
const data = JSON.parse(dataStr)
|
||||
|
||||
// 首个事件到达 → 隐藏 loading dots,无论什么事件类型
|
||||
if (!receivedFirstEvent) {
|
||||
receivedFirstEvent = true
|
||||
streamingActive.value = true
|
||||
@@ -538,6 +810,10 @@ async function sendMessage() {
|
||||
currentMsg.tool_calls_made = data.tool_calls_made || 0
|
||||
if (data.session_id) {
|
||||
sessionId.value[key] = data.session_id
|
||||
if (!currentSessionId.value) {
|
||||
currentSessionId.value = data.session_id
|
||||
loadSessions()
|
||||
}
|
||||
}
|
||||
streamingActive.value = false
|
||||
loading.value = false
|
||||
@@ -557,24 +833,31 @@ async function sendMessage() {
|
||||
}
|
||||
} catch {
|
||||
clearTimeout(streamTimeout)
|
||||
// 流式失败时标记为非流式,让 fallback POST 兜底
|
||||
usedStreaming = false
|
||||
streamingActive.value = false
|
||||
}
|
||||
|
||||
if (!usedStreaming) {
|
||||
// 移除 SSE 阶段残留的占位消息(Issue #1)
|
||||
if (placeholderIdx >= 0 && placeholderIdx < messages.value[key].length) {
|
||||
messages.value[key].splice(placeholderIdx, 1)
|
||||
}
|
||||
|
||||
// 降级:标准 POST 请求
|
||||
const fallbackEndpoint = currentAgentId.value
|
||||
? `/api/v1/agent-chat/${currentAgentId.value}`
|
||||
: '/api/v1/agent-chat/bare'
|
||||
const resp = await api.post(fallbackEndpoint, { message: text, session_id: sessId || undefined })
|
||||
const resp = await api.post(fallbackEndpoint, {
|
||||
message: text,
|
||||
session_id: sessId || undefined,
|
||||
model: chatModel.value || undefined,
|
||||
temperature: chatTemperature.value,
|
||||
max_iterations: chatMaxIterations.value,
|
||||
})
|
||||
const data = resp.data
|
||||
sessionId.value[key] = data.session_id
|
||||
if (!currentSessionId.value) {
|
||||
currentSessionId.value = data.session_id
|
||||
loadSessions()
|
||||
}
|
||||
messages.value[key].push({
|
||||
role: 'assistant', content: data.content, timestamp: Date.now(),
|
||||
iterations: data.iterations_used, tool_calls_made: data.tool_calls_made,
|
||||
@@ -601,16 +884,23 @@ function clearChat() {
|
||||
messages.value[key] = []
|
||||
if (chatMode.value === 'single') {
|
||||
sessionId.value[key] = ''
|
||||
currentSessionId.value = ''
|
||||
}
|
||||
saveState()
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
function scrollToBottom() { nextTick(() => { if (messagesRef.value && isNearBottom.value) messagesRef.value.scrollTop = messagesRef.value.scrollHeight }) }
|
||||
function stopGeneration() {
|
||||
abortController.value?.abort()
|
||||
loading.value = false
|
||||
streamingActive.value = false
|
||||
saveState()
|
||||
}
|
||||
function scrollToBottom() { nextTick(() => { if (messagesRef.value) messagesRef.value.scrollTop = messagesRef.value.scrollHeight }) }
|
||||
|
||||
function safeParseArgCount(args?: string): number {
|
||||
if (!args) return 0
|
||||
try {
|
||||
return Object.keys(JSON.parse(args)).length
|
||||
} catch { return 0 }
|
||||
try { return Object.keys(JSON.parse(args)).length }
|
||||
catch { return 0 }
|
||||
}
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
@@ -622,6 +912,27 @@ function relativeTime(ts: number): string {
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function editMessage(idx: number) {
|
||||
const key = currentAgentKey.value
|
||||
const msgs = messages.value[key]
|
||||
if (!msgs || !msgs[idx]) return
|
||||
const msg = msgs[idx]
|
||||
if (msg.role !== 'user') return
|
||||
inputMessage.value = msg.content
|
||||
msgs.splice(idx, 1)
|
||||
saveState()
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
function deleteMessage(idx: number) {
|
||||
const key = currentAgentKey.value
|
||||
const msgs = messages.value[key]
|
||||
if (!msgs || !msgs[idx]) return
|
||||
msgs.splice(idx, 1)
|
||||
saveState()
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
function copyMessage(msg: ChatMessage) {
|
||||
const text = msg.orchestrateResult?.final_answer || msg.content
|
||||
if (!text) return
|
||||
@@ -637,7 +948,6 @@ function retryMessage(idx: number) {
|
||||
const msgs = messages.value[key]
|
||||
if (!msgs) return
|
||||
|
||||
// 查找错误消息之前的最后一条用户消息
|
||||
let userMsg = ''
|
||||
let userIdx = -1
|
||||
for (let i = idx - 1; i >= 0; i--) {
|
||||
@@ -652,38 +962,23 @@ function retryMessage(idx: number) {
|
||||
return
|
||||
}
|
||||
|
||||
// 收集需要移除的消息:用户消息 + 错误消息 + 中间的流式占位消息
|
||||
const removeIndices: number[] = [userIdx, idx]
|
||||
for (let i = userIdx + 1; i < idx; i++) {
|
||||
const m = msgs[i]
|
||||
// 流式占位消息:assistant 角色,内容为空或有 steps 但无实质内容
|
||||
if (m.role === 'assistant' && (!m.content || m.content === '') && (m.steps?.length || 0) > 0) {
|
||||
removeIndices.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
// 从后往前删除,避免 index 错乱
|
||||
removeIndices.sort((a, b) => b - a)
|
||||
for (const ri of removeIndices) {
|
||||
msgs.splice(ri, 1)
|
||||
}
|
||||
|
||||
saveState()
|
||||
// 填入输入框并发送
|
||||
inputMessage.value = userMsg
|
||||
nextTick(() => sendMessage())
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
if (!text) return ''
|
||||
// XSS 防护: 先转义 HTML 特殊字符,再对安全模式做 Markdown 渲染
|
||||
return text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\n/g, '<br>')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -693,26 +988,46 @@ function renderMarkdown(text: string): string {
|
||||
.page-header-left h3 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||
.page-header-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.chat-messages { flex: 1; overflow-y: auto; padding: 8px 0; display: flex; flex-direction: column; gap: 12px; min-height: 0; }
|
||||
.chat-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--el-text-color-secondary); gap: 12px; }
|
||||
.chat-empty .hint { font-size: 13px; color: var(--el-text-color-placeholder); }
|
||||
.chat-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--el-text-color-secondary); gap: 12px; padding: 40px 20px; text-align: center; }
|
||||
.chat-empty .hint { font-size: 13px; color: var(--el-text-color-placeholder); margin-top: 8px; }
|
||||
.welcome-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
||||
.welcome-desc { margin: 0; font-size: 14px; color: var(--el-text-color-secondary); max-width: 480px; line-height: 1.6; }
|
||||
.welcome-tags { display: flex; gap: 6px; }
|
||||
.preset-questions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; max-width: 520px; margin-top: 4px; }
|
||||
.preset-q { padding: 8px 16px; background: var(--el-fill-color); border: 1px solid var(--el-border-color-light); border-radius: 20px; font-size: 13px; cursor: pointer; transition: all 0.15s; color: var(--el-text-color-regular); }
|
||||
.preset-q:hover { background: var(--el-color-primary-light-9); border-color: var(--el-color-primary-light-5); color: var(--el-color-primary); }
|
||||
.agent-quick-list { display: flex; flex-wrap: wrap; gap: 4px; justify-content: center; max-width: 480px; }
|
||||
|
||||
/* Chat settings popover */
|
||||
.chat-settings-popover { padding: 4px 0; }
|
||||
.message { display: flex; gap: 12px; max-width: 88%; }
|
||||
.message.user { align-self: flex-end; flex-direction: row-reverse; }
|
||||
.message.assistant { align-self: flex-start; }
|
||||
.message-bubble { padding: 10px 14px; border-radius: 12px; background: var(--el-fill-color-light); line-height: 1.6; font-size: 14px; }
|
||||
.message.user .message-bubble { background: var(--el-color-primary-light-8); }
|
||||
.message.error .message-bubble { border: 1px solid var(--el-color-danger-light-5); }
|
||||
.message-text :deep(pre) { background: var(--el-fill-color); padding: 12px; border-radius: 8px; overflow-x: auto; font-size: 13px; }
|
||||
.message-text :deep(code) { background: var(--el-fill-color); padding: 2px 6px; border-radius: 4px; font-size: 13px; }
|
||||
.message-text :deep(pre) { background: #1e1e2e; color: #cdd6f4; padding: 14px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.55; margin: 8px 0; }
|
||||
.message-text :deep(pre code) { background: none; padding: 0; border-radius: 0; font-size: inherit; color: inherit; }
|
||||
.message-text :deep(code) { background: var(--el-fill-color-darker); padding: 2px 6px; border-radius: 4px; font-size: 13px; font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace; }
|
||||
.message-text :deep(h1) { font-size: 1.4em; font-weight: 700; margin: 12px 0 6px; }
|
||||
.message-text :deep(h2) { font-size: 1.2em; font-weight: 700; margin: 10px 0 4px; }
|
||||
.message-text :deep(h3) { font-size: 1.1em; font-weight: 600; margin: 8px 0 4px; }
|
||||
.message-text :deep(ul), .message-text :deep(ol) { padding-left: 20px; margin: 4px 0; }
|
||||
.message-text :deep(li) { margin: 2px 0; }
|
||||
.message-text :deep(blockquote) { border-left: 3px solid var(--el-color-primary); margin: 8px 0; padding: 4px 12px; color: var(--el-text-color-secondary); background: var(--el-fill-color-lighter); border-radius: 0 6px 6px 0; }
|
||||
.message-text :deep(table) { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 13px; }
|
||||
.message-text :deep(th), .message-text :deep(td) { border: 1px solid var(--el-border-color); padding: 6px 10px; text-align: left; }
|
||||
.message-text :deep(th) { background: var(--el-fill-color); font-weight: 600; }
|
||||
.message-text :deep(a) { color: var(--el-color-primary); text-decoration: underline; }
|
||||
.message-text :deep(hr) { border: none; border-top: 1px solid var(--el-border-color-light); margin: 12px 0; }
|
||||
.message-text :deep(p) { margin: 4px 0; }
|
||||
.message-text :deep(img) { max-width: 100%; border-radius: 8px; margin: 6px 0; }
|
||||
|
||||
/* Tool calls */
|
||||
.tool-calls { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--el-border-color-light); }
|
||||
.tool-calls-header { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 4px; }
|
||||
.tool-call-item { display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 12px; }
|
||||
.tool-name { font-weight: 500; color: var(--el-color-primary); }
|
||||
.message-meta { font-size: 11px; color: var(--el-text-color-placeholder); margin-top: 4px; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
||||
.meta-iterations { color: var(--el-color-info); }
|
||||
.meta-actions { margin-left: auto; display: flex; gap: 2px; opacity: 0; transition: opacity 0.15s; }
|
||||
.message-bubble:hover .meta-actions { opacity: 1; }
|
||||
|
||||
/* Thinking trace */
|
||||
.thinking-trace { margin-top: 10px; border-top: 1px solid var(--el-border-color-light); padding-top: 8px; }
|
||||
@@ -745,10 +1060,9 @@ function renderMarkdown(text: string): string {
|
||||
.dot:nth-child(3) { animation-delay: 0.32s; }
|
||||
@keyframes bounce { 0%,80%,100% { transform: scale(0); } 40% { transform: scale(1); } }
|
||||
|
||||
/* Chat input */
|
||||
.chat-input { display: flex; gap: 12px; padding-top: 12px; border-top: 1px solid var(--el-border-color-light); }
|
||||
.chat-input .el-textarea { flex: 1; }
|
||||
.send-btn { align-self: flex-end; min-width: 100px; }
|
||||
/* Session selector */
|
||||
.session-option-title { font-size: 13px; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 260px; }
|
||||
.session-option-meta { font-size: 11px; color: var(--el-text-color-placeholder); }
|
||||
|
||||
/* Orchestrate result */
|
||||
.orchestrate-result { font-size: 14px; }
|
||||
|
||||
Reference in New Issue
Block a user