fix(frontend): 同步 main 并修复 phase2 关闭审阅意见

This commit is contained in:
2026-09-05 17:32:34 +08:00
109 changed files with 3926 additions and 1019 deletions
+2 -1
View File
@@ -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)) {
+248 -11
View File
@@ -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
View File
@@ -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,
}
})
+3 -2
View File
@@ -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 -1
View File
@@ -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
}
+6 -5
View File
@@ -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
+5 -4
View File
@@ -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
}
+6 -5
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
}
+1
View File
@@ -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)
+11 -44
View File
@@ -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()
})