| | |
| | | <!-- 系统聊天窗口 --> |
| | | <template> |
| | | <div> |
| | | <ElDrawer v-model="isDrawerVisible" :size="isMobile ? '100%' : '480px'" :with-header="false"> |
| | | <div class="mb-5 flex-cb"> |
| | | <div> |
| | | <span class="text-base font-medium">Art Bot</span> |
| | | <div class="mt-1.5 flex-c gap-1"> |
| | | <div |
| | | class="h-2 w-2 rounded-full" |
| | | :class="isOnline ? 'bg-success/100' : 'bg-danger/100'" |
| | | ></div> |
| | | <span class="text-xs text-g-600">{{ isOnline ? '在线' : '离线' }}</span> |
| | | <ElDrawer |
| | | v-model="isDrawerVisible" |
| | | :size="isMobile ? '100%' : '76vw'" |
| | | :with-header="false" |
| | | destroy-on-close |
| | | class="ai-chat-drawer" |
| | | > |
| | | <div class="flex h-full min-h-0 flex-col overflow-hidden bg-[var(--art-main-bg-color)]"> |
| | | <div class="flex items-center gap-4 border-b border-[var(--el-border-color-lighter)] bg-[var(--art-main-bg-color)] px-6 py-4"> |
| | | <div class="flex size-11 items-center justify-center rounded-3xl bg-[var(--el-color-primary-light-9)] text-[var(--el-color-primary)]"> |
| | | <ArtSvgIcon icon="ri:robot-2-line" class="text-[22px]" /> |
| | | </div> |
| | | <div class="min-w-0 flex-1"> |
| | | <div class="flex flex-wrap items-center gap-2"> |
| | | <h3 class="text-base font-semibold text-[var(--art-gray-900)]">{{ $t('ai.drawer.title') }}</h3> |
| | | <ElTag v-if="streaming" type="success" effect="light" round>Streaming</ElTag> |
| | | </div> |
| | | <p class="mt-1 truncate text-xs text-[var(--art-gray-500)]"> |
| | | {{ runtime?.promptName || runtime?.promptCode || DEFAULT_PROMPT_CODE }} |
| | | </p> |
| | | </div> |
| | | <div> |
| | | <ElIcon class="c-p" :size="20" @click="closeChat"> |
| | | <Close /> |
| | | </ElIcon> |
| | | </div> |
| | | <ElButton plain :disabled="streaming" @click="startNewSession"> |
| | | <ArtSvgIcon icon="ri:add-line" class="mr-1 text-sm" /> |
| | | {{ $t('ai.drawer.newSession') }} |
| | | </ElButton> |
| | | <ArtIconButton icon="ri:close-line" @click="closeChat" /> |
| | | </div> |
| | | <div class="flex h-[calc(100%-70px)] flex-col"> |
| | | <!-- 聊天消息区域 --> |
| | | <div |
| | | class="flex-1 overflow-y-auto border-t-d px-4 py-7.5 [&::-webkit-scrollbar]:!w-1" |
| | | ref="messageContainer" |
| | | > |
| | | <template v-for="(message, index) in messages" :key="index"> |
| | | <div |
| | | :class="[ |
| | | 'mb-7.5 flex w-full items-start gap-2', |
| | | message.isMe ? 'flex-row-reverse' : 'flex-row' |
| | | ]" |
| | | |
| | | <div class="min-h-0 flex-1 bg-g-100/35 ai-chat-body"> |
| | | <aside class="box-border flex min-h-0 flex-col gap-4 p-4 ai-chat-sidebar"> |
| | | <div class="rounded-3xl bg-[var(--art-main-bg-color)] p-4 shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-[var(--el-border-color-lighter)]"> |
| | | <div class="mb-3 flex items-center justify-between gap-3"> |
| | | <div class="text-sm font-semibold text-[var(--art-gray-900)]">{{ $t('ai.drawer.sessionList') }}</div> |
| | | <ElTag effect="plain" round>{{ sessions.length }}</ElTag> |
| | | </div> |
| | | <ElInput |
| | | v-model="sessionKeyword" |
| | | clearable |
| | | :placeholder="$t('ai.drawer.searchPlaceholder')" |
| | | > |
| | | <ElAvatar :size="32" :src="message.avatar" class="shrink-0" /> |
| | | <template #prefix> |
| | | <ArtSvgIcon icon="ri:search-line" /> |
| | | </template> |
| | | </ElInput> |
| | | </div> |
| | | |
| | | <div class="min-h-0 flex-1 overflow-hidden rounded-3xl bg-[var(--art-main-bg-color)] shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-[var(--el-border-color-lighter)]"> |
| | | <ElScrollbar class="h-full px-3 py-3"> |
| | | <div class="space-y-3"> |
| | | <ElEmpty v-if="!sessions.length" :description="$t('ai.drawer.noSessions')" :image-size="88" /> |
| | | |
| | | <button |
| | | v-for="item in sessions" |
| | | :key="item.sessionId" |
| | | type="button" |
| | | class="w-full rounded-3xl border p-3 text-left transition-all" |
| | | :class=" |
| | | item.sessionId === sessionId |
| | | ? 'border-[var(--el-color-primary-light-7)] bg-[var(--el-color-primary-light-9)] shadow-[0_12px_30px_rgba(64,158,255,0.08)]' |
| | | : 'border-transparent bg-[var(--art-main-bg-color)] hover:border-[var(--el-color-primary-light-8)] hover:bg-g-100/50' |
| | | " |
| | | :disabled="streaming" |
| | | @click="handleSwitchSession(item.sessionId)" |
| | | > |
| | | <div class="flex items-start gap-3"> |
| | | <div |
| | | class="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-2xl" |
| | | :class=" |
| | | item.sessionId === sessionId |
| | | ? 'bg-[var(--el-color-primary)]/12 text-[var(--el-color-primary)]' |
| | | : 'bg-g-100 text-[var(--art-gray-500)]' |
| | | " |
| | | > |
| | | <ArtSvgIcon :icon="item.pinned ? 'ri:pushpin-2-fill' : 'ri:chat-3-line'" class="text-base" /> |
| | | </div> |
| | | |
| | | <div class="min-w-0 flex-1"> |
| | | <div class="flex items-start justify-between gap-2"> |
| | | <div class="min-w-0"> |
| | | <div class="truncate text-sm font-medium text-[var(--art-gray-900)]"> |
| | | {{ item.title || $t('ai.drawer.sessionTitle', { id: item.sessionId }) }} |
| | | </div> |
| | | <div class="mt-1 truncate text-xs text-[var(--art-gray-500)]"> |
| | | {{ item.lastMessageTime || $t('ai.drawer.sessionMetric', { id: item.sessionId }) }} |
| | | </div> |
| | | </div> |
| | | <div class="shrink-0" @click.stop> |
| | | <ArtButtonMore |
| | | :list="getSessionActions(item)" |
| | | @click="handleSessionAction(item, $event)" |
| | | /> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="mt-3 line-clamp-2 text-xs leading-5 text-[var(--art-gray-500)]"> |
| | | {{ item.lastMessagePreview || $t('ai.drawer.emptyHint') }} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </button> |
| | | </div> |
| | | </ElScrollbar> |
| | | </div> |
| | | </aside> |
| | | |
| | | <section class="box-border flex min-h-0 flex-1 flex-col gap-4 p-4 pl-0"> |
| | | <div class="rounded-3xl bg-[var(--art-main-bg-color)] px-5 py-4 shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-[var(--el-border-color-lighter)]"> |
| | | <div class="flex flex-wrap items-center justify-between gap-3"> |
| | | <div> |
| | | <div class="text-sm font-semibold text-[var(--art-gray-900)]">{{ $t('ai.drawer.runtimeOverview') }}</div> |
| | | <div class="mt-1 text-xs text-[var(--art-gray-500)]"> |
| | | {{ $t('ai.drawer.modelSelectorHint') }} |
| | | </div> |
| | | </div> |
| | | <ElButton text @click="runtimePanelExpanded = !runtimePanelExpanded"> |
| | | {{ $t(runtimePanelExpanded ? 'ai.drawer.runtimeCollapse' : 'ai.drawer.runtimeExpand') }} |
| | | </ElButton> |
| | | </div> |
| | | |
| | | <ElCollapseTransition> |
| | | <div v-show="runtimePanelExpanded" class="mt-4 space-y-4"> |
| | | <div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> |
| | | <div |
| | | v-for="item in runtimeMetricCards" |
| | | :key="item.label" |
| | | class="rounded-2xl bg-g-100/55 px-4 py-3 ring-1 ring-[var(--el-border-color-extra-light)]" |
| | | > |
| | | <div class="mb-2 flex items-center gap-2 text-xs text-[var(--art-gray-500)]"> |
| | | <ArtSvgIcon :icon="item.icon" class="text-sm" /> |
| | | <span>{{ item.label }}</span> |
| | | </div> |
| | | <div class="truncate text-sm font-semibold text-[var(--art-gray-900)]"> |
| | | {{ item.value }} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="flex flex-wrap items-center gap-3"> |
| | | <ElSelect |
| | | v-if="selectableModelOptions.length" |
| | | v-model="selectedAiParamId" |
| | | :placeholder="$t('ai.drawer.modelSelectorLabel')" |
| | | :disabled="streaming || loadingRuntime || selectableModelOptions.length <= 1" |
| | | class="min-w-[280px]" |
| | | @change="handleModelChange" |
| | | > |
| | | <ElOption |
| | | v-for="item in selectableModelOptions" |
| | | :key="String(item.aiParamId)" |
| | | :label="formatModelOption(item)" |
| | | :value="item.aiParamId" |
| | | /> |
| | | </ElSelect> |
| | | |
| | | <div class="flex flex-wrap gap-2"> |
| | | <ElButton |
| | | v-for="item in quickLinks" |
| | | :key="item.path" |
| | | plain |
| | | size="small" |
| | | @click="navigateTo(item.path)" |
| | | > |
| | | {{ item.label }} |
| | | </ElButton> |
| | | <ElButton plain size="small" :disabled="!sessionId || streaming" @click="handleRetainLatestRound"> |
| | | {{ $t('ai.drawer.retainLatestRound') }} |
| | | </ElButton> |
| | | <ElButton plain size="small" :disabled="!sessionId || streaming" @click="handleClearMemory"> |
| | | {{ $t('ai.drawer.clearMemory') }} |
| | | </ElButton> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="flex flex-wrap gap-2"> |
| | | <ElTag effect="plain" round>{{ $t('ai.drawer.requestMetric', { value: runtimeSummary.requestId }) }}</ElTag> |
| | | <ElTag effect="plain" round>{{ $t('ai.drawer.sessionMetric', { id: sessionId || '--' }) }}</ElTag> |
| | | <ElTag effect="plain" round>{{ $t('ai.drawer.recentMetric', { value: runtimeSummary.recentMessageCount }) }}</ElTag> |
| | | <ElTag :type="runtimeSummary.hasSummary ? 'success' : 'info'" effect="light" round> |
| | | {{ $t(runtimeSummary.hasSummary ? 'ai.drawer.hasSummary' : 'ai.drawer.noSummary') }} |
| | | </ElTag> |
| | | <ElTag :type="runtimeSummary.hasFacts ? 'success' : 'info'" effect="light" round> |
| | | {{ $t(runtimeSummary.hasFacts ? 'ai.drawer.hasFacts' : 'ai.drawer.noFacts') }} |
| | | </ElTag> |
| | | </div> |
| | | |
| | | <ElAlert |
| | | v-if="runtime?.memorySummary" |
| | | type="info" |
| | | :closable="false" |
| | | :title="runtime.memorySummary" |
| | | /> |
| | | <ElAlert |
| | | v-if="runtime?.memoryFacts" |
| | | type="success" |
| | | :closable="false" |
| | | :title="runtime.memoryFacts" |
| | | /> |
| | | </div> |
| | | </ElCollapseTransition> |
| | | </div> |
| | | |
| | | <ElAlert |
| | | v-if="drawerError" |
| | | type="warning" |
| | | :closable="false" |
| | | :title="drawerError" |
| | | class="rounded-2xl" |
| | | /> |
| | | |
| | | <div v-if="traceEvents.length" class="rounded-3xl bg-[var(--art-main-bg-color)] px-5 py-4 shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-[var(--el-border-color-lighter)]"> |
| | | <div class="mb-3 flex items-center justify-between gap-3"> |
| | | <div class="text-sm font-semibold text-[var(--art-gray-900)]">{{ $t('ai.drawer.activityTrace') }}</div> |
| | | <ElTag effect="plain" round>{{ traceEvents.length }}</ElTag> |
| | | </div> |
| | | <div class="space-y-3"> |
| | | <div |
| | | :class="['flex max-w-[70%] flex-col', message.isMe ? 'items-end' : 'items-start']" |
| | | v-for="item in traceEvents" |
| | | :key="item.traceId" |
| | | class="rounded-2xl bg-g-100/55 px-4 py-3 ring-1 ring-[var(--el-border-color-extra-light)]" |
| | | > |
| | | <div class="flex flex-wrap items-center justify-between gap-3"> |
| | | <div class="min-w-0"> |
| | | <div class="flex items-center gap-2"> |
| | | <ElTag size="small" :type="item.traceType === 'thinking' ? 'primary' : 'success'" effect="light"> |
| | | {{ item.traceType === 'thinking' ? $t('ai.drawer.traceTypeThinking') : $t('ai.drawer.traceTypeTool') }} |
| | | </ElTag> |
| | | <span class="text-sm font-medium text-[var(--art-gray-900)]"> |
| | | {{ item.toolName || item.title || $t('ai.drawer.unknownTool') }} |
| | | </span> |
| | | </div> |
| | | <div class="mt-1 text-xs text-[var(--art-gray-500)]"> |
| | | {{ item.traceType === 'thinking' ? getThinkingStatusLabel(item.status) : getToolStatusLabel(item.status) }} |
| | | </div> |
| | | </div> |
| | | |
| | | <ElButton |
| | | v-if="item.traceType !== 'thinking'" |
| | | text |
| | | size="small" |
| | | @click="toggleTraceEventExpanded(item.traceId)" |
| | | > |
| | | {{ $t(expandedTraceIds.includes(item.traceId) ? 'ai.drawer.collapseDetail' : 'ai.drawer.viewDetail') }} |
| | | </ElButton> |
| | | </div> |
| | | |
| | | <div |
| | | v-if="item.traceType === 'thinking'" |
| | | class="mt-3 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--art-gray-700)]" |
| | | > |
| | | {{ item.content || $t('ai.drawer.thinkingEmpty') }} |
| | | </div> |
| | | |
| | | <div |
| | | v-else-if="expandedTraceIds.includes(item.traceId)" |
| | | class="mt-3 space-y-2 text-sm leading-6 text-[var(--art-gray-700)]" |
| | | > |
| | | <div v-if="item.title">{{ item.title }}</div> |
| | | <div v-if="item.inputSummary">{{ $t('ai.drawer.toolInput', { value: item.inputSummary }) }}</div> |
| | | <div v-if="item.outputSummary">{{ $t('ai.drawer.toolOutput', { value: item.outputSummary }) }}</div> |
| | | <div v-if="item.errorMessage" class="text-[var(--el-color-danger)]"> |
| | | {{ $t('ai.drawer.toolError', { value: item.errorMessage }) }} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-3xl bg-[var(--art-main-bg-color)] shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-[var(--el-border-color-lighter)]"> |
| | | <div class="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--el-border-color-extra-light)] px-5 py-4"> |
| | | <div> |
| | | <div class="text-sm font-semibold text-[var(--art-gray-900)]">{{ currentSessionTitle }}</div> |
| | | <div class="mt-1 text-xs text-[var(--art-gray-500)]"> |
| | | {{ usageSummaryText }} |
| | | </div> |
| | | </div> |
| | | <div class="flex flex-wrap gap-2"> |
| | | <ElTag v-if="usage?.totalTokens != null" effect="plain" round> |
| | | {{ $t('ai.drawer.tokenMetric', { |
| | | prompt: usage?.promptTokens ?? 0, |
| | | completion: usage?.completionTokens ?? 0, |
| | | total: usage?.totalTokens ?? 0 |
| | | }) }} |
| | | </ElTag> |
| | | </div> |
| | | </div> |
| | | |
| | | <ElScrollbar class="min-h-0 flex-1 bg-g-100/35 px-5 py-5"> |
| | | <div class="space-y-5"> |
| | | <div v-if="!messages.length" class="rounded-3xl border border-dashed border-[var(--el-border-color)] bg-slate-50/80 px-4 py-8 text-center text-sm text-[var(--art-gray-500)]"> |
| | | {{ $t('ai.drawer.emptyHint') }} |
| | | </div> |
| | | |
| | | <div |
| | | v-for="(message, index) in messages" |
| | | :key="`${message.role}-${index}`" |
| | | class="flex items-end gap-3" |
| | | :class="message.role === 'user' ? 'justify-end' : 'justify-start'" |
| | | > |
| | | <div |
| | | :class="[ |
| | | 'mb-1 flex gap-2 text-xs', |
| | | message.isMe ? 'flex-row-reverse' : 'flex-row' |
| | | ]" |
| | | v-if="message.role !== 'user'" |
| | | class="flex size-9 shrink-0 items-center justify-center rounded-2xl bg-[var(--el-color-primary-light-9)] text-[var(--el-color-primary)]" |
| | | > |
| | | <span class="font-medium">{{ message.sender }}</span> |
| | | <span class="text-g-600">{{ message.time }}</span> |
| | | <ArtSvgIcon icon="ri:robot-2-line" class="text-base" /> |
| | | </div> |
| | | <div |
| | | :class="[ |
| | | 'rounded-md px-3.5 py-2.5 text-sm leading-[1.4] text-g-900', |
| | | message.isMe ? 'message-right bg-theme/15' : 'message-left bg-g-300/50' |
| | | ]" |
| | | >{{ message.content }}</div |
| | | > |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </div> |
| | | |
| | | <!-- 聊天输入区域 --> |
| | | <div class="px-4 pt-4"> |
| | | <ElInput |
| | | v-model="messageText" |
| | | type="textarea" |
| | | :rows="3" |
| | | placeholder="输入消息" |
| | | resize="none" |
| | | @keyup.enter.prevent="sendMessage" |
| | | > |
| | | <template #append> |
| | | <div class="flex gap-2 py-2"> |
| | | <ElButton :icon="Paperclip" circle plain /> |
| | | <ElButton :icon="Picture" circle plain /> |
| | | <ElButton type="primary" @click="sendMessage" v-ripple>发送</ElButton> |
| | | <div class="max-w-[82%]"> |
| | | <div |
| | | class="rounded-3xl px-4 py-3 text-sm leading-6 shadow-[0_10px_30px_rgba(15,23,42,0.04)]" |
| | | :class=" |
| | | message.role === 'user' |
| | | ? 'rounded-br-xl bg-[var(--el-color-primary)] text-white' |
| | | : 'rounded-bl-xl bg-[var(--art-main-bg-color)] text-[var(--art-gray-900)] ring-1 ring-[var(--el-border-color-extra-light)]' |
| | | " |
| | | > |
| | | <div class="whitespace-pre-wrap break-words"> |
| | | {{ |
| | | message.role === 'assistant' |
| | | ? message.content || (streaming && index === messages.length - 1 ? $t('ai.drawer.thinking') : '') |
| | | : message.content || '' |
| | | }} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div |
| | | v-if="message.role === 'user'" |
| | | class="flex size-9 shrink-0 items-center justify-center rounded-2xl bg-[var(--el-color-primary)] text-white" |
| | | > |
| | | <ArtSvgIcon icon="ri:user-3-line" class="text-base" /> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </ElInput> |
| | | <div class="mt-3 flex-cb"> |
| | | <div class="flex-c"> |
| | | <ArtSvgIcon icon="ri:image-line" class="mr-5 c-p text-g-600 text-lg" /> |
| | | <ArtSvgIcon icon="ri:emotion-happy-line" class="mr-5 c-p text-g-600 text-lg" /> |
| | | |
| | | <div ref="messagesBottomRef"></div> |
| | | </div> |
| | | <ElButton type="primary" @click="sendMessage" v-ripple class="min-w-20">发送</ElButton> |
| | | </ElScrollbar> |
| | | |
| | | <div class="border-t border-[var(--el-border-color-extra-light)] bg-[var(--art-main-bg-color)] px-5 py-4"> |
| | | <div class="rounded-3xl bg-g-100/45 p-4 ring-1 ring-[var(--el-border-color-lighter)]"> |
| | | <ElInput |
| | | v-model="input" |
| | | type="textarea" |
| | | :autosize="{ minRows: 3, maxRows: 6 }" |
| | | resize="none" |
| | | :placeholder="$t('ai.drawer.inputPlaceholder')" |
| | | @keydown="handleInputKeydown" |
| | | /> |
| | | |
| | | <div class="mt-3 flex flex-wrap items-center justify-between gap-3"> |
| | | <div class="text-xs text-[var(--art-gray-500)]"> |
| | | Enter 发送,Shift + Enter 换行 |
| | | </div> |
| | | |
| | | <div class="flex flex-wrap items-center gap-2"> |
| | | <ElButton text @click="input = ''">{{ $t('ai.drawer.clearInput') }}</ElButton> |
| | | <ElButton |
| | | v-if="streaming" |
| | | type="warning" |
| | | plain |
| | | @click="stopStream(true)" |
| | | > |
| | | {{ $t('ai.drawer.stop') }} |
| | | </ElButton> |
| | | <ElButton |
| | | v-else |
| | | type="primary" |
| | | @click="handleSend" |
| | | > |
| | | <ArtSvgIcon icon="ri:send-plane-2-line" class="mr-1 text-sm" /> |
| | | {{ $t('ai.drawer.send') }} |
| | | </ElButton> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </section> |
| | | </div> |
| | | </ElDrawer> |
| | | </div> |
| | | </div> |
| | | |
| | | <ElDialog |
| | | v-model="renameDialog.open" |
| | | :title="$t('ai.drawer.renameDialogTitle')" |
| | | width="420px" |
| | | append-to-body |
| | | > |
| | | <ElInput |
| | | v-model="renameDialog.title" |
| | | :placeholder="$t('ai.drawer.sessionTitleField')" |
| | | /> |
| | | <template #footer> |
| | | <ElButton @click="closeRenameDialog">{{ $t('common.cancel') }}</ElButton> |
| | | <ElButton |
| | | type="primary" |
| | | :disabled="streaming || !renameDialog.title.trim()" |
| | | @click="handleRenameSubmit" |
| | | > |
| | | {{ $t('common.confirm') }} |
| | | </ElButton> |
| | | </template> |
| | | </ElDialog> |
| | | </ElDrawer> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { Picture, Paperclip, Close } from '@element-plus/icons-vue' |
| | | |
| | | import { ElMessage } from 'element-plus' |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import { useWindowSize } from '@vueuse/core' |
| | | import { useI18n } from 'vue-i18n' |
| | | import { mittBus } from '@/utils/sys' |
| | | import meAvatar from '@/assets/images/avatar/avatar5.webp' |
| | | import aiAvatar from '@/assets/images/avatar/avatar10.webp' |
| | | import { |
| | | DEFAULT_PROMPT_CODE, |
| | | fetchClearAiSessionMemory, |
| | | fetchGetAiRuntime, |
| | | fetchGetAiSessions, |
| | | fetchPinAiSession, |
| | | fetchRemoveAiSession, |
| | | fetchRenameAiSession, |
| | | fetchRetainAiSessionLatestRound, |
| | | fetchStreamAiChat |
| | | } from '@/api/ai-chat' |
| | | |
| | | defineOptions({ name: 'ArtChatWindow' }) |
| | | const MOBILE_BREAKPOINT = 640 |
| | | const SCROLL_DELAY = 100 |
| | | const BOT_NAME = 'Art Bot' |
| | | const USER_NAME = 'Ricky' |
| | | |
| | | const MOBILE_BREAKPOINT = 768 |
| | | const SESSION_SEARCH_DELAY = 250 |
| | | const router = useRouter() |
| | | const route = useRoute() |
| | | const { width } = useWindowSize() |
| | | const { t } = useI18n() |
| | | |
| | | const isMobile = computed(() => width.value < MOBILE_BREAKPOINT) |
| | | const isDrawerVisible = ref(false) |
| | | const isOnline = ref(true) |
| | | const messageText = ref('') |
| | | const messageId = ref(10) |
| | | const messageContainer = ref(null) |
| | | const initializeMessages = () => [ |
| | | { |
| | | id: 1, |
| | | sender: BOT_NAME, |
| | | content: '你好!我是你的AI助手,有什么我可以帮你的吗?', |
| | | time: '10:00', |
| | | isMe: false, |
| | | avatar: aiAvatar |
| | | }, |
| | | { |
| | | id: 2, |
| | | sender: USER_NAME, |
| | | content: '我想了解一下系统的使用方法。', |
| | | time: '10:01', |
| | | isMe: true, |
| | | avatar: meAvatar |
| | | }, |
| | | { |
| | | id: 3, |
| | | sender: BOT_NAME, |
| | | content: '好的,我来为您介绍系统的主要功能。首先,您可以通过左侧菜单访问不同的功能模块...', |
| | | time: '10:02', |
| | | isMe: false, |
| | | avatar: aiAvatar |
| | | }, |
| | | { |
| | | id: 4, |
| | | sender: USER_NAME, |
| | | content: '听起来很不错,能具体讲讲数据分析部分吗?', |
| | | time: '10:05', |
| | | isMe: true, |
| | | avatar: meAvatar |
| | | }, |
| | | { |
| | | id: 5, |
| | | sender: BOT_NAME, |
| | | content: '当然可以。数据分析模块可以帮助您实时监控关键指标,并生成详细的报表...', |
| | | time: '10:06', |
| | | isMe: false, |
| | | avatar: aiAvatar |
| | | }, |
| | | { |
| | | id: 6, |
| | | sender: USER_NAME, |
| | | content: '太好了,那我如何开始使用呢?', |
| | | time: '10:08', |
| | | isMe: true, |
| | | avatar: meAvatar |
| | | }, |
| | | { |
| | | id: 7, |
| | | sender: BOT_NAME, |
| | | content: '您可以先创建一个项目,然后在项目中添加相关的数据源,系统会自动进行分析。', |
| | | time: '10:09', |
| | | isMe: false, |
| | | avatar: aiAvatar |
| | | }, |
| | | { |
| | | id: 8, |
| | | sender: USER_NAME, |
| | | content: '明白了,谢谢你的帮助!', |
| | | time: '10:10', |
| | | isMe: true, |
| | | avatar: meAvatar |
| | | }, |
| | | { |
| | | id: 9, |
| | | sender: BOT_NAME, |
| | | content: '不客气,有任何问题随时联系我。', |
| | | time: '10:11', |
| | | isMe: false, |
| | | avatar: aiAvatar |
| | | const runtime = ref(null) |
| | | const selectedAiParamId = ref(null) |
| | | const sessionId = ref(null) |
| | | const sessions = ref([]) |
| | | const persistedMessages = ref([]) |
| | | const messages = ref([]) |
| | | const traceEvents = ref([]) |
| | | const expandedTraceIds = ref([]) |
| | | const input = ref('') |
| | | const usage = ref(null) |
| | | const drawerError = ref('') |
| | | const sessionKeyword = ref('') |
| | | const loadingRuntime = ref(false) |
| | | const streaming = ref(false) |
| | | const runtimePanelExpanded = ref(false) |
| | | const messagesBottomRef = ref(null) |
| | | const renameDialog = reactive({ |
| | | open: false, |
| | | sessionId: null, |
| | | title: '' |
| | | }) |
| | | const abortController = ref(null) |
| | | let sessionSearchTimer = null |
| | | |
| | | const quickLinks = computed(() => [ |
| | | { label: t('menu.aiParam'), path: '/system/ai-param' }, |
| | | { label: t('menu.aiPrompt'), path: '/system/ai-prompt' }, |
| | | { label: t('menu.aiMcpMount'), path: '/system/ai-mcp-mount' } |
| | | ]) |
| | | |
| | | const currentSessionRecord = computed(() => |
| | | sessions.value.find((item) => item.sessionId === sessionId.value) || null |
| | | ) |
| | | |
| | | const currentSessionTitle = computed(() => { |
| | | return ( |
| | | currentSessionRecord.value?.title || |
| | | (sessionId.value ? t('ai.drawer.sessionTitle', { id: sessionId.value }) : t('ai.drawer.newSession')) |
| | | ) |
| | | }) |
| | | |
| | | const runtimeSummary = computed(() => ({ |
| | | requestId: runtime.value?.requestId || '--', |
| | | promptName: runtime.value?.promptName || '--', |
| | | model: runtime.value?.model || '--', |
| | | mountedMcpCount: runtime.value?.mountedMcpCount ?? 0, |
| | | recentMessageCount: runtime.value?.recentMessageCount ?? 0, |
| | | hasSummary: !!runtime.value?.memorySummary, |
| | | hasFacts: !!runtime.value?.memoryFacts |
| | | })) |
| | | |
| | | const usageSummaryText = computed(() => { |
| | | if (loadingRuntime.value) { |
| | | return t('ai.drawer.loadingRuntime') |
| | | } |
| | | ] |
| | | const messages = ref(initializeMessages()) |
| | | const formatCurrentTime = () => { |
| | | return /* @__PURE__ */ new Date().toLocaleTimeString([], { |
| | | hour: '2-digit', |
| | | minute: '2-digit' |
| | | }) |
| | | } |
| | | const scrollToBottom = () => { |
| | | nextTick(() => { |
| | | setTimeout(() => { |
| | | if (messageContainer.value) { |
| | | messageContainer.value.scrollTop = messageContainer.value.scrollHeight |
| | | if (usage.value?.elapsedMs != null) { |
| | | const firstTokenText = |
| | | usage.value?.firstTokenLatencyMs != null |
| | | ? ` / ${t('ai.drawer.firstTokenMetric', { value: usage.value.firstTokenLatencyMs })}` |
| | | : '' |
| | | return `${t('ai.drawer.elapsedMetric', { value: usage.value.elapsedMs })}${firstTokenText}` |
| | | } |
| | | return t('ai.drawer.modelSelectorHint') |
| | | }) |
| | | |
| | | const runtimeMetricCards = computed(() => [ |
| | | { |
| | | label: t('ai.drawer.modelLabel'), |
| | | value: runtimeSummary.value.model, |
| | | icon: 'ri:cpu-line' |
| | | }, |
| | | { |
| | | label: t('ai.drawer.promptLabel'), |
| | | value: runtimeSummary.value.promptName, |
| | | icon: 'ri:magic-line' |
| | | }, |
| | | { |
| | | label: t('ai.drawer.mcpLabel'), |
| | | value: String(runtimeSummary.value.mountedMcpCount), |
| | | icon: 'ri:plug-2-line' |
| | | }, |
| | | { |
| | | label: t('ai.drawer.historyLabel'), |
| | | value: String(persistedMessages.value.length), |
| | | icon: 'ri:chat-history-line' |
| | | } |
| | | ]) |
| | | |
| | | const selectableModelOptions = computed(() => { |
| | | if (Array.isArray(runtime.value?.modelOptions) && runtime.value.modelOptions.length) { |
| | | return runtime.value.modelOptions |
| | | } |
| | | if (runtime.value?.model) { |
| | | return [ |
| | | { |
| | | aiParamId: runtime.value.aiParamId ?? 'CURRENT_MODEL', |
| | | name: runtime.value.model, |
| | | model: runtime.value.model, |
| | | active: true |
| | | } |
| | | }, SCROLL_DELAY) |
| | | }) |
| | | } |
| | | const sendMessage = () => { |
| | | const text = messageText.value.trim() |
| | | if (!text) return |
| | | const newMessage = { |
| | | id: messageId.value++, |
| | | sender: USER_NAME, |
| | | content: text, |
| | | time: formatCurrentTime(), |
| | | isMe: true, |
| | | avatar: meAvatar |
| | | ] |
| | | } |
| | | messages.value.push(newMessage) |
| | | messageText.value = '' |
| | | scrollToBottom() |
| | | } |
| | | const openChat = () => { |
| | | isDrawerVisible.value = true |
| | | scrollToBottom() |
| | | } |
| | | const closeChat = () => { |
| | | isDrawerVisible.value = false |
| | | } |
| | | return [] |
| | | }) |
| | | |
| | | watch(isDrawerVisible, async (visible) => { |
| | | if (visible) { |
| | | runtimePanelExpanded.value = false |
| | | await initializeDrawer() |
| | | scrollMessagesToBottom() |
| | | return |
| | | } |
| | | stopStream(false) |
| | | }) |
| | | |
| | | watch( |
| | | () => sessionKeyword.value, |
| | | () => { |
| | | if (!isDrawerVisible.value) { |
| | | return |
| | | } |
| | | if (sessionSearchTimer) { |
| | | window.clearTimeout(sessionSearchTimer) |
| | | } |
| | | sessionSearchTimer = window.setTimeout(() => { |
| | | void loadSessions() |
| | | }, SESSION_SEARCH_DELAY) |
| | | } |
| | | ) |
| | | |
| | | watch( |
| | | () => [messages.value.length, streaming.value, isDrawerVisible.value], |
| | | async ([, , visible]) => { |
| | | if (!visible) { |
| | | return |
| | | } |
| | | await nextTick() |
| | | scrollMessagesToBottom() |
| | | } |
| | | ) |
| | | |
| | | onMounted(() => { |
| | | scrollToBottom() |
| | | mittBus.on('openChat', openChat) |
| | | }) |
| | | |
| | | onUnmounted(() => { |
| | | mittBus.off('openChat', openChat) |
| | | stopStream(false) |
| | | if (sessionSearchTimer) { |
| | | window.clearTimeout(sessionSearchTimer) |
| | | sessionSearchTimer = null |
| | | } |
| | | }) |
| | | |
| | | function getSessionActions(item) { |
| | | return [ |
| | | { |
| | | key: item?.pinned ? 'unpin' : 'pin', |
| | | label: t(item?.pinned ? 'ai.drawer.unpinAction' : 'ai.drawer.pinAction'), |
| | | icon: item?.pinned ? 'ri:pushpin-line' : 'ri:pushpin-2-line', |
| | | disabled: streaming.value |
| | | }, |
| | | { |
| | | key: 'rename', |
| | | label: t('ai.drawer.renameAction'), |
| | | icon: 'ri:edit-line', |
| | | disabled: streaming.value |
| | | }, |
| | | { |
| | | key: 'delete', |
| | | label: t('ai.drawer.deleteAction'), |
| | | icon: 'ri:delete-bin-line', |
| | | color: 'var(--el-color-danger)', |
| | | disabled: streaming.value |
| | | } |
| | | ] |
| | | } |
| | | |
| | | function openChat() { |
| | | isDrawerVisible.value = true |
| | | } |
| | | |
| | | function closeChat() { |
| | | isDrawerVisible.value = false |
| | | } |
| | | |
| | | async function initializeDrawer(targetSessionId = null) { |
| | | traceEvents.value = [] |
| | | expandedTraceIds.value = [] |
| | | usage.value = null |
| | | await Promise.all([loadRuntime(targetSessionId), loadSessions()]) |
| | | } |
| | | |
| | | async function loadRuntime(targetSessionId = null, targetAiParamId = selectedAiParamId.value) { |
| | | loadingRuntime.value = true |
| | | drawerError.value = '' |
| | | try { |
| | | const data = await fetchGetAiRuntime(DEFAULT_PROMPT_CODE, targetSessionId, targetAiParamId) |
| | | const historyMessages = normalizeMessageList(data?.persistedMessages) |
| | | runtime.value = data |
| | | selectedAiParamId.value = data?.aiParamId ?? targetAiParamId ?? null |
| | | sessionId.value = data?.sessionId || null |
| | | persistedMessages.value = historyMessages |
| | | messages.value = historyMessages |
| | | return data |
| | | } catch (error) { |
| | | drawerError.value = error?.message || t('ai.drawer.runtimeFailed') |
| | | return null |
| | | } finally { |
| | | loadingRuntime.value = false |
| | | } |
| | | } |
| | | |
| | | async function loadSessions() { |
| | | try { |
| | | sessions.value = await fetchGetAiSessions(DEFAULT_PROMPT_CODE, sessionKeyword.value.trim()) |
| | | } catch (error) { |
| | | drawerError.value = error?.message || t('ai.drawer.sessionListFailed') |
| | | sessions.value = [] |
| | | } |
| | | } |
| | | |
| | | async function startNewSession() { |
| | | if (streaming.value) { |
| | | return |
| | | } |
| | | sessionId.value = null |
| | | persistedMessages.value = [] |
| | | messages.value = [] |
| | | traceEvents.value = [] |
| | | expandedTraceIds.value = [] |
| | | usage.value = null |
| | | drawerError.value = '' |
| | | await loadRuntime(null, selectedAiParamId.value) |
| | | await loadSessions() |
| | | } |
| | | |
| | | async function handleSwitchSession(targetSessionId) { |
| | | if (streaming.value || targetSessionId === sessionId.value) { |
| | | return |
| | | } |
| | | usage.value = null |
| | | traceEvents.value = [] |
| | | expandedTraceIds.value = [] |
| | | await loadRuntime(targetSessionId, selectedAiParamId.value) |
| | | } |
| | | |
| | | async function handleModelChange(nextAiParamId) { |
| | | if (streaming.value) { |
| | | return |
| | | } |
| | | const previousAiParamId = selectedAiParamId.value |
| | | selectedAiParamId.value = nextAiParamId |
| | | const data = await loadRuntime(sessionId.value, nextAiParamId) |
| | | if (!data) { |
| | | selectedAiParamId.value = previousAiParamId |
| | | ElMessage.error(t('ai.drawer.modelSwitchFailed')) |
| | | } |
| | | } |
| | | |
| | | async function handleDeleteSession(targetSessionId) { |
| | | if (streaming.value || !targetSessionId) { |
| | | return |
| | | } |
| | | try { |
| | | await fetchRemoveAiSession(targetSessionId) |
| | | ElMessage.success(t('ai.drawer.sessionDeleted')) |
| | | if (targetSessionId === sessionId.value) { |
| | | await startNewSession() |
| | | return |
| | | } |
| | | await loadSessions() |
| | | } catch (error) { |
| | | drawerError.value = error?.message || t('ai.drawer.deleteSessionFailed') |
| | | ElMessage.error(drawerError.value) |
| | | } |
| | | } |
| | | |
| | | async function handlePinSession(targetSessionId, pinned) { |
| | | if (streaming.value || !targetSessionId) { |
| | | return |
| | | } |
| | | try { |
| | | await fetchPinAiSession(targetSessionId, pinned) |
| | | ElMessage.success(t(pinned ? 'ai.drawer.pinned' : 'ai.drawer.unpinned')) |
| | | await loadSessions() |
| | | } catch (error) { |
| | | drawerError.value = error?.message || t('ai.drawer.pinFailed') |
| | | ElMessage.error(drawerError.value) |
| | | } |
| | | } |
| | | |
| | | function handleSessionAction(item, action) { |
| | | if (!item?.sessionId || !action?.key) { |
| | | return |
| | | } |
| | | if (action.key === 'pin') { |
| | | void handlePinSession(item.sessionId, true) |
| | | return |
| | | } |
| | | if (action.key === 'unpin') { |
| | | void handlePinSession(item.sessionId, false) |
| | | return |
| | | } |
| | | if (action.key === 'rename') { |
| | | openRenameDialog(item) |
| | | return |
| | | } |
| | | if (action.key === 'delete') { |
| | | void handleDeleteSession(item.sessionId) |
| | | } |
| | | } |
| | | |
| | | function openRenameDialog(item) { |
| | | renameDialog.open = true |
| | | renameDialog.sessionId = item?.sessionId || null |
| | | renameDialog.title = item?.title || '' |
| | | } |
| | | |
| | | function closeRenameDialog() { |
| | | renameDialog.open = false |
| | | renameDialog.sessionId = null |
| | | renameDialog.title = '' |
| | | } |
| | | |
| | | async function handleRenameSubmit() { |
| | | if (streaming.value || !renameDialog.sessionId) { |
| | | return |
| | | } |
| | | try { |
| | | await fetchRenameAiSession(renameDialog.sessionId, renameDialog.title.trim()) |
| | | ElMessage.success(t('ai.drawer.renamed')) |
| | | closeRenameDialog() |
| | | await loadSessions() |
| | | } catch (error) { |
| | | drawerError.value = error?.message || t('ai.drawer.renameFailed') |
| | | ElMessage.error(drawerError.value) |
| | | } |
| | | } |
| | | |
| | | async function handleClearMemory() { |
| | | if (streaming.value || !sessionId.value) { |
| | | return |
| | | } |
| | | try { |
| | | await fetchClearAiSessionMemory(sessionId.value) |
| | | ElMessage.success(t('ai.drawer.memoryCleared')) |
| | | await Promise.all([loadRuntime(sessionId.value, selectedAiParamId.value), loadSessions()]) |
| | | } catch (error) { |
| | | drawerError.value = error?.message || t('ai.drawer.clearMemoryFailed') |
| | | ElMessage.error(drawerError.value) |
| | | } |
| | | } |
| | | |
| | | async function handleRetainLatestRound() { |
| | | if (streaming.value || !sessionId.value) { |
| | | return |
| | | } |
| | | try { |
| | | await fetchRetainAiSessionLatestRound(sessionId.value) |
| | | ElMessage.success(t('ai.drawer.retainLatestRoundSuccess')) |
| | | await Promise.all([loadRuntime(sessionId.value, selectedAiParamId.value), loadSessions()]) |
| | | } catch (error) { |
| | | drawerError.value = error?.message || t('ai.drawer.retainLatestRoundFailed') |
| | | ElMessage.error(drawerError.value) |
| | | } |
| | | } |
| | | |
| | | function stopStream(showTip = true) { |
| | | if (!abortController.value) { |
| | | return |
| | | } |
| | | abortController.value.abort() |
| | | abortController.value = null |
| | | streaming.value = false |
| | | if (showTip) { |
| | | ElMessage.success(t('ai.drawer.stopSuccess')) |
| | | } |
| | | } |
| | | |
| | | function scrollMessagesToBottom() { |
| | | if (!messagesBottomRef.value) { |
| | | return |
| | | } |
| | | messagesBottomRef.value.scrollIntoView({ behavior: 'smooth', block: 'end' }) |
| | | } |
| | | |
| | | function appendAssistantDelta(delta) { |
| | | messages.value = (() => { |
| | | const next = [...messages.value] |
| | | const last = next[next.length - 1] |
| | | if (last && last.role === 'assistant') { |
| | | next[next.length - 1] = { |
| | | ...last, |
| | | content: `${last.content || ''}${delta}` |
| | | } |
| | | return next |
| | | } |
| | | next.push({ role: 'assistant', content: delta }) |
| | | return next |
| | | })() |
| | | } |
| | | |
| | | function ensureAssistantPlaceholder(seedMessages) { |
| | | const next = [...seedMessages] |
| | | const last = next[next.length - 1] |
| | | if (!last || last.role !== 'assistant') { |
| | | next.push({ role: 'assistant', content: '' }) |
| | | } |
| | | return next |
| | | } |
| | | |
| | | function appendTraceEvent(payload) { |
| | | if (!payload?.traceId) { |
| | | return |
| | | } |
| | | const index = traceEvents.value.findIndex((item) => item.traceId === payload.traceId) |
| | | if (index < 0) { |
| | | traceEvents.value = [...traceEvents.value, payload].sort( |
| | | (left, right) => (left?.sequence ?? 0) - (right?.sequence ?? 0) |
| | | ) |
| | | return |
| | | } |
| | | const next = [...traceEvents.value] |
| | | next[index] = { ...next[index], ...payload } |
| | | traceEvents.value = next |
| | | } |
| | | |
| | | function toggleTraceEventExpanded(traceId) { |
| | | if (!traceId) { |
| | | return |
| | | } |
| | | expandedTraceIds.value = expandedTraceIds.value.includes(traceId) |
| | | ? expandedTraceIds.value.filter((item) => item !== traceId) |
| | | : [...expandedTraceIds.value, traceId] |
| | | } |
| | | |
| | | function getThinkingStatusLabel(status) { |
| | | if (status === 'COMPLETED') return t('ai.drawer.thinkingStatusCompleted') |
| | | if (status === 'FAILED') return t('ai.drawer.thinkingStatusFailed') |
| | | if (status === 'ABORTED') return t('ai.drawer.thinkingStatusAborted') |
| | | if (status === 'UPDATED') return t('ai.drawer.thinkingStatusUpdated') |
| | | return t('ai.drawer.thinkingStatusStarted') |
| | | } |
| | | |
| | | function getToolStatusLabel(status) { |
| | | if (status === 'FAILED') return t('ai.drawer.toolStatusFailed') |
| | | if (status === 'COMPLETED') return t('ai.drawer.toolStatusCompleted') |
| | | return t('ai.drawer.toolStatusRunning') |
| | | } |
| | | |
| | | async function handleSend() { |
| | | const content = input.value.trim() |
| | | if (!content || streaming.value) { |
| | | return |
| | | } |
| | | |
| | | const nextMessages = [...messages.value, { role: 'user', content }] |
| | | input.value = '' |
| | | usage.value = null |
| | | drawerError.value = '' |
| | | traceEvents.value = [] |
| | | expandedTraceIds.value = [] |
| | | messages.value = ensureAssistantPlaceholder(nextMessages) |
| | | streaming.value = true |
| | | |
| | | const controller = new AbortController() |
| | | abortController.value = controller |
| | | |
| | | let completed = false |
| | | let completedSessionId = sessionId.value |
| | | let completedAiParamId = selectedAiParamId.value |
| | | |
| | | try { |
| | | await fetchStreamAiChat( |
| | | { |
| | | sessionId: sessionId.value, |
| | | aiParamId: selectedAiParamId.value, |
| | | promptCode: runtime.value?.promptCode || DEFAULT_PROMPT_CODE, |
| | | messages: [{ role: 'user', content }], |
| | | metadata: { |
| | | path: route.fullPath |
| | | } |
| | | }, |
| | | { |
| | | signal: controller.signal, |
| | | onEvent: (eventName, payload) => { |
| | | if (eventName === 'start') { |
| | | runtime.value = payload |
| | | selectedAiParamId.value = payload?.aiParamId ?? selectedAiParamId.value |
| | | if (payload?.sessionId) { |
| | | sessionId.value = payload.sessionId |
| | | completedSessionId = payload.sessionId |
| | | } |
| | | completedAiParamId = payload?.aiParamId ?? completedAiParamId |
| | | } |
| | | if (eventName === 'delta') { |
| | | appendAssistantDelta(payload?.content || '') |
| | | } |
| | | if (eventName === 'trace') { |
| | | appendTraceEvent(payload) |
| | | } |
| | | if (eventName === 'done') { |
| | | usage.value = payload |
| | | completed = true |
| | | if (payload?.sessionId) { |
| | | completedSessionId = payload.sessionId |
| | | } |
| | | } |
| | | if (eventName === 'error') { |
| | | const message = payload?.message || t('ai.drawer.chatFailed') |
| | | drawerError.value = payload?.requestId ? `${message} [${payload.requestId}]` : message |
| | | ElMessage.error(drawerError.value) |
| | | } |
| | | } |
| | | } |
| | | ) |
| | | } catch (error) { |
| | | if (error?.name !== 'AbortError') { |
| | | drawerError.value = error?.message || t('ai.drawer.chatFailed') |
| | | ElMessage.error(drawerError.value) |
| | | } |
| | | } finally { |
| | | abortController.value = null |
| | | streaming.value = false |
| | | if (completed) { |
| | | await Promise.all([ |
| | | loadRuntime(completedSessionId, completedAiParamId), |
| | | loadSessions() |
| | | ]) |
| | | } |
| | | } |
| | | } |
| | | |
| | | function handleInputKeydown(event) { |
| | | if (event.key === 'Enter' && !event.shiftKey) { |
| | | event.preventDefault() |
| | | void handleSend() |
| | | } |
| | | } |
| | | |
| | | function navigateTo(path) { |
| | | closeChat() |
| | | void router.push(path) |
| | | } |
| | | |
| | | function normalizeMessageList(list) { |
| | | if (!Array.isArray(list)) { |
| | | return [] |
| | | } |
| | | return list |
| | | .filter((item) => item && ['user', 'assistant'].includes(item.role)) |
| | | .map((item) => ({ |
| | | role: item.role, |
| | | content: item.content || '' |
| | | })) |
| | | } |
| | | |
| | | function formatModelOption(item) { |
| | | const label = item.name || item.model || '--' |
| | | const suffix = |
| | | item.model && item.name && item.name !== item.model ? ` / ${item.model}` : '' |
| | | const defaultMark = item.active ? ` ${t('ai.drawer.defaultModelSuffix')}` : '' |
| | | return `${label}${suffix}${defaultMark}` |
| | | } |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .ai-chat-body { |
| | | display: flex; |
| | | } |
| | | |
| | | .ai-chat-sidebar { |
| | | width: 320px; |
| | | } |
| | | |
| | | :deep(.ai-chat-drawer .el-drawer__body) { |
| | | padding: 0; |
| | | } |
| | | |
| | | :deep(.ai-chat-drawer .el-drawer) { |
| | | overflow: hidden; |
| | | } |
| | | |
| | | :deep(.ai-chat-drawer .el-input__wrapper), |
| | | :deep(.ai-chat-drawer .el-textarea__inner), |
| | | :deep(.ai-chat-drawer .el-select__wrapper) { |
| | | box-shadow: none !important; |
| | | border: 1px solid var(--el-border-color-lighter); |
| | | border-radius: 16px; |
| | | } |
| | | |
| | | :deep(.ai-chat-drawer .el-input__wrapper:hover), |
| | | :deep(.ai-chat-drawer .el-textarea__inner:hover), |
| | | :deep(.ai-chat-drawer .el-select__wrapper:hover) { |
| | | border-color: var(--el-color-primary-light-8); |
| | | } |
| | | |
| | | :deep(.ai-chat-drawer .el-input__wrapper.is-focus), |
| | | :deep(.ai-chat-drawer .el-select__wrapper.is-focused) { |
| | | border-color: var(--el-color-primary-light-7); |
| | | box-shadow: 0 0 0 3px var(--el-color-primary-light-9) !important; |
| | | } |
| | | |
| | | :deep(.ai-chat-drawer .el-textarea__inner), |
| | | :deep(.ai-chat-drawer .el-select__wrapper), |
| | | :deep(.ai-chat-drawer .el-input__wrapper) { |
| | | background: var(--art-main-bg-color); |
| | | } |
| | | |
| | | @media screen and (max-width: 768px) { |
| | | .ai-chat-body { |
| | | flex-direction: column; |
| | | } |
| | | |
| | | .ai-chat-sidebar { |
| | | width: 100%; |
| | | max-height: 280px; |
| | | border-right: 0; |
| | | border-bottom: 1px solid var(--art-border-color); |
| | | } |
| | | } |
| | | </style> |