feat: 拆分输入解析功能;拓展输入命令;更新文档规划
This commit is contained in:
parent
8c7a9098ef
commit
8fdf29b4e9
@ -15,10 +15,12 @@ pnpm dev
|
||||
其中设置 `DEEPSEEK_API_KEY`;
|
||||
也可用 `LLM_TO_AGENT_HOME` 修改数据根目录,用 `DEEPSEEK_MODEL` 修改模型。
|
||||
|
||||
CLI 支持 `/new` 新建对话、`/switch <id>` 切换对话、`/history` 查看当前对话、`/exit` 退出;输入 `//` 可以发送以 `/` 开头的普通消息。
|
||||
|
||||
文档:
|
||||
|
||||
- [架构总览](docs/architecture.md)
|
||||
- [源码目录](docs/source-layout.md)
|
||||
- [数据目录](docs/data-layout.md)
|
||||
- [Extension 目录](docs/extensions.md)
|
||||
- [Extension 规划](docs/extensions.md)
|
||||
- [路线图](docs/roadmap.md)
|
||||
|
||||
@ -2,53 +2,81 @@
|
||||
|
||||
## 目标
|
||||
|
||||
llm-to-agent 是个人项目,架构优先级是:
|
||||
llm-to-agent 是个人使用、本地优先的 Agent。架构优先级是:
|
||||
|
||||
```text
|
||||
简单易懂 > 容易修改 > 容易扩展 > 通用性
|
||||
简单易懂 > 容易扩展和修改 > 架构完整 > 通用性
|
||||
```
|
||||
|
||||
Runtime 内部长期只有:
|
||||
Runtime 长期保持:
|
||||
|
||||
```text
|
||||
Kernel + Extensions
|
||||
```
|
||||
|
||||
Kernel 管“Extension 怎样连接和运行”,Extension 管“系统具体能做什么”。
|
||||
Kernel 管 Extension 怎样连接和运行,Extension 管系统具体能做什么。
|
||||
|
||||
## Kernel
|
||||
|
||||
第一版 Kernel 只保留几件直观的事:
|
||||
Kernel 只提供所有 Extension 共用的运行机制:
|
||||
|
||||
- 按顺序装入 Extension;
|
||||
- 依次执行 `setup` 和 `start`,停止时倒序执行 `stop`;
|
||||
- 通过命名抓手提供和取得能力;
|
||||
- 通过命名事件发布和监听事实。
|
||||
|
||||
抓手和事件暂时使用带命名空间的字符串,不为它们建立复杂的 TypeScript 类型系统。Setup Context 负责注册,Runtime Context 负责使用;Kernel 信任 Extension 遵守生命周期,不增加 Context 失效检查和不可变包装。
|
||||
|
||||
Kernel 不认识 Workspace、Conversation、Run、Agent、模型或 Tool。只有所有 Extension 都必须遵守的运行规则才进入 Kernel。
|
||||
Kernel 不认识 Workspace、Agent、模型、Tool 或其他业务概念。
|
||||
|
||||
## Extensions
|
||||
|
||||
Extension 可以提供能力、调用其他能力、添加同类实现、发布或监听事实,并管理自己的数据与资源。
|
||||
Extension 是 Runtime 的独立装配单位,分为三类:
|
||||
|
||||
控制流程必须使用明确调用;事件只表示已经发生的事实,不能承担顺序、返回值、审批或回滚。
|
||||
- 共享能力:拥有明确状态、资源或业务流程;
|
||||
- Provider:接入可替换的模型、协议或外部系统;
|
||||
- 产品入口:处理 CLI、Web、Desktop 的输入、输出和平台资源。
|
||||
|
||||
Extension 之间只依赖公开契约,不引用彼此的实现。
|
||||
规划可以列出尚未实现的 Extension,源码、Catalog 和产品装配只包含已经开始实现的部分。Extension 内部文件和目录不设统一结构。
|
||||
|
||||
具体能力归属见 [Extension 目录](extensions.md)。
|
||||
最终能力归属见 [Extension 规划](extensions.md)。
|
||||
|
||||
## 扩展点
|
||||
|
||||
多个实现向能力所有者登记,不再为扩展点增加新的架构层:
|
||||
|
||||
```text
|
||||
models.providers
|
||||
tools.providers
|
||||
agent.contextContributors
|
||||
automation.triggers
|
||||
security.approvalChannels
|
||||
health.checks
|
||||
```
|
||||
|
||||
模型 Provider 由 `models` 选择,具体 Tool 由 `tools` 统一执行,上下文来源由 `agent` 组合。
|
||||
|
||||
## 协作
|
||||
|
||||
- 控制流程使用明确调用;
|
||||
- 事件只表示已经发生的事实;
|
||||
- Extension 只依赖其他 Extension 的公开契约;
|
||||
- Provider 向扩展点注册实现,能力所有者不引用 Provider 实现;
|
||||
- Tool 是能力 Extension 暴露给 Agent 的操作,不是新的 Extension;
|
||||
- 高风险操作统一经过 `security`,凭据统一通过 `secrets` 的受控引用取得。
|
||||
|
||||
事件不能承担顺序、返回值、审批、事务或回滚。
|
||||
|
||||
## Runtime 与产品
|
||||
|
||||
Runtime 持有唯一一套 Kernel、Extension 实例和本地数据。CLI、Web、Desktop 是不同产品入口,使用同一套能力和状态。
|
||||
Runtime 持有一套 Kernel、Extension 实例和本地数据。CLI、Web、Desktop 是不同产品入口,共用共享能力和数据。
|
||||
|
||||
自进化阶段再增加 Runtime 外的 Supervisor,用于版本切换、健康检查和失败回退。
|
||||
版本切换、进程守护、离线恢复和失败回退由 Runtime 外的 Supervisor 负责。
|
||||
|
||||
## 长期约束
|
||||
|
||||
- 默认在现有 Extension 内增加普通代码,出现真实独立边界后再拆。
|
||||
- 第一版静态装配,不建设插件平台、热加载或依赖图。
|
||||
- 保持单进程、单用户和本地优先,直到真实需求要求改变。
|
||||
- 保持单用户、本地优先和单 Runtime;
|
||||
- 保持一个 TypeScript package,直到出现独立构建、发布或进程边界;
|
||||
- 使用静态装配,不提前建设插件平台、热加载或依赖图;
|
||||
- 规划高风险边界,但不创建占位目录、文件、ID 或 Hook;
|
||||
- 普通函数、页面、命令、Parser、Prompt 和单个 Tool 不拆成 Extension;
|
||||
- 新增 Kernel 机制必须有多个具体使用者;
|
||||
- 不提前建设 Command Bus、Middleware、事件溯源或分布式状态。
|
||||
- 新增 Kernel 机制必须有多个具体使用者。
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
~/.llm-to-agent/
|
||||
```
|
||||
|
||||
开发环境可以指向另一个用户目录,测试使用临时目录。
|
||||
开发环境可以指定其他数据根,测试使用临时目录。
|
||||
|
||||
## 物理目录
|
||||
|
||||
@ -17,40 +17,51 @@
|
||||
├── config/
|
||||
├── workspaces/
|
||||
│ └── <workspace-id>/
|
||||
│ ├── workspace.json
|
||||
│ ├── files/
|
||||
│ ├── conversations/
|
||||
│ ├── runs/
|
||||
│ ├── artifacts/
|
||||
│ └── extensions/
|
||||
│ └── <extension-id>/
|
||||
├── extensions/
|
||||
│ └── <extension-id>/
|
||||
├── releases/
|
||||
├── runtime/
|
||||
└── trash/
|
||||
```
|
||||
|
||||
目录按需要创建,不规定尚未实现的数据文件和格式。
|
||||
目录按需要创建,不预建尚未使用的层级。
|
||||
|
||||
当前对话链路只会创建 Workspace 绑定信息和
|
||||
`conversations/default/messages.jsonl`,后续能力再增加自己的数据。
|
||||
## 数据归属
|
||||
|
||||
- `workspace` 管理 Workspace、Conversation、Message 和 Artifact;
|
||||
- `agent` 管理 Agent Run;
|
||||
- `tasks` 管理 Goal、Plan 和任务执行状态;
|
||||
- `memory` 与 `knowledge` 分别管理自己的长期数据和索引;
|
||||
- `automation` 管理 Workflow、Schedule 和执行历史;
|
||||
- `security` 管理审批和安全记录;
|
||||
- 每个 Extension 只能通过公开契约访问其他 Extension 的数据;
|
||||
- 每个数据所有者负责自己的迁移、校验、导出和清理。
|
||||
|
||||
Workspace 范围的私有数据写入 `workspaces/<workspace-id>/extensions/<extension-id>/`,全局私有数据写入 `extensions/<extension-id>/`。
|
||||
|
||||
凭据明文不写入数据根,由 `secrets` 使用系统 Keychain 或受控加密存储管理。
|
||||
|
||||
## Workspace
|
||||
|
||||
- `managed` Workspace 的工作文件位于自己的 `files/`。
|
||||
- `linked` Workspace 只记录用户已有目录的绑定关系。
|
||||
- 两种 Workspace 的 Conversation、Run、记忆和 Extension 数据都位于数据根目录。
|
||||
- `managed` Workspace 的工作文件位于自己的 `files/`;
|
||||
- `linked` Workspace 只记录用户已有目录的绑定关系;
|
||||
- 产品入口各自管理当前选择的 Workspace 和 Conversation;
|
||||
- 删除 linked Workspace 绝不能删除外部项目源码。
|
||||
|
||||
## 项目目录零落地
|
||||
|
||||
绑定外部项目时,项目源码保留在原位置,所有 llm-to-agent 专属数据仍保存在:
|
||||
|
||||
```text
|
||||
<LLM_TO_AGENT_HOME>/workspaces/<workspace-id>/
|
||||
```
|
||||
|
||||
项目中不创建 `.llm-to-agent/` 或其他专属元数据。项目移动时更新绑定关系;删除 linked Workspace 绝不能删除外部项目源码。
|
||||
绑定外部项目时,项目源码保留在原位置,所有 llm-to-agent 专属数据仍保存在数据根中。项目中不创建 `.llm-to-agent/` 或其他专属元数据。
|
||||
|
||||
## 数据规则
|
||||
|
||||
- Kernel 只提供安全路径、隔离和基础写入机制,不理解业务数据。
|
||||
- 每个 Extension 管理自己的数据,不能直接修改其他 Extension 的私有内容。
|
||||
- 缓存和临时数据必须可以清理重建。
|
||||
- 删除重要数据默认先进入 `trash/`,迁移和修复前先保留可恢复副本。
|
||||
- Runtime 公共库只提供安全路径、原子写入和基础存储工具,不理解业务数据;
|
||||
- 缓存、索引和临时数据必须可以清理重建;
|
||||
- 删除重要数据默认先进入 `trash/`;
|
||||
- 迁移、修复和恢复前保留可恢复副本;
|
||||
- 全局备份、离线恢复和版本回退由 Supervisor 协调。
|
||||
|
||||
@ -1,66 +1,78 @@
|
||||
# Extension 目录
|
||||
# Extension 规划
|
||||
|
||||
具体能力全部由 Extension 实现。Tool、页面、命令和事件监听器只是 Extension 内部组成,不是新的架构类型。
|
||||
## 共享能力
|
||||
|
||||
## 第一版
|
||||
| Extension | 职责 | 公开能力与 Tool |
|
||||
|---|---|---|
|
||||
| `workspace` | Workspace、Conversation、Message、Artifact | `WorkspaceService`;默认不提供 Tool |
|
||||
| `models` | 模型注册、选择、流式调用、Embedding 和 Fallback | `ModelService`;接收 `models.providers` |
|
||||
| `tools` | Tool 注册、Schema 校验、执行、取消和安全拦截 | `ToolService`;可提供 `tools.search`、`tools.describe` |
|
||||
| `agent` | 一次 Agent Run、上下文构建、模型与 Tool 循环 | `AgentService`;接收 `agent.contextContributors` |
|
||||
| `tasks` | Goal、Plan、子 Agent、任务依赖、等待和恢复 | `TaskService`;`tasks.update_plan`、`tasks.delegate`、`tasks.wait` |
|
||||
| `memory` | 用户偏好、事实、经验和长期记忆 | `MemoryService`;`memory.recall`、`memory.propose`、`memory.forget` |
|
||||
| `knowledge` | 知识来源、索引、检索和引用 | `KnowledgeService`;`knowledge.search`、`knowledge.open_source` |
|
||||
| `project` | 项目路径、文件、搜索、Patch、项目识别和验证 | `ProjectService`;`project.read`、`project.search`、`project.apply_patch`、`project.run_checks` |
|
||||
| `shell` | 命令、PTY、输出流、超时和取消 | `ShellService`;`shell.run`、`shell.read`、`shell.cancel` |
|
||||
| `git` | Repository、Diff、分支、Commit 和 Worktree | `GitService`;`git.status`、`git.diff`、`git.log`、`git.create_worktree` |
|
||||
| `automation` | Workflow、Scheduler、提醒、重试和执行历史 | `AutomationService`;`automation.create`、`automation.schedule`、`automation.pause`、`automation.run_now` |
|
||||
| `security` | 风险规则、授权、审批和安全记录 | `SecurityService`;接收 `security.approvalChannels`,不提供 Tool |
|
||||
| `secrets` | Keychain、凭据引用、授权范围和轮换 | `SecretsService`;不向模型提供明文凭据 |
|
||||
| `browser` | 浏览器 Session 和页面操作 | `BrowserService`;`browser.open`、`browser.read`、`browser.click`、`browser.type` |
|
||||
| `computer` | 屏幕、窗口、应用、键鼠和剪贴板 | `ComputerService`;`computer.screenshot`、`computer.open_app`、`computer.click`、`computer.type` |
|
||||
| `evolution` | 自身修改候选、验证和发布提案 | `EvolutionService`;`evolution.prepare`、`evolution.validate` |
|
||||
|
||||
`browser`、`computer` 和 `evolution` 在实现对应能力时创建。
|
||||
|
||||
## Provider
|
||||
|
||||
- 模型 Provider:`deepseek` 以及实际接入的其他模型;
|
||||
- Tool Provider:GitHub、MCP 和其他实际接入的外部系统;
|
||||
- Provider 管理自己的协议、配置、资源和故障,向所属扩展点注册实现;
|
||||
- 一个 Provider 可以提供多个 Tool,一个 Tool 不对应一个 Extension。
|
||||
|
||||
## 产品入口
|
||||
|
||||
- `cli`:终端输入、命令、展示和审批通道;
|
||||
- `web`:HTTP、WebSocket、页面和 Web 会话;
|
||||
- `desktop`:窗口、快捷键、通知和原生平台集成。
|
||||
|
||||
产品入口不拥有共享业务能力。
|
||||
|
||||
## 协作关系
|
||||
|
||||
```text
|
||||
src/extensions/
|
||||
├── catalog.ts
|
||||
├── shared/
|
||||
│ ├── workspace/
|
||||
│ ├── agent/
|
||||
│ └── deepseek/
|
||||
└── cli/
|
||||
模型 Provider ────────────────> models
|
||||
|
||||
workspace / memory / knowledge ─> agent.contextContributors
|
||||
|
||||
project / shell / git
|
||||
browser / computer / 外部 Provider ─> tools.providers
|
||||
|
||||
tasks ────────────────> agent
|
||||
automation ───────────> tasks / agent / tools
|
||||
agent ────────────────> workspace / models / tools / memory / knowledge
|
||||
tools ────────────────> security
|
||||
外部 Provider ────────> secrets
|
||||
evolution ────────────> project / shell / git
|
||||
|
||||
cli / web / desktop ──> workspace / agent / tasks / automation / security
|
||||
```
|
||||
|
||||
- `workspace`:Workspace、Conversation 和消息。
|
||||
- `agent`:Run、上下文和 Agent 执行。
|
||||
- `deepseek`:DeepSeek 模型接入。
|
||||
- `cli`:终端输入、命令和展示。
|
||||
## 不单独拆分
|
||||
|
||||
第一版只静态装配这四个 Extension。
|
||||
- Conversation、Message、Artifact 归 `workspace`;
|
||||
- Context、Agent Profile 和声明式 Skill 归 `agent`;
|
||||
- Workflow、Scheduler 和 Notification 请求归 `automation`;
|
||||
- Policy、Approval 和 Audit 归 `security`;
|
||||
- 项目文件归 `project`,进程归 `shell`,仓库归 `git`;
|
||||
- Run 由实际执行者拥有,不创建通用 Run Extension;
|
||||
- 页面、命令、Parser、Prompt、模板和单个 Tool 不是 Extension;
|
||||
- 数据迁移、校验和清理由数据所有者实现。
|
||||
|
||||
`catalog.ts` 集中列出所有跨 Extension 使用的 Extension ID、能力抓手和事实事件;它只保存字符串,不保存类型或运行逻辑。
|
||||
## 创建规则
|
||||
|
||||
当前协作链路:
|
||||
|
||||
```text
|
||||
workspace -> 提供 workspace
|
||||
deepseek -> 添加 model.providers
|
||||
agent -> 使用二者并提供 agent
|
||||
cli -> 调用 agent
|
||||
```
|
||||
|
||||
## 协作
|
||||
|
||||
- 一个明确能力由 Extension 提供,调用方取得后直接调用。
|
||||
- 模型、Tool 等多个实现通过同一扩展点汇集,由业务拥有者选择。
|
||||
- 状态真正发生后再发布事件,监听器只做展示、日志或派生处理。
|
||||
- Extension 只引用其他 Extension 的公开契约。
|
||||
|
||||
## 后续方向
|
||||
|
||||
后续公共能力大致包括:
|
||||
|
||||
```text
|
||||
项目操作与 Git
|
||||
自身进化
|
||||
计划与多 Agent
|
||||
记忆与知识
|
||||
浏览器与本机控制
|
||||
自动化
|
||||
```
|
||||
|
||||
CLI、Web、Desktop 各自先保持一个产品 Extension。远程仓库、外部 Tool 协议、Keychain 等在实际接入时再决定是否独立。
|
||||
|
||||
## 何时拆分
|
||||
|
||||
只有出现以下真实边界时才创建新 Extension:
|
||||
|
||||
- 需要独立启停;
|
||||
- 拥有独立的长期资源或数据;
|
||||
- 存在可替换实现;
|
||||
- 有明确产品或平台边界。
|
||||
|
||||
否则继续留在现有 Extension 内。目录只在能力开始实现时创建,不预建占位代码。
|
||||
- 规划中的 Extension 只有开始实现时才创建源码;
|
||||
- `catalog.ts` 只登记已实现并发生跨 Extension 协作的 ID、Hook 和 Event;
|
||||
- 不创建占位目录、文件、Factory、ID 或 Hook;
|
||||
- Extension 内部文件划分不作统一限制;
|
||||
- 当前保持单包,只有独立构建、发布或进程隔离时才拆包。
|
||||
|
||||
@ -1,46 +1,45 @@
|
||||
# 路线图
|
||||
|
||||
路线图只描述大的实现顺序。安全、日志、取消和数据保护从每个阶段开始就随能力一起实现。
|
||||
## P1:持续对话
|
||||
|
||||
## P1:能持续聊天
|
||||
|
||||
建立最小 Kernel、第一版 Extension 和集中式数据目录。
|
||||
建立 Kernel、`workspace`、`models`、`secrets`、`agent`、模型 Provider 和 `cli`。
|
||||
|
||||
完成标志:CLI 可以管理 Workspace 与 Conversation,流式聊天,并在重启后继续历史。
|
||||
|
||||
## P2:能操作项目
|
||||
## P2:项目闭环
|
||||
|
||||
加入文件、Shell、Tool Calling、Git、Worktree、验证和最小权限确认。
|
||||
实现 `tools`、`security`、`project`、`shell` 和 `git`。
|
||||
|
||||
完成标志:Agent 可以在普通代码项目中完成一次可审核的修改与测试闭环。
|
||||
完成标志:Agent 可以在受控范围内读取和修改项目、运行验证、检查 Git 变更,并处理审批。
|
||||
|
||||
## P3:能修改自己
|
||||
## P3:自身演进
|
||||
|
||||
加入隔离候选、构建验证、发布确认、版本切换和失败回退。
|
||||
实现 `evolution` 和 Runtime 外的 Supervisor。
|
||||
|
||||
完成标志:Agent 可以安全完成一次自身小能力的修改与发布。
|
||||
完成标志:Agent 可以生成隔离候选、完成验证和发布提案,Supervisor 可以安全切换版本并失败回退。
|
||||
|
||||
## P4:能处理复杂工作
|
||||
## P4:复杂任务
|
||||
|
||||
加入多模型、计划、多 Agent、工作流、长期记忆和知识检索。
|
||||
实现 `tasks`、`memory`、`knowledge` 和更多需要的模型 Provider。
|
||||
|
||||
完成标志:复杂目标可以持续执行,跨 Conversation 找到相关上下文,并给出可验证结果。
|
||||
完成标志:复杂目标可以持续执行、使用子 Agent、恢复任务并检索长期上下文。
|
||||
|
||||
## P5:能管理电脑
|
||||
## P5:电脑与自动化
|
||||
|
||||
加入浏览器、本机控制、多模态、提醒、定时任务和自动化。
|
||||
实现 `browser`、`computer` 和 `automation`,完善 `secrets` 的 Keychain、轮换和恢复能力。
|
||||
|
||||
完成标志:Agent 可以完成开发之外的高频电脑事务,并可靠重复执行。
|
||||
完成标志:Agent 可以操作浏览器和本机应用,管理提醒与自动化,并安全使用外部凭据。
|
||||
|
||||
## P6:适合长期使用
|
||||
## P6:长期使用
|
||||
|
||||
实现 Web 和 Desktop,完善远程访问、诊断、备份迁移、资源清理、凭据保护和版本维护。
|
||||
实现 `web` 和 `desktop`,完善远程访问、诊断、备份迁移、资源清理和版本维护。
|
||||
|
||||
完成标志:三个产品共用一套稳定 Runtime 和数据,能够长期日常使用。
|
||||
完成标志:CLI、Web、Desktop 共用一套稳定 Runtime 和数据,可以长期日常使用。
|
||||
|
||||
## 执行原则
|
||||
|
||||
- 每个阶段开始时再拆提交级任务。
|
||||
- 先跑通最短链路,再根据真实压力抽象。
|
||||
- 新增普通能力默认修改 Extension;只有通用运行机制才修改 Kernel。
|
||||
- 路线图允许随实践调整,不维护小功能状态表。
|
||||
- 每个阶段开始时再拆提交级任务;
|
||||
- 只创建当前阶段实际使用的 Extension;
|
||||
- 优先跑通最短闭环,再补充实现细节;
|
||||
- 安全、取消、日志和数据保护随能力一起实现;
|
||||
- 路线图只记录阶段和完成标志,不维护小功能状态表。
|
||||
|
||||
@ -7,6 +7,7 @@ llm-to-agent/
|
||||
├── src/
|
||||
│ ├── kernel/
|
||||
│ ├── extensions/
|
||||
│ │ ├── catalog.ts
|
||||
│ │ ├── shared/
|
||||
│ │ ├── cli/
|
||||
│ │ ├── web/
|
||||
@ -22,18 +23,22 @@ llm-to-agent/
|
||||
|
||||
## 目录职责
|
||||
|
||||
- `kernel/`:只保存 Extension 运行机制。
|
||||
- `extensions/shared/`:跨产品使用的具体能力。
|
||||
- `extensions/cli|web|desktop/`:三条产品线各自的输入、展示和平台集成。
|
||||
- `products/`:静态选择每个产品启用哪些 Extension,不写业务逻辑。
|
||||
- `tests/`:只保存跨 Extension、跨进程或跨版本测试;普通测试跟随源码。
|
||||
- `kernel/`:Extension 生命周期、能力注册和事实事件;
|
||||
- `extensions/catalog.ts`:已经实现的跨 Extension ID、Hook 和 Event;
|
||||
- `extensions/shared/`:跨产品使用的能力和 Provider;
|
||||
- `extensions/cli|web|desktop/`:产品输入、展示和平台集成;
|
||||
- `products/`:静态选择产品启用的 Extension,不写业务逻辑;
|
||||
- `tests/`:跨 Extension、跨进程或跨版本测试;
|
||||
- `tooling/`:构建、开发和发布辅助。
|
||||
|
||||
Extension 的具体划分见 [Extension 目录](extensions.md)。
|
||||
Extension 的最终职责见 [Extension 规划](extensions.md)。
|
||||
|
||||
## 放置规则
|
||||
|
||||
- 业务概念和流程进入 Extension,不为了复用方便放进 Kernel。
|
||||
- 产品私有实现不能互相依赖,真实复用出现后再提升到 `shared/`。
|
||||
- 目录和文件只在真实代码出现时创建,不预建占位层级。
|
||||
- 当前保持单包;只有构建、平台依赖、独立分发或进程隔离造成实际问题时才拆包。
|
||||
- 源码目录只在对应能力开始实现时创建;
|
||||
- Extension 内部文件和目录不设统一模板;
|
||||
- 业务概念和流程进入 Extension,不进入 Kernel;
|
||||
- 产品私有实现不能互相依赖;
|
||||
- Provider 只通过公开契约和扩展点接入能力所有者;
|
||||
- 普通复用代码不因此升级为 Extension;
|
||||
- 当前保持单包,只有独立构建、发布或进程隔离时才拆包。
|
||||
|
||||
@ -101,3 +101,46 @@ test("streams a reply, saves it, and restores the conversation after restart", a
|
||||
|
||||
await secondKernel.stop();
|
||||
});
|
||||
|
||||
test("creates a conversation and restores it after restart", async (t) => {
|
||||
const temporaryDirectory = await mkdtemp(join(tmpdir(), "llm-to-agent-"));
|
||||
const home = join(temporaryDirectory, "home");
|
||||
const projectPath = join(temporaryDirectory, "project");
|
||||
await mkdir(projectPath);
|
||||
t.after(() => rm(temporaryDirectory, { recursive: true, force: true }));
|
||||
|
||||
const firstKernel = new Kernel().use(
|
||||
createWorkspaceExtension({ home, projectPath }),
|
||||
);
|
||||
await firstKernel.start();
|
||||
|
||||
const firstWorkspace = firstKernel.get<WorkspaceService>(Hook.Workspace);
|
||||
await firstWorkspace.append("user", "旧对话消息");
|
||||
const conversationId = await firstWorkspace.newConversation();
|
||||
assert.deepEqual(await firstWorkspace.messages(), []);
|
||||
await firstWorkspace.append("user", "新对话消息");
|
||||
assert.equal(await firstWorkspace.switchConversation("default"), true);
|
||||
assert.deepEqual(
|
||||
(await firstWorkspace.messages()).map(({ role, content }) => ({ role, content })),
|
||||
[{ role: "user", content: "旧对话消息" }],
|
||||
);
|
||||
assert.equal(await firstWorkspace.switchConversation("missing"), false);
|
||||
assert.equal(firstWorkspace.conversationId, "default");
|
||||
assert.equal(await firstWorkspace.switchConversation(conversationId), true);
|
||||
await firstKernel.stop();
|
||||
|
||||
const secondKernel = new Kernel().use(
|
||||
createWorkspaceExtension({ home, projectPath }),
|
||||
);
|
||||
await secondKernel.start();
|
||||
|
||||
const secondWorkspace = secondKernel.get<WorkspaceService>(Hook.Workspace);
|
||||
assert.equal(secondWorkspace.conversationId, conversationId);
|
||||
assert.deepEqual(
|
||||
(await secondWorkspace.messages()).map(({ role, content }) => ({ role, content })),
|
||||
[{ role: "user", content: "新对话消息" }],
|
||||
);
|
||||
assert.deepEqual(await readdir(projectPath), []);
|
||||
|
||||
await secondKernel.stop();
|
||||
});
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
# CLI extensions
|
||||
|
||||
这里保存 REPL、终端渲染、斜杠命令和 TUI 等 CLI 私有能力。CLI 扩展把终端输入提交给 Kernel,并把 Run 事件转换成终端输出。
|
||||
这里保存终端输入解析、斜杠命令、展示和 TUI 等 CLI 私有能力。CLI Extension 通过公开抓手调用共享能力,并把运行结果转换成终端输出。
|
||||
|
||||
@ -6,6 +6,8 @@ import {
|
||||
import type { Extension } from "../../kernel";
|
||||
import { Event, ExtensionId, Hook } from "../catalog";
|
||||
import type { AgentService } from "../shared/agent";
|
||||
import type { WorkspaceService } from "../shared/workspace";
|
||||
import { parseInput } from "./input";
|
||||
|
||||
export function createCliExtension(): Extension {
|
||||
let terminal: ReadlineInterface | undefined;
|
||||
@ -19,6 +21,12 @@ export function createCliExtension(): Extension {
|
||||
|
||||
start(context) {
|
||||
const agent = context.get<AgentService>(Hook.Agent);
|
||||
const workspace = context.get<WorkspaceService>(Hook.Workspace);
|
||||
const roleNames = {
|
||||
user: "用户",
|
||||
assistant: "助手",
|
||||
system: "系统",
|
||||
};
|
||||
terminal = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
@ -35,24 +43,59 @@ export function createCliExtension(): Extension {
|
||||
loop = (async () => {
|
||||
try {
|
||||
process.stdout.write(
|
||||
`llm-to-agent\nWorkspace: ${process.cwd()}\n输入 /exit 退出,Ctrl+C 取消当前回复。\n\n`,
|
||||
`llm-to-agent\nWorkspace: ${process.cwd()}\n当前对话: ${workspace.conversationId}\n命令: /new、/switch <id>、/history、/exit,Ctrl+C 取消当前回复。\n\n`,
|
||||
);
|
||||
terminal?.setPrompt("> ");
|
||||
terminal?.prompt();
|
||||
|
||||
for await (const line of terminal!) {
|
||||
const input = line.trim();
|
||||
const input = parseInput(line);
|
||||
|
||||
if (input === "/exit") break;
|
||||
if (!input) {
|
||||
terminal?.prompt();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input.type === "command") {
|
||||
if (input.name === "exit") break;
|
||||
|
||||
if (input.name === "new") {
|
||||
const conversationId = await workspace.newConversation();
|
||||
process.stdout.write(`已新建对话: ${conversationId}\n\n`);
|
||||
} else if (input.name === "switch") {
|
||||
if (!input.argument) {
|
||||
process.stdout.write("用法: /switch <conversation-id>\n\n");
|
||||
} else if (await workspace.switchConversation(input.argument)) {
|
||||
process.stdout.write(`已切换到对话: ${workspace.conversationId}\n\n`);
|
||||
} else {
|
||||
process.stdout.write(`对话不存在: ${input.argument}\n\n`);
|
||||
}
|
||||
} else if (input.name === "history") {
|
||||
const messages = await workspace.messages();
|
||||
|
||||
if (messages.length === 0) {
|
||||
process.stdout.write("当前对话暂无消息。\n\n");
|
||||
} else {
|
||||
process.stdout.write(`对话 ${workspace.conversationId}\n`);
|
||||
for (const message of messages) {
|
||||
process.stdout.write(
|
||||
`${roleNames[message.role]}: ${message.content}\n`,
|
||||
);
|
||||
}
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(`未知命令: /${input.name}\n\n`);
|
||||
}
|
||||
|
||||
terminal?.prompt();
|
||||
continue;
|
||||
}
|
||||
|
||||
currentRequest = new AbortController();
|
||||
|
||||
try {
|
||||
for await (const chunk of agent.chat(input, currentRequest.signal)) {
|
||||
for await (const chunk of agent.chat(input.content, currentRequest.signal)) {
|
||||
process.stdout.write(chunk);
|
||||
}
|
||||
process.stdout.write("\n\n");
|
||||
|
||||
26
src/extensions/cli/input.test.ts
Normal file
26
src/extensions/cli/input.test.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { parseInput } from "./input";
|
||||
|
||||
test("parses CLI messages and commands", () => {
|
||||
assert.equal(parseInput(" "), undefined);
|
||||
assert.deepEqual(parseInput(" 你好 "), {
|
||||
type: "message",
|
||||
content: "你好",
|
||||
});
|
||||
assert.deepEqual(parseInput("/new\tdemo"), {
|
||||
type: "command",
|
||||
name: "new",
|
||||
argument: "demo",
|
||||
});
|
||||
assert.deepEqual(parseInput("/switch default"), {
|
||||
type: "command",
|
||||
name: "switch",
|
||||
argument: "default",
|
||||
});
|
||||
assert.deepEqual(parseInput("//path"), {
|
||||
type: "message",
|
||||
content: "/path",
|
||||
});
|
||||
});
|
||||
19
src/extensions/cli/input.ts
Normal file
19
src/extensions/cli/input.ts
Normal file
@ -0,0 +1,19 @@
|
||||
type ParsedInput =
|
||||
| { type: "message"; content: string }
|
||||
| { type: "command"; name: string; argument: string };
|
||||
|
||||
export function parseInput(value: string): ParsedInput | undefined {
|
||||
const input = value.trim();
|
||||
|
||||
if (!input) return;
|
||||
if (!input.startsWith("/")) return { type: "message", content: input };
|
||||
if (input.startsWith("//")) {
|
||||
return { type: "message", content: input.slice(1) };
|
||||
}
|
||||
|
||||
const separator = input.search(/\s/);
|
||||
const name = input.slice(1, separator < 0 ? undefined : separator).toLowerCase();
|
||||
const argument = separator < 0 ? "" : input.slice(separator + 1).trim();
|
||||
|
||||
return { type: "command", name, argument };
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
@ -21,6 +21,8 @@ export interface WorkspaceService {
|
||||
conversationId: string;
|
||||
messages(): Promise<ChatMessage[]>;
|
||||
append(role: MessageRole, content: string): Promise<ChatMessage>;
|
||||
newConversation(): Promise<string>;
|
||||
switchConversation(conversationId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface WorkspaceOptions {
|
||||
@ -35,20 +37,42 @@ export function createWorkspaceExtension(options: WorkspaceOptions = {}): Extens
|
||||
);
|
||||
const projectPath = resolve(options.projectPath ?? process.cwd());
|
||||
const workspaceId = createHash("sha256").update(projectPath).digest("hex").slice(0, 16);
|
||||
const conversationId = options.conversationId ?? "default";
|
||||
const workspaceDirectory = join(home, "workspaces", workspaceId);
|
||||
const conversationDirectory = join(workspaceDirectory, "conversations", conversationId);
|
||||
const messagesFile = join(conversationDirectory, "messages.jsonl");
|
||||
const conversationsDirectory = join(workspaceDirectory, "conversations");
|
||||
const workspaceFile = join(workspaceDirectory, "workspace.json");
|
||||
let conversationId = options.conversationId ?? "default";
|
||||
|
||||
async function saveWorkspace() {
|
||||
await writeFile(
|
||||
workspaceFile,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
id: workspaceId,
|
||||
kind: "linked",
|
||||
projectPath,
|
||||
activeConversationId: conversationId,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
const workspace: WorkspaceService = {
|
||||
home,
|
||||
projectPath,
|
||||
workspaceId,
|
||||
conversationId,
|
||||
get conversationId() {
|
||||
return conversationId;
|
||||
},
|
||||
|
||||
async messages() {
|
||||
try {
|
||||
const content = await readFile(messagesFile, "utf8");
|
||||
const content = await readFile(
|
||||
join(conversationsDirectory, conversationId, "messages.jsonl"),
|
||||
"utf8",
|
||||
);
|
||||
return content
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
@ -66,30 +90,57 @@ export function createWorkspaceExtension(options: WorkspaceOptions = {}): Extens
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await appendFile(messagesFile, `${JSON.stringify(message)}\n`, "utf8");
|
||||
await appendFile(
|
||||
join(conversationsDirectory, conversationId, "messages.jsonl"),
|
||||
`${JSON.stringify(message)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
return message;
|
||||
},
|
||||
|
||||
async newConversation() {
|
||||
const newConversationId = randomUUID();
|
||||
await mkdir(join(conversationsDirectory, newConversationId), { recursive: true });
|
||||
conversationId = newConversationId;
|
||||
await saveWorkspace();
|
||||
return conversationId;
|
||||
},
|
||||
|
||||
async switchConversation(nextConversationId) {
|
||||
const conversations = await readdir(conversationsDirectory, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
const exists = conversations.some(
|
||||
(entry) => entry.isDirectory() && entry.name === nextConversationId,
|
||||
);
|
||||
|
||||
if (!exists) return false;
|
||||
|
||||
conversationId = nextConversationId;
|
||||
await saveWorkspace();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
id: ExtensionId.Workspace,
|
||||
|
||||
async setup(context) {
|
||||
await mkdir(conversationDirectory, { recursive: true });
|
||||
await writeFile(
|
||||
join(workspaceDirectory, "workspace.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
id: workspaceId,
|
||||
kind: "linked",
|
||||
projectPath,
|
||||
activeConversationId: conversationId,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(workspaceDirectory, { recursive: true });
|
||||
|
||||
if (!options.conversationId) {
|
||||
try {
|
||||
const savedWorkspace = JSON.parse(await readFile(workspaceFile, "utf8"));
|
||||
if (savedWorkspace.activeConversationId) {
|
||||
conversationId = savedWorkspace.activeConversationId;
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir(join(conversationsDirectory, conversationId), { recursive: true });
|
||||
await saveWorkspace();
|
||||
|
||||
context.add(Hook.Workspace, workspace);
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user