feat(frontend): 搭建桌面端基础界面与 Workspace
- App Shell 壳层:主导航、次导航、Title Bar、状态栏 - Vault 入口页:最近 Vault、打开/创建 Vault、AI Core 状态 - Workspace 与文件树:浏览、打开、创建笔记/文件夹 - Design Token:浅色/深色主题 CSS Variables - Contracts / Service / Store / Router 基础架构 - 统一 ApiClient 与 SseClient,对接后端 API 契约
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
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'
|
||||
|
||||
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 isCreating = ref(false)
|
||||
const isRunning = ref(false)
|
||||
const permissionRequest = ref<PermissionRequest | null>(null)
|
||||
const toolCalls = ref<ToolCall[]>([])
|
||||
|
||||
const activeRun = computed(() =>
|
||||
runs.value.find((r) => r.run_id === activeRunId.value) || null
|
||||
)
|
||||
|
||||
const sortedRuns = computed(() =>
|
||||
[...runs.value].sort((a, b) => (b.started_at || '').localeCompare(a.started_at || ''))
|
||||
)
|
||||
|
||||
const currentStep = computed(() => {
|
||||
const tc = events.value.filter((e) => e.event === 'ToolCall').length
|
||||
return tc
|
||||
})
|
||||
|
||||
async function loadTools() {
|
||||
tools.value = await agentService.listTools()
|
||||
}
|
||||
|
||||
async function loadRuns() {
|
||||
const resp = await agentService.listAgentRuns()
|
||||
runs.value = resp.items
|
||||
}
|
||||
|
||||
async function loadRun(runId: string) {
|
||||
activeRunId.value = runId
|
||||
events.value = mockAgentEvents.filter((e) => e.run_id === runId)
|
||||
toolCalls.value = []
|
||||
for (const evt of events.value) {
|
||||
if (evt.event === 'ToolCall') {
|
||||
const data = evt.data as any
|
||||
toolCalls.value.push({
|
||||
tool_call_id: data.tool_call_id,
|
||||
name: data.name,
|
||||
parameters: data.parameters,
|
||||
status: data.status || 'completed',
|
||||
started_at: evt.timestamp,
|
||||
})
|
||||
} else if (evt.event === 'ToolResult') {
|
||||
const data = evt.data as any
|
||||
const tc = toolCalls.value.find((t) => t.tool_call_id === data.tool_call_id)
|
||||
if (tc) {
|
||||
tc.status = data.status
|
||||
tc.result = data.result
|
||||
tc.completed_at = evt.timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createRun(request: agentService.CreateAgentRunRequest) {
|
||||
isCreating.value = true
|
||||
try {
|
||||
const run = await agentService.createAgentRun(request)
|
||||
runs.value.unshift(run)
|
||||
activeRunId.value = run.run_id
|
||||
events.value = [{
|
||||
event: 'RunStarted',
|
||||
sequence: 1,
|
||||
run_id: run.run_id,
|
||||
data: { task: request.task },
|
||||
timestamp: new Date().toISOString(),
|
||||
}]
|
||||
isRunning.value = true
|
||||
// Mock events streaming
|
||||
simulateRun(run.run_id)
|
||||
return run
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function simulateRun(runId: string) {
|
||||
const runEvents: AgentEvent[] = [
|
||||
{ event: 'ThinkingDelta', sequence: 2, run_id: runId, data: { text: '我需要先搜索相关笔记...' }, timestamp: new Date().toISOString() },
|
||||
{ event: 'ToolCall', sequence: 3, run_id: runId, data: { tool_call_id: 'tc-mock-1', name: 'notes.search', parameters: { query: '红黑树', limit: 5 }, status: 'running' }, timestamp: new Date().toISOString() },
|
||||
{ event: 'ToolResult', sequence: 4, run_id: runId, data: { tool_call_id: 'tc-mock-1', name: 'notes.search', status: 'completed', result: '找到 5 条相关结果' }, timestamp: new Date().toISOString() },
|
||||
{ event: 'TextDelta', sequence: 5, run_id: runId, data: { text: '根据你的笔记,以下是...' }, timestamp: new Date().toISOString() },
|
||||
{ event: 'RunCompleted', sequence: 6, run_id: runId, data: { message: 'Task completed successfully' }, timestamp: new Date().toISOString() },
|
||||
]
|
||||
let idx = 0
|
||||
const push = () => {
|
||||
if (idx >= runEvents.length) {
|
||||
isRunning.value = false
|
||||
return
|
||||
}
|
||||
events.value.push(runEvents[idx])
|
||||
idx++
|
||||
setTimeout(push, 800)
|
||||
}
|
||||
setTimeout(push, 500)
|
||||
}
|
||||
|
||||
async function cancelRun(runId: string) {
|
||||
await agentService.cancelAgentRun(runId)
|
||||
const run = runs.value.find((r) => r.run_id === runId)
|
||||
if (run) run.status = 'cancelled'
|
||||
isRunning.value = false
|
||||
}
|
||||
|
||||
async function respondPermission(decision: 'allow' | 'deny', scope: 'once' | 'session' | 'always' = 'once') {
|
||||
if (!activeRunId.value || !permissionRequest.value) return
|
||||
await agentService.respondToPermission(activeRunId.value, permissionRequest.value.request_id, decision, scope)
|
||||
permissionRequest.value = null
|
||||
}
|
||||
|
||||
function showPermissionDemo() {
|
||||
permissionRequest.value = mockPermissionRequest
|
||||
}
|
||||
|
||||
return {
|
||||
runs,
|
||||
activeRunId,
|
||||
activeRun,
|
||||
sortedRuns,
|
||||
events,
|
||||
tools,
|
||||
isCreating,
|
||||
isRunning,
|
||||
permissionRequest,
|
||||
toolCalls,
|
||||
currentStep,
|
||||
loadTools,
|
||||
loadRuns,
|
||||
loadRun,
|
||||
createRun,
|
||||
cancelRun,
|
||||
respondPermission,
|
||||
showPermissionDemo,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { ChatMessage, Conversation, Citation } from '@/contracts'
|
||||
import { mockConversations, mockMessages } 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 isStreaming = ref(false)
|
||||
const inputText = ref('')
|
||||
const useRag = ref(true)
|
||||
const selectedSkillId = ref<string | null>(null)
|
||||
const selectedProviderId = ref('mock-provider')
|
||||
const selectedModel = ref('mock-1')
|
||||
let sseClient: SseClient | null = null
|
||||
|
||||
const activeConversation = computed(() =>
|
||||
conversations.value.find((c) => c.conversation_id === activeConversationId.value) || null
|
||||
)
|
||||
|
||||
const sortedConversations = computed(() =>
|
||||
[...conversations.value].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
||||
)
|
||||
|
||||
async function setActiveConversation(id: string) {
|
||||
activeConversationId.value = id
|
||||
messages.value = mockMessages[id] || []
|
||||
}
|
||||
|
||||
async function sendMessage(text: string) {
|
||||
if (!text.trim() || isStreaming.value) return
|
||||
const conversationId = activeConversationId.value || `conv-${Date.now()}`
|
||||
|
||||
if (!activeConversationId.value) {
|
||||
const newConv: Conversation = {
|
||||
conversation_id,
|
||||
title: text.slice(0, 30),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
message_count: 0,
|
||||
}
|
||||
conversations.value.unshift(newConv)
|
||||
activeConversationId.value = conversationId
|
||||
}
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
message_id: `msg-${Date.now()}`,
|
||||
conversation_id: conversationId,
|
||||
role: 'user',
|
||||
content: text,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
messages.value.push(userMsg)
|
||||
inputText.value = ''
|
||||
isStreaming.value = true
|
||||
|
||||
const aiMsg: ChatMessage = {
|
||||
message_id: `msg-${Date.now() + 1}`,
|
||||
conversation_id: conversationId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
created_at: new Date().toISOString(),
|
||||
citations: [],
|
||||
tool_calls: [],
|
||||
}
|
||||
messages.value.push(aiMsg)
|
||||
|
||||
// Mock streaming
|
||||
const fullText =
|
||||
'这是一个模拟的 AI 回复。在实际环境中,这里会通过 SSE 接收后端 AI Core 的流式输出,基于 RAG 引擎和你的知识库生成回答,并附带来源引用。\n\n**要点总结:**\n1. 这是演示用的流式输出\n2. 实际会调用 ModelEvent SSE\n3. 支持 Citation、Tool Call 等事件\n\n你可以在设置中配置真实的模型 Provider 来启用完整功能。'
|
||||
const citations: Citation[] = [
|
||||
{
|
||||
note_id: 'n-rbt',
|
||||
block_id: 'b1',
|
||||
file_path: '/数据结构/红黑树.md',
|
||||
heading_path: '数据结构 / 红黑树 / 概述',
|
||||
content: '红黑树是一种自平衡二叉搜索树...',
|
||||
},
|
||||
]
|
||||
|
||||
let i = 0
|
||||
const interval = setInterval(() => {
|
||||
if (i >= fullText.length) {
|
||||
clearInterval(interval)
|
||||
isStreaming.value = false
|
||||
aiMsg.citations = citations
|
||||
return
|
||||
}
|
||||
const chunk = fullText.slice(i, i + 3)
|
||||
aiMsg.content += chunk
|
||||
i += 3
|
||||
}, 20)
|
||||
}
|
||||
|
||||
function stopGeneration() {
|
||||
if (sseClient) {
|
||||
sseClient.cancel()
|
||||
sseClient = null
|
||||
}
|
||||
isStreaming.value = false
|
||||
}
|
||||
|
||||
function createNewConversation() {
|
||||
const newConv: Conversation = {
|
||||
conversation_id: `conv-${Date.now()}`,
|
||||
title: '新对话',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
message_count: 0,
|
||||
}
|
||||
conversations.value.unshift(newConv)
|
||||
activeConversationId.value = newConv.conversation_id
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
function deleteConversation(id: string) {
|
||||
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] || [] : []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
conversations,
|
||||
activeConversationId,
|
||||
activeConversation,
|
||||
sortedConversations,
|
||||
messages,
|
||||
isStreaming,
|
||||
inputText,
|
||||
useRag,
|
||||
selectedSkillId,
|
||||
selectedProviderId,
|
||||
selectedModel,
|
||||
setActiveConversation,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
createNewConversation,
|
||||
deleteConversation,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { SaveStatus } from '@/contracts'
|
||||
import * as workspaceService from '@/services/workspaceService'
|
||||
|
||||
export const useEditorStore = defineStore('editor', () => {
|
||||
const mode = ref<'wysiwyg' | 'source'>('wysiwyg')
|
||||
const content = ref('')
|
||||
const saveStatus = ref<SaveStatus>('idle')
|
||||
const lastSavedAt = ref<string | null>(null)
|
||||
const currentNoteId = ref<string | null>(null)
|
||||
const currentFilePath = ref<string | null>(null)
|
||||
const highlightBlockId = ref<string | null>(null)
|
||||
const cursorPosition = ref({ line: 0, column: 0 })
|
||||
|
||||
const wordCount = computed(() => {
|
||||
const text = content.value.replace(/[#*`>\-_\[\]()!]/g, '')
|
||||
return text.trim().length
|
||||
})
|
||||
|
||||
const lineCount = computed(() => content.value.split('\n').length)
|
||||
|
||||
function setMode(newMode: 'wysiwyg' | 'source') {
|
||||
mode.value = newMode
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
mode.value = mode.value === 'wysiwyg' ? 'source' : 'wysiwyg'
|
||||
}
|
||||
|
||||
function updateContent(newContent: string) {
|
||||
content.value = newContent
|
||||
if (saveStatus.value === 'saved' || saveStatus.value === 'idle') {
|
||||
saveStatus.value = 'dirty'
|
||||
}
|
||||
}
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function scheduleAutoSave(delay = 1500) {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => {
|
||||
void save()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!currentFilePath.value) return
|
||||
if (saveStatus.value === 'saving') return
|
||||
saveStatus.value = 'saving'
|
||||
try {
|
||||
await workspaceService.saveFileContent(currentFilePath.value, content.value)
|
||||
saveStatus.value = 'saved'
|
||||
lastSavedAt.value = new Date().toISOString()
|
||||
} catch {
|
||||
saveStatus.value = 'save_failed'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFile(filePath: string) {
|
||||
currentFilePath.value = filePath
|
||||
saveStatus.value = 'saving'
|
||||
try {
|
||||
content.value = await workspaceService.readFileContent(filePath)
|
||||
saveStatus.value = 'saved'
|
||||
lastSavedAt.value = new Date().toISOString()
|
||||
} catch {
|
||||
content.value = ''
|
||||
saveStatus.value = 'idle'
|
||||
}
|
||||
highlightBlockId.value = null
|
||||
}
|
||||
|
||||
function highlightBlock(blockId: string) {
|
||||
highlightBlockId.value = blockId
|
||||
setTimeout(() => {
|
||||
if (highlightBlockId.value === blockId) {
|
||||
highlightBlockId.value = null
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function setExternalChanged() {
|
||||
if (saveStatus.value === 'dirty') {
|
||||
saveStatus.value = 'conflict'
|
||||
} else {
|
||||
saveStatus.value = 'external_changed'
|
||||
}
|
||||
}
|
||||
|
||||
function closeFile() {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
currentFilePath.value = null
|
||||
currentNoteId.value = null
|
||||
content.value = ''
|
||||
saveStatus.value = 'idle'
|
||||
lastSavedAt.value = null
|
||||
highlightBlockId.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
content,
|
||||
saveStatus,
|
||||
lastSavedAt,
|
||||
currentNoteId,
|
||||
currentFilePath,
|
||||
highlightBlockId,
|
||||
cursorPosition,
|
||||
wordCount,
|
||||
lineCount,
|
||||
setMode,
|
||||
toggleMode,
|
||||
updateContent,
|
||||
scheduleAutoSave,
|
||||
save,
|
||||
loadFile,
|
||||
highlightBlock,
|
||||
setExternalChanged,
|
||||
closeFile,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
export { useWorkspaceStore } from './workspace'
|
||||
export { useEditorStore } from './editor'
|
||||
export { useSearchStore } from './search'
|
||||
export { useChatStore } from './chat'
|
||||
export { useAgentStore } from './agent'
|
||||
export { useSkillStore } from './skill'
|
||||
export { usePluginStore } from './plugin'
|
||||
export { useTaskStore } from './task'
|
||||
export { useThemeStore } from './theme'
|
||||
export { useProviderStore } from './provider'
|
||||
export { useSettingsStore } from './settings'
|
||||
@@ -0,0 +1,69 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Plugin } from '@/contracts'
|
||||
import { mockPlugins } from '@/services/pluginService'
|
||||
|
||||
export const usePluginStore = defineStore('plugin', () => {
|
||||
const plugins = ref<Plugin[]>(mockPlugins)
|
||||
const selectedPluginId = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const selectedPlugin = computed(() =>
|
||||
plugins.value.find((p) => p.plugin_id === selectedPluginId.value) || null
|
||||
)
|
||||
|
||||
const enabledPlugins = computed(() => plugins.value.filter((p) => p.enabled))
|
||||
const readyPlugins = computed(() => plugins.value.filter((p) => p.status === 'ready'))
|
||||
const errorPlugins = computed(() => plugins.value.filter((p) => p.status === 'error'))
|
||||
|
||||
async function loadPlugins() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const { listPlugins } = await import('@/services/pluginService')
|
||||
plugins.value = await listPlugins()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectPlugin(pluginId: string | null) {
|
||||
selectedPluginId.value = pluginId
|
||||
}
|
||||
|
||||
async function enablePlugin(pluginId: string) {
|
||||
const plugin = plugins.value.find((p) => p.plugin_id === pluginId)
|
||||
if (plugin) {
|
||||
plugin.enabled = true
|
||||
plugin.status = 'ready'
|
||||
}
|
||||
}
|
||||
|
||||
async function disablePlugin(pluginId: string) {
|
||||
const plugin = plugins.value.find((p) => p.plugin_id === pluginId)
|
||||
if (plugin) {
|
||||
plugin.enabled = false
|
||||
plugin.status = 'disabled'
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallPlugin(pluginId: string) {
|
||||
const idx = plugins.value.findIndex((p) => p.plugin_id === pluginId)
|
||||
if (idx > -1) plugins.value.splice(idx, 1)
|
||||
if (selectedPluginId.value === pluginId) selectedPluginId.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
plugins,
|
||||
selectedPluginId,
|
||||
selectedPlugin,
|
||||
enabledPlugins,
|
||||
readyPlugins,
|
||||
errorPlugins,
|
||||
isLoading,
|
||||
loadPlugins,
|
||||
selectPlugin,
|
||||
enablePlugin,
|
||||
disablePlugin,
|
||||
uninstallPlugin,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { ProviderConfig, ModelInfo } from '@/contracts'
|
||||
import { mockProviders, mockModels } from '@/services/providerService'
|
||||
|
||||
export const useProviderStore = defineStore('provider', () => {
|
||||
const providers = ref<ProviderConfig[]>(mockProviders)
|
||||
const modelsByProvider = ref<Record<string, ModelInfo[]>>(mockModels)
|
||||
const defaultProviderId = ref('mock-provider')
|
||||
const isLoading = ref(false)
|
||||
|
||||
const enabledProviders = computed(() => providers.value.filter((p) => p.enabled))
|
||||
const defaultProvider = computed(() =>
|
||||
providers.value.find((p) => p.provider_id === defaultProviderId.value) || null
|
||||
)
|
||||
|
||||
async function loadProviders() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const { listProviders } = await import('@/services/providerService')
|
||||
providers.value = await listProviders()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModels(providerId: string) {
|
||||
const { listModels } = await import('@/services/providerService')
|
||||
modelsByProvider.value[providerId] = await listModels(providerId)
|
||||
}
|
||||
|
||||
async function addProvider(data: Omit<ProviderConfig, 'provider_id'> & { api_key?: string }) {
|
||||
const newProvider: ProviderConfig = {
|
||||
...data,
|
||||
provider_id: `prov-${Date.now()}`,
|
||||
}
|
||||
providers.value.push(newProvider)
|
||||
return newProvider
|
||||
}
|
||||
|
||||
async function updateProvider(providerId: string, data: Partial<ProviderConfig>) {
|
||||
const p = providers.value.find((p) => p.provider_id === providerId)
|
||||
if (p) Object.assign(p, data)
|
||||
}
|
||||
|
||||
async function deleteProvider(providerId: string) {
|
||||
const idx = providers.value.findIndex((p) => p.provider_id === providerId)
|
||||
if (idx > -1) providers.value.splice(idx, 1)
|
||||
delete modelsByProvider.value[providerId]
|
||||
}
|
||||
|
||||
async function testProvider(providerId: string): Promise<{ success: boolean; latency_ms?: number; error?: string }> {
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
const p = providers.value.find((p) => p.provider_id === providerId)
|
||||
if (p?.enabled && p.has_credential) {
|
||||
return { success: true, latency_ms: 230 + Math.floor(Math.random() * 200) }
|
||||
}
|
||||
return { success: false, error: '认证失败,请检查 API Key' }
|
||||
}
|
||||
|
||||
function setDefaultProvider(providerId: string) {
|
||||
defaultProviderId.value = providerId
|
||||
}
|
||||
|
||||
return {
|
||||
providers,
|
||||
modelsByProvider,
|
||||
defaultProviderId,
|
||||
enabledProviders,
|
||||
defaultProvider,
|
||||
isLoading,
|
||||
loadProviders,
|
||||
loadModels,
|
||||
addProvider,
|
||||
updateProvider,
|
||||
deleteProvider,
|
||||
testProvider,
|
||||
setDefaultProvider,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { SearchResult, SearchRequest } from '@/contracts'
|
||||
import * as searchService from '@/services/searchService'
|
||||
|
||||
export const useSearchStore = defineStore('search', () => {
|
||||
const query = ref('')
|
||||
const mode = ref<'fts' | 'vector' | 'hybrid'>('hybrid')
|
||||
const results = ref<SearchResult[]>([])
|
||||
const total = ref(0)
|
||||
const isSearching = ref(false)
|
||||
const selectedIndex = ref(0)
|
||||
const recentQueries = ref<string[]>(['红黑树', '死锁', 'TCP三次握手'])
|
||||
const error = ref<string | null>(null)
|
||||
const vectorUnavailable = ref(false)
|
||||
|
||||
async function doSearch(request: SearchRequest) {
|
||||
query.value = request.query
|
||||
mode.value = request.mode || 'hybrid'
|
||||
isSearching.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const resp = await searchService.searchMock(request.query, request.mode || 'hybrid')
|
||||
results.value = resp.results
|
||||
total.value = resp.total
|
||||
selectedIndex.value = 0
|
||||
} catch (e: any) {
|
||||
error.value = e.message || '搜索失败'
|
||||
results.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
}
|
||||
|
||||
if (request.query && !recentQueries.value.includes(request.query)) {
|
||||
recentQueries.value.unshift(request.query)
|
||||
if (recentQueries.value.length > 10) recentQueries.value.pop()
|
||||
}
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
results.value = []
|
||||
query.value = ''
|
||||
total.value = 0
|
||||
selectedIndex.value = 0
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function selectNext() {
|
||||
if (selectedIndex.value < results.value.length - 1) selectedIndex.value++
|
||||
}
|
||||
|
||||
function selectPrev() {
|
||||
if (selectedIndex.value > 0) selectedIndex.value--
|
||||
}
|
||||
|
||||
function setMode(m: 'fts' | 'vector' | 'hybrid') {
|
||||
mode.value = m
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
mode,
|
||||
results,
|
||||
total,
|
||||
isSearching,
|
||||
selectedIndex,
|
||||
recentQueries,
|
||||
error,
|
||||
vectorUnavailable,
|
||||
doSearch,
|
||||
clearResults,
|
||||
selectNext,
|
||||
selectPrev,
|
||||
setMode,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { AiCoreStatus, IndexStatus } from '@/contracts'
|
||||
import { mockIndexStatus } from '@/services/indexService'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
// General
|
||||
const restoreLastVault = ref(true)
|
||||
const autoSaveInterval = ref(1500)
|
||||
const language = ref<'zh-CN' | 'en'>('zh-CN')
|
||||
const appVersion = ref('0.1.0')
|
||||
const aiCoreVersion = ref('0.1.0')
|
||||
|
||||
// Editor
|
||||
const defaultEditorMode = ref<'wysiwyg' | 'source'>('wysiwyg')
|
||||
const editorFontSize = ref(15)
|
||||
const editorLineHeight = ref(1.7)
|
||||
const editorLineWidth = ref(80)
|
||||
const spellCheck = ref(false)
|
||||
|
||||
// AI Core
|
||||
const aiCoreStatus = ref<AiCoreStatus>('running')
|
||||
const aiCoreAddress = ref('http://127.0.0.1:8000')
|
||||
|
||||
// Index
|
||||
const indexStatus = ref<IndexStatus>(mockIndexStatus)
|
||||
|
||||
// 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',
|
||||
})
|
||||
|
||||
function setAutoSaveInterval(ms: number) {
|
||||
autoSaveInterval.value = ms
|
||||
}
|
||||
|
||||
function setDefaultEditorMode(mode: 'wysiwyg' | 'source') {
|
||||
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'
|
||||
setTimeout(() => {
|
||||
indexStatus.value.status = 'idle'
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
return {
|
||||
restoreLastVault,
|
||||
autoSaveInterval,
|
||||
language,
|
||||
appVersion,
|
||||
aiCoreVersion,
|
||||
defaultEditorMode,
|
||||
editorFontSize,
|
||||
editorLineHeight,
|
||||
editorLineWidth,
|
||||
spellCheck,
|
||||
aiCoreStatus,
|
||||
aiCoreAddress,
|
||||
indexStatus,
|
||||
permissionPolicy,
|
||||
setAutoSaveInterval,
|
||||
setDefaultEditorMode,
|
||||
setPermission,
|
||||
setAiCoreStatus,
|
||||
restartAiCore,
|
||||
rebuildIndex,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Skill } from '@/contracts'
|
||||
import { mockSkills } from '@/services/skillService'
|
||||
|
||||
export const useSkillStore = defineStore('skill', () => {
|
||||
const skills = ref<Skill[]>(mockSkills)
|
||||
const selectedSkillId = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const selectedSkill = computed(() =>
|
||||
skills.value.find((s) => s.skill_id === selectedSkillId.value) || null
|
||||
)
|
||||
|
||||
const enabledSkills = computed(() => skills.value.filter((s) => s.enabled))
|
||||
const installedSkills = computed(() => skills.value.filter((s) => s.status !== 'error'))
|
||||
const readySkills = computed(() => skills.value.filter((s) => s.status === 'ready'))
|
||||
|
||||
async function loadSkills() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const { listSkills } = await import('@/services/skillService')
|
||||
skills.value = await listSkills()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectSkill(skillId: string | null) {
|
||||
selectedSkillId.value = skillId
|
||||
}
|
||||
|
||||
async function enableSkill(skillId: string) {
|
||||
const skill = skills.value.find((s) => s.skill_id === skillId)
|
||||
if (skill) {
|
||||
skill.enabled = true
|
||||
skill.status = 'ready'
|
||||
}
|
||||
}
|
||||
|
||||
async function disableSkill(skillId: string) {
|
||||
const skill = skills.value.find((s) => s.skill_id === skillId)
|
||||
if (skill) {
|
||||
skill.enabled = false
|
||||
skill.status = 'disabled'
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallSkill(skillId: string) {
|
||||
const idx = skills.value.findIndex((s) => s.skill_id === skillId)
|
||||
if (idx > -1) skills.value.splice(idx, 1)
|
||||
if (selectedSkillId.value === skillId) selectedSkillId.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
skills,
|
||||
selectedSkillId,
|
||||
selectedSkill,
|
||||
enabledSkills,
|
||||
installedSkills,
|
||||
readySkills,
|
||||
isLoading,
|
||||
loadSkills,
|
||||
selectSkill,
|
||||
enableSkill,
|
||||
disableSkill,
|
||||
uninstallSkill,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { TaskItem, TaskStatus, TaskPriority, TaskSource } from '@/contracts'
|
||||
import { mockTasks } from '@/services/taskService'
|
||||
|
||||
export const useTaskStore = defineStore('task', () => {
|
||||
const tasks = ref<TaskItem[]>(mockTasks)
|
||||
const filterStatus = ref<TaskStatus | 'all'>('all')
|
||||
const filterPriority = ref<TaskPriority | 'all'>('all')
|
||||
const filterSource = ref<TaskSource | 'all'>('all')
|
||||
const isLoading = ref(false)
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
return tasks.value.filter((t) => {
|
||||
if (filterStatus.value !== 'all' && t.status !== filterStatus.value) return false
|
||||
if (filterPriority.value !== 'all' && t.priority !== filterPriority.value) return false
|
||||
if (filterSource.value !== 'all' && t.source !== filterSource.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const todoTasks = computed(() => tasks.value.filter((t) => t.status === 'todo'))
|
||||
const inProgressTasks = computed(() => tasks.value.filter((t) => t.status === 'in_progress'))
|
||||
const doneTasks = computed(() => tasks.value.filter((t) => t.status === 'done'))
|
||||
|
||||
async function loadTasks() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const { listTasks } = await import('@/services/taskService')
|
||||
const resp = await listTasks()
|
||||
tasks.value = resp.items
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createTask(data: { title: string; description?: string; priority?: TaskPriority; due_date?: string; note_id?: string }) {
|
||||
const newTask: TaskItem = {
|
||||
task_id: `t-${Date.now()}`,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
status: 'todo',
|
||||
priority: data.priority || 'medium',
|
||||
due_date: data.due_date,
|
||||
note_id: data.note_id,
|
||||
source: 'user',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
tasks.value.unshift(newTask)
|
||||
return newTask
|
||||
}
|
||||
|
||||
async function updateTask(taskId: string, data: Partial<Pick<TaskItem, 'title' | 'description' | 'status' | 'priority' | 'due_date'>>) {
|
||||
const task = tasks.value.find((t) => t.task_id === taskId)
|
||||
if (task) {
|
||||
Object.assign(task, data)
|
||||
task.updated_at = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask(taskId: string) {
|
||||
const idx = tasks.value.findIndex((t) => t.task_id === taskId)
|
||||
if (idx > -1) tasks.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
function setFilterStatus(s: TaskStatus | 'all') { filterStatus.value = s }
|
||||
function setFilterPriority(p: TaskPriority | 'all') { filterPriority.value = p }
|
||||
function setFilterSource(s: TaskSource | 'all') { filterSource.value = s }
|
||||
|
||||
return {
|
||||
tasks,
|
||||
filterStatus,
|
||||
filterPriority,
|
||||
filterSource,
|
||||
filteredTasks,
|
||||
todoTasks,
|
||||
inProgressTasks,
|
||||
doneTasks,
|
||||
isLoading,
|
||||
loadTasks,
|
||||
createTask,
|
||||
updateTask,
|
||||
deleteTask,
|
||||
setFilterStatus,
|
||||
setFilterPriority,
|
||||
setFilterSource,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { ThemeConfig } from '@/contracts'
|
||||
|
||||
const builtinThemes: ThemeConfig[] = [
|
||||
{ theme_id: 'light', name: '浅色', version: '1.0.0', description: '默认浅色主题', is_dark: false, builtin: true },
|
||||
{ theme_id: 'dark', name: '深色', version: '1.0.0', description: '默认深色主题', is_dark: true, builtin: true },
|
||||
{ theme_id: 'sepia', name: '护眼', version: '1.0.0', description: '护眼暖色调', is_dark: false, builtin: true },
|
||||
]
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const themes = ref<ThemeConfig[]>(builtinThemes)
|
||||
const currentThemeId = ref<string>('light')
|
||||
const fontEditorSize = ref(15)
|
||||
const fontEditorFamily = ref('system-ui')
|
||||
const lineHeight = ref(1.7)
|
||||
|
||||
const currentTheme = computed(() =>
|
||||
themes.value.find((t) => t.theme_id === currentThemeId.value) || themes.value[0]
|
||||
)
|
||||
|
||||
const isDark = computed(() => currentTheme.value?.is_dark || false)
|
||||
|
||||
function applyTheme(themeId: string) {
|
||||
const theme = themes.value.find((t) => t.theme_id === themeId)
|
||||
if (!theme) return
|
||||
currentThemeId.value = themeId
|
||||
const root = document.documentElement
|
||||
if (theme.is_dark) {
|
||||
root.setAttribute('data-theme', 'dark')
|
||||
} else if (themeId === 'sepia') {
|
||||
root.setAttribute('data-theme', 'sepia')
|
||||
} else {
|
||||
root.setAttribute('data-theme', 'light')
|
||||
}
|
||||
localStorage.setItem('theme', themeId)
|
||||
}
|
||||
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem('theme')
|
||||
if (saved && themes.value.find((t) => t.theme_id === saved)) {
|
||||
applyTheme(saved)
|
||||
return
|
||||
}
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
applyTheme(prefersDark ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
applyTheme(isDark.value ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
function resetToDefault() {
|
||||
applyTheme('light')
|
||||
fontEditorSize.value = 15
|
||||
fontEditorFamily.value = 'system-ui'
|
||||
lineHeight.value = 1.7
|
||||
}
|
||||
|
||||
watch(fontEditorSize, (v) => {
|
||||
document.documentElement.style.setProperty('--font-editor-size', `${v}px`)
|
||||
})
|
||||
|
||||
watch(lineHeight, (v) => {
|
||||
document.documentElement.style.setProperty('--font-editor-line-height', String(v))
|
||||
})
|
||||
|
||||
return {
|
||||
themes,
|
||||
currentThemeId,
|
||||
currentTheme,
|
||||
isDark,
|
||||
fontEditorSize,
|
||||
fontEditorFamily,
|
||||
lineHeight,
|
||||
applyTheme,
|
||||
initTheme,
|
||||
toggleTheme,
|
||||
resetToDefault,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { FileNode } from '@/contracts'
|
||||
import * as workspaceService from '@/services/workspaceService'
|
||||
|
||||
export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
const vaultPath = ref('')
|
||||
const vaultName = ref('')
|
||||
const fileTree = ref<FileNode[]>([])
|
||||
const openFiles = ref<string[]>([])
|
||||
const activeFilePath = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const hasVault = ref(false)
|
||||
const recentVaults = ref<{ path: string; name: string }[]>([])
|
||||
|
||||
const activeFile = computed(() => {
|
||||
if (!activeFilePath.value) return null
|
||||
return findNodeByPath(fileTree.value, activeFilePath.value)
|
||||
})
|
||||
|
||||
function findNodeByPath(nodes: FileNode[], path: string): FileNode | null {
|
||||
for (const node of nodes) {
|
||||
if (node.path === path) return node
|
||||
if (node.children) {
|
||||
const found = findNodeByPath(node.children, path)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function toggleFolder(path: string) {
|
||||
const node = findNodeByPath(fileTree.value, path)
|
||||
if (node && node.type === 'folder') {
|
||||
node.is_open = !node.is_open
|
||||
}
|
||||
}
|
||||
|
||||
function openFile(path: string) {
|
||||
if (!openFiles.value.includes(path)) {
|
||||
openFiles.value.push(path)
|
||||
}
|
||||
activeFilePath.value = path
|
||||
}
|
||||
|
||||
function closeFile(path: string) {
|
||||
const idx = openFiles.value.indexOf(path)
|
||||
if (idx > -1) {
|
||||
openFiles.value.splice(idx, 1)
|
||||
if (activeFilePath.value === path) {
|
||||
activeFilePath.value = openFiles.value[Math.min(idx, openFiles.value.length - 1)] || null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveFile(path: string | null) {
|
||||
activeFilePath.value = path
|
||||
}
|
||||
|
||||
async function loadRecentVaults() {
|
||||
recentVaults.value = await workspaceService.getRecentVaults()
|
||||
}
|
||||
|
||||
async function openVault(path: string) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const info = await workspaceService.openVault(path)
|
||||
vaultPath.value = info.path
|
||||
vaultName.value = info.name
|
||||
fileTree.value = await workspaceService.getFileTree()
|
||||
hasVault.value = true
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createVault(path: string, name: string) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const info = await workspaceService.createVault(path, name)
|
||||
vaultPath.value = info.path
|
||||
vaultName.value = info.name
|
||||
fileTree.value = await workspaceService.getFileTree()
|
||||
hasVault.value = true
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addFileToTree(parentPath: string, file: FileNode) {
|
||||
const parent = findNodeByPath(fileTree.value, parentPath)
|
||||
if (parent?.children) {
|
||||
parent.children.push(file)
|
||||
parent.is_open = true
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromTree(path: string) {
|
||||
function remove(nodes: FileNode[]): boolean {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (nodes[i].path === path) {
|
||||
nodes.splice(i, 1)
|
||||
return true
|
||||
}
|
||||
if (nodes[i].children && remove(nodes[i].children!)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
remove(fileTree.value)
|
||||
}
|
||||
|
||||
return {
|
||||
vaultPath,
|
||||
vaultName,
|
||||
fileTree,
|
||||
openFiles,
|
||||
activeFilePath,
|
||||
activeFile,
|
||||
isLoading,
|
||||
hasVault,
|
||||
recentVaults,
|
||||
toggleFolder,
|
||||
openFile,
|
||||
closeFile,
|
||||
setActiveFile,
|
||||
loadRecentVaults,
|
||||
openVault,
|
||||
createVault,
|
||||
addFileToTree,
|
||||
removeFromTree,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user