Phase 006: migrate the chat page from naive-ui to Arco Design Vue

Swaps the rendering layer only. Chat store logic, keyboard handling, and the
/chat-run Socket.IO contract are untouched, so a mixed UI cannot hide a chat
regression behind a library swap.

Arco's dropdowns take their items from a #content slot of a-doption children
rather than a droplist prop, and Arco Triggers cannot be nested, so the two
toolbar menus lost their a-tooltip in favour of a native title. Both rules are
recorded in docs/arco-mapping.md, and tests/e2e/chat-input-dropdown.spec.ts plus
tests/e2e/chat-rendering-smoke.spec.ts keep the menus, the streaming markdown
rendering, and the no-Arco-inside-the-bubble red line from regressing silently.
This commit is contained in:
2026-10-17 22:17:32 +08:00
parent 4baccd5d88
commit effc29085c
20 changed files with 1356 additions and 819 deletions
@@ -7,7 +7,6 @@ import { useSettingsStore } from '@/stores/hermes/settings'
import { fetchContextLength } from '@/api/hermes/sessions'
import { setModelContext } from '@/api/hermes/model-context'
import { fetchSkills, type SkillCategory, type SkillInfo } from '@/api/hermes/skills'
import { NButton, NTooltip, NModal, NInputNumber, NPopselect, NDropdown, type DropdownOption } from 'naive-ui'
import { useMessage } from '@/composables/useMessage'
import { computed, ref, nextTick, onMounted, onUnmounted, watch, h } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -59,10 +58,10 @@ const reasoningEffortLabel = computed<string>(() => {
const opt = reasoningEffortOptions.value.find(o => o.value === v)
return opt?.label || v
})
function onReasoningEffortChange(value: string | null | undefined) {
function onReasoningEffortChange(value: unknown) {
const sid = chatStore.activeSessionId
if (!sid) return
chatStore.setSessionReasoningEffort(sid, value || '')
chatStore.setSessionReasoningEffort(sid, value ? String(value) : '')
}
function handleModelButtonClick() {
@@ -401,18 +400,18 @@ function startResize(e: MouseEvent) {
// 自动播放语音开关
const autoPlaySpeech = ref(false)
const inputSettingsOptions = computed<DropdownOption[]>(() => [
const inputSettingsOptions = computed(() => [
{
label: t('chat.autoPlaySpeech'),
key: 'autoPlaySpeech',
label: t('chat.autoPlaySpeech'),
icon: () => h('span', {
class: ['settings-check', { active: autoPlaySpeech.value }],
'aria-hidden': 'true',
}, autoPlaySpeech.value ? '✓' : ''),
},
{
label: t('chat.showToolCalls'),
key: 'toolTrace',
label: t('chat.showToolCalls'),
icon: () => h('span', {
class: ['settings-check', { active: toolTraceVisible.value }],
'aria-hidden': 'true',
@@ -477,7 +476,8 @@ watch(autoPlaySpeech, (value) => {
chatStore.setAutoPlaySpeech(value)
})
function handleInputSettingsSelect(key: string | number) {
function handleInputSettingsSelect(selected: string | number | Record<string, any> | undefined) {
const key = typeof selected === 'object' ? selected?.key : selected
if (key === 'autoPlaySpeech') {
autoPlaySpeech.value = !autoPlaySpeech.value
return
@@ -1048,14 +1048,12 @@ function isImage(type: string): boolean {
<div v-if="showContextUsage" class="context-usage-row">
<span class="context-info" :class="{ 'context-warning': usagePercent > 80 }">
{{ formatTokens(totalTokens) }} /
<NTooltip trigger="hover" :disabled="isMobileViewport">
<template #trigger>
<span class="context-limit-editable" @click="handleEditContextLimit">
{{ formatTokens(contextLength) }}
</span>
</template>
<a-tooltip trigger="hover" :disabled="isMobileViewport">
<span class="context-limit-editable" @click="handleEditContextLimit">
{{ formatTokens(contextLength) }}
</span>
<span>{{ t('chat.contextClickToEdit') }}</span>
</NTooltip>
</a-tooltip>
· {{ t('chat.contextRemaining') }} {{ formatTokens(remainingTokens) }}
</span>
<div class="context-bar">
@@ -1085,112 +1083,118 @@ function isImage(type: string): boolean {
<div class="input-toolbar">
<!-- Bottom bar: attach + input settings + actions -->
<div class="input-top-bar">
<NTooltip trigger="hover" :disabled="isMobileViewport">
<template #trigger>
<NButton quaternary size="tiny" @click="handleAttachClick" circle class="toolbar-icon-button">
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
</template>
</NButton>
</template>
{{ t('chat.attachFiles') }}
</NTooltip>
<NPopselect
:value="currentReasoningEffort"
:options="reasoningEffortOptions"
trigger="click"
@update:value="onReasoningEffortChange"
>
<NTooltip trigger="hover" :disabled="isMobileViewport">
<template #trigger>
<NButton
quaternary
size="tiny"
class="reasoning-effort-button"
:class="{ active: !!currentReasoningEffort }"
:aria-label="`${t('chat.reasoningEffort.tooltip')}: ${reasoningEffortLabel}`"
>
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z"/>
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z"/>
</svg>
</template>
<span class="reasoning-effort-label">{{ reasoningEffortLabel }}</span>
<svg class="toolbar-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
</NButton>
<a-tooltip trigger="hover" :disabled="isMobileViewport">
<a-button type="secondary" size="mini" shape="circle" @click="handleAttachClick" class="toolbar-icon-button">
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
</template>
{{ t('chat.reasoningEffort.tooltip') }}: {{ reasoningEffortLabel }}
</NTooltip>
</NPopselect>
</a-button>
{{ t('chat.attachFiles') }}
</a-tooltip>
<NDropdown
<a-dropdown
trigger="click"
@select="onReasoningEffortChange"
>
<a-button
type="secondary"
size="mini"
class="reasoning-effort-button"
:class="{ active: !!currentReasoningEffort }"
:title="isMobileViewport ? undefined : `${t('chat.reasoningEffort.tooltip')}: ${reasoningEffortLabel}`"
:aria-label="`${t('chat.reasoningEffort.tooltip')}: ${reasoningEffortLabel}`"
>
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z"/>
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 2.5 2.5 0 0 0-.34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z"/>
</svg>
</template>
<span class="reasoning-effort-label">{{ reasoningEffortLabel }}</span>
<svg class="toolbar-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
</a-button>
<template #content>
<a-doption
v-for="option in reasoningEffortOptions"
:key="option.value"
:value="option.value"
:active="option.value === currentReasoningEffort"
>
{{ option.label }}
</a-doption>
</template>
</a-dropdown>
<a-dropdown
trigger="click"
:options="inputSettingsOptions"
:show-arrow="true"
@select="handleInputSettingsSelect"
>
<NTooltip trigger="hover" :disabled="isMobileViewport">
<template #trigger>
<NButton
quaternary
size="tiny"
class="input-settings-button"
:aria-label="t('sidebar.settings')"
>
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.08a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c.2.6.77 1 1.4 1H21a2 2 0 1 1 0 4h-.09c-.63 0-1.2.4-1.51 1Z"/>
</svg>
</template>
<span class="input-settings-label">{{ t('sidebar.settings') }}</span>
<svg class="toolbar-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
</NButton>
<a-button
type="secondary"
size="mini"
class="input-settings-button"
:title="isMobileViewport ? undefined : t('sidebar.settings')"
:aria-label="t('sidebar.settings')"
>
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.08a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c.2.6.77 1 1.4 1H21a2 2 0 1 1 0 4h-.09c-.63 0-1.2.4-1.51 1Z"/>
</svg>
</template>
{{ t('sidebar.settings') }}
</NTooltip>
</NDropdown>
<NTooltip trigger="hover" :disabled="isMobileViewport">
<template #trigger>
<NButton
quaternary
size="tiny"
class="input-model-button"
:title="isMobileViewport ? undefined : props.modelLabel || t('models.selectModel')"
:aria-label="props.modelLabel || t('models.selectModel')"
@click="handleModelButtonClick"
<span class="input-settings-label">{{ t('sidebar.settings') }}</span>
<svg class="toolbar-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
</a-button>
<template #content>
<a-doption
v-for="option in inputSettingsOptions"
:key="option.key"
:value="option.key"
>
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M12 1v4" />
<path d="M12 19v4" />
<path d="M1 12h4" />
<path d="M19 12h4" />
<path d="M4.22 4.22l2.83 2.83" />
<path d="M16.95 16.95l2.83 2.83" />
<path d="M4.22 19.78l2.83-2.83" />
<path d="M16.95 7.05l2.83-2.83" />
</svg>
</template>
<span class="input-model-label">{{ compactModelLabel }}</span>
<svg class="toolbar-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
</NButton>
<span class="settings-option">
<component :is="option.icon" />
{{ option.label }}
</span>
</a-doption>
</template>
</a-dropdown>
<a-tooltip trigger="hover" :disabled="isMobileViewport">
<a-button
type="secondary"
size="mini"
class="input-model-button"
:title="isMobileViewport ? undefined : props.modelLabel || t('models.selectModel')"
:aria-label="props.modelLabel || t('models.selectModel')"
@click="handleModelButtonClick"
>
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M12 1v4" />
<path d="M12 19v4" />
<path d="M1 12h4" />
<path d="M19 12h4" />
<path d="M4.22 4.22l2.83 2.83" />
<path d="M16.95 16.95l2.83 2.83" />
<path d="M4.22 19.78l2.83-2.83" />
<path d="M16.95 7.05l2.83-2.83" />
</svg>
</template>
<span class="input-model-label">{{ compactModelLabel }}</span>
<svg class="toolbar-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg>
</a-button>
{{ props.modelLabel || t('models.selectModel') }}
</NTooltip>
</a-tooltip>
</div>
<div class="input-actions">
@@ -1203,10 +1207,10 @@ function isImage(type: string): boolean {
:on-stop="stopVoiceCapture"
:on-cancel="cancelVoiceCapture"
/>
<NButton
<a-button
size="medium"
type="primary"
circle
shape="circle"
class="send-button"
:class="{ 'send-button--stop': sendButtonIsStop }"
:disabled="sendButtonIsStop ? chatStore.isAborting : !canSend"
@@ -1226,7 +1230,7 @@ function isImage(type: string): boolean {
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="m5 12 7-7 7 7"/></svg>
</template>
<span class="visually-hidden">{{ sendButtonIsStop ? t('chat.stop') : t('chat.send') }}</span>
</NButton>
</a-button>
</div>
</div>
<Transition name="dropdown-fade">
@@ -1251,12 +1255,13 @@ function isImage(type: string): boolean {
</Transition>
</div>
<NModal
v-model:show="showSkillPicker"
<a-modal
v-model:visible="showSkillPicker"
:title="t('skills.title')"
:mask-closable="true"
preset="card"
style="width: min(620px, calc(100vw - 32px))"
width="min(620px, calc(100vw - 32px))"
:footer="false"
unmount-on-close
>
<div v-if="showSkillPicker" class="skill-picker-modal">
<input
@@ -1290,48 +1295,48 @@ function isImage(type: string): boolean {
</div>
</div>
</div>
</NModal>
</a-modal>
<!-- Context Length Edit Modal -->
<NModal
v-model:show="showContextEditModal"
<a-modal
v-model:visible="showContextEditModal"
:title="t('chat.contextEditTitle')"
:mask-closable="true"
preset="card"
style="width: 400px"
:width="400"
unmount-on-close
>
<div class="context-edit-content">
<p style="margin-bottom: 16px; color: #666;">
{{ t('chat.contextEditDesc') }}
</p>
<NInputNumber
v-model:value="editingContextLimit"
<a-input-number
v-model="editingContextLimit"
:min="1000"
:max="10000000"
:step="1000"
:show-button="false"
hide-button
:placeholder="t('chat.contextEditPlaceholder')"
style="width: 100%"
>
<template #suffix>
<span style="color: #999;">tokens</span>
</template>
</NInputNumber>
</a-input-number>
<div style="margin-top: 12px; font-size: 12px; color: #999;">
{{ t('chat.contextEditHint') }}
</div>
</div>
<template #footer>
<div style="display: flex; justify-content: flex-end; gap: 8px;">
<NButton @click="showContextEditModal = false" :disabled="isSavingContextLimit">
<a-button @click="showContextEditModal = false" :disabled="isSavingContextLimit">
{{ t('chat.contextEditCancel') }}
</NButton>
<NButton type="primary" @click="saveContextLimit" :loading="isSavingContextLimit">
</a-button>
<a-button type="primary" @click="saveContextLimit" :loading="isSavingContextLimit">
{{ t('chat.contextEditSave') }}
</NButton>
</a-button>
</div>
</template>
</NModal>
</a-modal>
</div>
</template>