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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user