架构与实现
这一页写给要读代码或改代码的人:分层、请求流、数据模型、分页与 SQLite 限制的处理方式。
整体形状:单后端进程 + 单数据库;前端只与后端 API/SSE 通信,模型与 MCP 都是出站依赖。
分层与依赖方向
Hetu.Api 控制器 / SSE 流 / 后台 Worker / DI 装配
│ (依赖 Core 与 Infrastructure 的接口)
Hetu.Core 实体 · 领域服务 · 仓储接口 ← 不引用 EF Core / ASP.NET 实现
▲
Hetu.Infrastructure EF Core 实现 · AI Provider · MCP · sqlite-vec · 文件与进程
Hetu.Shared DTO / 枚举 / 常量(前后端契约)
依赖方向是 Api → Core ← Infrastructure:Core 只定义规则与接口,具体实现(数据库、模型调用、进程、文件系统)都在 Infrastructure。
分层与一次典型请求的落点:控制器只做校验与转发,业务规则在服务层,SQL 在仓储层,长耗时操作交给后台任务。
一次请求的路径
- 前端(React + TanStack Query)调用
/api/*,统一走 axios 实例与响应拦截器。
- 控制器只做参数校验与调用服务;业务逻辑在
Hetu.Core/Services。
- 服务通过
IUnitOfWork 暴露的仓储访问数据库(EF Core),只读查询用 AsNoTracking 与投影。
- 需要长耗时的动作(索引、抽取、Wiki、看板执行)交给
IBackgroundTaskCoordinator,写入 TaskItem 记录后由后台 Worker 执行。
- 对话类请求走 SSE:控制器把事件流写回前端,前端按事件类型渲染。
数据模型要点
| 约定 | 说明 |
| 基类 | 实体继承 BaseEntity:Id(Guid)、CreatedAt、UpdatedAt |
| 时间 | 统一 DateTimeOffset 且为 UTC;SQLite 中存为定宽文本(yyyy-MM-dd HH:mm:ss.fffffff+00:00) |
| 软删除 | IsDeleted(+ DeletedAt)配合全局查询过滤器;需要包含已删数据时显式 IgnoreQueryFilters() |
| 迁移 | Code First;SQLite 与 PostgreSQL 各有一套迁移(后者在 Hetu.Infrastructure.PostgresMigrations) |
数据表一览(按域)
| 域 | 表 | 关键列 / 说明 |
| 笔记 | Notes · Notebooks · Tags · NoteTags · NoteVersions · ShareLinks | 笔记含 Title/Content/NotebookId/IsPinned/IsFavorite/IsDeleted/DeletedAt;版本表按 NoteId 关联并靠 CreatedAt 排序保留 20 条;分享表存 ShareCode/ExpiresAt/ViewCount |
| 知识底座 | KnowledgeItems · NoteChunks · NoteChunkEmbeddings · vec_chunk_embeddings | 知识项含 Type/Title/Content/SourceUrl/FileName/NoteId/IsDeleted;分块含 ChunkIndex/ChunkMethod/Content;向量元数据含 Model/Dimensions/UpdatedAt,向量本体在 vec0 虚拟表(按 rowid 对应) |
| 图谱 / Wiki | GraphEntities · GraphRelations · WikiDocuments · WikiGenerationJobs | 实体含 Name/Type/Description;关系含 SourceId/TargetId/RelationType;Wiki 文档含 SetId/ProjectId/SortOrder/Title/Content,任务含阶段与进度 |
| 记忆 | Memories · MemoryEmbeddings · vec_memory_embeddings | 记忆含 Content/Category/Importance/Scope/AccessCount/LastAccessedAt,Dream 依据后两个字段判定衰退与遗忘 |
| 工作台 | WorkProjects · WorkSessions · WorkMessages · WorkCodeChunks · WorkCheckpoints | 会话含 ProjectId/Title/Branch/WorktreePath/PermissionMode;消息含 SessionId/Type/Content(工具结果与正文分开存);代码块向量独立于笔记向量 |
| 对话 | ChatGroups · ChatTopics · ChatMessages | 话题含 GroupId/ModelId/SystemPrompt;消息按 CreatedAt 升序读取 |
| 任务与自动化 | TaskItems · KanbanTasks · KanbanTaskRuns · KanbanTaskRunSteps · Workflows · WorkflowRuns · ScheduledTasks | 后台任务含 TaskType/EntityId/Status/StartedAt/CompletedAt/ErrorMessage;看板运行与步骤分开记录,便于回看每次执行 |
| 模型与用量 | AiProviders · AiModels · LlmUsageLogs · AppSettings | 模型含 ProviderId/ModelId/Purpose/IsDefault;用量含 Source/RefId/ModelId/InputTokens/CompressedTokens/OutputTokens/CachedTokens/LatencyMs;应用设置为键值表 |
| 其它 | InboxNotifications · ManagedProjects · McpServers · Skills · PromptPresets | MCP 服务器含 Type(stdio/sse)/ConnectionConfig(JSON)/IsEnabled;收件箱含分类键与出现次数(相同事件合并计数) |
仓储接口
| 方法 | 用途 |
GetByIdAsync / GetAllAsync / FindAsync | 常规读取(只读场景内部用 AsNoTracking) |
CountAsync | SQL COUNT,不加载实体 |
SelectAsync | 只取部分列(投影),不物化完整实体 |
CountByAsync | SQL GROUP BY 计数,返回字典 |
GetPagedByDateAsync | 按时间字段分页:非 SQLite 走 ORDER BY + OFFSET/FETCH;SQLite 先投影 Id + 排序键 内存定序,再按主键回表取这一页 |
PruneAsync | 保留排序后前 N 条,其余用一条 DELETE 清理 |
关键文件落点
| 关注点 | 文件 |
| 应用装配 / 中间件 | src/Hetu.Api/Program.cs(DI、响应缓存、健康检查、Scalar) |
| SSE 流式入口 | src/Hetu.Api/Controllers/WorkStreamController.cs · ChatMessagesController.cs |
| Agent 循环 | src/Hetu.Core/Services/AgentLoopService.cs(含运行中引导、检查点、审批) |
| 仓储实现 | src/Hetu.Infrastructure/Repositories/(EfRepository 提供分页 / 聚合 / 清理原语) |
| 数据库上下文与迁移 | src/Hetu.Infrastructure/Data/ 与 src/Hetu.Infrastructure.PostgresMigrations/ |
| Git / 工作树 / PR | src/Hetu.Api/Services/WorkGitService.cs · src/Hetu.Core/Services/Work/WorkWorktreeService.cs |
| 压缩管道 | src/Hetu.Core/Services/CompressionPipelineService.cs |
| 桌面外壳 | shell/hetu-desktop/src/backend.rs(sidecar 生命周期)· src-tauri/nsis-hooks.nsh(安装钩子) |
| 前端路由与服务 | frontend/src/App.tsx · frontend/src/services/ · frontend/src/stores/ |
SQLite 的 DateTimeOffset 限制(实际踩过的坑)
- EF Core 的 SQLite provider 不支持在 SQL 中排序或聚合
DateTimeOffset:ORDER BY 抛 NotSupportedException,MAX() 无法翻译;比较表达式(x.CreatedAt > since)同样不能翻译。
- 处理方式:分页用
GetPagedByDateAsync;分组计数用 CountByAsync;时间戳聚合只投影两列后在内存取最大值;需要「最近 N 天」这类判断时先投影时间戳再在内存过滤。
- 不要在
IQueryable 上直接写 OrderBy(x => x.CreatedAt)——运行时才会炸。PostgreSQL 侧不受此限制。
性能实践
| 场景 | 做法 |
| 列表接口 | 分页与排序下推数据库;先 COUNT 取总数,再取当前页 |
| 状态统计 | 数据库侧聚合(GROUP BY / DISTINCT / EXISTS),不把整表读进内存 |
| 高频轮询接口 | 只读状态接口加 [ResponseCache] 短时缓存;前端只在「有进行中任务」时轮询 |
| 批量写入 / 清理 | 用 ExecuteDeleteAsync 等集合操作,避免逐条加载实体 |
| 前端渲染 | 派生状态代替 useEffect 里同步 setState;不稳定数组用 useMemo 固定引用 |
Agent 循环
- 请求带上权限模式、工具白名单、联网/知识库/记忆开关与推理强度。
- 循环开始时先取「运行中引导」(steering):如果在流式过程中用户注入了新指令,会在这一步并入上下文。
- 模型返回工具调用 → 按权限模式决定直接执行还是发审批请求 → 执行结果回填。
- 过程事件通过 SSE 推送:正文增量、思考、工具调用与结果、检查点、通知。
- 队列中的下一条消息在本轮结束后自动发送;真实落库的会话由首条消息创建。
质量约定
- 后端
dotnet build 与前端 npx eslint . 都要求零告警,且不接受任何抑制注释(#pragma warning disable / SuppressMessage / eslint-disable / @ts-ignore)。
- 依赖漏洞告警按升级包处理(例如把带漏洞的传递依赖固定到修复版本),而不是忽略。
- 文档站与 README 中英双语必须同步;截图放在
docs/screenshots/ 并保持命名稳定。