fix(frontend): 移除运行时演示数据并接入真实后端状态

This commit is contained in:
2026-09-04 07:47:05 +08:00
parent 2e496462a9
commit c04f4c1989
34 changed files with 332 additions and 869 deletions
+15 -13
View File
@@ -1,21 +1,21 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts'
import { mockAgentRuns, mockAgentEvents, mockTools, mockPermissionRequest } from '@/services/agentService'
import * as agentService from '@/services/agentService'
import type { SseClient } from '@/services/sseClient'
export const useAgentStore = defineStore('agent', () => {
const runs = ref<AgentRun[]>(mockAgentRuns)
const activeRunId = ref<string | null>('run-1')
const events = ref<AgentEvent[]>(mockAgentEvents.filter((e) => e.run_id === 'run-1'))
const tools = ref<ToolDefinition[]>(mockTools)
const runs = ref<AgentRun[]>([])
const activeRunId = ref<string | null>(null)
const events = ref<AgentEvent[]>([])
const tools = ref<ToolDefinition[]>([])
const isCreating = ref(false)
const isRunning = ref(false)
const permissionRequest = ref<PermissionRequest | null>(null)
const toolCalls = ref<ToolCall[]>([])
const error = ref<string | null>(null)
let eventStream: SseClient | null = null
let selectionVersion = 0
const activeRun = computed(() =>
runs.value.find((r) => r.run_id === activeRunId.value) || null
@@ -40,9 +40,15 @@ export const useAgentStore = defineStore('agent', () => {
}
async function loadRun(runId: string) {
const version = ++selectionVersion
eventStream?.cancel()
activeRunId.value = runId
events.value = []
toolCalls.value = []
permissionRequest.value = null
isRunning.value = false
const run = await agentService.getAgentRun(runId)
if (version !== selectionVersion) return
const existingIndex = runs.value.findIndex((item) => item.run_id === runId)
if (existingIndex >= 0) runs.value[existingIndex] = run
else runs.value.unshift(run)
@@ -106,9 +112,9 @@ export const useAgentStore = defineStore('agent', () => {
isRunning.value = true
error.value = null
eventStream = agentService.streamAgentEvents(runId, {
onEvent: processEvent,
onError(streamError) { error.value = streamError.message; isRunning.value = false },
onDone() { isRunning.value = false; eventStream = null },
onEvent(event) { if (activeRunId.value === runId) processEvent(event) },
onError(streamError) { if (activeRunId.value === runId) { error.value = streamError.message; isRunning.value = false } },
onDone() { if (activeRunId.value === runId) { isRunning.value = false; eventStream = null } },
})
}
@@ -116,6 +122,7 @@ export const useAgentStore = defineStore('agent', () => {
isCreating.value = true
try {
const run = await agentService.createAgentRun(request)
selectionVersion++
runs.value.unshift(run)
activeRunId.value = run.run_id
events.value = []
@@ -143,10 +150,6 @@ export const useAgentStore = defineStore('agent', () => {
permissionRequest.value = null
}
function showPermissionDemo() {
permissionRequest.value = mockPermissionRequest
}
return {
runs,
activeRunId,
@@ -166,6 +169,5 @@ export const useAgentStore = defineStore('agent', () => {
createRun,
cancelRun,
respondPermission,
showPermissionDemo,
}
})
+43
View File
@@ -0,0 +1,43 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useChatStore } from './chat'
import { streamChat } from '@/services/chatService'
import type { SseClient } from '@/services/sseClient'
vi.mock('@/services/chatService', () => ({ streamChat: vi.fn() }))
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(streamChat).mockReset().mockReturnValue({ cancel: vi.fn() } as unknown as SseClient)
})
it('sends real user history, applies streaming changes, and restores it when switching conversations', 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.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.onDone?.()
const id = store.activeConversationId!
store.createNewConversation()
expect(store.messages).toEqual([])
await store.setActiveConversation(id)
expect(store.messages.map(m => m.content)).toEqual(['user input', 'real response'])
})
it('does not send without a provider and ignores late callbacks from a cancelled conversation', async () => {
const store = useChatStore()
await store.sendMessage('no provider')
expect(streamChat).not.toHaveBeenCalled()
store.selectedProviderId = 'real'
store.selectedModel = 'configured-model'
await store.sendMessage('first')
const old = vi.mocked(streamChat).mock.calls[0]![1]
store.createNewConversation()
await store.sendMessage('second')
old.onDone?.()
expect(store.isStreaming).toBe(true)
expect(store.messages[0]?.content).toBe('second')
})
+43 -21
View File
@@ -1,22 +1,24 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ref, computed, reactive } from 'vue'
import type { ChatMessage, Conversation } from '@/contracts'
import { mockConversations, mockMessages, streamChat } from '@/services/chatService'
import { streamChat } from '@/services/chatService'
import type { SseClient } from '@/services/sseClient'
export const useChatStore = defineStore('chat', () => {
const conversations = ref<Conversation[]>(mockConversations)
const activeConversationId = ref<string | null>('conv-1')
const messages = ref<ChatMessage[]>(mockMessages['conv-1'] || [])
const conversations = ref<Conversation[]>([])
const activeConversationId = ref<string | null>(null)
const messages = ref<ChatMessage[]>([])
const isStreaming = ref(false)
const inputText = ref('')
const useRag = ref(true)
const useRag = ref(false)
const selectedSkillId = ref<string | null>(null)
const selectedProviderId = ref('mock')
const selectedModel = ref('mock-1')
const selectedProviderId = ref('')
const selectedModel = ref('')
let sseClient: SseClient | null = null
let streamVersion = 0
// TODO(chat): 会话持久化接口完成后移除 mockConversations/mockMessages 数据源。
// User-created conversations live in this browser session; no fabricated history.
const history = reactive<Record<string, ChatMessage[]>>({})
const activeConversation = computed(() =>
conversations.value.find((c) => c.conversation_id === activeConversationId.value) || null
@@ -27,13 +29,14 @@ export const useChatStore = defineStore('chat', () => {
)
async function setActiveConversation(id: string) {
stopGeneration()
activeConversationId.value = id
messages.value = mockMessages[id] || []
messages.value = history[id] ?? []
}
async function sendMessage(text: string) {
if (!text.trim() || isStreaming.value) return
const conversationId = activeConversationId.value || `conv-${Date.now()}`
if (!text.trim() || isStreaming.value || !selectedProviderId.value || !selectedModel.value) return
const conversationId = activeConversationId.value || crypto.randomUUID()
if (!activeConversationId.value) {
const newConv: Conversation = {
@@ -47,8 +50,10 @@ export const useChatStore = defineStore('chat', () => {
activeConversationId.value = conversationId
}
history[conversationId] = messages.value
const conversationMessages = messages.value
const userMsg: ChatMessage = {
message_id: `msg-${Date.now()}`,
message_id: crypto.randomUUID(),
conversation_id: conversationId,
role: 'user',
content: text,
@@ -57,29 +62,34 @@ export const useChatStore = defineStore('chat', () => {
messages.value.push(userMsg)
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 }
// 先插入占位消息,随后将 SSE 增量原位合并,避免每个 token 重建消息列表。
const aiMsg: ChatMessage = {
message_id: `msg-${Date.now() + 1}`,
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,
use_rag: useRag.value,
messages: messages.value
.filter((message) => message !== aiMsg)
.filter((message) => message.message_id !== aiMsg.message_id)
.map((message) => ({ role: message.role, content: message.content })),
}, {
onEvent(event) {
if (version !== streamVersion) return
if (event.event === 'TextDelta') aiMsg.content += String(event.data.text ?? '')
if (event.event === 'ThinkingDelta') aiMsg.thinking = `${aiMsg.thinking ?? ''}${String(event.data.text ?? '')}`
if (event.event === 'ToolCallStart') {
@@ -92,6 +102,11 @@ export const useChatStore = defineStore('chat', () => {
}
if (event.event === 'ToolCallDelta') {
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)
}
@@ -116,14 +131,16 @@ export const useChatStore = defineStore('chat', () => {
if (event.event === 'Error') aiMsg.content += `\n\n生成失败:${String(event.data.message ?? '未知错误')}`
},
onError(error) {
if (version !== streamVersion) return
aiMsg.content += `\n\n连接失败:${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 = messages.value.length
conversation.message_count = conversationMessages.length
conversation.updated_at = new Date().toISOString()
}
isStreaming.value = false
@@ -133,6 +150,7 @@ export const useChatStore = defineStore('chat', () => {
}
function stopGeneration() {
streamVersion++
if (sseClient) {
sseClient.cancel()
sseClient = null
@@ -141,8 +159,9 @@ export const useChatStore = defineStore('chat', () => {
}
function createNewConversation() {
stopGeneration()
const newConv: Conversation = {
conversation_id: `conv-${Date.now()}`,
conversation_id: crypto.randomUUID(),
title: '新对话',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
@@ -150,16 +169,19 @@ export const useChatStore = defineStore('chat', () => {
}
conversations.value.unshift(newConv)
activeConversationId.value = newConv.conversation_id
messages.value = []
history[newConv.conversation_id] = []
messages.value = history[newConv.conversation_id]
}
function deleteConversation(id: string) {
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)
if (activeConversationId.value === id) {
activeConversationId.value = conversations.value[0]?.conversation_id || null
messages.value = conversations.value[0] ? mockMessages[conversations.value[0].conversation_id] || [] : []
messages.value = conversations.value[0] ? history[conversations.value[0].conversation_id] || [] : []
}
}
}
+67
View File
@@ -0,0 +1,67 @@
// @vitest-environment happy-dom
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useAgentStore } from './agent'
import { useChatStore } from './chat'
import { useTaskStore } from './task'
import { usePluginStore } from './plugin'
import { useSkillStore } from './skill'
import { useProviderStore } from './provider'
import { useSettingsStore } from './settings'
import { listProviders } from '@/services/providerService'
import { getStatus } from '@/services/systemService'
beforeEach(() => { setActivePinia(createPinia()); localStorage.clear() })
afterEach(() => vi.unstubAllGlobals())
describe('runtime data sources', () => {
it('starts with no fabricated domain records or healthy diagnostics', () => {
expect(useAgentStore().runs).toEqual([])
expect(useAgentStore().events).toEqual([])
expect(useAgentStore().tools).toEqual([])
expect(useAgentStore().permissionRequest).toBeNull()
expect(useChatStore().conversations).toEqual([])
expect(useChatStore().messages).toEqual([])
expect(useTaskStore().tasks).toEqual([])
expect(usePluginStore().plugins).toEqual([])
expect(useSkillStore().skills).toEqual([])
expect(useProviderStore().providers).toEqual([])
expect(useProviderStore().defaultProviderId).toBe('')
expect(useSettingsStore().aiCoreStatus).toBe('unknown')
expect(useSettingsStore().indexStatus.total_notes).toBeNull()
expect(useSettingsStore().permissionPolicy).toEqual({})
})
it('keeps initial collections empty and exposes errors when the API is offline', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')))
const stores = [useTaskStore(), usePluginStore(), useSkillStore(), useProviderStore()] as const
await Promise.all([stores[0].loadTasks(), stores[1].loadPlugins(), stores[2].loadSkills(), stores[3].loadProviders()])
expect(stores.every(store => store.error)).toBe(true)
await useSettingsStore().loadDiagnostics()
expect(useSettingsStore().aiCoreStatus).toBe('error')
expect(useSettingsStore().indexStatus.total_blocks).toBeNull()
expect(useSettingsStore().diagnosticsError).toBeTruthy()
await expect(getStatus()).rejects.toThrow()
})
it('renders backend counts and effective permissions and excludes the test provider', async () => {
const data: Record<string, unknown> = {
'/health': { status: 'ok' }, '/api/status': { version: '9.2.1' },
'/api/index/status': { status: 'idle', pending_jobs: 0, total_notes: 7, total_blocks: 19 },
'/api/permissions/policy': { 'attachments.read': 'allow' },
'/api/providers': { items: [
{ provider_id: 'mock', provider_type: 'mock', capabilities: [] },
{ provider_id: 'real', name: 'Real', provider_type: 'ollama', capabilities: [], enabled: true, default_model: 'installed-model' },
] },
}
vi.stubGlobal('fetch', vi.fn(async (url: string) => new Response(JSON.stringify(data[url]), { status: 200, headers: { "content-type": "application/json" } })))
expect((await listProviders()).map(p => p.provider_id)).toEqual(['real'])
await useProviderStore().loadProviders()
expect(useProviderStore().defaultProviderId).toBe('real')
await useSettingsStore().loadDiagnostics()
expect(useSettingsStore().indexStatus.total_notes).toBe(7)
expect(useSettingsStore().indexStatus.total_blocks).toBe(19)
expect(useSettingsStore().aiCoreVersion).toBe('9.2.1')
expect(useSettingsStore().permissionPolicy).toEqual({ 'attachments.read': 'allow' })
})
})
+1 -1
View File
@@ -4,7 +4,7 @@ import type { Plugin } from '@/contracts'
import * as pluginService from '@/services/pluginService'
export const usePluginStore = defineStore('plugin', () => {
const plugins = ref<Plugin[]>(pluginService.mockPlugins)
const plugins = ref<Plugin[]>([])
const selectedPluginId = ref<string | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
-2
View File
@@ -4,8 +4,6 @@ import { createPinia, setActivePinia } from 'pinia'
import type { ProviderConfig, ProviderPreset } from '@/contracts'
vi.mock('@/services/providerService', () => ({
mockProviders: [],
mockModels: {},
listProviders: vi.fn(),
listProviderPresets: vi.fn(),
getCredentialStatus: vi.fn(),
+7 -4
View File
@@ -1,17 +1,17 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { ProviderConfig, ModelInfo, ProviderPreset } from '@/contracts'
import { createProvider, deleteProvider as deleteProviderRequest, getCredentialStatus, listModels, listProviderPresets, listProviders, mockProviders, mockModels, putCredential, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService'
import { createProvider, deleteProvider as deleteProviderRequest, getCredentialStatus, listModels, listProviderPresets, listProviders, putCredential, testProvider as testProviderRequest, updateProvider as updateProviderRequest } from '@/services/providerService'
import { ApiErrorClass } from '@/services/apiClient'
export const useProviderStore = defineStore('provider', () => {
const providers = ref<ProviderConfig[]>(mockProviders)
const providers = ref<ProviderConfig[]>([])
const presets = ref<ProviderPreset[]>([])
const modelsByProvider = ref<Record<string, ModelInfo[]>>(mockModels)
const modelsByProvider = ref<Record<string, ModelInfo[]>>({})
const modelLoadingByProvider = ref<Record<string, boolean>>({})
const modelErrorsByProvider = ref<Record<string, string>>({})
const credentialConfiguredById = ref<Record<string, boolean>>({})
const defaultProviderId = ref('mock')
const defaultProviderId = ref('')
const isLoading = ref(false)
const error = ref<string | null>(null)
@@ -24,6 +24,9 @@ export const useProviderStore = defineStore('provider', () => {
isLoading.value = true
try {
providers.value = await listProviders()
if (!enabledProviders.value.some(p => p.provider_id === defaultProviderId.value)) {
defaultProviderId.value = enabledProviders.value[0]?.provider_id ?? ''
}
error.value = null
} catch (reason) {
error.value = reason instanceof Error ? reason.message : 'Provider 加载失败'
+18 -47
View File
@@ -1,7 +1,8 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import type { AiCoreStatus, IndexStatus } from '@/contracts'
import { mockIndexStatus } from '@/services/indexService'
import { resolveApiUrl } from '@/services/apiClient'
import packageInfo from '../../package.json'
import * as indexService from '@/services/indexService'
import * as systemService from '@/services/systemService'
@@ -14,8 +15,8 @@ export const useSettingsStore = defineStore('settings', () => {
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 appVersion = ref('0.1.0')
const aiCoreVersion = ref('0.1.0')
const appVersion = ref(packageInfo.version)
const aiCoreVersion = ref('未获取')
// Editor
const defaultEditorMode = ref<'wysiwyg' | 'source'>(saved.defaultEditorMode === 'source' ? 'source' : 'wysiwyg')
@@ -23,24 +24,15 @@ export const useSettingsStore = defineStore('settings', () => {
const spellCheck = ref(saved.spellCheck === true)
// AI Core
const aiCoreStatus = ref<AiCoreStatus>('running')
const aiCoreAddress = ref('http://127.0.0.1:8000')
const aiCoreStatus = ref<AiCoreStatus>('unknown')
const aiCoreAddress = ref(resolveApiUrl('/api') || '/api')
// Index
const indexStatus = ref<IndexStatus>(mockIndexStatus)
const emptyIndex = (): IndexStatus => ({ status: 'unknown', pending_jobs: 0, total_notes: null, total_blocks: null })
const indexStatus = ref<IndexStatus>(emptyIndex())
// Permissions
const permissionPolicy = ref<Record<string, 'allow' | 'confirm' | 'deny'>>({
'notes.read': 'allow',
'notes.search': 'allow',
'notes.write': 'confirm',
'notes.delete': 'confirm',
'tasks.read': 'allow',
'tasks.write': 'confirm',
'attachments.read': 'confirm',
'network.request': 'confirm',
'secrets.use': 'confirm',
})
const permissionPolicy = ref<Record<string, 'allow' | 'confirm' | 'deny'>>({})
const diagnosticsError = ref<string | null>(null)
watch(() => ({
@@ -50,18 +42,15 @@ export const useSettingsStore = defineStore('settings', () => {
}), (value) => localStorage.setItem('app-settings', JSON.stringify(value)), { deep: true })
async function loadDiagnostics() {
try {
const [health, status, index] = await Promise.all([
systemService.healthCheck(), systemService.getStatus(), indexService.getIndexStatus(),
])
aiCoreStatus.value = health.status === 'ok' ? 'running' : 'error'
aiCoreVersion.value = status.version
indexStatus.value = index
diagnosticsError.value = null
} catch (reason) {
aiCoreStatus.value = 'error'
diagnosticsError.value = reason instanceof Error ? reason.message : '诊断信息加载失败'
}
const results = await Promise.allSettled([
systemService.healthCheck(), systemService.getStatus(), indexService.getIndexStatus(), systemService.getPermissionPolicy(),
])
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 : '未获取'
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
}
function setAutoSaveInterval(ms: number) {
@@ -72,21 +61,6 @@ export const useSettingsStore = defineStore('settings', () => {
defaultEditorMode.value = mode
}
function setPermission(permission: string, policy: 'allow' | 'confirm' | 'deny') {
permissionPolicy.value[permission] = policy
}
function setAiCoreStatus(status: AiCoreStatus) {
aiCoreStatus.value = status
}
async function restartAiCore(): Promise<boolean> {
aiCoreStatus.value = 'starting'
await new Promise((r) => setTimeout(r, 1500))
aiCoreStatus.value = 'running'
return true
}
async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full') {
indexStatus.value.status = 'indexing'
try {
@@ -115,9 +89,6 @@ export const useSettingsStore = defineStore('settings', () => {
loadDiagnostics,
setAutoSaveInterval,
setDefaultEditorMode,
setPermission,
setAiCoreStatus,
restartAiCore,
rebuildIndex,
}
})
+1 -1
View File
@@ -4,7 +4,7 @@ import type { Skill } from '@/contracts'
import * as skillService from '@/services/skillService'
export const useSkillStore = defineStore('skill', () => {
const skills = ref<Skill[]>(skillService.mockSkills)
const skills = ref<Skill[]>([])
const selectedSkillId = ref<string | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
+3 -3
View File
@@ -1,10 +1,10 @@
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, mockTasks, updateTask as updateTaskRequest } from '@/services/taskService'
import { createTask as createTaskRequest, deleteTask as deleteTaskRequest, listTasks, updateTask as updateTaskRequest } from '@/services/taskService'
export const useTaskStore = defineStore('task', () => {
const tasks = ref<TaskItem[]>(mockTasks)
const tasks = ref<TaskItem[]>([])
const filterStatus = ref<TaskStatus | 'all'>('all')
const filterPriority = ref<TaskPriority | 'all'>('all')
const filterSource = ref<TaskSource | 'all'>('all')
@@ -47,7 +47,7 @@ export const useTaskStore = defineStore('task', () => {
const task = tasks.value.find((t) => t.task_id === taskId)
if (task) {
const updated = await updateTaskRequest(taskId, data)
Object.assign(task, updated, data)
Object.assign(task, updated)
}
}