feat(workspace): 接入真实Vault数据链路

This commit is contained in:
2026-08-31 21:38:56 +08:00
parent 84077feb18
commit 8da75d4420
23 changed files with 1055 additions and 442 deletions
+4
View File
@@ -37,3 +37,7 @@ export async function deleteNote(noteId: string): Promise<OperationResponse> {
export async function moveNote(noteId: string, folder: string): Promise<ApiNote> {
return apiClient.post(`/api/notes/${noteId}/move`, { folder })
}
export async function renameNote(noteId: string, fileName: string): Promise<ApiNote> {
return apiClient.post(`/api/notes/${noteId}/rename`, { file_name: fileName })
}
@@ -0,0 +1,126 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiErrorClass } from './apiClient'
import * as workspaceService from './workspaceService'
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
const workspaceSnapshot = {
workspace: {
vault_id: 'default',
name: 'vault',
path: 'C:\\data\\vault',
file_count: 1,
indexed_note_count: 1,
requires_refresh: false,
},
items: [
{
entry_id: 'folder-course',
name: '课程',
path: '/课程',
type: 'folder',
note_id: null,
children: [
{
entry_id: 'note-os',
note_id: 'note-os',
name: '操作系统.md',
path: '/课程/操作系统.md',
type: 'file',
children: [],
},
],
},
],
}
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('workspaceService backend adapter', () => {
it('opens the configured Vault and reads/saves Markdown through Note API', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(async (input, init) => {
const url = String(input)
if (url === '/api/workspace/open') return jsonResponse(workspaceSnapshot)
if (url === '/api/notes/note-os' && init?.method === 'GET') {
return jsonResponse({
note_id: 'note-os', title: '操作系统', file_path: '课程/操作系统.md', tags: [],
created_at: '2026-08-31T00:00:00Z', updated_at: '2026-08-31T00:00:00Z',
markdown: '# 操作系统\n', blocks: [],
})
}
if (url === '/api/notes/note-os' && init?.method === 'PATCH') {
return jsonResponse({})
}
throw new Error(`Unexpected request: ${init?.method} ${url}`)
})
const vault = await workspaceService.openVault('C:\\data\\vault')
const tree = await workspaceService.getFileTree()
const markdown = await workspaceService.readFileContent('/课程/操作系统.md')
await workspaceService.saveFileContent('/课程/操作系统.md', '# 已更新\n')
expect(vault).toEqual({ path: 'C:\\data\\vault', name: 'vault' })
expect(tree[0].children?.[0]).toMatchObject({
id: 'note-os', note_id: 'note-os', path: '/课程/操作系统.md', type: 'file',
})
expect(markdown).toBe('# 操作系统\n')
const patchCall = fetchMock.mock.calls.find(([, init]) => init?.method === 'PATCH')
expect(JSON.parse(String(patchCall?.[1]?.body))).toEqual({ markdown: '# 已更新\n' })
})
it('creates notes and folders with Vault-relative paths', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(async (input, init) => {
const url = String(input)
if (url === '/api/workspace/open') return jsonResponse(workspaceSnapshot)
if (url === '/api/notes' && init?.method === 'POST') {
return jsonResponse({
note_id: 'note-new', title: '新笔记', file_path: '课程/新笔记.md', tags: [],
created_at: '2026-08-31T00:00:00Z', updated_at: '2026-08-31T00:00:00Z',
markdown: '# 新笔记\n', blocks: [],
})
}
if (url === '/api/workspace/folders' && init?.method === 'POST') {
return jsonResponse({
entry_id: 'folder-child', name: '子目录', path: '/课程/子目录', type: 'folder',
note_id: null, children: [],
})
}
throw new Error(`Unexpected request: ${init?.method} ${url}`)
})
await workspaceService.openVault('C:\\data\\vault')
const note = await workspaceService.createFile('/课程', '新笔记.md', '# 新笔记\n')
const folder = await workspaceService.createFolder('/课程', '子目录')
expect(note).toMatchObject({ id: 'note-new', path: '/课程/新笔记.md' })
expect(folder).toMatchObject({ id: 'folder-child', path: '/课程/子目录' })
const bodies = fetchMock.mock.calls
.filter(([, init]) => init?.method === 'POST')
.map(([, init]) => JSON.parse(String(init?.body)))
expect(bodies).toContainEqual({ title: '新笔记', folder: '课程', markdown: '# 新笔记\n' })
expect(bodies).toContainEqual({ parent: '课程', name: '子目录' })
})
it('reports backend connectivity errors instead of falling back to Mock data', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('offline'))
await expect(workspaceService.getWorkspaceInfo()).rejects.toEqual(
expect.objectContaining<Partial<ApiErrorClass>>({ code: 'NETWORK_ERROR' }),
)
})
})
+150 -237
View File
@@ -1,258 +1,171 @@
import type { FileNode } from '@/contracts'
// Web 开发模式使用内存实现,服务签名保持与未来桌面文件系统适配器一致。
// TODO(desktop): Tauri Host 就绪后通过 IPC 替换 Mock,并保留路径规范化与错误映射。
import type {
ApiNote,
ApiWorkspaceEntry,
ApiWorkspaceInfo,
ApiWorkspaceSnapshot,
FileNode,
OperationResponse,
} from '@/contracts'
import apiClient from './apiClient'
import * as noteService from './noteService'
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
export interface VaultInfo {
path: string
name: string
}
const MOCK_VAULTS: VaultInfo[] = [
{ path: '/Users/demo/Documents/MyVault', name: '我的知识库' },
{ path: '/Users/demo/Documents/StudyNotes', name: '学习笔记' },
]
let cachedTree: FileNode[] | null = null
const noteIdByPath = new Map<string, string>()
const typeByPath = new Map<string, FileNode['type']>()
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',
function normalizePublicPath(path: string): string {
const normalized = path.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
return normalized ? `/${normalized}` : '/'
}
function relativePath(path: string): string {
return normalizePublicPath(path).replace(/^\//, '')
}
function toFileNode(entry: ApiWorkspaceEntry): FileNode {
const path = normalizePublicPath(entry.path)
const node: FileNode = {
id: entry.entry_id,
note_id: entry.note_id ?? undefined,
name: entry.name,
path,
type: entry.type,
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: '欢迎使用 NotesAgent.md', path: '/欢迎使用 NotesAgent.md', type: 'file' },
]
const mockFileContents = new Map<string, string>()
function rememberContent(path: string, content: string): Promise<string> {
mockFileContents.set(path, content)
return Promise.resolve(content)
}
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 saved = mockFileContents.get(filePath)
if (saved !== undefined) return Promise.resolve(saved)
const name = filePath.split('/').pop() || 'Untitled'
if (name === '欢迎使用 NotesAgent.md') {
return rememberContent(filePath, `# 欢迎使用 NotesAgent
这是一款本地优先的 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
- [ ] 性能优化与测试
---
祝你写作愉快!
`)
children: entry.type === 'folder' ? entry.children.map(toFileNode) : undefined,
}
if (name === '红黑树.md') {
return rememberContent(filePath, `# 红黑树
typeByPath.set(path, entry.type)
if (entry.note_id) noteIdByPath.set(path, entry.note_id)
return node
}
红黑树(Red-Black Tree)是一种自平衡二叉搜索树,每个节点带有颜色属性(红色或黑色)。
function cacheEntries(entries: ApiWorkspaceEntry[]): FileNode[] {
noteIdByPath.clear()
typeByPath.clear()
cachedTree = entries.map(toFileNode)
return cachedTree
}
## 性质
1. 每个节点是红色或黑色
2. 根节点是黑色
3. 所有叶子节点(NIL)是黑色
4. 如果一个节点是红色,则它的两个子节点都是黑色
5. 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点
这些性质确保了红黑树的关键特性:**从根到叶子的最长可能路径不会超过最短可能路径的两倍长**。
## 插入操作
插入后可能破坏红黑性质,需要通过变色和旋转来修复。
### 情况1:叔叔节点是红色
将父节点和叔叔节点设为黑色,将祖父节点设为红色,当前节点上移到祖父节点,继续向上调整。
### 情况2:叔叔节点是黑色,且当前节点是右孩子
以父节点为支点左旋,将当前节点转换为左孩子,进入情况3。
### 情况3:叔叔节点是黑色,且当前节点是左孩子
以祖父节点为支点右旋,将父节点设为黑色,祖父节点设为红色。
## 与 AVL 树对比
| 特性 | AVL 树 | 红黑树 |
|------|--------|--------|
| 平衡严格度 | 高度差 ≤ 1 | 黑色高度相同 |
| 查找速度 | 更快 | 略慢 |
| 插入删除 | 旋转更多 | 旋转更少 |
| 适用场景 | 读多写少 | 读写均衡 |
## 应用场景
- C++ STL 的 map/set
- Java 的 TreeMap
- Linux 内核的完全公平调度器
`)
function nodeFromNote(note: ApiNote): FileNode {
const path = normalizePublicPath(note.file_path)
noteIdByPath.set(path, note.note_id)
typeByPath.set(path, 'file')
return {
id: note.note_id,
note_id: note.note_id,
name: path.split('/').at(-1) || note.title,
path,
type: 'file',
}
return rememberContent(filePath, `# ${name.replace('.md', '')}
这是一篇示例笔记。
## 第一部分
这里是笔记的内容。
## 第二部分
更多内容...
> 引用内容示例
\`\`\`javascript
console.log('Hello, NotesAgent!');
\`\`\`
`)
}
export function saveFileContent(filePath: string, content: string): Promise<void> {
console.debug(`[workspaceService] Save ${filePath}, ${content.length} chars`)
mockFileContents.set(filePath, content)
return Promise.resolve()
}
export function createFile(folderPath: string, name: string, content = ''): Promise<FileNode> {
const path = `${folderPath === '/' ? '' : folderPath}/${name}`
const id = `n-${Date.now()}`
mockFileContents.set(path, content)
return Promise.resolve({ id, name, path, type: 'file' })
}
export function createFolder(parentPath: string, name: string): Promise<FileNode> {
const path = `${parentPath === '/' ? '' : 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> {
const separator = oldPath.lastIndexOf('/')
const newPath = `${oldPath.slice(0, separator + 1)}${newName}`
for (const [path, content] of [...mockFileContents]) {
if (path === oldPath || path.startsWith(`${oldPath}/`)) {
mockFileContents.delete(path)
mockFileContents.set(`${newPath}${path.slice(oldPath.length)}`, content)
}
async function requireNoteId(filePath: string): Promise<string> {
const path = normalizePublicPath(filePath)
let noteId = noteIdByPath.get(path)
if (!noteId) {
await refreshTree()
noteId = noteIdByPath.get(path)
}
return Promise.resolve()
if (!noteId) throw new Error(`笔记尚未建立后端索引:${path}`)
return noteId
}
export function deleteFile(path: string): Promise<void> {
for (const filePath of [...mockFileContents.keys()]) {
if (filePath === path || filePath.startsWith(`${path}/`)) mockFileContents.delete(filePath)
export async function getWorkspaceInfo(): Promise<ApiWorkspaceInfo> {
return apiClient.get('/api/workspace')
}
export async function getRecentVaults(): Promise<VaultInfo[]> {
const workspace = await getWorkspaceInfo()
return [{ path: workspace.path, name: workspace.name }]
}
export async function openVault(path: string): Promise<VaultInfo> {
const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path })
cacheEntries(snapshot.items)
return { path: snapshot.workspace.path, name: snapshot.workspace.name }
}
export async function createVault(path: string, name: string): Promise<VaultInfo> {
// Web 模式不能创建任意本地目录;路径匹配时等价于初始化后端配置的 Vault。
void name
return openVault(path)
}
export async function refreshTree(): Promise<FileNode[]> {
const entries = await apiClient.get<ApiWorkspaceEntry[]>('/api/workspace/tree')
return cacheEntries(entries)
}
export async function getFileTree(): Promise<FileNode[]> {
return cachedTree ?? refreshTree()
}
export async function readFileContent(filePath: string): Promise<string> {
const note = await noteService.getNote(await requireNoteId(filePath))
return note.markdown
}
export async function saveFileContent(filePath: string, content: string): Promise<void> {
await noteService.updateNote(await requireNoteId(filePath), { markdown: content })
}
export async function createFile(
folderPath: string,
name: string,
content = '',
): Promise<FileNode> {
const title = name.replace(/\.md$/i, '')
const note = await noteService.createNote({
title,
folder: relativePath(folderPath),
markdown: content,
})
return nodeFromNote(note)
}
export async function createFolder(parentPath: string, name: string): Promise<FileNode> {
const entry = await apiClient.post<ApiWorkspaceEntry>('/api/workspace/folders', {
parent: relativePath(parentPath),
name,
})
return toFileNode(entry)
}
export async function renameFile(oldPath: string, newName: string): Promise<void> {
const path = normalizePublicPath(oldPath)
if (typeByPath.get(path) === 'folder') {
await apiClient.post('/api/workspace/folders/rename', {
path: relativePath(path),
new_name: newName,
})
} else {
await noteService.renameNote(await requireNoteId(path), newName)
}
return Promise.resolve()
await refreshTree()
}
export function moveFile(sourcePath: string, targetPath: string): Promise<void> {
// Mock 文件树由 Store 同步更新;真实实现必须在 Host 侧执行原子移动。
void sourcePath
void targetPath
return Promise.resolve()
export async function deleteFile(pathValue: string): Promise<void> {
const path = normalizePublicPath(pathValue)
if (typeByPath.get(path) === 'folder') {
await apiClient.post<OperationResponse>('/api/workspace/folders/delete', {
path: relativePath(path),
})
} else {
await noteService.deleteNote(await requireNoteId(path))
}
await refreshTree()
}
export async function moveFile(sourcePath: string, targetPath: string): Promise<void> {
const source = normalizePublicPath(sourcePath)
if (typeByPath.get(source) !== 'file') {
throw new Error('当前阶段只支持移动笔记文件。')
}
await noteService.moveNote(await requireNoteId(source), relativePath(targetPath))
await refreshTree()
}