From df8467bc8891af847802cc6aee501c18d50f451a Mon Sep 17 00:00:00 2001
From: zhou zhou <3272660260@qq.com>
Date: 星期二, 31 三月 2026 16:32:26 +0800
Subject: [PATCH] #前端

---
 rsf-design/src/views/system/ai-mcp-mount/index.vue                             |   64 +
 rsf-design/src/api/ai-chat.js                                                  |  153 +++++
 rsf-design/src/plugins/iconify.collections.js                                  |   12 
 rsf-design/src/views/manager/loc-preview/modules/loc-preview-detail-drawer.vue |    4 
 rsf-design/src/views/abnormal/index.vue                                        |   42 +
 rsf-design/src/components/core/layouts/art-chat-window/index.vue               | 1218 +++++++++++++++++++++++++++++++++++------
 rsf-design/src/locales/langs/en.json                                           |   85 ++
 rsf-design/src/views/system/role/rolePage.helpers.js                           |   38 
 rsf-design/src/locales/langs/zh.json                                           |   85 ++
 rsf-design/src/views/system/role/index.vue                                     |   24 
 10 files changed, 1,466 insertions(+), 259 deletions(-)

diff --git a/rsf-design/src/api/ai-chat.js b/rsf-design/src/api/ai-chat.js
new file mode 100644
index 0000000..5b6b8df
--- /dev/null
+++ b/rsf-design/src/api/ai-chat.js
@@ -0,0 +1,153 @@
+import request from '@/utils/http'
+import { useUserStore } from '@/store/modules/user'
+
+const DEFAULT_PROMPT_CODE = 'home.default'
+
+function buildRuntimeParams(promptCode = DEFAULT_PROMPT_CODE, sessionId = null, aiParamId = null) {
+  return {
+    promptCode,
+    ...(sessionId ? { sessionId } : {}),
+    ...(aiParamId !== null && aiParamId !== undefined ? { aiParamId } : {})
+  }
+}
+
+function fetchGetAiRuntime(promptCode = DEFAULT_PROMPT_CODE, sessionId = null, aiParamId = null) {
+  return request.get({
+    url: '/ai/chat/runtime',
+    params: buildRuntimeParams(promptCode, sessionId, aiParamId),
+    showErrorMessage: false
+  })
+}
+
+function fetchGetAiSessions(promptCode = DEFAULT_PROMPT_CODE, keyword = '') {
+  return request.get({
+    url: '/ai/chat/sessions',
+    params: {
+      promptCode,
+      ...(keyword ? { keyword } : {})
+    },
+    showErrorMessage: false
+  })
+}
+
+function fetchRemoveAiSession(sessionId) {
+  return request.post({
+    url: `/ai/chat/session/remove/${sessionId}`,
+    showErrorMessage: false
+  })
+}
+
+function fetchRenameAiSession(sessionId, title) {
+  return request.post({
+    url: `/ai/chat/session/rename/${sessionId}`,
+    params: { title },
+    showErrorMessage: false
+  })
+}
+
+function fetchPinAiSession(sessionId, pinned) {
+  return request.post({
+    url: `/ai/chat/session/pin/${sessionId}`,
+    params: { pinned },
+    showErrorMessage: false
+  })
+}
+
+function fetchClearAiSessionMemory(sessionId) {
+  return request.post({
+    url: `/ai/chat/session/memory/clear/${sessionId}`,
+    showErrorMessage: false
+  })
+}
+
+function fetchRetainAiSessionLatestRound(sessionId) {
+  return request.post({
+    url: `/ai/chat/session/memory/retain-latest/${sessionId}`,
+    showErrorMessage: false
+  })
+}
+
+async function fetchStreamAiChat(payload, { signal, onEvent } = {}) {
+  const { VITE_API_URL } = import.meta.env
+  const { accessToken } = useUserStore()
+  const response = await fetch(`${VITE_API_URL}/ai/chat/stream`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Accept: 'text/event-stream',
+      ...(accessToken ? { Authorization: accessToken } : {})
+    },
+    body: JSON.stringify(payload),
+    signal
+  })
+
+  if (!response.ok) {
+    throw new Error(`AI 璇锋眰澶辫触 (${response.status})`)
+  }
+
+  if (!response.body) {
+    throw new Error('AI 鍝嶅簲娴佷笉鍙敤')
+  }
+
+  const reader = response.body.getReader()
+  const decoder = new TextDecoder('utf-8')
+  let buffer = ''
+
+  while (true) {
+    const { done, value } = await reader.read()
+    if (done) {
+      break
+    }
+    buffer += decoder.decode(value, { stream: true })
+    const events = buffer.split(/\r?\n\r?\n/)
+    buffer = events.pop() || ''
+    events.forEach((item) => dispatchSseEvent(item, onEvent))
+  }
+
+  if (buffer.trim()) {
+    dispatchSseEvent(buffer, onEvent)
+  }
+}
+
+function dispatchSseEvent(rawEvent, onEvent) {
+  if (!onEvent) {
+    return
+  }
+
+  const lines = rawEvent.split(/\r?\n/)
+  let eventName = 'message'
+  const dataLines = []
+
+  lines.forEach((line) => {
+    if (line.startsWith('event:')) {
+      eventName = line.slice(6).trim()
+    }
+    if (line.startsWith('data:')) {
+      dataLines.push(line.slice(5).trim())
+    }
+  })
+
+  if (!dataLines.length) {
+    return
+  }
+
+  const rawData = dataLines.join('\n')
+  let payload = rawData
+  try {
+    payload = JSON.parse(rawData)
+  } catch {
+  }
+  onEvent(eventName, payload)
+}
+
+export {
+  DEFAULT_PROMPT_CODE,
+  fetchClearAiSessionMemory,
+  fetchGetAiRuntime,
+  fetchGetAiSessions,
+  fetchPinAiSession,
+  fetchRemoveAiSession,
+  fetchRenameAiSession,
+  fetchRetainAiSessionLatestRound,
+  fetchStreamAiChat
+}
diff --git a/rsf-design/src/components/core/layouts/art-chat-window/index.vue b/rsf-design/src/components/core/layouts/art-chat-window/index.vue
index 01d78c8..10efb89 100644
--- a/rsf-design/src/components/core/layouts/art-chat-window/index.vue
+++ b/rsf-design/src/components/core/layouts/art-chat-window/index.vue
@@ -1,228 +1,1056 @@
-<!-- 绯荤粺鑱婂ぉ绐楀彛 -->
 <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: '浣犲ソ锛佹垜鏄綘鐨凙I鍔╂墜锛屾湁浠�涔堟垜鍙互甯綘鐨勫悧锛�',
