diff --git a/frontend/src/components/common/AppShell.vue b/frontend/src/components/common/AppShell.vue index d6ef9a7..289cfac 100644 --- a/frontend/src/components/common/AppShell.vue +++ b/frontend/src/components/common/AppShell.vue @@ -26,7 +26,7 @@ const router = useRouter() const routeName = computed(() => route.name as string) const secondaryComponent = computed(() => { - switch (routeName) { + switch (routeName.value) { case 'workspace': return 'file-tree' case 'search': return 'search-filters' case 'chat': return 'conversation-list' diff --git a/frontend/src/components/common/SecondarySidebar.vue b/frontend/src/components/common/SecondarySidebar.vue index 34c8a1b..99c5a68 100644 --- a/frontend/src/components/common/SecondarySidebar.vue +++ b/frontend/src/components/common/SecondarySidebar.vue @@ -1,11 +1,6 @@ @@ -41,11 +36,7 @@ const showSkillToggle = computed(() => routeName === 'skills' || routeName === ' - - - - - + 该功能将在对应页面实现时补充。 @@ -109,4 +100,10 @@ const showSkillToggle = computed(() => routeName === 'skills' || routeName === ' overflow-y: auto; overflow-x: hidden; } + +.sidebar-placeholder { + padding: var(--space-lg); + color: var(--color-text-tertiary); + font-size: var(--font-size-sm); +} diff --git a/frontend/src/components/common/StatusBar.vue b/frontend/src/components/common/StatusBar.vue index a7e212d..825b833 100644 --- a/frontend/src/components/common/StatusBar.vue +++ b/frontend/src/components/common/StatusBar.vue @@ -78,7 +78,7 @@ const showEditorInfo = computed(() => route.name === 'workspace') 索引就绪 - + {{ aiCoreStatusText }} diff --git a/frontend/src/contracts/index.ts b/frontend/src/contracts/index.ts index 01e03c9..2497f16 100644 --- a/frontend/src/contracts/index.ts +++ b/frontend/src/contracts/index.ts @@ -247,15 +247,15 @@ export interface Plugin { enabled: boolean permissions: string[] contributions: PluginContribution[] - backend_type?: 'mcp' | 'internal' - transport?: 'stdio' | 'websocket' + backend_type?: 'mcp' | 'internal_rpc' | 'none' + transport?: 'stdio' | 'http' | 'none' last_error?: string dependent_skills?: string[] } // ============ Provider ============ -export type ProviderType = 'openai' | 'anthropic' | 'ollama' | 'openai-compatible' | 'mock' +export type ProviderType = ApiProviderType export interface ModelCapability { chat: boolean @@ -346,10 +346,10 @@ export interface ErrorResponse { } export interface SystemStatus { + status: 'ok' name: string version: string - environment: 'development' | 'production' | 'test' - ai_core_available: boolean + environment: string } export type SaveStatus = @@ -362,3 +362,178 @@ export type SaveStatus = | 'conflict' export type AiCoreStatus = 'starting' | 'running' | 'stopped' | 'error' + +// ============ FastAPI wire contracts ============ +// UI view models above may contain presentation-only fields. Services must use +// these DTOs at the HTTP boundary and explicitly map them to view models. + +export interface PageMeta { + total: number + limit: number + offset: number +} + +export interface OperationResponse { + status: 'accepted' | 'completed' + resource_id?: string | null + message?: string | null +} + +export interface ApiNoteBlock { + block_id: string + note_id: string + heading_path: string[] + start_offset: number + end_offset: number + content: string + content_hash: string + token_count: number +} + +export interface ApiNoteSummary { + note_id: string + title: string + file_path: string + tags: string[] + created_at: string + updated_at: string +} + +export interface ApiNote extends ApiNoteSummary { + markdown: string + blocks: ApiNoteBlock[] +} + +export interface ApiSearchResult { + note_id: string + block_id: string + title: string + file_path: string + heading_path: string[] + snippet?: string | null + score: number + citation: ApiCitation +} + +export interface ApiCitation { + citation_id: string + note_id: string + block_id: string + file_path: string + heading_path: string[] + start_offset?: number | null + end_offset?: number | null + source_audio?: string | null + start_time?: number | null + end_time?: number | null + speaker?: string | null +} + +export interface ApiAgentRun { + run_id: string + status: AgentRunStatus + input: string + provider_id: string + model: string + skill_id?: string | null + current_step: number + max_steps: number + token_budget?: number | null + cancelled: boolean + output?: string | null + error_code?: string | null + error_message?: string | null + token_usage: number + created_at: string + updated_at: string +} + +export interface ApiSkill { + manifest: { + skill_id: string + name: string + version: string + description: string + permissions: string[] + tools: string[] + retrieval: { top_k: number; rerank: boolean; citation: boolean } + model: { required_capabilities: string[] } + } + status: SkillStatus + enabled: boolean + missing_dependencies: string[] +} + +export interface ApiPlugin { + manifest: { + plugin_id: string + name: string + version: string + description: string + permissions: string[] + contributes: { + tools: string[] + commands: string[] + importers: string[] + exporters: string[] + panels: string[] + settings_sections: string[] + } + backend: { type: 'mcp' | 'internal_rpc' | 'none'; transport: 'stdio' | 'http' | 'none' } + } + status: PluginStatus + enabled: boolean + granted_permissions: string[] + error_message?: string | null +} + +export type ApiProviderType = + | 'mock' + | 'openai_responses' + | 'openai_chat' + | 'openai_compatible' + | 'anthropic_messages' + | 'ollama' + +export interface ApiProviderConfig { + provider_id: string + provider_type: ApiProviderType + name: string + base_url?: string | null + default_model?: string | null + credential_id?: string | null + enabled: boolean + capabilities: string[] +} + +export interface ApiModelInfo { + model: string + display_name: string + capabilities: string[] +} + +export interface ApiTask { + task_id: string + title: string + description: string + status: TaskStatus + note_id?: string | null + due_at?: string | null + created_at: string + updated_at: string +} + +export interface ApiIndexStatus { + status: 'idle' | 'queued' | 'running' | 'failed' + pending_jobs: number + active_job_id?: string | null + last_completed_at?: string | null + error_message?: string | null +} + +export interface ApiIndexJob { + job_id: string + status: 'queued' | 'running' | 'completed' | 'failed' + scope: 'all' | 'notes' | 'vectors' + created_at: string +} diff --git a/frontend/src/features/common/PlaceholderView.vue b/frontend/src/features/common/PlaceholderView.vue new file mode 100644 index 0000000..faf015f --- /dev/null +++ b/frontend/src/features/common/PlaceholderView.vue @@ -0,0 +1,34 @@ + + + + + + {{ title }} + 基础路由已经就绪,具体页面将在后续功能开发中实现。 + + + + + diff --git a/frontend/src/features/editor/EditorHeader.vue b/frontend/src/features/editor/EditorHeader.vue new file mode 100644 index 0000000..42a2801 --- /dev/null +++ b/frontend/src/features/editor/EditorHeader.vue @@ -0,0 +1,26 @@ + + + + + {{ workspaceStore.activeFile?.name ?? '未命名笔记' }} + {{ editorStore.saveStatus }} + + + + diff --git a/frontend/src/features/editor/EditorPane.vue b/frontend/src/features/editor/EditorPane.vue new file mode 100644 index 0000000..d4a60ba --- /dev/null +++ b/frontend/src/features/editor/EditorPane.vue @@ -0,0 +1,30 @@ + + + + + + + diff --git a/frontend/src/features/workspace/FileTreeNode.vue b/frontend/src/features/workspace/FileTreeNode.vue new file mode 100644 index 0000000..4d5c2b9 --- /dev/null +++ b/frontend/src/features/workspace/FileTreeNode.vue @@ -0,0 +1,31 @@ + + + + + + {{ node.type === 'folder' ? (node.is_open ? '📂' : '📁') : '📄' }} + {{ node.name }} + ● + + + emit('contextMenu', event, target)" /> + + + + + diff --git a/frontend/src/features/workspace/FileTreePanel.vue b/frontend/src/features/workspace/FileTreePanel.vue index cbe342b..1ed6605 100644 --- a/frontend/src/features/workspace/FileTreePanel.vue +++ b/frontend/src/features/workspace/FileTreePanel.vue @@ -1,406 +1,117 @@ - - - - - ➕ - - - 📁 - - - - 🔄 - + + + +📄 + +📁 - - - + + + 创建 + 取消 + + + - - - - - - - - - - - - - - { const n = workspaceStore.activeFile; if (n) startRename(n) }">✏️ 重命名 - { const n = workspaceStore.activeFile; if (n) deleteNode(n) }" class="danger">🗑️ 删除 + + 重命名 + 删除 - + - - diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 997bf2c..091f4b2 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,5 +1,6 @@ import { createRouter, createWebHashHistory } from 'vue-router' import { useWorkspaceStore } from '@/stores/workspace' +import PlaceholderView from '@/features/common/PlaceholderView.vue' const routes = [ { @@ -17,59 +18,50 @@ const routes = [ { path: '/search', name: 'search', - component: () => import('@/features/search/SearchView.vue'), + component: PlaceholderView, meta: { title: '搜索', requiresVault: true }, }, { path: '/chat', name: 'chat', - component: () => import('@/features/chat/ChatView.vue'), + component: PlaceholderView, meta: { title: 'AI 对话', requiresVault: true }, }, { path: '/agent/runs/:runId?', name: 'agent', - component: () => import('@/features/agent/AgentView.vue'), + component: PlaceholderView, meta: { title: 'Agent Trace', requiresVault: true }, }, { path: '/tasks', name: 'tasks', - component: () => import('@/features/tasks/TasksView.vue'), + component: PlaceholderView, meta: { title: '任务', requiresVault: true }, }, { path: '/extensions/skills', name: 'skills', - component: () => import('@/features/skills/SkillsView.vue'), + component: PlaceholderView, meta: { title: 'Skill 管理', requiresVault: true }, }, { path: '/extensions/plugins', name: 'plugins', - component: () => import('@/features/plugins/PluginsView.vue'), + component: PlaceholderView, meta: { title: 'Plugin 管理', requiresVault: true }, }, { path: '/themes', name: 'themes', - component: () => import('@/features/themes/ThemesView.vue'), + component: PlaceholderView, meta: { title: '主题管理', requiresVault: true }, }, { path: '/settings', name: 'settings', - component: () => import('@/features/settings/SettingsView.vue'), + component: PlaceholderView, meta: { title: '设置', requiresVault: true }, - children: [ - { path: '', redirect: '/settings/general' }, - { path: 'general', component: () => import('@/features/settings/sections/GeneralSection.vue') }, - { path: 'editor', component: () => import('@/features/settings/sections/EditorSection.vue') }, - { path: 'providers', component: () => import('@/features/settings/sections/ProvidersSection.vue') }, - { path: 'index', component: () => import('@/features/settings/sections/IndexSection.vue') }, - { path: 'permissions', component: () => import('@/features/settings/sections/PermissionsSection.vue') }, - { path: 'ai-core', component: () => import('@/features/settings/sections/AiCoreSection.vue') }, - ], }, ] diff --git a/frontend/src/services/agentService.ts b/frontend/src/services/agentService.ts index 102e661..b610ced 100644 --- a/frontend/src/services/agentService.ts +++ b/frontend/src/services/agentService.ts @@ -1,46 +1,61 @@ import apiClient from './apiClient' import { SseClient } from './sseClient' -import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest } from '@/contracts' +import type { AgentRun, AgentEvent, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition, PermissionRequest } from '@/contracts' + +function toAgentRun(run: ApiAgentRun): AgentRun { + return { + run_id: run.run_id, + status: run.status, + current_step: run.current_step, + max_steps: run.max_steps, + token_usage: { + input_tokens: 0, + output_tokens: 0, + total_tokens: run.token_usage, + }, + started_at: run.created_at, + completed_at: ['completed', 'failed', 'cancelled'].includes(run.status) ? run.updated_at : undefined, + error: run.error_message ?? undefined, + } +} export async function listAgentRuns(params?: { limit?: number offset?: number }): Promise<{ items: AgentRun[]; total: number }> { - return apiClient.get('/api/agent/runs', { params }) + const response = await apiClient.get<{ items: ApiAgentRun[]; page: PageMeta }>('/api/agent/runs', { params }) + return { items: response.items.map(toAgentRun), total: response.page.total } } export async function getAgentRun(runId: string): Promise { - return apiClient.get(`/api/agent/runs/${runId}`) + return toAgentRun(await apiClient.get(`/api/agent/runs/${runId}`)) } export interface CreateAgentRunRequest { - task: string - provider_id?: string - model?: string + input: string + provider_id: string + model: string skill_id?: string allowed_tools?: string[] max_steps?: number - tool_timeout?: number - run_timeout?: number + tool_timeout_seconds?: number + run_timeout_seconds?: number token_budget?: number allow_network?: boolean max_concurrent_tools?: number } export async function createAgentRun(request: CreateAgentRunRequest): Promise { - return apiClient.post('/api/agent/runs', request) + return toAgentRun(await apiClient.post('/api/agent/runs', request)) } -export async function cancelAgentRun(runId: string): Promise { +export async function cancelAgentRun(runId: string): Promise { return apiClient.post(`/api/agent/runs/${runId}/cancel`) } export async function listTools(): Promise { - try { - return await apiClient.get('/api/tools') - } catch { - return mockTools - } + const response = await apiClient.get<{ items: ToolDefinition[] }>('/api/tools') + return response.items } export function streamAgentEvents( @@ -75,12 +90,10 @@ export function streamAgentEvents( export async function respondToPermission( runId: string, requestId: string, - decision: 'allow' | 'deny', - scope?: 'once' | 'session' | 'always' -): Promise { + decision: 'allow_once' | 'allow_session' | 'deny' +): Promise { return apiClient.post(`/api/agent/runs/${runId}/permissions/${requestId}`, { decision, - scope, }) } diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts index a2e5654..df13010 100644 --- a/frontend/src/services/apiClient.ts +++ b/frontend/src/services/apiClient.ts @@ -1,6 +1,11 @@ import type { ApiError, ErrorResponse } from '@/contracts' -const BASE_URL = import.meta.env.VITE_API_BASE || '' +const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? '' + +export function resolveApiUrl(path: string): string { + if (/^https?:\/\//i.test(path)) return path + return `${BASE_URL.replace(/\/$/, '')}/${path.replace(/^\//, '')}` +} interface RequestOptions extends RequestInit { params?: Record @@ -22,7 +27,7 @@ export class ApiErrorClass extends Error { async function request(path: string, options: RequestOptions = {}): Promise { const { params, token, headers, ...rest } = options - let url = path.startsWith('http') ? path : `${BASE_URL}${path}` + let url = resolveApiUrl(path) if (params) { const usp = new URLSearchParams() @@ -94,6 +99,13 @@ export const apiClient = { body: body !== undefined ? JSON.stringify(body) : undefined, }) }, + put(path: string, body?: unknown, options?: Omit) { + return request(path, { + ...options, + method: 'PUT', + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + }, delete(path: string, options?: Omit) { return request(path, { ...options, method: 'DELETE' }) }, diff --git a/frontend/src/services/chatService.ts b/frontend/src/services/chatService.ts index 6aa0bb3..35f12b2 100644 --- a/frontend/src/services/chatService.ts +++ b/frontend/src/services/chatService.ts @@ -1,27 +1,21 @@ -import apiClient from './apiClient' import { SseClient } from './sseClient' import type { Conversation, ChatMessage, ModelEvent } from '@/contracts' -export async function listConversations(): Promise { - return apiClient.get('/api/conversations') -} - -export async function getConversation(conversationId: string): Promise { - return apiClient.get(`/api/conversations/${conversationId}`) -} - -export async function getMessages(conversationId: string): Promise { - return apiClient.get(`/api/conversations/${conversationId}/messages`) -} - export interface ChatRequest { + provider_id: string + model: string conversation_id?: string - message: string - provider_id?: string - model?: string + system?: string + messages: Array<{ + role: 'system' | 'user' | 'assistant' | 'tool' + content: string + name?: string + tool_call_id?: string + }> use_rag?: boolean - skill_id?: string attachments?: string[] + temperature?: number + max_tokens?: number } export function streamChat( diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index 5208e4d..968f5b3 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -1,5 +1,5 @@ export { apiClient, ApiErrorClass } from './apiClient' -export type { ApiError } from './apiClient' +export type { ApiError } from '@/contracts' export { SseClient } from './sseClient' export type { SseClientOptions, SseEventHandler } from './sseClient' export * as noteService from './noteService' diff --git a/frontend/src/services/indexService.ts b/frontend/src/services/indexService.ts index f365f94..72bcd34 100644 --- a/frontend/src/services/indexService.ts +++ b/frontend/src/services/indexService.ts @@ -1,25 +1,29 @@ import apiClient from './apiClient' -import type { IndexStatus } from '@/contracts' +import type { ApiIndexJob, ApiIndexStatus, IndexStatus } from '@/contracts' -export async function getIndexStatus(): Promise { - try { - return await apiClient.get('/api/index/status') - } catch { - return mockIndexStatus +function toIndexStatus(status: ApiIndexStatus): IndexStatus { + return { + status: status.status === 'idle' ? 'idle' : status.status === 'failed' ? 'error' : 'indexing', + pending_jobs: status.pending_jobs, + total_notes: 0, + total_blocks: 0, + fts_enabled: true, + vector_enabled: true, + last_indexed_at: status.last_completed_at ?? undefined, + error: status.error_message ?? undefined, } } -export async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full'): Promise<{ job_id: string }> { - return apiClient.post('/api/index/rebuild', { scope }) +export async function getIndexStatus(): Promise { + return toIndexStatus(await apiClient.get('/api/index/status')) } -export async function getIndexJob(jobId: string): Promise<{ - job_id: string - status: 'queued' | 'running' | 'completed' | 'failed' - progress: number - total: number - error?: string -}> { +export async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full'): Promise { + const apiScope = scope === 'full' ? 'all' : scope === 'fts' ? 'notes' : 'vectors' + return apiClient.post('/api/index/rebuild', { scope: apiScope }) +} + +export async function getIndexJob(jobId: string): Promise { return apiClient.get(`/api/index/jobs/${jobId}`) } diff --git a/frontend/src/services/noteService.ts b/frontend/src/services/noteService.ts index 7771be8..d568c6c 100644 --- a/frontend/src/services/noteService.ts +++ b/frontend/src/services/noteService.ts @@ -1,38 +1,39 @@ import apiClient from './apiClient' -import type { Note, NoteBlock } from '@/contracts' +import type { ApiNote, ApiNoteSummary, OperationResponse, PageMeta } from '@/contracts' export async function listNotes(params?: { folder?: string tag?: string limit?: number offset?: number -}): Promise<{ items: Note[]; total: number }> { +}): Promise<{ items: ApiNoteSummary[]; page: PageMeta }> { return apiClient.get('/api/notes', { params }) } -export async function getNote(noteId: string): Promise<{ note: Note; blocks: NoteBlock[] }> { +export async function getNote(noteId: string): Promise { return apiClient.get(`/api/notes/${noteId}`) } export async function createNote(data: { title: string - folder_path?: string - content?: string -}): Promise { + folder?: string + markdown?: string + tags?: string[] +}): Promise { return apiClient.post('/api/notes', data) } export async function updateNote( noteId: string, - data: { title?: string; content?: string; tags?: string[] } -): Promise { + data: { title?: string; markdown?: string; tags?: string[] } +): Promise { return apiClient.patch(`/api/notes/${noteId}`, data) } -export async function deleteNote(noteId: string): Promise { +export async function deleteNote(noteId: string): Promise { return apiClient.delete(`/api/notes/${noteId}`) } -export async function moveNote(noteId: string, target_folder: string): Promise { - return apiClient.post(`/api/notes/${noteId}/move`, { target_folder }) +export async function moveNote(noteId: string, folder: string): Promise { + return apiClient.post(`/api/notes/${noteId}/move`, { folder }) } diff --git a/frontend/src/services/pluginService.ts b/frontend/src/services/pluginService.ts index d2baf2b..ae81473 100644 --- a/frontend/src/services/pluginService.ts +++ b/frontend/src/services/pluginService.ts @@ -1,31 +1,59 @@ import apiClient from './apiClient' -import type { Plugin } from '@/contracts' +import type { ApiPlugin, OperationResponse, Plugin, PluginContribution } from '@/contracts' -export async function listPlugins(): Promise { - try { - return await apiClient.get('/api/plugins') - } catch { - return mockPlugins +function toPlugin(plugin: ApiPlugin): Plugin { + const { manifest } = plugin + const contributions: PluginContribution[] = [] + const append = (type: PluginContribution['type'], values: string[]) => { + values.forEach((id) => contributions.push({ type, id, name: id })) + } + append('tool', manifest.contributes.tools) + append('command', manifest.contributes.commands) + append('importer', manifest.contributes.importers) + append('exporter', manifest.contributes.exporters) + append('sidebar_panel', manifest.contributes.panels) + append('settings_section', manifest.contributes.settings_sections) + return { + plugin_id: manifest.plugin_id, + name: manifest.name, + version: manifest.version, + description: manifest.description, + status: plugin.status, + enabled: plugin.enabled, + permissions: plugin.granted_permissions, + contributions, + backend_type: manifest.backend.type, + transport: manifest.backend.transport, + last_error: plugin.error_message ?? undefined, } } -export async function getPlugin(pluginId: string): Promise { - return apiClient.get(`/api/plugins/${pluginId}`) +export async function listPlugins(): Promise { + const response = await apiClient.get<{ items: ApiPlugin[] }>('/api/plugins') + return response.items.map(toPlugin) } -export async function installPlugin(pluginId: string): Promise { - return apiClient.post('/api/plugins/install', { plugin_id: pluginId }) +export async function getPlugin(pluginId: string): Promise { + return toPlugin(await apiClient.get(`/api/plugins/${pluginId}`)) +} + +export async function installPlugin(packagePath: string): Promise { + return toPlugin(await apiClient.post('/api/plugins/install', { package_path: packagePath })) } export async function enablePlugin(pluginId: string): Promise { - return apiClient.post(`/api/plugins/${pluginId}/enable`) + return toPlugin(await apiClient.post(`/api/plugins/${pluginId}/enable`)) } export async function disablePlugin(pluginId: string): Promise { - return apiClient.post(`/api/plugins/${pluginId}/disable`) + return toPlugin(await apiClient.post(`/api/plugins/${pluginId}/disable`)) } -export async function uninstallPlugin(pluginId: string): Promise { +export async function grantPluginPermissions(pluginId: string, permissions: string[]): Promise { + return toPlugin(await apiClient.put(`/api/plugins/${pluginId}/permissions`, { permissions })) +} + +export async function uninstallPlugin(pluginId: string): Promise { return apiClient.delete(`/api/plugins/${pluginId}`) } @@ -80,7 +108,7 @@ export const mockPlugins: Plugin[] = [ contributions: [ { type: 'sidebar_panel', id: 'kanban.panel', name: '任务看板', description: '以看板方式查看和管理任务' }, ], - backend_type: 'internal', + backend_type: 'internal_rpc', }, { plugin_id: 'pdf-importer', @@ -114,6 +142,6 @@ export const mockPlugins: Plugin[] = [ { type: 'sidebar_panel', id: 'calendar.widget', name: '日历小部件', description: '侧边栏日历视图' }, ], backend_type: 'mcp', - transport: 'websocket', + transport: 'http', }, ] diff --git a/frontend/src/services/providerService.ts b/frontend/src/services/providerService.ts index 75397fa..ddaeee1 100644 --- a/frontend/src/services/providerService.ts +++ b/frontend/src/services/providerService.ts @@ -1,36 +1,67 @@ import apiClient from './apiClient' -import type { ProviderConfig, ModelInfo } from '@/contracts' +import type { ApiModelInfo, ApiProviderConfig, ModelCapability, ModelInfo, OperationResponse, ProviderConfig } from '@/contracts' -export async function listProviders(): Promise { - try { - return await apiClient.get('/api/providers') - } catch { - return mockProviders +function capabilityMap(capabilities: string[]): Partial { + return Object.fromEntries(capabilities.map((capability) => [capability, true])) as Partial +} + +function toProvider(provider: ApiProviderConfig): ProviderConfig { + return { + provider_id: provider.provider_id, + provider_type: provider.provider_type, + name: provider.name, + base_url: provider.base_url ?? undefined, + default_model: provider.default_model ?? '', + enabled: provider.enabled, + capabilities: capabilityMap(provider.capabilities), + credential_id: provider.credential_id ?? undefined, + has_credential: Boolean(provider.credential_id) || provider.provider_type === 'mock', } } +function toModel(model: ApiModelInfo): ModelInfo { + return { model_id: model.model, name: model.display_name, capabilities: capabilityMap(model.capabilities) } +} + +export async function listProviders(): Promise { + const response = await apiClient.get<{ items: ApiProviderConfig[] }>('/api/providers') + return response.items.map(toProvider) +} + export async function getProvider(providerId: string): Promise { - return apiClient.get(`/api/providers/${providerId}`) + return toProvider(await apiClient.get(`/api/providers/${providerId}`)) } -export async function createProvider(data: Omit & { api_key?: string }): Promise { - return apiClient.post('/api/providers', data) +export async function createProvider(data: Omit): Promise { + const response = await apiClient.post('/api/providers', { + provider_type: data.provider_type, + name: data.name, + base_url: data.base_url, + default_model: data.default_model || null, + credential_id: data.credential_id, + enabled: data.enabled, + }) + return toProvider(response) } -export async function updateProvider(providerId: string, data: Partial & { api_key?: string }): Promise { - return apiClient.patch(`/api/providers/${providerId}`, data) +export async function updateProvider(providerId: string, data: Partial): Promise { + const response = await apiClient.patch(`/api/providers/${providerId}`, { + name: data.name, + base_url: data.base_url, + default_model: data.default_model, + credential_id: data.credential_id, + enabled: data.enabled, + }) + return toProvider(response) } -export async function deleteProvider(providerId: string): Promise { +export async function deleteProvider(providerId: string): Promise { return apiClient.delete(`/api/providers/${providerId}`) } export async function listModels(providerId: string): Promise { - try { - return await apiClient.get(`/api/providers/${providerId}/models`) - } catch { - return mockModels[providerId] || [] - } + const response = await apiClient.get<{ provider_id: string; items: ApiModelInfo[] }>(`/api/providers/${providerId}/models`) + return response.items.map(toModel) } export interface TestResult { @@ -42,8 +73,8 @@ export interface TestResult { export async function testProvider(providerId: string): Promise { try { - const result = await apiClient.post<{ success: boolean; latency_ms: number }>('/api/providers/test', { provider_id: providerId }) - return { success: result.success, latency_ms: result.latency_ms } + const result = await apiClient.post<{ success: boolean; latency_ms?: number | null; message: string }>('/api/providers/test', { provider_id: providerId }) + return { success: result.success, latency_ms: result.latency_ms ?? undefined, error_message: result.success ? undefined : result.message } } catch (e: any) { return { success: false, error_code: e.code || 'TEST_FAILED', error_message: e.message } } @@ -51,7 +82,7 @@ export async function testProvider(providerId: string): Promise { export const mockProviders: ProviderConfig[] = [ { - provider_id: 'mock-provider', + provider_id: 'mock', provider_type: 'mock', name: 'Mock Provider (测试)', default_model: 'mock-1', @@ -69,7 +100,7 @@ export const mockProviders: ProviderConfig[] = [ }, { provider_id: 'openai-compat-1', - provider_type: 'openai-compatible', + provider_type: 'openai_compatible', name: 'OpenAI 兼容服务', base_url: 'https://api.openai.com/v1', default_model: 'gpt-4o-mini', @@ -106,7 +137,7 @@ export const mockProviders: ProviderConfig[] = [ ] export const mockModels: Record = { - 'mock-provider': [ + mock: [ { model_id: 'mock-1', name: 'Mock Model v1', diff --git a/frontend/src/services/searchService.ts b/frontend/src/services/searchService.ts index fd7a3e4..81758b5 100644 --- a/frontend/src/services/searchService.ts +++ b/frontend/src/services/searchService.ts @@ -1,15 +1,45 @@ import apiClient from './apiClient' -import type { SearchRequest, SearchResult } from '@/contracts' +import type { ApiSearchResult, PageMeta, SearchRequest, SearchResult } from '@/contracts' export async function search(request: SearchRequest): Promise<{ results: SearchResult[] total: number mode: SearchRequest['mode'] }> { - return apiClient.post('/api/search', request) + const response = await apiClient.post<{ + query: string + mode: 'fts' | 'vector' | 'hybrid' + items: ApiSearchResult[] + page: PageMeta + }>('/api/search', { + query: request.query, + mode: request.mode ?? 'hybrid', + folders: request.folder ? [request.folder] : [], + note_ids: request.note_id ? [request.note_id] : [], + tags: request.tag ? [request.tag] : [], + limit: request.limit ?? 20, + offset: request.offset ?? 0, + }) + return { + results: response.items.map((item) => ({ + block_id: item.block_id, + note_id: item.note_id, + note_title: item.title, + file_path: item.file_path, + heading_path: item.heading_path.join(' / '), + snippet: item.snippet ?? '', + score: item.score, + match_type: response.mode, + })), + total: response.page.total, + mode: response.mode, + } } -export async function searchMock(query: string, mode = 'hybrid' as const): Promise<{ +export async function searchMock( + query: string, + mode: 'fts' | 'vector' | 'hybrid' = 'hybrid' +): Promise<{ results: SearchResult[] total: number mode: 'fts' | 'vector' | 'hybrid' diff --git a/frontend/src/services/skillService.ts b/frontend/src/services/skillService.ts index a2f61a7..c2babca 100644 --- a/frontend/src/services/skillService.ts +++ b/frontend/src/services/skillService.ts @@ -1,31 +1,45 @@ import apiClient from './apiClient' -import type { Skill } from '@/contracts' +import type { ApiSkill, OperationResponse, Skill } from '@/contracts' -export async function listSkills(): Promise { - try { - return await apiClient.get('/api/skills') - } catch { - return mockSkills +function toSkill(skill: ApiSkill): Skill { + const { manifest } = skill + return { + skill_id: manifest.skill_id, + name: manifest.name, + version: manifest.version, + description: manifest.description, + permissions: manifest.permissions, + tools: manifest.tools, + retrieval_config: manifest.retrieval, + model_requirements: { capabilities: manifest.model.required_capabilities }, + status: skill.status, + missing_dependencies: skill.missing_dependencies, + enabled: skill.enabled, } } -export async function getSkill(skillId: string): Promise { - return apiClient.get(`/api/skills/${skillId}`) +export async function listSkills(): Promise { + const response = await apiClient.get<{ items: ApiSkill[] }>('/api/skills') + return response.items.map(toSkill) } -export async function installSkill(skillId: string): Promise { - return apiClient.post('/api/skills/install', { skill_id: skillId }) +export async function getSkill(skillId: string): Promise { + return toSkill(await apiClient.get(`/api/skills/${skillId}`)) +} + +export async function installSkill(packagePath: string): Promise { + return toSkill(await apiClient.post('/api/skills/install', { package_path: packagePath })) } export async function enableSkill(skillId: string): Promise { - return apiClient.post(`/api/skills/${skillId}/enable`) + return toSkill(await apiClient.post(`/api/skills/${skillId}/enable`)) } export async function disableSkill(skillId: string): Promise { - return apiClient.post(`/api/skills/${skillId}/disable`) + return toSkill(await apiClient.post(`/api/skills/${skillId}/disable`)) } -export async function uninstallSkill(skillId: string): Promise { +export async function uninstallSkill(skillId: string): Promise { return apiClient.delete(`/api/skills/${skillId}`) } diff --git a/frontend/src/services/sseClient.ts b/frontend/src/services/sseClient.ts index d5341ee..e94b9ce 100644 --- a/frontend/src/services/sseClient.ts +++ b/frontend/src/services/sseClient.ts @@ -1,3 +1,5 @@ +import { resolveApiUrl } from './apiClient' + export type SseEventHandler = (event: string, data: Record) => void export interface SseClientOptions { @@ -37,7 +39,7 @@ export class SseClient { headers['Authorization'] = `Bearer ${token}` } - const resp = await fetch(url, { + const resp = await fetch(resolveApiUrl(url), { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, @@ -53,6 +55,39 @@ export class SseClient { onOpen?.() const decoder = new TextDecoder('utf-8') + let eventName = 'message' + let dataLines: string[] = [] + let doneNotified = false + + const dispatchEvent = () => { + if (!dataLines.length) { + eventName = 'message' + return + } + try { + const data = JSON.parse(dataLines.join('\n')) as Record + onEvent?.(eventName, data) + if (!doneNotified && ['Done', 'RunCompleted', 'RunFailed', 'RunCancelled'].includes(eventName)) { + doneNotified = true + onDone?.() + } + } catch (error) { + onError?.(error instanceof Error ? error : new Error('Malformed SSE data')) + } + eventName = 'message' + dataLines = [] + } + + const consumeLine = (line: string) => { + if (line === '') return dispatchEvent() + if (line.startsWith(':')) return + const separator = line.indexOf(':') + const field = separator === -1 ? line : line.slice(0, separator) + let fieldValue = separator === -1 ? '' : line.slice(separator + 1) + if (fieldValue.startsWith(' ')) fieldValue = fieldValue.slice(1) + if (field === 'event') eventName = fieldValue + if (field === 'data') dataLines.push(fieldValue) + } while (true) { const { value, done } = await this.reader.read() @@ -60,39 +95,15 @@ export class SseClient { this.buffer += decoder.decode(value, { stream: true }) - const lines = this.buffer.split('\n') + const lines = this.buffer.split(/\r?\n/) this.buffer = lines.pop() || '' - - let eventName = 'message' - let dataStr = '' - - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) { - if (dataStr) { - try { - const data = JSON.parse(dataStr) - onEvent?.(eventName, data) - if (eventName === 'Done' || eventName === 'RunCompleted' || eventName === 'RunFailed' || eventName === 'RunCancelled') { - onDone?.() - } - } catch { - /* ignore malformed json */ - } - eventName = 'message' - dataStr = '' - } - continue - } - - if (trimmed.startsWith('event:')) { - eventName = trimmed.slice(6).trim() - } else if (trimmed.startsWith('data:')) { - const d = trimmed.slice(5).trim() - dataStr += dataStr ? '\n' + d : d - } - } + lines.forEach(consumeLine) } + + this.buffer += decoder.decode() + if (this.buffer) consumeLine(this.buffer.replace(/\r$/, '')) + dispatchEvent() + if (!doneNotified) onDone?.() } catch (e) { if ((e as Error).name === 'AbortError') return onError?.(e as Error) diff --git a/frontend/src/services/systemService.ts b/frontend/src/services/systemService.ts index d7c896d..5672e68 100644 --- a/frontend/src/services/systemService.ts +++ b/frontend/src/services/systemService.ts @@ -14,10 +14,10 @@ export async function getStatus(): Promise { return await apiClient.get('/api/status') } catch { return { + status: 'ok', name: 'notes-agent', version: '0.1.0', environment: import.meta.env.DEV ? 'development' : 'production', - ai_core_available: false, } } } diff --git a/frontend/src/services/taskService.ts b/frontend/src/services/taskService.ts index 478e8d6..9acda98 100644 --- a/frontend/src/services/taskService.ts +++ b/frontend/src/services/taskService.ts @@ -1,42 +1,62 @@ import apiClient from './apiClient' -import type { TaskItem, TaskStatus, TaskPriority } from '@/contracts' +import type { ApiTask, OperationResponse, PageMeta, TaskItem, TaskStatus, TaskPriority } from '@/contracts' -export async function listTasks(params?: { - status?: TaskStatus - priority?: TaskPriority - source?: 'user' | 'note' | 'agent' - limit?: number - offset?: number -}): Promise<{ items: TaskItem[]; total: number }> { - try { - return await apiClient.get('/api/tasks', { params }) - } catch { - return { items: mockTasks, total: mockTasks.length } +function toTask(task: ApiTask): TaskItem { + return { + task_id: task.task_id, + title: task.title, + description: task.description, + status: task.status, + priority: 'medium', + due_date: task.due_at ?? undefined, + note_id: task.note_id ?? undefined, + source: 'user', + created_at: task.created_at, + updated_at: task.updated_at, } } +export async function listTasks(params?: { + limit?: number + offset?: number +}): Promise<{ items: TaskItem[]; total: number }> { + const response = await apiClient.get<{ items: ApiTask[]; page: PageMeta }>('/api/tasks', { + params: { limit: params?.limit, offset: params?.offset }, + }) + return { items: response.items.map(toTask), total: response.page.total } +} + export async function getTask(taskId: string): Promise { - return apiClient.get(`/api/tasks/${taskId}`) + return toTask(await apiClient.get(`/api/tasks/${taskId}`)) } export async function createTask(data: { title: string description?: string - priority?: TaskPriority due_date?: string note_id?: string }): Promise { - return apiClient.post('/api/tasks', data) + return toTask(await apiClient.post('/api/tasks', { + title: data.title, + description: data.description ?? '', + due_at: data.due_date, + note_id: data.note_id, + })) } export async function updateTask( taskId: string, data: Partial> ): Promise { - return apiClient.patch(`/api/tasks/${taskId}`, data) + return toTask(await apiClient.patch(`/api/tasks/${taskId}`, { + title: data.title, + description: data.description, + status: data.status, + due_at: data.due_date, + })) } -export async function deleteTask(taskId: string): Promise { +export async function deleteTask(taskId: string): Promise { return apiClient.delete(`/api/tasks/${taskId}`) } diff --git a/frontend/src/services/workspaceService.ts b/frontend/src/services/workspaceService.ts index 1a8a032..bede20c 100644 --- a/frontend/src/services/workspaceService.ts +++ b/frontend/src/services/workspaceService.ts @@ -209,13 +209,13 @@ export function saveFileContent(filePath: string, content: string): Promise { - const path = `${folderPath}/${name}` + const path = `${folderPath === '/' ? '' : folderPath}/${name}` const id = `n-${Date.now()}` return Promise.resolve({ id, name, path, type: 'file' }) } export function createFolder(parentPath: string, name: string): Promise { - const path = `${parentPath}/${name}` + const path = `${parentPath === '/' ? '' : parentPath}/${name}` const id = `f-${Date.now()}` return Promise.resolve({ id, name, path, type: 'folder', is_open: true, children: [] }) } diff --git a/frontend/src/stores/agent.ts b/frontend/src/stores/agent.ts index 659a01f..414d959 100644 --- a/frontend/src/stores/agent.ts +++ b/frontend/src/stores/agent.ts @@ -72,7 +72,7 @@ export const useAgentStore = defineStore('agent', () => { event: 'RunStarted', sequence: 1, run_id: run.run_id, - data: { task: request.task }, + data: { input: request.input }, timestamp: new Date().toISOString(), }] isRunning.value = true @@ -112,9 +112,10 @@ export const useAgentStore = defineStore('agent', () => { isRunning.value = false } - async function respondPermission(decision: 'allow' | 'deny', scope: 'once' | 'session' | 'always' = 'once') { + async function respondPermission(decision: 'allow' | 'deny', scope: 'once' | 'session' = 'once') { if (!activeRunId.value || !permissionRequest.value) return - await agentService.respondToPermission(activeRunId.value, permissionRequest.value.request_id, decision, scope) + const apiDecision = decision === 'deny' ? 'deny' : scope === 'session' ? 'allow_session' : 'allow_once' + await agentService.respondToPermission(activeRunId.value, permissionRequest.value.request_id, apiDecision) permissionRequest.value = null } diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts index 8af2e7c..309ac04 100644 --- a/frontend/src/stores/chat.ts +++ b/frontend/src/stores/chat.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import type { ChatMessage, Conversation, Citation } from '@/contracts' -import { mockConversations, mockMessages } from '@/services/chatService' +import type { ChatMessage, Conversation } from '@/contracts' +import { mockConversations, mockMessages, streamChat } from '@/services/chatService' import type { SseClient } from '@/services/sseClient' export const useChatStore = defineStore('chat', () => { @@ -12,7 +12,7 @@ export const useChatStore = defineStore('chat', () => { const inputText = ref('') const useRag = ref(true) const selectedSkillId = ref(null) - const selectedProviderId = ref('mock-provider') + const selectedProviderId = ref('mock') const selectedModel = ref('mock-1') let sseClient: SseClient | null = null @@ -35,7 +35,7 @@ export const useChatStore = defineStore('chat', () => { if (!activeConversationId.value) { const newConv: Conversation = { - conversation_id, + conversation_id: conversationId, title: text.slice(0, 30), created_at: new Date().toISOString(), updated_at: new Date().toISOString(), @@ -67,31 +67,29 @@ export const useChatStore = defineStore('chat', () => { } 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: '红黑树是一种自平衡二叉搜索树...', + sseClient = streamChat({ + provider_id: selectedProviderId.value, + model: selectedModel.value, + conversation_id: conversationId, + use_rag: useRag.value, + messages: messages.value + .filter((message) => message !== aiMsg) + .map((message) => ({ role: message.role, content: message.content })), + }, { + onEvent(event) { + if (event.event === 'TextDelta') aiMsg.content += String(event.data.text ?? '') + if (event.event === 'Error') aiMsg.content += `\n\n生成失败:${String(event.data.message ?? '未知错误')}` }, - ] - - let i = 0 - const interval = setInterval(() => { - if (i >= fullText.length) { - clearInterval(interval) + onError(error) { + aiMsg.content += `\n\n连接失败:${error.message}` isStreaming.value = false - aiMsg.citations = citations - return - } - const chunk = fullText.slice(i, i + 3) - aiMsg.content += chunk - i += 3 - }, 20) + sseClient = null + }, + onDone() { + isStreaming.value = false + sseClient = null + }, + }) } function stopGeneration() { diff --git a/frontend/src/stores/provider.ts b/frontend/src/stores/provider.ts index 1c8f614..2c317f9 100644 --- a/frontend/src/stores/provider.ts +++ b/frontend/src/stores/provider.ts @@ -1,12 +1,12 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { ProviderConfig, ModelInfo } from '@/contracts' -import { mockProviders, mockModels } from '@/services/providerService' +import { listModels, listProviders, mockProviders, mockModels } from '@/services/providerService' export const useProviderStore = defineStore('provider', () => { const providers = ref(mockProviders) const modelsByProvider = ref>(mockModels) - const defaultProviderId = ref('mock-provider') + const defaultProviderId = ref('mock') const isLoading = ref(false) const enabledProviders = computed(() => providers.value.filter((p) => p.enabled)) @@ -17,7 +17,6 @@ export const useProviderStore = defineStore('provider', () => { async function loadProviders() { isLoading.value = true try { - const { listProviders } = await import('@/services/providerService') providers.value = await listProviders() } finally { isLoading.value = false @@ -25,11 +24,10 @@ export const useProviderStore = defineStore('provider', () => { } async function loadModels(providerId: string) { - const { listModels } = await import('@/services/providerService') modelsByProvider.value[providerId] = await listModels(providerId) } - async function addProvider(data: Omit & { api_key?: string }) { + async function addProvider(data: Omit) { const newProvider: ProviderConfig = { ...data, provider_id: `prov-${Date.now()}`, diff --git a/frontend/src/stores/search.ts b/frontend/src/stores/search.ts index 9cb4d62..03cae07 100644 --- a/frontend/src/stores/search.ts +++ b/frontend/src/stores/search.ts @@ -21,7 +21,7 @@ export const useSearchStore = defineStore('search', () => { error.value = null try { - const resp = await searchService.searchMock(request.query, request.mode || 'hybrid') + const resp = await searchService.search(request) results.value = resp.results total.value = resp.total selectedIndex.value = 0 diff --git a/frontend/src/stores/workspace.ts b/frontend/src/stores/workspace.ts index 56ae0db..50a3812 100644 --- a/frontend/src/stores/workspace.ts +++ b/frontend/src/stores/workspace.ts @@ -88,6 +88,10 @@ export const useWorkspaceStore = defineStore('workspace', () => { } function addFileToTree(parentPath: string, file: FileNode) { + if (parentPath === '/' || parentPath === '') { + fileTree.value.push(file) + return + } const parent = findNodeByPath(fileTree.value, parentPath) if (parent?.children) { parent.children.push(file) diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 4d16005..4c5cfb1 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -11,6 +11,10 @@ "esModuleInterop": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, "noEmit": true }, "include": ["src/**/*.ts", "src/**/*.vue"]
该功能将在对应页面实现时补充。
基础路由已经就绪,具体页面将在后续功能开发中实现。