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:
2026-08-29 10:36:16 +08:00
parent 694c1b27c1
commit f9efc4f4a2
42 changed files with 8044 additions and 167 deletions
+289
View File
@@ -0,0 +1,289 @@
import apiClient from './apiClient'
import { SseClient } from './sseClient'
import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest } from '@/contracts'
export async function listAgentRuns(params?: {
limit?: number
offset?: number
}): Promise<{ items: AgentRun[]; total: number }> {
return apiClient.get('/api/agent/runs', { params })
}
export async function getAgentRun(runId: string): Promise<AgentRun> {
return apiClient.get(`/api/agent/runs/${runId}`)
}
export interface CreateAgentRunRequest {
task: string
provider_id?: string
model?: string
skill_id?: string
allowed_tools?: string[]
max_steps?: number
tool_timeout?: number
run_timeout?: number
token_budget?: number
allow_network?: boolean
max_concurrent_tools?: number
}
export async function createAgentRun(request: CreateAgentRunRequest): Promise<AgentRun> {
return apiClient.post('/api/agent/runs', request)
}
export async function cancelAgentRun(runId: string): Promise<void> {
return apiClient.post(`/api/agent/runs/${runId}/cancel`)
}
export async function listTools(): Promise<ToolDefinition[]> {
try {
return await apiClient.get('/api/tools')
} catch {
return mockTools
}
}
export function streamAgentEvents(
runId: string,
handlers: {
onEvent?: (event: AgentEvent) => void
onError?: (error: Error) => void
onDone?: () => void
onOpen?: () => void
}
): SseClient {
const client = new SseClient({
url: `/api/agent/runs/${runId}/events`,
method: 'GET',
onEvent: (eventName, data) => {
handlers.onEvent?.({
event: eventName as AgentEvent['event'],
sequence: (data.sequence as number) || 0,
run_id: (data.run_id as string) || runId,
data: (data.data || data) as Record<string, unknown>,
timestamp: (data.timestamp as string) || new Date().toISOString(),
})
},
onError: handlers.onError,
onDone: handlers.onDone,
onOpen: handlers.onOpen,
})
client.connect().catch(() => {})
return client
}
export async function respondToPermission(
runId: string,
requestId: string,
decision: 'allow' | 'deny',
scope?: 'once' | 'session' | 'always'
): Promise<void> {
return apiClient.post(`/api/agent/runs/${runId}/permissions/${requestId}`, {
decision,
scope,
})
}
export const mockTools: ToolDefinition[] = [
{
name: 'notes.search',
description: '搜索笔记,支持关键词和语义检索',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: '搜索关键词' },
limit: { type: 'number', description: '返回结果数量' },
},
required: ['query'],
},
source: 'builtin',
},
{
name: 'notes.read',
description: '读取指定笔记的完整内容',
parameters: {
type: 'object',
properties: {
note_id: { type: 'string' },
},
required: ['note_id'],
},
source: 'builtin',
},
{
name: 'notes.create',
description: '创建新笔记',
parameters: {
type: 'object',
properties: {
title: { type: 'string' },
content: { type: 'string' },
folder_path: { type: 'string' },
},
required: ['title', 'content'],
},
source: 'builtin',
},
{
name: 'rag.search',
description: '基于 RAG 的语义检索,返回相关知识片段',
parameters: {
type: 'object',
properties: {
query: { type: 'string' },
top_k: { type: 'number' },
},
required: ['query'],
},
source: 'builtin',
},
{
name: 'tasks.create',
description: '创建任务',
parameters: {
type: 'object',
properties: {
title: { type: 'string' },
description: { type: 'string' },
priority: { type: 'string', enum: ['low', 'medium', 'high'] },
},
required: ['title'],
},
source: 'builtin',
},
{
name: 'system.echo',
description: '回显输入内容(测试用)',
parameters: {
type: 'object',
properties: {
text: { type: 'string' },
},
required: ['text'],
},
source: 'builtin',
},
{
name: 'math.add',
description: '两数相加(测试用)',
parameters: {
type: 'object',
properties: {
a: { type: 'number' },
b: { type: 'number' },
},
required: ['a', 'b'],
},
source: 'builtin',
},
]
export const mockAgentRuns: AgentRun[] = [
{
run_id: 'run-1',
status: 'completed',
current_step: 3,
max_steps: 10,
token_usage: { input_tokens: 2340, output_tokens: 890, total_tokens: 3230 },
started_at: '2026-08-25T11:00:00Z',
completed_at: '2026-08-25T11:02:30Z',
},
{
run_id: 'run-2',
status: 'running',
current_step: 2,
max_steps: 10,
token_usage: { input_tokens: 1500, output_tokens: 420, total_tokens: 1920 },
started_at: '2026-08-26T09:30:00Z',
},
]
export const mockAgentEvents: AgentEvent[] = [
{
event: 'RunStarted',
sequence: 1,
run_id: 'run-1',
data: { task: '帮我整理红黑树的核心知识点' },
timestamp: '2026-08-25T11:00:00Z',
},
{
event: 'ThinkingDelta',
sequence: 2,
run_id: 'run-1',
data: { text: '我需要先搜索笔记中关于红黑树的内容...' },
timestamp: '2026-08-25T11:00:01Z',
},
{
event: 'ToolCall',
sequence: 3,
run_id: 'run-1',
data: {
tool_call_id: 'tc-1',
name: 'notes.search',
parameters: { query: '红黑树 插入 删除', limit: 5 },
status: 'running',
},
timestamp: '2026-08-25T11:00:02Z',
},
{
event: 'ToolResult',
sequence: 4,
run_id: 'run-1',
data: {
tool_call_id: 'tc-1',
name: 'notes.search',
status: 'completed',
result: '找到 5 条相关结果,包括红黑树性质、插入操作、删除操作等...',
duration_ms: 320,
},
timestamp: '2026-08-25T11:00:02Z',
},
{
event: 'Citation',
sequence: 5,
run_id: 'run-1',
data: {
note_id: 'n-rbt',
block_id: 'b1',
heading_path: '数据结构 / 红黑树 / 性质',
},
timestamp: '2026-08-25T11:00:03Z',
},
{
event: 'ThinkingDelta',
sequence: 6,
run_id: 'run-1',
data: { text: '搜索结果很全面,让我整理一下结构...' },
timestamp: '2026-08-25T11:00:03Z',
},
{
event: 'TextDelta',
sequence: 7,
run_id: 'run-1',
data: { text: '## 红黑树核心知识点整理\n\n### 1. 基本性质\n红黑树是一种自平衡二叉搜索树,每个节点带有颜色属性...' },
timestamp: '2026-08-25T11:00:04Z',
},
{
event: 'Usage',
sequence: 8,
run_id: 'run-1',
data: { input_tokens: 2340, output_tokens: 890, total_tokens: 3230 },
timestamp: '2026-08-25T11:02:30Z',
},
{
event: 'RunCompleted',
sequence: 9,
run_id: 'run-1',
data: { message: 'Task completed successfully' },
timestamp: '2026-08-25T11:02:30Z',
},
]
export const mockPermissionRequest: PermissionRequest = {
request_id: 'perm-1',
run_id: 'run-2',
tool_name: 'notes.create',
permission: 'notes.write',
parameters: { title: '红黑树知识点总结', folder_path: '/数据结构' },
impact: '将在你的知识库中创建一篇新笔记',
}
+102
View File
@@ -0,0 +1,102 @@
import type { ApiError, ErrorResponse } from '@/contracts'
const BASE_URL = import.meta.env.VITE_API_BASE || ''
interface RequestOptions extends RequestInit {
params?: Record<string, string | number | boolean | undefined>
token?: string
}
export class ApiErrorClass extends Error {
code: string
details?: Record<string, unknown>
constructor(code: string, message: string, details?: Record<string, unknown>) {
super(message)
this.name = 'ApiError'
this.code = code
this.details = details
}
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { params, token, headers, ...rest } = options
let url = path.startsWith('http') ? path : `${BASE_URL}${path}`
if (params) {
const usp = new URLSearchParams()
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null) usp.append(k, String(v))
})
const qs = usp.toString()
if (qs) url += `?${qs}`
}
const reqHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...(headers as Record<string, string>),
}
if (token) {
reqHeaders['Authorization'] = `Bearer ${token}`
}
const reqId = crypto.randomUUID()
reqHeaders['X-Request-Id'] = reqId
try {
const resp = await fetch(url, {
...rest,
headers: reqHeaders,
})
if (resp.ok) {
if (resp.status === 204) return undefined as T
const ct = resp.headers.get('content-type') || ''
if (ct.includes('application/json')) return (await resp.json()) as T
return resp as unknown as T
}
let errBody: ErrorResponse | null = null
try {
errBody = (await resp.json()) as ErrorResponse
} catch {
/* ignore */
}
const code = errBody?.error?.code || `HTTP_${resp.status}`
const message = errBody?.error?.message || `Request failed with status ${resp.status}`
const details = errBody?.error?.details
throw new ApiErrorClass(code, message, details)
} catch (e) {
if (e instanceof ApiErrorClass) throw e
throw new ApiErrorClass('NETWORK_ERROR', (e as Error).message || 'Network error')
}
}
export const apiClient = {
get<T>(path: string, options?: Omit<RequestOptions, 'method'>) {
return request<T>(path, { ...options, method: 'GET' })
},
post<T>(path: string, body?: unknown, options?: Omit<RequestOptions, 'method' | 'body'>) {
return request<T>(path, {
...options,
method: 'POST',
body: body !== undefined ? JSON.stringify(body) : undefined,
})
},
patch<T>(path: string, body?: unknown, options?: Omit<RequestOptions, 'method' | 'body'>) {
return request<T>(path, {
...options,
method: 'PATCH',
body: body !== undefined ? JSON.stringify(body) : undefined,
})
},
delete<T>(path: string, options?: Omit<RequestOptions, 'method'>) {
return request<T>(path, { ...options, method: 'DELETE' })
},
}
export default apiClient
+138
View File
@@ -0,0 +1,138 @@
import apiClient from './apiClient'
import { SseClient } from './sseClient'
import type { Conversation, ChatMessage, ModelEvent } from '@/contracts'
export async function listConversations(): Promise<Conversation[]> {
return apiClient.get('/api/conversations')
}
export async function getConversation(conversationId: string): Promise<Conversation> {
return apiClient.get(`/api/conversations/${conversationId}`)
}
export async function getMessages(conversationId: string): Promise<ChatMessage[]> {
return apiClient.get(`/api/conversations/${conversationId}/messages`)
}
export interface ChatRequest {
conversation_id?: string
message: string
provider_id?: string
model?: string
use_rag?: boolean
skill_id?: string
attachments?: string[]
}
export function streamChat(
request: ChatRequest,
handlers: {
onEvent?: (event: ModelEvent) => void
onError?: (error: Error) => void
onDone?: () => void
onOpen?: () => void
}
): SseClient {
const client = new SseClient({
url: '/api/chat',
method: 'POST',
body: request,
onEvent: (eventName, data) => {
handlers.onEvent?.({
event: eventName as ModelEvent['event'],
sequence: data.sequence as number,
data: (data.data || {}) as Record<string, unknown>,
timestamp: (data.timestamp as string) || new Date().toISOString(),
})
},
onError: handlers.onError,
onDone: handlers.onDone,
onOpen: handlers.onOpen,
})
client.connect().catch(() => {})
return client
}
export const mockConversations: Conversation[] = [
{
conversation_id: 'conv-1',
title: '关于红黑树的讨论',
created_at: '2026-08-25T10:00:00Z',
updated_at: '2026-08-25T10:30:00Z',
message_count: 6,
},
{
conversation_id: 'conv-2',
title: '死锁避免算法',
created_at: '2026-08-24T14:00:00Z',
updated_at: '2026-08-24T15:20:00Z',
message_count: 4,
},
{
conversation_id: 'conv-3',
title: 'TCP三次握手',
created_at: '2026-08-22T09:00:00Z',
updated_at: '2026-08-22T09:15:00Z',
message_count: 3,
},
]
export const mockMessages: Record<string, ChatMessage[]> = {
'conv-1': [
{
message_id: 'msg-1',
conversation_id: 'conv-1',
role: 'user',
content: '红黑树的插入操作有哪些情况需要调整?',
created_at: '2026-08-25T10:00:00Z',
},
{
message_id: 'msg-2',
conversation_id: 'conv-1',
role: 'assistant',
content:
'红黑树插入后可能违反红黑性质,需要通过旋转和变色来修复。主要有以下几种情况:\n\n**情况1:叔叔节点是红色**\n- 将父节点和叔叔节点设为黑色\n- 将祖父节点设为红色\n- 当前节点上移到祖父节点,继续向上调整\n\n**情况2:叔叔节点是黑色,且当前节点是右孩子**\n- 以父节点为支点左旋\n- 将当前节点转换为左孩子,进入情况3\n\n**情况3:叔叔节点是黑色,且当前节点是左孩子**\n- 以祖父节点为支点右旋\n- 将父节点设为黑色,祖父节点设为红色\n\n以上三种情况需要从插入节点开始向上递归处理,直到根节点。',
created_at: '2026-08-25T10:01:00Z',
citations: [
{
note_id: 'n-rbt',
block_id: 'b1',
file_path: '/数据结构/红黑树.md',
heading_path: '数据结构 / 红黑树 / 插入操作',
content: '插入后可能破坏红黑性质,需要通过变色和旋转来修复...',
},
{
note_id: 'n-rbt',
block_id: 'b2',
file_path: '/数据结构/红黑树.md',
heading_path: '数据结构 / 红黑树 / 五种情况',
content: 'Case 1: 叔叔为红色,直接变色...',
},
],
},
{
message_id: 'msg-3',
conversation_id: 'conv-1',
role: 'user',
content: '能对比一下 AVL 树和红黑树的性能差异吗?',
created_at: '2026-08-25T10:10:00Z',
},
{
message_id: 'msg-4',
conversation_id: 'conv-1',
role: 'assistant',
content:
'AVL 树和红黑树都是平衡二叉搜索树,但它们的平衡策略和性能特点有所不同:\n\n| 特性 | AVL 树 | 红黑树 |\n|------|--------|--------|\n| 严格平衡 | 高度差 ≤ 1 | 黑色高度相同 |\n| 查找速度 | 更快(严格平衡) | 略慢但仍为 O(log n) |\n| 插入/删除 | 旋转更多 | 旋转更少(颜色调整为主) |\n| 存储开销 | 每个节点存高度(int) | 每个节点存颜色(1 bit) |\n| 适用场景 | 读多写少 | 读写均衡或写较多 |\n\n**总结:**\n- 如果应用以查询为主,很少修改,AVL 树可能更优\n- 如果插入删除频繁,红黑树更合适,重平衡开销更低\n- 红黑树在工业界应用更广泛(C++ STL 的 map/set、Java 的 TreeMap 等)',
created_at: '2026-08-25T10:11:00Z',
citations: [
{
note_id: 'n-rbt',
block_id: 'b3',
file_path: '/数据结构/红黑树.md',
heading_path: '数据结构 / 红黑树 / 与AVL树对比',
content: '红黑树相比AVL树,牺牲了部分平衡性以换取更少的旋转操作...',
},
],
},
],
}
+15
View File
@@ -0,0 +1,15 @@
export { apiClient, ApiErrorClass } from './apiClient'
export type { ApiError } from './apiClient'
export { SseClient } from './sseClient'
export type { SseClientOptions, SseEventHandler } from './sseClient'
export * as noteService from './noteService'
export * as searchService from './searchService'
export * as chatService from './chatService'
export * as agentService from './agentService'
export * as skillService from './skillService'
export * as pluginService from './pluginService'
export * as providerService from './providerService'
export * as taskService from './taskService'
export * as indexService from './indexService'
export * as systemService from './systemService'
export * as workspaceService from './workspaceService'
+36
View File
@@ -0,0 +1,36 @@
import apiClient from './apiClient'
import type { IndexStatus } from '@/contracts'
export async function getIndexStatus(): Promise<IndexStatus> {
try {
return await apiClient.get('/api/index/status')
} catch {
return mockIndexStatus
}
}
export async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full'): Promise<{ job_id: string }> {
return apiClient.post('/api/index/rebuild', { scope })
}
export async function getIndexJob(jobId: string): Promise<{
job_id: string
status: 'queued' | 'running' | 'completed' | 'failed'
progress: number
total: number
error?: string
}> {
return apiClient.get(`/api/index/jobs/${jobId}`)
}
export const mockIndexStatus: IndexStatus = {
status: 'idle',
pending_jobs: 0,
total_notes: 42,
total_blocks: 318,
fts_enabled: true,
vector_enabled: true,
embedding_model: 'bge-m3',
reranker_model: 'bge-reranker-base',
last_indexed_at: new Date().toISOString(),
}
+38
View File
@@ -0,0 +1,38 @@
import apiClient from './apiClient'
import type { Note, NoteBlock } from '@/contracts'
export async function listNotes(params?: {
folder?: string
tag?: string
limit?: number
offset?: number
}): Promise<{ items: Note[]; total: number }> {
return apiClient.get('/api/notes', { params })
}
export async function getNote(noteId: string): Promise<{ note: Note; blocks: NoteBlock[] }> {
return apiClient.get(`/api/notes/${noteId}`)
}
export async function createNote(data: {
title: string
folder_path?: string
content?: string
}): Promise<Note> {
return apiClient.post('/api/notes', data)
}
export async function updateNote(
noteId: string,
data: { title?: string; content?: string; tags?: string[] }
): Promise<Note> {
return apiClient.patch(`/api/notes/${noteId}`, data)
}
export async function deleteNote(noteId: string): Promise<void> {
return apiClient.delete(`/api/notes/${noteId}`)
}
export async function moveNote(noteId: string, target_folder: string): Promise<Note> {
return apiClient.post(`/api/notes/${noteId}/move`, { target_folder })
}
+119
View File
@@ -0,0 +1,119 @@
import apiClient from './apiClient'
import type { Plugin } from '@/contracts'
export async function listPlugins(): Promise<Plugin[]> {
try {
return await apiClient.get('/api/plugins')
} catch {
return mockPlugins
}
}
export async function getPlugin(pluginId: string): Promise<Plugin> {
return apiClient.get(`/api/plugins/${pluginId}`)
}
export async function installPlugin(pluginId: string): Promise<Plugin> {
return apiClient.post('/api/plugins/install', { plugin_id: pluginId })
}
export async function enablePlugin(pluginId: string): Promise<Plugin> {
return apiClient.post(`/api/plugins/${pluginId}/enable`)
}
export async function disablePlugin(pluginId: string): Promise<Plugin> {
return apiClient.post(`/api/plugins/${pluginId}/disable`)
}
export async function uninstallPlugin(pluginId: string): Promise<void> {
return apiClient.delete(`/api/plugins/${pluginId}`)
}
export const mockPlugins: Plugin[] = [
{
plugin_id: 'github-integration',
name: 'GitHub 集成',
version: '1.3.2',
description: '接入 GitHub API,支持搜索 Issue、查看 PR 和管理仓库',
icon: '🐙',
author: '知笔知己团队',
status: 'ready',
enabled: true,
permissions: ['notes.read', 'network.request'],
contributions: [
{ type: 'tool', id: 'github.search_issues', name: '搜索 Issue', description: '搜索 GitHub 仓库中的 Issue' },
{ type: 'tool', id: 'github.get_pr', name: '获取 PR 详情', description: '获取 Pull Request 的详细信息' },
{ type: 'command', id: 'github.open_repo', name: '打开仓库', description: '在浏览器中打开对应 GitHub 仓库' },
],
backend_type: 'mcp',
transport: 'stdio',
dependent_skills: ['research-assistant'],
},
{
plugin_id: 'translator',
name: '翻译助手',
version: '1.0.0',
description: '提供多语言翻译能力,支持文档批量翻译',
icon: '🌐',
author: '社区贡献',
status: 'ready',
enabled: false,
permissions: ['notes.read', 'notes.write', 'network.request'],
contributions: [
{ type: 'tool', id: 'translator.translate', name: '翻译文本', description: '翻译指定文本到目标语言' },
{ type: 'command', id: 'translator.translate_note', name: '翻译当前笔记', description: '翻译当前打开的笔记' },
{ type: 'settings_section', id: 'translator.settings', name: '翻译设置', description: '配置翻译服务和默认语言' },
],
backend_type: 'mcp',
transport: 'stdio',
},
{
plugin_id: 'kanban',
name: '看板视图',
version: '0.8.0',
description: '为任务提供看板视图,支持拖拽排序和多维度筛选',
icon: '📋',
author: '社区贡献',
status: 'installed',
enabled: false,
permissions: ['tasks.read', 'tasks.write'],
contributions: [
{ type: 'sidebar_panel', id: 'kanban.panel', name: '任务看板', description: '以看板方式查看和管理任务' },
],
backend_type: 'internal',
},
{
plugin_id: 'pdf-importer',
name: 'PDF 导入',
version: '2.1.0',
description: '导入 PDF 文档,提取文本和目录结构生成笔记',
icon: '📄',
author: '知笔知己团队',
status: 'error',
enabled: false,
permissions: ['notes.write', 'attachments.read'],
contributions: [
{ type: 'importer', id: 'pdf.import', name: 'PDF 导入器', description: '从 PDF 文件导入内容' },
],
backend_type: 'mcp',
transport: 'stdio',
last_error: 'PDF 解析库初始化失败,请检查 Python 依赖',
},
{
plugin_id: 'calendar',
name: '日历同步',
version: '0.5.0',
description: '同步日历事件,自动生成相关笔记和任务提醒',
icon: '📅',
author: '社区贡献',
status: 'dependency_missing',
enabled: false,
permissions: ['tasks.read', 'tasks.write', 'network.request'],
contributions: [
{ type: 'tool', id: 'calendar.events', name: '日历事件', description: '获取日历事件列表' },
{ type: 'sidebar_panel', id: 'calendar.widget', name: '日历小部件', description: '侧边栏日历视图' },
],
backend_type: 'mcp',
transport: 'websocket',
},
]
+149
View File
@@ -0,0 +1,149 @@
import apiClient from './apiClient'
import type { ProviderConfig, ModelInfo } from '@/contracts'
export async function listProviders(): Promise<ProviderConfig[]> {
try {
return await apiClient.get('/api/providers')
} catch {
return mockProviders
}
}
export async function getProvider(providerId: string): Promise<ProviderConfig> {
return apiClient.get(`/api/providers/${providerId}`)
}
export async function createProvider(data: Omit<ProviderConfig, 'provider_id'> & { api_key?: string }): Promise<ProviderConfig> {
return apiClient.post('/api/providers', data)
}
export async function updateProvider(providerId: string, data: Partial<ProviderConfig> & { api_key?: string }): Promise<ProviderConfig> {
return apiClient.patch(`/api/providers/${providerId}`, data)
}
export async function deleteProvider(providerId: string): Promise<void> {
return apiClient.delete(`/api/providers/${providerId}`)
}
export async function listModels(providerId: string): Promise<ModelInfo[]> {
try {
return await apiClient.get(`/api/providers/${providerId}/models`)
} catch {
return mockModels[providerId] || []
}
}
export interface TestResult {
success: boolean
latency_ms?: number
error_code?: string
error_message?: string
}
export async function testProvider(providerId: string): Promise<TestResult> {
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 }
} catch (e: any) {
return { success: false, error_code: e.code || 'TEST_FAILED', error_message: e.message }
}
}
export const mockProviders: ProviderConfig[] = [
{
provider_id: 'mock-provider',
provider_type: 'mock',
name: 'Mock Provider (测试)',
default_model: 'mock-1',
enabled: true,
has_credential: true,
capabilities: {
chat: true,
tool_calling: true,
streaming: true,
vision: false,
reasoning: false,
structured_output: true,
embedding: false,
},
},
{
provider_id: 'openai-compat-1',
provider_type: 'openai-compatible',
name: 'OpenAI 兼容服务',
base_url: 'https://api.openai.com/v1',
default_model: 'gpt-4o-mini',
enabled: true,
has_credential: true,
capabilities: {
chat: true,
tool_calling: true,
streaming: true,
vision: true,
reasoning: false,
structured_output: true,
embedding: true,
},
},
{
provider_id: 'ollama-local',
provider_type: 'ollama',
name: 'Ollama (本地)',
base_url: 'http://127.0.0.1:11434',
default_model: 'qwen2.5:7b',
enabled: false,
has_credential: false,
capabilities: {
chat: true,
tool_calling: false,
streaming: true,
vision: false,
reasoning: false,
structured_output: false,
embedding: true,
},
},
]
export const mockModels: Record<string, ModelInfo[]> = {
'mock-provider': [
{
model_id: 'mock-1',
name: 'Mock Model v1',
capabilities: { chat: true, tool_calling: true, streaming: true, structured_output: true },
context_window: 8192,
},
],
'openai-compat-1': [
{
model_id: 'gpt-4o-mini',
name: 'GPT-4o Mini',
capabilities: { chat: true, tool_calling: true, streaming: true, vision: true, structured_output: true },
context_window: 128000,
},
{
model_id: 'gpt-4o',
name: 'GPT-4o',
capabilities: { chat: true, tool_calling: true, streaming: true, vision: true, structured_output: true, reasoning: true },
context_window: 128000,
},
{
model_id: 'text-embedding-3-small',
name: 'Text Embedding 3 Small',
capabilities: { embedding: true },
},
],
'ollama-local': [
{
model_id: 'qwen2.5:7b',
name: 'Qwen 2.5 7B',
capabilities: { chat: true, streaming: true },
context_window: 32768,
},
{
model_id: 'bge-m3',
name: 'BGE M3',
capabilities: { embedding: true },
},
],
}
+73
View File
@@ -0,0 +1,73 @@
import apiClient from './apiClient'
import type { SearchRequest, SearchResult } from '@/contracts'
export async function search(request: SearchRequest): Promise<{
results: SearchResult[]
total: number
mode: SearchRequest['mode']
}> {
return apiClient.post('/api/search', request)
}
export async function searchMock(query: string, mode = 'hybrid' as const): Promise<{
results: SearchResult[]
total: number
mode: 'fts' | 'vector' | 'hybrid'
}> {
await new Promise((r) => setTimeout(r, 300))
if (!query.trim()) return { results: [], total: 0, mode }
const results: SearchResult[] = [
{
block_id: 'b1',
note_id: 'n-rbt',
note_title: '红黑树',
file_path: '/数据结构/红黑树.md',
heading_path: '数据结构 / 红黑树 / 插入操作',
snippet: '插入后可能破坏红黑性质,需要通过变色和旋转来修复...',
score: 0.95,
match_type: 'hybrid',
tags: ['数据结构', '树'],
},
{
block_id: 'b2',
note_id: 'n-rbt',
note_title: '红黑树',
file_path: '/数据结构/红黑树.md',
heading_path: '数据结构 / 红黑树 / 性质',
snippet: '红黑树是一种自平衡二叉搜索树,每个节点带有颜色属性(红或黑)...',
score: 0.87,
match_type: 'fts',
tags: ['数据结构'],
},
{
block_id: 'b3',
note_id: 'n-bst',
note_title: '二叉搜索树',
file_path: '/数据结构/二叉搜索树.md',
heading_path: '数据结构 / 二叉搜索树 / 基本操作',
snippet: '二叉搜索树的插入需要先找到合适的位置,再添加新节点...',
score: 0.72,
match_type: 'vector',
tags: ['数据结构', '树'],
},
{
block_id: 'b4',
note_id: 'n-deadlock',
note_title: '死锁',
file_path: '/操作系统/死锁.md',
heading_path: '操作系统 / 死锁 / 必要条件',
snippet: '死锁的四个必要条件:互斥、占有并等待、不可抢占、循环等待...',
score: 0.45,
match_type: 'vector',
tags: ['操作系统'],
},
]
const filtered = results.filter(
(r) =>
r.note_title.includes(query) ||
r.snippet.includes(query) ||
r.heading_path.includes(query) ||
query.length > 1
)
return { results: filtered, total: filtered.length, mode }
}
+104
View File
@@ -0,0 +1,104 @@
import apiClient from './apiClient'
import type { Skill } from '@/contracts'
export async function listSkills(): Promise<Skill[]> {
try {
return await apiClient.get('/api/skills')
} catch {
return mockSkills
}
}
export async function getSkill(skillId: string): Promise<Skill> {
return apiClient.get(`/api/skills/${skillId}`)
}
export async function installSkill(skillId: string): Promise<Skill> {
return apiClient.post('/api/skills/install', { skill_id: skillId })
}
export async function enableSkill(skillId: string): Promise<Skill> {
return apiClient.post(`/api/skills/${skillId}/enable`)
}
export async function disableSkill(skillId: string): Promise<Skill> {
return apiClient.post(`/api/skills/${skillId}/disable`)
}
export async function uninstallSkill(skillId: string): Promise<void> {
return apiClient.delete(`/api/skills/${skillId}`)
}
export const mockSkills: Skill[] = [
{
skill_id: 'exam-review',
name: '期末复习助手',
version: '1.0.0',
description: '根据课程笔记生成复习要点和练习题,帮助高效备考',
icon: '📚',
author: '知笔知己团队',
permissions: ['notes.search', 'notes.read', 'tasks.create'],
tools: ['notes.search', 'notes.read', 'tasks.create'],
retrieval_config: { top_k: 10, rerank: true, citation: true },
model_requirements: { capabilities: ['chat', 'tool_calling'] },
status: 'ready',
enabled: true,
},
{
skill_id: 'meeting-summary',
name: '会议纪要生成',
version: '1.1.0',
description: '从音频或文本中提取会议要点、行动项和待办任务',
icon: '📝',
author: '知笔知己团队',
permissions: ['notes.search', 'notes.write', 'tasks.write', 'attachments.read'],
tools: ['notes.search', 'notes.create', 'tasks.create', 'attachments.read'],
retrieval_config: { top_k: 5, rerank: false, citation: true },
model_requirements: { capabilities: ['chat', 'tool_calling', 'structured_output'] },
status: 'ready',
enabled: true,
},
{
skill_id: 'code-explainer',
name: '代码解读助手',
version: '0.9.0',
description: '分析代码片段,解释功能、复杂度和优化建议',
icon: '💻',
author: '社区贡献',
permissions: ['notes.search', 'notes.read'],
tools: ['notes.search', 'notes.read', 'rag.search'],
retrieval_config: { top_k: 8, rerank: true, citation: true },
model_requirements: { capabilities: ['chat', 'tool_calling'] },
status: 'installed',
enabled: false,
},
{
skill_id: 'research-assistant',
name: '文献研究助手',
version: '1.2.0',
description: '自动整理文献笔记,生成研究综述和引用关系图',
icon: '🔬',
author: '社区贡献',
permissions: ['notes.search', 'notes.read', 'notes.write'],
tools: ['notes.search', 'notes.read', 'notes.create', 'rag.search'],
retrieval_config: { top_k: 15, rerank: true, citation: true },
model_requirements: { capabilities: ['chat', 'tool_calling', 'reasoning'] },
status: 'dependency_missing',
enabled: false,
missing_dependencies: ['文献引用插件', '知识图谱插件'],
},
{
skill_id: 'language-tutor',
name: '语言学习助手',
version: '0.5.0',
description: '基于你的学习笔记生成语言练习和记忆卡片',
icon: '🌍',
author: '社区贡献',
permissions: ['notes.search', 'notes.read', 'tasks.create'],
tools: ['notes.search', 'notes.read', 'tasks.create'],
retrieval_config: { top_k: 6, rerank: false, citation: false },
model_requirements: { capabilities: ['chat'] },
status: 'ready',
enabled: true,
},
]
+114
View File
@@ -0,0 +1,114 @@
export type SseEventHandler = (event: string, data: Record<string, unknown>) => void
export interface SseClientOptions {
url: string
method?: string
body?: unknown
token?: string
onEvent?: SseEventHandler
onError?: (error: Error) => void
onOpen?: () => void
onDone?: () => void
}
export class SseClient {
private controller: AbortController
private reader: ReadableStreamDefaultReader<Uint8Array> | null = null
private options: SseClientOptions
private buffer = ''
private connected = false
constructor(options: SseClientOptions) {
this.options = options
this.controller = new AbortController()
}
async connect() {
const { url, method = 'POST', body, token, onEvent, onError, onOpen, onDone } = this.options
try {
const headers: Record<string, string> = {
Accept: 'text/event-stream',
}
if (body !== undefined) {
headers['Content-Type'] = 'application/json'
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const resp = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: this.controller.signal,
})
if (!resp.ok || !resp.body) {
throw new Error(`SSE connection failed: ${resp.status}`)
}
this.reader = resp.body.getReader()
this.connected = true
onOpen?.()
const decoder = new TextDecoder('utf-8')
while (true) {
const { value, done } = await this.reader.read()
if (done) break
this.buffer += decoder.decode(value, { stream: true })
const lines = this.buffer.split('\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
}
}
}
} catch (e) {
if ((e as Error).name === 'AbortError') return
onError?.(e as Error)
} finally {
this.connected = false
this.reader = null
}
}
cancel() {
this.controller.abort()
}
isConnected() {
return this.connected
}
}
export default SseClient
+23
View File
@@ -0,0 +1,23 @@
import apiClient from './apiClient'
import type { SystemStatus } from '@/contracts'
export async function healthCheck(): Promise<{ status: string }> {
try {
return await apiClient.get<{ status: string }>('/health')
} catch {
return { status: 'unavailable' }
}
}
export async function getStatus(): Promise<SystemStatus> {
try {
return await apiClient.get<SystemStatus>('/api/status')
} catch {
return {
name: 'notes-agent',
version: '0.1.0',
environment: import.meta.env.DEV ? 'development' : 'production',
ai_core_available: false,
}
}
}
+105
View File
@@ -0,0 +1,105 @@
import apiClient from './apiClient'
import type { 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 }
}
}
export async function getTask(taskId: string): Promise<TaskItem> {
return apiClient.get(`/api/tasks/${taskId}`)
}
export async function createTask(data: {
title: string
description?: string
priority?: TaskPriority
due_date?: string
note_id?: string
}): Promise<TaskItem> {
return apiClient.post('/api/tasks', data)
}
export async function updateTask(
taskId: string,
data: Partial<Pick<TaskItem, 'title' | 'description' | 'status' | 'priority' | 'due_date'>>
): Promise<TaskItem> {
return apiClient.patch(`/api/tasks/${taskId}`, data)
}
export async function deleteTask(taskId: string): Promise<void> {
return apiClient.delete(`/api/tasks/${taskId}`)
}
export const mockTasks: TaskItem[] = [
{
task_id: 't-1',
title: '完成红黑树章节复习',
description: '整理插入、删除操作的所有情况,准备期末复习',
status: 'todo',
priority: 'high',
due_date: '2026-08-30T23:59:00Z',
note_id: 'n-rbt',
note_title: '红黑树',
source: 'user',
created_at: '2026-08-20T10:00:00Z',
updated_at: '2026-08-25T14:30:00Z',
},
{
task_id: 't-2',
title: '理解死锁的银行家算法',
description: '推导银行家算法的安全性检查过程',
status: 'in_progress',
priority: 'medium',
note_id: 'n-deadlock',
note_title: '死锁',
source: 'agent',
created_at: '2026-08-22T09:00:00Z',
updated_at: '2026-08-24T16:00:00Z',
},
{
task_id: 't-3',
title: 'TCP 三次握手与四次挥手',
description: '',
status: 'done',
priority: 'high',
note_id: 'n-tcp',
note_title: 'TCP_IP',
source: 'user',
created_at: '2026-08-15T08:00:00Z',
updated_at: '2026-08-18T20:00:00Z',
},
{
task_id: 't-4',
title: 'HTTP 状态码整理',
description: '整理常见 HTTP 状态码及含义',
status: 'todo',
priority: 'low',
note_id: 'n-http',
note_title: 'HTTP协议',
source: 'note',
created_at: '2026-08-10T10:00:00Z',
updated_at: '2026-08-10T10:00:00Z',
},
{
task_id: 't-5',
title: '链表操作实现练习',
description: '实现单链表和双向链表的基本操作',
status: 'todo',
priority: 'medium',
note_id: 'n-slist',
note_title: '单链表',
source: 'agent',
created_at: '2026-08-23T11:00:00Z',
updated_at: '2026-08-23T11:00:00Z',
},
]
+233
View File
@@ -0,0 +1,233 @@
import type { FileNode } from '@/contracts'
// Mock workspace service for web dev mode
// In Tauri environment this will use Tauri IPC commands
export interface VaultInfo {
path: string
name: string
}
const MOCK_VAULTS: VaultInfo[] = [
{ path: '/Users/demo/Documents/MyVault', name: '我的知识库' },
{ path: '/Users/demo/Documents/StudyNotes', name: '学习笔记' },
]
const MOCK_FILE_TREE: FileNode[] = [
{
id: 'f-data',
name: '数据结构',
path: '/数据结构',
type: 'folder',
is_open: true,
children: [
{ id: 'n-rbt', name: '红黑树.md', path: '/数据结构/红黑树.md', type: 'file' },
{ id: 'n-bst', name: '二叉搜索树.md', path: '/数据结构/二叉搜索树.md', type: 'file' },
{
id: 'f-list',
name: '链表',
path: '/数据结构/链表',
type: 'folder',
is_open: false,
children: [
{ id: 'n-slist', name: '单链表.md', path: '/数据结构/链表/单链表.md', type: 'file' },
{ id: 'n-dlist', name: '双向链表.md', path: '/数据结构/链表/双向链表.md', type: 'file' },
],
},
],
},
{
id: 'f-os',
name: '操作系统',
path: '/操作系统',
type: 'folder',
is_open: false,
children: [
{ id: 'n-deadlock', name: '死锁.md', path: '/操作系统/死锁.md', type: 'file' },
{ id: 'n-sched', name: '进程调度.md', path: '/操作系统/进程调度.md', type: 'file' },
],
},
{
id: 'f-net',
name: '计算机网络',
path: '/计算机网络',
type: 'folder',
is_open: false,
children: [
{ id: 'n-tcp', name: 'TCP_IP.md', path: '/计算机网络/TCP_IP.md', type: 'file' },
{ id: 'n-http', name: 'HTTP协议.md', path: '/计算机网络/HTTP协议.md', type: 'file' },
],
},
{ id: 'n-welcome', name: '欢迎使用知笔知己.md', path: '/欢迎使用知笔知己.md', type: 'file' },
]
export function getRecentVaults(): Promise<VaultInfo[]> {
return Promise.resolve(MOCK_VAULTS)
}
export function openVault(path: string): Promise<VaultInfo> {
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Vault'
return Promise.resolve({ path, name })
}
export function createVault(path: string, name: string): Promise<VaultInfo> {
return Promise.resolve({ path, name })
}
export function getFileTree(): Promise<FileNode[]> {
return Promise.resolve(JSON.parse(JSON.stringify(MOCK_FILE_TREE)))
}
export function readFileContent(filePath: string): Promise<string> {
const name = filePath.split('/').pop() || 'Untitled'
if (name === '欢迎使用知笔知己.md') {
return Promise.resolve(`# 欢迎使用知笔知己
这是一款本地优先的 AI 笔记软件,支持 Markdown 编辑、智能检索、RAG 问答和 Agent 助手。
## 核心特性
- **本地优先**:所有笔记以 Markdown 格式保存在本地,数据完全由你掌控
- **混合检索**:FTS5 全文检索 + 向量语义检索,精准定位知识
- **AI 问答**:基于 RAG 技术,让 AI 基于你的笔记回答问题
- **Agent 助手**:通过工具调用,AI 可以帮你管理笔记、创建任务
- **Skill 系统**:将常用 AI 工作流保存为可复用的 Skill
- **插件扩展**:通过 Plugin 扩展应用能力
## 快速开始
1. 在左侧文件树中创建你的第一篇笔记
2. 使用 \`Ctrl+P\` 打开命令面板
3. 使用搜索功能快速找到你的笔记
4. 打开 AI 对话,开始与你的知识对话
> 提示:你可以在设置中配置你的模型提供商,开始使用 AI 功能。
## 编辑器模式
- **所见即所得模式**:使用 Milkdown 提供流畅的 Markdown 编辑体验
- **源码模式**:使用 CodeMirror 6 编辑原始 Markdown 源码
点击右上角按钮可以切换编辑模式。
## 代码示例
\`\`\`python
def quick_sort(arr):
if len(arr) <= 1:
return arr
pivot = arr[len(arr) // 2]
left = [x for x in arr if x < pivot]
middle = [x for x in arr if x == pivot]
right = [x for x in arr if x > pivot]
return quick_sort(left) + middle + quick_sort(right)
\`\`\`
## 任务列表
- [x] 完成项目初始化
- [x] 设计技术架构
- [ ] 实现前端界面
- [ ] 接入后端 AI Core
- [ ] 性能优化与测试
---
祝你写作愉快!
`)
}
if (name === '红黑树.md') {
return Promise.resolve(`# 红黑树
红黑树(Red-Black Tree)是一种自平衡二叉搜索树,每个节点带有颜色属性(红色或黑色)。
## 性质
1. 每个节点是红色或黑色
2. 根节点是黑色
3. 所有叶子节点(NIL)是黑色
4. 如果一个节点是红色,则它的两个子节点都是黑色
5. 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点
这些性质确保了红黑树的关键特性:**从根到叶子的最长可能路径不会超过最短可能路径的两倍长**。
## 插入操作
插入后可能破坏红黑性质,需要通过变色和旋转来修复。
### 情况1:叔叔节点是红色
将父节点和叔叔节点设为黑色,将祖父节点设为红色,当前节点上移到祖父节点,继续向上调整。
### 情况2:叔叔节点是黑色,且当前节点是右孩子
以父节点为支点左旋,将当前节点转换为左孩子,进入情况3。
### 情况3:叔叔节点是黑色,且当前节点是左孩子
以祖父节点为支点右旋,将父节点设为黑色,祖父节点设为红色。
## 与 AVL 树对比
| 特性 | AVL 树 | 红黑树 |
|------|--------|--------|
| 平衡严格度 | 高度差 ≤ 1 | 黑色高度相同 |
| 查找速度 | 更快 | 略慢 |
| 插入删除 | 旋转更多 | 旋转更少 |
| 适用场景 | 读多写少 | 读写均衡 |
## 应用场景
- C++ STL 的 map/set
- Java 的 TreeMap
- Linux 内核的完全公平调度器
`)
}
return Promise.resolve(`# ${name.replace('.md', '')}
这是一篇示例笔记。
## 第一部分
这里是笔记的内容。
## 第二部分
更多内容...
> 引用内容示例
\`\`\`javascript
console.log('Hello, Notes Agent!');
\`\`\`
`)
}
export function saveFileContent(filePath: string, content: string): Promise<void> {
console.debug(`[workspaceService] Save ${filePath}, ${content.length} chars`)
return Promise.resolve()
}
export function createFile(folderPath: string, name: string, content = ''): Promise<FileNode> {
const path = `${folderPath}/${name}`
const id = `n-${Date.now()}`
return Promise.resolve({ id, name, path, type: 'file' })
}
export function createFolder(parentPath: string, name: string): Promise<FileNode> {
const path = `${parentPath}/${name}`
const id = `f-${Date.now()}`
return Promise.resolve({ id, name, path, type: 'folder', is_open: true, children: [] })
}
export function renameFile(oldPath: string, newName: string): Promise<void> {
return Promise.resolve()
}
export function deleteFile(path: string): Promise<void> {
return Promise.resolve()
}
export function moveFile(sourcePath: string, targetPath: string): Promise<void> {
return Promise.resolve()
}