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:
2026-07-04 14:45:55 +08:00
parent 876789fac1
commit fbf8dbbe86
15 changed files with 1280 additions and 994 deletions

View File

@@ -23,9 +23,9 @@ class AgentRepository @Inject constructor(
val agents = response.body()?.map { dto -> val agents = response.body()?.map { dto ->
Agent( Agent(
id = dto.id, id = dto.id,
name = dto.name, name = dto.name ?: "",
description = dto.description ?: "", description = dto.description ?: "",
status = dto.status, status = dto.status ?: "published",
userId = dto.userId, userId = dto.userId,
version = dto.version, version = dto.version,
createdAt = dto.createdAt, createdAt = dto.createdAt,
@@ -48,9 +48,9 @@ class AgentRepository @Inject constructor(
Result.success( Result.success(
Agent( Agent(
id = dto.id, id = dto.id,
name = dto.name, name = dto.name ?: "",
description = dto.description ?: "", description = dto.description ?: "",
status = dto.status, status = dto.status ?: "published",
userId = dto.userId, userId = dto.userId,
version = dto.version, version = dto.version,
createdAt = dto.createdAt, createdAt = dto.createdAt,
@@ -148,9 +148,9 @@ class AgentRepository @Inject constructor(
private fun com.tiangong.aiagent.data.remote.dto.AgentResponse.toDomain() = Agent( private fun com.tiangong.aiagent.data.remote.dto.AgentResponse.toDomain() = Agent(
id = id, id = id,
name = name, name = name ?: "",
description = description ?: "", description = description ?: "",
status = status, status = status ?: "published",
userId = userId, userId = userId,
version = version, version = version,
createdAt = createdAt, createdAt = createdAt,

View File

@@ -6,6 +6,10 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.GsonBuilder 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.BuildConfig
import com.tiangong.aiagent.data.local.AppDatabase import com.tiangong.aiagent.data.local.AppDatabase
import com.tiangong.aiagent.data.local.CredentialStore import com.tiangong.aiagent.data.local.CredentialStore
@@ -33,9 +37,29 @@ object AppModule {
@Singleton @Singleton
fun provideGson(): Gson { fun provideGson(): Gson {
return GsonBuilder() return GsonBuilder()
.registerTypeAdapter(String::class.java, NullSafeStringAdapter())
.create() .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 @Provides
@Singleton @Singleton
fun provideLoggingInterceptor(): HttpLoggingInterceptor { fun provideLoggingInterceptor(): HttpLoggingInterceptor {

View File

@@ -262,7 +262,7 @@ fun AgentListItem(
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
Text( Text(
text = agent.name.take(1), text = (agent.name ?: "AI").take(1),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer color = MaterialTheme.colorScheme.onPrimaryContainer
@@ -275,7 +275,7 @@ fun AgentListItem(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = agent.name, text = agent.name ?: "未知",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
@@ -293,6 +293,7 @@ fun AgentListItem(
"running" -> "运行中" "running" -> "运行中"
"draft" -> "草稿" "draft" -> "草稿"
"stopped" -> "已停止" "stopped" -> "已停止"
null -> "未知"
else -> agent.status else -> agent.status
}, },
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), 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)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = agent.description, text = agent.description,

View File

@@ -5,6 +5,8 @@ import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts 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.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -19,7 +21,9 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -110,6 +114,10 @@ fun ChatScreen(
var lastLoadMoreTime by remember { mutableStateOf(0L) } 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) { LaunchedEffect(isAtTop) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (isAtTop && uiState.hasMoreMessages && !uiState.isLoadingMore && initialScrollDone if (isAtTop && uiState.hasMoreMessages && !uiState.isLoadingMore && initialScrollDone
@@ -126,6 +134,7 @@ fun ChatScreen(
} }
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
val haptic = LocalHapticFeedback.current
// Image picker (multi-select, max 9) // Image picker (multi-select, max 9)
val imagePickerLauncher = rememberLauncherForActivityResult( val imagePickerLauncher = rememberLauncherForActivityResult(
@@ -197,6 +206,26 @@ fun ChatScreen(
} }
) { ) {
Scaffold( 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 = { topBar = {
TopAppBar( TopAppBar(
navigationIcon = { navigationIcon = {
@@ -247,7 +276,10 @@ fun ChatScreen(
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
FilledTonalButton( FilledTonalButton(
onClick = { viewModel.stopGeneration() }, onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
viewModel.stopGeneration()
},
colors = ButtonDefaults.filledTonalButtonColors( colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer 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( IconButton(
onClick = { onClick = {
if (inputText.isNotBlank()) { if (inputText.isNotBlank()) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
val text = inputText val text = inputText
inputText = "" inputText = ""
if (uiState.editingMessageId != null) { if (uiState.editingMessageId != null) {
@@ -339,14 +381,12 @@ fun ChatScreen(
} }
} }
}, },
enabled = inputText.isNotBlank() && !uiState.isLoading && !uiState.isStreaming && !uiState.isOffline enabled = sendEnabled
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.Send, imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "发送", contentDescription = "发送",
tint = if (inputText.isNotBlank() && !uiState.isLoading && !uiState.isStreaming && !uiState.isOffline) tint = sendColor
MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
@@ -472,7 +512,10 @@ fun ChatScreen(
) )
} }
FilledTonalButton( FilledTonalButton(
onClick = { viewModel.approveToolCall() }, onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
viewModel.approveToolCall()
},
modifier = Modifier.padding(end = 8.dp) modifier = Modifier.padding(end = 8.dp)
) { ) {
Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp))
@@ -480,7 +523,10 @@ fun ChatScreen(
Text("允许") Text("允许")
} }
OutlinedButton( OutlinedButton(
onClick = { viewModel.rejectToolCall() } onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
viewModel.rejectToolCall()
}
) { ) {
Icon(Icons.Default.Close, contentDescription = null, modifier = Modifier.size(16.dp)) Icon(Icons.Default.Close, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))

View File

@@ -1,5 +1,6 @@
package com.tiangong.aiagent.ui.chat.components package com.tiangong.aiagent.ui.chat.components
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -35,7 +36,8 @@ fun MessageBubble(message: Message) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 12.dp), .padding(vertical = 4.dp, horizontal = 12.dp)
.animateContentSize(),
horizontalAlignment = alignment horizontalAlignment = alignment
) { ) {
if (!isUser && !isTool) { if (!isUser && !isTool) {

View File

@@ -1,15 +1,18 @@
package com.tiangong.aiagent.ui.chat.components 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.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.dp
import androidx.compose.ui.unit.sp
import com.tiangong.aiagent.util.MarkdownRenderer import com.tiangong.aiagent.util.MarkdownRenderer
@Composable @Composable
@@ -18,7 +21,7 @@ fun StreamingText(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (content.isEmpty()) { if (content.isEmpty()) {
// Show thinking indicator // Animated thinking indicator with pulsing dots
Row( Row(
modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp), modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -28,13 +31,19 @@ fun StreamingText(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(6.dp))
// Simple dot animation repeat(3) { index ->
Text( val alpha by animateDotAlpha(index)
text = "▌", Box(
style = MaterialTheme.typography.bodySmall, modifier = Modifier
color = MaterialTheme.colorScheme.primary .padding(horizontal = 1.5.dp)
) .size(5.dp)
.clip(CircleShape)
.background(
MaterialTheme.colorScheme.primary.copy(alpha = alpha)
)
)
}
} }
} else { } else {
Surface( 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"
)
}

View File

@@ -3,6 +3,7 @@ package com.tiangong.aiagent.ui.chat.components
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -73,8 +74,8 @@ fun ThinkTracePanel(
// Expanded content // Expanded content
AnimatedVisibility( AnimatedVisibility(
visible = expanded, visible = expanded,
enter = expandVertically(), enter = expandVertically(spring(dampingRatio = 0.65f, stiffness = 300f)),
exit = shrinkVertically() exit = shrinkVertically(spring(dampingRatio = 0.8f, stiffness = 300f))
) { ) {
Column( Column(
modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 10.dp) modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 10.dp)

View File

@@ -270,7 +270,7 @@ private fun AgentCard(
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
Text( Text(
agent.name, agent.name ?: "",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 1, maxLines = 1,
@@ -351,7 +351,7 @@ private fun AgentListItem(
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
agent.name, agent.name ?: "",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 1, maxLines = 1,
@@ -422,7 +422,7 @@ private fun TemplateCard(
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
template.name, template.name ?: "",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
@@ -482,7 +482,7 @@ private fun SceneTemplateCard(
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
template.name, template.name ?: "",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )

View File

@@ -486,7 +486,7 @@ private fun KnowledgeEntitiesTab(
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = item.name, text = item.name ?: "",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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,
}
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
}
}
return { renderMarkdown }
}

View File

@@ -21,6 +21,31 @@
<span>{{ a.name }}</span> <span>{{ a.name }}</span>
</el-option> </el-option>
</el-select> </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> </template>
<!-- 编排模式:模式选择 + Agent 数 --> <!-- 编排模式:模式选择 + Agent 数 -->
@@ -41,20 +66,54 @@
</div> </div>
<div class="chat-messages" ref="messagesRef"> <div class="chat-messages" ref="messagesRef">
<div v-if="displayMessages.length === 0" class="chat-empty"> <!-- Rich empty state -->
<el-icon :size="48"><ChatLineSquare /></el-icon> <div v-if="displayMessages.length === 0 && !loading" class="chat-empty">
<p v-if="chatMode === 'single'">选择一个 Agent 开始对话</p> <template v-if="chatMode === 'single' && agent">
<p v-else>配置多个 Agent 后发送消息进行编排对话</p> <el-avatar :size="56" icon="Promotion" style="background:var(--el-color-primary-light-5);margin-bottom:8px" />
<p class="hint">Agent 可以使用内置工具帮你完成任务</p> <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>
<div v-for="(msg, i) in displayMessages" :key="i" class="message" :class="[msg.role, msg.status === 'error' ? 'error' : '']"> <!-- Messages using shared ChatMessageBubble -->
<div class="message-avatar"> <ChatMessageBubble
<el-avatar :size="36" :icon="msg.role === 'user' ? UserFilled : Promotion" /> v-for="(msg, i) in displayMessages"
</div> :key="i"
<div class="message-bubble"> :role="msg.role"
<!-- 编排模式:显示每个 Agent 的独立输出 --> :content-html="renderMarkdown(msg.orchestrateResult?.final_answer || msg.content)"
<div v-if="msg.orchestrateResult" class="orchestrate-result"> :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"> <div class="orch-header">
<el-tag size="small" type="info">{{ msg.orchestrateResult.mode }}</el-tag> <el-tag size="small" type="info">{{ msg.orchestrateResult.mode }}</el-tag>
<span class="orch-agent-count">{{ msg.orchestrateResult.steps.length }} 个 Agent</span> <span class="orch-agent-count">{{ msg.orchestrateResult.steps.length }} 个 Agent</span>
@@ -85,72 +144,76 @@
</div> </div>
</div> </div>
</div> </div>
</template>
<!-- 单 Agent 模式:原有内容 --> <!-- Tool calls + thinking trace for non-orchestrate messages -->
<template v-if="!msg.orchestrateResult"> <template v-if="!msg.orchestrateResult" #extra>
<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 v-if="msg.tool_calls && msg.tool_calls.length > 0" class="tool-calls"> <div class="tool-calls-header">
<div class="tool-calls-header"> <el-icon><Tools /></el-icon> 工具调用 ({{ msg.tool_calls.length }})
<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>
<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 v-if="msg.steps && msg.steps.length > 0" class="thinking-trace"> <div class="trace-header" @click="toggleTrace(msg)">
<div class="trace-header" @click="toggleTrace(msg)"> <el-icon><CaretRight :style="{ transform: msg._traceOpen ? 'rotate(90deg)' : '' }" /></el-icon>
<el-icon><CaretRight :style="{ transform: msg._traceOpen ? 'rotate(90deg)' : '' }" /></el-icon> <span>思考链 ({{ msg.steps.length }} 步)</span>
<span>思考链 ({{ msg.steps.length }} 步)</span> </div>
</div> <div v-show="msg._traceOpen" class="trace-steps">
<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 v-for="(step, si) in msg.steps" :key="si" class="trace-step" :class="'step-' + step.type"> <div class="step-icon">
<div class="step-icon"> <el-icon v-if="step.type === 'think'"><ChatDotSquare /></el-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-if="step.type === 'tool_result'"><Tools /></el-icon> <el-icon v-else><Select /></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>
<div class="step-body"> <div v-if="step.content" class="step-content" v-html="renderMarkdown(step.content)"></div>
<div class="step-header"> <div v-if="step.reasoning" class="step-reasoning">
<span class="step-tag" :class="'tag-' + step.type">{{ {think:'思考',tool_result:'工具结果',final:'最终回答'}[step.type] || step.type }}</span> <div class="reasoning-header">推理过程</div>
<span class="step-iter">#{{ step.iteration }}</span> <div class="reasoning-text">{{ step.reasoning }}</div>
<span v-if="step.tool_name" class="step-tool-name">{{ step.tool_name }}</span> </div>
</div> <div v-if="step.tool_input && Object.keys(step.tool_input).length" class="step-tool-input">
<div v-if="step.content" class="step-content" v-html="renderMarkdown(step.content)"></div> <div class="reasoning-header">参数</div>
<div v-if="step.reasoning" class="step-reasoning"> <pre>{{ JSON.stringify(step.tool_input, null, 2) }}</pre>
<div class="reasoning-header">推理过程</div> </div>
<div class="reasoning-text">{{ step.reasoning }}</div> <div v-if="step.tool_result" class="step-tool-result">
</div> <div class="reasoning-header">结果</div>
<div v-if="step.tool_input && Object.keys(step.tool_input).length" class="step-tool-input"> <pre>{{ step.tool_result }}</pre>
<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> </div>
</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>
</div>
<!-- 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 v-if="loading && !streamingActive" class="message assistant">
<div class="message-avatar"><el-avatar :size="36" icon="Promotion" /></div> <div class="message-avatar"><el-avatar :size="36" icon="Promotion" /></div>
@@ -160,12 +223,49 @@
</div> </div>
</div> </div>
<div class="chat-input"> <!-- Shared ChatInputArea -->
<el-input v-model="inputMessage" type="textarea" :rows="3" placeholder="输入你的问题..." @keydown.enter.exact.prevent="sendMessage" :disabled="loading" /> <ChatInputArea
<el-button type="primary" @click="sendMessage" :loading="loading" :disabled="!inputMessage.trim()" class="send-btn"> ref="inputAreaRef"
{{ loading ? '处理中...' : '发送' }} v-model="inputMessage"
</el-button> :loading="loading"
</div> :disabled="loading || (chatMode === 'single' && !currentAgentId)"
:attachments="pendingAttachments"
:drag-over="inputDragOver"
:show-stop="loading"
:is-listening="isListening"
:voice-supported="voiceSupported"
placeholder="输入你的问题...&#10;Enter 发送,Shift+Enter 换行&#10;支持拖拽或粘贴图片/文件"
@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 编辑器 --> <!-- 编排 Agent 编辑器 -->
<el-dialog v-model="showOrchestrateEditor" title="编排 Agent 配置" width="700px" @closed="saveState"> <el-dialog v-model="showOrchestrateEditor" title="编排 Agent 配置" width="700px" @closed="saveState">
@@ -224,14 +324,27 @@
</template> </template>
<script setup lang="ts"> <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 { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus' 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 MainLayout from '@/components/MainLayout.vue'
import ChatMessageBubble from '@/components/ChatMessageBubble.vue'
import ChatInputArea from '@/components/ChatInputArea.vue'
import api from '@/api' import api from '@/api'
import type { Agent } from '@/stores/agent' 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 { interface AgentStep {
iteration: number; type: string; content: string iteration: number; type: string; content: string
tool_name?: string; tool_input?: Record<string, any>; tool_result?: string; reasoning?: 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' const STORAGE_KEY = 'agent_chat_state'
interface ChatState { interface ChatState {
messages: Record<string, ChatMessage[]> // keyed by agentId / '__bare__' / '__orchestrate__' messages: Record<string, ChatMessage[]>
sessionId: Record<string, string> sessionId: Record<string, string>
currentAgentId: string currentAgentId: string
chatMode: 'single' | 'orchestrate' chatMode: 'single' | 'orchestrate'
@@ -297,12 +410,10 @@ function loadState(): ChatState | null {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null if (!raw) return null
const parsed = JSON.parse(raw) const parsed = JSON.parse(raw)
// TTL 过期检查:24小时后自动清除聊天记录
if (parsed._savedAt && Date.now() - parsed._savedAt > 24 * 3600 * 1000) { if (parsed._savedAt && Date.now() - parsed._savedAt > 24 * 3600 * 1000) {
localStorage.removeItem(STORAGE_KEY) localStorage.removeItem(STORAGE_KEY)
return null return null
} }
// 兼容旧格式:旧版本 messages 是数组,迁移为 Record
if (Array.isArray(parsed.messages)) { if (Array.isArray(parsed.messages)) {
const oldSessionId = parsed.sessionId || '' const oldSessionId = parsed.sessionId || ''
parsed.messages = { '__bare__': parsed.messages } parsed.messages = { '__bare__': parsed.messages }
@@ -319,15 +430,33 @@ const messages = ref<Record<string, ChatMessage[]>>({})
const inputMessage = ref('') const inputMessage = ref('')
const loading = ref(false) const loading = ref(false)
const streamingActive = 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 showApprovalDialog = ref(false)
const approvalId = ref('') const approvalId = ref('')
const approvalToolName = ref('') const approvalToolName = ref('')
const approvalArgs = ref<Record<string, any>>({}) const approvalArgs = ref<Record<string, any>>({})
const messagesRef = ref<HTMLElement | null>(null) const messagesRef = ref<HTMLElement | null>(null)
const isNearBottom = ref(true)
const sessionId = ref<Record<string, string>>({}) const sessionId = ref<Record<string, string>>({})
const agent = ref<Agent | null>(null) 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(() => { const currentAgentKey = computed(() => {
if (chatMode.value === 'orchestrate') return '__orchestrate__' if (chatMode.value === 'orchestrate') return '__orchestrate__'
return currentAgentId.value || '__bare__' return currentAgentId.value || '__bare__'
@@ -364,6 +493,13 @@ function addOrchestrateAgent() {
watch(chatMode, saveState) watch(chatMode, saveState)
watch(orchestrateMode, 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 () => { onMounted(async () => {
await loadAgents() await loadAgents()
@@ -374,7 +510,6 @@ onMounted(async () => {
chatMode.value = saved.chatMode chatMode.value = saved.chatMode
orchestrateMode.value = saved.orchestrateMode orchestrateMode.value = saved.orchestrateMode
orchestrateAgents.value = saved.orchestrateAgents orchestrateAgents.value = saved.orchestrateAgents
// 恢复展开状态
for (const arr of Object.values(messages.value)) { for (const arr of Object.values(messages.value)) {
arr.forEach(m => { if (m.steps?.length) m._traceOpen = false }) arr.forEach(m => { if (m.steps?.length) m._traceOpen = false })
} }
@@ -388,7 +523,14 @@ onMounted(async () => {
await switchAgent() await switchAgent()
} }
nextTick(scrollToBottom) nextTick(() => {
messagesRef.value?.addEventListener('scroll', handleScroll, { passive: true })
scrollToBottom()
})
})
onUnmounted(() => {
messagesRef.value?.removeEventListener('scroll', handleScroll)
}) })
async function loadAgents() { async function loadAgents() {
@@ -397,10 +539,11 @@ async function loadAgents() {
} }
async function switchAgent() { 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 { try {
const resp = await api.get(`/api/v1/agents/${currentAgentId.value}`) const resp = await api.get(`/api/v1/agents/${currentAgentId.value}`)
agent.value = resp.data agent.value = resp.data
await loadSessions()
saveState() saveState()
nextTick(scrollToBottom) nextTick(scrollToBottom)
} catch { } 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() { async function sendMessage() {
const text = inputMessage.value.trim() 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 const key = currentAgentKey.value
if (!messages.value[key]) messages.value[key] = [] 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 = '' inputMessage.value = ''
pendingAttachments.value = []
inputDragOver.value = false
loading.value = true loading.value = true
scrollToBottom() scrollToBottom()
@@ -443,12 +711,12 @@ async function sendMessage() {
? `/api/v1/agent-chat/${currentAgentId.value}/stream` ? `/api/v1/agent-chat/${currentAgentId.value}/stream`
: '/api/v1/agent-chat/bare/stream' : '/api/v1/agent-chat/bare/stream'
// 尝试 SSE 流式(带超时控制)
let usedStreaming = false let usedStreaming = false
let placeholderIdx = -1 let placeholderIdx = -1
streamingActive.value = false streamingActive.value = false
const abortController = new AbortController() abortController.value = new AbortController()
const streamTimeout = setTimeout(() => abortController.abort(), 300000) // 5分钟超时,适应复杂 Agent 任务 const streamTimeout = setTimeout(() => abortController.value?.abort(), 300000)
try { try {
const token = localStorage.getItem('token') || '' const token = localStorage.getItem('token') || ''
const resp = await fetch(streamEndpoint, { const resp = await fetch(streamEndpoint, {
@@ -457,14 +725,19 @@ async function sendMessage() {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}), ...(token ? { 'Authorization': `Bearer ${token}` } : {}),
}, },
body: JSON.stringify({ message: text, session_id: sessId || undefined }), body: JSON.stringify({
signal: abortController.signal, 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) { if (resp.ok && resp.body) {
usedStreaming = true usedStreaming = true
// 创建占位消息,流式更新
const msg: ChatMessage = { const msg: ChatMessage = {
role: 'assistant', content: '', timestamp: Date.now(), role: 'assistant', content: '', timestamp: Date.now(),
steps: [], _traceOpen: true, iterations: 0, tool_calls_made: 0, steps: [], _traceOpen: true, iterations: 0, tool_calls_made: 0,
@@ -498,7 +771,6 @@ async function sendMessage() {
try { try {
const data = JSON.parse(dataStr) const data = JSON.parse(dataStr)
// 首个事件到达 → 隐藏 loading dots,无论什么事件类型
if (!receivedFirstEvent) { if (!receivedFirstEvent) {
receivedFirstEvent = true receivedFirstEvent = true
streamingActive.value = true streamingActive.value = true
@@ -538,6 +810,10 @@ async function sendMessage() {
currentMsg.tool_calls_made = data.tool_calls_made || 0 currentMsg.tool_calls_made = data.tool_calls_made || 0
if (data.session_id) { if (data.session_id) {
sessionId.value[key] = data.session_id sessionId.value[key] = data.session_id
if (!currentSessionId.value) {
currentSessionId.value = data.session_id
loadSessions()
}
} }
streamingActive.value = false streamingActive.value = false
loading.value = false loading.value = false
@@ -557,24 +833,31 @@ async function sendMessage() {
} }
} catch { } catch {
clearTimeout(streamTimeout) clearTimeout(streamTimeout)
// 流式失败时标记为非流式,让 fallback POST 兜底
usedStreaming = false usedStreaming = false
streamingActive.value = false streamingActive.value = false
} }
if (!usedStreaming) { if (!usedStreaming) {
// 移除 SSE 阶段残留的占位消息(Issue #1)
if (placeholderIdx >= 0 && placeholderIdx < messages.value[key].length) { if (placeholderIdx >= 0 && placeholderIdx < messages.value[key].length) {
messages.value[key].splice(placeholderIdx, 1) messages.value[key].splice(placeholderIdx, 1)
} }
// 降级:标准 POST 请求
const fallbackEndpoint = currentAgentId.value const fallbackEndpoint = currentAgentId.value
? `/api/v1/agent-chat/${currentAgentId.value}` ? `/api/v1/agent-chat/${currentAgentId.value}`
: '/api/v1/agent-chat/bare' : '/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 const data = resp.data
sessionId.value[key] = data.session_id sessionId.value[key] = data.session_id
if (!currentSessionId.value) {
currentSessionId.value = data.session_id
loadSessions()
}
messages.value[key].push({ messages.value[key].push({
role: 'assistant', content: data.content, timestamp: Date.now(), role: 'assistant', content: data.content, timestamp: Date.now(),
iterations: data.iterations_used, tool_calls_made: data.tool_calls_made, iterations: data.iterations_used, tool_calls_made: data.tool_calls_made,
@@ -601,16 +884,23 @@ function clearChat() {
messages.value[key] = [] messages.value[key] = []
if (chatMode.value === 'single') { if (chatMode.value === 'single') {
sessionId.value[key] = '' sessionId.value[key] = ''
currentSessionId.value = ''
} }
saveState() 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 { function safeParseArgCount(args?: string): number {
if (!args) return 0 if (!args) return 0
try { try { return Object.keys(JSON.parse(args)).length }
return Object.keys(JSON.parse(args)).length catch { return 0 }
} catch { return 0 }
} }
function relativeTime(ts: number): string { 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')}` 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) { function copyMessage(msg: ChatMessage) {
const text = msg.orchestrateResult?.final_answer || msg.content const text = msg.orchestrateResult?.final_answer || msg.content
if (!text) return if (!text) return
@@ -637,7 +948,6 @@ function retryMessage(idx: number) {
const msgs = messages.value[key] const msgs = messages.value[key]
if (!msgs) return if (!msgs) return
// 查找错误消息之前的最后一条用户消息
let userMsg = '' let userMsg = ''
let userIdx = -1 let userIdx = -1
for (let i = idx - 1; i >= 0; i--) { for (let i = idx - 1; i >= 0; i--) {
@@ -652,38 +962,23 @@ function retryMessage(idx: number) {
return return
} }
// 收集需要移除的消息:用户消息 + 错误消息 + 中间的流式占位消息
const removeIndices: number[] = [userIdx, idx] const removeIndices: number[] = [userIdx, idx]
for (let i = userIdx + 1; i < idx; i++) { for (let i = userIdx + 1; i < idx; i++) {
const m = msgs[i] const m = msgs[i]
// 流式占位消息:assistant 角色,内容为空或有 steps 但无实质内容
if (m.role === 'assistant' && (!m.content || m.content === '') && (m.steps?.length || 0) > 0) { if (m.role === 'assistant' && (!m.content || m.content === '') && (m.steps?.length || 0) > 0) {
removeIndices.push(i) removeIndices.push(i)
} }
} }
// 从后往前删除,避免 index 错乱
removeIndices.sort((a, b) => b - a) removeIndices.sort((a, b) => b - a)
for (const ri of removeIndices) { for (const ri of removeIndices) {
msgs.splice(ri, 1) msgs.splice(ri, 1)
} }
saveState() saveState()
// 填入输入框并发送
inputMessage.value = userMsg inputMessage.value = userMsg
nextTick(() => sendMessage()) nextTick(() => sendMessage())
} }
function renderMarkdown(text: string): string {
if (!text) return ''
// XSS 防护: 先转义 HTML 特殊字符,再对安全模式做 Markdown 渲染
return text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
.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> </script>
<style scoped> <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-left h3 { margin: 0; font-size: 16px; font-weight: 600; }
.page-header-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } .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-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 { 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); } .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 { display: flex; gap: 12px; max-width: 88%; }
.message.user { align-self: flex-end; flex-direction: row-reverse; } .message.user { align-self: flex-end; flex-direction: row-reverse; }
.message.assistant { align-self: flex-start; } .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-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.user .message-bubble { background: var(--el-color-primary-light-8); }
.message.error .message-bubble { border: 1px solid var(--el-color-danger-light-5); } .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(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(code) { background: var(--el-fill-color); padding: 2px 6px; border-radius: 4px; font-size: 13px; } .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 */
.tool-calls { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--el-border-color-light); } .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-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-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); } .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 */
.thinking-trace { margin-top: 10px; border-top: 1px solid var(--el-border-color-light); padding-top: 8px; } .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; } .dot:nth-child(3) { animation-delay: 0.32s; }
@keyframes bounce { 0%,80%,100% { transform: scale(0); } 40% { transform: scale(1); } } @keyframes bounce { 0%,80%,100% { transform: scale(0); } 40% { transform: scale(1); } }
/* Chat input */ /* Session selector */
.chat-input { display: flex; gap: 12px; padding-top: 12px; border-top: 1px solid var(--el-border-color-light); } .session-option-title { font-size: 13px; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 260px; }
.chat-input .el-textarea { flex: 1; } .session-option-meta { font-size: 11px; color: var(--el-text-color-placeholder); }
.send-btn { align-self: flex-end; min-width: 100px; }
/* Orchestrate result */ /* Orchestrate result */
.orchestrate-result { font-size: 14px; } .orchestrate-result { font-size: 14px; }