fix(chat): preserve unsent input draft (#1173)
* fix(chat): preserve unsent input draft * fix(chat): store drafts by session id
This commit is contained in:
@@ -16,6 +16,8 @@ const profilesStore = useProfilesStore()
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const { toolTraceVisible, toggleToolTraceVisible } = useToolTraceVisibility()
|
const { toolTraceVisible, toggleToolTraceVisible } = useToolTraceVisibility()
|
||||||
|
const DRAFT_STORAGE_KEY = 'hermes_chat_input_drafts_v1'
|
||||||
|
type DraftMap = Record<string, string>
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const textareaRef = ref<HTMLTextAreaElement>()
|
const textareaRef = ref<HTMLTextAreaElement>()
|
||||||
const commandDropdownRef = ref<HTMLDivElement>()
|
const commandDropdownRef = ref<HTMLDivElement>()
|
||||||
@@ -92,8 +94,43 @@ function startResize(e: MouseEvent) {
|
|||||||
// 自动播放语音开关
|
// 自动播放语音开关
|
||||||
const autoPlaySpeech = ref(false)
|
const autoPlaySpeech = ref(false)
|
||||||
|
|
||||||
|
function readDraftMap(): DraftMap {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(DRAFT_STORAGE_KEY) || '{}')
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveDraftSessionId() {
|
||||||
|
return chatStore.activeSessionId || chatStore.activeSession?.id || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDraftForActiveSession() {
|
||||||
|
const sessionId = getActiveDraftSessionId()
|
||||||
|
inputText.value = sessionId ? readDraftMap()[sessionId] || '' : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDraftForActiveSession(value: string) {
|
||||||
|
const sessionId = getActiveDraftSessionId()
|
||||||
|
if (!sessionId) return
|
||||||
|
const drafts = readDraftMap()
|
||||||
|
if (value) {
|
||||||
|
drafts[sessionId] = value
|
||||||
|
} else {
|
||||||
|
delete drafts[sessionId]
|
||||||
|
}
|
||||||
|
if (Object.keys(drafts).length > 0) {
|
||||||
|
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(drafts))
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(DRAFT_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 从 localStorage 读取设置
|
// 从 localStorage 读取设置
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
loadDraftForActiveSession()
|
||||||
const saved = localStorage.getItem('autoPlaySpeech')
|
const saved = localStorage.getItem('autoPlaySpeech')
|
||||||
if (saved !== null) {
|
if (saved !== null) {
|
||||||
autoPlaySpeech.value = saved === 'true'
|
autoPlaySpeech.value = saved === 'true'
|
||||||
@@ -109,6 +146,14 @@ watch(autoPlaySpeech, (value) => {
|
|||||||
chatStore.setAutoPlaySpeech(value)
|
chatStore.setAutoPlaySpeech(value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(inputText, (value) => {
|
||||||
|
saveDraftForActiveSession(value)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => chatStore.activeSession?.id, () => {
|
||||||
|
loadDraftForActiveSession()
|
||||||
|
})
|
||||||
|
|
||||||
const canSend = computed(() => inputText.value.trim() || attachments.value.length > 0)
|
const canSend = computed(() => inputText.value.trim() || attachments.value.length > 0)
|
||||||
|
|
||||||
function scrollCommandIntoView() {
|
function scrollCommandIntoView() {
|
||||||
@@ -354,6 +399,7 @@ function handleSend() {
|
|||||||
|
|
||||||
chatStore.sendMessage(text, attachments.value.length > 0 ? attachments.value : undefined)
|
chatStore.sendMessage(text, attachments.value.length > 0 ? attachments.value : undefined)
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
|
saveDraftForActiveSession('')
|
||||||
attachments.value = []
|
attachments.value = []
|
||||||
slashActive.value = false
|
slashActive.value = false
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { useChatStore } from '@/stores/hermes/chat'
|
||||||
|
import ChatInput from '@/components/hermes/chat/ChatInput.vue'
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({ t: (key: string) => key }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('naive-ui', () => ({
|
||||||
|
NButton: { template: '<button type="button" v-bind="$attrs"><slot /><slot name="icon" /></button>' },
|
||||||
|
NTooltip: { template: '<div><slot name="trigger" /><slot /></div>' },
|
||||||
|
NSwitch: { template: '<button type="button"></button>' },
|
||||||
|
NModal: { template: '<div><slot /><slot name="footer" /></div>' },
|
||||||
|
NInputNumber: { template: '<input />' },
|
||||||
|
useMessage: () => ({ error: vi.fn(), success: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/hermes/sessions', () => ({
|
||||||
|
fetchContextLength: vi.fn().mockResolvedValue(256000),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/hermes/model-context', () => ({
|
||||||
|
setModelContext: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useToolTraceVisibility', () => ({
|
||||||
|
useToolTraceVisibility: () => ({ toolTraceVisible: { value: true }, toggleToolTraceVisible: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function mountForSession(sessionId: string) {
|
||||||
|
const pinia = createTestingPinia({ stubActions: false, createSpy: vi.fn })
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
chatStore.sessions = [
|
||||||
|
{ id: sessionId, title: sessionId, source: 'cli', messages: [], createdAt: Date.now(), updatedAt: Date.now() },
|
||||||
|
]
|
||||||
|
chatStore.activeSessionId = sessionId
|
||||||
|
chatStore.activeSession = chatStore.sessions[0]
|
||||||
|
return mount(ChatInput, { global: { plugins: [pinia] } })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChatInput draft persistence', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores unsent text for the active session after the chat view is remounted', async () => {
|
||||||
|
const wrapper = mountForSession('session-a')
|
||||||
|
const textarea = wrapper.get('textarea')
|
||||||
|
|
||||||
|
await textarea.setValue('draft before tab switch')
|
||||||
|
await nextTick()
|
||||||
|
wrapper.unmount()
|
||||||
|
|
||||||
|
const remounted = mountForSession('session-a')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect((remounted.get('textarea').element as HTMLTextAreaElement).value).toBe('draft before tab switch')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores drafts under one localStorage key mapped by session id', async () => {
|
||||||
|
const wrapperA = mountForSession('session-a')
|
||||||
|
await wrapperA.get('textarea').setValue('draft for session a')
|
||||||
|
await nextTick()
|
||||||
|
wrapperA.unmount()
|
||||||
|
|
||||||
|
const wrapperB = mountForSession('session-b')
|
||||||
|
await wrapperB.get('textarea').setValue('draft for session b')
|
||||||
|
await nextTick()
|
||||||
|
wrapperB.unmount()
|
||||||
|
|
||||||
|
expect(localStorage.getItem('hermes_chat_input_draft_v1')).toBeNull()
|
||||||
|
expect(JSON.parse(localStorage.getItem('hermes_chat_input_drafts_v1') || '{}')).toEqual({
|
||||||
|
'session-a': 'draft for session a',
|
||||||
|
'session-b': 'draft for session b',
|
||||||
|
})
|
||||||
|
|
||||||
|
const remountedA = mountForSession('session-a')
|
||||||
|
await nextTick()
|
||||||
|
expect((remountedA.get('textarea').element as HTMLTextAreaElement).value).toBe('draft for session a')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user