fix(frontend): 同步 main 并修复 phase2 关闭审阅意见
This commit is contained in:
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue'
|
||||
import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts'
|
||||
import * as agentService from '@/services/agentService'
|
||||
import type { SseClient } from '@/services/sseClient'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export const useAgentStore = defineStore('agent', () => {
|
||||
const runs = ref<AgentRun[]>([])
|
||||
@@ -93,7 +94,7 @@ export const useAgentStore = defineStore('agent', () => {
|
||||
tool_name: String(call.name ?? 'unknown'),
|
||||
permission: String(data.permission ?? ''),
|
||||
parameters: (call.arguments ?? {}) as Record<string, unknown>,
|
||||
impact: '该工具需要获得权限后才能继续执行。',
|
||||
impact: t('该工具需要获得权限后才能继续执行。', 'This tool requires permission before it can continue.'),
|
||||
}
|
||||
if (run) run.status = 'waiting_permission'
|
||||
} else if (['RunCompleted', 'RunFailed', 'RunCancelled'].includes(event.event)) {
|
||||
|
||||
@@ -1,36 +1,85 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useChatStore } from './chat'
|
||||
import { streamChat } from '@/services/chatService'
|
||||
import {
|
||||
createConversation,
|
||||
listConversationMessages,
|
||||
listConversations,
|
||||
removeConversation,
|
||||
streamChat,
|
||||
} from '@/services/chatService'
|
||||
import type { ChatMessage, Conversation } from '@/contracts'
|
||||
import type { SseClient } from '@/services/sseClient'
|
||||
|
||||
vi.mock('@/services/chatService', () => ({ streamChat: vi.fn() }))
|
||||
vi.mock('@/services/chatService', () => ({
|
||||
createConversation: vi.fn(),
|
||||
listConversationMessages: vi.fn(),
|
||||
listConversations: vi.fn(),
|
||||
removeConversation: vi.fn(),
|
||||
streamChat: vi.fn(),
|
||||
}))
|
||||
|
||||
const page = { total: 0, limit: 100, offset: 0 }
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: Error) => void
|
||||
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(streamChat).mockReset().mockReturnValue({ cancel: vi.fn() } as unknown as SseClient)
|
||||
vi.mocked(listConversations).mockReset().mockResolvedValue({ items: [], page })
|
||||
vi.mocked(listConversationMessages).mockReset().mockResolvedValue({ items: [], page: { ...page, limit: 1000 } })
|
||||
vi.mocked(createConversation).mockReset().mockImplementation(async value => ({
|
||||
...value, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), message_count: 0,
|
||||
}))
|
||||
vi.mocked(removeConversation).mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('sends real user history, applies streaming changes, and restores it when switching conversations', async () => {
|
||||
it('sends persistent message ids and restores messages from the backend', async () => {
|
||||
const store = useChatStore()
|
||||
store.selectedProviderId = 'real'
|
||||
store.selectedModel = 'configured-model'
|
||||
await store.sendMessage('user input')
|
||||
const [request, handlers] = vi.mocked(streamChat).mock.calls[0]!
|
||||
expect(request.use_rag).toBe(true)
|
||||
handlers.onEvent?.({ event: 'Citation', sequence: 0, timestamp: '', data: { note_id: 'note', block_id: 'block', file_path: 'note.md', content: 'real evidence' } })
|
||||
expect(store.messages[1]?.citations?.[0]?.content).toBe('real evidence')
|
||||
expect(request.user_message_id).toBe(store.messages[0]?.message_id)
|
||||
expect(request.assistant_message_id).toBe(store.messages[1]?.message_id)
|
||||
expect(request.messages).toEqual([{ role: 'user', content: 'user input' }])
|
||||
handlers.onEvent?.({ event: 'TextDelta', sequence: 0, timestamp: '', data: { text: 'real response' } })
|
||||
expect(store.messages[1]?.content).toBe('real response')
|
||||
handlers.onEvent?.({ event: 'Citation', sequence: 0, timestamp: '', data: { note_id: 'note', block_id: 'block', file_path: 'note.md', heading_path: ['Heading'], content: 'real evidence' } })
|
||||
handlers.onEvent?.({ event: 'TextDelta', sequence: 1, timestamp: '', data: { text: 'real response' } })
|
||||
handlers.onDone?.()
|
||||
|
||||
const persisted = store.messages.map(message => ({ ...message })) as ChatMessage[]
|
||||
vi.mocked(listConversationMessages).mockResolvedValueOnce({ items: persisted, page: { total: 2, limit: 1000, offset: 0 } })
|
||||
const id = store.activeConversationId!
|
||||
store.createNewConversation()
|
||||
await store.createNewConversation()
|
||||
expect(store.messages).toEqual([])
|
||||
await store.setActiveConversation(id)
|
||||
expect(store.messages.map(m => m.content)).toEqual(['user input', 'real response'])
|
||||
expect(store.messages.map(message => message.content)).toEqual(['user input', 'real response'])
|
||||
expect(store.messages[1]?.citations?.[0]?.heading_path).toBe('Heading')
|
||||
})
|
||||
|
||||
it('does not send without a provider and ignores late callbacks from a cancelled conversation', async () => {
|
||||
it('loads the newest persisted conversation on initialization', async () => {
|
||||
const conversation: Conversation = {
|
||||
conversation_id: 'persisted', title: 'Saved', created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-02T00:00:00Z', message_count: 1,
|
||||
}
|
||||
vi.mocked(listConversations).mockResolvedValue({ items: [conversation], page: { ...page, total: 1 } })
|
||||
vi.mocked(listConversationMessages).mockResolvedValue({
|
||||
items: [{ message_id: 'm1', conversation_id: 'persisted', role: 'user', content: 'saved text', created_at: '2026-01-01T00:00:00Z' }],
|
||||
page: { total: 1, limit: 1000, offset: 0 },
|
||||
})
|
||||
const store = useChatStore()
|
||||
await store.loadConversations()
|
||||
expect(store.activeConversationId).toBe('persisted')
|
||||
expect(store.messages[0]?.content).toBe('saved text')
|
||||
})
|
||||
|
||||
it('does not send without a provider and ignores callbacks from a cancelled conversation', async () => {
|
||||
const store = useChatStore()
|
||||
await store.sendMessage('no provider')
|
||||
expect(streamChat).not.toHaveBeenCalled()
|
||||
@@ -38,9 +87,197 @@ it('does not send without a provider and ignores late callbacks from a cancelled
|
||||
store.selectedModel = 'configured-model'
|
||||
await store.sendMessage('first')
|
||||
const old = vi.mocked(streamChat).mock.calls[0]![1]
|
||||
store.createNewConversation()
|
||||
await store.createNewConversation()
|
||||
await store.sendMessage('second')
|
||||
old.onDone?.()
|
||||
expect(store.isStreaming).toBe(true)
|
||||
expect(store.messages[0]?.content).toBe('second')
|
||||
})
|
||||
|
||||
it('keeps a conversation visible when backend deletion fails', async () => {
|
||||
const store = useChatStore()
|
||||
await store.createNewConversation()
|
||||
const id = store.activeConversationId!
|
||||
vi.mocked(removeConversation).mockRejectedValueOnce(new Error('offline'))
|
||||
await store.deleteConversation(id)
|
||||
expect(store.conversations.some(item => item.conversation_id === id)).toBe(true)
|
||||
expect(store.historyError).toBe('offline')
|
||||
})
|
||||
|
||||
it('blocks sends until history is loaded, then includes that history', async () => {
|
||||
const store = useChatStore()
|
||||
store.selectedProviderId = 'real'
|
||||
store.selectedModel = 'model'
|
||||
await store.createNewConversation()
|
||||
const id = store.activeConversationId!
|
||||
const history = deferred<Awaited<ReturnType<typeof listConversationMessages>>>()
|
||||
vi.mocked(listConversationMessages).mockReturnValueOnce(history.promise)
|
||||
const loading = store.setActiveConversation(id)
|
||||
store.inputText = 'followup'
|
||||
expect(store.canSend).toBe(false)
|
||||
await store.sendMessage(store.inputText)
|
||||
expect(streamChat).not.toHaveBeenCalled()
|
||||
expect(store.inputText).toBe('followup')
|
||||
history.resolve({ items: [{ message_id: 'old', conversation_id: id, role: 'user', content: 'previous context', created_at: '' }], page: { ...page, total: 1 } })
|
||||
await loading
|
||||
expect(store.canSend).toBe(true)
|
||||
await store.sendMessage(store.inputText)
|
||||
expect(vi.mocked(streamChat).mock.calls[0]![0].messages).toEqual([
|
||||
{ role: 'user', content: 'previous context' }, { role: 'user', content: 'followup' },
|
||||
])
|
||||
expect(store.messages.map(m => m.content)).toEqual(['previous context', 'followup', ''])
|
||||
})
|
||||
|
||||
it('keeps sending blocked after history failure until a successful retry', async () => {
|
||||
const store = useChatStore()
|
||||
store.selectedProviderId = 'real'
|
||||
store.selectedModel = 'model'
|
||||
await store.createNewConversation()
|
||||
const id = store.activeConversationId!
|
||||
vi.mocked(listConversationMessages).mockRejectedValueOnce(new Error('offline'))
|
||||
await store.setActiveConversation(id)
|
||||
await store.sendMessage('followup')
|
||||
expect(streamChat).not.toHaveBeenCalled()
|
||||
expect(store.historyError).toBe('offline')
|
||||
expect(store.canSend).toBe(false)
|
||||
await store.setActiveConversation(id)
|
||||
expect(store.canSend).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['switch', 'stop', 'delete'] as const)('cancels a pending send on %s without touching another send', async action => {
|
||||
const store = useChatStore()
|
||||
store.selectedProviderId = 'real'
|
||||
store.selectedModel = 'model'
|
||||
await store.createNewConversation()
|
||||
const b = store.activeConversationId!
|
||||
const creation = deferred<Conversation>()
|
||||
vi.mocked(createConversation).mockReturnValueOnce(creation.promise)
|
||||
const creating = store.createNewConversation()
|
||||
const a = store.activeConversationId!
|
||||
const saved = { ...store.activeConversation! }
|
||||
const sending = store.sendMessage('belongs to a')
|
||||
expect(store.isPreparing).toBe(true)
|
||||
const deleting = action === 'delete' ? store.deleteConversation(a) : undefined
|
||||
if (action === 'stop') store.stopGeneration()
|
||||
await store.setActiveConversation(b)
|
||||
await store.sendMessage('belongs to b')
|
||||
creation.resolve(saved)
|
||||
await Promise.all([creating, sending, deleting])
|
||||
expect(store.activeConversationId).toBe(b)
|
||||
expect(store.messages.every(m => m.conversation_id === b)).toBe(true)
|
||||
expect(store.messages[0]?.content).toBe('belongs to b')
|
||||
expect(streamChat).toHaveBeenCalledTimes(1)
|
||||
expect(store.isStreaming).toBe(true)
|
||||
})
|
||||
|
||||
it('locks the initial send while creating its conversation and allows retry after failure', async () => {
|
||||
const store = useChatStore()
|
||||
store.selectedProviderId = 'real'
|
||||
store.selectedModel = 'model'
|
||||
const creation = deferred<Conversation>()
|
||||
vi.mocked(createConversation).mockReturnValueOnce(creation.promise)
|
||||
const sending = store.sendMessage('first')
|
||||
await store.sendMessage('duplicate')
|
||||
expect(createConversation).toHaveBeenCalledTimes(1)
|
||||
expect(streamChat).not.toHaveBeenCalled()
|
||||
creation.resolve({ ...store.activeConversation! })
|
||||
await sending
|
||||
expect(streamChat).toHaveBeenCalledTimes(1)
|
||||
store.stopGeneration()
|
||||
store.activeConversationId = null
|
||||
vi.mocked(createConversation).mockRejectedValueOnce(new Error('offline'))
|
||||
await store.sendMessage('retry')
|
||||
expect(store.isPreparing).toBe(false)
|
||||
expect(store.canSend).toBe(true)
|
||||
await store.sendMessage('retry')
|
||||
expect(streamChat).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('stops the first send before creation completes without switching conversations', async () => {
|
||||
const store = useChatStore()
|
||||
store.selectedProviderId = 'real'
|
||||
store.selectedModel = 'model'
|
||||
const creation = deferred<Conversation>()
|
||||
vi.mocked(createConversation).mockReturnValueOnce(creation.promise)
|
||||
const sending = store.sendMessage('cancelled')
|
||||
const saved = { ...store.activeConversation! }
|
||||
store.stopGeneration()
|
||||
creation.resolve(saved)
|
||||
await sending
|
||||
expect(streamChat).not.toHaveBeenCalled()
|
||||
expect(store.messages).toEqual([])
|
||||
expect(store.canSend).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores old history after switching to a new conversation and sending', async () => {
|
||||
const store = useChatStore()
|
||||
store.selectedProviderId = 'real'
|
||||
store.selectedModel = 'model'
|
||||
await store.createNewConversation()
|
||||
const id = store.activeConversationId!
|
||||
const history = deferred<Awaited<ReturnType<typeof listConversationMessages>>>()
|
||||
vi.mocked(listConversationMessages).mockReturnValueOnce(history.promise)
|
||||
const loading = store.setActiveConversation(id)
|
||||
await store.createNewConversation()
|
||||
await store.sendMessage('new question')
|
||||
history.resolve({ items: [], page })
|
||||
await loading
|
||||
expect(store.messages.map(m => m.content)).toEqual(['new question', ''])
|
||||
expect(store.isStreaming).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['success', 'failure'])('blocks sends and duplicate deletes until deletion ends with %s', async outcome => {
|
||||
const store = useChatStore()
|
||||
store.selectedProviderId = 'real'
|
||||
store.selectedModel = 'model'
|
||||
await store.createNewConversation()
|
||||
const id = store.activeConversationId!
|
||||
const removal = deferred<Awaited<ReturnType<typeof removeConversation>>>()
|
||||
vi.mocked(removeConversation).mockReturnValueOnce(removal.promise)
|
||||
const deleting = store.deleteConversation(id)
|
||||
store.inputText = 'keep this draft'
|
||||
expect(store.canSend).toBe(false)
|
||||
await store.sendMessage(store.inputText)
|
||||
await store.deleteConversation(id)
|
||||
expect(streamChat).not.toHaveBeenCalled()
|
||||
expect(removeConversation).toHaveBeenCalledTimes(1)
|
||||
expect(store.inputText).toBe('keep this draft')
|
||||
if (outcome === 'success') removal.resolve(undefined)
|
||||
else removal.reject(new Error('offline'))
|
||||
await deleting
|
||||
expect(store.isStreaming).toBe(false)
|
||||
expect(store.canSend).toBe(true)
|
||||
expect(store.activeConversationId).toBe(outcome === 'success' ? null : id)
|
||||
await store.sendMessage(store.inputText)
|
||||
expect(streamChat).toHaveBeenCalledTimes(1)
|
||||
const request = vi.mocked(streamChat).mock.calls[0]![0]
|
||||
if (outcome === 'success') expect(request.conversation_id).not.toBe(id)
|
||||
else expect(request.conversation_id).toBe(id)
|
||||
})
|
||||
|
||||
it('keeps a deleting conversation blocked after reselecting it without blocking other conversations', async () => {
|
||||
const store = useChatStore()
|
||||
store.selectedProviderId = 'real'
|
||||
store.selectedModel = 'model'
|
||||
await store.createNewConversation()
|
||||
const a = store.activeConversationId!
|
||||
await store.createNewConversation()
|
||||
const b = store.activeConversationId!
|
||||
const removal = deferred<Awaited<ReturnType<typeof removeConversation>>>()
|
||||
vi.mocked(removeConversation).mockReturnValueOnce(removal.promise)
|
||||
const deleting = store.deleteConversation(a)
|
||||
await store.setActiveConversation(a)
|
||||
expect(store.canSend).toBe(false)
|
||||
await store.sendMessage('blocked')
|
||||
expect(streamChat).not.toHaveBeenCalled()
|
||||
await store.setActiveConversation(b)
|
||||
expect(store.canSend).toBe(true)
|
||||
await store.sendMessage('belongs to b')
|
||||
const client = vi.mocked(streamChat).mock.results[0]!.value as SseClient
|
||||
removal.resolve(undefined)
|
||||
await deleting
|
||||
expect(store.activeConversationId).toBe(b)
|
||||
expect(store.messages[0]?.content).toBe('belongs to b')
|
||||
expect(store.isStreaming).toBe(true)
|
||||
expect(client.cancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
+188
-103
@@ -1,92 +1,206 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, reactive } from 'vue'
|
||||
import type { ChatMessage, Conversation } from '@/contracts'
|
||||
import { streamChat } from '@/services/chatService'
|
||||
import type { ChatMessage, Citation, Conversation } from '@/contracts'
|
||||
import {
|
||||
createConversation as createConversationApi,
|
||||
listConversationMessages,
|
||||
listConversations as listConversationsApi,
|
||||
removeConversation,
|
||||
streamChat,
|
||||
} from '@/services/chatService'
|
||||
import type { SseClient } from '@/services/sseClient'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
const conversations = ref<Conversation[]>([])
|
||||
const activeConversationId = ref<string | null>(null)
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const isStreaming = ref(false)
|
||||
const isPreparing = ref(false)
|
||||
const messagesReady = ref(true)
|
||||
const deletingConversations = reactive(new Set<string>())
|
||||
const canSend = computed(() => messagesReady.value && !isPreparing.value && !isStreaming.value
|
||||
&& (!activeConversationId.value || !deletingConversations.has(activeConversationId.value)))
|
||||
const inputText = ref('')
|
||||
const useRag = ref(true)
|
||||
const selectedSkillId = ref<string | null>(null)
|
||||
const selectedProviderId = ref('')
|
||||
const selectedModel = ref('')
|
||||
const historyError = ref('')
|
||||
let initialized = false
|
||||
let loading: Promise<void> | null = null
|
||||
let loadVersion = 0
|
||||
let sseClient: SseClient | null = null
|
||||
let streamVersion = 0
|
||||
|
||||
// User-created conversations live in this browser session; no fabricated history.
|
||||
const history = reactive<Record<string, ChatMessage[]>>({})
|
||||
const pendingCreates = new Map<string, Promise<void>>()
|
||||
|
||||
const activeConversation = computed(() =>
|
||||
conversations.value.find((c) => c.conversation_id === activeConversationId.value) || null
|
||||
conversations.value.find(item => item.conversation_id === activeConversationId.value) || null
|
||||
)
|
||||
|
||||
const sortedConversations = computed(() =>
|
||||
[...conversations.value].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
||||
)
|
||||
|
||||
function normalizeMessage(message: ChatMessage): ChatMessage {
|
||||
return {
|
||||
...message,
|
||||
citations: message.citations?.map(citation => ({
|
||||
...citation,
|
||||
heading_path: Array.isArray(citation.heading_path)
|
||||
? citation.heading_path.join(' / ')
|
||||
: citation.heading_path,
|
||||
} as Citation)),
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllConversations() {
|
||||
const items: Conversation[] = []
|
||||
while (true) {
|
||||
const result = await listConversationsApi(items.length, 100)
|
||||
items.push(...result.items)
|
||||
if (!result.items.length || items.length >= result.page.total) return items
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllMessages(conversationId: string) {
|
||||
const items: ChatMessage[] = []
|
||||
while (true) {
|
||||
const result = await listConversationMessages(conversationId, items.length, 500)
|
||||
items.push(...result.items)
|
||||
if (!result.items.length || items.length >= result.page.total) return items
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConversations(force = false) {
|
||||
if (loading) return loading
|
||||
if (initialized && !force) return
|
||||
const version = ++loadVersion
|
||||
messagesReady.value = false
|
||||
loading = (async () => {
|
||||
historyError.value = ''
|
||||
try {
|
||||
const items = await fetchAllConversations()
|
||||
if (version !== loadVersion) return
|
||||
conversations.value = items
|
||||
initialized = true
|
||||
const selected = activeConversationId.value && items.some(item => item.conversation_id === activeConversationId.value)
|
||||
? activeConversationId.value
|
||||
: items[0]?.conversation_id || null
|
||||
if (selected) await setActiveConversation(selected)
|
||||
else { activeConversationId.value = null; messages.value = []; messagesReady.value = true }
|
||||
} catch (error) {
|
||||
if (version === loadVersion) historyError.value = error instanceof Error ? error.message : t('聊天记录加载失败', 'Failed to load chat history')
|
||||
} finally {
|
||||
loading = null
|
||||
}
|
||||
})()
|
||||
return loading
|
||||
}
|
||||
|
||||
async function setActiveConversation(id: string) {
|
||||
stopGeneration()
|
||||
const version = ++loadVersion
|
||||
activeConversationId.value = id
|
||||
messages.value = history[id] ?? []
|
||||
messagesReady.value = false
|
||||
messages.value = []
|
||||
historyError.value = ''
|
||||
try {
|
||||
const loadedMessages = await fetchAllMessages(id)
|
||||
if (version === loadVersion && activeConversationId.value === id) {
|
||||
messages.value = loadedMessages.map(normalizeMessage)
|
||||
messagesReady.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
if (version === loadVersion) historyError.value = error instanceof Error ? error.message : t('消息加载失败', 'Failed to load messages')
|
||||
}
|
||||
}
|
||||
|
||||
function addLocalConversation(title: string) {
|
||||
loadVersion++
|
||||
const now = new Date().toISOString()
|
||||
const conversation: Conversation = {
|
||||
conversation_id: crypto.randomUUID(), title, created_at: now, updated_at: now, message_count: 0,
|
||||
}
|
||||
conversations.value.unshift(conversation)
|
||||
activeConversationId.value = conversation.conversation_id
|
||||
messages.value = []
|
||||
messagesReady.value = true
|
||||
return conversation
|
||||
}
|
||||
|
||||
async function persistConversation(conversation: Conversation) {
|
||||
const promise = createConversationApi(conversation).then(saved => {
|
||||
const index = conversations.value.findIndex(item => item.conversation_id === saved.conversation_id)
|
||||
if (index >= 0) Object.assign(conversations.value[index]!, saved)
|
||||
}).catch(error => {
|
||||
conversations.value = conversations.value.filter(item => item.conversation_id !== conversation.conversation_id)
|
||||
if (activeConversationId.value === conversation.conversation_id) {
|
||||
activeConversationId.value = null
|
||||
messages.value = []
|
||||
}
|
||||
historyError.value = error instanceof Error ? error.message : t('会话创建失败', 'Failed to create conversation')
|
||||
throw error
|
||||
}).finally(() => pendingCreates.delete(conversation.conversation_id))
|
||||
pendingCreates.set(conversation.conversation_id, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
async function createNewConversation() {
|
||||
stopGeneration()
|
||||
historyError.value = ''
|
||||
const conversation = addLocalConversation(t('新对话', 'New conversation'))
|
||||
try { await persistConversation(conversation) } catch { /* exposed through historyError */ }
|
||||
}
|
||||
|
||||
async function sendMessage(text: string) {
|
||||
if (!text.trim() || isStreaming.value || !selectedProviderId.value || !selectedModel.value) return
|
||||
const conversationId = activeConversationId.value || crypto.randomUUID()
|
||||
|
||||
if (!activeConversationId.value) {
|
||||
const newConv: Conversation = {
|
||||
conversation_id: conversationId,
|
||||
title: text.slice(0, 30),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
message_count: 0,
|
||||
const content = text.trim()
|
||||
if (!content || !canSend.value || !selectedProviderId.value || !selectedModel.value) return
|
||||
const version = ++streamVersion
|
||||
isPreparing.value = true
|
||||
historyError.value = ''
|
||||
let conversation = activeConversation.value
|
||||
try {
|
||||
if (!conversation) {
|
||||
conversation = addLocalConversation(content.slice(0, 30))
|
||||
await persistConversation(conversation)
|
||||
} else if (pendingCreates.has(conversation.conversation_id)) {
|
||||
await pendingCreates.get(conversation.conversation_id)
|
||||
}
|
||||
conversations.value.unshift(newConv)
|
||||
activeConversationId.value = conversationId
|
||||
} catch { return }
|
||||
finally {
|
||||
if (version === streamVersion) isPreparing.value = false
|
||||
}
|
||||
// Switching, stopping or deleting cancels sends still waiting for creation.
|
||||
if (version !== streamVersion || activeConversationId.value !== conversation.conversation_id) return
|
||||
|
||||
history[conversationId] = messages.value
|
||||
const conversationMessages = messages.value
|
||||
const conversationId = conversation.conversation_id
|
||||
if (conversation.message_count === 0) conversation.title = content.slice(0, 30)
|
||||
const userMsg: ChatMessage = {
|
||||
message_id: crypto.randomUUID(),
|
||||
conversation_id: conversationId,
|
||||
role: 'user',
|
||||
content: text,
|
||||
message_id: crypto.randomUUID(), conversation_id: conversationId, role: 'user', content,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
messages.value.push(userMsg)
|
||||
const aiMsg = reactive<ChatMessage>({
|
||||
message_id: crypto.randomUUID(), conversation_id: conversationId, role: 'assistant', content: '',
|
||||
created_at: new Date().toISOString(), citations: [], tool_calls: [],
|
||||
})
|
||||
messages.value.push(userMsg, aiMsg)
|
||||
inputText.value = ''
|
||||
isStreaming.value = true
|
||||
const conversation = conversations.value.find(c => c.conversation_id === conversationId)
|
||||
if (conversation) { conversation.updated_at = new Date().toISOString(); conversation.message_count = messages.value.length }
|
||||
conversation.updated_at = new Date().toISOString()
|
||||
conversation.message_count = messages.value.length
|
||||
|
||||
// 先插入占位消息,随后将 SSE 增量原位合并,避免每个 token 重建消息列表。
|
||||
const aiMsg = reactive<ChatMessage>({
|
||||
message_id: crypto.randomUUID(),
|
||||
conversation_id: conversationId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
created_at: new Date().toISOString(),
|
||||
citations: [],
|
||||
tool_calls: [],
|
||||
})
|
||||
messages.value.push(aiMsg)
|
||||
|
||||
const version = ++streamVersion
|
||||
const argumentBuffers = new Map<string, string>()
|
||||
sseClient = streamChat({
|
||||
provider_id: selectedProviderId.value,
|
||||
model: selectedModel.value,
|
||||
conversation_id: conversationId,
|
||||
user_message_id: userMsg.message_id,
|
||||
assistant_message_id: aiMsg.message_id,
|
||||
conversation_title: conversation.title,
|
||||
use_rag: useRag.value,
|
||||
messages: messages.value
|
||||
.filter((message) => message.message_id !== aiMsg.message_id)
|
||||
.map((message) => ({ role: message.role, content: message.content })),
|
||||
.filter(message => message.message_id !== aiMsg.message_id)
|
||||
.map(message => ({ role: message.role, content: message.content })),
|
||||
}, {
|
||||
onEvent(event) {
|
||||
if (version !== streamVersion) return
|
||||
@@ -94,25 +208,21 @@ export const useChatStore = defineStore('chat', () => {
|
||||
if (event.event === 'ThinkingDelta') aiMsg.thinking = `${aiMsg.thinking ?? ''}${String(event.data.text ?? '')}`
|
||||
if (event.event === 'ToolCallStart') {
|
||||
aiMsg.tool_calls?.push({
|
||||
tool_call_id: String(event.data.tool_call_id ?? ''),
|
||||
name: String(event.data.name ?? 'unknown'),
|
||||
parameters: (event.data.arguments ?? {}) as Record<string, unknown>,
|
||||
status: 'running',
|
||||
tool_call_id: String(event.data.tool_call_id ?? ''), name: String(event.data.name ?? 'unknown'),
|
||||
parameters: (event.data.arguments ?? {}) as Record<string, unknown>, status: 'running',
|
||||
})
|
||||
}
|
||||
if (event.event === 'ToolCallDelta') {
|
||||
const call = aiMsg.tool_calls?.find((item) => item.tool_call_id === event.data.tool_call_id)
|
||||
const call = aiMsg.tool_calls?.find(item => item.tool_call_id === event.data.tool_call_id)
|
||||
if (call && typeof event.data.arguments_delta === 'string') {
|
||||
const buffer = (argumentBuffers.get(call.tool_call_id) ?? '') + event.data.arguments_delta
|
||||
argumentBuffers.set(call.tool_call_id, buffer)
|
||||
try { call.parameters = JSON.parse(buffer) } catch { /* incomplete JSON fragment */ }
|
||||
}
|
||||
if (call && event.data.arguments && typeof event.data.arguments === 'object') {
|
||||
Object.assign(call.parameters, event.data.arguments)
|
||||
}
|
||||
if (call && event.data.arguments && typeof event.data.arguments === 'object') Object.assign(call.parameters, event.data.arguments)
|
||||
}
|
||||
if (event.event === 'ToolCallEnd') {
|
||||
const call = aiMsg.tool_calls?.find((item) => item.tool_call_id === event.data.tool_call_id)
|
||||
const call = aiMsg.tool_calls?.find(item => item.tool_call_id === event.data.tool_call_id)
|
||||
if (call) call.status = 'completed'
|
||||
}
|
||||
if (event.event === 'Usage') {
|
||||
@@ -128,21 +238,18 @@ export const useChatStore = defineStore('chat', () => {
|
||||
content: String(event.data.content ?? event.data.snippet ?? ''),
|
||||
})
|
||||
}
|
||||
if (event.event === 'Error') aiMsg.content += `\n\n生成失败:${String(event.data.message ?? '未知错误')}`
|
||||
if (event.event === 'Error') aiMsg.content += `\n\n${t('生成失败:', 'Generation failed: ')}${String(event.data.message ?? t('未知错误', 'Unknown error'))}`
|
||||
},
|
||||
onError(error) {
|
||||
if (version !== streamVersion) return
|
||||
aiMsg.content += `\n\n连接失败:${error.message}`
|
||||
aiMsg.content += `\n\n${t('连接失败:', 'Connection failed: ')}${error.message}`
|
||||
isStreaming.value = false
|
||||
sseClient = null
|
||||
},
|
||||
onDone() {
|
||||
if (version !== streamVersion) return
|
||||
const conversation = conversations.value.find((item) => item.conversation_id === conversationId)
|
||||
if (conversation) {
|
||||
conversation.message_count = conversationMessages.length
|
||||
conversation.updated_at = new Date().toISOString()
|
||||
}
|
||||
conversation!.message_count = messages.value.length
|
||||
conversation!.updated_at = new Date().toISOString()
|
||||
isStreaming.value = false
|
||||
sseClient = null
|
||||
},
|
||||
@@ -151,57 +258,35 @@ export const useChatStore = defineStore('chat', () => {
|
||||
|
||||
function stopGeneration() {
|
||||
streamVersion++
|
||||
if (sseClient) {
|
||||
sseClient.cancel()
|
||||
sseClient = null
|
||||
}
|
||||
isPreparing.value = false
|
||||
if (sseClient) { sseClient.cancel(); sseClient = null }
|
||||
isStreaming.value = false
|
||||
}
|
||||
|
||||
function createNewConversation() {
|
||||
stopGeneration()
|
||||
const newConv: Conversation = {
|
||||
conversation_id: crypto.randomUUID(),
|
||||
title: '新对话',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
message_count: 0,
|
||||
}
|
||||
conversations.value.unshift(newConv)
|
||||
activeConversationId.value = newConv.conversation_id
|
||||
history[newConv.conversation_id] = []
|
||||
messages.value = history[newConv.conversation_id]
|
||||
}
|
||||
|
||||
function deleteConversation(id: string) {
|
||||
async function deleteConversation(id: string) {
|
||||
if (deletingConversations.has(id)) return
|
||||
deletingConversations.add(id)
|
||||
if (activeConversationId.value === id) stopGeneration()
|
||||
delete history[id]
|
||||
const idx = conversations.value.findIndex((c) => c.conversation_id === id)
|
||||
if (idx > -1) {
|
||||
conversations.value.splice(idx, 1)
|
||||
historyError.value = ''
|
||||
try {
|
||||
if (pendingCreates.has(id)) await pendingCreates.get(id)
|
||||
await removeConversation(id)
|
||||
conversations.value = conversations.value.filter(item => item.conversation_id !== id)
|
||||
if (activeConversationId.value === id) {
|
||||
activeConversationId.value = conversations.value[0]?.conversation_id || null
|
||||
messages.value = conversations.value[0] ? history[conversations.value[0].conversation_id] || [] : []
|
||||
const next = sortedConversations.value[0]
|
||||
if (next) await setActiveConversation(next.conversation_id)
|
||||
else { loadVersion++; activeConversationId.value = null; messages.value = []; messagesReady.value = true }
|
||||
}
|
||||
} catch (error) {
|
||||
historyError.value = error instanceof Error ? error.message : t('会话删除失败', 'Failed to delete conversation')
|
||||
} finally {
|
||||
deletingConversations.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
conversations,
|
||||
activeConversationId,
|
||||
activeConversation,
|
||||
sortedConversations,
|
||||
messages,
|
||||
isStreaming,
|
||||
inputText,
|
||||
useRag,
|
||||
selectedSkillId,
|
||||
selectedProviderId,
|
||||
selectedModel,
|
||||
setActiveConversation,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
createNewConversation,
|
||||
deleteConversation,
|
||||
conversations, activeConversationId, activeConversation, sortedConversations, messages,
|
||||
isStreaming, isPreparing, canSend, inputText, useRag, selectedSkillId, selectedProviderId, selectedModel, historyError,
|
||||
loadConversations, setActiveConversation, sendMessage, stopGeneration, createNewConversation, deleteConversation,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { SaveStatus } from '@/contracts'
|
||||
import * as workspaceService from '@/services/workspaceService'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export const useEditorStore = defineStore('editor', () => {
|
||||
const mode = ref<'wysiwyg' | 'source'>('wysiwyg')
|
||||
@@ -76,12 +77,12 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
saveTimer = null
|
||||
}
|
||||
if (saveStatus.value === 'conflict') {
|
||||
throw new Error('当前文件存在编辑冲突,请处理后再切换文件。')
|
||||
throw new Error(t('当前文件存在编辑冲突,请处理后再切换文件。', 'The current file has an editing conflict. Resolve it before switching files.'))
|
||||
}
|
||||
if (pendingSave) await pendingSave
|
||||
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') await save()
|
||||
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') {
|
||||
throw new Error('当前文件保存失败,已阻止切换以避免内容丢失。')
|
||||
throw new Error(t('当前文件保存失败,已阻止切换以避免内容丢失。', 'The current file could not be saved. Switching was blocked to prevent data loss.'))
|
||||
}
|
||||
// 版本号使较慢的旧读取不能覆盖用户后选择的新文件。
|
||||
const version = ++loadVersion
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Plugin } from '@/contracts'
|
||||
import * as pluginService from '@/services/pluginService'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export const usePluginStore = defineStore('plugin', () => {
|
||||
const plugins = ref<Plugin[]>([])
|
||||
@@ -23,7 +24,7 @@ export const usePluginStore = defineStore('plugin', () => {
|
||||
plugins.value = await pluginService.listPlugins()
|
||||
error.value = null
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : 'Plugin 加载失败'
|
||||
error.value = reason instanceof Error ? reason.message : t('Plugin 加载失败', 'Failed to load Plugins')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue'
|
||||
import type { ProviderConfig, ModelInfo, ProviderPreset } from '@/contracts'
|
||||
import { createProvider, deleteProvider as deleteProviderRequest, getCredentialStatus, listModels, listProviderPresets, listProviders, putCredential, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService'
|
||||
import { ApiErrorClass } from '@/services/apiClient'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export const useProviderStore = defineStore('provider', () => {
|
||||
const providers = ref<ProviderConfig[]>([])
|
||||
@@ -29,7 +30,7 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
}
|
||||
error.value = null
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : 'Provider 加载失败'
|
||||
error.value = reason instanceof Error ? reason.message : t('Provider 加载失败', 'Failed to load Providers')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -39,7 +40,7 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
try {
|
||||
presets.value = await listProviderPresets()
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : 'Provider 预设加载失败'
|
||||
error.value = reason instanceof Error ? reason.message : t('Provider 预设加载失败', 'Failed to load Provider presets')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +56,11 @@ export const useProviderStore = defineStore('provider', () => {
|
||||
} catch (reason) {
|
||||
const provider = providers.value.find((item) => item.provider_id === providerId)
|
||||
const credentialId = provider?.credential_id
|
||||
let message = reason instanceof Error ? reason.message : '模型列表获取失败'
|
||||
let message = reason instanceof Error ? reason.message : t('模型列表获取失败', 'Failed to load the model list')
|
||||
if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_CREDENTIAL_MISSING') {
|
||||
message = '尚未配置 API Key,请编辑该 Provider 后填写并保存。'
|
||||
message = t('尚未配置 API Key,请编辑该 Provider 后填写并保存。', 'No API key is configured. Edit this Provider, enter a key, and save it.')
|
||||
} else if (reason instanceof ApiErrorClass && reason.code === 'PROVIDER_AUTH_FAILED') {
|
||||
message = `鉴权失败,请检查凭据“${credentialId || '未设置'}”对应的 API Key 是否有效。`
|
||||
message = t(`鉴权失败,请检查凭据“${credentialId || '未设置'}”对应的 API Key 是否有效。`, `Authentication failed. Check the API key for credential “${credentialId || 'not set'}”.`)
|
||||
}
|
||||
modelErrorsByProvider.value[providerId] = message
|
||||
throw reason
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
||||
import type { SearchResult, SearchRequest } from '@/contracts'
|
||||
import * as searchService from '@/services/searchService'
|
||||
import { ApiErrorClass } from '@/services/apiClient'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const VECTOR_ERROR_CODES = new Set([
|
||||
'SEMANTIC_INDEX_UNAVAILABLE',
|
||||
@@ -28,7 +29,7 @@ export const useSearchStore = defineStore('search', () => {
|
||||
if (version !== historyVersion) return
|
||||
recentQueries.value = response.queries
|
||||
historyError.value = ''
|
||||
} catch { if (version === historyVersion) historyError.value = '无法读取应用搜索记录,请检查后端连接。' }
|
||||
} catch { if (version === historyVersion) historyError.value = t('无法读取应用搜索记录,请检查后端连接。', 'Could not load search history. Check the backend connection.') }
|
||||
}
|
||||
async function clearHistory() {
|
||||
const version = ++historyVersion
|
||||
@@ -36,7 +37,7 @@ export const useSearchStore = defineStore('search', () => {
|
||||
await searchService.clearHistory()
|
||||
if (version !== historyVersion) return
|
||||
recentQueries.value = []; historyError.value = ''
|
||||
} catch { if (version === historyVersion) historyError.value = '清空搜索记录失败,请重试。' }
|
||||
} catch { if (version === historyVersion) historyError.value = t('清空搜索记录失败,请重试。', 'Failed to clear search history. Please retry.') }
|
||||
}
|
||||
const error = ref<string | null>(null)
|
||||
const vectorUnavailable = ref(false)
|
||||
@@ -71,12 +72,12 @@ export const useSearchStore = defineStore('search', () => {
|
||||
selectedIndex.value = 0
|
||||
} catch (fallbackError) {
|
||||
if (version !== searchVersion) return
|
||||
error.value = fallbackError instanceof Error ? fallbackError.message : '全文检索降级失败'
|
||||
error.value = fallbackError instanceof Error ? fallbackError.message : t('全文检索降级失败', 'Full-text search fallback failed')
|
||||
results.value = []
|
||||
total.value = 0
|
||||
}
|
||||
} else {
|
||||
error.value = reason instanceof Error ? reason.message : '搜索失败'
|
||||
error.value = reason instanceof Error ? reason.message : t('搜索失败', 'Search failed')
|
||||
results.value = []
|
||||
total.value = 0
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { resolveApiUrl } from '@/services/apiClient'
|
||||
import packageInfo from '../../package.json'
|
||||
import * as indexService from '@/services/indexService'
|
||||
import * as systemService from '@/services/systemService'
|
||||
import { appLocale, t } from '@/i18n'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const saved = (() => {
|
||||
@@ -14,9 +15,9 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
// General
|
||||
const restoreLastVault = ref(saved.restoreLastVault !== false)
|
||||
const autoSaveInterval = ref(typeof saved.autoSaveInterval === 'number' ? saved.autoSaveInterval : 1500)
|
||||
const language = ref<'zh-CN' | 'en'>(saved.language === 'en' ? 'en' : 'zh-CN')
|
||||
const language = appLocale
|
||||
const appVersion = ref(packageInfo.version)
|
||||
const aiCoreVersion = ref('未获取')
|
||||
const aiCoreVersion = ref('—')
|
||||
|
||||
// Editor
|
||||
const defaultEditorMode = ref<'wysiwyg' | 'source'>(saved.defaultEditorMode === 'source' ? 'source' : 'wysiwyg')
|
||||
@@ -47,10 +48,10 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
])
|
||||
const [health, status, index, policy] = results
|
||||
aiCoreStatus.value = health.status === 'fulfilled' && health.value.status === 'ok' ? 'running' : 'error'
|
||||
aiCoreVersion.value = status.status === 'fulfilled' ? status.value.version : '未获取'
|
||||
aiCoreVersion.value = status.status === 'fulfilled' ? status.value.version : '—'
|
||||
indexStatus.value = index.status === 'fulfilled' ? index.value : emptyIndex()
|
||||
permissionPolicy.value = policy.status === 'fulfilled' ? policy.value : {}
|
||||
diagnosticsError.value = results.filter(item => item.status === 'rejected').map(item => item.reason instanceof Error ? item.reason.message : '后端请求失败').join(';') || null
|
||||
diagnosticsError.value = results.filter(item => item.status === 'rejected').map(item => item.reason instanceof Error ? item.reason.message : t('后端请求失败', 'Backend request failed')).join(t(';', '; ')) || null
|
||||
}
|
||||
|
||||
function setAutoSaveInterval(ms: number) {
|
||||
@@ -68,7 +69,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
indexStatus.value = await indexService.getIndexStatus()
|
||||
} catch (reason) {
|
||||
indexStatus.value.status = 'error'
|
||||
indexStatus.value.error = reason instanceof Error ? reason.message : '索引重建失败'
|
||||
indexStatus.value.error = reason instanceof Error ? reason.message : t('索引重建失败', 'Index rebuild failed')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Skill } from '@/contracts'
|
||||
import * as skillService from '@/services/skillService'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export const useSkillStore = defineStore('skill', () => {
|
||||
const skills = ref<Skill[]>([])
|
||||
@@ -23,7 +24,7 @@ export const useSkillStore = defineStore('skill', () => {
|
||||
skills.value = await skillService.listSkills()
|
||||
error.value = null
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : 'Skill 加载失败'
|
||||
error.value = reason instanceof Error ? reason.message : t('Skill 加载失败', 'Failed to load Skills')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { TaskItem, TaskStatus, TaskPriority, TaskSource } from '@/contracts'
|
||||
import { createTask as createTaskRequest, deleteTask as deleteTaskRequest, listTasks, updateTask as updateTaskRequest } from '@/services/taskService'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export const useTaskStore = defineStore('task', () => {
|
||||
const tasks = ref<TaskItem[]>([])
|
||||
@@ -31,7 +32,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||
tasks.value = resp.items
|
||||
error.value = null
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : '任务加载失败'
|
||||
error.value = reason instanceof Error ? reason.message : t('任务加载失败', 'Failed to load tasks')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock('@/services/themePackageService', () => ({
|
||||
installTheme: vi.fn(),
|
||||
uninstallTheme: vi.fn(),
|
||||
installCommunityTheme: vi.fn(),
|
||||
setActiveCustomTheme: vi.fn(),
|
||||
}))
|
||||
|
||||
const listInstalledThemes = vi.mocked(themePkg.listInstalledThemes)
|
||||
|
||||
@@ -2,11 +2,12 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { ThemeConfig, ThemeManifest, InstalledTheme, ThemePackageInspection } from '@/contracts'
|
||||
import * as themePkg from '@/services/themePackageService'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const builtinThemes: ThemeConfig[] = [
|
||||
{ theme_id: 'light', name: '浅色', version: '1.0.0', description: '默认浅色主题', is_dark: false, builtin: true, code_theme: 'github-light' },
|
||||
{ theme_id: 'dark', name: '深色', version: '1.0.0', description: '默认深色主题', is_dark: true, builtin: true, code_theme: 'github-dark' },
|
||||
{ theme_id: 'sepia', name: '护眼', version: '1.0.0', description: '护眼暖色调', is_dark: false, builtin: true, code_theme: 'github-light' },
|
||||
const builtinThemes = (): ThemeConfig[] => [
|
||||
{ theme_id: 'light', name: t('浅色', 'Light'), version: '1.0.0', description: t('默认浅色主题', 'Default light theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
|
||||
{ theme_id: 'dark', name: t('深色', 'Dark'), version: '1.0.0', description: t('默认深色主题', 'Default dark theme'), is_dark: true, builtin: true, code_theme: 'github-dark' },
|
||||
{ theme_id: 'sepia', name: t('护眼', 'Sepia'), version: '1.0.0', description: t('护眼暖色调', 'Warm, low-glare theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
|
||||
]
|
||||
|
||||
export type CodeBlockThemePreference = 'auto' | 'github-light' | 'github-dark'
|
||||
@@ -38,7 +39,7 @@ const builtinToInstalled = (t: ThemeConfig): InstalledTheme => ({
|
||||
})
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const themes = ref<ThemeConfig[]>([...builtinThemes])
|
||||
const themes = computed<ThemeConfig[]>(() => [...builtinThemes(), ...installedCustomThemes.value.map(theme => ({ ...theme, description: theme.description ?? '' }))])
|
||||
const installedCustomThemes = ref<InstalledTheme[]>([])
|
||||
const currentThemeId = ref<string>('light')
|
||||
const fontEditorSize = ref(15)
|
||||
@@ -53,7 +54,7 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
let appearanceHydrated = false
|
||||
|
||||
const allThemes = computed<InstalledTheme[]>(() => [
|
||||
...builtinThemes.map(builtinToInstalled),
|
||||
...builtinThemes().map(builtinToInstalled),
|
||||
...installedCustomThemes.value,
|
||||
])
|
||||
|
||||
@@ -72,6 +73,7 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
function applyTheme(themeId: string, options: { persist?: boolean } = {}): boolean {
|
||||
const theme = allThemes.value.find((t) => t.theme_id === themeId)
|
||||
if (!theme) return false
|
||||
themePkg.setActiveCustomTheme(theme.builtin ? null : themeId)
|
||||
currentThemeId.value = themeId
|
||||
const root = document.documentElement
|
||||
if (theme.builtin) {
|
||||
@@ -90,7 +92,7 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
}
|
||||
|
||||
function isBuiltinThemeId(themeId: string): boolean {
|
||||
return builtinThemes.some((t) => t.theme_id === themeId)
|
||||
return builtinThemes().some((t) => t.theme_id === themeId)
|
||||
}
|
||||
|
||||
function systemThemeId(): string {
|
||||
@@ -142,16 +144,6 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
try {
|
||||
const list = await themePkg.listInstalledThemes()
|
||||
installedCustomThemes.value = list
|
||||
themes.value = [...builtinThemes, ...list.map((t) => ({
|
||||
theme_id: t.theme_id,
|
||||
name: t.name,
|
||||
version: t.version,
|
||||
description: t.description ?? '',
|
||||
is_dark: t.is_dark,
|
||||
builtin: false,
|
||||
author: t.author,
|
||||
code_theme: t.code_theme,
|
||||
}))]
|
||||
themeLoadWarning.value = null
|
||||
} catch (error) {
|
||||
// 只保留内置主题,但要让用户知道自定义主题这次没加载上。
|
||||
@@ -206,19 +198,7 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
const idx = installedCustomThemes.value.findIndex((t) => t.theme_id === installed.theme_id)
|
||||
if (idx >= 0) installedCustomThemes.value[idx] = installed
|
||||
else installedCustomThemes.value.push(installed)
|
||||
const themeConfig: ThemeConfig = {
|
||||
theme_id: installed.theme_id,
|
||||
name: installed.name,
|
||||
version: installed.version,
|
||||
description: installed.description ?? '',
|
||||
is_dark: installed.is_dark,
|
||||
builtin: false,
|
||||
author: installed.author,
|
||||
code_theme: installed.code_theme,
|
||||
}
|
||||
const existingIdx = themes.value.findIndex((t) => t.theme_id === installed.theme_id)
|
||||
if (existingIdx >= 0) themes.value[existingIdx] = themeConfig
|
||||
else themes.value.push(themeConfig)
|
||||
if (currentThemeId.value === installed.theme_id) applyTheme(installed.theme_id)
|
||||
pendingInspection.value = null
|
||||
return installed
|
||||
} catch (error) {
|
||||
@@ -232,7 +212,6 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
async function uninstallTheme(themeId: string) {
|
||||
await themePkg.uninstallTheme(themeId)
|
||||
installedCustomThemes.value = installedCustomThemes.value.filter((t) => t.theme_id !== themeId)
|
||||
themes.value = themes.value.filter((t) => t.theme_id !== themeId || t.builtin)
|
||||
if (currentThemeId.value === themeId) {
|
||||
applyTheme('light')
|
||||
}
|
||||
@@ -246,19 +225,7 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
const idx = installedCustomThemes.value.findIndex((t) => t.theme_id === installed.theme_id)
|
||||
if (idx >= 0) installedCustomThemes.value[idx] = installed
|
||||
else installedCustomThemes.value.push(installed)
|
||||
const themeConfig: ThemeConfig = {
|
||||
theme_id: installed.theme_id,
|
||||
name: installed.name,
|
||||
version: installed.version,
|
||||
description: installed.description ?? '',
|
||||
is_dark: installed.is_dark,
|
||||
builtin: false,
|
||||
author: installed.author,
|
||||
code_theme: installed.code_theme,
|
||||
}
|
||||
const existingIdx = themes.value.findIndex((t) => t.theme_id === installed.theme_id)
|
||||
if (existingIdx >= 0) themes.value[existingIdx] = themeConfig
|
||||
else themes.value.push(themeConfig)
|
||||
if (currentThemeId.value === installed.theme_id) applyTheme(installed.theme_id)
|
||||
return installed
|
||||
} catch (error) {
|
||||
importError.value = error instanceof Error ? error.message : '安装失败'
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useThemeStore } from './theme'
|
||||
import { installTheme } from '@/services/themePackageService'
|
||||
import type { ThemeManifest } from '@/contracts'
|
||||
|
||||
const manifest = (id: string): ThemeManifest => ({ theme_id: id, name: id, version: '1.0.0', author: 'test', min_app_version: '0.1.0', is_dark: false, css_entry: 'theme.css' })
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.head.querySelectorAll('style[id^="theme-style-"]').forEach(el => el.remove())
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('does not apply installed CSS until selected and removes it when returning to a builtin theme', async () => {
|
||||
const store = useThemeStore()
|
||||
store.applyTheme('light')
|
||||
const initialColor = getComputedStyle(document.body).color
|
||||
await store.installThemeFromInspection(manifest('first'), 'body { color: rgb(1, 2, 3) !important; }')
|
||||
await store.loadCustomThemes()
|
||||
expect(getComputedStyle(document.body).color).toBe(initialColor)
|
||||
expect(document.head.querySelectorAll('style[id^="theme-style-"]')).toHaveLength(0)
|
||||
store.applyTheme('first')
|
||||
expect(getComputedStyle(document.body).color).toBe('rgb(1, 2, 3)')
|
||||
store.applyTheme('dark')
|
||||
expect(getComputedStyle(document.body).color).toBe(initialColor)
|
||||
expect(document.head.querySelectorAll('style[id^="theme-style-"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps only the selected custom theme mounted, including after a list reload', async () => {
|
||||
const store = useThemeStore()
|
||||
await installTheme(manifest('first'), 'body { color: rgb(1, 2, 3) !important; }')
|
||||
await installTheme(manifest('second'), 'body { background-color: rgb(4, 5, 6) !important; }')
|
||||
await store.loadCustomThemes()
|
||||
store.applyTheme('first')
|
||||
await store.loadCustomThemes()
|
||||
expect(document.head.querySelectorAll('style[id^="theme-style-"]')).toHaveLength(1)
|
||||
store.applyTheme('second')
|
||||
expect(document.getElementById('theme-style-first')).toBeNull()
|
||||
expect(document.getElementById('theme-style-second')).not.toBeNull()
|
||||
await store.uninstallTheme('second')
|
||||
expect(store.currentThemeId).toBe('light')
|
||||
expect(document.head.querySelectorAll('style[id^="theme-style-"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('restores only the saved custom theme on startup', async () => {
|
||||
await installTheme(manifest('first'), 'body { color: rgb(1, 2, 3); }')
|
||||
await installTheme(manifest('second'), 'body { color: rgb(4, 5, 6); }')
|
||||
localStorage.setItem('theme', 'first')
|
||||
const store = useThemeStore()
|
||||
await store.initTheme()
|
||||
expect(store.currentThemeId).toBe('first')
|
||||
expect(document.getElementById('theme-style-first')).not.toBeNull()
|
||||
expect(document.getElementById('theme-style-second')).toBeNull()
|
||||
})
|
||||
Reference in New Issue
Block a user