-      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: '澶ソ浜嗭紝閭f垜濡備綍寮�濮嬩娇鐢ㄥ憿锛�',
-      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>
diff --git a/rsf-design/src/locales/langs/en.json b/rsf-design/src/locales/langs/en.json
index 6a5f6f8..8572b24 100644
--- a/rsf-design/src/locales/langs/en.json
+++ b/rsf-design/src/locales/langs/en.json
@@ -389,6 +389,91 @@
     "taskPathTemplateMerge": "TaskPathTemplateMerge",
     "missionFlowStepInstance": "Mission Flow Steps"
   },
+  "ai": {
+    "drawer": {
+      "title": "WMS Assistant",
+      "runtimeFailed": "Failed to load AI runtime",
+      "sessionListFailed": "Failed to load AI sessions",
+      "sessionDeleted": "Session deleted",
+      "deleteSessionFailed": "Failed to delete AI session",
+      "pinned": "Session pinned",
+      "unpinned": "Session unpinned",
+      "pinFailed": "Failed to update session pin state",
+      "renamed": "Session renamed",
+      "renameFailed": "Failed to rename session",
+      "memoryCleared": "Session memory cleared",
+      "clearMemoryFailed": "Failed to clear session memory",
+      "retainLatestRoundSuccess": "Only the latest round was kept",
+      "retainLatestRoundFailed": "Failed to keep only the latest round",
+      "stopSuccess": "Current output stopped",
+      "chatFailed": "AI chat failed",
+      "newSession": "New Session",
+      "sessionList": "Sessions",
+      "searchPlaceholder": "Search session titles",
+      "noSessions": "No history sessions",
+      "sessionTitle": "Session %{id}",
+      "pinAction": "Pin session",
+      "unpinAction": "Unpin session",
+      "renameAction": "Rename session",
+      "deleteAction": "Delete session",
+      "activityTrace": "Thinking & Tool Trace",
+      "thinkingEmpty": "Organizing the current stage information...",
+      "thinkingStatusStarted": "Started",
+      "thinkingStatusUpdated": "In Progress",
+      "thinkingStatusCompleted": "Completed",
+      "thinkingStatusFailed": "Failed",
+      "thinkingStatusAborted": "Aborted",
+      "unknownTool": "Unknown tool",
+      "traceTypeThinking": "Thinking",
+      "traceTypeTool": "Tool",
+      "toolStatusFailed": "Failed",
+      "toolStatusCompleted": "Completed",
+      "toolStatusRunning": "Running",
+      "collapseDetail": "Hide Details",
+      "viewDetail": "View Details",
+      "toolInput": "Input: %{value}",
+      "toolOutput": "Output summary: %{value}",
+      "toolError": "Error: %{value}",
+      "hasSummary": "Summary",
+      "noSummary": "No Summary",
+      "hasFacts": "Facts",
+      "noFacts": "No Facts",
+      "retainLatestRound": "Keep Latest Round",
+      "clearMemory": "Clear Memory",
+      "runtimeOverview": "Runtime Overview",
+      "runtimeExpand": "Show Overview",
+      "runtimeCollapse": "Hide Overview",
+      "loadingRuntime": "Loading AI runtime info...",
+      "emptyHint": "AI responses stream back through SSE here. You can also maintain parameters, prompts, and MCP mounts from the quick links above.",
+      "userRole": "You",
+      "assistantRole": "AI",
+      "thinking": "Thinking...",
+      "inputPlaceholder": "Type your question. Press Enter to send, Shift + Enter for a new line",
+      "clearInput": "Clear Input",
+      "stop": "Stop",
+      "send": "Send",
+      "renameDialogTitle": "Rename Session",
+      "sessionTitleField": "Session Title",
+        "requestMetric": "Req: %{value}",
+        "sessionMetric": "Session: %{id}",
+        "promptMetric": "Prompt: %{value}",
+        "modelMetric": "Model: %{value}",
+        "promptLabel": "Prompt",
+        "modelLabel": "Model",
+        "modelSelectorLabel": "Chat Model",
+        "modelSelectorHint": "Switching only affects subsequent replies in this session and does not change the global default model.",
+        "modelSwitchFailed": "Failed to switch the chat model",
+        "defaultModelSuffix": "(Default)",
+        "mcpMetric": "MCP: %{value}",
+        "historyMetric": "History: %{value}",
+        "mcpLabel": "MCP",
+        "historyLabel": "History",
+        "recentMetric": "Recent: %{value}",
+      "elapsedMetric": "Elapsed: %{value} ms",
+      "firstTokenMetric": "First token: %{value} ms",
+      "tokenMetric": "Tokens: prompt %{prompt} / completion %{completion} / total %{total}"
+    }
+  },
   "table": {
     "form": {
       "reset": "Reset",
diff --git a/rsf-design/src/locales/langs/zh.json b/rsf-design/src/locales/langs/zh.json
index ba38b1d..716e9cd 100644
--- a/rsf-design/src/locales/langs/zh.json
+++ b/rsf-design/src/locales/langs/zh.json
@@ -391,6 +391,91 @@
     "taskPathTemplateMerge": "浠诲姟璺緞妯℃澘鍚堝苟",
     "missionFlowStepInstance": "浠诲姟娴佺▼姝ラ"
   },
+  "ai": {
+    "drawer": {
+      "title": "WMS 鍔╂墜",
+      "runtimeFailed": "鑾峰彇 AI 杩愯鏃跺け璐�",
+      "sessionListFailed": "鑾峰彇 AI 浼氳瘽鍒楄〃澶辫触",
+      "sessionDeleted": "浼氳瘽宸插垹闄�",
+      "deleteSessionFailed": "鍒犻櫎 AI 浼氳瘽澶辫触",
+      "pinned": "浼氳瘽宸茬疆椤�",
+      "unpinned": "浼氳瘽宸插彇娑堢疆椤�",
+      "pinFailed": "鏇存柊浼氳瘽缃《鐘舵�佸け璐�",
+      "renamed": "浼氳瘽宸查噸鍛藉悕",
+      "renameFailed": "閲嶅懡鍚嶄細璇濆け璐�",
+      "memoryCleared": "浼氳瘽璁板繂宸叉竻绌�",
+      "clearMemoryFailed": "娓呯┖浼氳瘽璁板繂澶辫触",
+      "retainLatestRoundSuccess": "宸蹭粎淇濈暀褰撳墠杞蹇�",
+      "retainLatestRoundFailed": "淇濈暀褰撳墠杞蹇嗗け璐�",
+      "stopSuccess": "宸插仠姝㈠綋鍓嶅璇濊緭鍑�",
+      "chatFailed": "AI 瀵硅瘽澶辫触",
+      "newSession": "鏂板缓浼氳瘽",
+      "sessionList": "浼氳瘽鍒楄〃",
+      "searchPlaceholder": "鎼滅储浼氳瘽鏍囬",
+      "noSessions": "鏆傛棤鍘嗗彶浼氳瘽",
+      "sessionTitle": "浼氳瘽 %{id}",
+      "pinAction": "缃《浼氳瘽",
+      "unpinAction": "鍙栨秷缃《",
+      "renameAction": "閲嶅懡鍚嶄細璇�",
+      "deleteAction": "鍒犻櫎浼氳瘽",
+      "activityTrace": "鎬濈淮閾句笌宸ュ叿杞ㄨ抗",
+      "thinkingEmpty": "姝e湪鏁寸悊褰撳墠闃舵淇℃伅...",
+      "thinkingStatusStarted": "宸插紑濮�",
+      "thinkingStatusUpdated": "杩涜涓�",
+      "thinkingStatusCompleted": "宸插畬鎴�",
+      "thinkingStatusFailed": "澶辫触",
+      "thinkingStatusAborted": "宸蹭腑姝�",
+      "unknownTool": "鏈煡宸ュ叿",
+      "traceTypeThinking": "鎬濈淮閾�",
+      "traceTypeTool": "宸ュ叿",
+      "toolStatusFailed": "澶辫触",
+      "toolStatusCompleted": "瀹屾垚",
+      "toolStatusRunning": "鎵ц涓�",
+      "collapseDetail": "鏀惰捣璇︽儏",
+      "viewDetail": "鏌ョ湅璇︽儏",
+      "toolInput": "鍏ュ弬: %{value}",
+      "toolOutput": "缁撴灉鎽樿: %{value}",
+      "toolError": "閿欒: %{value}",
+      "hasSummary": "鏈夋憳瑕�",
+      "noSummary": "鏃犳憳瑕�",
+      "hasFacts": "鏈変簨瀹�",
+      "noFacts": "鏃犱簨瀹�",
+      "retainLatestRound": "浠呬繚鐣欏綋鍓嶈疆",
+      "clearMemory": "娓呯┖璁板繂",
+      "runtimeOverview": "杩愯姒傝",
+      "runtimeExpand": "灞曞紑姒傝",
+      "runtimeCollapse": "鏀惰捣姒傝",
+      "loadingRuntime": "姝e湪鍔犺浇 AI 杩愯鏃朵俊鎭�...",
+      "emptyHint": "杩欓噷浼氶�氳繃 SSE 娴佸紡杩斿洖 AI 鍥炲銆備綘涔熷彲浠ュ厛鍘讳笂闈㈢殑蹇嵎鍏ュ彛缁存姢鍙傛暟銆丳rompt 鍜� MCP 鎸傝浇銆�",
+      "userRole": "浣�",
+      "assistantRole": "AI",
+      "thinking": "鎬濊�冧腑...",
+      "inputPlaceholder": "杈撳叆浣犵殑闂锛屾寜 Enter 鍙戦�侊紝Shift + Enter 鎹㈣",
+      "clearInput": "娓呯┖杈撳叆",
+      "stop": "鍋滄",
+      "send": "鍙戦��",
+      "renameDialogTitle": "閲嶅懡鍚嶄細璇�",
+      "sessionTitleField": "浼氳瘽鏍囬",
+        "requestMetric": "Req: %{value}",
+        "sessionMetric": "Session: %{id}",
+        "promptMetric": "Prompt: %{value}",
+        "modelMetric": "Model: %{value}",
+        "promptLabel": "Prompt",
+        "modelLabel": "Model",
+        "modelSelectorLabel": "瀵硅瘽妯″瀷",
+        "modelSelectorHint": "鍒囨崲鍚庝粎褰卞搷褰撳墠浼氳瘽鍚庣画鍥炲锛屼笉浼氭敼鍔ㄥ叏灞�榛樿妯″瀷銆�",
+        "modelSwitchFailed": "鍒囨崲瀵硅瘽妯″瀷澶辫触",
+        "defaultModelSuffix": "(榛樿)",
+        "mcpMetric": "MCP: %{value}",
+        "historyMetric": "History: %{value}",
+        "mcpLabel": "MCP",
+        "historyLabel": "History",
+        "recentMetric": "Recent: %{value}",
+      "elapsedMetric": "鑰楁椂: %{value} ms",
+      "firstTokenMetric": "棣栧寘: %{value} ms",
+      "tokenMetric": "Tokens: prompt %{prompt} / completion %{completion} / total %{total}"
+    }
+  },
   "table": {
     "form": {
       "reset": "閲嶇疆",
diff --git a/rsf-design/src/plugins/iconify.collections.js b/rsf-design/src/plugins/iconify.collections.js
index a56d4b7..d729dbb 100644
--- a/rsf-design/src/plugins/iconify.collections.js
+++ b/rsf-design/src/plugins/iconify.collections.js
@@ -112,6 +112,9 @@
       'book-2-line': {
         body: '<path fill="currentColor" d="M21 18H6a1 1 0 1 0 0 2h15v2H6a3 3 0 0 1-3-3V4a2 2 0 0 1 2-2h16zM5 16.05q.243-.05.5-.05H19V4H5zM16 9H8V7h8z"/>'
       },
+      'chat-1-line': {
+        body: '<path fill="currentColor" d="M10 3h4a8 8 0 1 1 0 16v3.5c-5-2-12-5-12-11.5a8 8 0 0 1 8-8m2 14h2a6 6 0 0 0 0-12h-4a6 6 0 0 0-6 6c0 3.61 2.462 5.966 8 8.48z"/>'
+      },
       'check-fill': {
         body: '<path fill="currentColor" d="m10 15.17l9.192-9.191l1.414 1.414L10 17.999l-6.364-6.364l1.414-1.414z"/>'
       },
@@ -163,9 +166,6 @@
       'edit-2-line': {
         body: '<path fill="currentColor" d="M5 18.89h1.414l9.314-9.314l-1.414-1.414L5 17.476zm16 2H3v-4.243L16.435 3.212a1 1 0 0 1 1.414 0l2.829 2.829a1 1 0 0 1 0 1.414L9.243 18.89H21zM15.728 6.748l1.414 1.414l1.414-1.414l-1.414-1.414z"/>'
       },
-      'emotion-happy-line': {
-        body: '<path fill="currentColor" d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10m0-2a8 8 0 1 0 0-16a8 8 0 0 0 0 16m-5-7h2a3 3 0 1 0 6 0h2a5 5 0 0 1-10 0m1-2a1.5 1.5 0 1 1 0-3a1.5 1.5 0 0 1 0 3m8 0a1.5 1.5 0 1 1 0-3a1.5 1.5 0 0 1 0 3"/>'
-      },
       'error-warning-line': {
         body: '<path fill="currentColor" d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10s-4.477 10-10 10m0-2a8 8 0 1 0 0-16a8 8 0 0 0 0 16m-1-5h2v2h-2zm0-8h2v6h-2z"/>'
       },
@@ -210,9 +210,6 @@
       },
       'home-smile-2-line': {
         body: '<path fill="currentColor" d="M19 19V9.799l-7-5.522l-7 5.522V19zm2 1a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9.314a1 1 0 0 1 .38-.785l8-6.311a1 1 0 0 1 1.24 0l8 6.31a1 1 0 0 1 .38.786zM7 12h2a3 3 0 1 0 6 0h2a5 5 0 0 1-10 0"/>'
-      },
-      'image-line': {
-        body: '<path fill="currentColor" d="M2.992 21A.993.993 0 0 1 2 20.007V3.993A1 1 0 0 1 2.992 3h18.016c.548 0 .992.445.992.993v16.014a1 1 0 0 1-.992.993zM20 15V5H4v14L14 9zm0 2.828l-6-6L6.828 19H20zM8 11a2 2 0 1 1 0-4a2 2 0 0 1 0 4"/>'
       },
       'key-2-line': {
         body: '<path fill="currentColor" d="m10.758 11.828l7.849-7.849l1.414 1.414l-1.414 1.415l2.474 2.474l-1.414 1.415l-2.475-2.475l-1.414 1.414l2.121 2.121l-1.414 1.415l-2.121-2.122l-2.192 2.192a5.002 5.002 0 0 1-7.708 6.293a5 5 0 0 1 6.294-7.707m-.637 6.293A3 3 0 1 0 5.88 13.88a3 3 0 0 0 4.242 4.242"/>'
@@ -301,6 +298,9 @@
       'pulse-line': {
         body: '<path fill="currentColor" d="m9 7.539l6 14L18.66 13H23v-2h-5.66L15 16.461l-6-14L5.34 11H1v2h5.66z"/>'
       },
+      'pushpin-2-fill': {
+        body: '<path fill="currentColor" d="M18 3v2h-1v6l2 3v2h-6v7h-2v-7H5v-2l2-3V5H6V3z"/>'
+      },
       'pushpin-2-line': {
         body: '<path fill="currentColor" d="M18 3v2h-1v6l2 3v2h-6v7h-2v-7H5v-2l2-3V5H6V3zM9 5v6.606L7.404 14h9.192L15 11.606V5z"/>'
       },
diff --git a/rsf-design/src/views/abnormal/index.vue b/rsf-design/src/views/abnormal/index.vue
index c6bdf38..46e14a1 100644
--- a/rsf-design/src/views/abnormal/index.vue
+++ b/rsf-design/src/views/abnormal/index.vue
@@ -9,13 +9,20 @@
       >
         <div class="min-w-0 pr-6">
           <p class="text-sm font-medium text-g-700">{{ item.title }}</p>
-          <ArtCountTo class="mt-3 block text-[2.3rem] font-semibold leading-none text-g-900" :target="item.total" :duration="1200" />
+          <ArtCountTo
+            class="mt-3 block text-[2.3rem] font-semibold leading-none text-g-900"
+            :target="item.total"
+            :duration="1200"
+          />
           <div class="mt-4 flex items-center gap-2 text-sm">
             <span class="text-g-500">{{ item.badgeText }}</span>
             <span class="text-g-600">{{ item.subtitle }}</span>
           </div>
         </div>
-        <div class="flex size-13 shrink-0 items-center justify-center rounded-2xl" :class="item.iconBoxClass">
+        <div
+          class="flex size-13 shrink-0 items-center justify-center rounded-2xl"
+          :class="item.iconBoxClass"
+        >
           <ArtSvgIcon :icon="item.icon" class="text-2xl" :class="item.iconClass" />
         </div>
       </div>
@@ -39,11 +46,15 @@
             @click="navigateTo(item.route)"
           >
             <div class="flex min-w-0 items-center gap-3">
-              <div class="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-[var(--el-color-primary-light-9)]">
+              <div
+                class="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-[var(--el-color-primary-light-9)]"
+              >
                 <ArtSvgIcon :icon="item.icon" class="text-xl text-[var(--el-color-primary)]" />
               </div>
               <div class="min-w-0">
-                <p class="truncate text-sm font-medium text-[var(--art-gray-900)]">{{ item.title }}</p>
+                <p class="truncate text-sm font-medium text-[var(--art-gray-900)]">{{
+                  item.title
+                }}</p>
                 <p class="mt-1 truncate text-xs text-g-500">{{ item.description }}</p>
               </div>
             </div>
@@ -76,7 +87,9 @@
               </div>
               <div class="min-w-0 flex-1">
                 <div class="flex items-center gap-2">
-                  <p class="truncate text-base font-medium text-[var(--art-gray-900)]">{{ item.title }}</p>
+                  <p class="truncate text-base font-medium text-[var(--art-gray-900)]">{{
+                    item.title
+                  }}</p>
                   <ElTag size="small" effect="light" type="danger">{{ item.source }}</ElTag>
                 </div>
                 <p class="mt-2 text-sm text-g-600">{{ item.summary }}</p>
@@ -129,12 +142,19 @@
   async function loadOverview() {
     sectionLoading.summary = true
 
-    const [checkDiffResponse, qlyInspectResponse, freezeResponse, locReviseResponse] = await Promise.all([
-      withRequestGuard(fetchCheckDiffPage({ current: 1, pageSize: 5 }), { records: [], total: 0 }),
-      withRequestGuard(fetchQlyInspectPage({ current: 1, pageSize: 5 }), { records: [], total: 0 }),
-      withRequestGuard(fetchFreezePage({ current: 1, pageSize: 5 }), { records: [], total: 0 }),
-      withRequestGuard(fetchLocRevisePage({ current: 1, pageSize: 5 }), { records: [], total: 0 })
-    ])
+    const [checkDiffResponse, qlyInspectResponse, freezeResponse, locReviseResponse] =
+      await Promise.all([
+        withRequestGuard(fetchCheckDiffPage({ current: 1, pageSize: 5 }), {
+          records: [],
+          total: 0
+        }),
+        withRequestGuard(fetchQlyInspectPage({ current: 1, pageSize: 5 }), {
+          records: [],
+          total: 0
+        }),
+        withRequestGuard(fetchFreezePage({ current: 1, pageSize: 5 }), { records: [], total: 0 }),
+        withRequestGuard(fetchLocRevisePage({ current: 1, pageSize: 5 }), { records: [], total: 0 })
+      ])
 
     overviewState.value = {
       checkDiff: normalizeSummaryResponse(checkDiffResponse),
diff --git a/rsf-design/src/views/manager/loc-preview/modules/loc-preview-detail-drawer.vue b/rsf-design/src/views/manager/loc-preview/modules/loc-preview-detail-drawer.vue
index b9c3a65..b53a816 100644
--- a/rsf-design/src/views/manager/loc-preview/modules/loc-preview-detail-drawer.vue
+++ b/rsf-design/src/views/manager/loc-preview/modules/loc-preview-detail-drawer.vue
@@ -10,7 +10,9 @@
         <ElDescriptionsItem label="搴撲綅缂栫爜">{{ detail.locCode || '--' }}</ElDescriptionsItem>
         <ElDescriptionsItem label="浠撳簱">{{ detail.warehouseLabel || '--' }}</ElDescriptionsItem>
         <ElDescriptionsItem label="搴撳尯">{{ detail.areaLabel || '--' }}</ElDescriptionsItem>
-        <ElDescriptionsItem label="浣跨敤鐘舵��">{{ detail.useStatusLabel || '--' }}</ElDescriptionsItem>
+        <ElDescriptionsItem label="浣跨敤鐘舵��">{{
+          detail.useStatusLabel || '--'
+        }}</ElDescriptionsItem>
         <ElDescriptionsItem label="搴撲綅绫诲瀷">{{ detail.typeLabel || '--' }}</ElDescriptionsItem>
         <ElDescriptionsItem label="鏉$爜">{{ detail.barcode || '--' }}</ElDescriptionsItem>
         <ElDescriptionsItem label="鎺�">{{ detail.row ?? '--' }}</ElDescriptionsItem>
diff --git a/rsf-design/src/views/system/ai-mcp-mount/index.vue b/rsf-design/src/views/system/ai-mcp-mount/index.vue
index 9170481..66548a5 100644
--- a/rsf-design/src/views/system/ai-mcp-mount/index.vue
+++ b/rsf-design/src/views/system/ai-mcp-mount/index.vue
@@ -12,7 +12,9 @@
       <div class="mb-5 flex flex-wrap items-center justify-between gap-4">
         <div>
           <h3 class="text-lg font-semibold text-[var(--art-gray-900)]">MCP 鎸傝浇</h3>
-          <p class="mt-1 text-sm text-[var(--art-gray-500)]">鎸変紶杈撶被鍨嬬鐞� MCP 鎸傝浇銆佽繛閫氭�у拰宸ュ叿棰勮銆�</p>
+          <p class="mt-1 text-sm text-[var(--art-gray-500)]"
+            >鎸変紶杈撶被鍨嬬鐞� MCP 鎸傝浇銆佽繛閫氭�у拰宸ュ叿棰勮銆�</p
+          >
         </div>
 
         <ElSpace wrap>
@@ -39,12 +41,18 @@
               <div class="flex items-start justify-between gap-4">
                 <div class="min-w-0">
                   <div class="flex items-center gap-3">
-                    <div class="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600">
+                    <div
+                      class="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600"
+                    >
                       <ArtSvgIcon icon="ri:plug-2-line" class="text-xl" />
                     </div>
                     <div class="min-w-0">
-                      <h4 class="truncate text-base font-semibold text-[var(--art-gray-900)]">{{ item.name || '--' }}</h4>
-                      <p class="mt-1 truncate text-sm text-[var(--art-gray-500)]">{{ item.transportText }}</p>
+                      <h4 class="truncate text-base font-semibold text-[var(--art-gray-900)]">{{
+                        item.name || '--'
+                      }}</h4>
+                      <p class="mt-1 truncate text-sm text-[var(--art-gray-500)]">{{
+                        item.transportText
+                      }}</p>
                     </div>
                   </div>
                 </div>
@@ -56,20 +64,30 @@
               </div>
 
               <div class="mt-4 grid gap-3 text-sm sm:grid-cols-2">
-                <div class="rounded-2xl bg-[var(--art-main-bg-color)]/70 p-3 ring-1 ring-inset ring-[var(--art-border-color)]">
+                <div
+                  class="rounded-2xl bg-[var(--art-main-bg-color)]/70 p-3 ring-1 ring-inset ring-[var(--art-border-color)]"
+                >
                   <p class="text-xs text-[var(--art-gray-500)]">鐩爣鍦板潃</p>
-                  <p class="mt-2 break-all text-[var(--art-gray-900)]">{{ item.targetLabel || '--' }}</p>
+                  <p class="mt-2 break-all text-[var(--art-gray-900)]">{{
+                    item.targetLabel || '--'
+                  }}</p>
                 </div>
-                <div class="rounded-2xl bg-[var(--art-main-bg-color)]/70 p-3 ring-1 ring-inset ring-[var(--art-border-color)]">
+                <div
+                  class="rounded-2xl bg-[var(--art-main-bg-color)]/70 p-3 ring-1 ring-inset ring-[var(--art-border-color)]"
+                >
                   <p class="text-xs text-[var(--art-gray-500)]">鏈�杩戞祴璇�</p>
-                  <p class="mt-2 text-[var(--art-gray-900)]">{{ item['lastTestTime$'] || '鏈祴璇�' }}</p>
+                  <p class="mt-2 text-[var(--art-gray-900)]">{{
+                    item['lastTestTime$'] || '鏈祴璇�'
+                  }}</p>
                 </div>
               </div>
 
               <div class="mt-4 grid gap-3 text-sm sm:grid-cols-3">
                 <div class="rounded-2xl bg-slate-50 px-3 py-2">
                   <p class="text-xs text-[var(--art-gray-500)]">瓒呮椂</p>
-                  <p class="mt-1 font-medium text-[var(--art-gray-900)]">{{ item.requestTimeoutMs ?? '--' }} ms</p>
+                  <p class="mt-1 font-medium text-[var(--art-gray-900)]"
+                    >{{ item.requestTimeoutMs ?? '--' }} ms</p
+                  >
                 </div>
                 <div class="rounded-2xl bg-slate-50 px-3 py-2">
                   <p class="text-xs text-[var(--art-gray-500)]">鎺掑簭</p>
@@ -77,17 +95,25 @@
                 </div>
                 <div class="rounded-2xl bg-slate-50 px-3 py-2">
                   <p class="text-xs text-[var(--art-gray-500)]">鍒濆鍖栬�楁椂</p>
-                  <p class="mt-1 font-medium text-[var(--art-gray-900)]">{{ item.lastInitElapsedMs ?? '--' }}</p>
+                  <p class="mt-1 font-medium text-[var(--art-gray-900)]">{{
+                    item.lastInitElapsedMs ?? '--'
+                  }}</p>
                 </div>
               </div>
 
               <div class="mt-4 rounded-2xl bg-amber-50/80 px-4 py-3">
                 <p class="text-xs text-[var(--art-gray-500)]">澶囨敞</p>
-                <p class="mt-2 line-clamp-3 text-sm leading-6 text-[var(--art-gray-900)]">{{ item.memo || '--' }}</p>
+                <p class="mt-2 line-clamp-3 text-sm leading-6 text-[var(--art-gray-900)]">{{
+                  item.memo || '--'
+                }}</p>
               </div>
 
-              <div class="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-[var(--art-border-color)] pt-4">
-                <div class="text-xs text-[var(--art-gray-500)]">{{ item['updateTime$'] || '--' }}</div>
+              <div
+                class="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-[var(--art-border-color)] pt-4"
+              >
+                <div class="text-xs text-[var(--art-gray-500)]">{{
+                  item['updateTime$'] || '--'
+                }}</div>
 
                 <ElSpace wrap>
                   <ElButton text @click="openDetailDialog(item)">璇︽儏</ElButton>
@@ -101,7 +127,9 @@
                     杩為�氭�ф祴璇�
                   </ElButton>
                   <ElButton v-auth="'list'" text @click="openToolsDrawer(item)">宸ュ叿棰勮</ElButton>
-                  <ElButton v-auth="'remove'" text type="danger" @click="handleDelete(item)">鍒犻櫎</ElButton>
+                  <ElButton v-auth="'remove'" text type="danger" @click="handleDelete(item)"
+                    >鍒犻櫎</ElButton
+                  >
                 </ElSpace>
               </div>
             </article>
@@ -253,7 +281,9 @@
 
   async function openEditDialog(record) {
     try {
-      currentMcpMountData.value = buildAiMcpMountDialogModel(await fetchGetAiMcpMountDetail(record.id))
+      currentMcpMountData.value = buildAiMcpMountDialogModel(
+        await fetchGetAiMcpMountDetail(record.id)
+      )
       dialogMode.value = 'edit'
       dialogVisible.value = true
     } catch {
@@ -263,7 +293,9 @@
 
   async function openDetailDialog(record) {
     try {
-      currentMcpMountData.value = buildAiMcpMountDialogModel(await fetchGetAiMcpMountDetail(record.id))
+      currentMcpMountData.value = buildAiMcpMountDialogModel(
+        await fetchGetAiMcpMountDetail(record.id)
+      )
       dialogMode.value = 'show'
       dialogVisible.value = true
     } catch {
diff --git a/rsf-design/src/views/system/role/index.vue b/rsf-design/src/views/system/role/index.vue
index 49d2e34..2833f51 100644
--- a/rsf-design/src/views/system/role/index.vue
+++ b/rsf-design/src/views/system/role/index.vue
@@ -24,9 +24,9 @@
               :disabled="selectedRows.length === 0"
               @click="handleBatchDelete"
               v-ripple
-              >
-                鎵归噺鍒犻櫎
-              </ElButton>
+            >
+              鎵归噺鍒犻櫎
+            </ElButton>
             <span v-auth="'query'" class="inline-flex">
               <ListExportPrint
                 :preview-visible="previewVisible"
@@ -218,13 +218,17 @@
   }
 
   const resolvePrintRecords = async (payload) => {
-    const response = Array.isArray(payload?.ids) && payload.ids.length > 0
-      ? await fetchGetRoleMany(payload.ids)
-      : await fetchRolePrintPage({
-          ...reportQueryParams.value,
-          current: 1,
-          pageSize: Number(pagination.total) > 0 ? Number(pagination.total) : Number(payload?.pageSize) || 20
-        })
+    const response =
+      Array.isArray(payload?.ids) && payload.ids.length > 0
+        ? await fetchGetRoleMany(payload.ids)
+        : await fetchRolePrintPage({
+            ...reportQueryParams.value,
+            current: 1,
+            pageSize:
+              Number(pagination.total) > 0
+                ? Number(pagination.total)
+                : Number(payload?.pageSize) || 20
+          })
     return defaultResponseAdapter(response).records
   }
 
diff --git a/rsf-design/src/views/system/role/rolePage.helpers.js b/rsf-design/src/views/system/role/rolePage.helpers.js
index d01d937..0b2696f 100644
--- a/rsf-design/src/views/system/role/rolePage.helpers.js
+++ b/rsf-design/src/views/system/role/rolePage.helpers.js
@@ -28,7 +28,9 @@
   }
 
   return Object.fromEntries(
-    Object.entries(searchParams).filter(([, value]) => value !== '' && value !== void 0 && value !== null)
+    Object.entries(searchParams).filter(
+      ([, value]) => value !== '' && value !== void 0 && value !== null
+    )
   )
 }
 
@@ -86,7 +88,8 @@
         return null
       }
 
-      const source = ROLE_REPORT_SOURCE_ALIAS[column.source ?? column.prop] ?? column.source ?? column.prop
+      const source =
+        ROLE_REPORT_SOURCE_ALIAS[column.source ?? column.prop] ?? column.source ?? column.prop
       if (!source || !allowedColumns.has(source) || seenSources.has(source)) {
         return null
       }
@@ -224,9 +227,7 @@
     return []
   }
 
-  return treeData
-    .map((node) => normalizeRoleScopeNode(scopeType, node))
-    .filter(Boolean)
+  return treeData.map((node) => normalizeRoleScopeNode(scopeType, node)).filter(Boolean)
 }
 
 function normalizeRoleScopeNode(scopeType, node) {
@@ -242,16 +243,17 @@
     ? normalizeRoleScopeTreeData(scopeType, node.children)
     : []
   const metaSource = node.meta && typeof node.meta === 'object' ? node.meta : node
-  const authNodes = scopeType === 'menu' && Array.isArray(metaSource.authList) && metaSource.authList.length
-    ? metaSource.authList.map((auth, index) => ({
-        id: normalizeScopeKey(auth.id ?? auth.authMark ?? `${node.id || 'auth'}-${index}`),
-        label: normalizeScopeTitle(auth.title || auth.name || auth.authMark || ''),
-        type: 1,
-        isAuthButton: true,
-        authMark: auth.authMark || auth.authority || auth.code || '',
-        children: []
-      }))
-    : []
+  const authNodes =
+    scopeType === 'menu' && Array.isArray(metaSource.authList) && metaSource.authList.length
+      ? metaSource.authList.map((auth, index) => ({
+          id: normalizeScopeKey(auth.id ?? auth.authMark ?? `${node.id || 'auth'}-${index}`),
+          label: normalizeScopeTitle(auth.title || auth.name || auth.authMark || ''),
+          type: 1,
+          isAuthButton: true,
+          authMark: auth.authMark || auth.authority || auth.code || '',
+          children: []
+        }))
+      : []
 
   const mergedChildren =
     authNodes.length > 0 && !children.some((child) => child.isAuthButton)
@@ -291,11 +293,7 @@
   }
 
   return Array.from(
-    new Set(
-      keys
-        .map((key) => normalizeRoleId(key))
-        .filter((key) => key !== void 0)
-    )
+    new Set(keys.map((key) => normalizeRoleId(key)).filter((key) => key !== void 0))
   )
 }
 

--
Gitblit v1.9.1