mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 08:46:40 +08:00
feat: 添加历史会话功能,支持会话恢复和删除,更新多语言文本
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||||
|
import type { PerfectScrollbarExpose } from 'vue3-perfect-scrollbar'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore, useUserStore } from '@/stores'
|
import { useAuthStore, useUserStore } from '@/stores'
|
||||||
@@ -51,6 +52,14 @@ interface AgentChatMessage {
|
|||||||
choices: AgentChoiceCard[]
|
choices: AgentChoiceCard[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AgentSessionHistoryItem {
|
||||||
|
sessionId: string
|
||||||
|
title: string
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
messages: AgentChatMessage[]
|
||||||
|
}
|
||||||
|
|
||||||
interface AgentStreamEvent {
|
interface AgentStreamEvent {
|
||||||
type: 'start' | 'delta' | 'tool' | 'attachment' | 'choice' | 'done' | 'error'
|
type: 'start' | 'delta' | 'tool' | 'attachment' | 'choice' | 'done' | 'error'
|
||||||
attachment?: AgentMessageAttachment
|
attachment?: AgentMessageAttachment
|
||||||
@@ -91,16 +100,22 @@ const authStore = useAuthStore()
|
|||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const STORAGE_KEY = 'moviepilot-agent-assistant-state'
|
const STORAGE_KEY = 'moviepilot-agent-assistant-state'
|
||||||
|
const HISTORY_STORAGE_KEY = 'moviepilot-agent-assistant-history'
|
||||||
|
const MAX_HISTORY_SESSIONS = 20
|
||||||
const MAX_PERSISTED_MESSAGES = 30
|
const MAX_PERSISTED_MESSAGES = 30
|
||||||
|
const HISTORY_TITLE_LENGTH = 36
|
||||||
|
const HISTORY_PREVIEW_LENGTH = 72
|
||||||
|
|
||||||
const drawer = ref(false)
|
const drawer = ref(false)
|
||||||
const drawerViewportHeight = ref('100dvh')
|
const drawerViewportHeight = ref('100dvh')
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const messages = ref<AgentChatMessage[]>([])
|
const messages = ref<AgentChatMessage[]>([])
|
||||||
|
const historySessions = ref<AgentSessionHistoryItem[]>([])
|
||||||
const sessionId = ref('')
|
const sessionId = ref('')
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
const streamError = ref('')
|
const streamError = ref('')
|
||||||
const messageListRef = ref<HTMLElement | null>(null)
|
const historyMenuOpen = ref(false)
|
||||||
|
const messageListRef = ref<PerfectScrollbarExpose | null>(null)
|
||||||
const inputRef = ref<HTMLTextAreaElement | null>(null)
|
const inputRef = ref<HTMLTextAreaElement | null>(null)
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
const pendingAttachments = ref<AgentPendingAttachment[]>([])
|
const pendingAttachments = ref<AgentPendingAttachment[]>([])
|
||||||
@@ -126,6 +141,7 @@ const canSend = computed(
|
|||||||
// 窄屏下直接全屏,避免聊天内容被压成半屏窄栏。
|
// 窄屏下直接全屏,避免聊天内容被压成半屏窄栏。
|
||||||
const drawerWidth = computed(() => (display.mdAndDown.value ? '100vw' : '30rem'))
|
const drawerWidth = computed(() => (display.mdAndDown.value ? '100vw' : '30rem'))
|
||||||
const hasMessages = computed(() => messages.value.length > 0)
|
const hasMessages = computed(() => messages.value.length > 0)
|
||||||
|
const hasHistorySessions = computed(() => historySessions.value.length > 0)
|
||||||
const currentUserName = computed(() => userStore.getUserName || t('common.user'))
|
const currentUserName = computed(() => userStore.getUserName || t('common.user'))
|
||||||
const drawerStyle = computed(() => ({
|
const drawerStyle = computed(() => ({
|
||||||
'--agent-assistant-viewport-height': drawerViewportHeight.value,
|
'--agent-assistant-viewport-height': drawerViewportHeight.value,
|
||||||
@@ -151,30 +167,161 @@ function normalizeStoredMessages(value: unknown) {
|
|||||||
})) as AgentChatMessage[]
|
})) as AgentChatMessage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 规范化本地历史会话,过滤无效数据并按最近更新时间排序。
|
||||||
|
function normalizeHistorySessions(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map(item => {
|
||||||
|
const messages = normalizeStoredMessages(item?.messages)
|
||||||
|
const sessionIdValue = typeof item?.sessionId === 'string' ? item.sessionId : ''
|
||||||
|
if (!sessionIdValue || messages.length === 0) return null
|
||||||
|
|
||||||
|
const firstMessageTime = messages[0]?.createdAt || Date.now()
|
||||||
|
const lastMessageTime = messages.at(-1)?.createdAt || firstMessageTime
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: sessionIdValue,
|
||||||
|
title: typeof item?.title === 'string' && item.title.trim() ? item.title.trim() : buildSessionHistoryTitle(messages),
|
||||||
|
createdAt: Number(item?.createdAt) || firstMessageTime,
|
||||||
|
updatedAt: Number(item?.updatedAt) || lastMessageTime,
|
||||||
|
messages,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b!.updatedAt - a!.updatedAt)
|
||||||
|
.slice(0, MAX_HISTORY_SESSIONS) as AgentSessionHistoryItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 localStorage 读取历史会话索引,读取失败时回退为空列表。
|
||||||
|
function restoreHistorySessions() {
|
||||||
|
try {
|
||||||
|
historySessions.value = normalizeHistorySessions(JSON.parse(localStorage.getItem(HISTORY_STORAGE_KEY) || '[]'))
|
||||||
|
} catch (error) {
|
||||||
|
historySessions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function restoreState() {
|
function restoreState() {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
sessionId.value = createSessionId()
|
const latestSession = historySessions.value[0]
|
||||||
|
if (latestSession) {
|
||||||
|
sessionId.value = latestSession.sessionId
|
||||||
|
messages.value = normalizeStoredMessages(latestSession.messages)
|
||||||
|
} else {
|
||||||
|
sessionId.value = createSessionId()
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = JSON.parse(raw)
|
const state = JSON.parse(raw)
|
||||||
sessionId.value = state.sessionId || createSessionId()
|
sessionId.value = state.sessionId || createSessionId()
|
||||||
messages.value = normalizeStoredMessages(state.messages)
|
messages.value = normalizeStoredMessages(state.messages)
|
||||||
|
upsertCurrentSessionHistory()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sessionId.value = createSessionId()
|
sessionId.value = createSessionId()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistState() {
|
// 将历史会话列表写入 localStorage,空间不足时保留最近的一半会话重试。
|
||||||
localStorage.setItem(
|
function persistHistorySessions() {
|
||||||
STORAGE_KEY,
|
const sessions = historySessions.value.slice(0, MAX_HISTORY_SESSIONS)
|
||||||
JSON.stringify({
|
|
||||||
sessionId: sessionId.value,
|
try {
|
||||||
messages: messages.value.slice(-MAX_PERSISTED_MESSAGES),
|
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(sessions))
|
||||||
}),
|
} catch (error) {
|
||||||
)
|
const retainedCount = Math.max(1, Math.ceil(sessions.length / 2))
|
||||||
|
historySessions.value = sessions.slice(0, retainedCount)
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(historySessions.value))
|
||||||
|
} catch (retryError) {
|
||||||
|
// 浏览器本地空间不足时放弃本次历史写入,不影响当前对话继续使用。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成会话列表里显示的短标题,优先取第一条用户消息。
|
||||||
|
function buildSessionHistoryTitle(sessionMessages: AgentChatMessage[]) {
|
||||||
|
const firstUserMessage = sessionMessages.find(message => message.role === 'user' && getMessageSummaryText(message))
|
||||||
|
const firstReadableMessage = firstUserMessage || sessionMessages.find(message => getMessageSummaryText(message))
|
||||||
|
const title = firstReadableMessage ? getMessageSummaryText(firstReadableMessage) : ''
|
||||||
|
|
||||||
|
return truncateHistoryText(title || t('agentAssistant.untitledSession'), HISTORY_TITLE_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成会话列表里的预览文本,优先展示最近一条可读消息。
|
||||||
|
function getSessionHistoryPreview(session: AgentSessionHistoryItem) {
|
||||||
|
const latestMessage = [...session.messages].reverse().find(message => getMessageSummaryText(message))
|
||||||
|
if (!latestMessage) return ''
|
||||||
|
|
||||||
|
return truncateHistoryText(getMessageSummaryText(latestMessage), HISTORY_PREVIEW_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取消息的可读摘要,纯附件消息会使用附件名称或附件占位文本。
|
||||||
|
function getMessageSummaryText(message: AgentChatMessage) {
|
||||||
|
const text = message.content.replace(/\s+/g, ' ').trim()
|
||||||
|
if (text) return text
|
||||||
|
|
||||||
|
const firstAttachment = message.attachments[0]
|
||||||
|
if (firstAttachment) return firstAttachment.name || t('agentAssistant.attachmentMessage')
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按指定长度截断历史列表文本,避免长提示词撑开面板。
|
||||||
|
function truncateHistoryText(value: string, maxLength: number) {
|
||||||
|
if (value.length <= maxLength) return value
|
||||||
|
|
||||||
|
return `${value.slice(0, maxLength).trim()}...`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将当前会话同步到历史索引,空会话不会进入历史列表。
|
||||||
|
function upsertCurrentSessionHistory() {
|
||||||
|
if (!sessionId.value || messages.value.length === 0) return
|
||||||
|
|
||||||
|
const storedMessages = normalizeStoredMessages(messages.value)
|
||||||
|
const existingSession = historySessions.value.find(item => item.sessionId === sessionId.value)
|
||||||
|
const createdAt = existingSession?.createdAt || storedMessages[0]?.createdAt || Date.now()
|
||||||
|
const updatedAt = storedMessages.at(-1)?.createdAt || Date.now()
|
||||||
|
const nextSession: AgentSessionHistoryItem = {
|
||||||
|
sessionId: sessionId.value,
|
||||||
|
title: buildSessionHistoryTitle(storedMessages),
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
messages: storedMessages,
|
||||||
|
}
|
||||||
|
|
||||||
|
historySessions.value = [
|
||||||
|
nextSession,
|
||||||
|
...historySessions.value.filter(item => item.sessionId !== sessionId.value),
|
||||||
|
]
|
||||||
|
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
|
.slice(0, MAX_HISTORY_SESSIONS)
|
||||||
|
|
||||||
|
persistHistorySessions()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 持久化当前会话状态,并按需同步到历史会话列表。
|
||||||
|
function persistState(options: { syncHistory?: boolean } = {}) {
|
||||||
|
const { syncHistory = true } = options
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
sessionId: sessionId.value,
|
||||||
|
messages: messages.value.slice(-MAX_PERSISTED_MESSAGES),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
// 浏览器本地空间不足时保留内存态,避免发送过程被持久化异常打断。
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncHistory) upsertCurrentSessionHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMarkdown(value: string) {
|
function renderMarkdown(value: string) {
|
||||||
@@ -187,11 +334,20 @@ function resolveApiUrl(path: string) {
|
|||||||
return `${baseUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}`
|
return `${baseUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取 PerfectScrollbar 内部滚动元素,供自动滚动和滚动条刷新使用。
|
||||||
|
function getMessageScrollerElement() {
|
||||||
|
return messageListRef.value?.ps?.element || null
|
||||||
|
}
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!messageListRef.value) return
|
const scroller = getMessageScrollerElement()
|
||||||
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
|
if (!scroller) return
|
||||||
|
|
||||||
|
messageListRef.value?.ps?.update()
|
||||||
|
scroller.scrollTop = scroller.scrollHeight
|
||||||
|
messageListRef.value?.ps?.update()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -206,6 +362,12 @@ function syncInputHeight() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 刷新消息数组引用,确保流式回调中的消息字段变更能稳定触发当前会话视图更新。
|
||||||
|
function refreshMessageList() {
|
||||||
|
messages.value = [...messages.value]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加一条聊天消息,并返回消息列表中的响应式对象供后续流式更新使用。
|
||||||
function addMessage(
|
function addMessage(
|
||||||
role: AgentMessageRole,
|
role: AgentMessageRole,
|
||||||
content: string,
|
content: string,
|
||||||
@@ -223,9 +385,11 @@ function addMessage(
|
|||||||
tools: [],
|
tools: [],
|
||||||
}
|
}
|
||||||
messages.value.push(message)
|
messages.value.push(message)
|
||||||
|
const reactiveMessage = messages.value[messages.value.length - 1]
|
||||||
|
|
||||||
persistState()
|
persistState()
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
return message
|
return reactiveMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeToolMessage(message: string) {
|
function normalizeToolMessage(message: string) {
|
||||||
@@ -277,10 +441,13 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
|
|||||||
markToolsDone(assistantMessage)
|
markToolsDone(assistantMessage)
|
||||||
break
|
break
|
||||||
case 'start':
|
case 'start':
|
||||||
|
if (event.session_id) sessionId.value = event.session_id
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshMessageList()
|
||||||
persistState()
|
persistState()
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}
|
}
|
||||||
@@ -514,11 +681,13 @@ async function streamAgentMessage(
|
|||||||
if (assistantMessage.status === 'streaming') {
|
if (assistantMessage.status === 'streaming') {
|
||||||
assistantMessage.status = 'done'
|
assistantMessage.status = 'done'
|
||||||
markToolsDone(assistantMessage)
|
markToolsDone(assistantMessage)
|
||||||
|
refreshMessageList()
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.name === 'AbortError') {
|
if (error?.name === 'AbortError') {
|
||||||
assistantMessage.status = 'done'
|
assistantMessage.status = 'done'
|
||||||
markToolsDone(assistantMessage)
|
markToolsDone(assistantMessage)
|
||||||
|
refreshMessageList()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,6 +695,7 @@ async function streamAgentMessage(
|
|||||||
assistantMessage.content = error?.message || t('agentAssistant.error')
|
assistantMessage.content = error?.message || t('agentAssistant.error')
|
||||||
streamError.value = assistantMessage.content
|
streamError.value = assistantMessage.content
|
||||||
markToolsDone(assistantMessage)
|
markToolsDone(assistantMessage)
|
||||||
|
refreshMessageList()
|
||||||
} finally {
|
} finally {
|
||||||
abortController = null
|
abortController = null
|
||||||
persistState()
|
persistState()
|
||||||
@@ -583,12 +753,14 @@ async function handleChoiceClick(choice: AgentChoiceCard, button: AgentChoiceBut
|
|||||||
choice.status = 'selected'
|
choice.status = 'selected'
|
||||||
choice.selected_label = result.data?.feedback?.selected_label || button.label
|
choice.selected_label = result.data?.feedback?.selected_label || button.label
|
||||||
choice.selected_value = result.data?.feedback?.selected_value || result.data?.message
|
choice.selected_value = result.data?.feedback?.selected_value || result.data?.message
|
||||||
|
refreshMessageList()
|
||||||
persistState()
|
persistState()
|
||||||
|
|
||||||
await streamAgentMessage(String(result.data?.message || ''), [], [], [], false)
|
await streamAgentMessage(String(result.data?.message || ''), [], [], [], false)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
choice.status = 'expired'
|
choice.status = 'expired'
|
||||||
streamError.value = error?.message || t('agentAssistant.choiceExpired')
|
streamError.value = error?.message || t('agentAssistant.choiceExpired')
|
||||||
|
refreshMessageList()
|
||||||
persistState()
|
persistState()
|
||||||
} finally {
|
} finally {
|
||||||
sending.value = false
|
sending.value = false
|
||||||
@@ -604,10 +776,55 @@ function startNewSession() {
|
|||||||
sessionId.value = createSessionId()
|
sessionId.value = createSessionId()
|
||||||
messages.value = []
|
messages.value = []
|
||||||
streamError.value = ''
|
streamError.value = ''
|
||||||
|
historyMenuOpen.value = false
|
||||||
clearPendingAttachments()
|
clearPendingAttachments()
|
||||||
persistState()
|
persistState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从历史列表恢复指定会话,同时把它设为当前本地会话。
|
||||||
|
function loadHistorySession(targetSessionId: string) {
|
||||||
|
if (sending.value) return
|
||||||
|
|
||||||
|
const historySession = historySessions.value.find(item => item.sessionId === targetSessionId)
|
||||||
|
if (!historySession) return
|
||||||
|
|
||||||
|
stopGeneration()
|
||||||
|
sessionId.value = historySession.sessionId
|
||||||
|
messages.value = normalizeStoredMessages(historySession.messages)
|
||||||
|
streamError.value = ''
|
||||||
|
historyMenuOpen.value = false
|
||||||
|
clearPendingAttachments()
|
||||||
|
persistState({ syncHistory: false })
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除指定历史会话;若删除的是当前会话,则切换到新的空会话。
|
||||||
|
function deleteHistorySession(targetSessionId: string) {
|
||||||
|
if (sending.value && targetSessionId === sessionId.value) return
|
||||||
|
|
||||||
|
historySessions.value = historySessions.value.filter(item => item.sessionId !== targetSessionId)
|
||||||
|
persistHistorySessions()
|
||||||
|
|
||||||
|
if (targetSessionId === sessionId.value) startNewSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断历史项是否为当前打开的会话,用于高亮列表状态。
|
||||||
|
function isCurrentHistorySession(targetSessionId: string) {
|
||||||
|
return targetSessionId === sessionId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化历史会话时间,显示为本地日期和时间。
|
||||||
|
function formatHistoryTime(timestamp: number) {
|
||||||
|
if (!timestamp) return ''
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(new Date(timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
function openDrawer() {
|
function openDrawer() {
|
||||||
drawer.value = true
|
drawer.value = true
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
@@ -657,6 +874,7 @@ watch(drawerWidth, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
restoreHistorySessions()
|
||||||
restoreState()
|
restoreState()
|
||||||
syncDrawerViewportHeight()
|
syncDrawerViewportHeight()
|
||||||
syncInputHeight()
|
syncInputHeight()
|
||||||
@@ -689,7 +907,7 @@ onScopeDispose(() => {
|
|||||||
:title="t('agentAssistant.title')"
|
:title="t('agentAssistant.title')"
|
||||||
@click="openDrawer"
|
@click="openDrawer"
|
||||||
>
|
>
|
||||||
<VIcon icon="lucide:bot" size="21" />
|
<VIcon class="agent-assistant-fab__icon" icon="lucide:bot" size="21" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
@@ -713,6 +931,73 @@ onScopeDispose(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-center gap-1">
|
<div class="d-flex align-center gap-1">
|
||||||
|
<VMenu
|
||||||
|
v-model="historyMenuOpen"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
content-class="agent-assistant-history-overlay"
|
||||||
|
location="bottom end"
|
||||||
|
offset="8"
|
||||||
|
max-width="360"
|
||||||
|
:z-index="2103"
|
||||||
|
>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<IconBtn
|
||||||
|
v-bind="props"
|
||||||
|
:title="t('agentAssistant.history')"
|
||||||
|
:aria-label="t('agentAssistant.history')"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-history" />
|
||||||
|
</IconBtn>
|
||||||
|
</template>
|
||||||
|
<VCard class="agent-assistant-history-menu" elevation="10">
|
||||||
|
<div class="agent-assistant-history-menu__header">
|
||||||
|
<span>{{ t('agentAssistant.history') }}</span>
|
||||||
|
<VBtn
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
:disabled="sending"
|
||||||
|
@click="startNewSession"
|
||||||
|
>
|
||||||
|
{{ t('agentAssistant.newChat') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
<VDivider />
|
||||||
|
<PerfectScrollbar class="agent-assistant-history-list" :options="{ wheelPropagation: false }">
|
||||||
|
<div v-if="!hasHistorySessions" class="agent-assistant-history-empty">
|
||||||
|
{{ t('agentAssistant.noHistory') }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="historySession in historySessions"
|
||||||
|
:key="historySession.sessionId"
|
||||||
|
class="agent-assistant-history-item"
|
||||||
|
:class="{ 'is-active': isCurrentHistorySession(historySession.sessionId) }"
|
||||||
|
type="button"
|
||||||
|
:disabled="sending"
|
||||||
|
@click="loadHistorySession(historySession.sessionId)"
|
||||||
|
>
|
||||||
|
<span class="agent-assistant-history-item__content">
|
||||||
|
<span class="agent-assistant-history-item__title">{{ historySession.title }}</span>
|
||||||
|
<span class="agent-assistant-history-item__preview">
|
||||||
|
{{ getSessionHistoryPreview(historySession) }}
|
||||||
|
</span>
|
||||||
|
<span class="agent-assistant-history-item__time">
|
||||||
|
{{ formatHistoryTime(historySession.updatedAt) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<IconBtn
|
||||||
|
size="x-small"
|
||||||
|
:disabled="sending"
|
||||||
|
:title="t('agentAssistant.deleteHistory')"
|
||||||
|
:aria-label="t('agentAssistant.deleteHistory')"
|
||||||
|
@click.stop="deleteHistorySession(historySession.sessionId)"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-delete-outline" size="16" />
|
||||||
|
</IconBtn>
|
||||||
|
</button>
|
||||||
|
</PerfectScrollbar>
|
||||||
|
</VCard>
|
||||||
|
</VMenu>
|
||||||
<IconBtn
|
<IconBtn
|
||||||
:disabled="sending"
|
:disabled="sending"
|
||||||
:title="t('agentAssistant.newChat')"
|
:title="t('agentAssistant.newChat')"
|
||||||
@@ -727,7 +1012,12 @@ onScopeDispose(() => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main ref="messageListRef" class="agent-assistant-messages">
|
<PerfectScrollbar
|
||||||
|
ref="messageListRef"
|
||||||
|
tag="main"
|
||||||
|
class="agent-assistant-messages"
|
||||||
|
:options="{ wheelPropagation: false }"
|
||||||
|
>
|
||||||
<div v-if="!hasMessages" class="agent-assistant-empty">
|
<div v-if="!hasMessages" class="agent-assistant-empty">
|
||||||
<div class="agent-assistant-empty__mark">
|
<div class="agent-assistant-empty__mark">
|
||||||
<VIcon icon="lucide:sparkles" size="28" />
|
<VIcon icon="lucide:sparkles" size="28" />
|
||||||
@@ -865,13 +1155,17 @@ onScopeDispose(() => {
|
|||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</PerfectScrollbar>
|
||||||
|
|
||||||
<footer class="agent-assistant-composer">
|
<footer class="agent-assistant-composer">
|
||||||
<VAlert v-if="streamError" type="error" variant="tonal" density="compact" class="mb-3">
|
<VAlert v-if="streamError" type="error" variant="tonal" density="compact" class="mb-3">
|
||||||
{{ streamError }}
|
{{ streamError }}
|
||||||
</VAlert>
|
</VAlert>
|
||||||
<div v-if="pendingAttachments.length" class="agent-assistant-pending-files">
|
<PerfectScrollbar
|
||||||
|
v-if="pendingAttachments.length"
|
||||||
|
class="agent-assistant-pending-files"
|
||||||
|
:options="{ wheelPropagation: false }"
|
||||||
|
>
|
||||||
<div v-for="attachment in pendingAttachments" :key="attachment.id" class="agent-assistant-pending-file">
|
<div v-for="attachment in pendingAttachments" :key="attachment.id" class="agent-assistant-pending-file">
|
||||||
<img
|
<img
|
||||||
v-if="attachment.kind === 'image' && attachment.preview_url"
|
v-if="attachment.kind === 'image' && attachment.preview_url"
|
||||||
@@ -894,7 +1188,7 @@ onScopeDispose(() => {
|
|||||||
<VIcon icon="mdi-close" size="16" />
|
<VIcon icon="mdi-close" size="16" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PerfectScrollbar>
|
||||||
<div class="agent-assistant-input">
|
<div class="agent-assistant-input">
|
||||||
<input
|
<input
|
||||||
ref="fileInputRef"
|
ref="fileInputRef"
|
||||||
@@ -938,6 +1232,12 @@ onScopeDispose(() => {
|
|||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.agent-assistant-history-overlay {
|
||||||
|
z-index: 2103 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||||
/* stylelint-disable no-descending-specificity */
|
/* stylelint-disable no-descending-specificity */
|
||||||
@@ -970,10 +1270,20 @@ onScopeDispose(() => {
|
|||||||
transform: translate(0, -50%);
|
transform: translate(0, -50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-assistant-fab__icon {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
transition: transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-assistant-fab:hover .agent-assistant-fab__icon {
|
||||||
|
transform: rotate(0);
|
||||||
|
}
|
||||||
|
|
||||||
.agent-assistant-panel {
|
.agent-assistant-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 2101;
|
z-index: 2101;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
background: rgb(var(--v-theme-surface));
|
background: rgb(var(--v-theme-surface));
|
||||||
block-size: var(--agent-assistant-viewport-height, 100dvh) !important;
|
block-size: var(--agent-assistant-viewport-height, 100dvh) !important;
|
||||||
border-inline-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
border-inline-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
@@ -1030,7 +1340,103 @@ onScopeDispose(() => {
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-assistant-history-menu {
|
||||||
|
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
border-radius: 12px;
|
||||||
|
inline-size: min(22rem, calc(100vw - 2rem));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-assistant-history-menu__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.9);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
min-block-size: 3rem;
|
||||||
|
padding-block: 0.45rem;
|
||||||
|
padding-inline: 0.9rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-assistant-history-list {
|
||||||
|
max-block-size: min(26rem, calc(var(--agent-assistant-viewport-height, 100dvh) - 7rem));
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding-block: 0.35rem;
|
||||||
|
|
||||||
|
:deep(.ps__rail-x),
|
||||||
|
:deep(.ps__rail-y) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-assistant-history-empty {
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.58);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding-block: 1.5rem;
|
||||||
|
padding-inline: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-assistant-history-item {
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
column-gap: 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
inline-size: calc(100% - 0.7rem);
|
||||||
|
margin-inline: 0.35rem;
|
||||||
|
padding-block: 0.55rem;
|
||||||
|
padding-inline: 0.65rem 0.25rem;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-assistant-history-item:hover,
|
||||||
|
.agent-assistant-history-item.is-active {
|
||||||
|
background: rgba(var(--v-theme-primary), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-assistant-history-item:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-assistant-history-item__content {
|
||||||
|
display: grid;
|
||||||
|
min-inline-size: 0;
|
||||||
|
row-gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-assistant-history-item__title,
|
||||||
|
.agent-assistant-history-item__preview,
|
||||||
|
.agent-assistant-history-item__time {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-assistant-history-item__title {
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.9);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-assistant-history-item__preview {
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-assistant-history-item__time {
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.48);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
.agent-assistant-messages {
|
.agent-assistant-messages {
|
||||||
|
overscroll-behavior: contain;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-block: 1rem calc(env(safe-area-inset-bottom, 0px) + 12rem);
|
padding-block: 1rem calc(env(safe-area-inset-bottom, 0px) + 12rem);
|
||||||
padding-inline: 1rem;
|
padding-inline: 1rem;
|
||||||
@@ -1265,6 +1671,7 @@ onScopeDispose(() => {
|
|||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
margin-block-end: 0.55rem;
|
margin-block-end: 0.55rem;
|
||||||
max-block-size: 8rem;
|
max-block-size: 8rem;
|
||||||
|
overscroll-behavior: contain;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
@@ -1470,6 +1877,10 @@ onScopeDispose(() => {
|
|||||||
margin-block-end: 0.5rem;
|
margin-block-end: 0.5rem;
|
||||||
padding-inline-start: 1.25rem;
|
padding-inline-start: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(li) {
|
||||||
|
margin-block: 0.25rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes agent-typing {
|
@keyframes agent-typing {
|
||||||
|
|||||||
@@ -703,12 +703,17 @@ export default {
|
|||||||
ready: 'Ready',
|
ready: 'Ready',
|
||||||
thinking: 'Thinking',
|
thinking: 'Thinking',
|
||||||
newChat: 'New Chat',
|
newChat: 'New Chat',
|
||||||
|
history: 'Chat History',
|
||||||
|
noHistory: 'No chat history yet',
|
||||||
|
deleteHistory: 'Delete chat history',
|
||||||
|
untitledSession: 'Untitled chat',
|
||||||
emptyTitle: 'What should we handle today?',
|
emptyTitle: 'What should we handle today?',
|
||||||
emptySubtitle: 'Ask about sites, subscriptions, downloads, or organization tasks.',
|
emptySubtitle: 'Ask about sites, subscriptions, downloads, or organization tasks.',
|
||||||
placeholder: 'Ask MoviePilot...',
|
placeholder: 'Ask MoviePilot...',
|
||||||
stop: 'Stop generating',
|
stop: 'Stop generating',
|
||||||
download: 'Download',
|
download: 'Download',
|
||||||
attachFile: 'Choose image or file',
|
attachFile: 'Choose image or file',
|
||||||
|
attachmentMessage: 'Attachment message',
|
||||||
removeAttachment: 'Remove attachment',
|
removeAttachment: 'Remove attachment',
|
||||||
uploadFailed: 'Attachment upload failed',
|
uploadFailed: 'Attachment upload failed',
|
||||||
choiceSelected: 'Selected: {option}',
|
choiceSelected: 'Selected: {option}',
|
||||||
|
|||||||
@@ -699,12 +699,17 @@ export default {
|
|||||||
ready: '随时待命',
|
ready: '随时待命',
|
||||||
thinking: '思考中',
|
thinking: '思考中',
|
||||||
newChat: '新会话',
|
newChat: '新会话',
|
||||||
|
history: '历史会话',
|
||||||
|
noHistory: '暂无历史会话',
|
||||||
|
deleteHistory: '删除历史会话',
|
||||||
|
untitledSession: '未命名会话',
|
||||||
emptyTitle: '今天想处理什么?',
|
emptyTitle: '今天想处理什么?',
|
||||||
emptySubtitle: '站点、订阅、下载、整理任务,都可以直接问我。',
|
emptySubtitle: '站点、订阅、下载、整理任务,都可以直接问我。',
|
||||||
placeholder: '询问 MoviePilot...',
|
placeholder: '询问 MoviePilot...',
|
||||||
stop: '停止生成',
|
stop: '停止生成',
|
||||||
download: '下载',
|
download: '下载',
|
||||||
attachFile: '选择图片或文件',
|
attachFile: '选择图片或文件',
|
||||||
|
attachmentMessage: '附件消息',
|
||||||
removeAttachment: '移除附件',
|
removeAttachment: '移除附件',
|
||||||
uploadFailed: '附件上传失败',
|
uploadFailed: '附件上传失败',
|
||||||
choiceSelected: '已选择:{option}',
|
choiceSelected: '已选择:{option}',
|
||||||
|
|||||||
@@ -699,12 +699,17 @@ export default {
|
|||||||
ready: '隨時待命',
|
ready: '隨時待命',
|
||||||
thinking: '思考中',
|
thinking: '思考中',
|
||||||
newChat: '新會話',
|
newChat: '新會話',
|
||||||
|
history: '歷史會話',
|
||||||
|
noHistory: '暫無歷史會話',
|
||||||
|
deleteHistory: '刪除歷史會話',
|
||||||
|
untitledSession: '未命名會話',
|
||||||
emptyTitle: '今天想處理什麼?',
|
emptyTitle: '今天想處理什麼?',
|
||||||
emptySubtitle: '站點、訂閱、下載、整理任務,都可以直接問我。',
|
emptySubtitle: '站點、訂閱、下載、整理任務,都可以直接問我。',
|
||||||
placeholder: '詢問 MoviePilot...',
|
placeholder: '詢問 MoviePilot...',
|
||||||
stop: '停止生成',
|
stop: '停止生成',
|
||||||
download: '下載',
|
download: '下載',
|
||||||
attachFile: '選擇圖片或文件',
|
attachFile: '選擇圖片或文件',
|
||||||
|
attachmentMessage: '附件消息',
|
||||||
removeAttachment: '移除附件',
|
removeAttachment: '移除附件',
|
||||||
uploadFailed: '附件上傳失敗',
|
uploadFailed: '附件上傳失敗',
|
||||||
choiceSelected: '已選擇:{option}',
|
choiceSelected: '已選擇:{option}',
|
||||||
|
|||||||
Reference in New Issue
Block a user