mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-15 10:34:35 +08:00
3788 lines
120 KiB
Vue
3788 lines
120 KiB
Vue
<script setup lang="ts">
|
||
import { useDisplay } from 'vuetify'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useAuthStore, useUserStore } from '@/stores'
|
||
import { getCurrentLocale } from '@/plugins/i18n'
|
||
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
|
||
import AgentMarkdownContent from './AgentMarkdownContent.vue'
|
||
|
||
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 AgentMessageTextSegment {
|
||
type: 'text'
|
||
content: string
|
||
}
|
||
|
||
interface AgentMessageToolSegment {
|
||
type: 'tool'
|
||
toolIndex: number
|
||
}
|
||
|
||
type AgentMessageSegment = AgentMessageTextSegment | AgentMessageToolSegment
|
||
|
||
type AgentRenderableMessageSegment =
|
||
(AgentMessageTextSegment & { key: string }) | { type: 'tool'; key: string; tool: AgentToolCall }
|
||
|
||
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[]
|
||
segments: AgentMessageSegment[]
|
||
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_i18n?: 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 AgentPendingStreamRecovery {
|
||
sessionId: string
|
||
startedAt: number
|
||
attempts: number
|
||
}
|
||
|
||
interface AgentStreamReadResult {
|
||
receivedTerminalEvent: boolean
|
||
}
|
||
|
||
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
|
||
/** 是否允许面板的装饰性动效,不影响消息流、thinking 或输入反馈。 */
|
||
motionActive?: boolean
|
||
}>(),
|
||
{
|
||
modelValue: false,
|
||
motionActive: true,
|
||
},
|
||
)
|
||
|
||
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 isComposing = 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 fullscreen = ref(false)
|
||
const slashCommands = ref<AgentSlashCommand[]>([])
|
||
const slashCommandsLoading = ref(false)
|
||
const slashCommandsLoaded = ref(false)
|
||
const pendingStreamRecovery = ref<AgentPendingStreamRecovery | null>(null)
|
||
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 streamPersistLastRunAt = 0
|
||
let messageScrollerShouldFollow = true
|
||
let streamDeltaFrame: number | null = null
|
||
let pendingStreamDelta = ''
|
||
let pendingStreamDeltaMessage: AgentChatMessage | null = null
|
||
let userAbortRequested = false
|
||
let streamRecoveryAbortRequested = false
|
||
let streamRecoveryTimer: number | null = null
|
||
let activeStreamStartedAt = 0
|
||
|
||
// 汇总实时请求与后台恢复状态,保证恢复期间仍展示处理中并锁定会话操作。
|
||
const isBusy = computed(() => sending.value || Boolean(pendingStreamRecovery.value))
|
||
const canSend = computed(
|
||
() => (inputText.value.trim().length > 0 || pendingAttachments.value.length > 0) && !isBusy.value && !recording.value,
|
||
)
|
||
const canRecord = computed(() => !isBusy.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('/') &&
|
||
!isBusy.value &&
|
||
!recording.value &&
|
||
(filteredSlashCommands.value.length > 0 || slashCommandsLoading.value),
|
||
)
|
||
// 根据智能体处理状态切换输入框背景提示。
|
||
const inputPlaceholder = computed(() =>
|
||
isBusy.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 || fullscreen.value ? '100vw' : '30rem'))
|
||
// 仅桌面宽屏展示全屏开关,窄屏已默认占满视口。
|
||
const canToggleFullscreen = computed(() => !display.mdAndDown.value)
|
||
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,
|
||
zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.panel,
|
||
}))
|
||
|
||
// 创建前端展示用的临时 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 normalizeMessageSegments(value: unknown, content: string, tools: AgentToolCall[]) {
|
||
const normalizedSegments: AgentMessageSegment[] = []
|
||
|
||
if (Array.isArray(value)) {
|
||
value.forEach(rawSegment => {
|
||
if (!rawSegment || typeof rawSegment !== 'object' || Array.isArray(rawSegment)) return
|
||
|
||
const segment = rawSegment as Record<string, unknown>
|
||
if (segment.type === 'text' && typeof segment.content === 'string' && segment.content.trim()) {
|
||
normalizedSegments.push({ type: 'text', content: segment.content })
|
||
return
|
||
}
|
||
|
||
const toolIndex = Number(segment.toolIndex ?? segment.tool_index)
|
||
if (segment.type === 'tool' && Number.isInteger(toolIndex) && toolIndex >= 0 && toolIndex < tools.length) {
|
||
normalizedSegments.push({ type: 'tool', toolIndex })
|
||
}
|
||
})
|
||
}
|
||
|
||
if (normalizedSegments.length) return normalizedSegments
|
||
|
||
tools.forEach((_tool, toolIndex) => normalizedSegments.push({ type: 'tool', toolIndex }))
|
||
if (content.trim()) normalizedSegments.push({ type: 'text', content })
|
||
return normalizedSegments
|
||
}
|
||
|
||
// 规范化历史消息,补齐附件、工具、有序片段和选择项等可选数组。
|
||
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'
|
||
const content = typeof message.content === 'string' ? message.content : stringifyChoiceField(message.content)
|
||
const tools = Array.isArray(message.tools) ? (message.tools as AgentToolCall[]) : []
|
||
|
||
return {
|
||
...message,
|
||
id: stringifyChoiceField(message.id) || createId(role),
|
||
role,
|
||
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,
|
||
segments: normalizeMessageSegments(message.segments, content, 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 response = await fetch(resolveApiUrl(path), {
|
||
...options,
|
||
headers: buildAgentRequestHeaders(options.headers),
|
||
credentials: 'include',
|
||
})
|
||
|
||
if (!response.ok) throw new Error(await resolveAgentResponseErrorMessage(response))
|
||
|
||
const result = await response.json()
|
||
if (!result?.success) throw new Error(result?.message_i18n || 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
|
||
}
|
||
|
||
// 记录当前流的恢复上下文,并立即持久化,供 PWA 后台冻结或重载后继续轮询。
|
||
function beginStreamRecovery(targetSessionId: string, startedAt: number) {
|
||
const current = pendingStreamRecovery.value
|
||
pendingStreamRecovery.value = {
|
||
sessionId: targetSessionId,
|
||
startedAt,
|
||
attempts: current?.sessionId === targetSessionId ? current.attempts : 0,
|
||
}
|
||
persistState({ syncHistory: false })
|
||
}
|
||
|
||
// 判断服务端正式会话 ID 或客户端会话 ID 是否属于当前恢复任务。
|
||
function matchesStreamRecoverySession(session: AgentSessionHistoryItem, targetSessionId: string) {
|
||
const requestedIds = new Set([sessionId.value, targetSessionId].filter(Boolean))
|
||
|
||
return [session.sessionId, session.clientSessionId].some(id => Boolean(id && requestedIds.has(id)))
|
||
}
|
||
|
||
// 将无法继续恢复的流式占位转换成明确错误,避免界面永久停留在空白或加载态。
|
||
function failStreamRecovery() {
|
||
pendingStreamRecovery.value = null
|
||
const assistantMessage = [...messages.value]
|
||
.reverse()
|
||
.find(message => message.role === 'assistant' && message.status === 'streaming')
|
||
if (assistantMessage) {
|
||
assistantMessage.status = 'error'
|
||
if (!assistantMessage.content) appendAssistantTextSegment(assistantMessage, t('agentAssistant.recoveryFailed'))
|
||
markToolsDone(assistantMessage)
|
||
refreshMessageList()
|
||
} else {
|
||
streamError.value = t('agentAssistant.recoveryFailed')
|
||
}
|
||
persistState()
|
||
}
|
||
|
||
// 从服务端拉取当前会话展示快照,用于移动端后台断开 SSE 后恢复最终结果。
|
||
async function restoreCurrentSessionFromServer(targetSessionId: string, startedAt: number) {
|
||
const session = await loadServerHistorySession(targetSessionId)
|
||
const activeRecovery = pendingStreamRecovery.value
|
||
if (!activeRecovery || activeRecovery.sessionId !== targetSessionId) return { restored: false, pending: false }
|
||
if (!matchesStreamRecoverySession(session, targetSessionId)) return { restored: false, pending: false }
|
||
if (session.updatedAt < startedAt - 1000) return { restored: false, pending: Boolean(session.isProcessing) }
|
||
if (session.isProcessing) return { restored: false, pending: true }
|
||
if (!session.messages.length) return { restored: false, pending: false }
|
||
|
||
const restoredMessages = normalizeStoredMessages(session.messages)
|
||
restoredMessages.forEach(message => {
|
||
if (message.status !== 'streaming') return
|
||
|
||
message.status = 'done'
|
||
markToolsDone(message)
|
||
})
|
||
messages.value = restoredMessages
|
||
sessionId.value = session.sessionId
|
||
pendingStreamRecovery.value = null
|
||
clearStreamRecoveryTimer()
|
||
persistState({ syncHistory: false })
|
||
scrollToBottom()
|
||
if (abortController) {
|
||
streamRecoveryAbortRequested = true
|
||
abortController.abort()
|
||
}
|
||
return { restored: true, pending: false }
|
||
}
|
||
|
||
// WebAgent SSE 断流后,后台任务仍会完成并保存快照;前端回到前台后轮询拉取。
|
||
function scheduleStreamRecovery(delay = 1200) {
|
||
if (!pendingStreamRecovery.value || typeof window === 'undefined') return
|
||
|
||
clearStreamRecoveryTimer()
|
||
streamRecoveryTimer = window.setTimeout(async () => {
|
||
streamRecoveryTimer = null
|
||
if (!pendingStreamRecovery.value) return
|
||
if (document.visibilityState === 'hidden') return
|
||
|
||
const recovery = pendingStreamRecovery.value
|
||
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.value || pendingStreamRecovery.value.sessionId !== recovery.sessionId) return
|
||
pendingStreamRecovery.value.attempts += 1
|
||
if (pendingStreamRecovery.value.attempts > 8) {
|
||
failStreamRecovery()
|
||
return
|
||
}
|
||
|
||
persistState({ syncHistory: false })
|
||
scheduleStreamRecovery(Math.min(8000, 1200 + pendingStreamRecovery.value.attempts * 900))
|
||
}, delay)
|
||
}
|
||
|
||
// 从持久化元数据或最后一条 streaming 助手消息重建后台恢复任务。
|
||
function restorePendingStreamRecovery(value: unknown) {
|
||
const stored = value && typeof value === 'object' ? (value as Record<string, unknown>) : null
|
||
const latestAssistantMessage = messages.value.at(-1)
|
||
const recoverableAssistantMessage =
|
||
latestAssistantMessage?.role === 'assistant' &&
|
||
(latestAssistantMessage.status === 'streaming' ||
|
||
(latestAssistantMessage.status === 'done' && isEmptyAssistantMessage(latestAssistantMessage)))
|
||
? latestAssistantMessage
|
||
: undefined
|
||
const recoverySessionId = stringifyChoiceField(stored?.sessionId) || sessionId.value
|
||
const startedAt = Number(stored?.startedAt) || recoverableAssistantMessage?.createdAt || 0
|
||
if (!recoverySessionId || !startedAt || (!stored && !recoverableAssistantMessage)) {
|
||
pendingStreamRecovery.value = null
|
||
return
|
||
}
|
||
|
||
if (recoverableAssistantMessage) recoverableAssistantMessage.status = 'streaming'
|
||
pendingStreamRecovery.value = {
|
||
sessionId: recoverySessionId,
|
||
startedAt,
|
||
attempts: Math.max(0, Number(stored?.attempts) || 0),
|
||
}
|
||
}
|
||
|
||
// 恢复当前会话状态,优先使用本地状态,缺失时使用最近历史。
|
||
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)
|
||
restorePendingStreamRecovery(state.streamRecovery)
|
||
upsertCurrentSessionHistory()
|
||
} catch (error) {
|
||
sessionId.value = createSessionId()
|
||
pendingStreamRecovery.value = null
|
||
}
|
||
}
|
||
|
||
// 将历史会话列表写入 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),
|
||
streamRecovery: pendingStreamRecovery.value,
|
||
}),
|
||
)
|
||
} catch (error) {
|
||
// 浏览器本地空间不足时保留内存态,避免发送过程被持久化异常打断。
|
||
}
|
||
|
||
if (syncHistory) upsertCurrentSessionHistory()
|
||
}
|
||
|
||
// 拼接后端 API 地址。
|
||
function resolveApiUrl(path: string) {
|
||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/'
|
||
return `${baseUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}`
|
||
}
|
||
|
||
// 构造智能助手 fetch 请求头,补齐 Axios 拦截器无法覆盖的鉴权和语言信息。
|
||
function buildAgentRequestHeaders(headers?: HeadersInit) {
|
||
const requestHeaders = new Headers(headers || {})
|
||
const locale = getCurrentLocale()
|
||
|
||
if (authStore.token) requestHeaders.set('Authorization', `Bearer ${authStore.token}`)
|
||
requestHeaders.set('X-MoviePilot-Locale', locale)
|
||
requestHeaders.set('Accept-Language', locale)
|
||
|
||
return requestHeaders
|
||
}
|
||
|
||
// 解析智能助手 fetch 失败响应,优先使用后端返回的本地化错误文本。
|
||
async function resolveAgentResponseErrorMessage(response: Response) {
|
||
try {
|
||
const payload = await response.clone().json()
|
||
const message = payload?.detail_i18n || payload?.message_i18n || payload?.detail || payload?.message
|
||
if (typeof message === 'string' && message) return message
|
||
} catch {
|
||
// 非 JSON 错误响应保留 HTTP 状态文本,避免吞掉原始错误。
|
||
}
|
||
|
||
return `${response.status} ${response.statusText}`.trim()
|
||
}
|
||
|
||
// 消息主列表使用原生滚动,避免流式回复时 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 handleMessageScrollerScroll() {
|
||
messageScrollerShouldFollow = isMessageScrollerNearBottom()
|
||
}
|
||
|
||
// 合并滚动更新请求,降低流式输出时的布局测量频率。
|
||
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
|
||
messageScrollerShouldFollow = true
|
||
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
|
||
}
|
||
|
||
// 流式期间至多每秒保存一次轻量当前态,终态再同步完整历史。
|
||
function scheduleStreamPersist() {
|
||
if (streamPersistTimer !== null) return
|
||
|
||
const elapsed = Date.now() - streamPersistLastRunAt
|
||
const delay = Math.max(0, STREAM_STATE_PERSIST_DELAY - elapsed)
|
||
streamPersistTimer = window.setTimeout(() => {
|
||
streamPersistTimer = null
|
||
streamPersistLastRunAt = Date.now()
|
||
persistState({ syncHistory: false })
|
||
}, 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: [],
|
||
segments: role === 'assistant' && content ? [{ type: 'text', content }] : [],
|
||
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 parseToolSummary(message: string) {
|
||
const summaryMatch = message.match(/^((.+))$/)
|
||
if (!summaryMatch) return null
|
||
|
||
const parts = summaryMatch[1].split(',').map(part => {
|
||
const countMatch = part.trim().match(/^(.*?\D)(\d+)(\D.*)$/)
|
||
if (!countMatch) return null
|
||
return {
|
||
prefix: countMatch[1],
|
||
count: Number(countMatch[2]),
|
||
suffix: countMatch[3],
|
||
}
|
||
})
|
||
return parts.every(Boolean) ? (parts as Array<{ prefix: string; count: number; suffix: string }>) : null
|
||
}
|
||
|
||
// 仅合并相邻的非啰嗦工具汇总;正文或具体工具提示会自然终止当前聚合组。
|
||
function mergeToolSummaries(currentMessage: string, nextMessage: string) {
|
||
const currentParts = parseToolSummary(currentMessage)
|
||
const nextParts = parseToolSummary(nextMessage)
|
||
if (!currentParts || !nextParts) return null
|
||
|
||
const mergedParts = currentParts.map(part => ({ ...part }))
|
||
const partIndexes = new Map(mergedParts.map((part, index) => [`${part.prefix}\u0000${part.suffix}`, index]))
|
||
nextParts.forEach(part => {
|
||
const key = `${part.prefix}\u0000${part.suffix}`
|
||
const existingIndex = partIndexes.get(key)
|
||
if (existingIndex === undefined) {
|
||
partIndexes.set(key, mergedParts.length)
|
||
mergedParts.push({ ...part })
|
||
return
|
||
}
|
||
mergedParts[existingIndex].count += part.count
|
||
})
|
||
|
||
return `(${mergedParts.map(part => `${part.prefix}${part.count}${part.suffix}`).join(',')})`
|
||
}
|
||
|
||
// 将当前消息里的运行中工具标记为完成。
|
||
function markToolsDone(message: AgentChatMessage) {
|
||
message.tools.forEach(tool => {
|
||
tool.status = 'done'
|
||
})
|
||
}
|
||
|
||
// 追加助手文本,并只合并紧邻的文本片段以保留工具事件边界。
|
||
function appendAssistantTextSegment(message: AgentChatMessage, content: string) {
|
||
if (!content) return
|
||
|
||
message.content += content
|
||
const lastSegment = message.segments.at(-1)
|
||
if (lastSegment?.type === 'text') {
|
||
lastSegment.content += content
|
||
} else if (content.trim()) {
|
||
message.segments.push({ type: 'text', content })
|
||
}
|
||
}
|
||
|
||
// 替换助手文本但保留工具片段,用于无法继续流式处理时显示错误。
|
||
function replaceAssistantTextSegments(message: AgentChatMessage, content: string) {
|
||
message.content = content
|
||
message.segments = message.segments.filter(segment => segment.type === 'tool')
|
||
if (content.trim()) message.segments.push({ type: 'text', content })
|
||
}
|
||
|
||
// 按 SSE 事件顺序渲染文本与工具,只跳过无法产生可见内容的空白文本。
|
||
function getRenderableMessageSegments(message: AgentChatMessage): AgentRenderableMessageSegment[] {
|
||
return message.segments.reduce<AgentRenderableMessageSegment[]>((renderableSegments, segment, index) => {
|
||
if (segment.type === 'text') {
|
||
if (segment.content.trim()) renderableSegments.push({ ...segment, key: `text-${index}` })
|
||
return renderableSegments
|
||
}
|
||
|
||
const tool = message.tools[segment.toolIndex]
|
||
if (tool) {
|
||
const previousSegment = renderableSegments.at(-1)
|
||
const mergedMessage =
|
||
previousSegment?.type === 'tool' ? mergeToolSummaries(previousSegment.tool.message, tool.message) : null
|
||
if (previousSegment?.type === 'tool' && mergedMessage) {
|
||
previousSegment.tool = {
|
||
...previousSegment.tool,
|
||
message: mergedMessage,
|
||
status: previousSegment.tool.status === 'running' || tool.status === 'running' ? 'running' : 'done',
|
||
}
|
||
} else {
|
||
renderableSegments.push({ type: 'tool', key: `tool-${tool.id}`, tool })
|
||
}
|
||
}
|
||
return renderableSegments
|
||
}, [])
|
||
}
|
||
|
||
// 判断消息是否没有任何可展示内容,可用于清理编辑回调产生的占位回复。
|
||
function isEmptyAssistantMessage(message: AgentChatMessage) {
|
||
return (
|
||
message.role === 'assistant' &&
|
||
!message.content.trim() &&
|
||
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.segments = normalizeMessageSegments(target?.segments, message.content, message.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) {
|
||
switch (event.type) {
|
||
case 'delta':
|
||
appendAssistantTextSegment(assistantMessage, event.content || '')
|
||
emit('assistant-preview', assistantMessage.content)
|
||
break
|
||
case 'tool':
|
||
markToolsDone(assistantMessage)
|
||
assistantMessage.tools.push({
|
||
id: createId('tool'),
|
||
message: normalizeToolMessage(event.message || ''),
|
||
status: 'running',
|
||
})
|
||
assistantMessage.segments.push({ type: 'tool', toolIndex: assistantMessage.tools.length - 1 })
|
||
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 消息展示,避免底部提示条重复且持续占位。
|
||
if (!assistantMessage.content) {
|
||
appendAssistantTextSegment(assistantMessage, event.message_i18n || event.message || t('agentAssistant.error'))
|
||
}
|
||
emit('assistant-preview', assistantMessage.content)
|
||
markToolsDone(assistantMessage)
|
||
break
|
||
case 'start':
|
||
if (event.session_id) {
|
||
sessionId.value = event.session_id
|
||
if (pendingStreamRecovery.value) pendingStreamRecovery.value.sessionId = event.session_id
|
||
}
|
||
break
|
||
default:
|
||
break
|
||
}
|
||
|
||
scheduleStreamPersist()
|
||
nextTick(() => {
|
||
scheduleMessageScrollerUpdate({ toBottom: messageScrollerShouldFollow })
|
||
})
|
||
}
|
||
|
||
// 将同一条助手消息的连续文本增量合并到一个动画帧,语义事件到来前会同步冲刷。
|
||
function flushPendingStreamDelta() {
|
||
if (streamDeltaFrame !== null) {
|
||
window.cancelAnimationFrame(streamDeltaFrame)
|
||
streamDeltaFrame = null
|
||
}
|
||
if (!pendingStreamDeltaMessage || !pendingStreamDelta) return
|
||
|
||
const assistantMessage = pendingStreamDeltaMessage
|
||
const content = pendingStreamDelta
|
||
pendingStreamDeltaMessage = null
|
||
pendingStreamDelta = ''
|
||
applyStreamEvent({ type: 'delta', content }, assistantMessage)
|
||
}
|
||
|
||
function clearPendingStreamDelta() {
|
||
if (streamDeltaFrame !== null) window.cancelAnimationFrame(streamDeltaFrame)
|
||
streamDeltaFrame = null
|
||
pendingStreamDeltaMessage = null
|
||
pendingStreamDelta = ''
|
||
}
|
||
|
||
function schedulePendingStreamDeltaFlush() {
|
||
if (streamDeltaFrame !== null) return
|
||
|
||
streamDeltaFrame = window.requestAnimationFrame(() => {
|
||
streamDeltaFrame = null
|
||
flushPendingStreamDelta()
|
||
})
|
||
}
|
||
|
||
function queueStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMessage) {
|
||
if (event.type !== 'delta') {
|
||
flushPendingStreamDelta()
|
||
applyStreamEvent(event, assistantMessage)
|
||
return
|
||
}
|
||
|
||
if (pendingStreamDeltaMessage && pendingStreamDeltaMessage !== assistantMessage) flushPendingStreamDelta()
|
||
pendingStreamDeltaMessage = assistantMessage
|
||
pendingStreamDelta += event.content || ''
|
||
schedulePendingStreamDeltaFlush()
|
||
}
|
||
|
||
// 解析一个 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): Promise<AgentStreamReadResult> {
|
||
if (!response.body) {
|
||
throw new Error(t('agentAssistant.noStream'))
|
||
}
|
||
|
||
const reader = response.body.getReader()
|
||
const decoder = new TextDecoder('utf-8')
|
||
let buffer = ''
|
||
let receivedTerminalEvent = false
|
||
|
||
// 应用事件并记录服务端是否明确结束本轮流,区分正常完成与无异常的后台断流。
|
||
const consumeEvent = (event: AgentStreamEvent | null) => {
|
||
if (!event) return
|
||
|
||
queueStreamEvent(event, assistantMessage)
|
||
if (event.type === 'done' || event.type === 'error') receivedTerminalEvent = true
|
||
}
|
||
|
||
try {
|
||
while (true) {
|
||
const { value, done } = await reader.read()
|
||
if (done) break
|
||
|
||
buffer += decoder.decode(value, { stream: true })
|
||
const blocks = buffer.split(/\r?\n\r?\n/)
|
||
buffer = blocks.pop() || ''
|
||
|
||
for (const block of blocks) {
|
||
consumeEvent(parseSseBlock(block))
|
||
}
|
||
}
|
||
|
||
buffer += decoder.decode()
|
||
if (buffer.trim()) {
|
||
consumeEvent(parseSseBlock(buffer))
|
||
}
|
||
} finally {
|
||
flushPendingStreamDelta()
|
||
}
|
||
|
||
return { receivedTerminalEvent }
|
||
}
|
||
|
||
// 移动端浏览器退到后台时,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: buildAgentRequestHeaders(),
|
||
body: formData,
|
||
credentials: 'include',
|
||
})
|
||
|
||
if (!response.ok) throw new Error(await resolveAgentResponseErrorMessage(response))
|
||
|
||
const result = await response.json()
|
||
if (!result?.success) throw new Error(result?.message_i18n || 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
|
||
streamRecoveryAbortRequested = false
|
||
const streamStartedAt = Date.now()
|
||
activeStreamStartedAt = streamStartedAt
|
||
let shouldFollowBottomAfterStream = true
|
||
let shouldSaveClientSnapshot = true
|
||
|
||
try {
|
||
const response = await fetch(resolveApiUrl('message/agent/stream'), {
|
||
method: 'POST',
|
||
headers: buildAgentRequestHeaders({
|
||
'Content-Type': 'application/json',
|
||
}),
|
||
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(await resolveAgentResponseErrorMessage(response))
|
||
}
|
||
|
||
const streamResult = await readAgentStream(response, assistantMessage)
|
||
shouldFollowBottomAfterStream = isMessageScrollerNearBottom()
|
||
if (!streamResult.receivedTerminalEvent) {
|
||
shouldSaveClientSnapshot = false
|
||
beginStreamRecovery(sessionId.value, streamStartedAt)
|
||
refreshMessageList()
|
||
if (document.visibilityState === 'visible') scheduleStreamRecovery(0)
|
||
return
|
||
}
|
||
|
||
pendingStreamRecovery.value = null
|
||
clearStreamRecoveryTimer()
|
||
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' && streamRecoveryAbortRequested) return
|
||
|
||
if (error?.name === 'AbortError' && userAbortRequested) {
|
||
assistantMessage.status = 'done'
|
||
markToolsDone(assistantMessage)
|
||
refreshMessageList()
|
||
return
|
||
}
|
||
|
||
if (isRecoverableStreamDisconnect(error)) {
|
||
shouldSaveClientSnapshot = false
|
||
beginStreamRecovery(sessionId.value, streamStartedAt)
|
||
assistantMessage.status = 'streaming'
|
||
refreshMessageList()
|
||
if (document.visibilityState === 'visible') scheduleStreamRecovery(1200)
|
||
return
|
||
}
|
||
|
||
assistantMessage.status = 'error'
|
||
replaceAssistantTextSegments(assistantMessage, error?.message || t('agentAssistant.error'))
|
||
markToolsDone(assistantMessage)
|
||
refreshMessageList()
|
||
} finally {
|
||
abortController = null
|
||
activeStreamStartedAt = 0
|
||
userAbortRequested = false
|
||
streamRecoveryAbortRequested = 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) || isBusy.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 (isBusy.value || choice.status !== 'pending') return
|
||
|
||
sending.value = true
|
||
streamError.value = ''
|
||
|
||
try {
|
||
const response = await fetch(resolveApiUrl('message/agent/callback'), {
|
||
method: 'POST',
|
||
headers: buildAgentRequestHeaders({
|
||
'Content-Type': 'application/json',
|
||
}),
|
||
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(await resolveAgentResponseErrorMessage(response))
|
||
|
||
const result = await response.json()
|
||
if (!result?.success) throw new Error(result?.message_i18n || 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.value = null
|
||
clearStreamRecoveryTimer()
|
||
const assistantMessage = [...messages.value]
|
||
.reverse()
|
||
.find(message => message.role === 'assistant' && message.status === 'streaming')
|
||
if (assistantMessage) {
|
||
if (isEmptyAssistantMessage(assistantMessage)) {
|
||
messages.value = messages.value.filter(message => message.id !== assistantMessage.id)
|
||
} else {
|
||
assistantMessage.status = 'done'
|
||
markToolsDone(assistantMessage)
|
||
}
|
||
refreshMessageList()
|
||
}
|
||
persistState()
|
||
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 (isBusy.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 (isBusy.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
|
||
}
|
||
|
||
// 在桌面侧栏与全屏对话模式之间切换。
|
||
function toggleFullscreen() {
|
||
fullscreen.value = !fullscreen.value
|
||
}
|
||
|
||
// 同步面板打开状态到全局 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 === 'hidden') {
|
||
if (sending.value && activeStreamStartedAt && sessionId.value) {
|
||
beginStreamRecovery(sessionId.value, activeStreamStartedAt)
|
||
} else if (pendingStreamRecovery.value) {
|
||
clearStreamPersistTimer()
|
||
persistState({ syncHistory: false })
|
||
}
|
||
return
|
||
}
|
||
|
||
scheduleStreamRecovery(0)
|
||
}
|
||
|
||
// PWA 从页面缓存或系统挂起状态返回时补触发恢复,兼容未派发 visibilitychange 的浏览器。
|
||
function handlePageShow() {
|
||
if (document.visibilityState === 'visible') scheduleStreamRecovery(0)
|
||
}
|
||
|
||
// 处理输入框回车发送。
|
||
function handleInputKeydown(event: KeyboardEvent) {
|
||
if (event.key !== 'Enter' || event.shiftKey || isComposing.value || event.isComposing || event.keyCode === 229) return
|
||
event.preventDefault()
|
||
sendMessage()
|
||
}
|
||
|
||
// 开始输入法组合输入,避免回车确认候选词时误发送消息。
|
||
function handleCompositionStart() {
|
||
isComposing.value = true
|
||
}
|
||
|
||
// 结束输入法组合输入,恢复回车发送能力。
|
||
function handleCompositionEnd() {
|
||
isComposing.value = false
|
||
}
|
||
|
||
watch(isOpen, syncAgentAssistantOpenState, { immediate: true })
|
||
watch(drawerWidth, () => {
|
||
if (isOpen.value) syncAgentAssistantOpenState(true)
|
||
})
|
||
|
||
watch(isOpen, open => {
|
||
if (open) scrollToBottom()
|
||
})
|
||
|
||
watch(isBusy, value => emit('thinking-change', value), { immediate: true })
|
||
|
||
onMounted(() => {
|
||
restoreHistorySessions()
|
||
restoreState()
|
||
loadServerHistorySessions()
|
||
if (pendingStreamRecovery.value && document.visibilityState === 'visible') scheduleStreamRecovery(0)
|
||
syncInputHeight()
|
||
window.addEventListener('keydown', handleGlobalKeydown)
|
||
window.addEventListener('pageshow', handlePageShow)
|
||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||
})
|
||
|
||
onScopeDispose(clearAgentAssistantOpenState)
|
||
onScopeDispose(clearPendingAttachments)
|
||
onScopeDispose(cancelVoiceRecording)
|
||
onScopeDispose(clearMessageScrollFrame)
|
||
onScopeDispose(clearStreamPersistTimer)
|
||
onScopeDispose(clearPendingStreamDelta)
|
||
onScopeDispose(clearStreamRecoveryTimer)
|
||
onScopeDispose(() => {
|
||
if (typeof window === 'undefined') return
|
||
|
||
window.removeEventListener('keydown', handleGlobalKeydown)
|
||
window.removeEventListener('pageshow', handlePageShow)
|
||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<aside
|
||
v-show="isOpen"
|
||
class="agent-assistant-panel"
|
||
:class="{
|
||
'is-fullscreen': fullscreen,
|
||
'is-motion-paused': !props.motionActive,
|
||
'is-open': isOpen,
|
||
}"
|
||
:style="drawerStyle"
|
||
role="dialog"
|
||
:aria-label="t('agentAssistant.title')"
|
||
@focusin.stop
|
||
>
|
||
<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">
|
||
{{ isBusy ? 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"
|
||
location="bottom end"
|
||
offset="8"
|
||
max-width="360"
|
||
:style="{ zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.overlay }"
|
||
:z-index="AGENT_ASSISTANT_LAYER_Z_INDEX.overlay"
|
||
>
|
||
<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="isBusy"
|
||
@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="isBusy"
|
||
: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="isBusy"
|
||
:title="t('agentAssistant.newChat')"
|
||
:aria-label="t('agentAssistant.newChat')"
|
||
@click="startNewSession"
|
||
>
|
||
<VIcon icon="mdi-message-plus-outline" />
|
||
</IconBtn>
|
||
<IconBtn
|
||
v-if="canToggleFullscreen"
|
||
class="agent-assistant-fullscreen-toggle"
|
||
:title="t(fullscreen ? 'agentAssistant.exitFullscreen' : 'agentAssistant.enterFullscreen')"
|
||
:aria-label="t(fullscreen ? 'agentAssistant.exitFullscreen' : 'agentAssistant.enterFullscreen')"
|
||
:aria-pressed="fullscreen"
|
||
@click="toggleFullscreen"
|
||
>
|
||
<VIcon :icon="fullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'" />
|
||
</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 }"
|
||
@scroll.passive="handleMessageScrollerScroll"
|
||
>
|
||
<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.role === 'assistant' && (message.tools.length || message.content.trim())"
|
||
class="agent-assistant-segments"
|
||
>
|
||
<template v-for="segment in getRenderableMessageSegments(message)" :key="segment.key">
|
||
<AgentMarkdownContent
|
||
v-if="segment.type === 'text'"
|
||
:content="segment.content"
|
||
:streaming="message.status === 'streaming'"
|
||
/>
|
||
<div v-else class="agent-assistant-tool">
|
||
<VIcon
|
||
:icon="
|
||
segment.tool.status === 'running' && message.status === 'streaming'
|
||
? 'line-md:loading-twotone-loop'
|
||
: 'mdi-check-circle-outline'
|
||
"
|
||
size="16"
|
||
/>
|
||
<span>{{ segment.tool.message }}</span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<AgentMarkdownContent
|
||
v-else-if="message.content"
|
||
:content="message.content"
|
||
:streaming="message.status === 'streaming'"
|
||
/>
|
||
|
||
<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>
|
||
<AgentMarkdownContent :content="choice.prompt" variant="choice" />
|
||
<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="isBusy || 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.segments.length &&
|
||
!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="isBusy"
|
||
: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="isBusy"
|
||
@change="handleFileSelection"
|
||
/>
|
||
<IconBtn
|
||
class="agent-assistant-attach agent-assistant-surface-btn"
|
||
:disabled="isBusy || 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="isBusy || recording"
|
||
:placeholder="inputPlaceholder"
|
||
@input="handleInputChange"
|
||
@keydown="handleInputKeydown"
|
||
@compositionstart="handleCompositionStart"
|
||
@compositionend="handleCompositionEnd"
|
||
/>
|
||
<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="!isBusy && !canSend"
|
||
:title="isBusy ? t('agentAssistant.stop') : t('common.send')"
|
||
:aria-label="isBusy ? t('agentAssistant.stop') : t('common.send')"
|
||
@click="isBusy ? stopGeneration() : sendMessage()"
|
||
>
|
||
<VIcon :icon="isBusy ? 'mdi-stop' : 'mdi-send'" />
|
||
</IconBtn>
|
||
</div>
|
||
</footer>
|
||
</div>
|
||
</aside>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||
/* stylelint-disable no-descending-specificity */
|
||
|
||
.agent-assistant-panel {
|
||
position: fixed;
|
||
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;
|
||
}
|
||
|
||
.agent-assistant-panel.is-fullscreen {
|
||
border-inline-start: 0;
|
||
box-shadow: none;
|
||
|
||
.agent-assistant-message__bubble {
|
||
max-inline-size: min(100%, 64rem);
|
||
}
|
||
|
||
.agent-assistant-segments,
|
||
.agent-assistant-choices,
|
||
.agent-assistant-attachments {
|
||
inline-size: min(100%, 64rem);
|
||
}
|
||
|
||
.agent-assistant-composer {
|
||
inline-size: min(calc(100% - 2rem), 72rem);
|
||
inset-inline: 0;
|
||
margin-inline: auto;
|
||
}
|
||
}
|
||
|
||
@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: 100vh;
|
||
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;
|
||
}
|
||
|
||
@supports (block-size: 100dvh) {
|
||
.agent-assistant-shell {
|
||
block-size: 100dvh;
|
||
}
|
||
}
|
||
|
||
.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: color-mix(in srgb, rgb(var(--v-theme-primary)) 72%, #090510 28%);
|
||
--agent-assistant-mini-robot-outline-soft: color-mix(in srgb, rgb(var(--v-theme-primary)) 84%, #090510 16%);
|
||
--agent-assistant-mini-robot-shell-start: color-mix(in srgb, rgb(var(--v-theme-primary)) 38%, white 62%);
|
||
--agent-assistant-mini-robot-shell-mid: color-mix(in srgb, rgb(var(--v-theme-primary)) 74%, white 26%);
|
||
--agent-assistant-mini-robot-shell-end: rgb(var(--v-theme-primary));
|
||
--agent-assistant-mini-robot-face-start: color-mix(in srgb, rgb(var(--v-theme-primary)) 30%, #080a12 70%);
|
||
--agent-assistant-mini-robot-face-end: color-mix(in srgb, rgb(var(--v-theme-primary)) 14%, #03050a 86%);
|
||
--agent-assistant-mini-robot-eye: color-mix(in srgb, rgb(var(--v-theme-primary)) 12%, white 88%);
|
||
--agent-assistant-mini-robot-shade: color-mix(in srgb, rgb(var(--v-theme-primary)) 22%, transparent);
|
||
|
||
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 var(--agent-assistant-mini-robot-shade),
|
||
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;
|
||
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-panel.is-open:not(.is-motion-paused) .agent-assistant-mini-bot__eye {
|
||
animation: agent-fab-blink 4.8s ease-in-out 1;
|
||
}
|
||
|
||
.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;
|
||
overscroll-behavior-y: 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%;
|
||
}
|
||
|
||
.agent-assistant-panel.is-fullscreen .agent-assistant-messages__content {
|
||
inline-size: min(100%, 72rem);
|
||
margin-inline: auto;
|
||
}
|
||
|
||
/* 只有消息态预留输入框空间,避免 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-segments {
|
||
display: grid;
|
||
gap: 0.5rem;
|
||
inline-size: min(100%, 34rem);
|
||
}
|
||
|
||
.agent-assistant-segments .agent-assistant-message__bubble {
|
||
inline-size: 100%;
|
||
}
|
||
|
||
.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>
|