mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 23:18:44 +08:00
3363 lines
103 KiB
Vue
3363 lines
103 KiB
Vue
<script setup lang="ts">
|
|
import MarkdownIt from 'markdown-it'
|
|
import mdLinkAttributes from 'markdown-it-link-attributes'
|
|
import { useDisplay } from 'vuetify'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useAuthStore, useUserStore } from '@/stores'
|
|
|
|
type AgentMessageRole = 'user' | 'assistant'
|
|
type AgentMessageStatus = 'idle' | 'streaming' | 'done' | 'error'
|
|
type AgentAttachmentKind = 'audio' | 'file' | 'image'
|
|
type AgentChoiceStatus = 'pending' | 'selected' | 'expired'
|
|
type InfiniteScrollSide = 'start' | 'end' | 'both'
|
|
type InfiniteScrollStatus = 'empty' | 'error' | 'loading' | 'ok'
|
|
|
|
interface AgentToolCall {
|
|
id: string
|
|
message: string
|
|
status: 'running' | 'done'
|
|
}
|
|
|
|
interface AgentMessageAttachment {
|
|
kind: AgentAttachmentKind
|
|
url: string
|
|
download_url?: string
|
|
name?: string
|
|
mime_type?: string
|
|
size?: number
|
|
local_path?: string
|
|
}
|
|
|
|
interface AgentChoiceButton {
|
|
label: string
|
|
callback_data: string
|
|
description?: string
|
|
}
|
|
|
|
interface AgentChoiceCard {
|
|
id: string
|
|
title?: string
|
|
prompt: string
|
|
buttons: AgentChoiceButton[]
|
|
button_rows?: AgentChoiceButton[][]
|
|
status: AgentChoiceStatus
|
|
selected_label?: string
|
|
selected_value?: string
|
|
selected_description?: string
|
|
}
|
|
|
|
interface AgentChoiceSelection {
|
|
choice_id: string
|
|
title?: string
|
|
prompt: string
|
|
buttons: AgentChoiceButton[]
|
|
button_rows?: AgentChoiceButton[][]
|
|
selected_label?: string
|
|
selected_value?: string
|
|
selected_description?: string
|
|
}
|
|
|
|
interface AgentChatMessage {
|
|
id: string
|
|
role: AgentMessageRole
|
|
content: string
|
|
createdAt: number
|
|
status: AgentMessageStatus
|
|
tools: AgentToolCall[]
|
|
attachments: AgentMessageAttachment[]
|
|
choices: AgentChoiceCard[]
|
|
choice_selection?: AgentChoiceSelection
|
|
}
|
|
|
|
interface AgentSessionHistoryItem {
|
|
sessionId: string
|
|
clientSessionId?: string
|
|
title: string
|
|
preview?: string
|
|
channel?: string
|
|
source?: string
|
|
isProcessing?: boolean
|
|
createdAt: number
|
|
updatedAt: number
|
|
messages: AgentChatMessage[]
|
|
}
|
|
|
|
interface AgentStreamEvent {
|
|
type: 'start' | 'delta' | 'tool' | 'attachment' | 'choice' | 'message_update' | 'done' | 'error'
|
|
attachment?: AgentMessageAttachment
|
|
choice?: Omit<AgentChoiceCard, 'status'>
|
|
content?: string
|
|
message?: string
|
|
message_id?: string
|
|
target_message?: Partial<AgentChatMessage> & { id?: string }
|
|
session_id?: string
|
|
}
|
|
|
|
interface AgentPendingAttachment {
|
|
id: string
|
|
file: File
|
|
kind: AgentAttachmentKind
|
|
name: string
|
|
mime_type: string
|
|
size: number
|
|
preview_url?: string
|
|
}
|
|
|
|
interface AgentOutgoingFile {
|
|
ref: string
|
|
name?: string
|
|
mime_type?: string
|
|
size?: number
|
|
local_path?: string
|
|
status?: string
|
|
}
|
|
|
|
interface PreparedAgentAttachments {
|
|
images: string[]
|
|
files: AgentOutgoingFile[]
|
|
audioRefs: string[]
|
|
userAttachments: AgentMessageAttachment[]
|
|
}
|
|
|
|
interface AgentStreamMessageOptions {
|
|
echoUser?: boolean
|
|
displayText?: string
|
|
choiceSelection?: AgentChoiceSelection
|
|
originalMessageId?: string
|
|
originalChatId?: string
|
|
}
|
|
|
|
interface AgentSlashCommand {
|
|
command: string
|
|
description: string
|
|
category?: string
|
|
type?: string
|
|
pid?: string
|
|
}
|
|
|
|
interface AgentServerSession {
|
|
session_id: string
|
|
client_session_id?: string
|
|
title?: string
|
|
preview?: string
|
|
channel?: string
|
|
source?: string
|
|
created_at?: string
|
|
updated_at?: string
|
|
is_processing?: boolean
|
|
messages?: AgentChatMessage[]
|
|
}
|
|
|
|
const { t } = useI18n()
|
|
const display = useDisplay()
|
|
const authStore = useAuthStore()
|
|
const userStore = useUserStore()
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
modelValue?: boolean
|
|
}>(),
|
|
{
|
|
modelValue: false,
|
|
},
|
|
)
|
|
|
|
const emit = defineEmits<{
|
|
'assistant-preview': [value: string]
|
|
'thinking-change': [value: boolean]
|
|
'update:modelValue': [value: boolean]
|
|
}>()
|
|
|
|
const STORAGE_KEY = 'moviepilot-agent-assistant-state'
|
|
const HISTORY_STORAGE_KEY = 'moviepilot-agent-assistant-history'
|
|
const HISTORY_PAGE_SIZE = 30
|
|
const MAX_LOCAL_HISTORY_SESSIONS = 120
|
|
const MAX_PERSISTED_MESSAGES = 30
|
|
const HISTORY_TITLE_LENGTH = 36
|
|
const HISTORY_ITEM_HEIGHT = 76
|
|
const MESSAGE_SCROLL_FOLLOW_THRESHOLD = 96
|
|
const STREAM_STATE_PERSIST_DELAY = 1000
|
|
|
|
const inputText = ref('')
|
|
const messages = ref<AgentChatMessage[]>([])
|
|
const historySessions = ref<AgentSessionHistoryItem[]>([])
|
|
const sessionId = ref('')
|
|
const sending = ref(false)
|
|
const streamError = ref('')
|
|
const historyMenuOpen = ref(false)
|
|
const messageListRef = ref<HTMLElement | null>(null)
|
|
const inputRef = ref<HTMLTextAreaElement | null>(null)
|
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
|
const pendingAttachments = ref<AgentPendingAttachment[]>([])
|
|
const recording = ref(false)
|
|
const recordingStartedAt = ref(0)
|
|
const recordingDuration = ref(0)
|
|
const historyLoading = ref(false)
|
|
const historyLoadingMore = ref(false)
|
|
const historyPage = ref(1)
|
|
const historyHasMore = ref(true)
|
|
const slashCommands = ref<AgentSlashCommand[]>([])
|
|
const slashCommandsLoading = ref(false)
|
|
const slashCommandsLoaded = ref(false)
|
|
let abortController: AbortController | null = null
|
|
let mediaRecorder: MediaRecorder | null = null
|
|
let mediaRecorderStream: MediaStream | null = null
|
|
let recordingTimer: number | null = null
|
|
let recordingChunks: BlobPart[] = []
|
|
let messageScrollFrame: number | null = null
|
|
let pendingMessageScrollToBottom = false
|
|
let streamPersistTimer: number | null = null
|
|
let userAbortRequested = false
|
|
let streamRecoveryTimer: number | null = null
|
|
let pendingStreamRecovery:
|
|
| {
|
|
sessionId: string
|
|
startedAt: number
|
|
attempts: number
|
|
}
|
|
| null = null
|
|
|
|
const md = new MarkdownIt({
|
|
html: true,
|
|
breaks: true,
|
|
linkify: true,
|
|
typographer: true,
|
|
})
|
|
|
|
md.use(mdLinkAttributes, {
|
|
attrs: {
|
|
target: '_blank',
|
|
rel: 'noopener noreferrer',
|
|
},
|
|
})
|
|
|
|
const canSend = computed(
|
|
() =>
|
|
(inputText.value.trim().length > 0 || pendingAttachments.value.length > 0) && !sending.value && !recording.value,
|
|
)
|
|
const canRecord = computed(() => !sending.value && !recording.value)
|
|
// 获取当前输入对应的斜杠命令查询词。
|
|
const slashCommandQuery = computed(() => {
|
|
const text = inputText.value.trimStart()
|
|
if (!text.startsWith('/')) return ''
|
|
|
|
return text.slice(1).toLowerCase()
|
|
})
|
|
// 根据输入内容过滤可用斜杠命令。
|
|
const filteredSlashCommands = computed(() => {
|
|
const query = slashCommandQuery.value
|
|
if (!query) return slashCommands.value
|
|
|
|
return slashCommands.value
|
|
.filter(command => {
|
|
const haystack = `${command.command} ${command.description} ${command.category || ''}`.toLowerCase()
|
|
|
|
return haystack.includes(query)
|
|
})
|
|
})
|
|
// 判断是否展示命令建议浮层。
|
|
const showSlashCommandMenu = computed(
|
|
() =>
|
|
inputText.value.trimStart().startsWith('/') &&
|
|
!sending.value &&
|
|
!recording.value &&
|
|
(filteredSlashCommands.value.length > 0 || slashCommandsLoading.value),
|
|
)
|
|
// 根据智能体处理状态切换输入框背景提示。
|
|
const inputPlaceholder = computed(() =>
|
|
sending.value ? t('agentAssistant.processingPlaceholder') : t('agentAssistant.placeholder'),
|
|
)
|
|
const recordingTimeText = computed(() => {
|
|
const seconds = Math.max(0, recordingDuration.value)
|
|
const minutes = Math.floor(seconds / 60)
|
|
const remainSeconds = seconds % 60
|
|
|
|
return `${minutes}:${String(remainSeconds).padStart(2, '0')}`
|
|
})
|
|
// 窄屏下直接全屏,避免聊天内容被压成半屏窄栏。
|
|
const drawerWidth = computed(() => (display.mdAndDown.value ? '100vw' : '30rem'))
|
|
const hasMessages = computed(() => messages.value.length > 0)
|
|
const hasHistorySessions = computed(() => historySessions.value.length > 0)
|
|
const currentUserName = computed(() => userStore.getUserName || t('common.user'))
|
|
const isOpen = computed({
|
|
get: () => props.modelValue,
|
|
set: value => emit('update:modelValue', value),
|
|
})
|
|
const drawerStyle = computed(() => ({
|
|
'--agent-assistant-panel-width': drawerWidth.value,
|
|
}))
|
|
|
|
// 创建前端展示用的临时 ID。
|
|
function createId(prefix: string) {
|
|
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
}
|
|
|
|
// 创建 Web 智能助手本地会话 ID。
|
|
function createSessionId() {
|
|
return `web-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
}
|
|
|
|
// 将未知字段安全转换为可展示文本。
|
|
function stringifyChoiceField(value: unknown) {
|
|
if (typeof value === 'string') return value.trim()
|
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
|
|
return ''
|
|
}
|
|
|
|
// 规范化消息状态,避免历史脏数据影响渲染。
|
|
function normalizeMessageStatus(value: unknown): AgentMessageStatus {
|
|
const status = stringifyChoiceField(value)
|
|
|
|
return ['idle', 'streaming', 'done', 'error'].includes(status) ? (status as AgentMessageStatus) : 'done'
|
|
}
|
|
|
|
// 规范化选择卡片状态,兼容旧版本本地历史。
|
|
function normalizeChoiceStatus(value: unknown): AgentChoiceStatus {
|
|
const status = stringifyChoiceField(value)
|
|
|
|
return ['pending', 'selected', 'expired'].includes(status) ? (status as AgentChoiceStatus) : 'pending'
|
|
}
|
|
|
|
// 规范化单个选择按钮,兼容后端与旧历史的不同字段名。
|
|
function normalizeChoiceButton(value: unknown): AgentChoiceButton | null {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
|
|
const item = value as Record<string, unknown>
|
|
const callbackData =
|
|
stringifyChoiceField(item.callback_data) ||
|
|
stringifyChoiceField(item.callbackData) ||
|
|
stringifyChoiceField(item.value) ||
|
|
stringifyChoiceField(item.id)
|
|
const label =
|
|
stringifyChoiceField(item.label) ||
|
|
stringifyChoiceField(item.text) ||
|
|
stringifyChoiceField(item.title) ||
|
|
callbackData
|
|
const description = stringifyChoiceField(item.description) || stringifyChoiceField(item.desc)
|
|
|
|
if (!label && !callbackData) return null
|
|
|
|
return {
|
|
label: label || callbackData,
|
|
callback_data: callbackData || label,
|
|
...(description ? { description } : {}),
|
|
}
|
|
}
|
|
|
|
// 规范化选择按钮行,保留 Telegram 式二维键盘布局。
|
|
function normalizeChoiceButtonRows(value: unknown): AgentChoiceButton[][] {
|
|
if (!Array.isArray(value)) return []
|
|
|
|
const rawRows = value.some(Array.isArray) ? value : value.map(item => [item])
|
|
|
|
return rawRows
|
|
.map(row => {
|
|
const rowItems = Array.isArray(row) ? row : [row]
|
|
|
|
return rowItems.map(normalizeChoiceButton).filter(Boolean) as AgentChoiceButton[]
|
|
})
|
|
.filter(row => row.length > 0)
|
|
}
|
|
|
|
// 规范化选择卡片,保证历史数据和 SSE 新事件使用同一结构。
|
|
function normalizeChoiceCard(value: unknown): AgentChoiceCard | null {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
|
|
const item = value as Record<string, unknown>
|
|
const snakeCaseRows = normalizeChoiceButtonRows(item.button_rows)
|
|
const camelCaseRows = normalizeChoiceButtonRows(item.buttonRows)
|
|
const flatButtonRows = normalizeChoiceButtonRows(item.buttons)
|
|
const buttonRows =
|
|
snakeCaseRows.length > 0 ? snakeCaseRows : camelCaseRows.length > 0 ? camelCaseRows : flatButtonRows
|
|
const buttons = buttonRows.flat()
|
|
const prompt =
|
|
stringifyChoiceField(item.prompt) || stringifyChoiceField(item.message) || stringifyChoiceField(item.text)
|
|
const id = stringifyChoiceField(item.id) || createId('choice')
|
|
|
|
if (!prompt && buttons.length === 0) return null
|
|
|
|
return {
|
|
id,
|
|
title: stringifyChoiceField(item.title) || undefined,
|
|
prompt,
|
|
buttons,
|
|
button_rows: buttonRows,
|
|
status: normalizeChoiceStatus(item.status),
|
|
selected_label: stringifyChoiceField(item.selected_label) || stringifyChoiceField(item.selectedLabel) || undefined,
|
|
selected_value: stringifyChoiceField(item.selected_value) || stringifyChoiceField(item.selectedValue) || undefined,
|
|
selected_description:
|
|
stringifyChoiceField(item.selected_description) || stringifyChoiceField(item.selectedDescription) || undefined,
|
|
}
|
|
}
|
|
|
|
// 获取选择按钮行,旧历史只有一维按钮时回退为单列布局。
|
|
function getChoiceButtonRows(choice: AgentChoiceCard | AgentChoiceSelection) {
|
|
if (Array.isArray(choice.button_rows) && choice.button_rows.length) return choice.button_rows
|
|
|
|
return choice.buttons.map(button => [button])
|
|
}
|
|
|
|
// 获取用户消息中应展示的选项文字。
|
|
function getChoiceButtonSelectionText(button: AgentChoiceButton) {
|
|
return button.label || button.description || button.callback_data
|
|
}
|
|
|
|
// 构建用户选择快照,用于历史存储和刷新后恢复上下文。
|
|
function buildChoiceSelection(choice: AgentChoiceCard, button: AgentChoiceButton): AgentChoiceSelection {
|
|
return {
|
|
choice_id: choice.id,
|
|
title: choice.title,
|
|
prompt: choice.prompt,
|
|
buttons: [...choice.buttons],
|
|
button_rows: getChoiceButtonRows(choice).map(row => [...row]),
|
|
selected_label: button.label,
|
|
selected_value: button.callback_data,
|
|
selected_description: getChoiceButtonSelectionText(button),
|
|
}
|
|
}
|
|
|
|
// 规范化用户选择快照,兼容后端返回和本地历史。
|
|
function normalizeChoiceSelection(value: unknown): AgentChoiceSelection | undefined {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
|
|
const item = value as Record<string, unknown>
|
|
const choice = normalizeChoiceCard({
|
|
id: item.choice_id || item.choiceId || item.id,
|
|
title: item.title,
|
|
prompt: item.prompt,
|
|
buttons: item.button_rows || item.buttonRows || item.buttons,
|
|
button_rows: item.button_rows || item.buttonRows,
|
|
status: 'selected',
|
|
selected_label: item.selected_label || item.selectedLabel,
|
|
selected_value: item.selected_value || item.selectedValue,
|
|
selected_description: item.selected_description || item.selectedDescription,
|
|
})
|
|
|
|
if (!choice) return undefined
|
|
|
|
return {
|
|
choice_id: choice.id,
|
|
title: choice.title,
|
|
prompt: choice.prompt,
|
|
buttons: choice.buttons,
|
|
button_rows: getChoiceButtonRows(choice),
|
|
selected_label: choice.selected_label,
|
|
selected_value: choice.selected_value,
|
|
selected_description: choice.selected_description,
|
|
}
|
|
}
|
|
|
|
// 将选择卡片标记为已选择,并写入可读选择文字。
|
|
function markChoiceSelected(choice: AgentChoiceCard, button: AgentChoiceButton, selection?: AgentChoiceSelection) {
|
|
choice.status = 'selected'
|
|
choice.selected_label = selection?.selected_label || button.label
|
|
choice.selected_value = selection?.selected_value || button.callback_data
|
|
choice.selected_description = selection?.selected_description || getChoiceButtonSelectionText(button)
|
|
}
|
|
|
|
// 将旧历史中保存成 callback_data 的用户选择消息修正为可读选项文字。
|
|
function normalizeChoiceSelectionMessages(sessionMessages: AgentChatMessage[]) {
|
|
const choiceLookup = new Map<string, { choice: AgentChoiceCard; button: AgentChoiceButton }>()
|
|
|
|
// 选中后清理同一组按钮索引,避免后续普通文本误判为旧选择。
|
|
const forgetChoice = (choice: AgentChoiceCard) => {
|
|
choice.buttons.forEach(button => {
|
|
choiceLookup.delete(button.callback_data)
|
|
choiceLookup.delete(button.label)
|
|
if (button.description) choiceLookup.delete(button.description)
|
|
})
|
|
}
|
|
|
|
sessionMessages.forEach(message => {
|
|
if (message.role === 'assistant') {
|
|
message.choices.forEach(choice => {
|
|
choice.buttons.forEach(button => {
|
|
choiceLookup.set(button.callback_data, { choice, button })
|
|
choiceLookup.set(button.label, { choice, button })
|
|
if (button.description) choiceLookup.set(button.description, { choice, button })
|
|
})
|
|
})
|
|
|
|
return
|
|
}
|
|
|
|
const content = message.content.trim()
|
|
const selectedText =
|
|
message.choice_selection?.selected_label || message.choice_selection?.selected_description || ''
|
|
|
|
if (message.choice_selection && selectedText && choiceLookup.has(content)) {
|
|
message.content = selectedText
|
|
forgetChoice(choiceLookup.get(content)!.choice)
|
|
return
|
|
}
|
|
|
|
const matchedChoice = choiceLookup.get(content)
|
|
if (!matchedChoice) return
|
|
|
|
message.content = getChoiceButtonSelectionText(matchedChoice.button)
|
|
message.choice_selection = buildChoiceSelection(matchedChoice.choice, matchedChoice.button)
|
|
markChoiceSelected(matchedChoice.choice, matchedChoice.button)
|
|
forgetChoice(matchedChoice.choice)
|
|
})
|
|
|
|
return sessionMessages
|
|
}
|
|
|
|
// 规范化历史消息,补齐附件、工具和选择项等可选数组。
|
|
function normalizeStoredMessages(value: unknown) {
|
|
if (!Array.isArray(value)) return []
|
|
|
|
const normalizedMessages = value.slice(-MAX_PERSISTED_MESSAGES).map(rawMessage => {
|
|
const message = rawMessage && typeof rawMessage === 'object' ? (rawMessage as Record<string, unknown>) : {}
|
|
const role = message.role === 'assistant' ? 'assistant' : 'user'
|
|
|
|
return {
|
|
...message,
|
|
id: stringifyChoiceField(message.id) || createId(role),
|
|
role,
|
|
content: typeof message.content === 'string' ? message.content : stringifyChoiceField(message.content),
|
|
createdAt: Number(message.createdAt) || Number(message.created_at) || Date.now(),
|
|
status: normalizeMessageStatus(message.status),
|
|
attachments: Array.isArray(message.attachments) ? message.attachments : [],
|
|
choices: Array.isArray(message.choices)
|
|
? (message.choices.map(normalizeChoiceCard).filter(Boolean) as AgentChoiceCard[])
|
|
: [],
|
|
tools: Array.isArray(message.tools) ? message.tools : [],
|
|
choice_selection: normalizeChoiceSelection(message.choice_selection || message.choiceSelection),
|
|
} as AgentChatMessage
|
|
})
|
|
|
|
return normalizeChoiceSelectionMessages(normalizedMessages)
|
|
}
|
|
|
|
// 解析服务端时间字符串,失败时回退到当前时间。
|
|
function parseServerTime(value?: string) {
|
|
if (!value) return Date.now()
|
|
const parsed = Date.parse(value.replace(' ', 'T'))
|
|
|
|
return Number.isFinite(parsed) ? parsed : Date.now()
|
|
}
|
|
|
|
// 规范化本地历史会话,过滤无效数据并按最近更新时间排序。
|
|
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 && !item?.title && !item?.preview)) return null
|
|
|
|
const firstMessageTime = messages[0]?.createdAt || Date.now()
|
|
const lastMessageTime = messages.at(-1)?.createdAt || firstMessageTime
|
|
|
|
return {
|
|
sessionId: sessionIdValue,
|
|
clientSessionId: typeof item?.clientSessionId === 'string' ? item.clientSessionId : undefined,
|
|
title:
|
|
typeof item?.title === 'string' && item.title.trim() ? item.title.trim() : buildSessionHistoryTitle(messages),
|
|
preview: typeof item?.preview === 'string' ? item.preview : undefined,
|
|
channel: typeof item?.channel === 'string' ? item.channel : undefined,
|
|
source: typeof item?.source === 'string' ? item.source : undefined,
|
|
isProcessing: Boolean(item?.isProcessing),
|
|
createdAt: Number(item?.createdAt) || firstMessageTime,
|
|
updatedAt: Number(item?.updatedAt) || lastMessageTime,
|
|
messages,
|
|
}
|
|
})
|
|
.filter(Boolean)
|
|
.sort((a, b) => b!.updatedAt - a!.updatedAt)
|
|
.slice(0, MAX_LOCAL_HISTORY_SESSIONS) as AgentSessionHistoryItem[]
|
|
}
|
|
|
|
// 规范化服务端历史会话摘要或详情。
|
|
function normalizeServerSession(item: AgentServerSession, withMessages = false): AgentSessionHistoryItem | null {
|
|
const sessionIdValue = typeof item?.session_id === 'string' ? item.session_id : ''
|
|
if (!sessionIdValue) return null
|
|
|
|
const messages = normalizeStoredMessages(item.messages || [])
|
|
const createdAt = parseServerTime(item.created_at)
|
|
const updatedAt = parseServerTime(item.updated_at)
|
|
|
|
return {
|
|
sessionId: sessionIdValue,
|
|
clientSessionId: item.client_session_id,
|
|
title:
|
|
item.title?.trim() ||
|
|
(messages.length ? buildSessionHistoryTitle(messages) : t('agentAssistant.untitledSession')),
|
|
preview: item.preview,
|
|
channel: item.channel,
|
|
source: item.source,
|
|
isProcessing: Boolean(item.is_processing),
|
|
createdAt,
|
|
updatedAt,
|
|
messages: withMessages ? messages : [],
|
|
}
|
|
}
|
|
|
|
// 获取历史会话去重使用的稳定标识。
|
|
function getHistoryIdentity(session: AgentSessionHistoryItem) {
|
|
return session.clientSessionId || session.sessionId
|
|
}
|
|
|
|
// 合并本地和服务端历史会话,优先保留服务端记录。
|
|
function dedupeHistorySessions(sessions: AgentSessionHistoryItem[]) {
|
|
const serverSessions = sessions.filter(item => item.sessionId.startsWith('web-agent:'))
|
|
const serverClientIds = new Set(serverSessions.map(getHistoryIdentity))
|
|
const seen = new Set<string>()
|
|
const deduped: AgentSessionHistoryItem[] = []
|
|
|
|
for (const session of sessions) {
|
|
const identity = getHistoryIdentity(session)
|
|
if (!session.sessionId.startsWith('web-agent:') && serverClientIds.has(identity)) continue
|
|
if (seen.has(identity) || seen.has(session.sessionId)) continue
|
|
seen.add(identity)
|
|
seen.add(session.sessionId)
|
|
deduped.push(session)
|
|
}
|
|
|
|
return deduped.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
}
|
|
|
|
// 调用智能助手接口,并统一处理鉴权和标准响应格式。
|
|
async function fetchAgentApi(path: string, options: RequestInit = {}) {
|
|
const headers = new Headers(options.headers || {})
|
|
if (authStore.token) headers.set('Authorization', `Bearer ${authStore.token}`)
|
|
|
|
const response = await fetch(resolveApiUrl(path), {
|
|
...options,
|
|
headers,
|
|
credentials: 'include',
|
|
})
|
|
|
|
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim())
|
|
|
|
const result = await response.json()
|
|
if (!result?.success) throw new Error(result?.message || t('agentAssistant.error'))
|
|
|
|
return result.data
|
|
}
|
|
|
|
// 从 localStorage 读取历史会话索引,读取失败时回退为空列表。
|
|
function restoreHistorySessions() {
|
|
try {
|
|
historySessions.value = normalizeHistorySessions(JSON.parse(localStorage.getItem(HISTORY_STORAGE_KEY) || '[]'))
|
|
} catch (error) {
|
|
historySessions.value = []
|
|
}
|
|
}
|
|
|
|
// 加载第一页服务端历史会话。
|
|
async function loadServerHistorySessions() {
|
|
historyPage.value = 1
|
|
historyHasMore.value = true
|
|
historyLoading.value = true
|
|
try {
|
|
const data = await fetchAgentApi(`message/agent/sessions?page=1&count=${HISTORY_PAGE_SIZE}`)
|
|
const sessions = Array.isArray(data)
|
|
? (data
|
|
.map(item => normalizeServerSession(item as AgentServerSession))
|
|
.filter(Boolean) as AgentSessionHistoryItem[])
|
|
: []
|
|
historySessions.value = dedupeHistorySessions(sessions)
|
|
historyHasMore.value = sessions.length >= HISTORY_PAGE_SIZE
|
|
persistHistorySessions()
|
|
} catch (error) {
|
|
restoreHistorySessions()
|
|
historyHasMore.value = false
|
|
} finally {
|
|
historyLoading.value = false
|
|
}
|
|
}
|
|
|
|
// 加载下一页服务端历史会话。
|
|
async function loadMoreServerHistorySessions(options?: { done?: (status: InfiniteScrollStatus) => void }) {
|
|
if (historyLoading.value || historyLoadingMore.value || !historyHasMore.value) {
|
|
options?.done?.(historyHasMore.value ? 'ok' : 'empty')
|
|
return
|
|
}
|
|
|
|
historyLoadingMore.value = true
|
|
try {
|
|
const nextPage = historyPage.value + 1
|
|
const data = await fetchAgentApi(`message/agent/sessions?page=${nextPage}&count=${HISTORY_PAGE_SIZE}`)
|
|
const sessions = Array.isArray(data)
|
|
? (data
|
|
.map(item => normalizeServerSession(item as AgentServerSession))
|
|
.filter(Boolean) as AgentSessionHistoryItem[])
|
|
: []
|
|
const existingIds = new Set(historySessions.value.map(item => item.sessionId))
|
|
historySessions.value = dedupeHistorySessions([
|
|
...historySessions.value,
|
|
...sessions.filter(item => !existingIds.has(item.sessionId)),
|
|
]).slice(0, MAX_LOCAL_HISTORY_SESSIONS)
|
|
historyPage.value = nextPage
|
|
historyHasMore.value = sessions.length >= HISTORY_PAGE_SIZE
|
|
persistHistorySessions()
|
|
options?.done?.(sessions.length ? 'ok' : 'empty')
|
|
} catch (error) {
|
|
options?.done?.('error')
|
|
} finally {
|
|
historyLoadingMore.value = false
|
|
}
|
|
}
|
|
|
|
// 加载 Web 智能助手可补全的斜杠命令列表。
|
|
async function loadSlashCommands() {
|
|
if (slashCommandsLoaded.value || slashCommandsLoading.value) return
|
|
|
|
slashCommandsLoading.value = true
|
|
try {
|
|
const data = await fetchAgentApi('message/agent/commands')
|
|
slashCommands.value = Array.isArray(data)
|
|
? data
|
|
.map(item => ({
|
|
command: stringifyChoiceField(item?.command),
|
|
description: stringifyChoiceField(item?.description),
|
|
category: stringifyChoiceField(item?.category) || undefined,
|
|
type: stringifyChoiceField(item?.type) || undefined,
|
|
pid: stringifyChoiceField(item?.pid) || undefined,
|
|
}))
|
|
.filter(item => item.command.startsWith('/'))
|
|
: []
|
|
slashCommandsLoaded.value = true
|
|
} catch (error: any) {
|
|
streamError.value = error?.message || t('agentAssistant.commandLoadFailed')
|
|
} finally {
|
|
slashCommandsLoading.value = false
|
|
}
|
|
}
|
|
|
|
// 选择命令建议并写入输入框。
|
|
function selectSlashCommand(command: AgentSlashCommand) {
|
|
inputText.value = `${command.command} `
|
|
nextTick(() => {
|
|
syncInputHeight()
|
|
inputRef.value?.focus()
|
|
})
|
|
}
|
|
|
|
// 输入内容变化时同步高度,并按需预加载命令建议。
|
|
function handleInputChange() {
|
|
syncInputHeight()
|
|
if (inputText.value.trimStart().startsWith('/')) loadSlashCommands()
|
|
}
|
|
|
|
// 处理历史菜单的无限滚动加载事件。
|
|
async function handleHistoryInfiniteLoad({
|
|
done,
|
|
}: {
|
|
side: InfiniteScrollSide
|
|
done: (status: InfiniteScrollStatus) => void
|
|
}) {
|
|
await loadMoreServerHistorySessions({ done })
|
|
}
|
|
|
|
// 加载服务端历史会话详情,并更新本地缓存。
|
|
async function loadServerHistorySession(targetSessionId: string) {
|
|
const data = await fetchAgentApi(`message/agent/sessions/${encodeURIComponent(targetSessionId)}`)
|
|
const session = normalizeServerSession(data as AgentServerSession, true)
|
|
if (!session) throw new Error(t('agentAssistant.historyLoadFailed'))
|
|
|
|
historySessions.value = dedupeHistorySessions([
|
|
session,
|
|
...historySessions.value.filter(item => item.sessionId !== targetSessionId),
|
|
]).slice(0, MAX_LOCAL_HISTORY_SESSIONS)
|
|
persistHistorySessions()
|
|
|
|
return session
|
|
}
|
|
|
|
// 清理等待中的 WebAgent 断流恢复轮询。
|
|
function clearStreamRecoveryTimer() {
|
|
if (streamRecoveryTimer === null) return
|
|
|
|
window.clearTimeout(streamRecoveryTimer)
|
|
streamRecoveryTimer = null
|
|
}
|
|
|
|
// 从服务端拉取当前会话展示快照,用于移动端后台断开 SSE 后恢复最终结果。
|
|
async function restoreCurrentSessionFromServer(targetSessionId: string, startedAt: number) {
|
|
const session = await loadServerHistorySession(targetSessionId)
|
|
const activeSessionIds = new Set([sessionId.value, targetSessionId, session.clientSessionId].filter(Boolean))
|
|
if (!activeSessionIds.has(session.sessionId)) return { restored: false, pending: false }
|
|
if (session.updatedAt < startedAt - 1000) return { restored: false, pending: Boolean(session.isProcessing) }
|
|
if (!session.messages.length) return { restored: false, pending: Boolean(session.isProcessing) }
|
|
|
|
messages.value = normalizeStoredMessages(session.messages)
|
|
pendingStreamRecovery = null
|
|
persistState({ syncHistory: false })
|
|
scrollToBottom()
|
|
return { restored: true, pending: false }
|
|
}
|
|
|
|
// WebAgent SSE 断流后,后台任务仍会完成并保存快照;前端回到前台后轮询拉取。
|
|
function scheduleStreamRecovery(delay = 1200) {
|
|
if (!pendingStreamRecovery || typeof window === 'undefined') return
|
|
|
|
clearStreamRecoveryTimer()
|
|
streamRecoveryTimer = window.setTimeout(async () => {
|
|
streamRecoveryTimer = null
|
|
if (!pendingStreamRecovery) return
|
|
if (document.visibilityState === 'hidden') return
|
|
|
|
const recovery = pendingStreamRecovery
|
|
try {
|
|
const result = await restoreCurrentSessionFromServer(recovery.sessionId, recovery.startedAt)
|
|
if (result.restored) return
|
|
if (result.pending) {
|
|
scheduleStreamRecovery(Math.min(8000, 1200 + recovery.attempts * 900))
|
|
return
|
|
}
|
|
} catch (error) {
|
|
// 会话快照可能还未写入,继续按退避间隔等待。
|
|
}
|
|
|
|
if (!pendingStreamRecovery || pendingStreamRecovery.sessionId !== recovery.sessionId) return
|
|
pendingStreamRecovery.attempts += 1
|
|
if (pendingStreamRecovery.attempts > 8) {
|
|
pendingStreamRecovery = null
|
|
return
|
|
}
|
|
|
|
scheduleStreamRecovery(Math.min(8000, 1200 + pendingStreamRecovery.attempts * 900))
|
|
}, delay)
|
|
}
|
|
|
|
// 恢复当前会话状态,优先使用本地状态,缺失时使用最近历史。
|
|
function restoreState() {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY)
|
|
if (!raw) {
|
|
const latestSession = historySessions.value[0]
|
|
if (latestSession?.messages.length) {
|
|
sessionId.value = latestSession.sessionId
|
|
messages.value = normalizeStoredMessages(latestSession.messages)
|
|
} else {
|
|
sessionId.value = createSessionId()
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
const state = JSON.parse(raw)
|
|
sessionId.value = state.sessionId || createSessionId()
|
|
messages.value = normalizeStoredMessages(state.messages)
|
|
upsertCurrentSessionHistory()
|
|
} catch (error) {
|
|
sessionId.value = createSessionId()
|
|
}
|
|
}
|
|
|
|
// 将历史会话列表写入 localStorage,空间不足时保留最近的一半会话重试。
|
|
function persistHistorySessions() {
|
|
const sessions = dedupeHistorySessions(historySessions.value).slice(0, MAX_LOCAL_HISTORY_SESSIONS)
|
|
historySessions.value = sessions
|
|
|
|
try {
|
|
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 getSessionChannelLabel(session: AgentSessionHistoryItem) {
|
|
if (session.channel === 'WebAgent') return t('agentAssistant.webAgentChannel')
|
|
|
|
const parts = [session.channel, session.source].filter(Boolean)
|
|
if (!parts.length) return t('agentAssistant.unknownChannel')
|
|
|
|
return parts.join(' / ')
|
|
}
|
|
|
|
// 提取消息的可读摘要,纯附件消息会使用附件名称或附件占位文本。
|
|
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,
|
|
clientSessionId: existingSession?.clientSessionId || sessionId.value,
|
|
title: buildSessionHistoryTitle(storedMessages),
|
|
preview: existingSession?.preview,
|
|
channel: existingSession?.channel || 'WebAgent',
|
|
source: existingSession?.source || 'web-agent',
|
|
createdAt,
|
|
updatedAt,
|
|
messages: storedMessages,
|
|
}
|
|
|
|
historySessions.value = dedupeHistorySessions([
|
|
nextSession,
|
|
...historySessions.value.filter(item => item.sessionId !== sessionId.value),
|
|
]).slice(0, MAX_LOCAL_HISTORY_SESSIONS)
|
|
|
|
persistHistorySessions()
|
|
}
|
|
|
|
// 将当前会话展示消息保存到服务端历史。
|
|
async function saveCurrentSessionToServer() {
|
|
if (!sessionId.value || messages.value.length === 0) return
|
|
|
|
await fetchAgentApi(`message/agent/sessions/${encodeURIComponent(sessionId.value)}/display`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
title: buildSessionHistoryTitle(messages.value),
|
|
messages: normalizeStoredMessages(messages.value),
|
|
}),
|
|
})
|
|
}
|
|
|
|
// 持久化当前会话状态,并按需同步到历史会话列表。
|
|
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()
|
|
}
|
|
|
|
// 渲染助手消息中的 Markdown 文本。
|
|
function renderMarkdown(value: string) {
|
|
if (!value) return ''
|
|
return md.render(value)
|
|
}
|
|
|
|
// 拼接后端 API 地址。
|
|
function resolveApiUrl(path: string) {
|
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/'
|
|
return `${baseUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}`
|
|
}
|
|
|
|
// 消息主列表使用原生滚动,避免流式回复时 JS 滚动库频繁测量影响手感。
|
|
function getMessageScrollerElement() {
|
|
return messageListRef.value
|
|
}
|
|
|
|
// 判断消息列表是否停留在底部附近,用于流式输出自动跟随。
|
|
function isMessageScrollerNearBottom() {
|
|
const scroller = getMessageScrollerElement()
|
|
if (!scroller) return true
|
|
|
|
return scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= MESSAGE_SCROLL_FOLLOW_THRESHOLD
|
|
}
|
|
|
|
// 合并滚动更新请求,降低流式输出时的布局测量频率。
|
|
function scheduleMessageScrollerUpdate(options: { toBottom?: boolean } = {}) {
|
|
const { toBottom = false } = options
|
|
pendingMessageScrollToBottom ||= toBottom
|
|
if (messageScrollFrame !== null) return
|
|
|
|
messageScrollFrame = window.requestAnimationFrame(() => {
|
|
messageScrollFrame = null
|
|
const scroller = getMessageScrollerElement()
|
|
if (!scroller) return
|
|
|
|
if (pendingMessageScrollToBottom) scroller.scrollTop = scroller.scrollHeight
|
|
pendingMessageScrollToBottom = false
|
|
})
|
|
}
|
|
|
|
// 将消息列表滚动到底部。
|
|
function scrollToBottom(options: { smooth?: boolean } = {}) {
|
|
const { smooth = false } = options
|
|
nextTick(() => {
|
|
const scroller = getMessageScrollerElement()
|
|
if (!scroller) return
|
|
|
|
if (smooth) {
|
|
scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' })
|
|
scheduleMessageScrollerUpdate()
|
|
return
|
|
}
|
|
|
|
scheduleMessageScrollerUpdate({ toBottom: true })
|
|
})
|
|
}
|
|
|
|
// 新会话展示空态时必须回到顶部,避免复用上一段长会话的滚动位置导致空白。
|
|
function scrollToTop() {
|
|
nextTick(() => {
|
|
requestAnimationFrame(() => {
|
|
const scroller = getMessageScrollerElement()
|
|
if (!scroller) return
|
|
|
|
scroller.scrollTop = 0
|
|
})
|
|
})
|
|
}
|
|
|
|
// 清理流式消息延迟持久化计时器。
|
|
function clearStreamPersistTimer() {
|
|
if (streamPersistTimer === null) return
|
|
|
|
window.clearTimeout(streamPersistTimer)
|
|
streamPersistTimer = null
|
|
}
|
|
|
|
// 清理等待执行的消息滚动动画帧。
|
|
function clearMessageScrollFrame() {
|
|
if (messageScrollFrame === null) return
|
|
|
|
window.cancelAnimationFrame(messageScrollFrame)
|
|
messageScrollFrame = null
|
|
pendingMessageScrollToBottom = false
|
|
}
|
|
|
|
// 延迟持久化流式消息,避免每个 token 都写入本地存储。
|
|
function scheduleStreamPersist() {
|
|
clearStreamPersistTimer()
|
|
streamPersistTimer = window.setTimeout(() => {
|
|
persistState()
|
|
streamPersistTimer = null
|
|
}, STREAM_STATE_PERSIST_DELAY)
|
|
}
|
|
|
|
// 同步输入框高度,使多行输入不撑破底部布局。
|
|
function syncInputHeight() {
|
|
nextTick(() => {
|
|
const input = inputRef.value
|
|
if (!input) return
|
|
|
|
input.style.blockSize = 'auto'
|
|
input.style.blockSize = `${Math.min(input.scrollHeight, 120)}px`
|
|
})
|
|
}
|
|
|
|
// 刷新消息数组引用,确保流式回调中的消息字段变更能稳定触发当前会话视图更新。
|
|
function refreshMessageList() {
|
|
messages.value = [...messages.value]
|
|
}
|
|
|
|
// 添加一条聊天消息,并返回消息列表中的响应式对象供后续流式更新使用。
|
|
function addMessage(
|
|
role: AgentMessageRole,
|
|
content: string,
|
|
status: AgentMessageStatus = 'idle',
|
|
attachments: AgentMessageAttachment[] = [],
|
|
choiceSelection?: AgentChoiceSelection,
|
|
) {
|
|
const message: AgentChatMessage = {
|
|
id: createId(role),
|
|
role,
|
|
content,
|
|
createdAt: Date.now(),
|
|
status,
|
|
attachments,
|
|
choices: [],
|
|
tools: [],
|
|
choice_selection: choiceSelection,
|
|
}
|
|
messages.value.push(message)
|
|
const reactiveMessage = messages.value[messages.value.length - 1]
|
|
|
|
persistState()
|
|
scrollToBottom()
|
|
return reactiveMessage
|
|
}
|
|
|
|
// 清理后端工具提示前缀。
|
|
function normalizeToolMessage(message: string) {
|
|
return message.replace(/^=>\s*/, '').trim()
|
|
}
|
|
|
|
// 将当前消息里的运行中工具标记为完成。
|
|
function markToolsDone(message: AgentChatMessage) {
|
|
message.tools.forEach(tool => {
|
|
tool.status = 'done'
|
|
})
|
|
}
|
|
|
|
// 判断消息是否没有任何可展示内容,可用于清理编辑回调产生的占位回复。
|
|
function isEmptyAssistantMessage(message: AgentChatMessage) {
|
|
return (
|
|
message.role === 'assistant' &&
|
|
!message.content &&
|
|
message.attachments.length === 0 &&
|
|
message.choices.length === 0 &&
|
|
message.tools.length === 0
|
|
)
|
|
}
|
|
|
|
// 将服务端编辑事件应用到原助手消息卡片。
|
|
function applyMessageUpdate(event: AgentStreamEvent) {
|
|
const target = event.target_message
|
|
const targetId = String(target?.id || event.message_id || '')
|
|
if (!targetId) return false
|
|
|
|
const message = messages.value.find(item => item.id === targetId)
|
|
if (!message || message.role !== 'assistant') return false
|
|
|
|
message.content = typeof target?.content === 'string' ? target.content : ''
|
|
message.attachments = Array.isArray(target?.attachments) ? target.attachments : []
|
|
message.tools = Array.isArray(target?.tools) ? target.tools : []
|
|
message.choices = Array.isArray(target?.choices)
|
|
? (target.choices.map(normalizeChoiceCard).filter(Boolean) as AgentChoiceCard[])
|
|
: []
|
|
message.status = normalizeMessageStatus(target?.status || 'done')
|
|
refreshMessageList()
|
|
persistState()
|
|
return true
|
|
}
|
|
|
|
// 将单个 SSE 事件应用到正在流式输出的助手消息。
|
|
function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMessage) {
|
|
const shouldFollowBottom = isMessageScrollerNearBottom()
|
|
|
|
switch (event.type) {
|
|
case 'delta':
|
|
assistantMessage.content += event.content || ''
|
|
emit('assistant-preview', assistantMessage.content)
|
|
break
|
|
case 'tool':
|
|
markToolsDone(assistantMessage)
|
|
assistantMessage.tools.push({
|
|
id: createId('tool'),
|
|
message: normalizeToolMessage(event.message || ''),
|
|
status: 'running',
|
|
})
|
|
break
|
|
case 'attachment':
|
|
if (event.attachment?.url) {
|
|
assistantMessage.attachments.push(event.attachment)
|
|
}
|
|
break
|
|
case 'choice':
|
|
if (event.choice?.id) {
|
|
assistantMessage.choices.push({
|
|
...event.choice,
|
|
status: 'pending',
|
|
})
|
|
}
|
|
break
|
|
case 'message_update':
|
|
applyMessageUpdate(event)
|
|
break
|
|
case 'done':
|
|
if (assistantMessage.status !== 'error') {
|
|
assistantMessage.status = 'done'
|
|
}
|
|
markToolsDone(assistantMessage)
|
|
break
|
|
case 'error':
|
|
assistantMessage.status = 'error'
|
|
// 后端流式错误已经以 AI 消息展示,避免底部提示条重复且持续占位。
|
|
assistantMessage.content ||= event.message || t('agentAssistant.error')
|
|
emit('assistant-preview', assistantMessage.content)
|
|
markToolsDone(assistantMessage)
|
|
break
|
|
case 'start':
|
|
if (event.session_id) sessionId.value = event.session_id
|
|
break
|
|
default:
|
|
break
|
|
}
|
|
|
|
scheduleStreamPersist()
|
|
nextTick(() => {
|
|
scheduleMessageScrollerUpdate({ toBottom: shouldFollowBottom })
|
|
})
|
|
}
|
|
|
|
// 解析一个 SSE 数据块。
|
|
function parseSseBlock(block: string) {
|
|
const data = block
|
|
.split('\n')
|
|
.filter(line => line.startsWith('data:'))
|
|
.map(line => line.slice(5).trimStart())
|
|
.join('\n')
|
|
|
|
if (!data) return null
|
|
return JSON.parse(data) as AgentStreamEvent
|
|
}
|
|
|
|
// 读取并应用智能助手 SSE 响应流。
|
|
async function readAgentStream(response: Response, assistantMessage: AgentChatMessage) {
|
|
if (!response.body) {
|
|
throw new Error(t('agentAssistant.noStream'))
|
|
}
|
|
|
|
const reader = response.body.getReader()
|
|
const decoder = new TextDecoder('utf-8')
|
|
let buffer = ''
|
|
|
|
while (true) {
|
|
const { value, done } = await reader.read()
|
|
if (done) break
|
|
|
|
buffer += decoder.decode(value, { stream: true })
|
|
const blocks = buffer.split(/\n\n/)
|
|
buffer = blocks.pop() || ''
|
|
|
|
for (const block of blocks) {
|
|
const event = parseSseBlock(block)
|
|
if (event) applyStreamEvent(event, assistantMessage)
|
|
}
|
|
}
|
|
|
|
buffer += decoder.decode()
|
|
if (buffer.trim()) {
|
|
const event = parseSseBlock(buffer)
|
|
if (event) applyStreamEvent(event, assistantMessage)
|
|
}
|
|
}
|
|
|
|
// 移动端浏览器退到后台时,SSE/fetch 可能以 TypeError: Load failed 等形式被动断开。
|
|
function isRecoverableStreamDisconnect(error: any) {
|
|
if (userAbortRequested) return false
|
|
|
|
const message = String(error?.message || error || '').toLowerCase()
|
|
if (document.visibilityState === 'hidden') return true
|
|
|
|
return (
|
|
error?.name === 'AbortError' ||
|
|
message.includes('load failed') ||
|
|
message.includes('failed to fetch') ||
|
|
message.includes('networkerror') ||
|
|
message.includes('network error')
|
|
)
|
|
}
|
|
|
|
// 解析附件地址,支持相对 API 路径。
|
|
function resolveAttachmentUrl(url?: string) {
|
|
if (!url) return ''
|
|
if (/^(https?:|data:|blob:|\/)/.test(url)) return url
|
|
|
|
return resolveApiUrl(url)
|
|
}
|
|
|
|
// 获取附件下载地址。
|
|
function getAttachmentDownloadUrl(attachment: AgentMessageAttachment) {
|
|
return resolveAttachmentUrl(attachment.download_url || attachment.url)
|
|
}
|
|
|
|
// 获取附件展示名称。
|
|
function getAttachmentName(attachment: AgentMessageAttachment) {
|
|
return attachment.name || (attachment.kind === 'image' ? 'image' : 'attachment')
|
|
}
|
|
|
|
// 获取附件类型图标。
|
|
function getAttachmentIcon(attachment: { kind: AgentAttachmentKind }) {
|
|
if (attachment.kind === 'audio') return 'mdi-volume-high'
|
|
if (attachment.kind === 'image') return 'mdi-image-outline'
|
|
return 'mdi-file-outline'
|
|
}
|
|
|
|
// 格式化附件大小。
|
|
function formatAttachmentSize(size?: number) {
|
|
if (!size) return ''
|
|
if (size < 1024) return `${size} B`
|
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
|
|
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
|
}
|
|
|
|
// 根据浏览器文件 MIME 类型判断附件类别。
|
|
function getFileKind(file: File): AgentAttachmentKind {
|
|
if (file.type.startsWith('image/')) return 'image'
|
|
if (file.type.startsWith('audio/')) return 'audio'
|
|
return 'file'
|
|
}
|
|
|
|
// 打开系统文件选择器。
|
|
function openFilePicker() {
|
|
fileInputRef.value?.click()
|
|
}
|
|
|
|
// 处理文件选择并生成待发送附件预览。
|
|
function handleFileSelection(event: Event) {
|
|
const input = event.target as HTMLInputElement
|
|
const files = Array.from(input.files || [])
|
|
const nextAttachments = files.map(file => {
|
|
const kind = getFileKind(file)
|
|
|
|
return {
|
|
id: createId('attachment'),
|
|
file,
|
|
kind,
|
|
name: file.name,
|
|
mime_type: file.type || 'application/octet-stream',
|
|
size: file.size,
|
|
preview_url: kind === 'image' ? URL.createObjectURL(file) : undefined,
|
|
}
|
|
})
|
|
|
|
pendingAttachments.value.push(...nextAttachments)
|
|
input.value = ''
|
|
}
|
|
|
|
// 移除一条待发送附件。
|
|
function removePendingAttachment(id: string) {
|
|
const attachment = pendingAttachments.value.find(item => item.id === id)
|
|
if (attachment?.preview_url) URL.revokeObjectURL(attachment.preview_url)
|
|
pendingAttachments.value = pendingAttachments.value.filter(item => item.id !== id)
|
|
}
|
|
|
|
// 清理全部待发送附件和临时预览地址。
|
|
function clearPendingAttachments() {
|
|
pendingAttachments.value.forEach(item => {
|
|
if (item.preview_url) URL.revokeObjectURL(item.preview_url)
|
|
})
|
|
pendingAttachments.value = []
|
|
}
|
|
|
|
// 将图片文件读取为 data URL,供多模态输入和本地展示使用。
|
|
function readFileAsDataUrl(file: File) {
|
|
return new Promise<string>((resolve, reject) => {
|
|
const reader = new FileReader()
|
|
reader.onload = () => resolve(String(reader.result || ''))
|
|
reader.onerror = () => reject(reader.error || new Error(t('agentAssistant.uploadFailed')))
|
|
reader.readAsDataURL(file)
|
|
})
|
|
}
|
|
|
|
// 上传智能助手附件,返回后端可引用的附件描述。
|
|
async function uploadAgentAttachment(file: File) {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
formData.append('session_id', sessionId.value)
|
|
|
|
const response = await fetch(resolveApiUrl('message/agent/upload'), {
|
|
method: 'POST',
|
|
headers: {
|
|
...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}),
|
|
},
|
|
body: formData,
|
|
credentials: 'include',
|
|
})
|
|
|
|
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim())
|
|
|
|
const result = await response.json()
|
|
if (!result?.success) throw new Error(result?.message || t('agentAssistant.uploadFailed'))
|
|
|
|
return result.data as AgentMessageAttachment & AgentOutgoingFile
|
|
}
|
|
|
|
// 准备本轮发送给 Agent 的图片、文件、音频和展示附件。
|
|
async function prepareAgentAttachments(items: AgentPendingAttachment[]): Promise<PreparedAgentAttachments> {
|
|
const images: string[] = []
|
|
const files: AgentOutgoingFile[] = []
|
|
const audioRefs: string[] = []
|
|
const userAttachments: AgentMessageAttachment[] = []
|
|
|
|
for (const item of items) {
|
|
const imageDataUrl = item.kind === 'image' ? await readFileAsDataUrl(item.file) : ''
|
|
const uploaded = await uploadAgentAttachment(item.file)
|
|
const displayAttachment: AgentMessageAttachment = {
|
|
kind: item.kind,
|
|
url: item.kind === 'image' ? imageDataUrl : uploaded.url,
|
|
download_url: uploaded.download_url || uploaded.url,
|
|
name: item.name,
|
|
mime_type: item.mime_type,
|
|
size: item.size,
|
|
}
|
|
|
|
if (imageDataUrl) images.push(imageDataUrl)
|
|
const outgoingFile = {
|
|
ref: uploaded.ref || uploaded.url,
|
|
name: uploaded.name || item.name,
|
|
mime_type: uploaded.mime_type || item.mime_type,
|
|
size: uploaded.size || item.size,
|
|
local_path: uploaded.local_path,
|
|
status: uploaded.status || 'ready',
|
|
}
|
|
|
|
if (item.kind === 'audio') {
|
|
audioRefs.push(outgoingFile.ref)
|
|
} else {
|
|
files.push(outgoingFile)
|
|
}
|
|
userAttachments.push(displayAttachment)
|
|
}
|
|
|
|
return { images, files, audioRefs, userAttachments }
|
|
}
|
|
|
|
// 发送一轮智能助手消息,并处理本地展示、SSE 回复和历史同步。
|
|
async function streamAgentMessage(
|
|
text: string,
|
|
images: string[] = [],
|
|
files: AgentOutgoingFile[] = [],
|
|
audioRefs: string[] = [],
|
|
userAttachments: AgentMessageAttachment[] = [],
|
|
options: AgentStreamMessageOptions | boolean = {},
|
|
) {
|
|
const content = text.trim()
|
|
const streamOptions = typeof options === 'boolean' ? { echoUser: options } : options
|
|
const { echoUser = true, displayText, choiceSelection, originalMessageId, originalChatId } = streamOptions
|
|
const displayContent = (displayText ?? content).trim()
|
|
if (!content && !images.length && !files.length && !audioRefs.length) return
|
|
|
|
if (echoUser) addMessage('user', displayContent || content, 'done', userAttachments, choiceSelection)
|
|
const assistantMessage = addMessage('assistant', '', 'streaming')
|
|
|
|
abortController = new AbortController()
|
|
userAbortRequested = false
|
|
const streamStartedAt = Date.now()
|
|
let shouldFollowBottomAfterStream = true
|
|
let shouldSaveClientSnapshot = true
|
|
|
|
try {
|
|
const response = await fetch(resolveApiUrl('message/agent/stream'), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}),
|
|
},
|
|
body: JSON.stringify({
|
|
text: content,
|
|
display_text: displayContent || content,
|
|
session_id: sessionId.value,
|
|
images,
|
|
files,
|
|
audio_refs: audioRefs,
|
|
choice_selection: choiceSelection,
|
|
original_message_id: originalMessageId,
|
|
original_chat_id: originalChatId,
|
|
echo_user: echoUser,
|
|
}),
|
|
credentials: 'include',
|
|
signal: abortController.signal,
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status} ${response.statusText}`.trim())
|
|
}
|
|
|
|
await readAgentStream(response, assistantMessage)
|
|
shouldFollowBottomAfterStream = isMessageScrollerNearBottom()
|
|
if (isEmptyAssistantMessage(assistantMessage)) {
|
|
messages.value = messages.value.filter(message => message.id !== assistantMessage.id)
|
|
refreshMessageList()
|
|
return
|
|
}
|
|
if (assistantMessage.status === 'streaming') {
|
|
assistantMessage.status = 'done'
|
|
markToolsDone(assistantMessage)
|
|
refreshMessageList()
|
|
}
|
|
} catch (error: any) {
|
|
if (error?.name === 'AbortError' && userAbortRequested) {
|
|
assistantMessage.status = 'done'
|
|
markToolsDone(assistantMessage)
|
|
refreshMessageList()
|
|
return
|
|
}
|
|
|
|
if (isRecoverableStreamDisconnect(error)) {
|
|
shouldSaveClientSnapshot = false
|
|
pendingStreamRecovery = {
|
|
sessionId: sessionId.value,
|
|
startedAt: streamStartedAt,
|
|
attempts: 0,
|
|
}
|
|
assistantMessage.status = 'done'
|
|
markToolsDone(assistantMessage)
|
|
refreshMessageList()
|
|
if (document.visibilityState === 'visible') scheduleStreamRecovery(1200)
|
|
return
|
|
}
|
|
|
|
assistantMessage.status = 'error'
|
|
assistantMessage.content = error?.message || t('agentAssistant.error')
|
|
markToolsDone(assistantMessage)
|
|
refreshMessageList()
|
|
} finally {
|
|
abortController = null
|
|
userAbortRequested = false
|
|
clearStreamPersistTimer()
|
|
persistState()
|
|
if (shouldSaveClientSnapshot) {
|
|
try {
|
|
await saveCurrentSessionToServer()
|
|
await loadServerHistorySessions()
|
|
} catch (error) {
|
|
// 服务端历史保存失败时保留本地兜底历史,不影响当前会话继续交互。
|
|
}
|
|
}
|
|
if (shouldFollowBottomAfterStream) scrollToBottom()
|
|
}
|
|
}
|
|
|
|
// 发送输入框中的文本和附件。
|
|
async function sendMessage() {
|
|
const text = inputText.value.trim()
|
|
const attachments = [...pendingAttachments.value]
|
|
if ((!text && !attachments.length) || sending.value) return
|
|
|
|
streamError.value = ''
|
|
inputText.value = ''
|
|
clearPendingAttachments()
|
|
syncInputHeight()
|
|
sending.value = true
|
|
|
|
try {
|
|
const prepared = await prepareAgentAttachments(attachments)
|
|
await streamAgentMessage(text, prepared.images, prepared.files, prepared.audioRefs, prepared.userAttachments)
|
|
} catch (error: any) {
|
|
// 附件准备失败同样落到对话消息里,底部提示条只保留给没有消息承载的本地错误。
|
|
addMessage('assistant', error?.message || t('agentAssistant.uploadFailed'), 'error')
|
|
} finally {
|
|
sending.value = false
|
|
}
|
|
}
|
|
|
|
// 获取当前浏览器支持的录音 MIME 类型。
|
|
function getRecorderMimeType() {
|
|
const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus']
|
|
return candidates.find(type => MediaRecorder.isTypeSupported(type)) || ''
|
|
}
|
|
|
|
// 根据录音 MIME 类型推断文件扩展名。
|
|
function getRecordingFileExtension(mimeType: string) {
|
|
if (mimeType.includes('mp4')) return 'm4a'
|
|
if (mimeType.includes('ogg')) return 'ogg'
|
|
return 'webm'
|
|
}
|
|
|
|
// 停止录音媒体流,释放麦克风。
|
|
function stopRecordingStream() {
|
|
mediaRecorderStream?.getTracks().forEach(track => track.stop())
|
|
mediaRecorderStream = null
|
|
}
|
|
|
|
// 清理录音计时器。
|
|
function clearRecordingTimer() {
|
|
if (recordingTimer === null) return
|
|
|
|
window.clearInterval(recordingTimer)
|
|
recordingTimer = null
|
|
}
|
|
|
|
// 结束录音状态并释放相关资源。
|
|
function finishRecordingState() {
|
|
recording.value = false
|
|
recordingStartedAt.value = 0
|
|
recordingDuration.value = 0
|
|
mediaRecorder = null
|
|
clearRecordingTimer()
|
|
stopRecordingStream()
|
|
}
|
|
|
|
// 开始录制语音消息。
|
|
async function startVoiceRecording() {
|
|
if (!canRecord.value) return
|
|
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
|
streamError.value = t('agentAssistant.recordUnsupported')
|
|
return
|
|
}
|
|
|
|
try {
|
|
streamError.value = ''
|
|
recordingChunks = []
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
const mimeType = getRecorderMimeType()
|
|
const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)
|
|
|
|
mediaRecorderStream = stream
|
|
mediaRecorder = recorder
|
|
recording.value = true
|
|
recordingStartedAt.value = Date.now()
|
|
recordingDuration.value = 0
|
|
recordingTimer = window.setInterval(() => {
|
|
recordingDuration.value = Math.floor((Date.now() - recordingStartedAt.value) / 1000)
|
|
}, 500)
|
|
|
|
recorder.ondataavailable = event => {
|
|
if (event.data.size > 0) recordingChunks.push(event.data)
|
|
}
|
|
recorder.onstop = () => {
|
|
const recordedMimeType = recorder.mimeType || mimeType || 'audio/webm'
|
|
const audioBlob = new Blob(recordingChunks, { type: recordedMimeType })
|
|
const extension = getRecordingFileExtension(recordedMimeType)
|
|
const file = new File([audioBlob], `voice-${Date.now()}.${extension}`, { type: recordedMimeType })
|
|
|
|
finishRecordingState()
|
|
recordingChunks = []
|
|
|
|
if (audioBlob.size <= 0) {
|
|
streamError.value = t('agentAssistant.recordFailed')
|
|
return
|
|
}
|
|
|
|
pendingAttachments.value.push({
|
|
id: createId('recording'),
|
|
file,
|
|
kind: 'audio',
|
|
name: file.name,
|
|
mime_type: recordedMimeType,
|
|
size: file.size,
|
|
})
|
|
sendMessage()
|
|
}
|
|
recorder.onerror = () => {
|
|
finishRecordingState()
|
|
recordingChunks = []
|
|
streamError.value = t('agentAssistant.recordFailed')
|
|
}
|
|
recorder.start()
|
|
} catch (error: any) {
|
|
finishRecordingState()
|
|
recordingChunks = []
|
|
streamError.value = error?.message || t('agentAssistant.recordPermissionDenied')
|
|
}
|
|
}
|
|
|
|
// 停止当前录音并触发文件生成。
|
|
function stopVoiceRecording() {
|
|
if (!mediaRecorder || mediaRecorder.state === 'inactive') {
|
|
finishRecordingState()
|
|
return
|
|
}
|
|
|
|
mediaRecorder.stop()
|
|
}
|
|
|
|
// 取消当前录音并丢弃已录制内容。
|
|
function cancelVoiceRecording() {
|
|
if (mediaRecorder) {
|
|
mediaRecorder.ondataavailable = null
|
|
mediaRecorder.onstop = null
|
|
mediaRecorder.onerror = null
|
|
if (mediaRecorder.state !== 'inactive') mediaRecorder.stop()
|
|
}
|
|
mediaRecorder = null
|
|
recordingChunks = []
|
|
finishRecordingState()
|
|
}
|
|
|
|
// 切换语音录制状态。
|
|
function toggleVoiceRecording() {
|
|
if (recording.value) {
|
|
stopVoiceRecording()
|
|
return
|
|
}
|
|
|
|
startVoiceRecording()
|
|
}
|
|
|
|
// 处理选择按钮点击,保存可读选择描述并把真实值发给 Agent。
|
|
async function handleChoiceClick(message: AgentChatMessage, choice: AgentChoiceCard, button: AgentChoiceButton) {
|
|
if (sending.value || choice.status !== 'pending') return
|
|
|
|
sending.value = true
|
|
streamError.value = ''
|
|
|
|
try {
|
|
const response = await fetch(resolveApiUrl('message/agent/callback'), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}),
|
|
},
|
|
body: JSON.stringify({
|
|
session_id: sessionId.value,
|
|
callback_data: button.callback_data,
|
|
original_message_id: message.id,
|
|
original_chat_id: sessionId.value,
|
|
}),
|
|
credentials: 'include',
|
|
})
|
|
|
|
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim())
|
|
|
|
const result = await response.json()
|
|
if (!result?.success) throw new Error(result?.message || t('agentAssistant.choiceExpired'))
|
|
|
|
const agentMessage = String(result.data?.message || '')
|
|
if (result.data?.traditional) {
|
|
const choiceSelection = buildChoiceSelection(choice, button)
|
|
choiceSelection.selected_description = getChoiceButtonSelectionText(button)
|
|
markChoiceSelected(choice, button, choiceSelection)
|
|
refreshMessageList()
|
|
persistState()
|
|
await streamAgentMessage(agentMessage, [], [], [], [], {
|
|
echoUser: false,
|
|
displayText: choiceSelection.selected_label || choiceSelection.selected_description,
|
|
choiceSelection,
|
|
originalMessageId: String(result.data?.original_message_id || message.id),
|
|
originalChatId: String(result.data?.original_chat_id || sessionId.value),
|
|
})
|
|
return
|
|
}
|
|
|
|
const backendSelection = normalizeChoiceSelection(result.data?.choice_selection)
|
|
const choiceSelection = backendSelection || buildChoiceSelection(choice, button)
|
|
choiceSelection.selected_label =
|
|
result.data?.feedback?.selected_label || choiceSelection.selected_label || button.label
|
|
choiceSelection.selected_value =
|
|
result.data?.feedback?.selected_value || choiceSelection.selected_value || agentMessage
|
|
choiceSelection.selected_description =
|
|
result.data?.display_message ||
|
|
result.data?.feedback?.selected_description ||
|
|
choiceSelection.selected_description ||
|
|
getChoiceButtonSelectionText(button)
|
|
|
|
markChoiceSelected(choice, button, choiceSelection)
|
|
refreshMessageList()
|
|
persistState()
|
|
|
|
await streamAgentMessage(agentMessage, [], [], [], [], {
|
|
echoUser: false,
|
|
displayText: choiceSelection.selected_label || choiceSelection.selected_description,
|
|
choiceSelection,
|
|
})
|
|
} catch (error: any) {
|
|
choice.status = 'expired'
|
|
streamError.value = error?.message || t('agentAssistant.choiceExpired')
|
|
refreshMessageList()
|
|
persistState()
|
|
} finally {
|
|
sending.value = false
|
|
}
|
|
}
|
|
|
|
// 中止当前流式回复。
|
|
function stopGeneration() {
|
|
userAbortRequested = true
|
|
pendingStreamRecovery = null
|
|
clearStreamRecoveryTimer()
|
|
if (sessionId.value) {
|
|
fetchAgentApi(`message/agent/sessions/${encodeURIComponent(sessionId.value)}/stop`, {
|
|
method: 'POST',
|
|
}).catch(() => {
|
|
// 本地中止优先,停止接口失败不阻塞用户操作。
|
|
})
|
|
}
|
|
abortController?.abort()
|
|
}
|
|
|
|
// 开始新的空白会话。
|
|
function startNewSession() {
|
|
stopGeneration()
|
|
sessionId.value = createSessionId()
|
|
messages.value = []
|
|
streamError.value = ''
|
|
historyMenuOpen.value = false
|
|
clearPendingAttachments()
|
|
persistState()
|
|
scrollToTop()
|
|
}
|
|
|
|
// 从历史列表恢复指定会话,同时把它设为当前本地会话。
|
|
async function loadHistorySession(targetSessionId: string) {
|
|
if (sending.value) return
|
|
|
|
let historySession = historySessions.value.find(item => item.sessionId === targetSessionId)
|
|
if (!historySession) return
|
|
|
|
try {
|
|
stopGeneration()
|
|
if (!historySession.messages.length) {
|
|
historySession = await loadServerHistorySession(targetSessionId)
|
|
}
|
|
sessionId.value = historySession.sessionId
|
|
messages.value = normalizeStoredMessages(historySession.messages)
|
|
streamError.value = ''
|
|
historyMenuOpen.value = false
|
|
clearPendingAttachments()
|
|
persistState({ syncHistory: false })
|
|
scrollToBottom()
|
|
} catch (error: any) {
|
|
streamError.value = error?.message || t('agentAssistant.historyLoadFailed')
|
|
}
|
|
}
|
|
|
|
// 删除指定历史会话;若删除的是当前会话,则切换到新的空会话。
|
|
async function deleteHistorySession(targetSessionId: string) {
|
|
if (sending.value && targetSessionId === sessionId.value) return
|
|
|
|
try {
|
|
await fetchAgentApi(`message/agent/sessions/${encodeURIComponent(targetSessionId)}`, {
|
|
method: 'DELETE',
|
|
})
|
|
} catch (error) {
|
|
// 删除接口失败时仍允许清理本地兜底历史,避免坏记录一直挡在列表里。
|
|
} finally {
|
|
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 closeDrawer() {
|
|
isOpen.value = false
|
|
}
|
|
|
|
// 同步面板打开状态到全局 DOM,供悬浮入口避让面板宽度。
|
|
function syncAgentAssistantOpenState(isOpen: boolean) {
|
|
if (typeof document === 'undefined') return
|
|
|
|
const roots = [document.documentElement, document.body]
|
|
roots.forEach(root => {
|
|
if (isOpen) {
|
|
root.setAttribute('data-agent-assistant-open', 'true')
|
|
return
|
|
}
|
|
|
|
root.removeAttribute('data-agent-assistant-open')
|
|
})
|
|
|
|
if (isOpen) {
|
|
document.documentElement.style.setProperty('--agent-assistant-fab-offset', drawerWidth.value)
|
|
} else {
|
|
document.documentElement.style.removeProperty('--agent-assistant-fab-offset')
|
|
}
|
|
}
|
|
|
|
// 清理面板打开状态的全局 DOM 标记。
|
|
function clearAgentAssistantOpenState() {
|
|
syncAgentAssistantOpenState(false)
|
|
}
|
|
|
|
// 处理全局快捷键。
|
|
function handleGlobalKeydown(event: KeyboardEvent) {
|
|
if (event.key === 'Escape' && isOpen.value) closeDrawer()
|
|
}
|
|
|
|
// 页面从后台恢复时尝试拉取 WebAgent 后台完成后的会话快照。
|
|
function handleVisibilityChange() {
|
|
if (document.visibilityState === 'visible') scheduleStreamRecovery(0)
|
|
}
|
|
|
|
// 处理输入框回车发送。
|
|
function handleInputKeydown(event: KeyboardEvent) {
|
|
if (event.key !== 'Enter' || event.shiftKey) return
|
|
event.preventDefault()
|
|
sendMessage()
|
|
}
|
|
|
|
watch(isOpen, syncAgentAssistantOpenState, { immediate: true })
|
|
watch(drawerWidth, () => {
|
|
if (isOpen.value) syncAgentAssistantOpenState(true)
|
|
})
|
|
|
|
watch(isOpen, open => {
|
|
if (open) scrollToBottom()
|
|
})
|
|
|
|
watch(sending, value => emit('thinking-change', value), { immediate: true })
|
|
|
|
onMounted(() => {
|
|
restoreHistorySessions()
|
|
restoreState()
|
|
loadServerHistorySessions()
|
|
syncInputHeight()
|
|
window.addEventListener('keydown', handleGlobalKeydown)
|
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
})
|
|
|
|
onScopeDispose(clearAgentAssistantOpenState)
|
|
onScopeDispose(clearPendingAttachments)
|
|
onScopeDispose(cancelVoiceRecording)
|
|
onScopeDispose(clearMessageScrollFrame)
|
|
onScopeDispose(clearStreamPersistTimer)
|
|
onScopeDispose(clearStreamRecoveryTimer)
|
|
onScopeDispose(() => {
|
|
if (typeof window === 'undefined') return
|
|
|
|
window.removeEventListener('keydown', handleGlobalKeydown)
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<aside
|
|
v-show="isOpen"
|
|
class="agent-assistant-panel"
|
|
:style="drawerStyle"
|
|
role="dialog"
|
|
:aria-label="t('agentAssistant.title')"
|
|
>
|
|
<div class="agent-assistant-shell">
|
|
<header class="agent-assistant-header">
|
|
<div class="agent-assistant-title">
|
|
<div class="agent-assistant-title__mark">
|
|
<span class="agent-assistant-mini-bot" aria-hidden="true">
|
|
<span class="agent-assistant-mini-bot__antenna" />
|
|
<span class="agent-assistant-mini-bot__head">
|
|
<span class="agent-assistant-mini-bot__face">
|
|
<span class="agent-assistant-mini-bot__eye agent-assistant-mini-bot__eye--left" />
|
|
<span class="agent-assistant-mini-bot__eye agent-assistant-mini-bot__eye--right" />
|
|
</span>
|
|
</span>
|
|
<span class="agent-assistant-mini-bot__body" />
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<div class="text-subtitle-1 font-weight-semibold">{{ t('agentAssistant.title') }}</div>
|
|
<div class="agent-assistant-status">
|
|
{{ sending ? t('agentAssistant.thinking') : t('agentAssistant.ready') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<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>
|
|
</div>
|
|
<VDivider />
|
|
<div
|
|
class="agent-assistant-history-list"
|
|
:class="{ 'agent-assistant-history-list--empty': historyLoading || !hasHistorySessions }"
|
|
>
|
|
<div v-if="historyLoading" class="agent-assistant-history-empty">
|
|
{{ t('agentAssistant.historyLoading') }}
|
|
</div>
|
|
<div v-else-if="!hasHistorySessions" class="agent-assistant-history-empty">
|
|
{{ t('agentAssistant.noHistory') }}
|
|
</div>
|
|
<VInfiniteScroll
|
|
v-else
|
|
mode="intersect"
|
|
side="end"
|
|
:items="historySessions"
|
|
class="agent-assistant-history-infinite"
|
|
@load="handleHistoryInfiniteLoad"
|
|
>
|
|
<VVirtualScroll renderless :items="historySessions" :item-height="HISTORY_ITEM_HEIGHT">
|
|
<template #default="{ item: historySession, itemRef }">
|
|
<button
|
|
:ref="itemRef"
|
|
: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__channel">
|
|
{{ getSessionChannelLabel(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>
|
|
</template>
|
|
</VVirtualScroll>
|
|
<template #empty />
|
|
<template #loading>
|
|
<div class="agent-assistant-history-loading">
|
|
{{ t('agentAssistant.historyLoading') }}
|
|
</div>
|
|
</template>
|
|
</VInfiniteScroll>
|
|
</div>
|
|
</VCard>
|
|
</VMenu>
|
|
<IconBtn
|
|
:disabled="sending"
|
|
:title="t('agentAssistant.newChat')"
|
|
:aria-label="t('agentAssistant.newChat')"
|
|
@click="startNewSession"
|
|
>
|
|
<VIcon icon="mdi-message-plus-outline" />
|
|
</IconBtn>
|
|
<IconBtn :title="t('common.close')" :aria-label="t('common.close')" @click="closeDrawer">
|
|
<VIcon icon="mdi-close" />
|
|
</IconBtn>
|
|
</div>
|
|
</header>
|
|
|
|
<main
|
|
ref="messageListRef"
|
|
class="agent-assistant-messages"
|
|
:class="{ 'agent-assistant-messages--has-content': hasMessages }"
|
|
>
|
|
<div class="agent-assistant-messages__content">
|
|
<div v-if="!hasMessages" class="agent-assistant-empty">
|
|
<div class="agent-assistant-empty__mark">
|
|
<VIcon icon="lucide:sparkles" size="28" />
|
|
</div>
|
|
<div class="agent-assistant-empty__title">{{ t('agentAssistant.emptyTitle') }}</div>
|
|
<div class="agent-assistant-empty__subtitle">{{ t('agentAssistant.emptySubtitle') }}</div>
|
|
</div>
|
|
|
|
<div
|
|
v-for="message in messages"
|
|
:key="message.id"
|
|
class="agent-assistant-message"
|
|
:class="`agent-assistant-message--${message.role}`"
|
|
>
|
|
<div class="agent-assistant-message__meta">
|
|
<VIcon :icon="message.role === 'user' ? 'mdi-account-circle-outline' : 'lucide:bot'" size="16" />
|
|
<span>{{ message.role === 'user' ? currentUserName : t('agentAssistant.assistant') }}</span>
|
|
</div>
|
|
|
|
<div v-if="message.tools.length" class="agent-assistant-tools">
|
|
<div v-for="tool in message.tools" :key="tool.id" class="agent-assistant-tool">
|
|
<VIcon
|
|
:icon="
|
|
tool.status === 'running' && message.status === 'streaming'
|
|
? 'line-md:loading-twotone-loop'
|
|
: 'mdi-check-circle-outline'
|
|
"
|
|
size="16"
|
|
/>
|
|
<span>{{ tool.message }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="message.content"
|
|
class="agent-assistant-message__bubble markdown-body"
|
|
v-html="renderMarkdown(message.content)"
|
|
/>
|
|
|
|
<div v-if="message.choices.length" class="agent-assistant-choices">
|
|
<div v-for="choice in message.choices" :key="choice.id" class="agent-assistant-choice">
|
|
<div class="agent-assistant-choice__bubble">
|
|
<div v-if="choice.title" class="agent-assistant-choice__title">{{ choice.title }}</div>
|
|
<div class="agent-assistant-choice__prompt markdown-body" v-html="renderMarkdown(choice.prompt)" />
|
|
<div v-if="choice.status === 'selected'" class="agent-assistant-choice__selected">
|
|
<VIcon icon="mdi-check-circle-outline" size="16" />
|
|
<span>{{
|
|
t('agentAssistant.choiceSelected', {
|
|
option: choice.selected_label || choice.selected_description,
|
|
})
|
|
}}</span>
|
|
</div>
|
|
<div v-else-if="choice.status === 'expired'" class="agent-assistant-choice__selected is-expired">
|
|
<VIcon icon="mdi-alert-circle-outline" size="16" />
|
|
<span>{{ t('agentAssistant.choiceExpired') }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="agent-assistant-choice__buttons">
|
|
<VBtn
|
|
v-for="button in choice.buttons"
|
|
:key="button.callback_data"
|
|
class="agent-assistant-choice__button"
|
|
size="small"
|
|
variant="flat"
|
|
:disabled="sending || choice.status !== 'pending'"
|
|
@click="handleChoiceClick(message, choice, button)"
|
|
>
|
|
{{ button.label }}
|
|
</VBtn>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="message.attachments.length" class="agent-assistant-attachments">
|
|
<div
|
|
v-for="attachment in message.attachments"
|
|
:key="`${message.id}-${attachment.url}`"
|
|
class="agent-assistant-attachment"
|
|
:class="`agent-assistant-attachment--${attachment.kind}`"
|
|
>
|
|
<img
|
|
v-if="attachment.kind === 'image'"
|
|
class="agent-assistant-attachment__image"
|
|
:src="resolveAttachmentUrl(attachment.url)"
|
|
:alt="getAttachmentName(attachment)"
|
|
loading="lazy"
|
|
/>
|
|
|
|
<template v-else-if="attachment.kind === 'audio'">
|
|
<div class="agent-assistant-attachment__meta">
|
|
<VIcon :icon="getAttachmentIcon(attachment)" size="18" />
|
|
<span>{{ getAttachmentName(attachment) }}</span>
|
|
</div>
|
|
<audio
|
|
class="agent-assistant-attachment__audio"
|
|
controls
|
|
:src="resolveAttachmentUrl(attachment.url)"
|
|
/>
|
|
<VBtn
|
|
class="agent-assistant-surface-btn"
|
|
:href="getAttachmentDownloadUrl(attachment)"
|
|
:download="getAttachmentName(attachment)"
|
|
size="small"
|
|
variant="tonal"
|
|
color="primary"
|
|
prepend-icon="mdi-download"
|
|
>
|
|
{{ t('agentAssistant.download') }}
|
|
</VBtn>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<div class="agent-assistant-attachment__file">
|
|
<VIcon :icon="getAttachmentIcon(attachment)" size="22" />
|
|
<div class="agent-assistant-attachment__file-text">
|
|
<span>{{ getAttachmentName(attachment) }}</span>
|
|
<small>{{ attachment.mime_type || formatAttachmentSize(attachment.size) }}</small>
|
|
</div>
|
|
<VBtn
|
|
class="agent-assistant-surface-btn"
|
|
:href="getAttachmentDownloadUrl(attachment)"
|
|
:download="getAttachmentName(attachment)"
|
|
icon
|
|
variant="text"
|
|
color="primary"
|
|
:aria-label="t('agentAssistant.download')"
|
|
>
|
|
<VIcon icon="mdi-download" />
|
|
</VBtn>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="
|
|
!message.content &&
|
|
!message.attachments.length &&
|
|
!message.choices.length &&
|
|
message.status === 'streaming'
|
|
"
|
|
class="agent-assistant-typing"
|
|
>
|
|
<span />
|
|
<span />
|
|
<span />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<footer class="agent-assistant-composer">
|
|
<VAlert v-if="streamError" type="error" variant="tonal" density="compact" class="mb-3">
|
|
{{ streamError }}
|
|
</VAlert>
|
|
<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">
|
|
<img
|
|
v-if="attachment.kind === 'image' && attachment.preview_url"
|
|
class="agent-assistant-pending-file__preview"
|
|
:src="attachment.preview_url"
|
|
:alt="attachment.name"
|
|
/>
|
|
<VIcon v-else :icon="getAttachmentIcon(attachment)" size="18" />
|
|
<div class="agent-assistant-pending-file__text">
|
|
<span>{{ attachment.name }}</span>
|
|
<small>{{ formatAttachmentSize(attachment.size) || attachment.mime_type }}</small>
|
|
</div>
|
|
<IconBtn
|
|
class="agent-assistant-surface-btn"
|
|
size="x-small"
|
|
:disabled="sending"
|
|
:title="t('agentAssistant.removeAttachment')"
|
|
:aria-label="t('agentAssistant.removeAttachment')"
|
|
@click="removePendingAttachment(attachment.id)"
|
|
>
|
|
<VIcon icon="mdi-close" size="16" />
|
|
</IconBtn>
|
|
</div>
|
|
</PerfectScrollbar>
|
|
<div v-if="showSlashCommandMenu" class="agent-assistant-command-menu">
|
|
<div
|
|
v-for="command in filteredSlashCommands"
|
|
:key="command.command"
|
|
class="agent-assistant-command"
|
|
role="button"
|
|
tabindex="0"
|
|
@click="selectSlashCommand(command)"
|
|
@keydown.enter.prevent="selectSlashCommand(command)"
|
|
>
|
|
<span class="agent-assistant-command__name">{{ command.command }}</span>
|
|
<span class="agent-assistant-command__desc">{{ command.description }}</span>
|
|
</div>
|
|
<div v-if="slashCommandsLoading" class="agent-assistant-command is-loading">
|
|
{{ t('agentAssistant.commandLoading') }}
|
|
</div>
|
|
</div>
|
|
<div class="agent-assistant-input">
|
|
<input
|
|
ref="fileInputRef"
|
|
class="agent-assistant-file-input"
|
|
type="file"
|
|
multiple
|
|
:disabled="sending"
|
|
@change="handleFileSelection"
|
|
/>
|
|
<IconBtn
|
|
class="agent-assistant-attach agent-assistant-surface-btn"
|
|
:disabled="sending || recording"
|
|
:title="t('agentAssistant.attachFile')"
|
|
:aria-label="t('agentAssistant.attachFile')"
|
|
@click="openFilePicker"
|
|
>
|
|
<VIcon icon="mdi-plus" />
|
|
</IconBtn>
|
|
<textarea
|
|
ref="inputRef"
|
|
v-model="inputText"
|
|
class="agent-assistant-textarea"
|
|
rows="1"
|
|
:disabled="sending || recording"
|
|
:placeholder="inputPlaceholder"
|
|
@input="handleInputChange"
|
|
@keydown="handleInputKeydown"
|
|
/>
|
|
<IconBtn
|
|
class="agent-assistant-record agent-assistant-surface-btn"
|
|
:class="{ 'is-recording': recording }"
|
|
:disabled="!recording && !canRecord"
|
|
:title="
|
|
recording
|
|
? t('agentAssistant.stopRecording', { time: recordingTimeText })
|
|
: t('agentAssistant.recordVoice')
|
|
"
|
|
:aria-label="
|
|
recording
|
|
? t('agentAssistant.stopRecording', { time: recordingTimeText })
|
|
: t('agentAssistant.recordVoice')
|
|
"
|
|
@click="toggleVoiceRecording"
|
|
>
|
|
<VIcon :icon="recording ? 'mdi-stop-circle-outline' : 'mdi-microphone-outline'" />
|
|
</IconBtn>
|
|
<IconBtn
|
|
class="agent-assistant-send agent-assistant-surface-btn"
|
|
:disabled="!sending && !canSend"
|
|
:title="sending ? t('agentAssistant.stop') : t('common.send')"
|
|
:aria-label="sending ? t('agentAssistant.stop') : t('common.send')"
|
|
@click="sending ? stopGeneration() : sendMessage()"
|
|
>
|
|
<VIcon :icon="sending ? 'mdi-stop' : 'mdi-send'" />
|
|
</IconBtn>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</aside>
|
|
</template>
|
|
|
|
<style lang="scss">
|
|
.agent-assistant-history-overlay {
|
|
z-index: 2103 !important;
|
|
}
|
|
</style>
|
|
|
|
<style lang="scss" scoped>
|
|
/* stylelint-disable selector-pseudo-class-no-unknown */
|
|
/* stylelint-disable no-descending-specificity */
|
|
|
|
.agent-assistant-panel {
|
|
position: fixed;
|
|
z-index: 2101;
|
|
overflow: hidden;
|
|
background: rgb(var(--v-theme-surface));
|
|
|
|
/* 背景层必须覆盖完整视口,不能跟随 iOS 键盘后的 visual viewport 缩短。 */
|
|
block-size: 100vh !important;
|
|
border-inline-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
box-shadow: var(--app-surface-shadow);
|
|
inline-size: var(--agent-assistant-panel-width, 30rem);
|
|
inset-block-start: 0;
|
|
inset-inline-end: 0;
|
|
max-block-size: none !important;
|
|
min-block-size: 100vh !important;
|
|
overscroll-behavior: contain;
|
|
}
|
|
|
|
@supports (block-size: 100lvh) {
|
|
.agent-assistant-panel {
|
|
block-size: 100lvh !important;
|
|
min-block-size: 100lvh !important;
|
|
}
|
|
}
|
|
|
|
.agent-assistant-shell {
|
|
position: relative;
|
|
display: grid;
|
|
block-size: 100%;
|
|
grid-template-rows: auto minmax(0, 1fr);
|
|
min-block-size: 0;
|
|
|
|
--agent-assistant-assistant-bg: rgba(var(--v-theme-surface), 0.92);
|
|
--agent-assistant-assistant-border: rgba(var(--v-theme-on-surface), 0.08);
|
|
--agent-assistant-panel-bg: rgba(var(--v-theme-surface), 0.94);
|
|
--agent-assistant-panel-blur: 10px;
|
|
}
|
|
|
|
.agent-assistant-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
padding-block: calc(env(safe-area-inset-top, 0px) + 0.8rem) 0.8rem;
|
|
padding-inline: 1rem;
|
|
}
|
|
|
|
.agent-assistant-title {
|
|
display: flex;
|
|
align-items: center;
|
|
column-gap: 0.75rem;
|
|
min-inline-size: 0;
|
|
}
|
|
|
|
.agent-assistant-title__mark {
|
|
display: inline-flex;
|
|
flex: 0 0 auto;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: var(--app-control-radius);
|
|
|
|
--agent-assistant-mini-robot-outline: #5b00c5;
|
|
--agent-assistant-mini-robot-outline-soft: #7432df;
|
|
--agent-assistant-mini-robot-shell-start: #d3bbff;
|
|
--agent-assistant-mini-robot-shell-mid: #a576ff;
|
|
--agent-assistant-mini-robot-shell-end: #8d51f9;
|
|
--agent-assistant-mini-robot-face-start: #24124e;
|
|
--agent-assistant-mini-robot-face-end: #100525;
|
|
--agent-assistant-mini-robot-eye: #f1dcff;
|
|
|
|
background: rgba(var(--v-theme-primary), 0.12);
|
|
block-size: 2.5rem;
|
|
color: rgb(var(--v-theme-primary));
|
|
inline-size: 2.5rem;
|
|
}
|
|
|
|
.agent-assistant-mini-bot,
|
|
.agent-assistant-mini-bot span {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.agent-assistant-mini-bot {
|
|
position: relative;
|
|
display: block;
|
|
block-size: 1.85rem;
|
|
inline-size: 1.85rem;
|
|
}
|
|
|
|
.agent-assistant-mini-bot__antenna {
|
|
position: absolute;
|
|
display: block;
|
|
border-radius: 999px;
|
|
background: var(--agent-assistant-mini-robot-outline);
|
|
block-size: 0.42rem;
|
|
inline-size: 0.12rem;
|
|
inset-block-start: 0;
|
|
inset-inline-start: 1.18rem;
|
|
transform: rotate(20deg);
|
|
transform-origin: bottom center;
|
|
}
|
|
|
|
.agent-assistant-mini-bot__antenna::after {
|
|
position: absolute;
|
|
border: 1.5px solid var(--agent-assistant-mini-robot-outline);
|
|
border-radius: 999px;
|
|
background: var(--agent-assistant-mini-robot-shell-start);
|
|
block-size: 0.28rem;
|
|
content: '';
|
|
inline-size: 0.28rem;
|
|
inset-block-start: -0.24rem;
|
|
inset-inline-start: -0.09rem;
|
|
}
|
|
|
|
.agent-assistant-mini-bot__head {
|
|
position: absolute;
|
|
display: block;
|
|
border: 1.5px solid var(--agent-assistant-mini-robot-outline);
|
|
border-radius: 8px;
|
|
background: linear-gradient(
|
|
145deg,
|
|
var(--agent-assistant-mini-robot-shell-start) 0%,
|
|
var(--agent-assistant-mini-robot-shell-end) 100%
|
|
);
|
|
block-size: 1.04rem;
|
|
box-shadow:
|
|
inset 0 -0.12rem 0 rgba(54, 0, 126, 22%),
|
|
inset 0.08rem 0.08rem 0 rgba(255, 255, 255, 22%);
|
|
inline-size: 1.45rem;
|
|
inset-block-start: 0.42rem;
|
|
inset-inline-start: 0.2rem;
|
|
}
|
|
|
|
.agent-assistant-mini-bot__face {
|
|
position: absolute;
|
|
display: block;
|
|
border: 1.5px solid var(--agent-assistant-mini-robot-outline-soft);
|
|
border-radius: 6px;
|
|
background: linear-gradient(
|
|
180deg,
|
|
var(--agent-assistant-mini-robot-face-start) 0%,
|
|
var(--agent-assistant-mini-robot-face-end) 100%
|
|
);
|
|
block-size: 0.62rem;
|
|
inline-size: 1rem;
|
|
inset-block-start: 0.18rem;
|
|
inset-inline-start: 0.16rem;
|
|
}
|
|
|
|
.agent-assistant-mini-bot__eye {
|
|
position: absolute;
|
|
display: block;
|
|
border-radius: 0 0 999px 999px;
|
|
animation: agent-fab-blink 4.8s ease-in-out infinite;
|
|
block-size: 0.24rem;
|
|
border-block-end: 0.1rem solid var(--agent-assistant-mini-robot-eye);
|
|
inline-size: 0.22rem;
|
|
inset-block-start: 0.16rem;
|
|
}
|
|
|
|
.agent-assistant-mini-bot__eye--left {
|
|
inset-inline-start: 0.22rem;
|
|
}
|
|
|
|
.agent-assistant-mini-bot__eye--right {
|
|
inset-inline-end: 0.22rem;
|
|
}
|
|
|
|
.agent-assistant-mini-bot__body {
|
|
position: absolute;
|
|
display: block;
|
|
border: 1.5px solid var(--agent-assistant-mini-robot-outline);
|
|
border-radius: 0.4rem;
|
|
background: linear-gradient(
|
|
145deg,
|
|
var(--agent-assistant-mini-robot-shell-mid) 0%,
|
|
var(--agent-assistant-mini-robot-shell-end) 82%
|
|
);
|
|
block-size: 0.54rem;
|
|
inline-size: 0.98rem;
|
|
inset-block-start: 1.3rem;
|
|
inset-inline-start: 0.44rem;
|
|
}
|
|
|
|
.agent-assistant-status {
|
|
color: rgba(var(--v-theme-on-surface), 0.62);
|
|
font-size: 0.78rem;
|
|
}
|
|
|
|
.agent-assistant-history-menu {
|
|
overflow: hidden;
|
|
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
border-radius: var(--app-overlay-radius);
|
|
inline-size: min(22rem, calc(100vw - 2rem));
|
|
}
|
|
|
|
.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 {
|
|
block-size: min(26rem, calc(100vh - 7rem));
|
|
max-block-size: min(26rem, calc(100vh - 7rem));
|
|
overflow-y: auto;
|
|
overscroll-behavior: contain;
|
|
padding-block: 0.35rem;
|
|
}
|
|
|
|
.agent-assistant-history-list--empty {
|
|
block-size: auto;
|
|
max-block-size: none;
|
|
}
|
|
|
|
@supports (block-size: 100lvh) {
|
|
.agent-assistant-history-list {
|
|
block-size: min(26rem, calc(100lvh - 7rem));
|
|
max-block-size: min(26rem, calc(100lvh - 7rem));
|
|
}
|
|
|
|
.agent-assistant-history-list--empty {
|
|
block-size: auto;
|
|
max-block-size: none;
|
|
}
|
|
}
|
|
|
|
.agent-assistant-history-infinite {
|
|
gap: 0.25rem;
|
|
min-block-size: 100%;
|
|
}
|
|
|
|
.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-loading {
|
|
color: rgba(var(--v-theme-on-surface), 0.48);
|
|
font-size: 0.75rem;
|
|
padding-block: 0.75rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.agent-assistant-history-item {
|
|
display: grid;
|
|
align-items: center;
|
|
border: 0;
|
|
border-radius: var(--app-control-radius);
|
|
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;
|
|
min-block-size: 4.75rem;
|
|
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__channel,
|
|
.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__channel {
|
|
display: inline-flex;
|
|
border-radius: 999px;
|
|
background: rgba(var(--v-theme-primary), 0.1);
|
|
color: rgba(var(--v-theme-primary), 0.9);
|
|
font-size: 0.68rem;
|
|
font-weight: 700;
|
|
inline-size: fit-content;
|
|
max-inline-size: 100%;
|
|
padding-block: 0.1rem;
|
|
padding-inline: 0.4rem;
|
|
}
|
|
|
|
.agent-assistant-history-item__time {
|
|
color: rgba(var(--v-theme-on-surface), 0.48);
|
|
font-size: 0.7rem;
|
|
}
|
|
|
|
.agent-assistant-messages {
|
|
box-sizing: border-box;
|
|
block-size: 100%;
|
|
min-block-size: 0;
|
|
-webkit-overflow-scrolling: touch;
|
|
-ms-overflow-style: none;
|
|
overflow-y: auto;
|
|
overscroll-behavior: contain;
|
|
padding-block: 1rem;
|
|
padding-inline: 1rem;
|
|
scroll-behavior: auto;
|
|
scrollbar-width: none;
|
|
|
|
&::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
.agent-assistant-messages__content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-block-size: 100%;
|
|
}
|
|
|
|
/* 只有消息态预留输入框空间,避免 iOS 空态被 padding 撑出不可滚动的滚动条。 */
|
|
.agent-assistant-messages--has-content {
|
|
padding-block-end: calc(env(safe-area-inset-bottom, 0px) + 8.75rem);
|
|
}
|
|
|
|
.agent-assistant-empty {
|
|
display: flex;
|
|
box-sizing: border-box;
|
|
flex: 1 1 auto;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: rgba(var(--v-theme-on-surface), 0.7);
|
|
min-block-size: 0;
|
|
padding-block: 2rem 1.25rem;
|
|
padding-inline: 0.25rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.agent-assistant-empty__mark {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: rgb(var(--v-theme-primary));
|
|
margin-block-end: 1rem;
|
|
}
|
|
|
|
.agent-assistant-empty__title {
|
|
color: rgba(var(--v-theme-on-surface), 0.9);
|
|
font-size: 1.1rem;
|
|
font-weight: 700;
|
|
line-height: 1.35;
|
|
}
|
|
|
|
.agent-assistant-empty__subtitle {
|
|
color: rgba(var(--v-theme-on-surface), 0.58);
|
|
font-size: 0.86rem;
|
|
line-height: 1.55;
|
|
margin-block-start: 0.4rem;
|
|
max-inline-size: 21rem;
|
|
}
|
|
|
|
.agent-assistant-message {
|
|
display: flex;
|
|
flex: 0 0 auto;
|
|
flex-direction: column;
|
|
margin-block-end: 1rem;
|
|
}
|
|
|
|
.agent-assistant-message--user {
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.agent-assistant-message--assistant {
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.agent-assistant-message__meta {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
color: rgba(var(--v-theme-on-surface), 0.56);
|
|
column-gap: 0.3rem;
|
|
font-size: 0.75rem;
|
|
margin-block-end: 0.35rem;
|
|
}
|
|
|
|
.agent-assistant-message__bubble {
|
|
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
border-radius: var(--app-surface-radius);
|
|
font-size: 0.92rem;
|
|
line-height: 1.55;
|
|
max-inline-size: min(100%, 34rem);
|
|
min-inline-size: 0;
|
|
padding-block: 0.75rem;
|
|
padding-inline: 0.85rem;
|
|
}
|
|
|
|
.agent-assistant-message--user .agent-assistant-message__bubble {
|
|
border-color: rgba(var(--v-theme-primary), 0.18);
|
|
background: rgba(var(--v-theme-primary), 0.12);
|
|
}
|
|
|
|
.agent-assistant-message--assistant .agent-assistant-message__bubble {
|
|
border-color: var(--agent-assistant-assistant-border);
|
|
backdrop-filter: blur(var(--agent-assistant-panel-blur));
|
|
background: var(--agent-assistant-assistant-bg);
|
|
}
|
|
|
|
.agent-assistant-tools {
|
|
display: grid;
|
|
gap: 0.4rem;
|
|
inline-size: min(100%, 34rem);
|
|
margin-block-end: 0.5rem;
|
|
}
|
|
|
|
.agent-assistant-tool {
|
|
display: flex;
|
|
align-items: center;
|
|
border: 1px solid rgba(25, 178, 160, 28%);
|
|
border-radius: var(--app-control-radius);
|
|
background: rgba(25, 178, 160, 8%);
|
|
color: rgba(var(--v-theme-on-surface), 0.78);
|
|
column-gap: 0.45rem;
|
|
font-size: 0.8rem;
|
|
line-height: 1.35;
|
|
padding-block: 0.45rem;
|
|
padding-inline: 0.6rem;
|
|
}
|
|
|
|
.agent-assistant-choices {
|
|
display: grid;
|
|
gap: 0.35rem;
|
|
inline-size: min(100%, 34rem);
|
|
margin-block-start: 0.5rem;
|
|
}
|
|
|
|
.agent-assistant-choice {
|
|
display: grid;
|
|
gap: 0.35rem;
|
|
}
|
|
|
|
.agent-assistant-choice__bubble {
|
|
display: grid;
|
|
border: 1px solid var(--agent-assistant-assistant-border);
|
|
border-radius: var(--app-surface-radius);
|
|
backdrop-filter: blur(var(--agent-assistant-panel-blur));
|
|
background: var(--agent-assistant-assistant-bg);
|
|
gap: 0.65rem;
|
|
padding-block: 0.75rem;
|
|
padding-inline: 0.8rem;
|
|
}
|
|
|
|
.agent-assistant-choice__title {
|
|
color: rgba(var(--v-theme-on-surface), 0.82);
|
|
font-size: 0.78rem;
|
|
font-weight: 700;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.agent-assistant-choice__prompt {
|
|
color: rgba(var(--v-theme-on-surface), 0.9);
|
|
font-size: 0.92rem;
|
|
line-height: 1.45;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.agent-assistant-choice__selected {
|
|
display: inline-flex;
|
|
align-items: flex-start;
|
|
border: 1px solid rgba(25, 178, 160, 28%);
|
|
border-radius: var(--app-control-radius);
|
|
background: rgba(25, 178, 160, 10%);
|
|
color: rgba(var(--v-theme-on-surface), 0.78);
|
|
column-gap: 0.4rem;
|
|
font-size: 0.8rem;
|
|
inline-size: fit-content;
|
|
max-inline-size: 100%;
|
|
min-inline-size: 0;
|
|
padding-block: 0.35rem;
|
|
padding-inline: 0.5rem;
|
|
white-space: normal;
|
|
}
|
|
|
|
.agent-assistant-choice__selected .v-icon {
|
|
flex: 0 0 auto;
|
|
margin-block-start: 0.08rem;
|
|
}
|
|
|
|
.agent-assistant-choice__selected span {
|
|
line-height: 1.4;
|
|
min-inline-size: 0;
|
|
overflow-wrap: anywhere;
|
|
white-space: normal;
|
|
}
|
|
|
|
.agent-assistant-choice__selected.is-expired {
|
|
border-color: rgba(var(--v-theme-warning), 0.28);
|
|
background: rgba(var(--v-theme-warning), 0.1);
|
|
}
|
|
|
|
.agent-assistant-choice__buttons {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.35rem;
|
|
}
|
|
|
|
.agent-assistant-choice__button {
|
|
flex: 1 1 max-content;
|
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08) !important;
|
|
border-radius: var(--app-control-radius) !important;
|
|
backdrop-filter: blur(var(--agent-assistant-panel-blur));
|
|
background: rgba(var(--v-theme-surface), 0.9) !important;
|
|
box-shadow: none !important;
|
|
color: rgb(var(--v-theme-primary)) !important;
|
|
max-inline-size: 100%;
|
|
min-block-size: 2.7rem;
|
|
min-inline-size: max-content;
|
|
}
|
|
|
|
.agent-assistant-choice__button:disabled {
|
|
color: rgba(var(--v-theme-on-surface), 0.46) !important;
|
|
}
|
|
|
|
.agent-assistant-choice__button :deep(.v-btn__content) {
|
|
overflow: hidden;
|
|
min-inline-size: 0;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.agent-assistant-typing {
|
|
display: inline-flex;
|
|
border: 1px solid var(--agent-assistant-assistant-border);
|
|
border-radius: 999px;
|
|
backdrop-filter: blur(var(--agent-assistant-panel-blur));
|
|
background: var(--agent-assistant-assistant-bg);
|
|
gap: 0.28rem;
|
|
padding-block: 0.7rem;
|
|
padding-inline: 0.8rem;
|
|
}
|
|
|
|
.agent-assistant-typing span {
|
|
border-radius: 999px;
|
|
animation: agent-typing 1s infinite ease-in-out;
|
|
background: rgba(var(--v-theme-on-surface), 0.54);
|
|
block-size: 0.35rem;
|
|
inline-size: 0.35rem;
|
|
}
|
|
|
|
.agent-assistant-typing span:nth-child(2) {
|
|
animation-delay: 0.15s;
|
|
}
|
|
|
|
.agent-assistant-typing span:nth-child(3) {
|
|
animation-delay: 0.3s;
|
|
}
|
|
|
|
.agent-assistant-composer {
|
|
position: absolute;
|
|
z-index: 2;
|
|
inset-block-end: calc(env(safe-area-inset-bottom, 0px) + 0.85rem);
|
|
inset-inline: 1rem;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.agent-assistant-composer > * {
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.agent-assistant-pending-files {
|
|
display: grid;
|
|
padding: 0.55rem;
|
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
|
border-radius: var(--app-surface-radius);
|
|
backdrop-filter: blur(var(--agent-assistant-panel-blur));
|
|
background: var(--agent-assistant-panel-bg);
|
|
box-shadow: var(--app-surface-shadow);
|
|
gap: 0.45rem;
|
|
margin-block-end: 0.55rem;
|
|
max-block-size: 8rem;
|
|
overflow-y: auto;
|
|
overscroll-behavior: contain;
|
|
scrollbar-width: thin;
|
|
}
|
|
|
|
.agent-assistant-pending-file {
|
|
display: grid;
|
|
align-items: center;
|
|
border-radius: var(--app-control-radius);
|
|
column-gap: 0.55rem;
|
|
grid-template-columns: auto 1fr auto;
|
|
min-inline-size: 0;
|
|
padding-block: 0.25rem;
|
|
padding-inline: 0.35rem 0.15rem;
|
|
}
|
|
|
|
.agent-assistant-pending-file__preview {
|
|
border-radius: var(--app-control-radius);
|
|
block-size: 2.1rem;
|
|
inline-size: 2.1rem;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.agent-assistant-pending-file__text {
|
|
display: grid;
|
|
min-inline-size: 0;
|
|
}
|
|
|
|
.agent-assistant-pending-file__text span,
|
|
.agent-assistant-pending-file__text small {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.agent-assistant-pending-file__text small {
|
|
color: rgba(var(--v-theme-on-surface), 0.58);
|
|
font-size: 0.72rem;
|
|
}
|
|
|
|
.agent-assistant-command-menu {
|
|
display: grid;
|
|
padding: 0.35rem;
|
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
|
border-radius: var(--app-overlay-radius);
|
|
backdrop-filter: blur(var(--agent-assistant-panel-blur));
|
|
background: var(--agent-assistant-panel-bg);
|
|
box-shadow: var(--app-surface-shadow);
|
|
gap: 0.25rem;
|
|
margin-block-end: 0.55rem;
|
|
max-block-size: 15rem;
|
|
overflow-y: auto;
|
|
pointer-events: auto;
|
|
scrollbar-width: thin;
|
|
}
|
|
|
|
.agent-assistant-command {
|
|
display: grid;
|
|
align-items: center;
|
|
border-radius: var(--app-control-radius);
|
|
column-gap: 0.7rem;
|
|
cursor: pointer;
|
|
grid-template-columns: minmax(6.8rem, auto) minmax(0, 1fr);
|
|
min-block-size: 2.45rem;
|
|
padding-block: 0.35rem;
|
|
padding-inline: 0.55rem;
|
|
}
|
|
|
|
.agent-assistant-command:hover,
|
|
.agent-assistant-command:focus-visible {
|
|
background: rgba(var(--v-theme-primary), 0.1);
|
|
outline: none;
|
|
}
|
|
|
|
.agent-assistant-command.is-loading {
|
|
color: rgba(var(--v-theme-on-surface), 0.58);
|
|
cursor: default;
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.agent-assistant-command__name {
|
|
overflow: hidden;
|
|
color: rgb(var(--v-theme-primary));
|
|
font-family: var(--v-font-family-monospace, monospace);
|
|
font-size: 0.86rem;
|
|
font-weight: 700;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.agent-assistant-command__desc {
|
|
overflow: hidden;
|
|
color: rgba(var(--v-theme-on-surface), 0.68);
|
|
font-size: 0.82rem;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.agent-assistant-input {
|
|
display: grid;
|
|
align-items: center;
|
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
|
border-radius: var(--app-field-radius);
|
|
backdrop-filter: blur(var(--agent-assistant-panel-blur));
|
|
background: var(--agent-assistant-panel-bg);
|
|
box-shadow: var(--app-surface-shadow);
|
|
column-gap: 0.25rem;
|
|
grid-template-columns: auto 1fr auto auto;
|
|
min-block-size: 3.25rem;
|
|
padding-inline: 0.35rem;
|
|
pointer-events: auto;
|
|
transition:
|
|
border-radius 0.2s ease,
|
|
box-shadow 0.2s ease;
|
|
}
|
|
|
|
.agent-assistant-file-input {
|
|
display: none;
|
|
}
|
|
|
|
// 面板内文件下载、附件和发送按钮使用全局阴影 token,跟随主题阴影设置即时变化。
|
|
.agent-assistant-surface-btn {
|
|
transition: box-shadow 0.2s ease;
|
|
}
|
|
|
|
.agent-assistant-attach {
|
|
align-self: center;
|
|
}
|
|
|
|
.agent-assistant-record {
|
|
align-self: center;
|
|
}
|
|
|
|
.agent-assistant-record.is-recording {
|
|
color: rgb(var(--v-theme-error));
|
|
}
|
|
|
|
.agent-assistant-textarea {
|
|
box-sizing: border-box;
|
|
align-self: center;
|
|
padding: 0;
|
|
border: 0;
|
|
background: transparent;
|
|
block-size: 1.5rem;
|
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
|
font: inherit;
|
|
inline-size: 100%;
|
|
line-height: 1.5rem;
|
|
max-block-size: 7.5rem;
|
|
min-block-size: 1.5rem;
|
|
outline: none;
|
|
overflow-y: auto;
|
|
resize: none;
|
|
}
|
|
|
|
.agent-assistant-textarea::placeholder {
|
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
|
opacity: 1;
|
|
}
|
|
|
|
.agent-assistant-send {
|
|
align-self: center;
|
|
}
|
|
|
|
.agent-assistant-attachments {
|
|
display: grid;
|
|
gap: 0.55rem;
|
|
inline-size: min(100%, 34rem);
|
|
margin-block-start: 0.5rem;
|
|
}
|
|
|
|
.agent-assistant-attachment {
|
|
overflow: hidden;
|
|
border: 1px solid var(--agent-assistant-assistant-border);
|
|
border-radius: var(--app-surface-radius);
|
|
backdrop-filter: blur(var(--agent-assistant-panel-blur));
|
|
background: var(--agent-assistant-assistant-bg);
|
|
}
|
|
|
|
.agent-assistant-attachment__image {
|
|
display: block;
|
|
block-size: auto;
|
|
inline-size: 100%;
|
|
max-block-size: 18rem;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.agent-assistant-attachment--audio {
|
|
display: grid;
|
|
padding: 0.75rem;
|
|
gap: 0.65rem;
|
|
}
|
|
|
|
.agent-assistant-attachment__meta {
|
|
display: flex;
|
|
align-items: center;
|
|
color: rgba(var(--v-theme-on-surface), 0.76);
|
|
column-gap: 0.45rem;
|
|
font-size: 0.82rem;
|
|
min-inline-size: 0;
|
|
}
|
|
|
|
.agent-assistant-attachment__meta span {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.agent-assistant-attachment__audio {
|
|
inline-size: 100%;
|
|
}
|
|
|
|
.agent-assistant-attachment__file {
|
|
display: grid;
|
|
align-items: center;
|
|
column-gap: 0.65rem;
|
|
grid-template-columns: auto 1fr auto;
|
|
min-inline-size: 0;
|
|
padding-block: 0.65rem;
|
|
padding-inline: 0.75rem 0.45rem;
|
|
}
|
|
|
|
.agent-assistant-attachment__file-text {
|
|
display: grid;
|
|
min-inline-size: 0;
|
|
}
|
|
|
|
.agent-assistant-attachment__file-text span,
|
|
.agent-assistant-attachment__file-text small {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.agent-assistant-attachment__file-text small {
|
|
color: rgba(var(--v-theme-on-surface), 0.58);
|
|
}
|
|
|
|
.markdown-body {
|
|
overflow-wrap: anywhere;
|
|
word-break: break-word;
|
|
|
|
:deep(h1),
|
|
:deep(h2),
|
|
:deep(h3) {
|
|
font-weight: 600;
|
|
line-height: 1.3;
|
|
margin-block: 0.5rem;
|
|
}
|
|
|
|
:deep(h1) {
|
|
font-size: 1.5rem;
|
|
}
|
|
|
|
:deep(h2) {
|
|
font-size: 1.25rem;
|
|
}
|
|
|
|
:deep(h3) {
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
:deep(p) {
|
|
margin-block-end: 0.5rem;
|
|
}
|
|
|
|
:deep(p:last-child) {
|
|
margin-block-end: 0;
|
|
}
|
|
|
|
:deep(a) {
|
|
color: rgb(var(--v-theme-primary));
|
|
text-decoration: underline;
|
|
}
|
|
|
|
:deep(code) {
|
|
border-radius: var(--app-control-radius);
|
|
background: rgba(var(--v-theme-on-surface), 0.08);
|
|
font-family: monospace;
|
|
padding-block: 0.1rem;
|
|
padding-inline: 0.3rem;
|
|
}
|
|
|
|
:deep(pre) {
|
|
overflow: auto;
|
|
padding: 0.75rem;
|
|
border-radius: var(--app-surface-radius);
|
|
background: rgba(var(--v-theme-on-surface), 0.08);
|
|
margin-block: 0.5rem;
|
|
max-inline-size: 100%;
|
|
}
|
|
|
|
:deep(pre code) {
|
|
padding: 0;
|
|
background: transparent;
|
|
}
|
|
|
|
:deep(ul),
|
|
:deep(ol) {
|
|
margin-block-end: 0.5rem;
|
|
padding-inline-start: 1.5rem;
|
|
}
|
|
|
|
:deep(ul) {
|
|
list-style-type: disc;
|
|
}
|
|
|
|
:deep(ol) {
|
|
list-style-type: decimal;
|
|
}
|
|
|
|
:deep(li) {
|
|
display: list-item;
|
|
margin-block: 0.25rem;
|
|
}
|
|
|
|
:deep(blockquote) {
|
|
border-inline-start: 4px solid rgba(var(--v-border-color), 0.2);
|
|
color: rgba(var(--v-theme-on-surface), 0.74);
|
|
font-style: italic;
|
|
margin-block: 0.5rem;
|
|
padding-inline-start: 1rem;
|
|
}
|
|
|
|
:deep(table) {
|
|
display: block;
|
|
border-collapse: collapse;
|
|
inline-size: max-content;
|
|
margin-block: 0.5rem;
|
|
max-inline-size: 100%;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
:deep(th),
|
|
:deep(td) {
|
|
border: 1px solid rgba(var(--v-border-color), 0.16);
|
|
padding-block: 0.4rem;
|
|
padding-inline: 0.65rem;
|
|
text-align: start;
|
|
}
|
|
|
|
:deep(th) {
|
|
background: rgba(var(--v-border-color), 0.08);
|
|
font-weight: 600;
|
|
}
|
|
|
|
:deep(hr) {
|
|
border: none;
|
|
border-block-start: 1px solid rgba(var(--v-border-color), 0.24);
|
|
margin-block: 1rem;
|
|
}
|
|
|
|
:deep(img) {
|
|
block-size: auto;
|
|
max-inline-size: 100%;
|
|
}
|
|
}
|
|
|
|
@keyframes agent-typing {
|
|
0%,
|
|
80%,
|
|
100% {
|
|
opacity: 0.35;
|
|
transform: translateY(0);
|
|
}
|
|
|
|
40% {
|
|
opacity: 1;
|
|
transform: translateY(-0.18rem);
|
|
}
|
|
}
|
|
|
|
@keyframes agent-fab-blink {
|
|
0%,
|
|
4%,
|
|
8%,
|
|
100% {
|
|
opacity: 1;
|
|
scale: 1 1;
|
|
}
|
|
|
|
6% {
|
|
opacity: 0.45;
|
|
scale: 1 0.15;
|
|
}
|
|
}
|
|
|
|
@media (width <= 960px) {
|
|
.agent-assistant-panel {
|
|
inline-size: 100vw !important;
|
|
}
|
|
}
|
|
|
|
@media (width <= 600px) {
|
|
.agent-assistant-empty {
|
|
justify-content: flex-start;
|
|
padding-block-start: 2.75rem;
|
|
}
|
|
|
|
.agent-assistant-messages {
|
|
padding-block: 0.85rem;
|
|
padding-inline: 0.85rem;
|
|
}
|
|
|
|
.agent-assistant-messages--has-content {
|
|
padding-block-end: calc(env(safe-area-inset-bottom, 0px) + 11.8rem);
|
|
}
|
|
|
|
.agent-assistant-composer {
|
|
inset-block-end: calc(env(safe-area-inset-bottom, 0px) + 0.7rem);
|
|
inset-inline: 0.85rem;
|
|
}
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.agent-assistant-mini-bot__eye,
|
|
.agent-assistant-typing span {
|
|
animation-duration: 0.01ms !important;
|
|
animation-iteration-count: 1 !important;
|
|
scroll-behavior: auto !important;
|
|
transition-duration: 0.01ms !important;
|
|
}
|
|
}
|
|
</style>
|