dify历史记录

This commit is contained in:
2026-02-25 18:21:25 +08:00
parent 0efca50be3
commit 2e93211697
13 changed files with 1328 additions and 74 deletions

View File

@@ -18,9 +18,14 @@
<span class="agent-category">{{ agent?.categoryName || '通用' }}</span>
</div>
</div>
<button class="close-btn" @click="handleClose">
<CloseOutlined />
</button>
<div class="header-actions">
<button class="history-btn" @click="openHistory" title="历史记录">
<HistoryOutlined />
</button>
<button class="close-btn" @click="handleClose">
<CloseOutlined />
</button>
</div>
</header>
<!-- Main Content -->
@@ -139,13 +144,20 @@
<p class="footer-tip"> Enter 发送Shift + Enter 换行</p>
</footer>
</div>
<!-- History Drawer -->
<HistoryPanel
:visible="historyVisible"
:agent-id="agent?.id"
@close="closeHistory"
/>
</div>
</transition>
</teleport>
</template>
<script setup>
import { ref, watch, nextTick, computed } from 'vue'
import { ref, watch, nextTick } from 'vue'
import {
CloseOutlined,
RobotOutlined,
@@ -154,11 +166,13 @@ import {
ThunderboltFilled,
EditOutlined,
SendOutlined,
LoadingOutlined
LoadingOutlined,
HistoryOutlined
} from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import { sendChatStream } from '@/api/agent'
import { copyToClipboard } from '@/utils/clipboard'
import HistoryPanel from './HistoryPanel.vue'
const props = defineProps({
visible: { type: Boolean, default: false },
@@ -179,6 +193,9 @@ const textareaRef = ref(null)
const resultBodyRef = ref(null)
const abortController = ref(null)
// History State
const historyVisible = ref(false)
// Methods
const handleClose = (force = false) => {
// 正在生成时,不允许关闭
@@ -234,42 +251,10 @@ const handleGenerate = async () => {
isGenerating.value = true
generatedContent.value = ''
inputText.value = ''
if (textareaRef.value) textareaRef.value.style.height = 'auto'
abortController.value = new AbortController()
try {
await sendChatStream({
agentId: props.agent?.id,
content: prompt,
modelMode: modelMode.value,
ctrl: abortController.value,
onMessage: (result) => {
if (result.event === 'message' && result.content) {
generatedContent.value += result.content
scrollToBottom()
} else if (result.event === 'error') {
message.error(result.errorMessage || '生成出错')
}
},
onError: (error) => {
console.error('生成失败:', error)
message.error('生成失败,请重试')
if (!generatedContent.value) isGenerating.value = false
},
onClose: () => {
isGenerating.value = false
abortController.value = null
}
})
emit('send', { agentId: props.agent?.id, content: prompt, modelMode: modelMode.value })
} catch (error) {
if (error.name !== 'AbortError') {
message.error('生成失败,请重试')
}
isGenerating.value = false
}
await executeStreamRequest(prompt)
emit('send', { agentId: props.agent?.id, content: prompt, modelMode: modelMode.value })
}
const handleKeyDown = (e) => {
@@ -281,25 +266,17 @@ const handleKeyDown = (e) => {
const handleCopy = async (content) => {
const success = await copyToClipboard(content)
if (success) {
message.success('已复制')
} else {
message.error('复制失败')
}
success ? message.success('已复制') : message.error('复制失败')
}
const handleRegenerate = async () => {
if (!currentInput.value || isGenerating.value) return
generatedContent.value = ''
isGenerating.value = true
usedProMode.value = modelMode.value === 'pro'
// 执行流式请求
const executeStreamRequest = async (prompt) => {
abortController.value = new AbortController()
try {
await sendChatStream({
agentId: props.agent?.id,
content: currentInput.value,
content: prompt,
modelMode: modelMode.value,
ctrl: abortController.value,
onMessage: (result) => {
@@ -325,6 +302,14 @@ const handleRegenerate = async () => {
}
}
const handleRegenerate = async () => {
if (!currentInput.value || isGenerating.value) return
generatedContent.value = ''
isGenerating.value = true
usedProMode.value = modelMode.value === 'pro'
await executeStreamRequest(currentInput.value)
}
const resetTask = () => {
generatedContent.value = ''
currentInput.value = ''
@@ -336,6 +321,15 @@ const resetTask = () => {
nextTick(() => textareaRef.value?.focus())
}
// History Methods
const openHistory = () => {
historyVisible.value = true
}
const closeHistory = () => {
historyVisible.value = false
}
const scrollToBottom = () => {
nextTick(() => {
if (resultBodyRef.value) {
@@ -886,4 +880,30 @@ watch(() => props.visible, (val) => {
.drawer-fade-leave-to {
opacity: 0;
}
// Header Actions
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.history-btn {
width: 36px;
height: 36px;
border-radius: 10px;
border: none;
background: transparent;
color: @text-light;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
background: #f1f5f9;
color: @primary;
}
}
</style>

View File

@@ -0,0 +1,595 @@
<template>
<transition name="history-slide">
<div v-if="visible" class="history-panel">
<!-- Header -->
<header class="panel-header">
<div class="header-left">
<button v-if="selectedConversation" class="back-btn" @click="backToList">
<LeftOutlined />
</button>
<h3 class="header-title">{{ selectedConversation ? '对话详情' : '历史记录' }}</h3>
</div>
<button class="close-btn" @click="handleClose">
<CloseOutlined />
</button>
</header>
<!-- Content -->
<div class="panel-content">
<!-- Conversation List -->
<div v-if="!selectedConversation" class="conversation-view">
<a-spin :spinning="loading">
<!-- Empty State -->
<div v-if="groupedConversations.length === 0 && !loading" class="empty-state">
<div class="empty-visual">
<div class="visual-ring"></div>
<HistoryOutlined class="empty-icon" />
</div>
<p class="empty-text">暂无历史记录</p>
<p class="empty-hint">开始对话后记录将保存在这里</p>
</div>
<!-- Grouped List -->
<div v-else class="conversation-groups">
<div
v-for="group in groupedConversations"
:key="group.label"
class="conversation-group"
>
<div class="group-header">
<span class="group-label">{{ group.label }}</span>
<span class="group-count">{{ group.items.length }}</span>
</div>
<div class="group-list">
<button
v-for="item in group.items"
:key="item.id"
class="conversation-card"
@click="selectConversation(item)"
>
<div class="card-main">
<h4 class="card-title">{{ item.name || '未命名会话' }}</h4>
<p class="card-preview">{{ item.preview || '暂无预览' }}</p>
</div>
<div class="card-meta">
<span class="meta-time">
<ClockCircleOutlined />
{{ formatTime(item.updatedAt || item.createdAt) }}
</span>
<RightOutlined class="meta-arrow" />
</div>
</button>
</div>
</div>
</div>
</a-spin>
</div>
<!-- Message Detail -->
<div v-else class="message-view">
<a-spin :spinning="messageLoading">
<div v-if="messageList.length === 0 && !messageLoading" class="empty-state">
<CommentOutlined class="empty-icon" />
<p>暂无消息</p>
</div>
<div v-else class="message-list">
<div v-for="(msg, index) in messageList" :key="msg.id" class="message-item">
<div class="message-bubble message-bubble--user">
<div class="bubble-header">
<UserOutlined class="bubble-avatar" />
<span class="bubble-label">你的问题</span>
<span class="bubble-time">{{ formatTime(msg.createdAt) }}</span>
</div>
<p class="bubble-content">{{ msg.query }}</p>
</div>
<div class="message-bubble message-bubble--ai">
<div class="bubble-header">
<RobotOutlined class="bubble-avatar" />
<span class="bubble-label">AI 回答</span>
<button class="bubble-copy" @click="copyContent(msg.answer)">
<CopyOutlined />
</button>
</div>
<p class="bubble-content">{{ msg.answer }}</p>
</div>
</div>
</div>
</a-spin>
</div>
</div>
</div>
</transition>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import {
CloseOutlined,
LeftOutlined,
RightOutlined,
HistoryOutlined,
ClockCircleOutlined,
CopyOutlined,
UserOutlined,
RobotOutlined,
CommentOutlined
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import { getConversations, getMessages } from '@/api/agent'
import { copyToClipboard } from '@/utils/clipboard'
const props = defineProps({
visible: { type: Boolean, default: false },
agentId: { type: [String, Number], default: null }
})
const emit = defineEmits(['close'])
// State
const loading = ref(false)
const conversationList = ref([])
const selectedConversation = ref(null)
const messageList = ref([])
const messageLoading = ref(false)
// 按日期分组
const groupedConversations = computed(() => {
const today = dayjs().startOf('day')
const yesterday = dayjs().subtract(1, 'day').startOf('day')
const week = dayjs().subtract(7, 'day').startOf('day')
const groups = {
today: { label: '今天', items: [] },
yesterday: { label: '昨天', items: [] },
week: { label: '近7天', items: [] },
older: { label: '更早', items: [] }
}
conversationList.value.forEach(item => {
const date = dayjs((item.updatedAt || item.createdAt) * 1000)
if (date.isAfter(today)) {
groups.today.items.push(item)
} else if (date.isAfter(yesterday)) {
groups.yesterday.items.push(item)
} else if (date.isAfter(week)) {
groups.week.items.push(item)
} else {
groups.older.items.push(item)
}
})
return Object.values(groups).filter(g => g.items.length > 0)
})
// Methods
const loadConversations = async () => {
if (!props.agentId) return
loading.value = true
try {
const res = await getConversations({ agentId: props.agentId, limit: 50 })
if (res.code === 0) {
conversationList.value = res.data?.data || []
}
} catch (e) {
console.error('加载会话列表失败:', e)
} finally {
loading.value = false
}
}
const selectConversation = async (conversation) => {
selectedConversation.value = conversation
messageLoading.value = true
messageList.value = []
try {
const res = await getMessages({
agentId: props.agentId,
conversationId: conversation.id,
limit: 50
})
if (res.code === 0) {
messageList.value = (res.data?.data || []).reverse()
}
} catch (e) {
console.error('加载消息详情失败:', e)
} finally {
messageLoading.value = false
}
}
const backToList = () => {
selectedConversation.value = null
messageList.value = []
}
const handleClose = () => {
emit('close')
backToList()
}
const copyContent = async (content) => {
const success = await copyToClipboard(content)
success ? message.success('已复制') : message.error('复制失败')
}
const formatTime = (timestamp) => {
if (!timestamp) return ''
const date = dayjs(timestamp * 1000)
const now = dayjs()
if (date.isSame(now, 'day')) {
return date.format('HH:mm')
} else if (date.isSame(now.subtract(1, 'day'), 'day')) {
return '昨天 ' + date.format('HH:mm')
} else if (date.isAfter(now.subtract(7, 'day'))) {
return date.format('dddd HH:mm')
} else {
return date.format('MM-DD HH:mm')
}
}
// Watch
watch(() => props.visible, (val) => {
if (val) {
backToList()
loadConversations()
}
})
</script>
<style scoped lang="less">
@primary: #6366f1;
@primary-light: rgba(99, 102, 241, 0.1);
@surface: #ffffff;
@bg: #f8fafc;
@text: #1e293b;
@text-light: #64748b;
@text-muted: #94a3b8;
@border: #e2e8f0;
@radius: 12px;
.history-panel {
position: absolute;
inset: 0;
background: @bg;
z-index: 20;
display: flex;
flex-direction: column;
}
// Header
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(12px);
border-bottom: 1px solid @border;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.back-btn {
width: 32px;
height: 32px;
border-radius: 8px;
border: none;
background: transparent;
color: @text-light;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
background: #f1f5f9;
color: @text;
}
}
.header-title {
font-size: 15px;
font-weight: 600;
color: @text;
margin: 0;
}
.close-btn {
width: 32px;
height: 32px;
border-radius: 8px;
border: none;
background: transparent;
color: @text-light;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
background: #fee2e2;
color: #ef4444;
}
}
// Content
.panel-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
// Empty State
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
}
.empty-visual {
position: relative;
width: 80px;
height: 80px;
margin-bottom: 24px;
}
.visual-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px dashed @border;
animation: ringRotate 20s linear infinite;
}
@keyframes ringRotate {
to { transform: rotate(360deg); }
}
.empty-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 32px;
color: @text-muted;
}
.empty-text {
font-size: 15px;
font-weight: 500;
color: @text;
margin: 0 0 6px;
}
.empty-hint {
font-size: 13px;
color: @text-muted;
margin: 0;
}
// Conversation Groups
.conversation-groups {
display: flex;
flex-direction: column;
gap: 20px;
}
.conversation-group {
// Group styling
}
.group-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 0 4px;
}
.group-label {
font-size: 12px;
font-weight: 600;
color: @text-light;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.group-count {
font-size: 11px;
font-weight: 500;
color: @text-muted;
background: #f1f5f9;
padding: 2px 8px;
border-radius: 10px;
}
.group-list {
display: flex;
flex-direction: column;
gap: 8px;
}
// Conversation Card
.conversation-card {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: @surface;
border: 1px solid @border;
border-radius: @radius;
cursor: pointer;
transition: all 0.2s;
text-align: left;
&:hover {
border-color: @primary;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.1);
transform: translateY(-1px);
.meta-arrow {
color: @primary;
transform: translateX(2px);
}
}
}
.card-main {
flex: 1;
min-width: 0;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: @text;
margin: 0 0 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-preview {
font-size: 12px;
color: @text-muted;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
margin-left: 12px;
}
.meta-time {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: @text-muted;
}
.meta-arrow {
font-size: 10px;
color: @text-muted;
transition: all 0.2s;
}
// Message View
.message-view {
// Message view container
}
.message-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.message-item {
display: flex;
flex-direction: column;
gap: 12px;
}
.message-bubble {
border-radius: @radius;
overflow: hidden;
&--user {
background: linear-gradient(135deg, #f8fafc, #f1f5f9);
border: 1px solid @border;
}
&--ai {
background: linear-gradient(135deg, @primary-light, rgba(139, 92, 246, 0.08));
border: 1px solid rgba(99, 102, 241, 0.2);
}
}
.bubble-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.bubble-avatar {
font-size: 14px;
color: @text-light;
}
.bubble-label {
font-size: 11px;
font-weight: 600;
color: @text-light;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.bubble-time {
font-size: 10px;
color: @text-muted;
margin-left: auto;
}
.bubble-copy {
margin-left: auto;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: @text-muted;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
background: rgba(99, 102, 241, 0.1);
color: @primary;
}
}
.bubble-content {
margin: 0;
padding: 12px 14px;
font-size: 13px;
line-height: 1.7;
color: @text;
white-space: pre-wrap;
word-break: break-word;
}
// Transition
.history-slide-enter-active,
.history-slide-leave-active {
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
}
.history-slide-enter-from,
.history-slide-leave-to {
transform: translateX(100%);
}
</style>