From 08321a86da7d548336a35fe6536e58e6215ef979 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 25 Mar 2026 22:15:45 +0800 Subject: [PATCH 01/15] =?UTF-8?q?:=E6=96=B0=E5=A2=9Eflow=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E6=96=B9=E6=A1=88=E5=92=8Ctodo=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/flow-design.md | 675 +++++++++++++++ docs/todo-flow-aligned-implementation-plan.md | 786 ++++++++++++++++++ 2 files changed, 1461 insertions(+) create mode 100644 docs/flow-design.md create mode 100644 docs/todo-flow-aligned-implementation-plan.md diff --git a/docs/flow-design.md b/docs/flow-design.md new file mode 100644 index 00000000..b928e48f --- /dev/null +++ b/docs/flow-design.md @@ -0,0 +1,675 @@ +# NeoCode 思考流程改造统一方案(优化版) + +## 1. 文档定位 + +本文整合并替代此前关于 NeoCode 思考流程改造的多份草案,形成一份统一的团队讨论与实施指导文档。目标是同时回答以下问题: + +- 当前项目是否适合推进思考流程改造 +- 这次改造应该落实到什么边界 +- 当记忆模块和工具模块后续继续大改时,如何保证 Flow 模块能独立落地 +- Flow 相关新增能力应该如何组织目录与职责 +- 是否应引入设计模式,如果引入,应引入哪些、落在哪些位置 + +本文强调两点: + +- 先稳定边界,再扩展流程 +- 引入设计模式,但只引入“对当前项目真正有帮助的模式”,避免过度框架化 + +--- + +## 2. 结论摘要 + +当前项目适合推进思考流程改造,而且现有代码结构已经具备较好的基础: + +- 服务端当前仍是“单次请求 -> 单次模型调用”,见 [chat_service.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/service/chat_service.go#L29) +- 工具闭环主要在 TUI 内完成,见 [update.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/core/update.go#L63) +- 角色、记忆、工作记忆、安全、工具注册、配置系统均已存在,可被编排层统一收口 + +但如果目标是长期可演进,那么本次改造不能只做“功能追加”,而应同时完成三类建设: + +1. 建立 Flow 编排层 +2. 冻结 Flow 与记忆/工具之间的稳定能力接口 +3. 用适合当前项目的设计模式组织流程,而不是继续用散落的 `if-else` 和 UI 闭环来驱动 + +--- + +## 3. 当前问题与改造动机 + +### 3.1 当前主要问题 +当前系统的核心问题不是“不能调用工具”,而是“流程能力没有统一归位”。 + +具体表现为: + +- Service 只负责单轮问答 +- TUI 负责工具闭环、审批暂停、结果回灌 +- 记忆和工具能力被主链路直接调用,缺乏中间抽象 +- 新增流程时,很容易牵一发动全身 + +### 3.2 如果不改,会发生什么 +如果继续沿用当前模式,后续新增任何一种思考流程时,都需要重复处理这些问题: + +- 何时调模型 +- 何时调工具 +- 工具结果如何回灌 +- 审批如何暂停与恢复 +- 记忆何时注入,何时写回 +- 何时终止,何时回退 + +这会直接导致: + +- 流程逻辑分散 +- TUI 越来越重 +- 函数命名和接口不断震荡 +- 测试难以稳定 + +--- + +## 4. 推荐的设计模式 + +本项目可以考虑引入设计模式,但应遵循“少而准”的原则。推荐引入以下模式: + +## 4.1 Strategy 模式:用于 Flow 本身 +最适合当前项目的模式是 Strategy。 + +不同思考流程本质上就是不同执行策略: + +- `single_shot` +- `react` +- `plan_execute` + +它们共享依赖、共享上下文能力、共享工具执行器,但控制步骤不同。因此最自然的建模方式就是: + +```go +type Flow interface { + Run(ctx context.Context, req *ChatRequest) (<-chan ChatEvent, error) +} +``` + +每个 Flow 都是一个策略实现。 + +### 为什么适合 +- 与当前“可切换思考流程”的目标天然匹配 +- 新增流程时只新增实现,不改主链路 +- 比把流程分支继续塞进 `chat_service.go` 更可维护 + +--- + +## 4.2 Factory + Registry 模式:用于 Flow 创建与切换 +Flow 不应靠 `switch name { ... }` 散落在多个地方创建。 + +建议引入 Factory + Registry: + +```go +type FlowFactory func(deps FlowDeps) Flow +``` + +配合注册表: + +- 注册 `single_shot` +- 注册 `react` +- 注册 `plan_execute` +- 统一负责名称归一化、查找、默认值、未知值回退 + +### 为什么适合 +- 与配置化切换、`/flow ` 命令天然匹配 +- 能避免流程名称字符串散落在 TUI、Service、Config 校验中 +- 新增流程时对主链路零侵入 + +--- + +## 4.3 Ports and Adapters:用于 Flow 与记忆/工具解耦 +严格来说这不是一个传统 GoF 模式,而是一种非常适合当前项目的架构模式。 + +Flow 不应直接依赖: + +- `MemoryService` 内部算法 +- `WorkingMemoryService` 内部状态建模 +- `GlobalRegistry` +- 工具安全审批内部实现 + +Flow 只应依赖几个稳定端口: + +- `TurnContextAssembler` +- `TurnFinalizer` +- `FlowToolExecutor` +- `ToolCallParser` + +然后由适配器桥接现有实现。 + +### 为什么适合 +- 当前项目最大的风险就是“记忆和工具模块未来还会大改” +- 这个模式可以让 Flow 成为相对稳定的上层模块 +- 底层重构时只改适配器,不改 Flow 策略本体 + +--- + +## 4.4 State Machine 模式:用于 `react` / `plan_execute` 的内部控制 +对于 `single_shot`,不必引入状态机;但对于 `react` 和 `plan_execute`,建议显式引入轻量状态机思想。 + +建议至少把这些状态明确化: + +- `PreparingContext` +- `CallingLLM` +- `ParsingToolCall` +- `WaitingApproval` +- `ExecutingTool` +- `Summarizing` +- `Completed` +- `Failed` + +不一定需要引入外部状态机框架,但流程代码应按状态推进,而不是在一个函数里不断嵌套条件。 + +### 为什么适合 +- ReAct 和 Plan/Execute 都天然是多阶段流程 +- 状态化后更容易处理暂停/恢复、终止条件和错误回退 +- 审批恢复特别适合用状态视角来建模 + +--- + +## 4.5 Rule Set / Policy Object:用于终止、审批、回退决策 +你提到“规则树”,这个思路可以用,但不建议做成过重的通用规则树引擎。 + +更适合当前项目的做法是: + +- 在几个高风险决策点引入轻量规则对象或策略组合 +- 不做全局通用规则树 DSL + +推荐用于以下场景: + +### 终止策略 +例如: +- 是否达到 `max_loops` +- 是否达到 `max_tool_calls` +- 是否当前已完成 +- 是否应强制回退 + +### 审批策略 +例如: +- 当前工具调用是否应 allow / ask / deny +- ask 后如何恢复 +- reject 后是否继续生成解释性回复 + +### 回退策略 +例如: +- planner schema 校验失败后回退到 `react` +- 工具协议解析失败时改为普通文本继续处理 +- 工具异常时是否停止流程或继续下一轮总结 + +### 为什么是“规则对象”而不是“规则树引擎” +因为当前项目规模还没到需要引入完整规则树框架的程度。过早引入会造成: + +- 规则表达复杂度高于业务复杂度 +- 团队后续维护成本变大 +- 调试困难 + +因此建议是: + +- 可以引入“策略对象 / policy object” +- 不建议一开始引入重量级规则树或工作流引擎 + +--- + +## 4.6 不建议优先引入的模式 +以下模式当前不建议优先上: + +### Template Method +原因: +- 当前流程差异不仅是步骤顺序差异,还有阶段能力差异 +- Strategy + 状态机更自然 + +### Command 模式 +原因: +- 当前项目里的“动作”虽然可以抽象成 `CallLLM / ExecuteTool / Emit / Finalize` +- 但暂时没有必要将所有动作对象化,否则会把简单编排变复杂 + +### Visitor +原因: +- 当前没有复杂 AST 或多层对象遍历需求 +- 不适用于当前主要问题 + +--- + +## 5. 推荐总体架构 + +## 5.1 目录结构 + +```text +internal/server/domain/ + chat.go + tool.go + memory.go + working_memory.go + flow.go + +internal/server/service/ + chat_service.go + memory_service.go + working_memory_service.go + todo_service.go + role_service.go + security_service.go + +internal/server/orchestration/flow/ + engine.go + registry.go + ports.go + policy.go + single_shot.go + react.go + plan_execute.go + context_assembler.go + turn_finalizer.go + tool_executor.go + tool_protocol_json.go +``` + +## 5.2 各层职责 +### `domain/` +放稳定契约: + +- Flow 接口 +- 事件结构 +- Flow 状态 +- 审批请求 +- 抽象端口定义 + +### `service/` +保留稳定领域服务: + +- 记忆服务 +- 工作记忆服务 +- 角色服务 +- TODO 服务 +- 安全服务 + +### `orchestration/flow/` +放所有与 Flow 一起变化的内容: + +- 流程实现 +- 工具协议解析 +- 上下文组装 +- 最终写回 +- 工具执行适配 +- 终止/审批/回退策略 + +--- + +## 6. 稳定能力接口 + +建议在 `domain/flow.go` 或 `orchestration/flow/ports.go` 中定义以下接口。 + +## 6.1 TurnContextAssembler +```go +type TurnContextAssembler interface { + Assemble(ctx context.Context, req *ChatRequest, state *FlowState) ([]ContextBlock, error) +} +``` + +职责: + +- 统一组装 role、working memory、todo、retrieved memory +- Flow 不直接依赖各类 context service + +--- + +## 6.2 TurnFinalizer +```go +type TurnFinalizer interface { + Finalize(ctx context.Context, req *ChatRequest, result *TurnResult) error +} +``` + +职责: + +- 最终回复结束后刷新 working memory +- 最终回复结束后写 long-term memory +- 未来可扩展 todo、trace、metrics + +--- + +## 6.3 FlowToolExecutor +```go +type FlowToolExecutor interface { + Execute(ctx context.Context, call ToolCall) (*ToolExecution, error) + ResolveApproval(ctx context.Context, token string, approved bool) (*ToolExecution, error) +} +``` + +职责: + +- 工具查找 +- 安全检查 +- 审批 token 生成与恢复 +- 工具结果标准化 + +--- + +## 6.4 ToolCallParser +```go +type ToolCallParser interface { + ParseAssistantMessage(text string) (*ToolCall, bool, error) +} +``` + +职责: + +- 从 assistant 文本中识别工具调用 +- 当前实现为 `json_v1` +- 未来可替换为 native function calling + +--- + +## 6.5 FlowPolicy +建议新增统一策略对象: + +```go +type FlowPolicy interface { + ShouldStop(state *FlowState) (bool, string) + ShouldFallback(err error, state *FlowState) (bool, FlowName) +} +``` + +如果不做统一接口,也至少应在 `policy.go` 中集中放置终止与回退策略。 + +--- + +## 7. 三类 Flow 的职责 + +## 7.1 `single_shot` +- 最简单的策略实现 +- 只做单轮上下文组装、单次模型调用、最终写回 +- 不进入工具循环 +- 作用是保持兼容现状,并作为所有新架构的基准流程 + +## 7.2 `react` +- 使用状态机思想驱动: + - 组装上下文 + - 调模型 + - 解析工具调用 + - 执行工具 + - 审批暂停/恢复 + - 继续迭代 +- 终止逻辑由 policy 控制,不写死在流程体中 + +## 7.3 `plan_execute` +- 先调用 planner +- 对计划做 schema 校验 +- 步骤执行复用 `react` 的工具执行能力 +- 校验失败或 planner 不可靠时,允许按 policy 回退到 `react` + +--- + +## 8. 如何保证记忆模块和工具模块大改时不影响 Flow + +## 8.1 核心原则 +Flow 不依赖实现,只依赖能力。 + +### 具体意味着 +Flow 不应直接依赖: + +- [MemoryService.BuildContext](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/domain/memory.go#L44) +- [MemoryService.Save](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/domain/memory.go#L44) +- [WorkingMemoryService.BuildContext](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/domain/working_memory.go#L36) +- [WorkingMemoryService.Refresh](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/domain/working_memory.go#L36) +- [GlobalRegistry.Execute](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/infra/tools/tool.go#L67) +- [ApproveSecurityAsk](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/infra/tools/security.go#L33) + +Flow 只应依赖: + +- `TurnContextAssembler` +- `TurnFinalizer` +- `FlowToolExecutor` +- `ToolCallParser` + +--- + +## 8.2 记忆模块大改时的改动边界 +如果未来记忆模块重构为: + +- 多源检索 +- 向量数据库 +- 结构化工作记忆 +- 新的写回策略 + +应只改这些位置: + +- `memory_service.go` +- `working_memory_service.go` +- `context_assembler.go` +- `turn_finalizer.go` + +不应改: + +- `single_shot.go` +- `react.go` +- `plan_execute.go` + +如果必须改 Flow 主循环,说明边界设计失败。 + +--- + +## 8.3 工具模块大改时的改动边界 +如果未来工具模块重构为: + +- 远端执行 +- 原生 function calling +- 新审批模型 +- 新工具元数据结构 + +应只改这些位置: + +- `infra/tools/*` +- `tool_executor.go` +- `tool_protocol_json.go` 或新的 parser +- 安全相关适配层 + +不应改: + +- Flow 主循环 +- TUI 主事件处理 +- Flow 状态机逻辑 + +--- + +## 9. TUI 的新职责 + +TUI 应从当前的“半个流程控制器”退回为“事件渲染层 + 用户交互层”。 + +## 9.1 TUI 保留职责 +- 渲染文本流 +- 渲染工具状态 +- 渲染审批提示 +- 发送 `/y` `/n` +- 发送 `/flow `、`/provider`、`/switch` + +## 9.2 TUI 移除职责 +- assistant JSON 工具调用解析 +- 本地执行工具 +- 工具结果回灌为 system message +- 审批恢复状态机本体 + +相关现状参考: + +- [update.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/core/update.go#L82) +- [update.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/core/update.go#L165) +- [runtime_services.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/services/runtime_services.go#L46) + +--- + +## 10. 配置与切换 + +建议在 [app_config.go](C:/Users/10116/Desktop/study/gopro/neo-code/configs/app_config.go#L20) 的 `AI` 下新增结构化 `flow` 配置: + +```yaml +ai: + provider: "openll" + api_key: "AI_API_KEY" + model: "gpt-5.4" + flow: + name: "single_shot" + tool_protocol: "json_v1" + max_loops: 8 + max_tool_calls: 12 + planner_model: "" + executor_model: "" + plan_template_path: "" + executor_template_path: "" +``` + +建议支持环境变量覆盖: + +- `NEOCODE_FLOW` + +TUI 新增: + +- `/flow ` + +状态栏新增: + +- 当前 `model` +- 当前 `flow` + +--- + +## 11. 推荐实施顺序 + +## 阶段 A:先建模式与边界,不改核心实现 +实施内容: + +- 新建 `domain/flow.go` +- 新建 `orchestration/flow/ports.go` +- 新建 `orchestration/flow/registry.go` +- 新建 `orchestration/flow/policy.go` + +此阶段只完成: + +- Strategy +- Factory/Registry +- Ports and Adapters +- 轻量 Policy + +暂不改记忆算法和工具系统。 + +--- + +## 阶段 B:旧实现接入新适配层 +实施内容: + +- `context_assembler.go` 桥接当前记忆/角色/todo/working memory +- `turn_finalizer.go` 桥接当前写回逻辑 +- `tool_executor.go` 桥接当前工具注册表和安全检查 +- `tool_protocol_json.go` 承接当前 JSON 协议 + +此阶段目标是“旧实现,新边界”。 + +--- + +## 阶段 C:落地 `single_shot` 与 `react` +实施内容: + +- `single_shot.go` +- `react.go` +- TUI 改为事件流消费 +- `/y` `/n` 改为调用审批恢复接口 + +--- + +## 阶段 D:落地 `plan_execute` +实施内容: + +- `plan_execute.go` +- planner schema 校验 +- 回退策略 +- 复用 `react` 执行器 + +--- + +## 阶段 E:推进记忆与工具模块重构 +前提: + +- Flow 已经只依赖稳定接口 +- Flow 主循环已有合同测试 + +这样后续底层模块可独立演进。 + +--- + +## 12. 测试策略 + +## 12.1 Flow 合同测试 +Flow 测试必须只依赖 fake 端口,不依赖真实实现。 + +应验证: + +- 流程切换 +- 状态推进 +- 审批暂停与恢复 +- 终止与回退 +- `single_shot` / `react` / `plan_execute` 的输出行为 + +## 12.2 适配器测试 +分别验证: + +- `context_assembler.go` +- `turn_finalizer.go` +- `tool_executor.go` +- `tool_protocol_json.go` + +这些测试负责保护 Flow 与底层模块之间的桥接逻辑。 + +## 12.3 TUI 回归测试 +现有大量测试都围绕 TUI 本地工具闭环,应逐步改为: + +- TUI 接收事件 +- TUI 展示事件 +- TUI 发起审批恢复 + +重点受影响测试参考: + +- [update_test.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/core/update_test.go#L949) +- [update_test.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/core/update_test.go#L1009) +- [update_test.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/core/update_test.go#L1381) + +--- + +## 13. 风险与规避 + +### 风险一:模式引入过多,复杂度上升 +规避: +- 只引入四类模式:Strategy、Factory/Registry、Ports and Adapters、轻量 State Machine/Policy +- 不引入重量级规则树引擎和工作流框架 + +### 风险二:Flow 继续碰底层实现 +规避: +- 通过包结构和合同测试约束 Flow 只能依赖端口接口 + +### 风险三:`service` 再次变成杂糅层 +规避: +- 所有 Flow 相关适配与策略统一进入 `orchestration/flow/` + +### 风险四:审批恢复设计不完整 +规避: +- 明确 token 化审批恢复接口 +- 不再依赖 TUI 本地状态机拼装流程恢复 + +--- + +## 14. 建议团队优先确认的决策 + +这份方案建议团队优先讨论并确认以下几点: + +- 是否接受以 Strategy 模式建模思考流程 +- 是否接受以 Factory + Registry 管理 Flow 创建与切换 +- 是否接受用 Ports and Adapters 保护 Flow 不受记忆与工具重构影响 +- 是否接受用轻量 Policy/State Machine 管理 `react` 和 `plan_execute` +- 是否接受将 Flow 相关适配层统一放入 `internal/server/orchestration/flow/` +- 是否接受本轮只升级内部事件流,不改外部传输契约 + +--- + +## 15. 最终建议 + +建议团队采纳以下统一方向: + +“NeoCode 将引入独立的 Flow 编排上下文,以 Strategy 模式定义不同思考流程,以 Factory + Registry 模式实现配置化切换,并通过 Ports and Adapters 将记忆模块与工具模块隔离在稳定边界之外。`react` 与 `plan_execute` 的内部控制采用轻量状态机与策略对象管理终止、审批和回退决策。所有与 Flow 一起演进的适配层统一放入 `internal/server/orchestration/flow/`,而不是继续堆入 `service` 根包。外部传输契约本轮保持兼容,待内部编排稳定后再评估进一步升级。” + diff --git a/docs/todo-flow-aligned-implementation-plan.md b/docs/todo-flow-aligned-implementation-plan.md new file mode 100644 index 00000000..1b511a18 --- /dev/null +++ b/docs/todo-flow-aligned-implementation-plan.md @@ -0,0 +1,786 @@ +# NeoCode Todo 模块实施方案(完全对齐 Flow 设计) + +## 1. 文档目标 + +本文将当前 Todo 模块的增强方案重写为**完全符合** [flow-design.md](/C:/Users/10116/Desktop/study/gopro/neo-code/docs/flow-design.md) 的版本,目标是: + +- 让 Todo 成为 Flow 可复用的稳定能力,而不是新的编排中心 +- 保持 `single_shot / react / plan_execute` 为唯一的流程策略入口 +- 让 TUI 从“半个流程控制器”退回为“事件渲染层 + 用户交互层” +- 在不破坏现有 `json_v1` 工具协议的前提下,逐步增强 Todo 的表达力与可维护性 + +本文覆盖: + +- 模块边界 +- 接口规范 +- 依赖关系 +- 调用流程 +- 设计模式选择 +- 分阶段实施计划 +- 测试与风险控制 + +--- + +## 2. 设计原则 + +本方案严格遵守 [flow-design.md](/C:/Users/10116/Desktop/study/gopro/neo-code/docs/flow-design.md) 的以下原则: + +1. **Flow-first** + Flow 是一等公民;Todo 只是 Flow 通过稳定端口接入的一项能力。 + +2. **Ports and Adapters** + Flow 主循环只依赖稳定端口,不直接依赖 `TodoService`、`MemoryService`、`GlobalRegistry` 等具体实现。 + +3. **orchestration/flow 收口** + 所有与 Flow 一起演进的内容统一进入 `internal/server/orchestration/flow/`,不继续堆入 `service` 根包。 + +4. **TUI 瘦身** + TUI 不负责 assistant JSON 解析、工具执行、工具结果回灌或审批恢复状态机本体。 + +5. **兼容优先** + 第一阶段保留现有 `todo` 工具名和 `json_v1` 协议,优先做“旧实现,新边界”。 + +6. **少而准的模式** + 只使用文档已认可的模式:Strategy、Factory/Registry、Ports and Adapters、轻量 State Machine、Policy Object。 + 不优先引入 Command、重型规则树或工作流引擎。 + +--- + +## 3. 总体架构 + +### 3.1 角色定位 + +- `domain/`:定义稳定契约与领域模型 +- `service/`:保留稳定领域服务,如 Todo、Memory、WorkingMemory、Role、Security +- `orchestration/flow/`:承载 Flow 策略、上下文组装、最终落盘、工具执行适配、协议解析、终止/回退策略 +- `tui/`:渲染事件、接收用户输入、发起审批恢复或用户命令 + +### 3.2 Todo 的定位 + +Todo 在新架构中不是独立流程引擎,而是三种能力之一: + +- 被 `TurnContextAssembler` 读取,拼装为当前轮上下文 +- 被 `TurnFinalizer` 更新,用于在回合结束后推进状态或写入补充信息 +- 被 `FlowToolExecutor` 间接调用,通过 `todo` 工具显式增删改查 + +--- + +## 4. 目录建议 + +在遵守 [flow-design.md](/C:/Users/10116/Desktop/study/gopro/neo-code/docs/flow-design.md) 推荐目录的前提下,Todo 相关改造建议如下: + +```text +internal/server/domain/ + todo.go + flow.go + +internal/server/service/ + todo_service.go + +internal/server/infra/repository/ + todo_repository.go + file_todo_repository.go + +internal/server/infra/tools/ + todo.go + +internal/server/orchestration/flow/ + engine.go + registry.go + ports.go + policy.go + single_shot.go + react.go + plan_execute.go + context_assembler.go + turn_finalizer.go + tool_executor.go + tool_protocol_json.go +``` + +注意: + +- Todo 领域模型和领域服务继续留在 `domain/` 与 `service/` +- Todo 的 Flow 接入点只能放在 `orchestration/flow/` +- 不新增 `service/agent_orchestrator.go`、`service/plan_context_provider.go` 之类文件 + +--- + +## 5. 模块边界与依赖关系 + +### 5.1 允许的依赖方向 + +```text +TUI -> services facade -> orchestration/flow -> domain ports + | | + v v + service domain + | + v + infra/repository + infra/tools +``` + +### 5.2 不允许的依赖 + +Flow 层不得直接依赖: + +- `TodoService` 的具体实现细节 +- `MemoryService.BuildContext(...)` +- `WorkingMemoryService.Refresh(...)` +- `tools.GlobalRegistry.Execute(...)` +- `tools.ApproveSecurityAsk(...)` +- TUI 状态结构 + +### 5.3 Todo 与 Flow 的正确边界 + +正确方式: + +- `context_assembler.go` 依赖 `TodoService`,把 Todo 摘要装配为 `ContextBlock` +- `turn_finalizer.go` 依赖 `TodoService`,根据当前轮结果决定是否推进 Todo +- `tool_executor.go` 依赖工具注册表,间接执行 `todo` 工具 + +错误方式: + +- `react.go` 直接调用 `TodoService.AddTodo(...)` +- `plan_execute.go` 直接依赖 `todo_repository` +- TUI 继续解析模型输出 JSON 并直接执行 `todo` 工具 + +--- + +## 6. 接口规范 + +## 6.1 Flow 稳定端口 + +这些接口应定义在 `domain/flow.go` 或 `orchestration/flow/ports.go`,并成为 Flow 的唯一稳定依赖。 + +### TurnContextAssembler + +```go +type TurnContextAssembler interface { + Assemble(ctx context.Context, req *ChatRequest, state *FlowState) ([]ContextBlock, error) +} +``` + +职责: + +- 统一组装 role +- 统一组装 working memory +- 统一组装 todo context +- 统一组装 long-term memory context + +Todo 相关要求: + +- Todo 只能通过这里进入 Flow 上下文 +- 不允许在 `single_shot.go`、`react.go`、`plan_execute.go` 中手写 Todo prompt 拼装逻辑 + +### TurnFinalizer + +```go +type TurnFinalizer interface { + Finalize(ctx context.Context, req *ChatRequest, result *TurnResult) error +} +``` + +职责: + +- 刷新 working memory +- 保存 long-term memory +- 更新 Todo 派生状态 +- 记录 trace / metrics 的扩展点 + +Todo 相关要求: + +- Todo 的自动推进只能经由这里扩展 +- 不能把 Todo 状态推进逻辑散落在 TUI 或 Flow 策略实现中 + +### FlowToolExecutor + +```go +type FlowToolExecutor interface { + Execute(ctx context.Context, call ToolCall) (*ToolExecution, error) + ResolveApproval(ctx context.Context, token string, approved bool) (*ToolExecution, error) +} +``` + +职责: + +- 查找工具 +- 安全检查 +- 审批 token 生成与恢复 +- 返回标准化工具执行结果 + +Todo 相关要求: + +- Flow 若要显式修改 Todo,必须通过 `todo` 工具进入 +- 不允许绕过工具执行器直接修改仓储 + +### ToolCallParser + +```go +type ToolCallParser interface { + ParseAssistantMessage(text string) (*ToolCall, bool, error) +} +``` + +职责: + +- 识别 assistant 输出中的工具调用 +- 当前承接 `json_v1` +- 未来可替换为 native function calling + +Todo 相关要求: + +- `todo` 工具协议解析只能在 parser 中演进 +- TUI 不再承担 assistant JSON 解析 + +### FlowPolicy + +```go +type FlowPolicy interface { + ShouldStop(state *FlowState) (bool, string) + ShouldFallback(err error, state *FlowState) (bool, FlowName) +} +``` + +职责: + +- 终止条件 +- planner 失败回退 +- 工具异常后的继续/中断决策 + +Todo 相关要求: + +- Todo 不能承担 Flow 是否继续执行的判定职责 +- Todo 只能提供上下文和状态,不替代 FlowPolicy + +--- + +## 6.2 Todo 领域接口 + +Todo 仍然属于领域服务,不属于 Flow 端口。 + +建议在不破坏现有契约的前提下,增强 [todo.go](/C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/domain/todo.go)。 + +### 领域模型 + +保持现有核心字段: + +```go +type Todo struct { + ID string `json:"id"` + Content string `json:"content"` + Status TodoStatus `json:"status"` + Priority TodoPriority `json:"priority"` +} +``` + +建议逐步新增可选字段: + +```go +type Todo struct { + ID string `json:"id"` + Content string `json:"content"` + Status TodoStatus `json:"status"` + Priority TodoPriority `json:"priority"` + Detail string `json:"detail,omitempty"` + Dependencies []string `json:"dependencies,omitempty"` + Source string `json:"source,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} +``` + +说明: + +- `content/status/priority` 继续保持外部兼容 +- 新字段只作为渐进增强,不影响当前工具协议的最小集 + +### TodoService + +保守演进版本: + +```go +type TodoService interface { + AddTodo(ctx context.Context, content string, priority TodoPriority) (*Todo, error) + UpdateTodoStatus(ctx context.Context, id string, status TodoStatus) error + ListTodos(ctx context.Context) ([]Todo, error) + ClearTodos(ctx context.Context) error + RemoveTodo(ctx context.Context, id string) error +} +``` + +增强建议: + +```go +type TodoService interface { + AddTodo(ctx context.Context, content string, priority TodoPriority) (*Todo, error) + UpdateTodoStatus(ctx context.Context, id string, status TodoStatus) error + UpdateTodo(ctx context.Context, todo Todo) (*Todo, error) + ListTodos(ctx context.Context) ([]Todo, error) + ListActiveTodos(ctx context.Context) ([]Todo, error) + ClearTodos(ctx context.Context) error + RemoveTodo(ctx context.Context, id string) error +} +``` + +注意: + +- 即使增强 `TodoService`,Flow 也不直接依赖它 +- Flow 只通过 `TurnContextAssembler` / `TurnFinalizer` / `FlowToolExecutor` 间接使用 Todo 能力 + +### TodoRepository + +```go +type TodoRepository interface { + Add(ctx context.Context, todo Todo) (*Todo, error) + UpdateStatus(ctx context.Context, id string, status TodoStatus) error + Update(ctx context.Context, todo Todo) (*Todo, error) + List(ctx context.Context) ([]Todo, error) + Clear(ctx context.Context) error + Remove(ctx context.Context, id string) error +} +``` + +第一阶段可以保留内存仓储,第二阶段增加文件仓储实现。 + +--- + +## 6.3 todo 工具协议 + +为了符合“外部契约保持兼容”的要求,保留当前工具名与主协议: + +```json +{ + "tool": "todo", + "params": { + "action": "add|update|list|remove|clear", + "content": "write tests", + "priority": "medium", + "id": "todo-1", + "status": "completed" + } +} +``` + +实施建议: + +- 阶段 A/B 不修改外部协议 +- 阶段 C 以后再考虑增加可选动作,如 `replace`、`advance` +- 即使新增动作,也必须保持老协议可用 + +--- + +## 7. 调用流程 + +## 7.1 `single_shot` 流程 + +```text +TUI -> flow engine(single_shot) + -> TurnContextAssembler + -> ChatProvider.Chat + -> TurnFinalizer + -> TUI 渲染最终回答 +``` + +Todo 参与方式: + +- 只通过 `TurnContextAssembler` 提供 Todo 摘要 +- 不进入工具循环 +- 可由 `TurnFinalizer` 做非常保守的收尾更新 + +适用场景: + +- 保持现状兼容 +- 作为新架构基线 + +## 7.2 `react` 流程 + +```text +TUI -> flow engine(react) + -> Assemble context + -> Call LLM + -> Parse tool call + -> FlowToolExecutor.Execute + -> 生成 ToolExecution 事件 + -> 继续下一轮 + -> TurnFinalizer +``` + +Todo 参与方式: + +- Todo 摘要由 `TurnContextAssembler` 注入 +- 模型若需显式修改 Todo,必须调用 `todo` 工具 +- 工具执行完成后,由 `TurnFinalizer` 做状态补充或归档 + +审批要求: + +- `FlowToolExecutor` 负责 token 化审批 +- TUI 只展示审批并发起 `/y` `/n` +- 审批恢复走 `ResolveApproval(...)` + +## 7.3 `plan_execute` 流程 + +```text +TUI -> flow engine(plan_execute) + -> planner 生成计划 + -> schema 校验 + -> 合法则进入执行阶段 + -> 执行阶段复用 react 的工具执行能力 + -> TurnFinalizer +``` + +Todo 参与方式: + +- planner 产生的计划可以映射为 Todo 列表 +- 计划写入不应直接发生在 `plan_execute.go` +- 应通过 `todo` 工具或 `TurnFinalizer` 的明确策略完成 + +回退要求: + +- planner schema 校验失败时,由 `FlowPolicy` 决定是否回退到 `react` + +## 7.4 TUI 手工 `/todo` 流程 + +```text +用户输入 /todo -> TUI 命令处理 -> ChatClient facade -> TodoService -> Repository +``` + +注意: + +- `/todo` 是用户交互入口,不是 Flow 主循环 +- TUI 可以保留 `/todo` 命令,但不能继续承担工具协议解析与本地工具闭环 +- `/todo` 与 agent 自动 `todo` 工具操作最终应落到同一 `TodoService` + +--- + +## 8. Todo 上下文策略 + +Todo 不能再像当前 [chat_service.go](/C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/service/chat_service.go#L117) 那样在主链路直接手写拼接。 + +建议由 `context_assembler.go` 统一生成: + +```text +[TODO] +In Progress: +- todo-3: implement parser + +Pending: +- todo-4: add tests +- todo-5: update docs + +Blocked: +- todo-6: waiting user decision +``` + +规则: + +- 优先显示 `in_progress` +- `pending` 最多显示前 3 到 5 项 +- `completed` 默认不注入 +- Todo 过多时只摘要,不全量塞入 prompt + +这样做的好处: + +- 减少上下文噪音 +- 保持 Flow 策略代码无 Todo 拼接细节 +- 后续 Todo 模型增强时只需改 `context_assembler.go` + +--- + +## 9. Todo 状态机 + +文档推荐对 `react / plan_execute` 使用轻量状态机;这里 Todo 自身也应有轻量状态迁移规则,但它是**领域状态机**,不是 Flow 状态机。 + +建议状态: + +- `pending` +- `in_progress` +- `completed` +- 第二阶段可增加 `blocked` + +建议迁移: + +```text +pending -> in_progress +in_progress -> completed +in_progress -> pending +blocked -> in_progress +pending -> cancelled (可选,后续阶段) +``` + +约束: + +- 状态迁移校验放在 `TodoService` 内部 +- TUI 不再自己定义“切一下状态就行” +- `todo` 工具更新状态时也走同一校验逻辑 + +--- + +## 10. 设计模式选择 + +## 10.1 应采用的模式 + +### Strategy + +用于三种 Flow: + +- `single_shot` +- `react` +- `plan_execute` + +Todo 不是 Strategy 主体,只是被策略消费的一种能力。 + +### Factory + Registry + +用于创建和切换 Flow: + +- 注册 `single_shot` +- 注册 `react` +- 注册 `plan_execute` + +Todo 不参与 Factory,只作为底层能力提供给适配器。 + +### Ports and Adapters + +这是 Todo 改造中最关键的约束: + +- Flow 不直接依赖 Todo 实现 +- Todo 能力通过 `TurnContextAssembler`、`TurnFinalizer`、`FlowToolExecutor` 间接进入 Flow + +### Lightweight State Machine + +用于: + +- `react` / `plan_execute` 的流程状态推进 +- TodoService 内部的状态迁移校验 + +### Policy Object + +用于: + +- Flow 终止条件 +- planner 失败回退 +- 工具审批恢复 +- 工具异常后继续/中断决策 + +## 10.2 不建议优先引入的模式 + +### Command + +不引入 `AddTodoCommand`、`UpdateTodoCommand` 这类完整 Command 模式体系。 + +原因: + +- 与 [flow-design.md](/C:/Users/10116/Desktop/study/gopro/neo-code/docs/flow-design.md) 的“少而准”原则不一致 +- 当前阶段会把简单编排进一步对象化,复杂度高于收益 + +### 重型规则树 / DSL + +不引入通用规则引擎,不做全局 DSL。 + +原因: + +- 当前规模不需要 +- 调试和维护成本过高 + +--- + +## 11. 分阶段实施 + +## 阶段 A:先建边界,不改核心实现 + +目标: + +- 建立 Flow 的稳定端口 +- 明确目录与依赖边界 + +工作项: + +- 新建 `domain/flow.go` +- 新建 `orchestration/flow/ports.go` +- 新建 `orchestration/flow/registry.go` +- 新建 `orchestration/flow/policy.go` + +Todo 相关要求: + +- 不重写 TodoService +- 不改工具协议 +- 不改 Memory 算法 +- 只为后续 Todo 接入预留合法位置 + +## 阶段 B:旧实现接入新适配层 + +目标: + +- 做到“旧实现,新边界” + +工作项: + +- `context_assembler.go` 桥接 role / working memory / todo / memory +- `turn_finalizer.go` 桥接 working memory 刷新、memory save、todo 补充更新 +- `tool_executor.go` 桥接当前工具注册表与安全检查 +- `tool_protocol_json.go` 承接当前 `json_v1` + +Todo 相关要求: + +- Todo 从 [chat_service.go](/C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/service/chat_service.go) 的直接拼接中移出 +- TUI 不再处理 assistant JSON tool call + +## 阶段 C:落地 `single_shot` 与 `react` + +目标: + +- 让 Flow engine 接管当前闭环 + +工作项: + +- 实现 `single_shot.go` +- 实现 `react.go` +- TUI 改为消费 Flow 事件 +- `/y` `/n` 改为调用审批恢复接口 + +Todo 相关要求: + +- `/todo` 保留为用户命令 +- agent 自动修改 Todo 通过 `todo` 工具执行 +- Todo 状态推进从 TUI 迁出,统一由 TodoService 校验 + +## 阶段 D:落地 `plan_execute` + +目标: + +- 在不破坏边界的前提下引入规划能力 + +工作项: + +- 实现 `plan_execute.go` +- planner schema 校验 +- fallback 策略 +- 执行阶段复用 `react` + +Todo 相关要求: + +- planner 输出可映射为 Todo +- 映射逻辑放在 `plan_execute` 配套适配器中,不直接侵入 Todo 仓储 + +## 阶段 E:增强 Todo 领域能力 + +前提: + +- Flow 已稳定运行在新架构中 +- Flow 合同测试已建立 + +工作项: + +- 引入文件型 Todo 仓储 +- 增加 `detail/dependencies/source/updated_at` +- 增强筛选、排序、归档 +- 视情况增加 `blocked` + +注意: + +- 这一阶段增强 Todo,但不修改 Flow 主循环 +- 若需要改 `react.go` 或 `plan_execute.go` 才能支持 Todo 新能力,说明边界设计失败 + +--- + +## 12. 测试策略 + +## 12.1 Flow 合同测试 + +用 fake 端口测试: + +- `TurnContextAssembler` +- `TurnFinalizer` +- `FlowToolExecutor` +- `ToolCallParser` + +验证: + +- 流程切换 +- 工具执行 +- 审批暂停与恢复 +- 终止与回退 + +## 12.2 Todo 领域测试 + +验证: + +- 状态迁移是否合法 +- 排序与筛选逻辑 +- 文件仓储与内存仓储的一致性 +- `/todo add` 多词内容场景 + +## 12.3 适配器测试 + +重点覆盖: + +- `context_assembler.go` +- `turn_finalizer.go` +- `tool_executor.go` +- `tool_protocol_json.go` + +验证 Todo 是否正确通过适配层进入 Flow,而非被 Flow 直接依赖。 + +## 12.4 TUI 回归测试 + +TUI 测试目标改为: + +- 接收 Flow 事件 +- 展示工具状态 +- 展示审批 +- 发起 `/y` `/n` +- 处理 `/todo` + +而不是继续测试“本地 JSON 解析 + 本地工具执行 + system message 回灌”。 + +--- + +## 13. 风险与优化建议 + +### 风险 1:Todo 继续演化成新的编排中心 + +规避: + +- 不新增 `PlanService` 作为 Flow 的直接依赖 +- 不让 `react.go` 或 `plan_execute.go` 直接 import Todo 实现细节 + +### 风险 2:TUI 迁移不彻底 + +规避: + +- 第一优先迁走 assistant JSON 解析 +- 第二优先迁走工具执行 +- 第三优先迁走审批恢复状态机 + +### 风险 3:Todo 增强过早,拖慢 Flow 重构 + +规避: + +- 阶段 A/B 只做边界 +- Todo 富模型增强放到阶段 E + +### 风险 4:外部协议破坏兼容 + +规避: + +- 保留 `todo` 工具名 +- 保留 `json_v1` +- 保留 `add/update/list/remove/clear` + +--- + +## 14. 最终建议 + +建议团队采用以下方向: + +- 先按 [flow-design.md](/C:/Users/10116/Desktop/study/gopro/neo-code/docs/flow-design.md) 建立 `Flow engine + Ports + Registry + Policy` +- Todo 不升级为新的流程中心,而是保留为领域能力 +- Todo 与 Flow 的连接统一收敛到 `internal/server/orchestration/flow/` +- TUI 只做渲染与交互,不做编排 +- 在 Flow 稳定后,再逐步增强 Todo 的模型、持久化和规划表达力 + +一句话概括: + +**先让 Todo 成为“被 Flow 正确消费的能力”,再让它变强;不要让 Todo 在 Flow 稳定之前抢走编排中心的位置。** From 589d6d2bf687caed0ad240562c34420bce74d07f Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sat, 28 Mar 2026 11:27:05 +0800 Subject: [PATCH 02/15] =?UTF-8?q?pref:=E4=BC=98=E5=8C=96TUI=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E5=92=8C=E7=83=AD=E9=94=AE=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 37 - docs/flow-design.md | 675 --------------- docs/todo-flow-aligned-implementation-plan.md | 786 ------------------ internal/tui/app.go | 22 +- internal/tui/commands.go | 6 +- internal/tui/keymap.go | 32 +- internal/tui/styles.go | 41 +- internal/tui/update.go | 17 +- internal/tui/update_test.go | 36 +- internal/tui/view.go | 255 ++++-- 10 files changed, 269 insertions(+), 1638 deletions(-) delete mode 100644 .gitignore diff --git a/.gitignore b/.gitignore deleted file mode 100644 index ce2d9e9e..00000000 --- a/.gitignore +++ /dev/null @@ -1,37 +0,0 @@ -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Code coverage profiles and other test artifacts -cover -coverage -*.out -coverage.* -*.coverprofile -profile.cov - -# Dependency directories -# vendor/ - -# Go workspace file -go.work -go.work.sum - -# Local configuration and secrets -.env -config.yaml -config.local.yaml - -# Local data -data/ - -# Editor/IDE -.idea/ -workspace.xml -.vscode/ diff --git a/docs/flow-design.md b/docs/flow-design.md index b928e48f..e69de29b 100644 --- a/docs/flow-design.md +++ b/docs/flow-design.md @@ -1,675 +0,0 @@ -# NeoCode 思考流程改造统一方案(优化版) - -## 1. 文档定位 - -本文整合并替代此前关于 NeoCode 思考流程改造的多份草案,形成一份统一的团队讨论与实施指导文档。目标是同时回答以下问题: - -- 当前项目是否适合推进思考流程改造 -- 这次改造应该落实到什么边界 -- 当记忆模块和工具模块后续继续大改时,如何保证 Flow 模块能独立落地 -- Flow 相关新增能力应该如何组织目录与职责 -- 是否应引入设计模式,如果引入,应引入哪些、落在哪些位置 - -本文强调两点: - -- 先稳定边界,再扩展流程 -- 引入设计模式,但只引入“对当前项目真正有帮助的模式”,避免过度框架化 - ---- - -## 2. 结论摘要 - -当前项目适合推进思考流程改造,而且现有代码结构已经具备较好的基础: - -- 服务端当前仍是“单次请求 -> 单次模型调用”,见 [chat_service.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/service/chat_service.go#L29) -- 工具闭环主要在 TUI 内完成,见 [update.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/core/update.go#L63) -- 角色、记忆、工作记忆、安全、工具注册、配置系统均已存在,可被编排层统一收口 - -但如果目标是长期可演进,那么本次改造不能只做“功能追加”,而应同时完成三类建设: - -1. 建立 Flow 编排层 -2. 冻结 Flow 与记忆/工具之间的稳定能力接口 -3. 用适合当前项目的设计模式组织流程,而不是继续用散落的 `if-else` 和 UI 闭环来驱动 - ---- - -## 3. 当前问题与改造动机 - -### 3.1 当前主要问题 -当前系统的核心问题不是“不能调用工具”,而是“流程能力没有统一归位”。 - -具体表现为: - -- Service 只负责单轮问答 -- TUI 负责工具闭环、审批暂停、结果回灌 -- 记忆和工具能力被主链路直接调用,缺乏中间抽象 -- 新增流程时,很容易牵一发动全身 - -### 3.2 如果不改,会发生什么 -如果继续沿用当前模式,后续新增任何一种思考流程时,都需要重复处理这些问题: - -- 何时调模型 -- 何时调工具 -- 工具结果如何回灌 -- 审批如何暂停与恢复 -- 记忆何时注入,何时写回 -- 何时终止,何时回退 - -这会直接导致: - -- 流程逻辑分散 -- TUI 越来越重 -- 函数命名和接口不断震荡 -- 测试难以稳定 - ---- - -## 4. 推荐的设计模式 - -本项目可以考虑引入设计模式,但应遵循“少而准”的原则。推荐引入以下模式: - -## 4.1 Strategy 模式:用于 Flow 本身 -最适合当前项目的模式是 Strategy。 - -不同思考流程本质上就是不同执行策略: - -- `single_shot` -- `react` -- `plan_execute` - -它们共享依赖、共享上下文能力、共享工具执行器,但控制步骤不同。因此最自然的建模方式就是: - -```go -type Flow interface { - Run(ctx context.Context, req *ChatRequest) (<-chan ChatEvent, error) -} -``` - -每个 Flow 都是一个策略实现。 - -### 为什么适合 -- 与当前“可切换思考流程”的目标天然匹配 -- 新增流程时只新增实现,不改主链路 -- 比把流程分支继续塞进 `chat_service.go` 更可维护 - ---- - -## 4.2 Factory + Registry 模式:用于 Flow 创建与切换 -Flow 不应靠 `switch name { ... }` 散落在多个地方创建。 - -建议引入 Factory + Registry: - -```go -type FlowFactory func(deps FlowDeps) Flow -``` - -配合注册表: - -- 注册 `single_shot` -- 注册 `react` -- 注册 `plan_execute` -- 统一负责名称归一化、查找、默认值、未知值回退 - -### 为什么适合 -- 与配置化切换、`/flow ` 命令天然匹配 -- 能避免流程名称字符串散落在 TUI、Service、Config 校验中 -- 新增流程时对主链路零侵入 - ---- - -## 4.3 Ports and Adapters:用于 Flow 与记忆/工具解耦 -严格来说这不是一个传统 GoF 模式,而是一种非常适合当前项目的架构模式。 - -Flow 不应直接依赖: - -- `MemoryService` 内部算法 -- `WorkingMemoryService` 内部状态建模 -- `GlobalRegistry` -- 工具安全审批内部实现 - -Flow 只应依赖几个稳定端口: - -- `TurnContextAssembler` -- `TurnFinalizer` -- `FlowToolExecutor` -- `ToolCallParser` - -然后由适配器桥接现有实现。 - -### 为什么适合 -- 当前项目最大的风险就是“记忆和工具模块未来还会大改” -- 这个模式可以让 Flow 成为相对稳定的上层模块 -- 底层重构时只改适配器,不改 Flow 策略本体 - ---- - -## 4.4 State Machine 模式:用于 `react` / `plan_execute` 的内部控制 -对于 `single_shot`,不必引入状态机;但对于 `react` 和 `plan_execute`,建议显式引入轻量状态机思想。 - -建议至少把这些状态明确化: - -- `PreparingContext` -- `CallingLLM` -- `ParsingToolCall` -- `WaitingApproval` -- `ExecutingTool` -- `Summarizing` -- `Completed` -- `Failed` - -不一定需要引入外部状态机框架,但流程代码应按状态推进,而不是在一个函数里不断嵌套条件。 - -### 为什么适合 -- ReAct 和 Plan/Execute 都天然是多阶段流程 -- 状态化后更容易处理暂停/恢复、终止条件和错误回退 -- 审批恢复特别适合用状态视角来建模 - ---- - -## 4.5 Rule Set / Policy Object:用于终止、审批、回退决策 -你提到“规则树”,这个思路可以用,但不建议做成过重的通用规则树引擎。 - -更适合当前项目的做法是: - -- 在几个高风险决策点引入轻量规则对象或策略组合 -- 不做全局通用规则树 DSL - -推荐用于以下场景: - -### 终止策略 -例如: -- 是否达到 `max_loops` -- 是否达到 `max_tool_calls` -- 是否当前已完成 -- 是否应强制回退 - -### 审批策略 -例如: -- 当前工具调用是否应 allow / ask / deny -- ask 后如何恢复 -- reject 后是否继续生成解释性回复 - -### 回退策略 -例如: -- planner schema 校验失败后回退到 `react` -- 工具协议解析失败时改为普通文本继续处理 -- 工具异常时是否停止流程或继续下一轮总结 - -### 为什么是“规则对象”而不是“规则树引擎” -因为当前项目规模还没到需要引入完整规则树框架的程度。过早引入会造成: - -- 规则表达复杂度高于业务复杂度 -- 团队后续维护成本变大 -- 调试困难 - -因此建议是: - -- 可以引入“策略对象 / policy object” -- 不建议一开始引入重量级规则树或工作流引擎 - ---- - -## 4.6 不建议优先引入的模式 -以下模式当前不建议优先上: - -### Template Method -原因: -- 当前流程差异不仅是步骤顺序差异,还有阶段能力差异 -- Strategy + 状态机更自然 - -### Command 模式 -原因: -- 当前项目里的“动作”虽然可以抽象成 `CallLLM / ExecuteTool / Emit / Finalize` -- 但暂时没有必要将所有动作对象化,否则会把简单编排变复杂 - -### Visitor -原因: -- 当前没有复杂 AST 或多层对象遍历需求 -- 不适用于当前主要问题 - ---- - -## 5. 推荐总体架构 - -## 5.1 目录结构 - -```text -internal/server/domain/ - chat.go - tool.go - memory.go - working_memory.go - flow.go - -internal/server/service/ - chat_service.go - memory_service.go - working_memory_service.go - todo_service.go - role_service.go - security_service.go - -internal/server/orchestration/flow/ - engine.go - registry.go - ports.go - policy.go - single_shot.go - react.go - plan_execute.go - context_assembler.go - turn_finalizer.go - tool_executor.go - tool_protocol_json.go -``` - -## 5.2 各层职责 -### `domain/` -放稳定契约: - -- Flow 接口 -- 事件结构 -- Flow 状态 -- 审批请求 -- 抽象端口定义 - -### `service/` -保留稳定领域服务: - -- 记忆服务 -- 工作记忆服务 -- 角色服务 -- TODO 服务 -- 安全服务 - -### `orchestration/flow/` -放所有与 Flow 一起变化的内容: - -- 流程实现 -- 工具协议解析 -- 上下文组装 -- 最终写回 -- 工具执行适配 -- 终止/审批/回退策略 - ---- - -## 6. 稳定能力接口 - -建议在 `domain/flow.go` 或 `orchestration/flow/ports.go` 中定义以下接口。 - -## 6.1 TurnContextAssembler -```go -type TurnContextAssembler interface { - Assemble(ctx context.Context, req *ChatRequest, state *FlowState) ([]ContextBlock, error) -} -``` - -职责: - -- 统一组装 role、working memory、todo、retrieved memory -- Flow 不直接依赖各类 context service - ---- - -## 6.2 TurnFinalizer -```go -type TurnFinalizer interface { - Finalize(ctx context.Context, req *ChatRequest, result *TurnResult) error -} -``` - -职责: - -- 最终回复结束后刷新 working memory -- 最终回复结束后写 long-term memory -- 未来可扩展 todo、trace、metrics - ---- - -## 6.3 FlowToolExecutor -```go -type FlowToolExecutor interface { - Execute(ctx context.Context, call ToolCall) (*ToolExecution, error) - ResolveApproval(ctx context.Context, token string, approved bool) (*ToolExecution, error) -} -``` - -职责: - -- 工具查找 -- 安全检查 -- 审批 token 生成与恢复 -- 工具结果标准化 - ---- - -## 6.4 ToolCallParser -```go -type ToolCallParser interface { - ParseAssistantMessage(text string) (*ToolCall, bool, error) -} -``` - -职责: - -- 从 assistant 文本中识别工具调用 -- 当前实现为 `json_v1` -- 未来可替换为 native function calling - ---- - -## 6.5 FlowPolicy -建议新增统一策略对象: - -```go -type FlowPolicy interface { - ShouldStop(state *FlowState) (bool, string) - ShouldFallback(err error, state *FlowState) (bool, FlowName) -} -``` - -如果不做统一接口,也至少应在 `policy.go` 中集中放置终止与回退策略。 - ---- - -## 7. 三类 Flow 的职责 - -## 7.1 `single_shot` -- 最简单的策略实现 -- 只做单轮上下文组装、单次模型调用、最终写回 -- 不进入工具循环 -- 作用是保持兼容现状,并作为所有新架构的基准流程 - -## 7.2 `react` -- 使用状态机思想驱动: - - 组装上下文 - - 调模型 - - 解析工具调用 - - 执行工具 - - 审批暂停/恢复 - - 继续迭代 -- 终止逻辑由 policy 控制,不写死在流程体中 - -## 7.3 `plan_execute` -- 先调用 planner -- 对计划做 schema 校验 -- 步骤执行复用 `react` 的工具执行能力 -- 校验失败或 planner 不可靠时,允许按 policy 回退到 `react` - ---- - -## 8. 如何保证记忆模块和工具模块大改时不影响 Flow - -## 8.1 核心原则 -Flow 不依赖实现,只依赖能力。 - -### 具体意味着 -Flow 不应直接依赖: - -- [MemoryService.BuildContext](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/domain/memory.go#L44) -- [MemoryService.Save](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/domain/memory.go#L44) -- [WorkingMemoryService.BuildContext](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/domain/working_memory.go#L36) -- [WorkingMemoryService.Refresh](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/domain/working_memory.go#L36) -- [GlobalRegistry.Execute](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/infra/tools/tool.go#L67) -- [ApproveSecurityAsk](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/infra/tools/security.go#L33) - -Flow 只应依赖: - -- `TurnContextAssembler` -- `TurnFinalizer` -- `FlowToolExecutor` -- `ToolCallParser` - ---- - -## 8.2 记忆模块大改时的改动边界 -如果未来记忆模块重构为: - -- 多源检索 -- 向量数据库 -- 结构化工作记忆 -- 新的写回策略 - -应只改这些位置: - -- `memory_service.go` -- `working_memory_service.go` -- `context_assembler.go` -- `turn_finalizer.go` - -不应改: - -- `single_shot.go` -- `react.go` -- `plan_execute.go` - -如果必须改 Flow 主循环,说明边界设计失败。 - ---- - -## 8.3 工具模块大改时的改动边界 -如果未来工具模块重构为: - -- 远端执行 -- 原生 function calling -- 新审批模型 -- 新工具元数据结构 - -应只改这些位置: - -- `infra/tools/*` -- `tool_executor.go` -- `tool_protocol_json.go` 或新的 parser -- 安全相关适配层 - -不应改: - -- Flow 主循环 -- TUI 主事件处理 -- Flow 状态机逻辑 - ---- - -## 9. TUI 的新职责 - -TUI 应从当前的“半个流程控制器”退回为“事件渲染层 + 用户交互层”。 - -## 9.1 TUI 保留职责 -- 渲染文本流 -- 渲染工具状态 -- 渲染审批提示 -- 发送 `/y` `/n` -- 发送 `/flow `、`/provider`、`/switch` - -## 9.2 TUI 移除职责 -- assistant JSON 工具调用解析 -- 本地执行工具 -- 工具结果回灌为 system message -- 审批恢复状态机本体 - -相关现状参考: - -- [update.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/core/update.go#L82) -- [update.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/core/update.go#L165) -- [runtime_services.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/services/runtime_services.go#L46) - ---- - -## 10. 配置与切换 - -建议在 [app_config.go](C:/Users/10116/Desktop/study/gopro/neo-code/configs/app_config.go#L20) 的 `AI` 下新增结构化 `flow` 配置: - -```yaml -ai: - provider: "openll" - api_key: "AI_API_KEY" - model: "gpt-5.4" - flow: - name: "single_shot" - tool_protocol: "json_v1" - max_loops: 8 - max_tool_calls: 12 - planner_model: "" - executor_model: "" - plan_template_path: "" - executor_template_path: "" -``` - -建议支持环境变量覆盖: - -- `NEOCODE_FLOW` - -TUI 新增: - -- `/flow ` - -状态栏新增: - -- 当前 `model` -- 当前 `flow` - ---- - -## 11. 推荐实施顺序 - -## 阶段 A:先建模式与边界,不改核心实现 -实施内容: - -- 新建 `domain/flow.go` -- 新建 `orchestration/flow/ports.go` -- 新建 `orchestration/flow/registry.go` -- 新建 `orchestration/flow/policy.go` - -此阶段只完成: - -- Strategy -- Factory/Registry -- Ports and Adapters -- 轻量 Policy - -暂不改记忆算法和工具系统。 - ---- - -## 阶段 B:旧实现接入新适配层 -实施内容: - -- `context_assembler.go` 桥接当前记忆/角色/todo/working memory -- `turn_finalizer.go` 桥接当前写回逻辑 -- `tool_executor.go` 桥接当前工具注册表和安全检查 -- `tool_protocol_json.go` 承接当前 JSON 协议 - -此阶段目标是“旧实现,新边界”。 - ---- - -## 阶段 C:落地 `single_shot` 与 `react` -实施内容: - -- `single_shot.go` -- `react.go` -- TUI 改为事件流消费 -- `/y` `/n` 改为调用审批恢复接口 - ---- - -## 阶段 D:落地 `plan_execute` -实施内容: - -- `plan_execute.go` -- planner schema 校验 -- 回退策略 -- 复用 `react` 执行器 - ---- - -## 阶段 E:推进记忆与工具模块重构 -前提: - -- Flow 已经只依赖稳定接口 -- Flow 主循环已有合同测试 - -这样后续底层模块可独立演进。 - ---- - -## 12. 测试策略 - -## 12.1 Flow 合同测试 -Flow 测试必须只依赖 fake 端口,不依赖真实实现。 - -应验证: - -- 流程切换 -- 状态推进 -- 审批暂停与恢复 -- 终止与回退 -- `single_shot` / `react` / `plan_execute` 的输出行为 - -## 12.2 适配器测试 -分别验证: - -- `context_assembler.go` -- `turn_finalizer.go` -- `tool_executor.go` -- `tool_protocol_json.go` - -这些测试负责保护 Flow 与底层模块之间的桥接逻辑。 - -## 12.3 TUI 回归测试 -现有大量测试都围绕 TUI 本地工具闭环,应逐步改为: - -- TUI 接收事件 -- TUI 展示事件 -- TUI 发起审批恢复 - -重点受影响测试参考: - -- [update_test.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/core/update_test.go#L949) -- [update_test.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/core/update_test.go#L1009) -- [update_test.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/core/update_test.go#L1381) - ---- - -## 13. 风险与规避 - -### 风险一:模式引入过多,复杂度上升 -规避: -- 只引入四类模式:Strategy、Factory/Registry、Ports and Adapters、轻量 State Machine/Policy -- 不引入重量级规则树引擎和工作流框架 - -### 风险二:Flow 继续碰底层实现 -规避: -- 通过包结构和合同测试约束 Flow 只能依赖端口接口 - -### 风险三:`service` 再次变成杂糅层 -规避: -- 所有 Flow 相关适配与策略统一进入 `orchestration/flow/` - -### 风险四:审批恢复设计不完整 -规避: -- 明确 token 化审批恢复接口 -- 不再依赖 TUI 本地状态机拼装流程恢复 - ---- - -## 14. 建议团队优先确认的决策 - -这份方案建议团队优先讨论并确认以下几点: - -- 是否接受以 Strategy 模式建模思考流程 -- 是否接受以 Factory + Registry 管理 Flow 创建与切换 -- 是否接受用 Ports and Adapters 保护 Flow 不受记忆与工具重构影响 -- 是否接受用轻量 Policy/State Machine 管理 `react` 和 `plan_execute` -- 是否接受将 Flow 相关适配层统一放入 `internal/server/orchestration/flow/` -- 是否接受本轮只升级内部事件流,不改外部传输契约 - ---- - -## 15. 最终建议 - -建议团队采纳以下统一方向: - -“NeoCode 将引入独立的 Flow 编排上下文,以 Strategy 模式定义不同思考流程,以 Factory + Registry 模式实现配置化切换,并通过 Ports and Adapters 将记忆模块与工具模块隔离在稳定边界之外。`react` 与 `plan_execute` 的内部控制采用轻量状态机与策略对象管理终止、审批和回退决策。所有与 Flow 一起演进的适配层统一放入 `internal/server/orchestration/flow/`,而不是继续堆入 `service` 根包。外部传输契约本轮保持兼容,待内部编排稳定后再评估进一步升级。” - diff --git a/docs/todo-flow-aligned-implementation-plan.md b/docs/todo-flow-aligned-implementation-plan.md index 1b511a18..e69de29b 100644 --- a/docs/todo-flow-aligned-implementation-plan.md +++ b/docs/todo-flow-aligned-implementation-plan.md @@ -1,786 +0,0 @@ -# NeoCode Todo 模块实施方案(完全对齐 Flow 设计) - -## 1. 文档目标 - -本文将当前 Todo 模块的增强方案重写为**完全符合** [flow-design.md](/C:/Users/10116/Desktop/study/gopro/neo-code/docs/flow-design.md) 的版本,目标是: - -- 让 Todo 成为 Flow 可复用的稳定能力,而不是新的编排中心 -- 保持 `single_shot / react / plan_execute` 为唯一的流程策略入口 -- 让 TUI 从“半个流程控制器”退回为“事件渲染层 + 用户交互层” -- 在不破坏现有 `json_v1` 工具协议的前提下,逐步增强 Todo 的表达力与可维护性 - -本文覆盖: - -- 模块边界 -- 接口规范 -- 依赖关系 -- 调用流程 -- 设计模式选择 -- 分阶段实施计划 -- 测试与风险控制 - ---- - -## 2. 设计原则 - -本方案严格遵守 [flow-design.md](/C:/Users/10116/Desktop/study/gopro/neo-code/docs/flow-design.md) 的以下原则: - -1. **Flow-first** - Flow 是一等公民;Todo 只是 Flow 通过稳定端口接入的一项能力。 - -2. **Ports and Adapters** - Flow 主循环只依赖稳定端口,不直接依赖 `TodoService`、`MemoryService`、`GlobalRegistry` 等具体实现。 - -3. **orchestration/flow 收口** - 所有与 Flow 一起演进的内容统一进入 `internal/server/orchestration/flow/`,不继续堆入 `service` 根包。 - -4. **TUI 瘦身** - TUI 不负责 assistant JSON 解析、工具执行、工具结果回灌或审批恢复状态机本体。 - -5. **兼容优先** - 第一阶段保留现有 `todo` 工具名和 `json_v1` 协议,优先做“旧实现,新边界”。 - -6. **少而准的模式** - 只使用文档已认可的模式:Strategy、Factory/Registry、Ports and Adapters、轻量 State Machine、Policy Object。 - 不优先引入 Command、重型规则树或工作流引擎。 - ---- - -## 3. 总体架构 - -### 3.1 角色定位 - -- `domain/`:定义稳定契约与领域模型 -- `service/`:保留稳定领域服务,如 Todo、Memory、WorkingMemory、Role、Security -- `orchestration/flow/`:承载 Flow 策略、上下文组装、最终落盘、工具执行适配、协议解析、终止/回退策略 -- `tui/`:渲染事件、接收用户输入、发起审批恢复或用户命令 - -### 3.2 Todo 的定位 - -Todo 在新架构中不是独立流程引擎,而是三种能力之一: - -- 被 `TurnContextAssembler` 读取,拼装为当前轮上下文 -- 被 `TurnFinalizer` 更新,用于在回合结束后推进状态或写入补充信息 -- 被 `FlowToolExecutor` 间接调用,通过 `todo` 工具显式增删改查 - ---- - -## 4. 目录建议 - -在遵守 [flow-design.md](/C:/Users/10116/Desktop/study/gopro/neo-code/docs/flow-design.md) 推荐目录的前提下,Todo 相关改造建议如下: - -```text -internal/server/domain/ - todo.go - flow.go - -internal/server/service/ - todo_service.go - -internal/server/infra/repository/ - todo_repository.go - file_todo_repository.go - -internal/server/infra/tools/ - todo.go - -internal/server/orchestration/flow/ - engine.go - registry.go - ports.go - policy.go - single_shot.go - react.go - plan_execute.go - context_assembler.go - turn_finalizer.go - tool_executor.go - tool_protocol_json.go -``` - -注意: - -- Todo 领域模型和领域服务继续留在 `domain/` 与 `service/` -- Todo 的 Flow 接入点只能放在 `orchestration/flow/` -- 不新增 `service/agent_orchestrator.go`、`service/plan_context_provider.go` 之类文件 - ---- - -## 5. 模块边界与依赖关系 - -### 5.1 允许的依赖方向 - -```text -TUI -> services facade -> orchestration/flow -> domain ports - | | - v v - service domain - | - v - infra/repository - infra/tools -``` - -### 5.2 不允许的依赖 - -Flow 层不得直接依赖: - -- `TodoService` 的具体实现细节 -- `MemoryService.BuildContext(...)` -- `WorkingMemoryService.Refresh(...)` -- `tools.GlobalRegistry.Execute(...)` -- `tools.ApproveSecurityAsk(...)` -- TUI 状态结构 - -### 5.3 Todo 与 Flow 的正确边界 - -正确方式: - -- `context_assembler.go` 依赖 `TodoService`,把 Todo 摘要装配为 `ContextBlock` -- `turn_finalizer.go` 依赖 `TodoService`,根据当前轮结果决定是否推进 Todo -- `tool_executor.go` 依赖工具注册表,间接执行 `todo` 工具 - -错误方式: - -- `react.go` 直接调用 `TodoService.AddTodo(...)` -- `plan_execute.go` 直接依赖 `todo_repository` -- TUI 继续解析模型输出 JSON 并直接执行 `todo` 工具 - ---- - -## 6. 接口规范 - -## 6.1 Flow 稳定端口 - -这些接口应定义在 `domain/flow.go` 或 `orchestration/flow/ports.go`,并成为 Flow 的唯一稳定依赖。 - -### TurnContextAssembler - -```go -type TurnContextAssembler interface { - Assemble(ctx context.Context, req *ChatRequest, state *FlowState) ([]ContextBlock, error) -} -``` - -职责: - -- 统一组装 role -- 统一组装 working memory -- 统一组装 todo context -- 统一组装 long-term memory context - -Todo 相关要求: - -- Todo 只能通过这里进入 Flow 上下文 -- 不允许在 `single_shot.go`、`react.go`、`plan_execute.go` 中手写 Todo prompt 拼装逻辑 - -### TurnFinalizer - -```go -type TurnFinalizer interface { - Finalize(ctx context.Context, req *ChatRequest, result *TurnResult) error -} -``` - -职责: - -- 刷新 working memory -- 保存 long-term memory -- 更新 Todo 派生状态 -- 记录 trace / metrics 的扩展点 - -Todo 相关要求: - -- Todo 的自动推进只能经由这里扩展 -- 不能把 Todo 状态推进逻辑散落在 TUI 或 Flow 策略实现中 - -### FlowToolExecutor - -```go -type FlowToolExecutor interface { - Execute(ctx context.Context, call ToolCall) (*ToolExecution, error) - ResolveApproval(ctx context.Context, token string, approved bool) (*ToolExecution, error) -} -``` - -职责: - -- 查找工具 -- 安全检查 -- 审批 token 生成与恢复 -- 返回标准化工具执行结果 - -Todo 相关要求: - -- Flow 若要显式修改 Todo,必须通过 `todo` 工具进入 -- 不允许绕过工具执行器直接修改仓储 - -### ToolCallParser - -```go -type ToolCallParser interface { - ParseAssistantMessage(text string) (*ToolCall, bool, error) -} -``` - -职责: - -- 识别 assistant 输出中的工具调用 -- 当前承接 `json_v1` -- 未来可替换为 native function calling - -Todo 相关要求: - -- `todo` 工具协议解析只能在 parser 中演进 -- TUI 不再承担 assistant JSON 解析 - -### FlowPolicy - -```go -type FlowPolicy interface { - ShouldStop(state *FlowState) (bool, string) - ShouldFallback(err error, state *FlowState) (bool, FlowName) -} -``` - -职责: - -- 终止条件 -- planner 失败回退 -- 工具异常后的继续/中断决策 - -Todo 相关要求: - -- Todo 不能承担 Flow 是否继续执行的判定职责 -- Todo 只能提供上下文和状态,不替代 FlowPolicy - ---- - -## 6.2 Todo 领域接口 - -Todo 仍然属于领域服务,不属于 Flow 端口。 - -建议在不破坏现有契约的前提下,增强 [todo.go](/C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/domain/todo.go)。 - -### 领域模型 - -保持现有核心字段: - -```go -type Todo struct { - ID string `json:"id"` - Content string `json:"content"` - Status TodoStatus `json:"status"` - Priority TodoPriority `json:"priority"` -} -``` - -建议逐步新增可选字段: - -```go -type Todo struct { - ID string `json:"id"` - Content string `json:"content"` - Status TodoStatus `json:"status"` - Priority TodoPriority `json:"priority"` - Detail string `json:"detail,omitempty"` - Dependencies []string `json:"dependencies,omitempty"` - Source string `json:"source,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` -} -``` - -说明: - -- `content/status/priority` 继续保持外部兼容 -- 新字段只作为渐进增强,不影响当前工具协议的最小集 - -### TodoService - -保守演进版本: - -```go -type TodoService interface { - AddTodo(ctx context.Context, content string, priority TodoPriority) (*Todo, error) - UpdateTodoStatus(ctx context.Context, id string, status TodoStatus) error - ListTodos(ctx context.Context) ([]Todo, error) - ClearTodos(ctx context.Context) error - RemoveTodo(ctx context.Context, id string) error -} -``` - -增强建议: - -```go -type TodoService interface { - AddTodo(ctx context.Context, content string, priority TodoPriority) (*Todo, error) - UpdateTodoStatus(ctx context.Context, id string, status TodoStatus) error - UpdateTodo(ctx context.Context, todo Todo) (*Todo, error) - ListTodos(ctx context.Context) ([]Todo, error) - ListActiveTodos(ctx context.Context) ([]Todo, error) - ClearTodos(ctx context.Context) error - RemoveTodo(ctx context.Context, id string) error -} -``` - -注意: - -- 即使增强 `TodoService`,Flow 也不直接依赖它 -- Flow 只通过 `TurnContextAssembler` / `TurnFinalizer` / `FlowToolExecutor` 间接使用 Todo 能力 - -### TodoRepository - -```go -type TodoRepository interface { - Add(ctx context.Context, todo Todo) (*Todo, error) - UpdateStatus(ctx context.Context, id string, status TodoStatus) error - Update(ctx context.Context, todo Todo) (*Todo, error) - List(ctx context.Context) ([]Todo, error) - Clear(ctx context.Context) error - Remove(ctx context.Context, id string) error -} -``` - -第一阶段可以保留内存仓储,第二阶段增加文件仓储实现。 - ---- - -## 6.3 todo 工具协议 - -为了符合“外部契约保持兼容”的要求,保留当前工具名与主协议: - -```json -{ - "tool": "todo", - "params": { - "action": "add|update|list|remove|clear", - "content": "write tests", - "priority": "medium", - "id": "todo-1", - "status": "completed" - } -} -``` - -实施建议: - -- 阶段 A/B 不修改外部协议 -- 阶段 C 以后再考虑增加可选动作,如 `replace`、`advance` -- 即使新增动作,也必须保持老协议可用 - ---- - -## 7. 调用流程 - -## 7.1 `single_shot` 流程 - -```text -TUI -> flow engine(single_shot) - -> TurnContextAssembler - -> ChatProvider.Chat - -> TurnFinalizer - -> TUI 渲染最终回答 -``` - -Todo 参与方式: - -- 只通过 `TurnContextAssembler` 提供 Todo 摘要 -- 不进入工具循环 -- 可由 `TurnFinalizer` 做非常保守的收尾更新 - -适用场景: - -- 保持现状兼容 -- 作为新架构基线 - -## 7.2 `react` 流程 - -```text -TUI -> flow engine(react) - -> Assemble context - -> Call LLM - -> Parse tool call - -> FlowToolExecutor.Execute - -> 生成 ToolExecution 事件 - -> 继续下一轮 - -> TurnFinalizer -``` - -Todo 参与方式: - -- Todo 摘要由 `TurnContextAssembler` 注入 -- 模型若需显式修改 Todo,必须调用 `todo` 工具 -- 工具执行完成后,由 `TurnFinalizer` 做状态补充或归档 - -审批要求: - -- `FlowToolExecutor` 负责 token 化审批 -- TUI 只展示审批并发起 `/y` `/n` -- 审批恢复走 `ResolveApproval(...)` - -## 7.3 `plan_execute` 流程 - -```text -TUI -> flow engine(plan_execute) - -> planner 生成计划 - -> schema 校验 - -> 合法则进入执行阶段 - -> 执行阶段复用 react 的工具执行能力 - -> TurnFinalizer -``` - -Todo 参与方式: - -- planner 产生的计划可以映射为 Todo 列表 -- 计划写入不应直接发生在 `plan_execute.go` -- 应通过 `todo` 工具或 `TurnFinalizer` 的明确策略完成 - -回退要求: - -- planner schema 校验失败时,由 `FlowPolicy` 决定是否回退到 `react` - -## 7.4 TUI 手工 `/todo` 流程 - -```text -用户输入 /todo -> TUI 命令处理 -> ChatClient facade -> TodoService -> Repository -``` - -注意: - -- `/todo` 是用户交互入口,不是 Flow 主循环 -- TUI 可以保留 `/todo` 命令,但不能继续承担工具协议解析与本地工具闭环 -- `/todo` 与 agent 自动 `todo` 工具操作最终应落到同一 `TodoService` - ---- - -## 8. Todo 上下文策略 - -Todo 不能再像当前 [chat_service.go](/C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/service/chat_service.go#L117) 那样在主链路直接手写拼接。 - -建议由 `context_assembler.go` 统一生成: - -```text -[TODO] -In Progress: -- todo-3: implement parser - -Pending: -- todo-4: add tests -- todo-5: update docs - -Blocked: -- todo-6: waiting user decision -``` - -规则: - -- 优先显示 `in_progress` -- `pending` 最多显示前 3 到 5 项 -- `completed` 默认不注入 -- Todo 过多时只摘要,不全量塞入 prompt - -这样做的好处: - -- 减少上下文噪音 -- 保持 Flow 策略代码无 Todo 拼接细节 -- 后续 Todo 模型增强时只需改 `context_assembler.go` - ---- - -## 9. Todo 状态机 - -文档推荐对 `react / plan_execute` 使用轻量状态机;这里 Todo 自身也应有轻量状态迁移规则,但它是**领域状态机**,不是 Flow 状态机。 - -建议状态: - -- `pending` -- `in_progress` -- `completed` -- 第二阶段可增加 `blocked` - -建议迁移: - -```text -pending -> in_progress -in_progress -> completed -in_progress -> pending -blocked -> in_progress -pending -> cancelled (可选,后续阶段) -``` - -约束: - -- 状态迁移校验放在 `TodoService` 内部 -- TUI 不再自己定义“切一下状态就行” -- `todo` 工具更新状态时也走同一校验逻辑 - ---- - -## 10. 设计模式选择 - -## 10.1 应采用的模式 - -### Strategy - -用于三种 Flow: - -- `single_shot` -- `react` -- `plan_execute` - -Todo 不是 Strategy 主体,只是被策略消费的一种能力。 - -### Factory + Registry - -用于创建和切换 Flow: - -- 注册 `single_shot` -- 注册 `react` -- 注册 `plan_execute` - -Todo 不参与 Factory,只作为底层能力提供给适配器。 - -### Ports and Adapters - -这是 Todo 改造中最关键的约束: - -- Flow 不直接依赖 Todo 实现 -- Todo 能力通过 `TurnContextAssembler`、`TurnFinalizer`、`FlowToolExecutor` 间接进入 Flow - -### Lightweight State Machine - -用于: - -- `react` / `plan_execute` 的流程状态推进 -- TodoService 内部的状态迁移校验 - -### Policy Object - -用于: - -- Flow 终止条件 -- planner 失败回退 -- 工具审批恢复 -- 工具异常后继续/中断决策 - -## 10.2 不建议优先引入的模式 - -### Command - -不引入 `AddTodoCommand`、`UpdateTodoCommand` 这类完整 Command 模式体系。 - -原因: - -- 与 [flow-design.md](/C:/Users/10116/Desktop/study/gopro/neo-code/docs/flow-design.md) 的“少而准”原则不一致 -- 当前阶段会把简单编排进一步对象化,复杂度高于收益 - -### 重型规则树 / DSL - -不引入通用规则引擎,不做全局 DSL。 - -原因: - -- 当前规模不需要 -- 调试和维护成本过高 - ---- - -## 11. 分阶段实施 - -## 阶段 A:先建边界,不改核心实现 - -目标: - -- 建立 Flow 的稳定端口 -- 明确目录与依赖边界 - -工作项: - -- 新建 `domain/flow.go` -- 新建 `orchestration/flow/ports.go` -- 新建 `orchestration/flow/registry.go` -- 新建 `orchestration/flow/policy.go` - -Todo 相关要求: - -- 不重写 TodoService -- 不改工具协议 -- 不改 Memory 算法 -- 只为后续 Todo 接入预留合法位置 - -## 阶段 B:旧实现接入新适配层 - -目标: - -- 做到“旧实现,新边界” - -工作项: - -- `context_assembler.go` 桥接 role / working memory / todo / memory -- `turn_finalizer.go` 桥接 working memory 刷新、memory save、todo 补充更新 -- `tool_executor.go` 桥接当前工具注册表与安全检查 -- `tool_protocol_json.go` 承接当前 `json_v1` - -Todo 相关要求: - -- Todo 从 [chat_service.go](/C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/service/chat_service.go) 的直接拼接中移出 -- TUI 不再处理 assistant JSON tool call - -## 阶段 C:落地 `single_shot` 与 `react` - -目标: - -- 让 Flow engine 接管当前闭环 - -工作项: - -- 实现 `single_shot.go` -- 实现 `react.go` -- TUI 改为消费 Flow 事件 -- `/y` `/n` 改为调用审批恢复接口 - -Todo 相关要求: - -- `/todo` 保留为用户命令 -- agent 自动修改 Todo 通过 `todo` 工具执行 -- Todo 状态推进从 TUI 迁出,统一由 TodoService 校验 - -## 阶段 D:落地 `plan_execute` - -目标: - -- 在不破坏边界的前提下引入规划能力 - -工作项: - -- 实现 `plan_execute.go` -- planner schema 校验 -- fallback 策略 -- 执行阶段复用 `react` - -Todo 相关要求: - -- planner 输出可映射为 Todo -- 映射逻辑放在 `plan_execute` 配套适配器中,不直接侵入 Todo 仓储 - -## 阶段 E:增强 Todo 领域能力 - -前提: - -- Flow 已稳定运行在新架构中 -- Flow 合同测试已建立 - -工作项: - -- 引入文件型 Todo 仓储 -- 增加 `detail/dependencies/source/updated_at` -- 增强筛选、排序、归档 -- 视情况增加 `blocked` - -注意: - -- 这一阶段增强 Todo,但不修改 Flow 主循环 -- 若需要改 `react.go` 或 `plan_execute.go` 才能支持 Todo 新能力,说明边界设计失败 - ---- - -## 12. 测试策略 - -## 12.1 Flow 合同测试 - -用 fake 端口测试: - -- `TurnContextAssembler` -- `TurnFinalizer` -- `FlowToolExecutor` -- `ToolCallParser` - -验证: - -- 流程切换 -- 工具执行 -- 审批暂停与恢复 -- 终止与回退 - -## 12.2 Todo 领域测试 - -验证: - -- 状态迁移是否合法 -- 排序与筛选逻辑 -- 文件仓储与内存仓储的一致性 -- `/todo add` 多词内容场景 - -## 12.3 适配器测试 - -重点覆盖: - -- `context_assembler.go` -- `turn_finalizer.go` -- `tool_executor.go` -- `tool_protocol_json.go` - -验证 Todo 是否正确通过适配层进入 Flow,而非被 Flow 直接依赖。 - -## 12.4 TUI 回归测试 - -TUI 测试目标改为: - -- 接收 Flow 事件 -- 展示工具状态 -- 展示审批 -- 发起 `/y` `/n` -- 处理 `/todo` - -而不是继续测试“本地 JSON 解析 + 本地工具执行 + system message 回灌”。 - ---- - -## 13. 风险与优化建议 - -### 风险 1:Todo 继续演化成新的编排中心 - -规避: - -- 不新增 `PlanService` 作为 Flow 的直接依赖 -- 不让 `react.go` 或 `plan_execute.go` 直接 import Todo 实现细节 - -### 风险 2:TUI 迁移不彻底 - -规避: - -- 第一优先迁走 assistant JSON 解析 -- 第二优先迁走工具执行 -- 第三优先迁走审批恢复状态机 - -### 风险 3:Todo 增强过早,拖慢 Flow 重构 - -规避: - -- 阶段 A/B 只做边界 -- Todo 富模型增强放到阶段 E - -### 风险 4:外部协议破坏兼容 - -规避: - -- 保留 `todo` 工具名 -- 保留 `json_v1` -- 保留 `add/update/list/remove/clear` - ---- - -## 14. 最终建议 - -建议团队采用以下方向: - -- 先按 [flow-design.md](/C:/Users/10116/Desktop/study/gopro/neo-code/docs/flow-design.md) 建立 `Flow engine + Ports + Registry + Policy` -- Todo 不升级为新的流程中心,而是保留为领域能力 -- Todo 与 Flow 的连接统一收敛到 `internal/server/orchestration/flow/` -- TUI 只做渲染与交互,不做编排 -- 在 Flow 稳定后,再逐步增强 Todo 的模型、持久化和规划表达力 - -一句话概括: - -**先让 Todo 成为“被 Flow 正确消费的能力”,再让它变强;不要让 Todo 在 Flow 稳定之前抢走编排中心的位置。** diff --git a/internal/tui/app.go b/internal/tui/app.go index 3f8a7538..1adc41ab 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -6,7 +6,7 @@ import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -26,7 +26,7 @@ type App struct { sessions list.Model modelPicker list.Model transcript viewport.Model - input textarea.Model + input textinput.Model activeMessages []provider.Message focus panel width int @@ -48,19 +48,25 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime delegate := sessionDelegate{styles: uiStyles} sessionList := list.New([]list.Item{}, delegate, 0, 0) sessionList.Title = "" + sessionList.SetShowTitle(false) sessionList.SetShowHelp(false) sessionList.SetShowStatusBar(false) + sessionList.SetShowFilter(false) + sessionList.SetShowPagination(false) sessionList.SetFilteringEnabled(true) sessionList.DisableQuitKeybindings() + sessionList.FilterInput.Prompt = "筛选:" + sessionList.FilterInput.Placeholder = "输入关键词…" - input := textarea.New() + input := textinput.New() input.Placeholder = "Ask NeoCode to inspect, edit, or build. Type / to browse commands." - input.Prompt = "> " + input.Prompt = "" input.CharLimit = 24000 - input.ShowLineNumbers = false - input.SetHeight(1) + input.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)) + input.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorText)) + input.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorSubtle)) + input.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)) input.Focus() - input.KeyMap.InsertNewline.SetEnabled(false) spin := spinner.New() spin.Spinner = spinner.Line @@ -110,5 +116,5 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime } func (a App) Init() tea.Cmd { - return tea.Batch(ListenForRuntimeEvent(a.runtime.Events()), textarea.Blink, a.spinner.Tick) + return tea.Batch(ListenForRuntimeEvent(a.runtime.Events()), textinput.Blink, a.spinner.Tick) } diff --git a/internal/tui/commands.go b/internal/tui/commands.go index c7f176f1..fc5d3305 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -22,12 +22,12 @@ const ( slashUsageModel = "/model" commandMenuTitle = "Commands" - composerHintText = "Enter send | /set url | /set key | /model | Tab switch panels" modelPickerTitle = "Select Model" modelPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" - sidebarTitle = "Sessions" - sidebarSubtitle = "Use / to filter, Enter to open" + sidebarTitle = "Sessions" + sidebarFilterHint = "按 \"/\" 筛选" + sidebarOpenHint = "Enter 打开" draftSessionTitle = "Draft" emptyConversationText = "No conversation yet.\nAsk NeoCode to inspect or change code, or type / to browse local commands." diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go index afe04047..c115f686 100644 --- a/internal/tui/keymap.go +++ b/internal/tui/keymap.go @@ -23,59 +23,59 @@ func newKeyMap() keyMap { return keyMap{ Send: key.NewBinding( key.WithKeys("enter", "ctrl+s"), - key.WithHelp("enter", "send"), + key.WithHelp("Enter/Ctrl+S", "发送(输入框)"), ), NewSession: key.NewBinding( key.WithKeys("ctrl+n"), - key.WithHelp("ctrl+n", "new"), + key.WithHelp("Ctrl+N", "新会话"), ), NextPanel: key.NewBinding( key.WithKeys("tab"), - key.WithHelp("tab", "next panel"), + key.WithHelp("Tab", "下个面板"), ), PrevPanel: key.NewBinding( key.WithKeys("shift+tab"), - key.WithHelp("shift+tab", "prev panel"), + key.WithHelp("Shift+Tab", "上个面板"), ), FocusInput: key.NewBinding( key.WithKeys("esc"), - key.WithHelp("esc", "focus input"), + key.WithHelp("Esc", "聚焦输入框"), ), OpenSession: key.NewBinding( key.WithKeys("enter"), - key.WithHelp("enter", "open session"), + key.WithHelp("Enter", "打开会话(会话列表)"), ), ToggleHelp: key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "help"), + key.WithKeys("ctrl+q"), + key.WithHelp("Ctrl+Q", "帮助"), ), Quit: key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), + key.WithKeys("ctrl+u"), + key.WithHelp("Ctrl+U", "退出"), ), ScrollUp: key.NewBinding( key.WithKeys("up", "k"), - key.WithHelp("up/k", "up"), + key.WithHelp("↑/k", "向上滚动"), ), ScrollDown: key.NewBinding( key.WithKeys("down", "j"), - key.WithHelp("down/j", "down"), + key.WithHelp("↓/j", "向下滚动"), ), PageUp: key.NewBinding( key.WithKeys("pgup", "b"), - key.WithHelp("pgup", "page up"), + key.WithHelp("PgUp/b", "向上翻页"), ), PageDown: key.NewBinding( key.WithKeys("pgdown", "f"), - key.WithHelp("pgdn", "page down"), + key.WithHelp("PgDn/f", "向下翻页"), ), Top: key.NewBinding( key.WithKeys("g", "home"), - key.WithHelp("g", "top"), + key.WithHelp("g/Home", "跳到顶部"), ), Bottom: key.NewBinding( key.WithKeys("G", "end"), - key.WithHelp("G", "bottom"), + key.WithHelp("G/End", "跳到底部"), ), } } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index ccbd99e1..199de3d8 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -56,8 +56,10 @@ type styles struct { commandUsage lipgloss.Style commandUsageMatch lipgloss.Style commandDesc lipgloss.Style - inputMeta lipgloss.Style + inputPrefix lipgloss.Style inputLine lipgloss.Style + inputBox lipgloss.Style + inputBoxFocused lipgloss.Style footer lipgloss.Style badgeUser lipgloss.Style badgeAgent lipgloss.Style @@ -71,12 +73,12 @@ func newStyles() styles { panel := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(colorBorder)). - Background(lipgloss.Color(colorPanel)). + Background(lipgloss.Color(colorBg)). Padding(0, 1) return styles{ doc: lipgloss.NewStyle(). - Padding(1, 2). + Padding(1, 2, 0, 2). Background(lipgloss.Color(colorBg)). Foreground(lipgloss.Color(colorText)), headerBrand: lipgloss.NewStyle(). @@ -99,7 +101,8 @@ func newStyles() styles { panelSubtitle: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)), panelBody: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorText)), + Foreground(lipgloss.Color(colorText)). + Background(lipgloss.Color(colorBg)), empty: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)). Padding(1, 0), @@ -123,19 +126,20 @@ func newStyles() styles { streamMeta: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)), streamContent: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorText)), + Foreground(lipgloss.Color(colorText)). + Background(lipgloss.Color(colorBg)), messageUserTag: tagStyle(colorUser, colorInk), messageAgentTag: tagStyle(colorPrimary, colorInk), messageToolTag: tagStyle(colorSuccess, colorInk), messageBody: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorText)). - PaddingLeft(1), + Padding(0, 1), messageUserBody: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorText)). - PaddingLeft(1), + Padding(0, 1), messageToolBody: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSuccess)). - PaddingLeft(1), + Padding(0, 1), inlineNotice: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)). Italic(true), @@ -154,7 +158,7 @@ func newStyles() styles { codeText: lipgloss.NewStyle(). Foreground(lipgloss.Color("#BAC2DE")), commandMenu: lipgloss.NewStyle(). - Background(lipgloss.Color(colorCode)). + Background(lipgloss.Color(colorBg)). Padding(1, 1), commandMenuTitle: lipgloss.NewStyle(). Bold(true). @@ -166,13 +170,24 @@ func newStyles() styles { Foreground(lipgloss.Color(colorPrimary)), commandDesc: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)), - inputMeta: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorSubtle)), + inputPrefix: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorUser)). + Bold(true), inputLine: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorText)), + inputBox: lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(colorBorder)). + Background(lipgloss.Color(colorBg)). + Padding(0, 1), + inputBoxFocused: lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(colorUser)). + Background(lipgloss.Color(colorBg)). + Padding(0, 1), footer: lipgloss.NewStyle(). - PaddingTop(1). - Foreground(lipgloss.Color(colorSubtle)), + Foreground(lipgloss.Color(colorSubtle)). + Background(lipgloss.Color(colorBg)), badgeUser: badge(colorUser, colorInk), badgeAgent: badge(colorPrimary, colorInk), badgeSuccess: badge(colorSuccess, colorInk), diff --git a/internal/tui/update.go b/internal/tui/update.go index 3a4e0c51..a187751b 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/dust/neo-code/internal/config" "github.com/dust/neo-code/internal/provider" @@ -122,7 +123,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch a.focus { case panelSessions: - if key.Matches(typed, a.keys.OpenSession) && !a.isFilteringSessions() { + if key.Matches(typed, a.keys.OpenSession) && !a.sessions.SettingFilter() { if err := a.activateSelectedSession(); err != nil { a.state.StatusText = err.Error() a.state.ExecutionError = err.Error() @@ -135,6 +136,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } var cmd tea.Cmd a.sessions, cmd = a.sessions.Update(msg) + a.sessions.SetShowFilter(a.sessions.FilterState() != list.Unfiltered) cmds = append(cmds, cmd) return a, tea.Batch(cmds...) case panelTranscript: @@ -446,12 +448,17 @@ func (a *App) applyFocus() { func (a *App) resizeComponents() { lay := a.computeLayout() a.help.ShowAll = a.state.ShowHelp - a.sessions.SetSize(max(20, lay.sidebarWidth-4), max(4, lay.sidebarHeight-4)) + sidebarFrameWidth := a.styles.panelFocused.GetHorizontalFrameSize() + sidebarFrameHeight := a.styles.panelFocused.GetVerticalFrameSize() + sidebarBodyWidth := max(14, lay.sidebarWidth-sidebarFrameWidth) + sidebarBodyHeight := max(4, lay.sidebarHeight-sidebarFrameHeight-lipgloss.Height(a.renderSidebarHeader(sidebarBodyWidth))) + a.sessions.SetSize(sidebarBodyWidth, sidebarBodyHeight) menuHeight := a.commandMenuHeight(max(24, lay.rightWidth)) a.transcript.Width = max(24, lay.rightWidth) - a.transcript.Height = max(6, lay.rightHeight-2-menuHeight-1) - a.input.SetWidth(max(24, lay.rightWidth-2)) - a.input.SetHeight(1) + promptInnerWidth := max(8, lay.rightWidth-a.styles.inputBoxFocused.GetHorizontalFrameSize()) + a.input.Width = max(4, promptInnerWidth-lipgloss.Width("> ")) + promptHeight := lipgloss.Height(a.renderPrompt(a.transcript.Width)) + a.transcript.Height = max(6, lay.rightHeight-menuHeight-promptHeight) a.modelPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) a.rebuildTranscript() } diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 74596fe1..ec2defc5 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/dust/neo-code/internal/config" "github.com/dust/neo-code/internal/provider" @@ -299,10 +300,18 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { app.syncConfigState(manager.Get()) app.rebuildTranscript() - if app.View() == "" { + view := app.View() + if view == "" { t.Fatalf("expected non-empty View()") } - if app.renderHeader() == "" || app.renderBody(app.computeLayout()) == "" { + if lipgloss.Height(view) > app.height+1 { + t.Fatalf("expected view height to stay within window bounds, got %d", lipgloss.Height(view)) + } + lines := strings.Split(strings.TrimRight(view, "\n"), "\n") + if len(lines) == 0 || !strings.Contains(lines[len(lines)-1], "Ctrl+U") { + t.Fatalf("expected footer help to render on the last visible line") + } + if app.renderHeader(app.computeLayout().contentWidth) == "" || app.renderBody(app.computeLayout()) == "" { t.Fatalf("expected non-empty render output") } app.state.ShowModelPicker = true @@ -320,6 +329,22 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if app.renderPrompt(80) == "" || app.renderHelp(80) == "" { t.Fatalf("expected prompt and help output") } + if lipgloss.Width(app.renderPrompt(80)) != 80 { + t.Fatalf("expected prompt width 80, got %d", lipgloss.Width(app.renderPrompt(80))) + } + sidebar := app.renderSidebar(26, 12) + if lipgloss.Width(sidebar) != 26 || lipgloss.Height(sidebar) != 12 { + t.Fatalf("expected sidebar to respect requested dimensions, got %dx%d", lipgloss.Width(sidebar), lipgloss.Height(sidebar)) + } + if !strings.Contains(app.renderSidebar(26, 12), sidebarTitle) || !strings.Contains(app.renderSidebar(26, 12), sidebarOpenHint) { + t.Fatalf("expected updated sidebar header text") + } + if strings.Contains(app.renderPrompt(80), "Enter/Ctrl+S") { + t.Fatalf("expected composer hint line to be removed") + } + if strings.TrimSpace(app.renderPrompt(80)) == "" { + t.Fatalf("expected prompt to render a visible border") + } if app.focusLabel() == "" || app.statusBadge("ready") == "" { t.Fatalf("expected status helpers to render") } @@ -362,6 +387,9 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if !app.isFilteringSessions() { t.Fatalf("expected filtering state") } + if app.sessions.ShowPagination() { + t.Fatalf("expected sessions pagination to stay hidden") + } } func TestTUIStandaloneHelpers(t *testing.T) { @@ -504,7 +532,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, { name: "toggle help flips state", - msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}, + msg: tea.KeyMsg{Type: tea.KeyCtrlQ}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() if !app.state.ShowHelp || !app.help.ShowAll { @@ -627,7 +655,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, { name: "quit returns quit command", - msg: tea.KeyMsg{Type: tea.KeyCtrlC}, + msg: tea.KeyMsg{Type: tea.KeyCtrlU}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() foundQuit := false diff --git a/internal/tui/view.go b/internal/tui/view.go index d7490ba9..ec001494 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1,7 +1,6 @@ package tui import ( - "fmt" "strings" "github.com/charmbracelet/bubbles/list" @@ -18,21 +17,32 @@ type layout struct { sidebarHeight int rightWidth int rightHeight int + bodyGap int } func (a App) View() string { - if a.width < 84 || a.height < 24 { - return a.styles.doc.Render("Window too small.\nPlease resize to at least 84x24.") + docWidth := max(0, a.width-a.styles.doc.GetHorizontalFrameSize()) + docHeight := max(0, a.height-a.styles.doc.GetVerticalFrameSize()) + if docWidth < 84 || docHeight < 24 { + return strings.TrimRight(a.styles.doc.Render(lipgloss.Place(docWidth, docHeight, lipgloss.Left, lipgloss.Top, "Window too small.\nPlease resize to at least 84x24.")), "\n") } lay := a.computeLayout() - header := a.renderHeader() + header := a.renderHeader(lay.contentWidth) body := a.renderBody(lay) helpView := a.renderHelp(lay.contentWidth) - return a.styles.doc.Render(lipgloss.JoinVertical(lipgloss.Left, header, body, helpView)) + usedHeight := lipgloss.Height(header) + lipgloss.Height(body) + lipgloss.Height(helpView) + spacerHeight := max(0, docHeight-usedHeight) + parts := []string{header, body} + if spacerHeight > 0 { + parts = append(parts, lipgloss.NewStyle().Height(spacerHeight).Background(lipgloss.Color(colorBg)).Render("")) + } + parts = append(parts, helpView) + content := lipgloss.JoinVertical(lipgloss.Left, parts...) + return strings.TrimRight(a.styles.doc.Render(lipgloss.Place(docWidth, docHeight, lipgloss.Left, lipgloss.Top, content)), "\n") } -func (a App) renderHeader() string { +func (a App) renderHeader(width int) string { status := a.state.StatusText if a.state.IsAgentRunning { status = a.spinner.View() + " " + fallback(status, statusRunning) @@ -41,28 +51,28 @@ func (a App) renderHeader() string { brand := lipgloss.JoinHorizontal( lipgloss.Center, a.styles.headerBrand.Render("NeoCode"), - a.styles.headerSpacer.Render(""), - a.styles.headerSub.Render("immersive coding agent"), ) meta := lipgloss.JoinHorizontal( lipgloss.Top, - a.styles.badgeAgent.Render("Provider "+a.state.CurrentProvider), - a.styles.badgeUser.Render("Model "+a.state.CurrentModel), - a.styles.badgeMuted.Render("Focus "+a.focusLabel()), + a.styles.badgeAgent.Render(a.state.CurrentProvider), + a.styles.badgeUser.Render(a.state.CurrentModel), + a.styles.badgeMuted.Render(a.focusLabel()), a.statusBadge(status), ) - return lipgloss.JoinVertical( - lipgloss.Left, + spacerWidth := lipgloss.Width(a.styles.headerSpacer.Render("")) + workdirWidth := max(12, width-lipgloss.Width(brand)-lipgloss.Width(meta)-(spacerWidth*2)-lipgloss.Width("Workdir ")) + + header := lipgloss.JoinHorizontal( + lipgloss.Center, brand, - lipgloss.JoinHorizontal( - lipgloss.Top, - a.styles.headerMeta.Render("Workdir "+trimMiddle(a.state.CurrentWorkdir, max(28, a.width/3))), - a.styles.headerSpacer.Render(""), - meta, - ), + a.styles.headerSpacer.Render(""), + a.styles.headerMeta.Render("Workdir "+trimMiddle(a.state.CurrentWorkdir, workdirWidth)), + a.styles.headerSpacer.Render(""), + meta, ) + return lipgloss.Place(width, 1, lipgloss.Left, lipgloss.Top, header) } func (a App) renderBody(lay layout) string { @@ -71,11 +81,32 @@ func (a App) renderBody(lay layout) string { if lay.stacked { return lipgloss.JoinVertical(lipgloss.Left, sidebar, stream) } - return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, stream) + return lipgloss.JoinHorizontal( + lipgloss.Top, + sidebar, + lipgloss.NewStyle().Width(lay.bodyGap).Background(lipgloss.Color(colorBg)).Render(""), + stream, + ) } func (a App) renderSidebar(width int, height int) string { - return a.renderPanel(sidebarTitle, sidebarSubtitle, a.sessions.View(), width, height, a.focus == panelSessions) + style := a.styles.panel + if a.focus == panelSessions { + style = a.styles.panelFocused + } + + frameHeight := style.GetVerticalFrameSize() + borderWidth := 2 + paddingWidth := style.GetHorizontalFrameSize() - borderWidth + panelWidth := max(1, width-borderWidth) + bodyWidth := max(10, panelWidth-paddingWidth) + header := a.renderSidebarHeader(bodyWidth) + bodyHeight := max(3, height-frameHeight-lipgloss.Height(header)) + a.sessions.SetSize(bodyWidth, bodyHeight) + body := a.styles.panelBody.Width(bodyWidth).Height(bodyHeight).Render(a.sessions.View()) + + panel := style.Width(panelWidth).Height(max(1, height-frameHeight)).Render(lipgloss.JoinVertical(lipgloss.Left, header, body)) + return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, panel) } func (a App) renderWaterfall(width int, height int) string { @@ -89,82 +120,68 @@ func (a App) renderWaterfall(width int, height int) string { ) } - header := lipgloss.JoinHorizontal( - lipgloss.Top, - a.styles.streamTitle.Render(fallback(a.state.ActiveSessionTitle, draftSessionTitle)), - a.styles.headerSpacer.Render(""), - a.styles.streamMeta.Render(fmt.Sprintf("%d messages", len(a.activeMessages))), - ) - subline := lipgloss.JoinHorizontal( - lipgloss.Top, - a.styles.streamMeta.Render("Active model "+a.state.CurrentModel), - a.styles.headerSpacer.Render(""), - a.styles.streamMeta.Render(fallback(a.state.CurrentTool, a.state.StatusText)), - ) transcript := a.styles.streamContent.Width(width).Height(a.transcript.Height).Render(a.transcript.View()) - parts := []string{header, subline, transcript} + parts := []string{transcript} if menu := a.renderCommandMenu(width); menu != "" { parts = append(parts, menu) } parts = append(parts, a.renderPrompt(width)) - return lipgloss.NewStyle().Width(width).Height(height).Render(lipgloss.JoinVertical(lipgloss.Left, parts...)) + return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, lipgloss.JoinVertical(lipgloss.Left, parts...)) } func (a App) renderModelPicker(width int, height int) string { + frameHeight := a.styles.panelFocused.GetVerticalFrameSize() content := lipgloss.JoinVertical( lipgloss.Left, a.styles.panelTitle.Render(modelPickerTitle), a.styles.panelSubtitle.Render(modelPickerSubtitle), a.modelPicker.View(), ) - return a.styles.panelFocused.Width(width).Height(height).Render(content) + panel := a.styles.panelFocused. + Width(max(1, width-2)). + Height(max(1, height-frameHeight)). + Render(content) + return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, panel) } func (a App) renderPrompt(width int) string { - return lipgloss.JoinVertical( - lipgloss.Left, - a.styles.inputMeta.Render(composerHintText), - a.styles.inputLine.Width(width).Render(a.input.View()), - ) -} - -func (a App) renderCommandMenu(width int) string { - suggestions := a.matchingSlashCommands(strings.TrimSpace(a.input.Value())) - if len(suggestions) == 0 { - return "" + box := a.styles.inputBox + if a.focus == panelInput && !a.state.ShowModelPicker { + box = a.styles.inputBoxFocused } - lines := make([]string, 0, len(suggestions)+1) - lines = append(lines, a.styles.commandMenuTitle.Render(commandMenuTitle)) - for _, suggestion := range suggestions { - usageStyle := a.styles.commandUsage - if suggestion.Match { - usageStyle = a.styles.commandUsageMatch - } - lines = append(lines, lipgloss.JoinHorizontal( - lipgloss.Top, - usageStyle.Render(suggestion.Command.Usage), - lipgloss.NewStyle().Width(2).Render(""), - a.styles.commandDesc.Render(suggestion.Command.Description), - )) - } + // 计算边框和内边距占用的空间 + boxWidth := max(8, width-2) - return a.styles.commandMenu.Width(width).Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) -} + prefix := a.styles.inputPrefix.Render(">") + inputContent := a.input.View() -func (a App) commandMenuHeight(width int) int { - menu := a.renderCommandMenu(width) - if strings.TrimSpace(menu) == "" { - return 0 - } - return lipgloss.Height(menu) + content := lipgloss.JoinHorizontal( + lipgloss.Left, + prefix, + lipgloss.NewStyle().Width(1).Render(" "), + a.styles.inputLine.Width(max(4, boxWidth-2-lipgloss.Width(prefix)-1)).Render(inputContent), + ) + return box.Width(boxWidth).Render(content) } -func (a App) renderHelp(width int) string { - a.help.ShowAll = a.state.ShowHelp - return a.styles.footer.Width(width).Render(a.help.View(a.keys)) +func (a App) renderSidebarHeader(width int) string { + title := a.styles.panelTitle.Render(sidebarTitle) + filterWidth := max(6, width-lipgloss.Width(title)-1) + titleRow := lipgloss.JoinHorizontal( + lipgloss.Center, + title, + lipgloss.NewStyle().Width(1).Render(""), + a.styles.panelSubtitle.Render(trimRunes(sidebarFilterHint, filterWidth)), + ) + openRow := a.styles.panelSubtitle.Render(trimRunes(sidebarOpenHint, width)) + return lipgloss.JoinVertical( + lipgloss.Left, + lipgloss.Place(width, 1, lipgloss.Left, lipgloss.Top, titleRow), + lipgloss.Place(width, 1, lipgloss.Left, lipgloss.Top, openRow), + ) } func (a App) renderPanel(title string, subtitle string, body string, width int, height int, focused bool) string { @@ -173,15 +190,21 @@ func (a App) renderPanel(title string, subtitle string, body string, width int, style = a.styles.panelFocused } + frameHeight := style.GetVerticalFrameSize() + borderWidth := 2 + paddingWidth := style.GetHorizontalFrameSize() - borderWidth header := lipgloss.JoinHorizontal( lipgloss.Center, a.styles.panelTitle.Render(title), lipgloss.NewStyle().Width(2).Render(""), a.styles.panelSubtitle.Render(subtitle), ) - bodyHeight := max(3, height-lipgloss.Height(header)-2) - panelBody := a.styles.panelBody.Width(max(10, width-4)).Height(bodyHeight).Render(body) - return style.Width(width).Height(height).Render(lipgloss.JoinVertical(lipgloss.Left, header, panelBody)) + panelWidth := max(1, width-borderWidth) + bodyWidth := max(10, panelWidth-paddingWidth) + bodyHeight := max(3, height-frameHeight-lipgloss.Height(header)) + panelBody := a.styles.panelBody.Width(bodyWidth).Height(bodyHeight).Render(body) + panel := style.Width(panelWidth).Height(max(1, height-frameHeight)).Render(lipgloss.JoinVertical(lipgloss.Left, header, panelBody)) + return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, panel) } func (a App) renderMessageBlock(message provider.Message, width int) string { @@ -194,16 +217,19 @@ func (a App) renderMessageBlock(message provider.Message, width int) string { return a.styles.inlineSystem.Width(width).Render(" - " + wrapPlain(message.Content, max(16, width-6))) } - maxMessageWidth := clamp(width, 24, max(24, int(float64(width)*0.92))) + maxMessageWidth := clamp(int(float64(width)*0.84), 24, width) tag := messageTagAgent tagStyle := a.styles.messageAgentTag bodyStyle := a.styles.messageBody + blockAlign := lipgloss.Left switch message.Role { case roleUser: + maxMessageWidth = clamp(int(float64(width)*0.68), 24, width) tag = messageTagUser tagStyle = a.styles.messageUserTag bodyStyle = a.styles.messageUserBody + blockAlign = lipgloss.Right case roleTool: tag = messageTagTool tagStyle = a.styles.messageToolTag @@ -222,17 +248,66 @@ func (a App) renderMessageBlock(message provider.Message, width int) string { content = emptyMessageText } - return lipgloss.JoinVertical( - lipgloss.Left, + contentBlock := a.renderMessageContent(content, maxMessageWidth-2, bodyStyle) + if message.Role == roleUser { + contentBlock = lipgloss.PlaceHorizontal(maxMessageWidth, lipgloss.Right, contentBlock) + } + + block := lipgloss.JoinVertical( + blockAlign, tagStyle.Render(tag), - a.renderMessageContent(content, maxMessageWidth-2, bodyStyle), + contentBlock, ) + + if message.Role == roleUser { + return lipgloss.PlaceHorizontal(width, lipgloss.Right, block) + } + return block +} + +func (a App) renderCommandMenu(width int) string { + suggestions := a.matchingSlashCommands(strings.TrimSpace(a.input.Value())) + if len(suggestions) == 0 { + return "" + } + + lines := make([]string, 0, len(suggestions)+1) + lines = append(lines, a.styles.commandMenuTitle.Render(commandMenuTitle)) + for _, suggestion := range suggestions { + usageStyle := a.styles.commandUsage + if suggestion.Match { + usageStyle = a.styles.commandUsageMatch + } + lines = append(lines, lipgloss.JoinHorizontal( + lipgloss.Top, + usageStyle.Render(suggestion.Command.Usage), + lipgloss.NewStyle().Width(2).Render(""), + a.styles.commandDesc.Render(suggestion.Command.Description), + )) + } + + return a.styles.commandMenu.Width(width).Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) +} + +func (a App) commandMenuHeight(width int) int { + menu := a.renderCommandMenu(width) + if strings.TrimSpace(menu) == "" { + return 0 + } + return lipgloss.Height(menu) +} + +func (a App) renderHelp(width int) string { + a.help.ShowAll = a.state.ShowHelp + helpContent := a.help.View(a.keys) + // 确保帮助视图填充整个宽度,避免边框断裂 + return a.styles.footer.Width(width).Render(helpContent) } func (a App) renderMessageContent(content string, width int, bodyStyle lipgloss.Style) string { parts := strings.Split(content, "```") if len(parts) == 1 { - return bodyStyle.Width(width).Render(wrapPlain(content, max(16, width-2))) + return bodyStyle.Render(wrapPlain(content, max(16, width-2))) } blocks := make([]string, 0, len(parts)) @@ -242,7 +317,7 @@ func (a App) renderMessageContent(content string, width int, bodyStyle lipgloss. if trimmed == "" { continue } - blocks = append(blocks, bodyStyle.Width(width).Render(wrapPlain(trimmed, max(16, width-2)))) + blocks = append(blocks, bodyStyle.Render(wrapPlain(trimmed, max(16, width-2)))) continue } @@ -255,7 +330,7 @@ func (a App) renderMessageContent(content string, width int, bodyStyle lipgloss. } if len(blocks) == 0 { - return bodyStyle.Width(width).Render(emptyMessageText) + return bodyStyle.Render(emptyMessageText) } return lipgloss.JoinVertical(lipgloss.Left, blocks...) @@ -285,13 +360,10 @@ func (a App) focusLabel() string { } func (a App) computeLayout() layout { - contentWidth := max(80, a.width-4) - helpHeight := 2 - if a.state.ShowHelp { - helpHeight = 6 - } - - contentHeight := max(18, a.height-7-helpHeight) + contentWidth := max(0, a.width-a.styles.doc.GetHorizontalFrameSize()) + headerHeight := lipgloss.Height(a.renderHeader(contentWidth)) + helpHeight := lipgloss.Height(a.renderHelp(contentWidth)) + contentHeight := max(1, a.height-a.styles.doc.GetVerticalFrameSize()-headerHeight-helpHeight) lay := layout{contentWidth: contentWidth, contentHeight: contentHeight} if contentWidth < 110 { lay.stacked = true @@ -302,9 +374,10 @@ func (a App) computeLayout() layout { return lay } - lay.sidebarWidth = 30 + lay.bodyGap = 1 + lay.sidebarWidth = 22 lay.sidebarHeight = contentHeight - lay.rightWidth = contentWidth - lay.sidebarWidth + lay.rightWidth = max(24, contentWidth-lay.sidebarWidth-lay.bodyGap) lay.rightHeight = contentHeight return lay } From 262b069f32fe8865562ca8409e49ba4036d0922e Mon Sep 17 00:00:00 2001 From: creatang Date: Sat, 28 Mar 2026 12:52:00 +0800 Subject: [PATCH 03/15] feat(provider): narrow MVP provider support surface --- internal/provider/anthropic/provider.go | 14 ++- internal/provider/anthropic/provider_test.go | 7 +- internal/provider/gemini/provider.go | 14 ++- internal/provider/gemini/provider_test.go | 7 +- internal/provider/openai/openai.go | 25 +++++ internal/provider/openai/openai_test.go | 26 +++++ internal/provider/registry.go | 38 +++++++ internal/provider/registry_test.go | 65 +++++++++++ internal/provider/types.go | 108 +++++++++++++++++++ 9 files changed, 298 insertions(+), 6 deletions(-) diff --git a/internal/provider/anthropic/provider.go b/internal/provider/anthropic/provider.go index d45e0f4e..1513fd75 100644 --- a/internal/provider/anthropic/provider.go +++ b/internal/provider/anthropic/provider.go @@ -2,7 +2,6 @@ package anthropic import ( "context" - "errors" "github.com/dust/neo-code/internal/config" "github.com/dust/neo-code/internal/provider" @@ -20,6 +19,17 @@ func (p *Provider) Name() string { return p.cfg.Name } +func (p *Provider) Descriptor() provider.ProviderDescriptor { + return provider.ProviderDescriptor{ + Name: p.cfg.Name, + DisplayName: "Anthropic", + SupportLevel: provider.SupportLevelScaffolded, + MVPVisible: false, + Available: false, + Summary: "Scaffolded only. Not officially available in the current MVP.", + } +} + func (p *Provider) Chat(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) (provider.ChatResponse, error) { - return provider.ChatResponse{}, errors.New("anthropic provider is scaffolded but not implemented in this MVP") + return provider.ChatResponse{}, provider.ScaffoldedProviderError(p.Name()) } diff --git a/internal/provider/anthropic/provider_test.go b/internal/provider/anthropic/provider_test.go index cf5ca5ca..3c01c6e5 100644 --- a/internal/provider/anthropic/provider_test.go +++ b/internal/provider/anthropic/provider_test.go @@ -18,8 +18,13 @@ func TestProviderScaffold(t *testing.T) { t.Fatalf("expected provider name %q, got %q", config.ProviderAnthropic, p.Name()) } + desc := p.Descriptor() + if desc.SupportLevel != provider.SupportLevelScaffolded || desc.Available || desc.MVPVisible { + t.Fatalf("unexpected descriptor: %+v", desc) + } + _, err := p.Chat(context.Background(), provider.ChatRequest{}, make(chan provider.StreamEvent)) - if err == nil || !strings.Contains(err.Error(), "not implemented") { + if err == nil || !strings.Contains(err.Error(), "only OpenAI-compatible provider is officially supported") { t.Fatalf("expected scaffold error, got %v", err) } } diff --git a/internal/provider/gemini/provider.go b/internal/provider/gemini/provider.go index 90d3349e..74d76d49 100644 --- a/internal/provider/gemini/provider.go +++ b/internal/provider/gemini/provider.go @@ -2,7 +2,6 @@ package gemini import ( "context" - "errors" "github.com/dust/neo-code/internal/config" "github.com/dust/neo-code/internal/provider" @@ -20,6 +19,17 @@ func (p *Provider) Name() string { return p.cfg.Name } +func (p *Provider) Descriptor() provider.ProviderDescriptor { + return provider.ProviderDescriptor{ + Name: p.cfg.Name, + DisplayName: "Gemini", + SupportLevel: provider.SupportLevelScaffolded, + MVPVisible: false, + Available: false, + Summary: "Scaffolded only. Not officially available in the current MVP.", + } +} + func (p *Provider) Chat(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) (provider.ChatResponse, error) { - return provider.ChatResponse{}, errors.New("gemini provider is scaffolded but not implemented in this MVP") + return provider.ChatResponse{}, provider.ScaffoldedProviderError(p.Name()) } diff --git a/internal/provider/gemini/provider_test.go b/internal/provider/gemini/provider_test.go index 40ff8adf..cf512496 100644 --- a/internal/provider/gemini/provider_test.go +++ b/internal/provider/gemini/provider_test.go @@ -18,8 +18,13 @@ func TestProviderScaffold(t *testing.T) { t.Fatalf("expected provider name %q, got %q", config.ProviderGemini, p.Name()) } + desc := p.Descriptor() + if desc.SupportLevel != provider.SupportLevelScaffolded || desc.Available || desc.MVPVisible { + t.Fatalf("unexpected descriptor: %+v", desc) + } + _, err := p.Chat(context.Background(), provider.ChatRequest{}, make(chan provider.StreamEvent)) - if err == nil || !strings.Contains(err.Error(), "not implemented") { + if err == nil || !strings.Contains(err.Error(), "only OpenAI-compatible provider is officially supported") { t.Fatalf("expected scaffold error, got %v", err) } } diff --git a/internal/provider/openai/openai.go b/internal/provider/openai/openai.go index 07b64b28..007dd934 100644 --- a/internal/provider/openai/openai.go +++ b/internal/provider/openai/openai.go @@ -39,6 +39,31 @@ func (p *Provider) Name() string { return p.cfg.Name } +func (p *Provider) Descriptor() domain.ProviderDescriptor { + models := []domain.ModelOption{ + {Name: config.DefaultOpenAIModel, Description: "Stable OpenAI-compatible default model"}, + {Name: "gpt-4o", Description: "Fast general-purpose OpenAI-compatible model"}, + {Name: "gpt-5.3-codex", Description: "Code-focused OpenAI-compatible model"}, + {Name: "gpt-5.4", Description: "Frontier reasoning and coding model"}, + } + if configured := strings.TrimSpace(p.cfg.Model); configured != "" { + models = append(models, domain.ModelOption{ + Name: configured, + Description: "Configured default model", + }) + } + + return domain.ProviderDescriptor{ + Name: p.cfg.Name, + DisplayName: "OpenAI-compatible", + SupportLevel: domain.SupportLevelMVP, + MVPVisible: true, + Available: true, + Summary: "The only officially supported provider in the current MVP.", + Models: models, + } +} + func (p *Provider) Chat(ctx context.Context, req domain.ChatRequest, events chan<- domain.StreamEvent) (domain.ChatResponse, error) { apiKey, err := p.cfg.ResolveAPIKey() if err != nil { diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index 33377eb3..91b410ec 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -71,6 +71,32 @@ func TestMergeToolCallDeltas(t *testing.T) { } } +func TestProviderDescriptorMarksOpenAIAsMVPSupported(t *testing.T) { + t.Parallel() + + p, err := New(config.ProviderConfig{ + Name: config.ProviderOpenAI, + Type: config.ProviderOpenAI, + BaseURL: config.DefaultOpenAIBaseURL, + Model: "gpt-5.4", + APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + desc := p.Descriptor() + if desc.SupportLevel != domain.SupportLevelMVP || !desc.Available || !desc.MVPVisible { + t.Fatalf("unexpected descriptor: %+v", desc) + } + if desc.DisplayName != "OpenAI-compatible" { + t.Fatalf("expected display name OpenAI-compatible, got %q", desc.DisplayName) + } + if len(desc.Models) == 0 { + t.Fatalf("expected at least one model option in descriptor") + } +} + func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { t.Setenv(config.DefaultOpenAIAPIKeyEnv, "test-key") diff --git a/internal/provider/registry.go b/internal/provider/registry.go index 5a1f6aec..9ed289e1 100644 --- a/internal/provider/registry.go +++ b/internal/provider/registry.go @@ -2,6 +2,7 @@ package provider import ( "errors" + "sort" "strings" ) @@ -27,3 +28,40 @@ func (r *Registry) Get(name string) (Provider, error) { } return item, nil } + +func (r *Registry) Descriptors() []ProviderDescriptor { + return r.filteredDescriptors(func(ProviderDescriptor) bool { return true }) +} + +func (r *Registry) MVPDescriptors() []ProviderDescriptor { + return r.filteredDescriptors(func(desc ProviderDescriptor) bool { + return desc.SupportLevel == SupportLevelMVP && desc.MVPVisible + }) +} + +func (r *Registry) AvailableDescriptors() []ProviderDescriptor { + return r.filteredDescriptors(func(desc ProviderDescriptor) bool { + return desc.Available + }) +} + +func (r *Registry) filteredDescriptors(keep func(ProviderDescriptor) bool) []ProviderDescriptor { + if r == nil || len(r.items) == 0 { + return nil + } + + descriptors := make([]ProviderDescriptor, 0, len(r.items)) + for _, item := range r.items { + desc := Describe(item) + if keep != nil && !keep(desc) { + continue + } + descriptors = append(descriptors, desc) + } + + sort.Slice(descriptors, func(i, j int) bool { + return strings.ToLower(descriptors[i].Name) < strings.ToLower(descriptors[j].Name) + }) + + return descriptors +} diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index 9a76c9d8..d4ae432b 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -7,6 +7,7 @@ import ( "github.com/dust/neo-code/internal/config" "github.com/dust/neo-code/internal/provider" "github.com/dust/neo-code/internal/provider/anthropic" + "github.com/dust/neo-code/internal/provider/gemini" "github.com/dust/neo-code/internal/provider/openai" ) @@ -30,11 +31,19 @@ func TestRegistryRegisterAndGet(t *testing.T) { Model: config.DefaultAnthropicModel, APIKeyEnv: config.DefaultAnthropicAPIKeyEnv, }) + geminiProvider := gemini.New(config.ProviderConfig{ + Name: config.ProviderGemini, + Type: config.ProviderGemini, + BaseURL: config.DefaultGeminiBaseURL, + Model: config.DefaultGeminiModel, + APIKeyEnv: config.DefaultGeminiAPIKeyEnv, + }) registry := provider.NewRegistry() registry.Register(nil) registry.Register(openAIProvider) registry.Register(anthropicProvider) + registry.Register(geminiProvider) tests := []struct { name string @@ -78,3 +87,59 @@ func TestRegistryGetMissingProvider(t *testing.T) { t.Fatalf("expected not found error, got %v", err) } } + +func TestRegistryDescriptorsFiltersSupportSurface(t *testing.T) { + t.Parallel() + + openAIProvider, err := openai.New(config.ProviderConfig{ + Name: config.ProviderOpenAI, + Type: config.ProviderOpenAI, + BaseURL: config.DefaultOpenAIBaseURL, + Model: config.DefaultOpenAIModel, + APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, + }) + if err != nil { + t.Fatalf("openai.New() error = %v", err) + } + + registry := provider.NewRegistry() + registry.Register(openAIProvider) + registry.Register(anthropic.New(config.ProviderConfig{ + Name: config.ProviderAnthropic, + Type: config.ProviderAnthropic, + BaseURL: config.DefaultAnthropicBaseURL, + Model: config.DefaultAnthropicModel, + APIKeyEnv: config.DefaultAnthropicAPIKeyEnv, + })) + registry.Register(gemini.New(config.ProviderConfig{ + Name: config.ProviderGemini, + Type: config.ProviderGemini, + BaseURL: config.DefaultGeminiBaseURL, + Model: config.DefaultGeminiModel, + APIKeyEnv: config.DefaultGeminiAPIKeyEnv, + })) + + all := registry.Descriptors() + if len(all) != 3 { + t.Fatalf("expected 3 descriptors, got %d", len(all)) + } + + mvp := registry.MVPDescriptors() + if len(mvp) != 1 { + t.Fatalf("expected 1 MVP descriptor, got %d", len(mvp)) + } + if mvp[0].Name != config.ProviderOpenAI { + t.Fatalf("expected MVP provider %q, got %q", config.ProviderOpenAI, mvp[0].Name) + } + if mvp[0].SupportLevel != provider.SupportLevelMVP || !mvp[0].Available || !mvp[0].MVPVisible { + t.Fatalf("unexpected MVP descriptor: %+v", mvp[0]) + } + + available := registry.AvailableDescriptors() + if len(available) != 1 { + t.Fatalf("expected 1 available descriptor, got %d", len(available)) + } + if available[0].Name != config.ProviderOpenAI { + t.Fatalf("expected available provider %q, got %q", config.ProviderOpenAI, available[0].Name) + } +} diff --git a/internal/provider/types.go b/internal/provider/types.go index 23cc3a58..737999c3 100644 --- a/internal/provider/types.go +++ b/internal/provider/types.go @@ -1,5 +1,11 @@ package provider +import ( + "fmt" + "sort" + "strings" +) + type Message struct { Role string `json:"role"` Content string `json:"content"` @@ -38,3 +44,105 @@ type Usage struct { OutputTokens int `json:"output_tokens"` TotalTokens int `json:"total_tokens"` } + +type SupportLevel string + +const ( + SupportLevelMVP SupportLevel = "mvp" + SupportLevelScaffolded SupportLevel = "scaffolded" +) + +type ModelOption struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +type ProviderDescriptor struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + SupportLevel SupportLevel `json:"support_level"` + MVPVisible bool `json:"mvp_visible"` + Available bool `json:"available"` + Summary string `json:"summary"` + Models []ModelOption `json:"models,omitempty"` +} + +type Describer interface { + Descriptor() ProviderDescriptor +} + +func Describe(p Provider) ProviderDescriptor { + if p == nil { + return ProviderDescriptor{} + } + + if describer, ok := p.(Describer); ok { + return normalizeDescriptor(describer.Descriptor(), p.Name()) + } + + return ProviderDescriptor{ + Name: strings.TrimSpace(p.Name()), + DisplayName: strings.TrimSpace(p.Name()), + SupportLevel: SupportLevelScaffolded, + Summary: "Provider metadata is not available.", + } +} + +func ScaffoldedProviderError(name string) error { + providerName := strings.TrimSpace(name) + if providerName == "" { + providerName = "unknown" + } + return fmt.Errorf( + "%s provider is scaffolded only and not available in this MVP; only OpenAI-compatible provider is officially supported", + providerName, + ) +} + +func normalizeDescriptor(desc ProviderDescriptor, fallbackName string) ProviderDescriptor { + name := strings.TrimSpace(desc.Name) + if name == "" { + name = strings.TrimSpace(fallbackName) + } + desc.Name = name + + if strings.TrimSpace(desc.DisplayName) == "" { + desc.DisplayName = name + } + if strings.TrimSpace(desc.Summary) == "" { + desc.Summary = "Provider metadata is not available." + } + + desc.Models = normalizedModels(desc.Models) + return desc +} + +func normalizedModels(models []ModelOption) []ModelOption { + if len(models) == 0 { + return nil + } + + deduped := make([]ModelOption, 0, len(models)) + seen := make(map[string]struct{}, len(models)) + for _, model := range models { + name := strings.TrimSpace(model.Name) + if name == "" { + continue + } + key := strings.ToLower(name) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + deduped = append(deduped, ModelOption{ + Name: name, + Description: strings.TrimSpace(model.Description), + }) + } + + sort.Slice(deduped, func(i, j int) bool { + return strings.ToLower(deduped[i].Name) < strings.ToLower(deduped[j].Name) + }) + + return deduped +} From 4a946ae2c98252d3d601e92683e306c968a00902 Mon Sep 17 00:00:00 2001 From: creatang Date: Sat, 28 Mar 2026 13:12:39 +0800 Subject: [PATCH 04/15] ci: skip codecov upload when token is unavailable --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46550a93..c0054790 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,7 @@ jobs: run: go test ./... -covermode=atomic -coverprofile=coverage.out - name: Upload coverage to Codecov + if: ${{ secrets.CODECOV_TOKEN != '' }} uses: codecov/codecov-action@v5 with: files: ./coverage.out From 34db0850982f2860e80b9116ec92c0659e35d872 Mon Sep 17 00:00:00 2001 From: creatang Date: Sat, 28 Mar 2026 13:16:29 +0800 Subject: [PATCH 05/15] ci: make codecov upload non-blocking --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0054790..0cd0f9f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,11 +38,11 @@ jobs: run: go test ./... -covermode=atomic -coverprofile=coverage.out - name: Upload coverage to Codecov - if: ${{ secrets.CODECOV_TOKEN != '' }} + continue-on-error: true uses: codecov/codecov-action@v5 with: files: ./coverage.out flags: unittests - fail_ci_if_error: true + fail_ci_if_error: false env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} From f7bf1c776a4eb8d39d12cdac28b95b5e2a5c624e Mon Sep 17 00:00:00 2001 From: creatang Date: Sat, 28 Mar 2026 13:28:11 +0800 Subject: [PATCH 06/15] feat(config): add provider presets --- internal/config/config_test.go | 107 ++++++++++++++++++++++++++++++ internal/config/model.go | 118 ++++++++++++++++++++++++++------- 2 files changed, 202 insertions(+), 23 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 108317d1..e34f56a1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -681,6 +681,113 @@ func TestLoaderDefaultsAndBuiltinCatalog(t *testing.T) { } } +func TestBuiltinProviderConfigs(t *testing.T) { + t.Parallel() + + providers := BuiltinProviderConfigs() + if len(providers) != 3 { + t.Fatalf("expected 3 builtin providers, got %d", len(providers)) + } + + openai, err := BuiltinProviderConfig(ProviderOpenAI) + if err != nil { + t.Fatalf("BuiltinProviderConfig(%q) error = %v", ProviderOpenAI, err) + } + if openai.BaseURL != DefaultOpenAIBaseURL || openai.Model != DefaultOpenAIModel { + t.Fatalf("unexpected openai preset: %+v", openai) + } + + anthropic, err := BuiltinProviderConfig(ProviderAnthropic) + if err != nil { + t.Fatalf("BuiltinProviderConfig(%q) error = %v", ProviderAnthropic, err) + } + if anthropic.BaseURL != DefaultAnthropicBaseURL || anthropic.Model != DefaultAnthropicModel { + t.Fatalf("unexpected anthropic preset: %+v", anthropic) + } + + gemini, err := BuiltinProviderConfig(ProviderGemini) + if err != nil { + t.Fatalf("BuiltinProviderConfig(%q) error = %v", ProviderGemini, err) + } + if gemini.BaseURL != DefaultGeminiBaseURL || gemini.Model != DefaultGeminiModel { + t.Fatalf("unexpected gemini preset: %+v", gemini) + } + + if _, err := BuiltinProviderConfig("unknown"); err == nil { + t.Fatalf("expected unknown provider lookup to fail") + } +} + +func TestProviderPresetSlices(t *testing.T) { + t.Parallel() + + mvpProviders := MVPProviderConfigs() + if len(mvpProviders) != 1 { + t.Fatalf("expected 1 mvp provider, got %d", len(mvpProviders)) + } + if got := mvpProviders[0].Name; got != ProviderOpenAI { + t.Fatalf("expected mvp provider %q, got %q", ProviderOpenAI, got) + } + + scaffoldProviders := ScaffoldProviderConfigs() + if len(scaffoldProviders) != 2 { + t.Fatalf("expected 2 scaffold providers, got %d", len(scaffoldProviders)) + } + if scaffoldProviders[0].Name != ProviderAnthropic || scaffoldProviders[1].Name != ProviderGemini { + t.Fatalf("unexpected scaffold providers: %+v", scaffoldProviders) + } +} + +func TestBuiltinModelCatalogForProvider(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + providerName string + wantCount int + wantFirst string + }{ + { + name: "openai", + providerName: ProviderOpenAI, + wantCount: 4, + wantFirst: DefaultOpenAIModel, + }, + { + name: "anthropic", + providerName: ProviderAnthropic, + wantCount: 1, + wantFirst: DefaultAnthropicModel, + }, + { + name: "gemini", + providerName: ProviderGemini, + wantCount: 1, + wantFirst: DefaultGeminiModel, + }, + { + name: "unknown", + providerName: "unknown", + wantCount: 0, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + catalog := BuiltinModelCatalogForProvider(tc.providerName) + if len(catalog) != tc.wantCount { + t.Fatalf("BuiltinModelCatalogForProvider(%q) count = %d, want %d", tc.providerName, len(catalog), tc.wantCount) + } + if tc.wantCount > 0 && catalog[0].Name != tc.wantFirst { + t.Fatalf("BuiltinModelCatalogForProvider(%q) first model = %q, want %q", tc.providerName, catalog[0].Name, tc.wantFirst) + } + }) + } +} + func restoreEnv(t *testing.T, key string) { t.Helper() value, ok := os.LookupEnv(key) diff --git a/internal/config/model.go b/internal/config/model.go index 63ecb697..1eb867c6 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -60,31 +60,80 @@ type ModelOption struct { Description string } +// BuiltinProviderConfigs returns all known provider presets, including scaffolded ones. +func BuiltinProviderConfigs() []ProviderConfig { + return []ProviderConfig{ + { + Name: ProviderOpenAI, + Type: ProviderOpenAI, + BaseURL: DefaultOpenAIBaseURL, + Model: DefaultOpenAIModel, + APIKeyEnv: DefaultOpenAIAPIKeyEnv, + }, + { + Name: ProviderAnthropic, + Type: ProviderAnthropic, + BaseURL: DefaultAnthropicBaseURL, + Model: DefaultAnthropicModel, + APIKeyEnv: DefaultAnthropicAPIKeyEnv, + }, + { + Name: ProviderGemini, + Type: ProviderGemini, + BaseURL: DefaultGeminiBaseURL, + Model: DefaultGeminiModel, + APIKeyEnv: DefaultGeminiAPIKeyEnv, + }, + } +} + +// MVPProviderConfigs returns the provider presets that should be enabled by default in the MVP. +func MVPProviderConfigs() []ProviderConfig { + return []ProviderConfig{ + { + Name: ProviderOpenAI, + Type: ProviderOpenAI, + BaseURL: DefaultOpenAIBaseURL, + Model: DefaultOpenAIModel, + APIKeyEnv: DefaultOpenAIAPIKeyEnv, + }, + } +} + +// ScaffoldProviderConfigs returns provider presets that are prepared but not officially enabled in the MVP. +func ScaffoldProviderConfigs() []ProviderConfig { + return []ProviderConfig{ + { + Name: ProviderAnthropic, + Type: ProviderAnthropic, + BaseURL: DefaultAnthropicBaseURL, + Model: DefaultAnthropicModel, + APIKeyEnv: DefaultAnthropicAPIKeyEnv, + }, + { + Name: ProviderGemini, + Type: ProviderGemini, + BaseURL: DefaultGeminiBaseURL, + Model: DefaultGeminiModel, + APIKeyEnv: DefaultGeminiAPIKeyEnv, + }, + } +} + +// BuiltinProviderConfig returns the preset configuration for a known provider. +func BuiltinProviderConfig(name string) (ProviderConfig, error) { + target := strings.ToLower(strings.TrimSpace(name)) + for _, provider := range BuiltinProviderConfigs() { + if strings.ToLower(strings.TrimSpace(provider.Name)) == target { + return provider, nil + } + } + return ProviderConfig{}, fmt.Errorf("config: built-in provider %q not found", name) +} + func Default() *Config { return &Config{ - Providers: []ProviderConfig{ - { - Name: ProviderOpenAI, - Type: ProviderOpenAI, - BaseURL: DefaultOpenAIBaseURL, - Model: DefaultOpenAIModel, - APIKeyEnv: DefaultOpenAIAPIKeyEnv, - }, - { - Name: ProviderAnthropic, - Type: ProviderAnthropic, - BaseURL: DefaultAnthropicBaseURL, - Model: DefaultAnthropicModel, - APIKeyEnv: DefaultAnthropicAPIKeyEnv, - }, - { - Name: ProviderGemini, - Type: ProviderGemini, - BaseURL: DefaultGeminiBaseURL, - Model: DefaultGeminiModel, - APIKeyEnv: DefaultGeminiAPIKeyEnv, - }, - }, + Providers: BuiltinProviderConfigs(), SelectedProvider: DefaultSelectedProvider, CurrentModel: DefaultOpenAIModel, Workdir: DefaultWorkdir, @@ -105,6 +154,29 @@ func BuiltinModelCatalog() []ModelOption { ) } +// BuiltinModelCatalogForProvider returns the built-in model options for the given provider. +func BuiltinModelCatalogForProvider(providerName string) []ModelOption { + switch strings.ToLower(strings.TrimSpace(providerName)) { + case ProviderOpenAI: + return []ModelOption{ + {Name: DefaultOpenAIModel, Description: "Stable OpenAI default model"}, + {Name: "gpt-4o", Description: "Fast general-purpose OpenAI model"}, + {Name: "gpt-5.4", Description: "Frontier reasoning and coding model"}, + {Name: "gpt-5.3-codex", Description: "Code-focused GPT-5.3 variant"}, + } + case ProviderAnthropic: + return []ModelOption{ + {Name: DefaultAnthropicModel, Description: "Balanced Anthropic coding model"}, + } + case ProviderGemini: + return []ModelOption{ + {Name: DefaultGeminiModel, Description: "Default Gemini reasoning model"}, + } + default: + return nil + } +} + func (c *Config) Clone() Config { if c == nil { return *Default() From 5c0918c4fa303068eae56b92fb3f97744c719209 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sat, 28 Mar 2026 15:21:38 +0800 Subject: [PATCH 07/15] =?UTF-8?q?pref:=E4=BF=AE=E6=AD=A3TUI=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E7=9A=84=E8=83=8C=E6=99=AF=E8=89=B2=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/registry.go | 6 -- internal/tui/state.go | 15 +++-- internal/tui/styles.go | 119 +++++++++++++++++++++------------- internal/tui/view.go | 17 +++-- 4 files changed, 95 insertions(+), 62 deletions(-) diff --git a/internal/provider/registry.go b/internal/provider/registry.go index 9ed289e1..a64da502 100644 --- a/internal/provider/registry.go +++ b/internal/provider/registry.go @@ -33,12 +33,6 @@ func (r *Registry) Descriptors() []ProviderDescriptor { return r.filteredDescriptors(func(ProviderDescriptor) bool { return true }) } -func (r *Registry) MVPDescriptors() []ProviderDescriptor { - return r.filteredDescriptors(func(desc ProviderDescriptor) bool { - return desc.SupportLevel == SupportLevelMVP && desc.MVPVisible - }) -} - func (r *Registry) AvailableDescriptors() []ProviderDescriptor { return r.filteredDescriptors(func(desc ProviderDescriptor) bool { return desc.Available diff --git a/internal/tui/state.go b/internal/tui/state.go index bfa1a6e9..21d18c29 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -108,19 +108,22 @@ func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list. prefix = ">" } - content := lipgloss.JoinVertical( - lipgloss.Left, - fmt.Sprintf("%s %s", prefix, title), - d.styles.sessionMeta.Render(" "+meta), - ) - style := d.styles.sessionRow + metaStyle := d.styles.sessionMeta if session.Active { style = d.styles.sessionRowActive + metaStyle = d.styles.sessionMetaActive } if index == m.Index() { style = d.styles.sessionRowFocused + metaStyle = d.styles.sessionMetaFocus } + content := lipgloss.JoinVertical( + lipgloss.Left, + fmt.Sprintf("%s %s", prefix, title), + metaStyle.Render(" "+meta), + ) + fmt.Fprint(w, style.Width(width).Render(content)) } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 199de3d8..a0326dcb 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -7,23 +7,27 @@ import ( ) const ( - colorPrimary = "#CBA6F7" - colorUser = "#89B4FA" - colorBorder = "#45475A" - colorError = "#F38BA8" - colorSuccess = "#A6E3A1" - colorText = "#CDD6F4" - colorSubtle = "#7F849C" - colorBg = "#11111B" - colorPanel = "#181825" - colorCode = "#1E1E2E" - colorInk = "#11111B" - colorWarning = "#F9E2AF" + colorPrimary = "#7AA2F7" + colorUser = "#4FD6BE" + colorBorder = "#2B3342" + colorError = "#F7768E" + colorSuccess = "#73DACA" + colorText = "#DCE3F0" + colorSubtle = "#7E8AA3" + colorBg = "#090C12" + colorPanel = "#11161F" + colorPanelAlt = "#151C27" + colorCode = "#0E131B" + colorInk = "#081018" + colorWarning = "#E0AF68" ) type styles struct { doc lipgloss.Style + headerBar lipgloss.Style headerBrand lipgloss.Style + headerLabel lipgloss.Style + headerPath lipgloss.Style headerSub lipgloss.Style headerMeta lipgloss.Style headerSpacer lipgloss.Style @@ -37,6 +41,8 @@ type styles struct { sessionRowActive lipgloss.Style sessionRowFocused lipgloss.Style sessionMeta lipgloss.Style + sessionMetaActive lipgloss.Style + sessionMetaFocus lipgloss.Style streamTitle lipgloss.Style streamMeta lipgloss.Style streamContent lipgloss.Style @@ -71,27 +77,35 @@ type styles struct { func newStyles() styles { panel := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). + Border(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color(colorBorder)). - Background(lipgloss.Color(colorBg)). + UnsetBackground(). Padding(0, 1) return styles{ doc: lipgloss.NewStyle(). Padding(1, 2, 0, 2). - Background(lipgloss.Color(colorBg)). + UnsetBackground(). + Foreground(lipgloss.Color(colorText)), + headerBar: lipgloss.NewStyle(). + UnsetBackground(). Foreground(lipgloss.Color(colorText)), headerBrand: lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color(colorInk)). - Background(lipgloss.Color(colorPrimary)). + Foreground(lipgloss.Color(colorPrimary)). + UnsetBackground(). Padding(0, 1), + headerLabel: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorSubtle)), + headerPath: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorText)), headerSub: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)), headerMeta: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorSubtle)), + Foreground(lipgloss.Color(colorText)), headerSpacer: lipgloss.NewStyle(). - Width(2), + Width(1). + UnsetBackground(), panel: panel, panelFocused: panel.Copy(). BorderForeground(lipgloss.Color(colorPrimary)), @@ -102,24 +116,29 @@ func newStyles() styles { Foreground(lipgloss.Color(colorSubtle)), panelBody: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorText)). - Background(lipgloss.Color(colorBg)), + UnsetBackground(), empty: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)). Padding(1, 0), sessionRow: lipgloss.NewStyle(). Padding(0, 1). - Foreground(lipgloss.Color(colorText)), + Foreground(lipgloss.Color(colorText)). + UnsetBackground(), sessionRowActive: lipgloss.NewStyle(). Padding(0, 1). Foreground(lipgloss.Color(colorText)). - Background(lipgloss.Color(colorCode)), + UnsetBackground(), sessionRowFocused: lipgloss.NewStyle(). Padding(0, 1). - Foreground(lipgloss.Color(colorInk)). - Background(lipgloss.Color(colorPrimary)). + Foreground(lipgloss.Color(colorText)). + UnsetBackground(). Bold(true), sessionMeta: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)), + sessionMetaActive: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#94A0B8")), + sessionMetaFocus: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#AFC3FF")), streamTitle: lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color(colorText)), @@ -127,38 +146,47 @@ func newStyles() styles { Foreground(lipgloss.Color(colorSubtle)), streamContent: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorText)). - Background(lipgloss.Color(colorBg)), - messageUserTag: tagStyle(colorUser, colorInk), - messageAgentTag: tagStyle(colorPrimary, colorInk), - messageToolTag: tagStyle(colorSuccess, colorInk), + UnsetBackground(), + messageUserTag: tagStyle(colorUser), + messageAgentTag: tagStyle(colorPrimary), + messageToolTag: tagStyle(colorSuccess), messageBody: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorText)). + Background(lipgloss.Color(colorPanel)). Padding(0, 1), messageUserBody: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorText)). + Background(lipgloss.Color("#12202D")). Padding(0, 1), messageToolBody: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSuccess)). + Background(lipgloss.Color("#0F1B1C")). Padding(0, 1), inlineNotice: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)). + Background(lipgloss.Color(colorPanel)). + Padding(0, 1). Italic(true), inlineError: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorError)). + Background(lipgloss.Color("#21131A")). + Padding(0, 1). Bold(true), inlineSystem: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorSubtle)), + Foreground(lipgloss.Color(colorSubtle)). + Background(lipgloss.Color(colorPanel)). + Padding(0, 1), codeBlock: lipgloss.NewStyle(). MarginLeft(1). Padding(0, 1). Background(lipgloss.Color(colorCode)). BorderLeft(true). BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color(colorBorder)), + BorderForeground(lipgloss.Color(colorPrimary)), codeText: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#BAC2DE")), + Foreground(lipgloss.Color(colorText)), commandMenu: lipgloss.NewStyle(). - Background(lipgloss.Color(colorBg)). + UnsetBackground(). Padding(1, 1), commandMenuTitle: lipgloss.NewStyle(). Bold(true). @@ -178,37 +206,38 @@ func newStyles() styles { inputBox: lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color(colorBorder)). - Background(lipgloss.Color(colorBg)). + UnsetBackground(). Padding(0, 1), inputBoxFocused: lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color(colorUser)). - Background(lipgloss.Color(colorBg)). + BorderForeground(lipgloss.Color(colorPrimary)). + UnsetBackground(). Padding(0, 1), footer: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)). - Background(lipgloss.Color(colorBg)), - badgeUser: badge(colorUser, colorInk), - badgeAgent: badge(colorPrimary, colorInk), - badgeSuccess: badge(colorSuccess, colorInk), - badgeWarning: badge(colorWarning, colorInk), - badgeError: badge(colorError, colorInk), - badgeMuted: badge(colorBorder, colorText), + UnsetBackground(), + badgeUser: badge("#12202D", colorUser), + badgeAgent: badge("#16233A", colorPrimary), + badgeSuccess: badge("#102018", colorSuccess), + badgeWarning: badge("#241B10", colorWarning), + badgeError: badge("#26131A", colorError), + badgeMuted: badge(colorPanelAlt, colorSubtle), } } -func tagStyle(bg string, fg string) lipgloss.Style { +func tagStyle(fg string) lipgloss.Style { return lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color(fg)). - Background(lipgloss.Color(bg)). + Background(lipgloss.Color(colorBg)). Padding(0, 1) } func badge(bg string, fg string) lipgloss.Style { return lipgloss.NewStyle(). + Bold(true). Foreground(lipgloss.Color(fg)). - Background(lipgloss.Color(bg)). + UnsetBackground(). Padding(0, 1) } diff --git a/internal/tui/view.go b/internal/tui/view.go index ec001494..970d509f 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -35,7 +35,7 @@ func (a App) View() string { spacerHeight := max(0, docHeight-usedHeight) parts := []string{header, body} if spacerHeight > 0 { - parts = append(parts, lipgloss.NewStyle().Height(spacerHeight).Background(lipgloss.Color(colorBg)).Render("")) + parts = append(parts, lipgloss.NewStyle().Height(spacerHeight).Render("")) } parts = append(parts, helpView) content := lipgloss.JoinVertical(lipgloss.Left, parts...) @@ -62,17 +62,24 @@ func (a App) renderHeader(width int) string { ) spacerWidth := lipgloss.Width(a.styles.headerSpacer.Render("")) - workdirWidth := max(12, width-lipgloss.Width(brand)-lipgloss.Width(meta)-(spacerWidth*2)-lipgloss.Width("Workdir ")) + workdirLabel := a.styles.headerLabel.Render("Workdir") + workdirWidth := max(12, width-lipgloss.Width(brand)-lipgloss.Width(meta)-(spacerWidth*2)-lipgloss.Width(workdirLabel)-1) + workdir := lipgloss.JoinHorizontal( + lipgloss.Center, + workdirLabel, + " ", + a.styles.headerPath.Render(trimMiddle(a.state.CurrentWorkdir, workdirWidth)), + ) header := lipgloss.JoinHorizontal( lipgloss.Center, brand, a.styles.headerSpacer.Render(""), - a.styles.headerMeta.Render("Workdir "+trimMiddle(a.state.CurrentWorkdir, workdirWidth)), + workdir, a.styles.headerSpacer.Render(""), meta, ) - return lipgloss.Place(width, 1, lipgloss.Left, lipgloss.Top, header) + return a.styles.headerBar.Width(width).Render(lipgloss.Place(width, 1, lipgloss.Left, lipgloss.Top, header)) } func (a App) renderBody(lay layout) string { @@ -84,7 +91,7 @@ func (a App) renderBody(lay layout) string { return lipgloss.JoinHorizontal( lipgloss.Top, sidebar, - lipgloss.NewStyle().Width(lay.bodyGap).Background(lipgloss.Color(colorBg)).Render(""), + lipgloss.NewStyle().Width(lay.bodyGap).Render(""), stream, ) } From 72322f22eb2f6ac1e712916456db86b74107f99b Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sat, 28 Mar 2026 18:14:37 +0800 Subject: [PATCH 08/15] =?UTF-8?q?pref:=E4=BC=98=E5=8C=96provide=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 38 ++ README.md | 96 +++-- agents.md | 60 ++- internal/app/bootstrap.go | 14 +- internal/config/config_test.go | 432 ++++++++++--------- internal/config/loader.go | 131 ++++-- internal/config/loader_test.go | 63 ++- internal/config/manager.go | 64 +-- internal/config/model.go | 295 ++++++------- internal/provider/anthropic/provider.go | 35 -- internal/provider/anthropic/provider_test.go | 30 -- internal/provider/builtin/builtin.go | 33 ++ internal/provider/gemini/provider.go | 35 -- internal/provider/gemini/provider_test.go | 30 -- internal/provider/openai/defaults.go | 20 + internal/provider/openai/driver.go | 60 +++ internal/provider/openai/openai.go | 43 +- internal/provider/openai/openai_test.go | 96 ++--- internal/provider/provider.go | 1 - internal/provider/registry.go | 91 ++-- internal/provider/registry_test.go | 293 ++++++++----- internal/provider/service.go | 170 ++++++++ internal/provider/types.go | 105 ++--- internal/runtime/runtime.go | 40 +- internal/runtime/runtime_test.go | 110 ++--- internal/tui/app.go | 54 ++- internal/tui/commands.go | 246 +++++------ internal/tui/commands_test.go | 125 +----- internal/tui/provider_service.go | 14 + internal/tui/state.go | 31 +- internal/tui/update.go | 77 +++- internal/tui/update_test.go | 162 +++++-- internal/tui/view.go | 22 +- 33 files changed, 1707 insertions(+), 1409 deletions(-) create mode 100644 .gitignore delete mode 100644 internal/provider/anthropic/provider.go delete mode 100644 internal/provider/anthropic/provider_test.go create mode 100644 internal/provider/builtin/builtin.go delete mode 100644 internal/provider/gemini/provider.go delete mode 100644 internal/provider/gemini/provider_test.go create mode 100644 internal/provider/openai/defaults.go create mode 100644 internal/provider/openai/driver.go create mode 100644 internal/provider/service.go create mode 100644 internal/tui/provider_service.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..df09beca --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +cover +coverage +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# Local configuration and secrets +.env +config.yaml +config.local.yaml + +# Local data +data/ +.cache/ + +# Editor/IDE +.idea/ +workspace.xml +.vscode/ diff --git a/README.md b/README.md index 037ee9e3..86fc240d 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,63 @@ # NeoCode -NeoCode 是一个基于 Go 和 Bubble Tea 构建的本地 Coding Agent MVP。它把 Provider 防腐层、工具调用闭环、本地会话持久化和沉浸式瀑布流 TUI 组合在一起,用于日常代码理解、修改和终端内协作。 -## 功能特性 -- 基于防腐层的 Provider 架构,当前支持 OpenAI 兼容流式接口,并为 Anthropic、Gemini 预留了扩展位置。 -- ReAct 风格的 runtime 主循环,能够读取模型输出、执行工具调用、回灌结果并继续推理。 -- 瀑布流终端界面,支持流式 transcript、内联工具事件、Slash Command 和会话侧边栏。 -- 本地 YAML 配置、`.env` 加载与并发安全的热更新能力。 -- 本地 JSON 会话持久化,不依赖远端服务即可恢复历史上下文。 -- 文件系统高级工具:读取、写入、搜索、Glob 探测和精准替换。 +NeoCode 是一个基于 Go 和 Bubble Tea 的本地编码 Agent。它在终端中运行 ReAct 闭环,能够对话、调用工具、持久化会话,并以流式方式展示模型输出。 -## 环境要求 -- Go 1.21 及以上 -- 至少一个可用的模型 Provider API Key,例如 `OPENAI_API_KEY` +## 当前 Provider 策略 -## 快速开始 -1. 克隆仓库。 -2. 准备 Provider 的 API Key,可以通过环境变量或 NeoCode 托管的 `.env` 文件提供。 -3. 运行应用: +- 内建 provider 定义跟随代码版本发布 +- `config.yaml` 不再持久化完整 `providers` 列表 +- `config.yaml` 只保存用户状态和显式覆写 +- 运行时的 `providers` 由“内建定义 + 用户 overrides”合并得到 +- API Key 只从环境变量读取,不写入 YAML -```bash -go run ./cmd/neocode +这意味着: + +- 新用户启动后会自动拿到当前版本最新的内建 provider +- 未来代码新增 provider 时,新用户不需要修改 YAML +- 老用户如果没有覆写某个字段,也会跟随最新内建默认值 + +## 配置文件 + +默认路径: + +[`~/.neocode/config.yaml`](~/.neocode/config.yaml) + +当前落盘结构示例: + +```yaml +selected_provider: openai +current_model: gpt-5.4 +provider_overrides: + - name: openai + base_url: https://example.com/v1 + model: gpt-5.4 + api_key_env: AI_API_KEY +workdir: . +shell: powershell +max_loops: 8 +tool_timeout_sec: 20 ``` -首次启动时,NeoCode 会在当前用户主目录下创建自己的托管目录、默认配置和会话存储结构。 +其中: + +- `selected_provider` 和 `current_model` 是用户当前选择 +- `provider_overrides` 只保存和内建默认值不同的部分 +- 完整 provider 列表不落盘 -## Slash Command -- `/set url `:更新当前选中 Provider 的 Base URL。 -- `/set key `:将当前 Provider 的密钥写入托管 `.env` 并立即重新加载。 -- `/model`:打开交互式模型选择列表。 +## Slash Commands -## 开发与验证 -在提交 Pull Request 前建议至少执行以下命令: +- `/provider`:打开 provider 选择器 +- `/model`:打开当前 provider 的模型选择器 + +## 运行 ```bash -gofmt -w ./cmd ./internal -go test ./... +go run ./cmd/neocode ``` -## 架构概览 -- `internal/config`:负责 YAML 加载、`.env` 集成、默认值管理和并发安全更新。 -- `internal/provider`:将厂商特定的请求和流式响应抹平成统一领域模型。 -- `internal/runtime`:负责事件总线、ReAct loop、Provider 动态构建和会话持久化。 -- `internal/tools`:提供工具注册表以及各类具体工具实现。 -- `internal/tui`:负责终端交互体验,以及 runtime 事件到 Bubble Tea 消息的桥接。 - -## 目录结构 -```text -. -|-- cmd/neocode -|-- docs -|-- internal/app -|-- internal/config -|-- internal/provider -|-- internal/runtime -|-- internal/tools -`-- internal/tui -``` +## 开发 -## 当前状态 -NeoCode 目前聚焦于 MVP 闭环:本地对话、工具调用、Session 持久化和终端交互体验。当前版本正在继续向“高质量开源项目”标准收敛,重点补强文档、测试覆盖率和工具能力。 +```bash +gofmt -w ./cmd ./internal +go test ./... +``` diff --git a/agents.md b/agents.md index 450eb129..1a36defa 100644 --- a/agents.md +++ b/agents.md @@ -1,43 +1,39 @@ # NeoCode + ## Name + NeoCode + ## Description + NeoCode 是一个基于 Go 和 Bubble Tea 构建的本地编码 Agent。它在终端中运行 ReAct 闭环,可以对话、调用工具、持久化会话,并以流式方式展示推理结果。 + ## Capabilities -- 通过 provider 无关的 runtime 读取、理解并操作当前工作区代码。 -- 调用文件系统、Shell 和 Web 工具完成读取、写入、搜索、精准修改等任务。 -- 将会话历史持久化到本地,并在 TUI 侧边栏中恢复旧会话。 -- 在瀑布流终端界面中实时展示模型输出和工具执行过程。 -- 通过 Slash Command 在运行时更新 Provider URL、API Key 和当前模型。 -## Tools -- `filesystem_read_file`:读取工作区内的文件内容。 -- `filesystem_write_file`:创建或覆盖工作区内的文件。 -- `filesystem_grep`:在工作区内按关键字或正则搜索代码片段。 -- `filesystem_glob`:按通配模式列出文件路径,帮助 Agent 探测代码树结构。 -- `filesystem_edit`:基于唯一匹配块做精准替换,避免整文件重写。 -- `bash`:在限定工作区内执行 Shell 命令,并支持超时控制。 -- `webfetch`:按上下文超时规则抓取远程 HTTP 内容。 + +- 通过 provider 无关的 runtime 读取、理解并操作当前工作区代码 +- 调用文件系统、Shell 和 Web 工具完成读取、写入、搜索、精准修改等任务 +- 将会话历史持久化到本地,并在 TUI 侧边栏中恢复旧会话 +- 在瀑布流终端界面中实时展示模型输出和工具执行过程 +- 通过 Slash Command 切换当前 provider 和模型 + ## Project Structure -- `cmd/neocode`:CLI 入口。 -- `internal/config`:配置模型、YAML 加载、环境变量管理和并发安全访问。 -- `internal/provider`:Provider 接口、领域模型以及各模型厂商适配器。 -- `internal/runtime`:事件流、Session 持久化、Prompt 编排与 ReAct 主循环。 -- `internal/tools`:工具契约、注册表和具体工具实现。 -- `internal/tui`:Bubble Tea 状态机、渲染层、Slash Command 和事件桥接。 -## How To Work With This Project -- 从 runtime 边界开始理解系统。TUI 不能直接调用 provider 或 tools。 -- 将 `internal/provider` 视为防腐层。runtime 只能操作 provider 标准消息和工具调用结构。 -- 一切配置修改都必须经由 `ConfigManager`,以保证并发安全。 -- API Key 不得写入 YAML,只允许在配置中保存环境变量名,并在运行时解析真实值。 -- 对 config、provider streaming、runtime orchestration 或 tools 的改动,优先补充或更新测试。 + +- `internal/config`:配置结构、YAML 加载、环境变量管理和并发安全访问 +- `internal/provider`:provider 接口、catalog、driver 注册和厂商适配器 +- `internal/provider/builtin`:内建 provider 定义和注册入口 +- `internal/runtime`:事件流、Prompt 编排与 ReAct 主循环 +- `internal/tui`:Bubble Tea 状态机、渲染层、Slash Command 和事件桥接 + +## Provider Rules + +- 内建 provider 定义属于代码,不属于用户 YAML +- `config.yaml` 只保存 `selected_provider`、`current_model` 和 `provider_overrides` +- 运行时 provider 列表由“内建 provider + overrides”合并得到 +- API Key 不得写入 YAML,只能通过环境变量提供 +- TUI 不提供 `/set url` 或 `/set key` + ## Setup Commands + - 启动应用:`go run ./cmd/neocode` - 运行测试:`go test ./...` - 格式化代码:`gofmt -w ./cmd ./internal` -## Interaction Tips -- 普通编码任务和仓库问题可以直接用自然语言输入。 -- 本地控制命令统一通过 Composer 中的 Slash Command 触发: - - `/set url ` - - `/set key ` - - `/model` -- 第一次启动应用后,NeoCode 会自动创建自己的托管目录、配置文件和会话存储目录。 diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 2639538f..e0d9bb96 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -7,6 +7,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/dust/neo-code/internal/config" + "github.com/dust/neo-code/internal/provider" + "github.com/dust/neo-code/internal/provider/builtin" agentruntime "github.com/dust/neo-code/internal/runtime" "github.com/dust/neo-code/internal/tools" "github.com/dust/neo-code/internal/tools/bash" @@ -16,7 +18,7 @@ import ( ) func NewProgram(ctx context.Context) (*tea.Program, error) { - loader := config.NewLoader("") + loader := config.NewLoader("", builtin.DefaultConfig()) manager := config.NewManager(loader) cfg, err := manager.Load(ctx) if err != nil { @@ -32,10 +34,16 @@ func NewProgram(ctx context.Context) (*tea.Program, error) { toolRegistry.Register(bash.New(cfg.Workdir, cfg.Shell, time.Duration(cfg.ToolTimeoutSec)*time.Second)) toolRegistry.Register(webfetch.New(time.Duration(cfg.ToolTimeoutSec) * time.Second)) + providerRegistry, err := builtin.NewRegistry() + if err != nil { + return nil, err + } + providerService := provider.NewService(manager, providerRegistry) + sessionStore := agentruntime.NewSessionStore(loader.BaseDir()) - runtimeSvc := agentruntime.New(manager, toolRegistry, sessionStore) + runtimeSvc := agentruntime.NewWithFactory(manager, toolRegistry, sessionStore, providerRegistry) - tuiApp, err := tui.New(&cfg, manager, runtimeSvc) + tuiApp, err := tui.New(&cfg, manager, runtimeSvc, providerService) if err != nil { return nil, err } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e34f56a1..772af579 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -9,6 +9,32 @@ import ( "testing" ) +const ( + testProviderName = "openai" + testBaseURL = "https://api.openai.com/v1" + testModel = "gpt-4.1" + testAPIKeyEnv = "OPENAI_API_KEY" +) + +func testDefaultProviderConfig() ProviderConfig { + return ProviderConfig{ + Name: testProviderName, + Driver: testProviderName, + BaseURL: testBaseURL, + Model: testModel, + APIKeyEnv: testAPIKeyEnv, + } +} + +func testDefaultConfig() *Config { + cfg := Default() + defaultProvider := testDefaultProviderConfig() + cfg.Providers = []ProviderConfig{defaultProvider} + cfg.SelectedProvider = defaultProvider.Name + cfg.CurrentModel = defaultProvider.Model + return cfg +} + func TestParseConfigFormats(t *testing.T) { t.Parallel() @@ -18,15 +44,14 @@ func TestParseConfigFormats(t *testing.T) { assert func(t *testing.T, cfg *Config) }{ { - name: "current format", + name: "current format with provider overrides", data: ` selected_provider: openai current_model: gpt-5.4 workdir: . shell: powershell -providers: +provider_overrides: - name: openai - type: openai base_url: https://example.com/v1 model: gpt-5.4 api_key_env: OPENAI_API_KEY @@ -45,6 +70,34 @@ providers: } }, }, + { + name: "legacy persisted providers list", + data: ` +selected_provider: openai +current_model: gpt-5.4 +workdir: . +shell: powershell +providers: + - name: openai + type: openai + base_url: https://example.com/v1 + model: gpt-5.4 + api_key_env: OPENAI_API_KEY +`, + assert: func(t *testing.T, cfg *Config) { + t.Helper() + provider, err := cfg.SelectedProviderConfig() + if err != nil { + t.Fatalf("selected provider: %v", err) + } + if provider.BaseURL != "https://example.com/v1" { + t.Fatalf("expected migrated base url, got %q", provider.BaseURL) + } + if provider.Model != "gpt-5.4" { + t.Fatalf("expected migrated model, got %q", provider.Model) + } + }, + }, { name: "legacy format", data: ` @@ -81,11 +134,11 @@ providers: tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - cfg, err := parseConfig([]byte(tt.data)) + cfg, err := parseConfig([]byte(tt.data), *testDefaultConfig()) if err != nil { t.Fatalf("parseConfig() error = %v", err) } - cfg.ApplyDefaults() + cfg.ApplyDefaultsFrom(*testDefaultConfig()) tt.assert(t, cfg) }) } @@ -121,10 +174,10 @@ func TestProviderConfigResolveAPIKey(t *testing.T) { } provider := ProviderConfig{ - Name: ProviderOpenAI, - Type: ProviderOpenAI, - BaseURL: DefaultOpenAIBaseURL, - Model: DefaultOpenAIModel, + Name: testProviderName, + Driver: testProviderName, + BaseURL: testBaseURL, + Model: testModel, APIKeyEnv: tt.envKey, } @@ -171,7 +224,7 @@ func TestConfigMethodErrorPaths(t *testing.T) { _, err := (ProviderConfig{ Name: "custom", - Type: "custom", + Driver: "custom", BaseURL: "https://example.com", Model: "custom-model", APIKeyEnv: "MISSING_PROVIDER_KEY", @@ -205,8 +258,8 @@ func TestLoaderLoadEnvironmentSources(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { tempDir := t.TempDir() - restoreEnv(t, DefaultOpenAIAPIKeyEnv) - _ = os.Unsetenv(DefaultOpenAIAPIKeyEnv) + restoreEnv(t, testAPIKeyEnv) + _ = os.Unsetenv(testAPIKeyEnv) previousWD, err := os.Getwd() if err != nil { @@ -225,7 +278,7 @@ func TestLoaderLoadEnvironmentSources(t *testing.T) { } } - loader := NewLoader(filepath.Join(tempDir, ".neocode")) + loader := NewLoader(filepath.Join(tempDir, ".neocode"), testDefaultConfig()) if tt.managedDotEnv != "" { if err := os.MkdirAll(loader.BaseDir(), 0o755); err != nil { t.Fatalf("mkdir managed dir: %v", err) @@ -238,11 +291,11 @@ func TestLoaderLoadEnvironmentSources(t *testing.T) { loader.LoadEnvironment() provider := ProviderConfig{ - Name: ProviderOpenAI, - Type: ProviderOpenAI, - BaseURL: DefaultOpenAIBaseURL, - Model: DefaultOpenAIModel, - APIKeyEnv: DefaultOpenAIAPIKeyEnv, + Name: testProviderName, + Driver: testProviderName, + BaseURL: testBaseURL, + Model: testModel, + APIKeyEnv: testAPIKeyEnv, } key, err := provider.ResolveAPIKey() @@ -256,36 +309,9 @@ func TestLoaderLoadEnvironmentSources(t *testing.T) { } } -func TestManagerUpsertEnvAndReload(t *testing.T) { - tempDir := t.TempDir() - loader := NewLoader(tempDir) - manager := NewManager(loader) - - if err := manager.UpsertEnv("OPENAI_API_KEY", "first"); err != nil { - t.Fatalf("UpsertEnv() error = %v", err) - } - if err := manager.UpsertEnv("OPENAI_API_KEY", "second"); err != nil { - t.Fatalf("UpsertEnv() overwrite error = %v", err) - } - if err := loader.OverloadManagedEnvironment(); err != nil { - t.Fatalf("OverloadManagedEnvironment() error = %v", err) - } - - data, err := os.ReadFile(loader.EnvPath()) - if err != nil { - t.Fatalf("ReadFile() error = %v", err) - } - if strings.Count(string(data), "OPENAI_API_KEY=") != 1 { - t.Fatalf("expected single env assignment, got %q", string(data)) - } - if got := strings.TrimSpace(os.Getenv("OPENAI_API_KEY")); got != "second" { - t.Fatalf("expected env value second, got %q", got) - } -} - func TestManagerConcurrentAccess(t *testing.T) { tempDir := t.TempDir() - manager := NewManager(NewLoader(tempDir)) + manager := NewManager(NewLoader(tempDir, testDefaultConfig())) if _, err := manager.Load(context.Background()); err != nil { t.Fatalf("Load() error = %v", err) } @@ -324,7 +350,7 @@ func TestManagerConcurrentAccess(t *testing.T) { wg.Wait() finalConfig := manager.Get() - finalConfig.ApplyDefaults() + finalConfig.ApplyDefaultsFrom(*testDefaultConfig()) if err := finalConfig.Validate(); err != nil { t.Fatalf("final config should validate, got %v", err) } @@ -336,28 +362,28 @@ func TestConfigApplyDefaultsFillsMissingFields(t *testing.T) { cfg := &Config{ Providers: []ProviderConfig{ { - Name: ProviderOpenAI, + Name: testProviderName, }, }, - SelectedProvider: ProviderOpenAI, + SelectedProvider: testProviderName, CurrentModel: "", Workdir: ".", } - cfg.ApplyDefaults() + cfg.ApplyDefaultsFrom(*testDefaultConfig()) provider, err := cfg.SelectedProviderConfig() if err != nil { t.Fatalf("SelectedProviderConfig() error = %v", err) } - if provider.BaseURL != DefaultOpenAIBaseURL { - t.Fatalf("expected default base url %q, got %q", DefaultOpenAIBaseURL, provider.BaseURL) + if provider.BaseURL != testBaseURL { + t.Fatalf("expected default base url %q, got %q", testBaseURL, provider.BaseURL) } - if provider.APIKeyEnv != DefaultOpenAIAPIKeyEnv { - t.Fatalf("expected default api key env %q, got %q", DefaultOpenAIAPIKeyEnv, provider.APIKeyEnv) + if provider.APIKeyEnv != testAPIKeyEnv { + t.Fatalf("expected default api key env %q, got %q", testAPIKeyEnv, provider.APIKeyEnv) } - if cfg.CurrentModel != DefaultOpenAIModel { - t.Fatalf("expected current model %q, got %q", DefaultOpenAIModel, cfg.CurrentModel) + if cfg.CurrentModel != testModel { + t.Fatalf("expected current model %q, got %q", testModel, cfg.CurrentModel) } if !filepath.IsAbs(cfg.Workdir) { t.Fatalf("expected absolute workdir, got %q", cfg.Workdir) @@ -367,8 +393,8 @@ func TestConfigApplyDefaultsFillsMissingFields(t *testing.T) { func TestConfigValidateFailures(t *testing.T) { t.Parallel() - validConfig := Default().Clone() - validConfig.ApplyDefaults() + validConfig := testDefaultConfig().Clone() + validConfig.ApplyDefaultsFrom(*testDefaultConfig()) tests := []struct { name string @@ -383,8 +409,8 @@ func TestConfigValidateFailures(t *testing.T) { { name: "no providers", config: &Config{ - SelectedProvider: ProviderOpenAI, - CurrentModel: DefaultOpenAIModel, + SelectedProvider: testProviderName, + CurrentModel: testModel, Workdir: filepath.Clean(t.TempDir()), }, expectErr: "providers is empty", @@ -393,11 +419,11 @@ func TestConfigValidateFailures(t *testing.T) { name: "duplicate providers", config: &Config{ Providers: []ProviderConfig{ - {Name: ProviderOpenAI, Type: ProviderOpenAI, BaseURL: DefaultOpenAIBaseURL, Model: DefaultOpenAIModel, APIKeyEnv: DefaultOpenAIAPIKeyEnv}, - {Name: ProviderOpenAI, Type: ProviderOpenAI, BaseURL: DefaultOpenAIBaseURL, Model: DefaultOpenAIModel, APIKeyEnv: DefaultOpenAIAPIKeyEnv}, + testDefaultProviderConfig(), + testDefaultProviderConfig(), }, - SelectedProvider: ProviderOpenAI, - CurrentModel: DefaultOpenAIModel, + SelectedProvider: testProviderName, + CurrentModel: testModel, Workdir: filepath.Clean(t.TempDir()), }, expectErr: "duplicate provider name", @@ -406,10 +432,10 @@ func TestConfigValidateFailures(t *testing.T) { name: "relative workdir", config: &Config{ Providers: []ProviderConfig{ - {Name: ProviderOpenAI, Type: ProviderOpenAI, BaseURL: DefaultOpenAIBaseURL, Model: DefaultOpenAIModel, APIKeyEnv: DefaultOpenAIAPIKeyEnv}, + testDefaultProviderConfig(), }, - SelectedProvider: ProviderOpenAI, - CurrentModel: DefaultOpenAIModel, + SelectedProvider: testProviderName, + CurrentModel: testModel, Workdir: ".", }, expectErr: "workdir must be absolute", @@ -451,36 +477,36 @@ func TestProviderConfigValidateFailures(t *testing.T) { expectErr: "provider name is empty", }, { - name: "missing type", + name: "missing driver", provider: ProviderConfig{ - Name: ProviderOpenAI, + Name: testProviderName, }, - expectErr: "type is empty", + expectErr: "driver is empty", }, { name: "missing base url", provider: ProviderConfig{ - Name: ProviderOpenAI, - Type: ProviderOpenAI, + Name: testProviderName, + Driver: testProviderName, }, expectErr: "base_url is empty", }, { name: "missing model", provider: ProviderConfig{ - Name: ProviderOpenAI, - Type: ProviderOpenAI, - BaseURL: DefaultOpenAIBaseURL, + Name: testProviderName, + Driver: testProviderName, + BaseURL: testBaseURL, }, expectErr: "model is empty", }, { name: "missing api key env", provider: ProviderConfig{ - Name: ProviderOpenAI, - Type: ProviderOpenAI, - BaseURL: DefaultOpenAIBaseURL, - Model: DefaultOpenAIModel, + Name: testProviderName, + Driver: testProviderName, + BaseURL: testBaseURL, + Model: testModel, }, expectErr: "api_key_env is empty", }, @@ -499,9 +525,9 @@ func TestProviderConfigValidateFailures(t *testing.T) { } func TestProviderLookupAndResolveSelectedProvider(t *testing.T) { - t.Setenv(DefaultOpenAIAPIKeyEnv, "lookup-key") + t.Setenv(testAPIKeyEnv, "lookup-key") - manager := NewManager(NewLoader(t.TempDir())) + manager := NewManager(NewLoader(t.TempDir(), testDefaultConfig())) if _, err := manager.Load(context.Background()); err != nil { t.Fatalf("Load() error = %v", err) } @@ -511,8 +537,8 @@ func TestProviderLookupAndResolveSelectedProvider(t *testing.T) { if err != nil { t.Fatalf("ProviderByName() error = %v", err) } - if provider.Name != ProviderOpenAI { - t.Fatalf("expected provider %q, got %q", ProviderOpenAI, provider.Name) + if provider.Name != testProviderName { + t.Fatalf("expected provider %q, got %q", testProviderName, provider.Name) } resolved, err := manager.ResolvedSelectedProvider() @@ -526,7 +552,7 @@ func TestProviderLookupAndResolveSelectedProvider(t *testing.T) { func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { tempDir := t.TempDir() - loader := NewLoader(tempDir) + loader := NewLoader(tempDir, testDefaultConfig()) cfg, err := loader.Load(context.Background()) if err != nil { @@ -546,6 +572,18 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { t.Fatalf("Save() error = %v", err) } + data, err := os.ReadFile(loader.ConfigPath()) + if err != nil { + t.Fatalf("read config file: %v", err) + } + text := string(data) + if !strings.Contains(text, "provider_overrides:") { + t.Fatalf("expected persisted provider overrides, got:\n%s", text) + } + if strings.Contains(text, "\nproviders:") || strings.HasPrefix(text, "providers:") { + t.Fatalf("expected persisted config to omit full providers list, got:\n%s", text) + } + reloaded, err := loader.Load(context.Background()) if err != nil { t.Fatalf("Load() reload error = %v", err) @@ -555,6 +593,79 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { } } +func TestLoaderUsesUpdatedBuiltinProviderWhenUserHasNoOverride(t *testing.T) { + tempDir := t.TempDir() + + initialDefaults := testDefaultConfig() + initialDefaults.Providers[0].BaseURL = "https://old.example/v1" + initialDefaults.CurrentModel = initialDefaults.Providers[0].Model + + initialLoader := NewLoader(tempDir, initialDefaults) + if _, err := initialLoader.Load(context.Background()); err != nil { + t.Fatalf("initial Load() error = %v", err) + } + + updatedDefaults := testDefaultConfig() + updatedDefaults.Providers[0].BaseURL = "https://new.example/v1" + updatedDefaults.CurrentModel = updatedDefaults.Providers[0].Model + + updatedLoader := NewLoader(tempDir, updatedDefaults) + reloaded, err := updatedLoader.Load(context.Background()) + if err != nil { + t.Fatalf("updated Load() error = %v", err) + } + + provider, err := reloaded.SelectedProviderConfig() + if err != nil { + t.Fatalf("SelectedProviderConfig() error = %v", err) + } + if provider.BaseURL != "https://new.example/v1" { + t.Fatalf("expected latest builtin base url, got %q", provider.BaseURL) + } +} + +func TestDeriveAndMergeProviderOverrides(t *testing.T) { + defaults := []ProviderConfig{testDefaultProviderConfig()} + current := []ProviderConfig{ + { + Name: testProviderName, + Driver: testProviderName, + BaseURL: "https://override.example/v1", + Model: "gpt-5.4", + APIKeyEnv: "AI_API_KEY", + }, + { + Name: "openai-alt", + Driver: testProviderName, + BaseURL: "https://alt.example/v1", + Model: "gpt-4o", + APIKeyEnv: "ALT_KEY", + }, + } + + overrides := DeriveProviderOverrides(current, defaults) + merged := MergeProviderOverrides(defaults, overrides) + if len(merged) != 2 { + t.Fatalf("expected 2 merged providers, got %d", len(merged)) + } + + base, err := (&Config{Providers: merged, SelectedProvider: testProviderName}).SelectedProviderConfig() + if err != nil { + t.Fatalf("SelectedProviderConfig() error = %v", err) + } + if base.BaseURL != "https://override.example/v1" || base.Model != "gpt-5.4" || base.APIKeyEnv != "AI_API_KEY" { + t.Fatalf("unexpected merged builtin override: %+v", base) + } + + alt, err := (&Config{Providers: merged}).ProviderByName("openai-alt") + if err != nil { + t.Fatalf("ProviderByName(openai-alt) error = %v", err) + } + if alt.Driver != testProviderName || alt.BaseURL != "https://alt.example/v1" { + t.Fatalf("unexpected merged custom provider: %+v", alt) + } +} + func TestNormalizeWorkdirAndClone(t *testing.T) { t.Parallel() @@ -613,12 +724,12 @@ func TestNormalizeWorkdirAndClone(t *testing.T) { var nilConfig *Config clonedNil := nilConfig.Clone() - clonedNil.ApplyDefaults() + clonedNil.ApplyDefaultsFrom(*testDefaultConfig()) if err := clonedNil.Validate(); err != nil { t.Fatalf("cloned nil config should validate, got %v", err) } - cfg := Default() + cfg := testDefaultConfig() cloned := cfg.Clone() cloned.CurrentModel = "modified" if cfg.CurrentModel == cloned.CurrentModel { @@ -628,7 +739,7 @@ func TestNormalizeWorkdirAndClone(t *testing.T) { func TestManagerHelperMethodsAndReloads(t *testing.T) { tempDir := t.TempDir() - manager := NewManager(NewLoader(tempDir)) + manager := NewManager(NewLoader(tempDir, testDefaultConfig())) if _, err := manager.Load(context.Background()); err != nil { t.Fatalf("Load() error = %v", err) @@ -642,26 +753,12 @@ func TestManagerHelperMethodsAndReloads(t *testing.T) { if got := manager.ConfigPath(); got != filepath.Join(tempDir, configName) { t.Fatalf("expected config path %q, got %q", filepath.Join(tempDir, configName), got) } - if got := manager.EnvPath(); got != filepath.Join(tempDir, envName) { - t.Fatalf("expected env path %q, got %q", filepath.Join(tempDir, envName), got) - } - - if err := manager.UpsertEnv(DefaultOpenAIAPIKeyEnv, "manager-key"); err != nil { - t.Fatalf("UpsertEnv() error = %v", err) - } - manager.ReloadEnvironment() - if err := manager.OverloadManagedEnvironment(); err != nil { - t.Fatalf("OverloadManagedEnvironment() error = %v", err) - } - if got := os.Getenv(DefaultOpenAIAPIKeyEnv); got != "manager-key" { - t.Fatalf("expected env value %q, got %q", "manager-key", got) - } } -func TestLoaderDefaultsAndBuiltinCatalog(t *testing.T) { +func TestLoaderDefaultsAndProviderDefaults(t *testing.T) { t.Parallel() - loader := NewLoader("") + loader := NewLoader("", testDefaultConfig()) if loader.BaseDir() == "" { t.Fatalf("expected default base dir to be set") } @@ -672,120 +769,33 @@ func TestLoaderDefaultsAndBuiltinCatalog(t *testing.T) { t.Fatalf("expected defaultBaseDir() to return a value") } - catalog := BuiltinModelCatalog() - if len(catalog) == 0 { - t.Fatalf("expected builtin model catalog to be non-empty") + defaultCfg := testDefaultConfig() + if len(defaultCfg.Providers) != 1 { + t.Fatalf("expected 1 default provider, got %d", len(defaultCfg.Providers)) } - if catalog[0].Name == "" || catalog[0].Description == "" { - t.Fatalf("expected model catalog entries to contain name and description") + if defaultCfg.Providers[0].Name != testProviderName { + t.Fatalf("expected default provider %q, got %q", testProviderName, defaultCfg.Providers[0].Name) } } -func TestBuiltinProviderConfigs(t *testing.T) { - t.Parallel() - - providers := BuiltinProviderConfigs() - if len(providers) != 3 { - t.Fatalf("expected 3 builtin providers, got %d", len(providers)) - } - - openai, err := BuiltinProviderConfig(ProviderOpenAI) - if err != nil { - t.Fatalf("BuiltinProviderConfig(%q) error = %v", ProviderOpenAI, err) - } - if openai.BaseURL != DefaultOpenAIBaseURL || openai.Model != DefaultOpenAIModel { - t.Fatalf("unexpected openai preset: %+v", openai) - } - - anthropic, err := BuiltinProviderConfig(ProviderAnthropic) - if err != nil { - t.Fatalf("BuiltinProviderConfig(%q) error = %v", ProviderAnthropic, err) - } - if anthropic.BaseURL != DefaultAnthropicBaseURL || anthropic.Model != DefaultAnthropicModel { - t.Fatalf("unexpected anthropic preset: %+v", anthropic) - } - - gemini, err := BuiltinProviderConfig(ProviderGemini) - if err != nil { - t.Fatalf("BuiltinProviderConfig(%q) error = %v", ProviderGemini, err) - } - if gemini.BaseURL != DefaultGeminiBaseURL || gemini.Model != DefaultGeminiModel { - t.Fatalf("unexpected gemini preset: %+v", gemini) - } - - if _, err := BuiltinProviderConfig("unknown"); err == nil { - t.Fatalf("expected unknown provider lookup to fail") - } -} - -func TestProviderPresetSlices(t *testing.T) { - t.Parallel() - - mvpProviders := MVPProviderConfigs() - if len(mvpProviders) != 1 { - t.Fatalf("expected 1 mvp provider, got %d", len(mvpProviders)) - } - if got := mvpProviders[0].Name; got != ProviderOpenAI { - t.Fatalf("expected mvp provider %q, got %q", ProviderOpenAI, got) - } - - scaffoldProviders := ScaffoldProviderConfigs() - if len(scaffoldProviders) != 2 { - t.Fatalf("expected 2 scaffold providers, got %d", len(scaffoldProviders)) - } - if scaffoldProviders[0].Name != ProviderAnthropic || scaffoldProviders[1].Name != ProviderGemini { - t.Fatalf("unexpected scaffold providers: %+v", scaffoldProviders) - } -} - -func TestBuiltinModelCatalogForProvider(t *testing.T) { - t.Parallel() - - cases := []struct { - name string - providerName string - wantCount int - wantFirst string - }{ - { - name: "openai", - providerName: ProviderOpenAI, - wantCount: 4, - wantFirst: DefaultOpenAIModel, - }, - { - name: "anthropic", - providerName: ProviderAnthropic, - wantCount: 1, - wantFirst: DefaultAnthropicModel, - }, - { - name: "gemini", - providerName: ProviderGemini, - wantCount: 1, - wantFirst: DefaultGeminiModel, - }, - { - name: "unknown", - providerName: "unknown", - wantCount: 0, - }, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - catalog := BuiltinModelCatalogForProvider(tc.providerName) - if len(catalog) != tc.wantCount { - t.Fatalf("BuiltinModelCatalogForProvider(%q) count = %d, want %d", tc.providerName, len(catalog), tc.wantCount) +func TestConstructorsRejectMissingDependencies(t *testing.T) { + t.Run("NewLoader panics on nil defaults", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatalf("expected NewLoader to panic when defaults are nil") } - if tc.wantCount > 0 && catalog[0].Name != tc.wantFirst { - t.Fatalf("BuiltinModelCatalogForProvider(%q) first model = %q, want %q", tc.providerName, catalog[0].Name, tc.wantFirst) + }() + _ = NewLoader(t.TempDir(), nil) + }) + + t.Run("NewManager panics on nil loader", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatalf("expected NewManager to panic when loader is nil") } - }) - } + }() + _ = NewManager(nil) + }) } func restoreEnv(t *testing.T, key string) { diff --git a/internal/config/loader.go b/internal/config/loader.go index adcee01d..7e214594 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -19,14 +19,42 @@ const ( ) type Loader struct { - baseDir string + baseDir string + defaults Config } -func NewLoader(baseDir string) *Loader { +type persistedConfig struct { + ProviderOverrides []ProviderOverride `yaml:"provider_overrides,omitempty"` + SelectedProvider string `yaml:"selected_provider"` + CurrentModel string `yaml:"current_model"` + Workdir string `yaml:"workdir"` + Shell string `yaml:"shell"` + MaxLoops int `yaml:"max_loops,omitempty"` + ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` + + // Legacy read-only field. New saves never emit it. + Providers []ProviderConfig `yaml:"providers,omitempty"` +} + +func NewLoader(baseDir string, defaults *Config) *Loader { + if defaults == nil { + panic("config: loader defaults are nil") + } + if strings.TrimSpace(baseDir) == "" { baseDir = defaultBaseDir() } - return &Loader{baseDir: baseDir} + + snapshot := defaults.Clone() + snapshot.ApplyDefaultsFrom(*Default()) + if err := snapshot.Validate(); err != nil { + panic(fmt.Sprintf("config: invalid loader defaults: %v", err)) + } + + return &Loader{ + baseDir: baseDir, + defaults: snapshot, + } } func (l *Loader) BaseDir() string { @@ -41,6 +69,10 @@ func (l *Loader) EnvPath() string { return filepath.Join(l.baseDir, envName) } +func (l *Loader) DefaultConfig() Config { + return l.defaults.Clone() +} + func (l *Loader) Load(ctx context.Context) (*Config, error) { if err := ctx.Err(); err != nil { return nil, err @@ -52,7 +84,8 @@ func (l *Loader) Load(ctx context.Context) (*Config, error) { return nil, fmt.Errorf("config: create config dir: %w", err) } if _, err := os.Stat(l.ConfigPath()); os.IsNotExist(err) { - if err := l.Save(ctx, Default()); err != nil { + defaultCfg := l.DefaultConfig() + if err := l.Save(ctx, &defaultCfg); err != nil { return nil, err } } @@ -62,14 +95,19 @@ func (l *Loader) Load(ctx context.Context) (*Config, error) { return nil, fmt.Errorf("config: read config file: %w", err) } - cfg, err := parseConfig(data) + cfg, err := parseConfig(data, l.defaults) if err != nil { return nil, fmt.Errorf("config: parse config file: %w", err) } - cfg.ApplyDefaults() + cfg.ApplyDefaultsFrom(l.defaults) if err := cfg.Validate(); err != nil { return nil, err } + if requiresConfigRewrite(data) { + if err := l.Save(ctx, cfg); err != nil { + return nil, err + } + } return cfg, nil } @@ -83,12 +121,22 @@ func (l *Loader) Save(ctx context.Context, cfg *Config) error { } snapshot := cfg.Clone() - snapshot.ApplyDefaults() + snapshot.ApplyDefaultsFrom(l.defaults) if err := snapshot.Validate(); err != nil { return err } - data, err := yaml.Marshal(&snapshot) + file := persistedConfig{ + ProviderOverrides: DeriveProviderOverrides(snapshot.Providers, l.defaults.Providers), + SelectedProvider: snapshot.SelectedProvider, + CurrentModel: snapshot.CurrentModel, + Workdir: snapshot.Workdir, + Shell: snapshot.Shell, + MaxLoops: snapshot.MaxLoops, + ToolTimeoutSec: snapshot.ToolTimeoutSec, + } + + data, err := yaml.Marshal(&file) if err != nil { return fmt.Errorf("config: marshal config: %w", err) } @@ -109,10 +157,6 @@ func (l *Loader) LoadEnvironment() { _ = godotenv.Load(l.EnvPath()) } -func (l *Loader) OverloadManagedEnvironment() error { - return godotenv.Overload(l.EnvPath()) -} - func defaultBaseDir() string { home, err := os.UserHomeDir() if err != nil { @@ -121,17 +165,17 @@ func defaultBaseDir() string { return filepath.Join(home, dirName) } -func parseConfig(data []byte) (*Config, error) { +func parseConfig(data []byte, defaults Config) (*Config, error) { if len(bytes.TrimSpace(data)) == 0 { - return Default(), nil + return &Config{}, nil } - cfg, currentErr := parseCurrentConfig(data) + cfg, currentErr := parseCurrentConfig(data, defaults) if currentErr == nil { return cfg, nil } - legacy, legacyErr := parseLegacyConfig(data) + legacy, legacyErr := parseLegacyConfig(data, defaults) if legacyErr == nil { return legacy, nil } @@ -161,35 +205,51 @@ type legacyProviderConfig struct { Models []string `yaml:"models"` } -func parseCurrentConfig(data []byte) (*Config, error) { - cfg := &Config{} - if err := yaml.Unmarshal(data, cfg); err != nil { +func parseCurrentConfig(data []byte, defaults Config) (*Config, error) { + var file persistedConfig + if err := yaml.Unmarshal(data, &file); err != nil { return nil, err } var aliases aliasConfig if err := yaml.Unmarshal(data, &aliases); err == nil { - if cfg.MaxLoops == 0 && aliases.MaxLoop > 0 { - cfg.MaxLoops = aliases.MaxLoop + if file.MaxLoops == 0 && aliases.MaxLoop > 0 { + file.MaxLoops = aliases.MaxLoop } - if strings.TrimSpace(cfg.Workdir) == "" && strings.TrimSpace(aliases.WorkspaceRoot) != "" { - cfg.Workdir = aliases.WorkspaceRoot + if strings.TrimSpace(file.Workdir) == "" && strings.TrimSpace(aliases.WorkspaceRoot) != "" { + file.Workdir = aliases.WorkspaceRoot } } + cfg := &Config{ + SelectedProvider: strings.TrimSpace(file.SelectedProvider), + CurrentModel: strings.TrimSpace(file.CurrentModel), + Workdir: strings.TrimSpace(file.Workdir), + Shell: strings.TrimSpace(file.Shell), + MaxLoops: file.MaxLoops, + ToolTimeoutSec: file.ToolTimeoutSec, + } + + switch { + case len(file.ProviderOverrides) > 0: + cfg.Providers = MergeProviderOverrides(defaults.Providers, file.ProviderOverrides) + case len(file.Providers) > 0: + cfg.Providers = MergeProviderOverrides(defaults.Providers, DeriveProviderOverrides(file.Providers, defaults.Providers)) + } + return cfg, nil } -func parseLegacyConfig(data []byte) (*Config, error) { +func parseLegacyConfig(data []byte, defaults Config) (*Config, error) { var legacy legacyConfig if err := yaml.Unmarshal(data, &legacy); err != nil { return nil, err } - return convertLegacyConfig(legacy), nil + return convertLegacyConfig(legacy, defaults), nil } -func convertLegacyConfig(in legacyConfig) *Config { +func convertLegacyConfig(in legacyConfig, defaults Config) *Config { out := &Config{ SelectedProvider: strings.TrimSpace(in.SelectedProvider), CurrentModel: strings.TrimSpace(in.CurrentModel), @@ -207,13 +267,14 @@ func convertLegacyConfig(in legacyConfig) *Config { out.Providers = append(out.Providers, ProviderConfig{ Name: strings.TrimSpace(name), - Type: strings.TrimSpace(provider.Type), + Driver: strings.TrimSpace(provider.Type), BaseURL: strings.TrimSpace(provider.BaseURL), Model: strings.TrimSpace(model), APIKeyEnv: strings.TrimSpace(provider.APIKeyEnv), }) } + out.Providers = MergeProviderOverrides(defaults.Providers, DeriveProviderOverrides(out.Providers, defaults.Providers)) return out } @@ -225,3 +286,19 @@ func firstNonEmpty(items ...string) string { } return "" } + +func requiresConfigRewrite(data []byte) bool { + text := strings.TrimSpace(string(data)) + switch { + case text == "": + return false + case strings.Contains(text, "provider_overrides:"): + return strings.Contains(text, "workspace_root:") || strings.Contains(text, "max_loop:") + case strings.Contains(text, "\nproviders:") || strings.HasPrefix(text, "providers:"): + return true + case strings.Contains(text, "workspace_root:") || strings.Contains(text, "max_loop:"): + return true + default: + return false + } +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index af85806e..0f37a271 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -11,7 +11,7 @@ import ( func TestLoaderLoadMissingConfigCreatesDefault(t *testing.T) { t.Parallel() - loader := NewLoader(t.TempDir()) + loader := NewLoader(t.TempDir(), testDefaultConfig()) if _, err := os.Stat(loader.ConfigPath()); !os.IsNotExist(err) { t.Fatalf("expected config file to be missing before load, got %v", err) } @@ -31,7 +31,7 @@ func TestLoaderLoadMissingConfigCreatesDefault(t *testing.T) { func TestLoaderLoadMalformedYAML(t *testing.T) { t.Parallel() - loader := NewLoader(t.TempDir()) + loader := NewLoader(t.TempDir(), testDefaultConfig()) if err := os.MkdirAll(loader.BaseDir(), 0o755); err != nil { t.Fatalf("mkdir base dir: %v", err) } @@ -47,8 +47,8 @@ func TestLoaderLoadMalformedYAML(t *testing.T) { func TestLoaderLoadEnvironmentSilentlyIgnoresDotEnvFailures(t *testing.T) { tempDir := t.TempDir() - restoreEnv(t, DefaultOpenAIAPIKeyEnv) - _ = os.Unsetenv(DefaultOpenAIAPIKeyEnv) + restoreEnv(t, testAPIKeyEnv) + _ = os.Unsetenv(testAPIKeyEnv) previousWD, err := os.Getwd() if err != nil { @@ -65,14 +65,14 @@ func TestLoaderLoadEnvironmentSilentlyIgnoresDotEnvFailures(t *testing.T) { t.Fatalf("mkdir cwd .env dir: %v", err) } - loader := NewLoader(filepath.Join(tempDir, ".neocode")) + loader := NewLoader(filepath.Join(tempDir, ".neocode"), testDefaultConfig()) if err := os.MkdirAll(loader.EnvPath(), 0o755); err != nil { t.Fatalf("mkdir managed .env dir: %v", err) } loader.LoadEnvironment() - if got := os.Getenv(DefaultOpenAIAPIKeyEnv); got != "" { + if got := os.Getenv(testAPIKeyEnv); got != "" { t.Fatalf("expected env to stay empty when dotenv loading fails, got %q", got) } } @@ -86,9 +86,58 @@ func TestLoaderLoadInvalidBaseDir(t *testing.T) { t.Fatalf("write base file: %v", err) } - loader := NewLoader(baseFile) + loader := NewLoader(baseFile, testDefaultConfig()) _, err := loader.Load(context.Background()) if err == nil || !strings.Contains(err.Error(), "create config dir") { t.Fatalf("expected invalid base dir error, got %v", err) } } + +func TestLoaderRewritesLegacyProvidersFormatOnLoad(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + if err := os.MkdirAll(loader.BaseDir(), 0o755); err != nil { + t.Fatalf("mkdir base dir: %v", err) + } + + legacy := ` +selected_provider: openai +current_model: gpt-5.4 +workdir: . +shell: powershell +providers: + - name: openai + type: openai + base_url: https://example.com/v1 + model: gpt-5.4 + api_key_env: OPENAI_API_KEY +` + if err := os.WriteFile(loader.ConfigPath(), []byte(strings.TrimSpace(legacy)+"\n"), 0o644); err != nil { + t.Fatalf("write legacy config: %v", err) + } + + cfg, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + provider, err := cfg.SelectedProviderConfig() + if err != nil { + t.Fatalf("SelectedProviderConfig() error = %v", err) + } + if provider.BaseURL != "https://example.com/v1" { + t.Fatalf("expected migrated provider base url, got %q", provider.BaseURL) + } + + rewritten, err := os.ReadFile(loader.ConfigPath()) + if err != nil { + t.Fatalf("read rewritten config: %v", err) + } + text := string(rewritten) + if !strings.Contains(text, "provider_overrides:") { + t.Fatalf("expected rewritten config to use provider_overrides, got:\n%s", text) + } + if strings.Contains(text, "\nproviders:") || strings.HasPrefix(text, "providers:") { + t.Fatalf("expected rewritten config to omit providers list, got:\n%s", text) + } +} diff --git a/internal/config/manager.go b/internal/config/manager.go index bfb7beb0..743a9758 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -1,11 +1,8 @@ package config import ( - "bufio" "context" "errors" - "os" - "strings" "sync" ) @@ -17,12 +14,15 @@ type Manager struct { func NewManager(loader *Loader) *Manager { if loader == nil { - loader = NewLoader("") + panic("config: loader is nil") } return &Manager{ loader: loader, - config: Default(), + config: func() *Config { + cfg := loader.DefaultConfig() + return &cfg + }(), } } @@ -73,7 +73,7 @@ func (m *Manager) Update(ctx context.Context, mutate func(*Config) error) error return err } - next.ApplyDefaults() + next.ApplyDefaultsFrom(m.loader.DefaultConfig()) if err := next.Validate(); err != nil { return err } @@ -106,55 +106,3 @@ func (m *Manager) BaseDir() string { func (m *Manager) ConfigPath() string { return m.loader.ConfigPath() } - -func (m *Manager) EnvPath() string { - return m.loader.EnvPath() -} - -func (m *Manager) ReloadEnvironment() { - m.loader.LoadEnvironment() -} - -func (m *Manager) OverloadManagedEnvironment() error { - return m.loader.OverloadManagedEnvironment() -} - -func (m *Manager) UpsertEnv(key string, value string) error { - key = strings.TrimSpace(key) - if key == "" { - return errors.New("config: env key is empty") - } - - lines := []string{} - if data, err := os.ReadFile(m.EnvPath()); err == nil { - scanner := bufio.NewScanner(strings.NewReader(string(data))) - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - } - - replaced := false - for i, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - if strings.HasPrefix(trimmed, key+"=") { - lines[i] = key + "=" + value - replaced = true - } - } - if !replaced { - lines = append(lines, key+"="+value) - } - - content := strings.Join(lines, "\n") - if !strings.HasSuffix(content, "\n") { - content += "\n" - } - - if err := os.MkdirAll(m.BaseDir(), 0o755); err != nil { - return err - } - return os.WriteFile(m.EnvPath(), []byte(content), 0o644) -} diff --git a/internal/config/model.go b/internal/config/model.go index 1eb867c6..10093eeb 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -10,30 +10,13 @@ import ( ) const ( - ProviderOpenAI = "openai" - ProviderAnthropic = "anthropic" - ProviderGemini = "gemini" - - DefaultOpenAIBaseURL = "https://api.openai.com/v1" - DefaultAnthropicBaseURL = "https://api.anthropic.com" - DefaultGeminiBaseURL = "https://generativelanguage.googleapis.com" - - DefaultOpenAIModel = "gpt-4.1" - DefaultAnthropicModel = "claude-3-7-sonnet-latest" - DefaultGeminiModel = "gemini-2.5-pro" - - DefaultOpenAIAPIKeyEnv = "OPENAI_API_KEY" - DefaultAnthropicAPIKeyEnv = "ANTHROPIC_API_KEY" - DefaultGeminiAPIKeyEnv = "GEMINI_API_KEY" - - DefaultSelectedProvider = ProviderOpenAI - DefaultWorkdir = "." - DefaultMaxLoops = 8 - DefaultToolTimeoutSec = 20 + DefaultWorkdir = "." + DefaultMaxLoops = 8 + DefaultToolTimeoutSec = 20 ) type Config struct { - Providers []ProviderConfig `yaml:"providers"` + Providers []ProviderConfig `yaml:"-"` SelectedProvider string `yaml:"selected_provider"` CurrentModel string `yaml:"current_model"` Workdir string `yaml:"workdir"` @@ -44,7 +27,7 @@ type Config struct { type ProviderConfig struct { Name string `yaml:"name"` - Type string `yaml:"type"` + Driver string `yaml:"type"` BaseURL string `yaml:"base_url"` Model string `yaml:"model"` APIKeyEnv string `yaml:"api_key_env"` @@ -55,125 +38,20 @@ type ResolvedProviderConfig struct { APIKey string `yaml:"-"` } -type ModelOption struct { - Name string - Description string -} - -// BuiltinProviderConfigs returns all known provider presets, including scaffolded ones. -func BuiltinProviderConfigs() []ProviderConfig { - return []ProviderConfig{ - { - Name: ProviderOpenAI, - Type: ProviderOpenAI, - BaseURL: DefaultOpenAIBaseURL, - Model: DefaultOpenAIModel, - APIKeyEnv: DefaultOpenAIAPIKeyEnv, - }, - { - Name: ProviderAnthropic, - Type: ProviderAnthropic, - BaseURL: DefaultAnthropicBaseURL, - Model: DefaultAnthropicModel, - APIKeyEnv: DefaultAnthropicAPIKeyEnv, - }, - { - Name: ProviderGemini, - Type: ProviderGemini, - BaseURL: DefaultGeminiBaseURL, - Model: DefaultGeminiModel, - APIKeyEnv: DefaultGeminiAPIKeyEnv, - }, - } -} - -// MVPProviderConfigs returns the provider presets that should be enabled by default in the MVP. -func MVPProviderConfigs() []ProviderConfig { - return []ProviderConfig{ - { - Name: ProviderOpenAI, - Type: ProviderOpenAI, - BaseURL: DefaultOpenAIBaseURL, - Model: DefaultOpenAIModel, - APIKeyEnv: DefaultOpenAIAPIKeyEnv, - }, - } -} - -// ScaffoldProviderConfigs returns provider presets that are prepared but not officially enabled in the MVP. -func ScaffoldProviderConfigs() []ProviderConfig { - return []ProviderConfig{ - { - Name: ProviderAnthropic, - Type: ProviderAnthropic, - BaseURL: DefaultAnthropicBaseURL, - Model: DefaultAnthropicModel, - APIKeyEnv: DefaultAnthropicAPIKeyEnv, - }, - { - Name: ProviderGemini, - Type: ProviderGemini, - BaseURL: DefaultGeminiBaseURL, - Model: DefaultGeminiModel, - APIKeyEnv: DefaultGeminiAPIKeyEnv, - }, - } -} - -// BuiltinProviderConfig returns the preset configuration for a known provider. -func BuiltinProviderConfig(name string) (ProviderConfig, error) { - target := strings.ToLower(strings.TrimSpace(name)) - for _, provider := range BuiltinProviderConfigs() { - if strings.ToLower(strings.TrimSpace(provider.Name)) == target { - return provider, nil - } - } - return ProviderConfig{}, fmt.Errorf("config: built-in provider %q not found", name) +type ProviderOverride struct { + Name string `yaml:"name"` + Driver string `yaml:"type,omitempty"` + BaseURL string `yaml:"base_url,omitempty"` + Model string `yaml:"model,omitempty"` + APIKeyEnv string `yaml:"api_key_env,omitempty"` } func Default() *Config { return &Config{ - Providers: BuiltinProviderConfigs(), - SelectedProvider: DefaultSelectedProvider, - CurrentModel: DefaultOpenAIModel, - Workdir: DefaultWorkdir, - Shell: defaultShell(), - MaxLoops: DefaultMaxLoops, - ToolTimeoutSec: DefaultToolTimeoutSec, - } -} - -func BuiltinModelCatalog() []ModelOption { - return append([]ModelOption(nil), - ModelOption{Name: DefaultOpenAIModel, Description: "Stable OpenAI default model"}, - ModelOption{Name: "gpt-4o", Description: "Fast general-purpose OpenAI model"}, - ModelOption{Name: "gpt-5.4", Description: "Frontier reasoning and coding model"}, - ModelOption{Name: "gpt-5.3-codex", Description: "Code-focused GPT-5.3 variant"}, - ModelOption{Name: DefaultAnthropicModel, Description: "Balanced Anthropic coding model"}, - ModelOption{Name: DefaultGeminiModel, Description: "Default Gemini reasoning model"}, - ) -} - -// BuiltinModelCatalogForProvider returns the built-in model options for the given provider. -func BuiltinModelCatalogForProvider(providerName string) []ModelOption { - switch strings.ToLower(strings.TrimSpace(providerName)) { - case ProviderOpenAI: - return []ModelOption{ - {Name: DefaultOpenAIModel, Description: "Stable OpenAI default model"}, - {Name: "gpt-4o", Description: "Fast general-purpose OpenAI model"}, - {Name: "gpt-5.4", Description: "Frontier reasoning and coding model"}, - {Name: "gpt-5.3-codex", Description: "Code-focused GPT-5.3 variant"}, - } - case ProviderAnthropic: - return []ModelOption{ - {Name: DefaultAnthropicModel, Description: "Balanced Anthropic coding model"}, - } - case ProviderGemini: - return []ModelOption{ - {Name: DefaultGeminiModel, Description: "Default Gemini reasoning model"}, - } - default: - return nil + Workdir: DefaultWorkdir, + Shell: defaultShell(), + MaxLoops: DefaultMaxLoops, + ToolTimeoutSec: DefaultToolTimeoutSec, } } @@ -187,38 +65,38 @@ func (c *Config) Clone() Config { return clone } -func (c *Config) ApplyDefaults() { +func (c *Config) ApplyDefaultsFrom(defaults Config) { if c == nil { return } - def := Default() - if len(c.Providers) == 0 { - c.Providers = append([]ProviderConfig(nil), def.Providers...) + c.Providers = append([]ProviderConfig(nil), defaults.Providers...) } else { - c.Providers = applyProviderDefaults(c.Providers, def.Providers) + c.Providers = applyProviderDefaults(c.Providers, defaults.Providers) } if strings.TrimSpace(c.SelectedProvider) == "" { - c.SelectedProvider = def.SelectedProvider + c.SelectedProvider = defaults.SelectedProvider } if strings.TrimSpace(c.CurrentModel) == "" { if selected, err := c.SelectedProviderConfig(); err == nil { c.CurrentModel = selected.Model + } else if strings.TrimSpace(defaults.CurrentModel) != "" { + c.CurrentModel = defaults.CurrentModel } } if strings.TrimSpace(c.Workdir) == "" { - c.Workdir = def.Workdir + c.Workdir = defaults.Workdir } if strings.TrimSpace(c.Shell) == "" { - c.Shell = def.Shell + c.Shell = defaults.Shell } if c.MaxLoops <= 0 { - c.MaxLoops = def.MaxLoops + c.MaxLoops = defaults.MaxLoops } if c.ToolTimeoutSec <= 0 { - c.ToolTimeoutSec = def.ToolTimeoutSec + c.ToolTimeoutSec = defaults.ToolTimeoutSec } c.Workdir = normalizeWorkdir(c.Workdir) @@ -294,8 +172,8 @@ func (p ProviderConfig) Validate() error { if strings.TrimSpace(p.Name) == "" { return errors.New("provider name is empty") } - if strings.TrimSpace(p.Type) == "" { - return fmt.Errorf("provider %q type is empty", p.Name) + if strings.TrimSpace(p.Driver) == "" { + return fmt.Errorf("provider %q driver is empty", p.Name) } if strings.TrimSpace(p.BaseURL) == "" { return fmt.Errorf("provider %q base_url is empty", p.Name) @@ -352,8 +230,8 @@ func mergeProviderDefaults(provider ProviderConfig, defaults []ProviderConfig) P if strings.TrimSpace(provider.Name) == "" { provider.Name = base.Name } - if strings.TrimSpace(provider.Type) == "" { - provider.Type = base.Type + if strings.TrimSpace(provider.Driver) == "" { + provider.Driver = base.Driver } if strings.TrimSpace(provider.BaseURL) == "" { provider.BaseURL = base.BaseURL @@ -370,7 +248,7 @@ func mergeProviderDefaults(provider ProviderConfig, defaults []ProviderConfig) P func matchDefaultProvider(provider ProviderConfig, defaults []ProviderConfig) (ProviderConfig, bool) { name := strings.ToLower(strings.TrimSpace(provider.Name)) - kind := strings.ToLower(strings.TrimSpace(provider.Type)) + kind := strings.ToLower(strings.TrimSpace(provider.Driver)) for _, candidate := range defaults { if name != "" && strings.ToLower(candidate.Name) == name { @@ -378,7 +256,7 @@ func matchDefaultProvider(provider ProviderConfig, defaults []ProviderConfig) (P } } for _, candidate := range defaults { - if kind != "" && strings.ToLower(candidate.Type) == kind { + if kind != "" && strings.ToLower(candidate.Driver) == kind { return candidate, true } } @@ -386,6 +264,117 @@ func matchDefaultProvider(provider ProviderConfig, defaults []ProviderConfig) (P return ProviderConfig{}, false } +func MergeProviderOverrides(defaults []ProviderConfig, overrides []ProviderOverride) []ProviderConfig { + merged := append([]ProviderConfig(nil), defaults...) + indexByName := make(map[string]int, len(merged)) + for i, provider := range merged { + indexByName[strings.ToLower(strings.TrimSpace(provider.Name))] = i + } + + for _, override := range overrides { + name := strings.TrimSpace(override.Name) + if name == "" { + continue + } + + key := strings.ToLower(name) + if idx, ok := indexByName[key]; ok { + merged[idx] = applyProviderOverride(merged[idx], override) + continue + } + + custom := applyProviderOverride(ProviderConfig{}, override) + merged = append(merged, custom) + indexByName[key] = len(merged) - 1 + } + + return merged +} + +func DeriveProviderOverrides(providers []ProviderConfig, defaults []ProviderConfig) []ProviderOverride { + if len(providers) == 0 { + return nil + } + + defaultByName := make(map[string]ProviderConfig, len(defaults)) + for _, provider := range defaults { + defaultByName[strings.ToLower(strings.TrimSpace(provider.Name))] = provider + } + + overrides := make([]ProviderOverride, 0, len(providers)) + for _, provider := range providers { + key := strings.ToLower(strings.TrimSpace(provider.Name)) + base, ok := defaultByName[key] + if !ok { + override := ProviderOverride{ + Name: strings.TrimSpace(provider.Name), + Driver: strings.TrimSpace(provider.Driver), + BaseURL: strings.TrimSpace(provider.BaseURL), + Model: strings.TrimSpace(provider.Model), + APIKeyEnv: strings.TrimSpace(provider.APIKeyEnv), + } + if override.Name == "" { + continue + } + overrides = append(overrides, override) + continue + } + + override := diffProviderOverride(base, provider) + if hasProviderOverrideChanges(override) { + overrides = append(overrides, override) + } + } + + return overrides +} + +func applyProviderOverride(base ProviderConfig, override ProviderOverride) ProviderConfig { + if name := strings.TrimSpace(override.Name); name != "" { + base.Name = name + } + if driver := strings.TrimSpace(override.Driver); driver != "" { + base.Driver = driver + } + if baseURL := strings.TrimSpace(override.BaseURL); baseURL != "" { + base.BaseURL = baseURL + } + if model := strings.TrimSpace(override.Model); model != "" { + base.Model = model + } + if apiKeyEnv := strings.TrimSpace(override.APIKeyEnv); apiKeyEnv != "" { + base.APIKeyEnv = apiKeyEnv + } + + return base +} + +func diffProviderOverride(base ProviderConfig, current ProviderConfig) ProviderOverride { + override := ProviderOverride{ + Name: strings.TrimSpace(current.Name), + } + if strings.TrimSpace(current.Driver) != strings.TrimSpace(base.Driver) { + override.Driver = strings.TrimSpace(current.Driver) + } + if strings.TrimSpace(current.BaseURL) != strings.TrimSpace(base.BaseURL) { + override.BaseURL = strings.TrimSpace(current.BaseURL) + } + if strings.TrimSpace(current.Model) != strings.TrimSpace(base.Model) { + override.Model = strings.TrimSpace(current.Model) + } + if strings.TrimSpace(current.APIKeyEnv) != strings.TrimSpace(base.APIKeyEnv) { + override.APIKeyEnv = strings.TrimSpace(current.APIKeyEnv) + } + return override +} + +func hasProviderOverrideChanges(override ProviderOverride) bool { + return strings.TrimSpace(override.Driver) != "" || + strings.TrimSpace(override.BaseURL) != "" || + strings.TrimSpace(override.Model) != "" || + strings.TrimSpace(override.APIKeyEnv) != "" +} + func normalizeWorkdir(workdir string) string { workdir = strings.TrimSpace(workdir) if workdir == "" { diff --git a/internal/provider/anthropic/provider.go b/internal/provider/anthropic/provider.go deleted file mode 100644 index 1513fd75..00000000 --- a/internal/provider/anthropic/provider.go +++ /dev/null @@ -1,35 +0,0 @@ -package anthropic - -import ( - "context" - - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" -) - -type Provider struct { - cfg config.ProviderConfig -} - -func New(cfg config.ProviderConfig) *Provider { - return &Provider{cfg: cfg} -} - -func (p *Provider) Name() string { - return p.cfg.Name -} - -func (p *Provider) Descriptor() provider.ProviderDescriptor { - return provider.ProviderDescriptor{ - Name: p.cfg.Name, - DisplayName: "Anthropic", - SupportLevel: provider.SupportLevelScaffolded, - MVPVisible: false, - Available: false, - Summary: "Scaffolded only. Not officially available in the current MVP.", - } -} - -func (p *Provider) Chat(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) (provider.ChatResponse, error) { - return provider.ChatResponse{}, provider.ScaffoldedProviderError(p.Name()) -} diff --git a/internal/provider/anthropic/provider_test.go b/internal/provider/anthropic/provider_test.go deleted file mode 100644 index 3c01c6e5..00000000 --- a/internal/provider/anthropic/provider_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package anthropic - -import ( - "context" - "strings" - "testing" - - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" -) - -func TestProviderScaffold(t *testing.T) { - t.Parallel() - - cfg := config.ProviderConfig{Name: config.ProviderAnthropic, Type: config.ProviderAnthropic} - p := New(cfg) - if p.Name() != config.ProviderAnthropic { - t.Fatalf("expected provider name %q, got %q", config.ProviderAnthropic, p.Name()) - } - - desc := p.Descriptor() - if desc.SupportLevel != provider.SupportLevelScaffolded || desc.Available || desc.MVPVisible { - t.Fatalf("unexpected descriptor: %+v", desc) - } - - _, err := p.Chat(context.Background(), provider.ChatRequest{}, make(chan provider.StreamEvent)) - if err == nil || !strings.Contains(err.Error(), "only OpenAI-compatible provider is officially supported") { - t.Fatalf("expected scaffold error, got %v", err) - } -} diff --git a/internal/provider/builtin/builtin.go b/internal/provider/builtin/builtin.go new file mode 100644 index 00000000..0ddd9c04 --- /dev/null +++ b/internal/provider/builtin/builtin.go @@ -0,0 +1,33 @@ +package builtin + +import ( + "errors" + + "github.com/dust/neo-code/internal/config" + "github.com/dust/neo-code/internal/provider" + "github.com/dust/neo-code/internal/provider/openai" +) + +func DefaultConfig() *config.Config { + cfg := config.Default() + defaultProvider := openai.DefaultConfig() + cfg.Providers = []config.ProviderConfig{defaultProvider} + cfg.SelectedProvider = defaultProvider.Name + cfg.CurrentModel = defaultProvider.Model + return cfg +} + +func NewRegistry() (*provider.Registry, error) { + registry := provider.NewRegistry() + if err := Register(registry); err != nil { + return nil, err + } + return registry, nil +} + +func Register(registry *provider.Registry) error { + if registry == nil { + return errors.New("builtin provider registry is nil") + } + return registry.Register(openai.DriverDefinition()) +} diff --git a/internal/provider/gemini/provider.go b/internal/provider/gemini/provider.go deleted file mode 100644 index 74d76d49..00000000 --- a/internal/provider/gemini/provider.go +++ /dev/null @@ -1,35 +0,0 @@ -package gemini - -import ( - "context" - - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" -) - -type Provider struct { - cfg config.ProviderConfig -} - -func New(cfg config.ProviderConfig) *Provider { - return &Provider{cfg: cfg} -} - -func (p *Provider) Name() string { - return p.cfg.Name -} - -func (p *Provider) Descriptor() provider.ProviderDescriptor { - return provider.ProviderDescriptor{ - Name: p.cfg.Name, - DisplayName: "Gemini", - SupportLevel: provider.SupportLevelScaffolded, - MVPVisible: false, - Available: false, - Summary: "Scaffolded only. Not officially available in the current MVP.", - } -} - -func (p *Provider) Chat(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) (provider.ChatResponse, error) { - return provider.ChatResponse{}, provider.ScaffoldedProviderError(p.Name()) -} diff --git a/internal/provider/gemini/provider_test.go b/internal/provider/gemini/provider_test.go deleted file mode 100644 index cf512496..00000000 --- a/internal/provider/gemini/provider_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package gemini - -import ( - "context" - "strings" - "testing" - - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" -) - -func TestProviderScaffold(t *testing.T) { - t.Parallel() - - cfg := config.ProviderConfig{Name: config.ProviderGemini, Type: config.ProviderGemini} - p := New(cfg) - if p.Name() != config.ProviderGemini { - t.Fatalf("expected provider name %q, got %q", config.ProviderGemini, p.Name()) - } - - desc := p.Descriptor() - if desc.SupportLevel != provider.SupportLevelScaffolded || desc.Available || desc.MVPVisible { - t.Fatalf("unexpected descriptor: %+v", desc) - } - - _, err := p.Chat(context.Background(), provider.ChatRequest{}, make(chan provider.StreamEvent)) - if err == nil || !strings.Contains(err.Error(), "only OpenAI-compatible provider is officially supported") { - t.Fatalf("expected scaffold error, got %v", err) - } -} diff --git a/internal/provider/openai/defaults.go b/internal/provider/openai/defaults.go new file mode 100644 index 00000000..2a13f440 --- /dev/null +++ b/internal/provider/openai/defaults.go @@ -0,0 +1,20 @@ +package openai + +import "github.com/dust/neo-code/internal/config" + +const ( + DriverName = "openai" + DefaultBaseURL = "https://api.openai.com/v1" + DefaultModel = "gpt-4.1" + DefaultAPIKeyEnv = "OPENAI_API_KEY" +) + +func DefaultConfig() config.ProviderConfig { + return config.ProviderConfig{ + Name: DriverName, + Driver: DriverName, + BaseURL: DefaultBaseURL, + Model: DefaultModel, + APIKeyEnv: DefaultAPIKeyEnv, + } +} diff --git a/internal/provider/openai/driver.go b/internal/provider/openai/driver.go new file mode 100644 index 00000000..1c63d803 --- /dev/null +++ b/internal/provider/openai/driver.go @@ -0,0 +1,60 @@ +package openai + +import ( + "context" + "strings" + + "github.com/dust/neo-code/internal/config" + "github.com/dust/neo-code/internal/provider" +) + +func DriverDefinition() provider.DriverDefinition { + return provider.DriverDefinition{ + Name: DriverName, + Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { + return New(cfg) + }, + Catalog: CatalogItem, + } +} + +func CatalogItem(cfg config.ProviderConfig) (provider.ProviderCatalogItem, error) { + models := []provider.ModelDescriptor{ + { + ID: DefaultModel, + Name: DefaultModel, + Description: "Stable OpenAI-compatible default model", + }, + { + ID: "gpt-4o", + Name: "gpt-4o", + Description: "Fast general-purpose OpenAI-compatible model", + }, + { + ID: "gpt-5.3-codex", + Name: "gpt-5.3-codex", + Description: "Code-focused OpenAI-compatible model", + }, + { + ID: "gpt-5.4", + Name: "gpt-5.4", + Description: "Frontier reasoning and coding model", + }, + } + + if configured := strings.TrimSpace(cfg.Model); configured != "" { + models = append(models, provider.ModelDescriptor{ + ID: configured, + Name: configured, + Description: "Configured default model", + }) + } + + return provider.ProviderCatalogItem{ + ID: cfg.Name, + Name: cfg.Name, + Description: "OpenAI-compatible chat completions provider.", + APIKeyEnv: cfg.APIKeyEnv, + Models: models, + }, nil +} diff --git a/internal/provider/openai/openai.go b/internal/provider/openai/openai.go index 007dd934..d309e67a 100644 --- a/internal/provider/openai/openai.go +++ b/internal/provider/openai/openai.go @@ -18,14 +18,17 @@ import ( ) type Provider struct { - cfg config.ProviderConfig + cfg config.ResolvedProviderConfig client *http.Client } -func New(cfg config.ProviderConfig) (*Provider, error) { +func New(cfg config.ResolvedProviderConfig) (*Provider, error) { if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("openai provider: %w", err) } + if strings.TrimSpace(cfg.APIKey) == "" { + return nil, errors.New("openai provider: api key is empty") + } return &Provider{ cfg: cfg, @@ -35,41 +38,7 @@ func New(cfg config.ProviderConfig) (*Provider, error) { }, nil } -func (p *Provider) Name() string { - return p.cfg.Name -} - -func (p *Provider) Descriptor() domain.ProviderDescriptor { - models := []domain.ModelOption{ - {Name: config.DefaultOpenAIModel, Description: "Stable OpenAI-compatible default model"}, - {Name: "gpt-4o", Description: "Fast general-purpose OpenAI-compatible model"}, - {Name: "gpt-5.3-codex", Description: "Code-focused OpenAI-compatible model"}, - {Name: "gpt-5.4", Description: "Frontier reasoning and coding model"}, - } - if configured := strings.TrimSpace(p.cfg.Model); configured != "" { - models = append(models, domain.ModelOption{ - Name: configured, - Description: "Configured default model", - }) - } - - return domain.ProviderDescriptor{ - Name: p.cfg.Name, - DisplayName: "OpenAI-compatible", - SupportLevel: domain.SupportLevelMVP, - MVPVisible: true, - Available: true, - Summary: "The only officially supported provider in the current MVP.", - Models: models, - } -} - func (p *Provider) Chat(ctx context.Context, req domain.ChatRequest, events chan<- domain.StreamEvent) (domain.ChatResponse, error) { - apiKey, err := p.cfg.ResolveAPIKey() - if err != nil { - return domain.ChatResponse{}, fmt.Errorf("openai provider: resolve api key: %w", err) - } - payload, err := p.buildRequest(req) if err != nil { return domain.ChatResponse{}, err @@ -85,7 +54,7 @@ func (p *Provider) Chat(ctx context.Context, req domain.ChatRequest, events chan if err != nil { return domain.ChatResponse{}, fmt.Errorf("openai provider: build request: %w", err) } - httpReq.Header.Set("Authorization", "Bearer "+apiKey) + httpReq.Header.Set("Authorization", "Bearer "+p.cfg.APIKey) httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "text/event-stream") diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index 91b410ec..4b844e13 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -12,6 +12,26 @@ import ( domain "github.com/dust/neo-code/internal/provider" ) +func resolvedConfig(baseURL string, model string) config.ResolvedProviderConfig { + if strings.TrimSpace(baseURL) == "" { + baseURL = DefaultBaseURL + } + if strings.TrimSpace(model) == "" { + model = DefaultModel + } + + return config.ResolvedProviderConfig{ + ProviderConfig: config.ProviderConfig{ + Name: DriverName, + Driver: DriverName, + BaseURL: baseURL, + Model: model, + APIKeyEnv: DefaultAPIKeyEnv, + }, + APIKey: "test-key", + } +} + func TestMergeToolCallDeltas(t *testing.T) { t.Parallel() @@ -71,34 +91,30 @@ func TestMergeToolCallDeltas(t *testing.T) { } } -func TestProviderDescriptorMarksOpenAIAsMVPSupported(t *testing.T) { +func TestCatalogItemIncludesConfiguredModel(t *testing.T) { t.Parallel() - p, err := New(config.ProviderConfig{ - Name: config.ProviderOpenAI, - Type: config.ProviderOpenAI, - BaseURL: config.DefaultOpenAIBaseURL, + item, err := CatalogItem(config.ProviderConfig{ + Name: DriverName, + Driver: DriverName, + BaseURL: DefaultBaseURL, Model: "gpt-5.4", - APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, + APIKeyEnv: DefaultAPIKeyEnv, }) if err != nil { - t.Fatalf("New() error = %v", err) + t.Fatalf("CatalogItem() error = %v", err) } - desc := p.Descriptor() - if desc.SupportLevel != domain.SupportLevelMVP || !desc.Available || !desc.MVPVisible { - t.Fatalf("unexpected descriptor: %+v", desc) - } - if desc.DisplayName != "OpenAI-compatible" { - t.Fatalf("expected display name OpenAI-compatible, got %q", desc.DisplayName) + if item.Name != DriverName { + t.Fatalf("expected provider name %q, got %q", DriverName, item.Name) } - if len(desc.Models) == 0 { - t.Fatalf("expected at least one model option in descriptor") + if len(item.Models) == 0 { + t.Fatalf("expected at least one model option in catalog item") } } func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { - t.Setenv(config.DefaultOpenAIAPIKeyEnv, "test-key") + t.Setenv(DefaultAPIKeyEnv, "test-key") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/chat/completions" { @@ -195,13 +211,7 @@ func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { })) defer server.Close() - provider, err := New(config.ProviderConfig{ - Name: config.ProviderOpenAI, - Type: config.ProviderOpenAI, - BaseURL: server.URL, - Model: "gpt-5.4", - APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, - }) + provider, err := New(resolvedConfig(server.URL, "gpt-5.4")) if err != nil { t.Fatalf("New() error = %v", err) } @@ -274,7 +284,7 @@ func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { } func TestProviderChatHTTPErrorResponses(t *testing.T) { - t.Setenv(config.DefaultOpenAIAPIKeyEnv, "test-key") + t.Setenv(DefaultAPIKeyEnv, "test-key") tests := []struct { name string @@ -307,20 +317,14 @@ func TestProviderChatHTTPErrorResponses(t *testing.T) { })) defer server.Close() - provider, err := New(config.ProviderConfig{ - Name: config.ProviderOpenAI, - Type: config.ProviderOpenAI, - BaseURL: server.URL, - Model: config.DefaultOpenAIModel, - APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, - }) + provider, err := New(resolvedConfig(server.URL, DefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } provider.client = server.Client() _, err = provider.Chat(context.Background(), domain.ChatRequest{ - Model: config.DefaultOpenAIModel, + Model: DefaultModel, }, make(chan domain.StreamEvent, 1)) if err == nil || !strings.Contains(err.Error(), tt.expectErr) { t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) @@ -332,13 +336,7 @@ func TestProviderChatHTTPErrorResponses(t *testing.T) { func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { t.Parallel() - provider, err := New(config.ProviderConfig{ - Name: config.ProviderOpenAI, - Type: config.ProviderOpenAI, - BaseURL: config.DefaultOpenAIBaseURL, - Model: config.DefaultOpenAIModel, - APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, - }) + provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -373,8 +371,8 @@ func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { t.Fatalf("buildRequest() error = %v", err) } - if payload.Model != config.DefaultOpenAIModel { - t.Fatalf("expected default model %q, got %q", config.DefaultOpenAIModel, payload.Model) + if payload.Model != DefaultModel { + t.Fatalf("expected default model %q, got %q", DefaultModel, payload.Model) } if !payload.Stream { t.Fatalf("expected stream=true") @@ -399,13 +397,7 @@ func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { func TestParseErrorAndEmitTextDelta(t *testing.T) { t.Parallel() - provider, err := New(config.ProviderConfig{ - Name: config.ProviderOpenAI, - Type: config.ProviderOpenAI, - BaseURL: config.DefaultOpenAIBaseURL, - Model: config.DefaultOpenAIModel, - APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, - }) + provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -463,13 +455,7 @@ func TestParseErrorAndEmitTextDelta(t *testing.T) { func TestProviderConsumeStreamRejectsDirtyJSON(t *testing.T) { t.Parallel() - provider, err := New(config.ProviderConfig{ - Name: config.ProviderOpenAI, - Type: config.ProviderOpenAI, - BaseURL: config.DefaultOpenAIBaseURL, - Model: config.DefaultOpenAIModel, - APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, - }) + provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 55561217..775ae374 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -3,7 +3,6 @@ package provider import "context" type Provider interface { - Name() string Chat(ctx context.Context, req ChatRequest, events chan<- StreamEvent) (ChatResponse, error) } diff --git a/internal/provider/registry.go b/internal/provider/registry.go index a64da502..1f8d7ed9 100644 --- a/internal/provider/registry.go +++ b/internal/provider/registry.go @@ -1,61 +1,84 @@ package provider import ( + "context" "errors" - "sort" + "fmt" "strings" + + "github.com/dust/neo-code/internal/config" ) +type Builder func(ctx context.Context, cfg config.ResolvedProviderConfig) (Provider, error) + +type CatalogBuilder func(cfg config.ProviderConfig) (ProviderCatalogItem, error) + +type DriverDefinition struct { + Name string + Build Builder + Catalog CatalogBuilder +} + type Registry struct { - items map[string]Provider + drivers map[string]DriverDefinition } func NewRegistry() *Registry { - return &Registry{items: map[string]Provider{}} + return &Registry{drivers: map[string]DriverDefinition{}} } -func (r *Registry) Register(provider Provider) { - if provider == nil { - return +func (r *Registry) Register(driver DriverDefinition) error { + driverType := normalizeKey(driver.Name) + if driverType == "" { + return errors.New("provider: driver name is empty") + } + if driver.Build == nil { + return fmt.Errorf("provider: driver %q build func is nil", driver.Name) } - r.items[strings.ToLower(provider.Name())] = provider + if driver.Catalog == nil { + return fmt.Errorf("provider: driver %q catalog func is nil", driver.Name) + } + r.drivers[driverType] = driver + return nil } -func (r *Registry) Get(name string) (Provider, error) { - item, ok := r.items[strings.ToLower(name)] - if !ok { - return nil, errors.New("provider: not found") +func (r *Registry) Build(ctx context.Context, cfg config.ResolvedProviderConfig) (Provider, error) { + driver, err := r.driver(cfg.Driver) + if err != nil { + return nil, err } - return item, nil + return driver.Build(ctx, cfg) } -func (r *Registry) Descriptors() []ProviderDescriptor { - return r.filteredDescriptors(func(ProviderDescriptor) bool { return true }) +func (r *Registry) Catalog(cfg config.ProviderConfig) (ProviderCatalogItem, error) { + driver, err := r.driver(cfg.Driver) + if err != nil { + return ProviderCatalogItem{}, err + } + + item, err := driver.Catalog(cfg) + if err != nil { + return ProviderCatalogItem{}, err + } + return normalizeCatalogItem(item, cfg.Name, cfg.APIKeyEnv), nil } -func (r *Registry) AvailableDescriptors() []ProviderDescriptor { - return r.filteredDescriptors(func(desc ProviderDescriptor) bool { - return desc.Available - }) +func (r *Registry) Supports(driverType string) bool { + _, err := r.driver(driverType) + return err == nil } -func (r *Registry) filteredDescriptors(keep func(ProviderDescriptor) bool) []ProviderDescriptor { - if r == nil || len(r.items) == 0 { - return nil +func (r *Registry) driver(driverType string) (DriverDefinition, error) { + if r == nil { + return DriverDefinition{}, ErrDriverNotFound } - - descriptors := make([]ProviderDescriptor, 0, len(r.items)) - for _, item := range r.items { - desc := Describe(item) - if keep != nil && !keep(desc) { - continue - } - descriptors = append(descriptors, desc) + driver, ok := r.drivers[normalizeKey(driverType)] + if !ok { + return DriverDefinition{}, fmt.Errorf("%w: %s", ErrDriverNotFound, strings.TrimSpace(driverType)) } + return driver, nil +} - sort.Slice(descriptors, func(i, j int) bool { - return strings.ToLower(descriptors[i].Name) < strings.ToLower(descriptors[j].Name) - }) - - return descriptors +func normalizeKey(value string) string { + return strings.ToLower(strings.TrimSpace(value)) } diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index d4ae432b..edfc083e 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -1,145 +1,218 @@ package provider_test import ( + "context" + "errors" "strings" "testing" "github.com/dust/neo-code/internal/config" "github.com/dust/neo-code/internal/provider" - "github.com/dust/neo-code/internal/provider/anthropic" - "github.com/dust/neo-code/internal/provider/gemini" + "github.com/dust/neo-code/internal/provider/builtin" "github.com/dust/neo-code/internal/provider/openai" ) -func TestRegistryRegisterAndGet(t *testing.T) { - t.Parallel() +type stubProvider struct{} - openAIProvider, err := openai.New(config.ProviderConfig{ - Name: config.ProviderOpenAI, - Type: config.ProviderOpenAI, - BaseURL: config.DefaultOpenAIBaseURL, - Model: config.DefaultOpenAIModel, - APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, - }) - if err != nil { - t.Fatalf("openai.New() error = %v", err) - } - anthropicProvider := anthropic.New(config.ProviderConfig{ - Name: config.ProviderAnthropic, - Type: config.ProviderAnthropic, - BaseURL: config.DefaultAnthropicBaseURL, - Model: config.DefaultAnthropicModel, - APIKeyEnv: config.DefaultAnthropicAPIKeyEnv, - }) - geminiProvider := gemini.New(config.ProviderConfig{ - Name: config.ProviderGemini, - Type: config.ProviderGemini, - BaseURL: config.DefaultGeminiBaseURL, - Model: config.DefaultGeminiModel, - APIKeyEnv: config.DefaultGeminiAPIKeyEnv, - }) +func (stubProvider) Chat(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) (provider.ChatResponse, error) { + return provider.ChatResponse{}, nil +} - registry := provider.NewRegistry() - registry.Register(nil) - registry.Register(openAIProvider) - registry.Register(anthropicProvider) - registry.Register(geminiProvider) - - tests := []struct { - name string - lookup string - expectName string - }{ - { - name: "gets openai provider case insensitively", - lookup: "OPENAI", - expectName: config.ProviderOpenAI, +func stubDriver(driverType string, models ...string) provider.DriverDefinition { + return provider.DriverDefinition{ + Name: driverType, + Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { + return stubProvider{}, nil }, - { - name: "gets anthropic provider case insensitively", - lookup: "Anthropic", - expectName: config.ProviderAnthropic, + Catalog: func(cfg config.ProviderConfig) (provider.ProviderCatalogItem, error) { + items := make([]provider.ModelDescriptor, 0, len(models)) + for _, model := range models { + items = append(items, provider.ModelDescriptor{ + ID: model, + Name: model, + }) + } + return provider.ProviderCatalogItem{ + ID: cfg.Name, + Name: strings.ToUpper(driverType), + APIKeyEnv: cfg.APIKeyEnv, + Models: items, + }, nil }, } +} - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() +func newTestRegistry(t *testing.T) *provider.Registry { + t.Helper() - got, err := registry.Get(tt.lookup) - if err != nil { - t.Fatalf("Get(%q) error = %v", tt.lookup, err) - } - if got == nil || !strings.EqualFold(got.Name(), tt.expectName) { - t.Fatalf("expected provider %q, got %+v", tt.expectName, got) - } - }) + registry := provider.NewRegistry() + if err := registry.Register(openai.DriverDefinition()); err != nil { + t.Fatalf("register openai driver: %v", err) + } + return registry +} + +func newTestManager(t *testing.T) *config.Manager { + t.Helper() + + manager := config.NewManager(config.NewLoader(t.TempDir(), builtin.DefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("load config: %v", err) } + return manager } -func TestRegistryGetMissingProvider(t *testing.T) { +func TestRegistryBuildsRegisteredDriverCaseInsensitively(t *testing.T) { t.Parallel() - registry := provider.NewRegistry() - _, err := registry.Get("missing") - if err == nil || !strings.Contains(err.Error(), "not found") { - t.Fatalf("expected not found error, got %v", err) + registry := newTestRegistry(t) + got, err := registry.Build(context.Background(), config.ResolvedProviderConfig{ + ProviderConfig: config.ProviderConfig{ + Name: "openai-main", + Driver: "OPENAI", + BaseURL: openai.DefaultBaseURL, + Model: openai.DefaultModel, + APIKeyEnv: openai.DefaultAPIKeyEnv, + }, + APIKey: "test-key", + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if _, ok := got.(*openai.Provider); !ok { + t.Fatalf("expected openai.Provider, got %T", got) } } -func TestRegistryDescriptorsFiltersSupportSurface(t *testing.T) { +func TestRegistryCatalogNormalizesConfiguredProvider(t *testing.T) { t.Parallel() - openAIProvider, err := openai.New(config.ProviderConfig{ - Name: config.ProviderOpenAI, - Type: config.ProviderOpenAI, - BaseURL: config.DefaultOpenAIBaseURL, - Model: config.DefaultOpenAIModel, - APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, + registry := newTestRegistry(t) + item, err := registry.Catalog(config.ProviderConfig{ + Name: "openai-main", + Driver: openai.DriverName, + BaseURL: openai.DefaultBaseURL, + Model: "custom-model", + APIKeyEnv: openai.DefaultAPIKeyEnv, }) if err != nil { - t.Fatalf("openai.New() error = %v", err) + t.Fatalf("Catalog() error = %v", err) + } + if item.ID != "openai-main" { + t.Fatalf("expected catalog id %q, got %q", "openai-main", item.ID) + } + if item.APIKeyEnv != openai.DefaultAPIKeyEnv { + t.Fatalf("expected env key %q, got %q", openai.DefaultAPIKeyEnv, item.APIKeyEnv) + } + + foundCustom := 0 + for _, model := range item.Models { + if model.ID == "custom-model" { + foundCustom++ + } + } + if foundCustom != 1 { + t.Fatalf("expected configured model to appear once, got %d", foundCustom) } +} + +func TestRegistryUnknownDriverReturnsTypedError(t *testing.T) { + t.Parallel() registry := provider.NewRegistry() - registry.Register(openAIProvider) - registry.Register(anthropic.New(config.ProviderConfig{ - Name: config.ProviderAnthropic, - Type: config.ProviderAnthropic, - BaseURL: config.DefaultAnthropicBaseURL, - Model: config.DefaultAnthropicModel, - APIKeyEnv: config.DefaultAnthropicAPIKeyEnv, - })) - registry.Register(gemini.New(config.ProviderConfig{ - Name: config.ProviderGemini, - Type: config.ProviderGemini, - BaseURL: config.DefaultGeminiBaseURL, - Model: config.DefaultGeminiModel, - APIKeyEnv: config.DefaultGeminiAPIKeyEnv, - })) - - all := registry.Descriptors() - if len(all) != 3 { - t.Fatalf("expected 3 descriptors, got %d", len(all)) - } - - mvp := registry.MVPDescriptors() - if len(mvp) != 1 { - t.Fatalf("expected 1 MVP descriptor, got %d", len(mvp)) - } - if mvp[0].Name != config.ProviderOpenAI { - t.Fatalf("expected MVP provider %q, got %q", config.ProviderOpenAI, mvp[0].Name) - } - if mvp[0].SupportLevel != provider.SupportLevelMVP || !mvp[0].Available || !mvp[0].MVPVisible { - t.Fatalf("unexpected MVP descriptor: %+v", mvp[0]) - } - - available := registry.AvailableDescriptors() - if len(available) != 1 { - t.Fatalf("expected 1 available descriptor, got %d", len(available)) - } - if available[0].Name != config.ProviderOpenAI { - t.Fatalf("expected available provider %q, got %q", config.ProviderOpenAI, available[0].Name) + _, err := registry.Catalog(config.ProviderConfig{Driver: "missing"}) + if !errors.Is(err, provider.ErrDriverNotFound) { + t.Fatalf("expected ErrDriverNotFound, got %v", err) + } +} + +func TestServiceListProvidersFiltersUnsupportedDrivers(t *testing.T) { + t.Parallel() + + manager := newTestManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Providers = append(cfg.Providers, config.ProviderConfig{ + Name: "unsupported", + Driver: "custom", + BaseURL: "https://example.com", + Model: "custom-model", + APIKeyEnv: "CUSTOM_API_KEY", + }) + return nil + }); err != nil { + t.Fatalf("append provider: %v", err) + } + + service := provider.NewService(manager, newTestRegistry(t)) + items, err := service.ListProviders(context.Background()) + if err != nil { + t.Fatalf("ListProviders() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("expected only supported providers, got %d", len(items)) + } + if items[0].ID != openai.DriverName { + t.Fatalf("expected supported provider %q, got %q", openai.DriverName, items[0].ID) + } +} + +func TestServiceSelectProviderAndSetCurrentModel(t *testing.T) { + manager := newTestManager(t) + registry := newTestRegistry(t) + if err := registry.Register(stubDriver("custom", "custom-model")); err != nil { + t.Fatalf("register stub driver: %v", err) + } + + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.CurrentModel = "gpt-5.4" + cfg.Providers = append(cfg.Providers, config.ProviderConfig{ + Name: "custom-main", + Driver: "custom", + BaseURL: "https://example.com", + Model: "custom-model", + APIKeyEnv: "CUSTOM_API_KEY", + }) + return nil + }); err != nil { + t.Fatalf("append custom provider: %v", err) + } + + service := provider.NewService(manager, registry) + + selection, err := service.SelectProvider(context.Background(), "custom-main") + if err != nil { + t.Fatalf("SelectProvider() error = %v", err) + } + if selection.ProviderID != "custom-main" || selection.ModelID != "custom-model" { + t.Fatalf("unexpected selection after switch: %+v", selection) + } + + if _, err := service.SetCurrentModel(context.Background(), "missing-model"); !errors.Is(err, provider.ErrModelNotFound) { + t.Fatalf("expected ErrModelNotFound, got %v", err) + } + + selection, err = service.SelectProvider(context.Background(), openai.DriverName) + if err != nil { + t.Fatalf("SelectProvider(openai) error = %v", err) + } + if selection.ProviderID != openai.DriverName { + t.Fatalf("expected selected provider %q, got %+v", openai.DriverName, selection) + } + + selection, err = service.SetCurrentModel(context.Background(), "gpt-4o") + if err != nil { + t.Fatalf("SetCurrentModel() error = %v", err) + } + if selection.ModelID != "gpt-4o" { + t.Fatalf("expected selected model %q, got %+v", "gpt-4o", selection) + } + + cfg := manager.Get() + selected, err := cfg.SelectedProviderConfig() + if err != nil { + t.Fatalf("SelectedProviderConfig() error = %v", err) + } + if selected.Model != "gpt-4o" || cfg.CurrentModel != "gpt-4o" { + t.Fatalf("expected config model to be updated, got provider=%q current=%q", selected.Model, cfg.CurrentModel) } } diff --git a/internal/provider/service.go b/internal/provider/service.go new file mode 100644 index 00000000..646f63db --- /dev/null +++ b/internal/provider/service.go @@ -0,0 +1,170 @@ +package provider + +import ( + "context" + "errors" + "strings" + + "github.com/dust/neo-code/internal/config" +) + +type Service struct { + manager *config.Manager + registry *Registry +} + +func NewService(manager *config.Manager, registry *Registry) *Service { + return &Service{ + manager: manager, + registry: registry, + } +} + +func (s *Service) ListProviders(ctx context.Context) ([]ProviderCatalogItem, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + cfg := s.manager.Get() + items := make([]ProviderCatalogItem, 0, len(cfg.Providers)) + for _, providerCfg := range cfg.Providers { + item, err := s.registry.Catalog(providerCfg) + if errors.Is(err, ErrDriverNotFound) { + continue + } + if err != nil { + return nil, err + } + items = append(items, item) + } + + return items, nil +} + +func (s *Service) SelectProvider(ctx context.Context, providerID string) (ProviderSelection, error) { + var selection ProviderSelection + + err := s.manager.Update(ctx, func(cfg *config.Config) error { + item, providerCfg, err := s.catalogForProvider(*cfg, providerID) + if err != nil { + return err + } + + cfg.SelectedProvider = providerCfg.Name + if !containsModel(item.Models, cfg.CurrentModel) { + cfg.CurrentModel = providerCfg.Model + } + + for i := range cfg.Providers { + if strings.EqualFold(strings.TrimSpace(cfg.Providers[i].Name), strings.TrimSpace(providerCfg.Name)) { + cfg.Providers[i].Model = cfg.CurrentModel + break + } + } + + selection = ProviderSelection{ + ProviderID: cfg.SelectedProvider, + ModelID: cfg.CurrentModel, + } + return nil + }) + if err != nil { + return ProviderSelection{}, err + } + + return selection, nil +} + +func (s *Service) ListModels(ctx context.Context) ([]ModelDescriptor, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + cfg := s.manager.Get() + selected, err := cfg.SelectedProviderConfig() + if err != nil { + return nil, err + } + + item, err := s.registry.Catalog(selected) + if errors.Is(err, ErrDriverNotFound) { + return nil, ErrProviderNotFound + } + if err != nil { + return nil, err + } + + return append([]ModelDescriptor(nil), item.Models...), nil +} + +func (s *Service) SetCurrentModel(ctx context.Context, modelID string) (ProviderSelection, error) { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return ProviderSelection{}, ErrModelNotFound + } + + var selection ProviderSelection + err := s.manager.Update(ctx, func(cfg *config.Config) error { + selected, err := cfg.SelectedProviderConfig() + if err != nil { + return err + } + + item, err := s.registry.Catalog(selected) + if errors.Is(err, ErrDriverNotFound) { + return ErrProviderNotFound + } + if err != nil { + return err + } + if !containsModel(item.Models, modelID) { + return ErrModelNotFound + } + + cfg.CurrentModel = modelID + for i := range cfg.Providers { + if strings.EqualFold(strings.TrimSpace(cfg.Providers[i].Name), strings.TrimSpace(selected.Name)) { + cfg.Providers[i].Model = modelID + break + } + } + + selection = ProviderSelection{ + ProviderID: cfg.SelectedProvider, + ModelID: cfg.CurrentModel, + } + return nil + }) + if err != nil { + return ProviderSelection{}, err + } + + return selection, nil +} + +func (s *Service) catalogForProvider(cfg config.Config, providerID string) (ProviderCatalogItem, config.ProviderConfig, error) { + providerCfg, err := cfg.ProviderByName(providerID) + if err != nil { + return ProviderCatalogItem{}, config.ProviderConfig{}, ErrProviderNotFound + } + + item, err := s.registry.Catalog(providerCfg) + if errors.Is(err, ErrDriverNotFound) { + return ProviderCatalogItem{}, config.ProviderConfig{}, ErrProviderNotFound + } + if err != nil { + return ProviderCatalogItem{}, config.ProviderConfig{}, err + } + + return item, providerCfg, nil +} + +func containsModel(models []ModelDescriptor, modelID string) bool { + target := strings.ToLower(strings.TrimSpace(modelID)) + for _, model := range models { + if strings.ToLower(strings.TrimSpace(model.ID)) == target { + return true + } + } + return false +} diff --git a/internal/provider/types.go b/internal/provider/types.go index 737999c3..46fb38f4 100644 --- a/internal/provider/types.go +++ b/internal/provider/types.go @@ -1,8 +1,7 @@ package provider import ( - "fmt" - "sort" + "errors" "strings" ) @@ -45,104 +44,76 @@ type Usage struct { TotalTokens int `json:"total_tokens"` } -type SupportLevel string - -const ( - SupportLevelMVP SupportLevel = "mvp" - SupportLevelScaffolded SupportLevel = "scaffolded" +var ( + ErrProviderNotFound = errors.New("provider not found") + ErrModelNotFound = errors.New("model not found") + ErrDriverNotFound = errors.New("provider driver not found") ) -type ModelOption struct { +type ModelDescriptor struct { + ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` } -type ProviderDescriptor struct { - Name string `json:"name"` - DisplayName string `json:"display_name"` - SupportLevel SupportLevel `json:"support_level"` - MVPVisible bool `json:"mvp_visible"` - Available bool `json:"available"` - Summary string `json:"summary"` - Models []ModelOption `json:"models,omitempty"` +type ProviderCatalogItem struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + APIKeyEnv string `json:"api_key_env,omitempty"` + Models []ModelDescriptor `json:"models,omitempty"` } -type Describer interface { - Descriptor() ProviderDescriptor +type ProviderSelection struct { + ProviderID string `json:"provider_id"` + ModelID string `json:"model_id"` } -func Describe(p Provider) ProviderDescriptor { - if p == nil { - return ProviderDescriptor{} +func normalizeCatalogItem(item ProviderCatalogItem, fallbackID string, fallbackEnv string) ProviderCatalogItem { + if strings.TrimSpace(item.ID) == "" { + item.ID = strings.TrimSpace(fallbackID) } - - if describer, ok := p.(Describer); ok { - return normalizeDescriptor(describer.Descriptor(), p.Name()) + if strings.TrimSpace(item.Name) == "" { + item.Name = item.ID } - - return ProviderDescriptor{ - Name: strings.TrimSpace(p.Name()), - DisplayName: strings.TrimSpace(p.Name()), - SupportLevel: SupportLevelScaffolded, - Summary: "Provider metadata is not available.", + if strings.TrimSpace(item.APIKeyEnv) == "" { + item.APIKeyEnv = strings.TrimSpace(fallbackEnv) } -} -func ScaffoldedProviderError(name string) error { - providerName := strings.TrimSpace(name) - if providerName == "" { - providerName = "unknown" - } - return fmt.Errorf( - "%s provider is scaffolded only and not available in this MVP; only OpenAI-compatible provider is officially supported", - providerName, - ) + item.Models = normalizeModels(item.Models) + return item } -func normalizeDescriptor(desc ProviderDescriptor, fallbackName string) ProviderDescriptor { - name := strings.TrimSpace(desc.Name) - if name == "" { - name = strings.TrimSpace(fallbackName) - } - desc.Name = name - - if strings.TrimSpace(desc.DisplayName) == "" { - desc.DisplayName = name - } - if strings.TrimSpace(desc.Summary) == "" { - desc.Summary = "Provider metadata is not available." - } - - desc.Models = normalizedModels(desc.Models) - return desc -} - -func normalizedModels(models []ModelOption) []ModelOption { +func normalizeModels(models []ModelDescriptor) []ModelDescriptor { if len(models) == 0 { return nil } - deduped := make([]ModelOption, 0, len(models)) + deduped := make([]ModelDescriptor, 0, len(models)) seen := make(map[string]struct{}, len(models)) for _, model := range models { + id := strings.TrimSpace(model.ID) name := strings.TrimSpace(model.Name) - if name == "" { + if id == "" { + id = name + } + if id == "" { continue } - key := strings.ToLower(name) + if name == "" { + name = id + } + key := strings.ToLower(id) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} - deduped = append(deduped, ModelOption{ + deduped = append(deduped, ModelDescriptor{ + ID: id, Name: name, Description: strings.TrimSpace(model.Description), }) } - sort.Slice(deduped, func(i, j int) bool { - return strings.ToLower(deduped[i].Name) < strings.ToLower(deduped[j].Name) - }) - return deduped } diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 1ec1acb9..d9d51643 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -3,15 +3,11 @@ package runtime import ( "context" "errors" - "fmt" "strings" "time" "github.com/dust/neo-code/internal/config" "github.com/dust/neo-code/internal/provider" - "github.com/dust/neo-code/internal/provider/anthropic" - "github.com/dust/neo-code/internal/provider/gemini" - "github.com/dust/neo-code/internal/provider/openai" "github.com/dust/neo-code/internal/tools" ) @@ -30,11 +26,9 @@ type UserInput struct { } type ProviderFactory interface { - Build(cfg config.Config) (provider.Provider, error) + Build(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) } -type DefaultProviderFactory struct{} - type Service struct { configManager *config.Manager sessionStore Store @@ -43,13 +37,9 @@ type Service struct { events chan RuntimeEvent } -func New(configManager *config.Manager, toolRegistry *tools.Registry, sessionStore Store) *Service { - return NewWithFactory(configManager, toolRegistry, sessionStore, DefaultProviderFactory{}) -} - func NewWithFactory(configManager *config.Manager, toolRegistry *tools.Registry, sessionStore Store, providerFactory ProviderFactory) *Service { if providerFactory == nil { - providerFactory = DefaultProviderFactory{} + providerFactory = provider.NewRegistry() } return &Service{ @@ -96,7 +86,13 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { return err } - modelProvider, err := s.providerFactory.Build(cfg) + resolvedProvider, err := s.configManager.ResolvedSelectedProvider() + if err != nil { + s.emit(EventError, session.ID, err.Error()) + return err + } + + modelProvider, err := s.providerFactory.Build(ctx, resolvedProvider) if err != nil { s.emit(EventError, session.ID, err.Error()) return err @@ -258,21 +254,3 @@ Use tools when necessary. When a tool fails, inspect the error and continue safely. Stay within the workspace and avoid destructive behavior unless clearly requested.` } - -func (DefaultProviderFactory) Build(cfg config.Config) (provider.Provider, error) { - selected, err := cfg.SelectedProviderConfig() - if err != nil { - return nil, err - } - - switch strings.ToLower(strings.TrimSpace(selected.Type)) { - case "openai": - return openai.New(selected) - case "anthropic": - return anthropic.New(selected), nil - case "gemini": - return gemini.New(selected), nil - default: - return nil, fmt.Errorf("runtime: unsupported provider type %q", selected.Type) - } -} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index d9f4ce1b..60c68388 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -4,11 +4,14 @@ import ( "context" "errors" "fmt" + "os" "strings" "testing" "github.com/dust/neo-code/internal/config" "github.com/dust/neo-code/internal/provider" + "github.com/dust/neo-code/internal/provider/builtin" + "github.com/dust/neo-code/internal/provider/openai" "github.com/dust/neo-code/internal/tools" ) @@ -61,17 +64,12 @@ func (s *memoryStore) ListSummaries(ctx context.Context) ([]SessionSummary, erro } type scriptedProvider struct { - name string responses []provider.ChatResponse streams [][]provider.StreamEvent requests []provider.ChatRequest callCount int } -func (p *scriptedProvider) Name() string { - return p.name -} - func (p *scriptedProvider) Chat(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) (provider.ChatResponse, error) { p.requests = append(p.requests, cloneChatRequest(req)) @@ -100,7 +98,7 @@ type scriptedProviderFactory struct { err error } -func (f *scriptedProviderFactory) Build(cfg config.Config) (provider.Provider, error) { +func (f *scriptedProviderFactory) Build(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { f.calls++ if f.err != nil { return nil, f.err @@ -271,7 +269,6 @@ func TestServiceRun(t *testing.T) { } scripted := &scriptedProvider{ - name: "scripted", responses: tt.providerResponses, streams: tt.providerStreams, } @@ -313,7 +310,7 @@ func TestServiceTrimMessagesPreservesToolPairs(t *testing.T) { t.Parallel() manager := newRuntimeConfigManager(t) - service := NewWithFactory(manager, tools.NewRegistry(), newMemoryStore(), &scriptedProviderFactory{provider: &scriptedProvider{name: "noop"}}) + service := NewWithFactory(manager, tools.NewRegistry(), newMemoryStore(), &scriptedProviderFactory{provider: &scriptedProvider{}}) messages := make([]provider.Message, 0, maxContextTurns+4) for i := 0; i < 8; i++ { @@ -355,7 +352,7 @@ func TestServiceTrimMessagesBoundaries(t *testing.T) { t.Parallel() manager := newRuntimeConfigManager(t) - service := NewWithFactory(manager, tools.NewRegistry(), newMemoryStore(), &scriptedProviderFactory{provider: &scriptedProvider{name: "noop"}}) + service := NewWithFactory(manager, tools.NewRegistry(), newMemoryStore(), &scriptedProviderFactory{provider: &scriptedProvider{}}) tests := []struct { name string @@ -449,7 +446,6 @@ func TestServiceRunErrorPaths(t *testing.T) { input: UserInput{Content: "loop"}, maxLoops: 1, provider: &scriptedProvider{ - name: "looping", responses: []provider.ChatResponse{ { Message: provider.Message{ @@ -493,7 +489,6 @@ func TestServiceRunErrorPaths(t *testing.T) { Content: "continue", }, provider: &scriptedProvider{ - name: "resume", responses: []provider.ChatResponse{ { Message: provider.Message{ @@ -584,7 +579,7 @@ func TestServiceConstructorsAndDelegates(t *testing.T) { registry := tools.NewRegistry() registry.Register(&stubTool{name: "filesystem_read_file", content: "ok"}) - service := New(manager, registry, store) + service := NewWithFactory(manager, registry, store, nil) if service == nil { t.Fatalf("expected service") } @@ -617,82 +612,13 @@ func TestServiceConstructorsAndDelegates(t *testing.T) { } } -func TestDefaultProviderFactoryBuild(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - mutate func(*config.Config) - expectErr string - expectName string - }{ - { - name: "openai provider", - mutate: func(cfg *config.Config) { - cfg.SelectedProvider = config.ProviderOpenAI - cfg.CurrentModel = config.DefaultOpenAIModel - }, - expectName: config.ProviderOpenAI, - }, - { - name: "anthropic provider", - mutate: func(cfg *config.Config) { - cfg.SelectedProvider = config.ProviderAnthropic - cfg.CurrentModel = config.DefaultAnthropicModel - }, - expectName: config.ProviderAnthropic, - }, - { - name: "gemini provider", - mutate: func(cfg *config.Config) { - cfg.SelectedProvider = config.ProviderGemini - cfg.CurrentModel = config.DefaultGeminiModel - }, - expectName: config.ProviderGemini, - }, - { - name: "unsupported provider type", - mutate: func(cfg *config.Config) { - cfg.SelectedProvider = "custom" - cfg.CurrentModel = "custom-model" - cfg.Providers = append(cfg.Providers, config.ProviderConfig{ - Name: "custom", - Type: "custom", - BaseURL: "https://example.com", - Model: "custom-model", - APIKeyEnv: "CUSTOM_API_KEY", - }) - }, - expectErr: `unsupported provider type "custom"`, - }, - } - - factory := DefaultProviderFactory{} - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - cfg := config.Default() - tt.mutate(cfg) - got, err := factory.Build(cfg.Clone()) - if tt.expectErr != "" { - if err == nil || !containsError(err, tt.expectErr) { - t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got == nil || got.Name() != tt.expectName { - t.Fatalf("expected provider %q, got %+v", tt.expectName, got) - } - }) - } -} - func newRuntimeConfigManager(t *testing.T) *config.Manager { t.Helper() - manager := config.NewManager(config.NewLoader(t.TempDir())) + restoreRuntimeEnv(t, openai.DefaultAPIKeyEnv) + if err := os.Setenv(openai.DefaultAPIKeyEnv, "test-key"); err != nil { + t.Fatalf("set env: %v", err) + } + manager := config.NewManager(config.NewLoader(t.TempDir(), builtin.DefaultConfig())) if _, err := manager.Load(context.Background()); err != nil { t.Fatalf("load config: %v", err) } @@ -707,6 +633,18 @@ func newRuntimeConfigManager(t *testing.T) *config.Manager { return manager } +func restoreRuntimeEnv(t *testing.T, key string) { + t.Helper() + value, ok := os.LookupEnv(key) + t.Cleanup(func() { + if !ok { + _ = os.Unsetenv(key) + return + } + _ = os.Setenv(key, value) + }) +} + func onlySession(t *testing.T, store *memoryStore) Session { t.Helper() if len(store.sessions) != 1 { diff --git a/internal/tui/app.go b/internal/tui/app.go index 1adc41ab..921b1ce7 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -19,11 +19,13 @@ import ( type App struct { state UIState configManager *config.Manager + providerSvc ProviderController runtime agentruntime.Runtime keys keyMap help help.Model spinner spinner.Model sessions list.Model + providerPicker list.Model modelPicker list.Model transcript viewport.Model input textinput.Model @@ -34,14 +36,17 @@ type App struct { styles styles } -func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime.Runtime) (App, error) { - if cfg == nil { - defaultCfg := config.Default() - cfg = defaultCfg - } +func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime.Runtime, providerSvc ProviderController) (App, error) { if configManager == nil { return App{}, fmt.Errorf("tui: config manager is nil") } + if providerSvc == nil { + return App{}, fmt.Errorf("tui: provider service is nil") + } + if cfg == nil { + snapshot := configManager.Get() + cfg = &snapshot + } uiStyles := newStyles() keys := newKeyMap() @@ -55,8 +60,8 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime sessionList.SetShowPagination(false) sessionList.SetFilteringEnabled(true) sessionList.DisableQuitKeybindings() - sessionList.FilterInput.Prompt = "筛选:" - sessionList.FilterInput.Placeholder = "输入关键词…" + sessionList.FilterInput.Prompt = "Filter: " + sessionList.FilterInput.Placeholder = "Type to search sessions" input := textinput.New() input.Placeholder = "Ask NeoCode to inspect, edit, or build. Type / to browse commands." @@ -84,19 +89,21 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime ActiveSessionTitle: draftSessionTitle, Focus: panelInput, }, - configManager: configManager, - runtime: runtime, - keys: keys, - help: h, - spinner: spin, - sessions: sessionList, - modelPicker: newModelPicker(), - transcript: viewport.New(0, 0), - input: input, - focus: panelInput, - width: 128, - height: 40, - styles: uiStyles, + configManager: configManager, + providerSvc: providerSvc, + runtime: runtime, + keys: keys, + help: h, + spinner: spin, + sessions: sessionList, + providerPicker: newProviderPicker(nil), + modelPicker: newModelPicker(nil), + transcript: viewport.New(0, 0), + input: input, + focus: panelInput, + width: 128, + height: 40, + styles: uiStyles, } if err := app.refreshSessions(); err != nil { @@ -110,6 +117,13 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime } app.syncActiveSessionTitle() app.syncConfigState(configManager.Get()) + if err := app.refreshProviderPicker(); err != nil { + return App{}, err + } + if err := app.refreshModelPicker(); err != nil { + return App{}, err + } + app.selectCurrentProvider(cfg.SelectedProvider) app.selectCurrentModel(cfg.CurrentModel) app.resizeComponents() return app, nil diff --git a/internal/tui/commands.go b/internal/tui/commands.go index fc5d3305..bd517830 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -3,47 +3,47 @@ package tui import ( "context" "fmt" - "net/url" "strings" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" - "github.com/dust/neo-code/internal/config" + "github.com/dust/neo-code/internal/provider" ) const ( - slashPrefix = "/" - slashCommandSet = "/set" - slashCommandModelPicker = "/model" + slashPrefix = "/" + slashCommandProviderPick = "/provider" + slashCommandModelPicker = "/model" - slashUsageSetURL = "/set url " - slashUsageSetKey = "/set key " - slashUsageModel = "/model" + slashUsageProvider = "/provider" + slashUsageModel = "/model" - commandMenuTitle = "Commands" - modelPickerTitle = "Select Model" - modelPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" + commandMenuTitle = "Commands" + providerPickerTitle = "Select Provider" + providerPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" + modelPickerTitle = "Select Model" + modelPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" sidebarTitle = "Sessions" - sidebarFilterHint = "按 \"/\" 筛选" - sidebarOpenHint = "Enter 打开" + sidebarFilterHint = "Type / to search" + sidebarOpenHint = "Enter to open" draftSessionTitle = "Draft" emptyConversationText = "No conversation yet.\nAsk NeoCode to inspect or change code, or type / to browse local commands." emptyMessageText = "(empty)" - statusReady = "Ready" - statusRuntimeClosed = "Runtime closed" - statusThinking = "Thinking" - statusRunningTool = "Running tool" - statusToolFinished = "Tool finished" - statusToolError = "Tool error" - statusError = "Error" - statusDraft = "New draft" - statusRunning = "Running" - statusApplyingCommand = "Applying local command" - statusChooseModel = "Choose a model" + statusReady = "Ready" + statusRuntimeClosed = "Runtime closed" + statusThinking = "Thinking" + statusRunningTool = "Running tool" + statusToolFinished = "Tool finished" + statusToolError = "Tool error" + statusError = "Error" + statusDraft = "New draft" + statusRunning = "Running" + statusChooseProvider = "Choose a provider" + statusChooseModel = "Choose a model" focusLabelSessions = "Sessions" focusLabelTranscript = "Transcript" @@ -72,18 +72,11 @@ type commandSuggestion struct { } var builtinSlashCommands = []slashCommand{ - {Usage: slashUsageSetURL, Description: "Set the API Base URL"}, - {Usage: slashUsageSetKey, Description: "Update the API Key"}, + {Usage: slashUsageProvider, Description: "Open the interactive provider picker"}, {Usage: slashUsageModel, Description: "Open the interactive model picker"}, } -func newModelPicker() list.Model { - catalog := config.BuiltinModelCatalog() - items := make([]list.Item, 0, len(catalog)) - for _, option := range catalog { - items = append(items, modelItem{name: option.Name, description: option.Description}) - } - +func newSelectionPicker(items []list.Item) list.Model { delegate := list.NewDefaultDelegate() picker := list.New(items, delegate, 0, 0) picker.Title = "" @@ -94,24 +87,103 @@ func newModelPicker() list.Model { return picker } +func newProviderPicker(items []provider.ProviderCatalogItem) list.Model { + listItems := make([]list.Item, 0, len(items)) + for _, item := range items { + description := item.Description + if item.APIKeyEnv != "" { + if description != "" { + description += " | " + } + description += "API key env: " + item.APIKeyEnv + } + listItems = append(listItems, providerItem{ + id: item.ID, + name: item.Name, + description: description, + }) + } + return newSelectionPicker(listItems) +} + +func newModelPicker(models []provider.ModelDescriptor) list.Model { + items := make([]list.Item, 0, len(models)) + for _, option := range models { + items = append(items, modelItem{ + id: option.ID, + name: option.Name, + description: option.Description, + }) + } + return newSelectionPicker(items) +} + +func replacePickerItems(current list.Model, next list.Model) list.Model { + next.SetSize(current.Width(), current.Height()) + return next +} + +func (a *App) refreshProviderPicker() error { + items, err := a.providerSvc.ListProviders(context.Background()) + if err != nil { + return err + } + + a.providerPicker = replacePickerItems(a.providerPicker, newProviderPicker(items)) + a.selectCurrentProvider(a.state.CurrentProvider) + return nil +} + +func (a *App) refreshModelPicker() error { + models, err := a.providerSvc.ListModels(context.Background()) + if err != nil { + return err + } + + a.modelPicker = replacePickerItems(a.modelPicker, newModelPicker(models)) + a.selectCurrentModel(a.state.CurrentModel) + return nil +} + +func (a *App) openProviderPicker() { + a.state.ActivePicker = pickerProvider + a.state.StatusText = statusChooseProvider + a.input.Blur() + a.selectCurrentProvider(a.state.CurrentProvider) +} + func (a *App) openModelPicker() { - a.state.ShowModelPicker = true + a.state.ActivePicker = pickerModel a.state.StatusText = statusChooseModel a.input.Blur() a.selectCurrentModel(a.state.CurrentModel) } -func (a *App) closeModelPicker() { - a.state.ShowModelPicker = false +func (a *App) closePicker() { + a.state.ActivePicker = pickerNone a.focus = panelInput a.applyFocus() } -func (a *App) selectCurrentModel(model string) { +func (a *App) selectCurrentProvider(providerID string) { + items := a.providerPicker.Items() + for idx, item := range items { + candidate, ok := item.(providerItem) + if ok && strings.EqualFold(candidate.id, providerID) { + a.providerPicker.Select(idx) + return + } + } + if len(items) > 0 { + a.providerPicker.Select(0) + } +} + +func (a *App) selectCurrentModel(modelID string) { items := a.modelPicker.Items() for idx, item := range items { candidate, ok := item.(modelItem) - if ok && strings.EqualFold(candidate.name, model) { + if ok && strings.EqualFold(candidate.id, modelID) { a.modelPicker.Select(idx) return } @@ -138,98 +210,26 @@ func (a App) matchingSlashCommands(input string) []commandSuggestion { return out } -func runLocalCommand(configManager *config.Manager, raw string) tea.Cmd { - return func() tea.Msg { - notice, err := executeLocalCommand(context.Background(), configManager, raw) - return localCommandResultMsg{notice: notice, err: err} - } -} - -func runModelSelection(configManager *config.Manager, model string) tea.Cmd { +func runProviderSelection(providerSvc ProviderController, providerID string) tea.Cmd { return func() tea.Msg { - notice, err := setCurrentModel(context.Background(), configManager, model) - return localCommandResultMsg{notice: notice, err: err} - } -} - -func executeLocalCommand(ctx context.Context, configManager *config.Manager, raw string) (string, error) { - fields := strings.Fields(strings.TrimSpace(raw)) - if len(fields) == 0 { - return "", fmt.Errorf("empty command") - } - - if !strings.EqualFold(fields[0], slashCommandSet) { - return "", fmt.Errorf("unknown command %q", fields[0]) - } - if len(fields) < 3 { - return "", fmt.Errorf("usage: %s | %s | %s", slashUsageSetURL, slashUsageSetKey, slashUsageModel) - } - - value := strings.TrimSpace(strings.Join(fields[2:], " ")) - if value == "" { - return "", fmt.Errorf("command value is empty") - } - - switch strings.ToLower(fields[1]) { - case "url": - if _, err := url.ParseRequestURI(value); err != nil { - return "", fmt.Errorf("invalid url: %w", err) - } - if err := configManager.Update(ctx, func(cfg *config.Config) error { - selectedName := strings.TrimSpace(cfg.SelectedProvider) - for i := range cfg.Providers { - if strings.EqualFold(strings.TrimSpace(cfg.Providers[i].Name), selectedName) { - cfg.Providers[i].BaseURL = value - return nil - } - } - return fmt.Errorf("selected provider %q not found", cfg.SelectedProvider) - }); err != nil { - return "", err - } - cfg := configManager.Get() - return fmt.Sprintf("[System] Base URL updated for %s -> %s", cfg.SelectedProvider, value), nil - case "key": - cfg := configManager.Get() - selected, err := cfg.SelectedProviderConfig() + selection, err := providerSvc.SelectProvider(context.Background(), providerID) if err != nil { - return "", err + return localCommandResultMsg{err: err} } - if err := configManager.UpsertEnv(selected.APIKeyEnv, value); err != nil { - return "", fmt.Errorf("persist api key: %w", err) + return localCommandResultMsg{ + notice: fmt.Sprintf("[System] Current provider switched to %s.", selection.ProviderID), } - if err := configManager.OverloadManagedEnvironment(); err != nil { - return "", fmt.Errorf("reload managed env: %w", err) - } - if _, err := configManager.Reload(ctx); err != nil { - return "", fmt.Errorf("reload config: %w", err) - } - return fmt.Sprintf("[System] %s updated and loaded.", selected.APIKeyEnv), nil - case "model": - return setCurrentModel(ctx, configManager, value) - default: - return "", fmt.Errorf("unsupported /set field %q", fields[1]) } } -func setCurrentModel(ctx context.Context, configManager *config.Manager, model string) (string, error) { - if err := configManager.Update(ctx, func(cfg *config.Config) error { - cfg.CurrentModel = model - selectedName := strings.TrimSpace(cfg.SelectedProvider) - for i := range cfg.Providers { - if strings.EqualFold(strings.TrimSpace(cfg.Providers[i].Name), selectedName) { - cfg.Providers[i].Model = model - return nil - } +func runModelSelection(providerSvc ProviderController, modelID string) tea.Cmd { + return func() tea.Msg { + selection, err := providerSvc.SetCurrentModel(context.Background(), modelID) + if err != nil { + return localCommandResultMsg{err: err} + } + return localCommandResultMsg{ + notice: fmt.Sprintf("[System] Current model switched to %s.", selection.ModelID), } - return nil - }); err != nil { - return "", err - } - - if _, err := configManager.Reload(ctx); err != nil { - return "", fmt.Errorf("reload config: %w", err) } - - return fmt.Sprintf("[System] Current model switched to %s.", model), nil } diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index 2eaf306c..af472f74 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/commands_test.go @@ -1,125 +1,6 @@ package tui -import ( - "context" - "os" - "strings" - "testing" - - "github.com/dust/neo-code/internal/config" -) - -func TestExecuteLocalCommand(t *testing.T) { - tests := []struct { - name string - command string - expectErr string - assert func(t *testing.T, manager *config.Manager, notice string) - }{ - { - name: "set url updates selected provider", - command: "/set url https://test.example/v1", - assert: func(t *testing.T, manager *config.Manager, notice string) { - t.Helper() - cfg := manager.Get() - selected, err := cfg.SelectedProviderConfig() - if err != nil { - t.Fatalf("SelectedProviderConfig() error = %v", err) - } - if selected.BaseURL != "https://test.example/v1" { - t.Fatalf("expected updated base url, got %q", selected.BaseURL) - } - if !strings.Contains(notice, "Base URL updated") { - t.Fatalf("expected update notice, got %q", notice) - } - }, - }, - { - name: "set model updates current model", - command: "/set model gpt-5.4", - assert: func(t *testing.T, manager *config.Manager, notice string) { - t.Helper() - cfg := manager.Get() - if cfg.CurrentModel != "gpt-5.4" { - t.Fatalf("expected current model gpt-5.4, got %q", cfg.CurrentModel) - } - selected, err := cfg.SelectedProviderConfig() - if err != nil { - t.Fatalf("SelectedProviderConfig() error = %v", err) - } - if selected.Model != "gpt-5.4" { - t.Fatalf("expected selected provider model gpt-5.4, got %q", selected.Model) - } - if !strings.Contains(notice, "Current model switched") { - t.Fatalf("expected model switch notice, got %q", notice) - } - }, - }, - { - name: "set key writes managed env and reloads", - command: "/set key secret-key", - assert: func(t *testing.T, manager *config.Manager, notice string) { - t.Helper() - if got := strings.TrimSpace(os.Getenv(config.DefaultOpenAIAPIKeyEnv)); got != "secret-key" { - t.Fatalf("expected env to be reloaded, got %q", got) - } - if !strings.Contains(notice, "updated and loaded") { - t.Fatalf("expected key reload notice, got %q", notice) - } - }, - }, - { - name: "unknown command is rejected", - command: "/unknown", - expectErr: `unknown command "/unknown"`, - }, - { - name: "unknown command with suffix is rejected", - command: "/unknown_cmd", - expectErr: `unknown command "/unknown_cmd"`, - }, - { - name: "set usage requires enough arguments", - command: "/set url", - expectErr: "usage:", - }, - { - name: "unsupported set field is rejected", - command: "/set nope value", - expectErr: `unsupported /set field "nope"`, - }, - { - name: "invalid url is rejected", - command: "/set url not-a-url", - expectErr: "invalid url", - }, - { - name: "empty command is rejected", - command: " ", - expectErr: "empty command", - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - manager := newTestConfigManager(t) - notice, err := executeLocalCommand(context.Background(), manager, tt.command) - if tt.expectErr != "" { - if err == nil || !strings.Contains(err.Error(), tt.expectErr) { - t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if tt.assert != nil { - tt.assert(t, manager, notice) - } - }) - } -} +import "testing" func TestMatchingSlashCommands(t *testing.T) { t.Parallel() @@ -144,9 +25,9 @@ func TestMatchingSlashCommands(t *testing.T) { }, { name: "prefix narrows suggestions", - input: "/set u", + input: "/mo", expectCount: 1, - expectUsage: slashUsageSetURL, + expectUsage: slashUsageModel, }, } diff --git a/internal/tui/provider_service.go b/internal/tui/provider_service.go new file mode 100644 index 00000000..21985e73 --- /dev/null +++ b/internal/tui/provider_service.go @@ -0,0 +1,14 @@ +package tui + +import ( + "context" + + "github.com/dust/neo-code/internal/provider" +) + +type ProviderController interface { + ListProviders(ctx context.Context) ([]provider.ProviderCatalogItem, error) + SelectProvider(ctx context.Context, providerID string) (provider.ProviderSelection, error) + ListModels(ctx context.Context) ([]provider.ModelDescriptor, error) + SetCurrentModel(ctx context.Context, modelID string) (provider.ProviderSelection, error) +} diff --git a/internal/tui/state.go b/internal/tui/state.go index 21d18c29..bceaa5a2 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -22,6 +22,14 @@ const ( panelInput ) +type pickerMode int + +const ( + pickerNone pickerMode = iota + pickerProvider + pickerModel +) + type UIState struct { Sessions []agentruntime.SessionSummary ActiveSessionID string @@ -36,7 +44,7 @@ type UIState struct { CurrentModel string CurrentWorkdir string ShowHelp bool - ShowModelPicker bool + ActivePicker pickerMode Focus panel } @@ -58,6 +66,7 @@ func (s sessionItem) FilterValue() string { } type modelItem struct { + id string name string description string } @@ -71,7 +80,25 @@ func (m modelItem) Description() string { } func (m modelItem) FilterValue() string { - return strings.ToLower(m.name + " " + m.description) + return strings.ToLower(m.id + " " + m.name + " " + m.description) +} + +type providerItem struct { + id string + name string + description string +} + +func (p providerItem) Title() string { + return p.name +} + +func (p providerItem) Description() string { + return p.description +} + +func (p providerItem) FilterValue() string { + return strings.ToLower(p.id + " " + p.name + " " + p.description) } type sessionDelegate struct { diff --git a/internal/tui/update.go b/internal/tui/update.go index a187751b..715c1c58 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -2,6 +2,7 @@ package tui import ( "context" + "fmt" "strings" "github.com/charmbracelet/bubbles/key" @@ -75,7 +76,19 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state.StatusText = typed.notice cfg := a.configManager.Get() a.syncConfigState(cfg) - a.selectCurrentModel(cfg.CurrentModel) + if err := a.refreshProviderPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendInlineMessage(roleError, err.Error()) + } + if err := a.refreshModelPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendInlineMessage(roleError, err.Error()) + } else { + a.selectCurrentProvider(cfg.SelectedProvider) + a.selectCurrentModel(cfg.CurrentModel) + } a.appendInlineMessage(roleSystem, typed.notice) } a.rebuildTranscript() @@ -90,8 +103,8 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.resizeComponents() return a, tea.Batch(cmds...) } - if a.state.ShowModelPicker { - return a.updateModelPicker(typed) + if a.state.ActivePicker != pickerNone { + return a.updatePicker(typed) } if key.Matches(typed, a.keys.NextPanel) { a.focusNext() @@ -161,14 +174,34 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.state.InputText = "" switch strings.ToLower(input) { + case slashCommandProviderPick: + if err := a.refreshProviderPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendInlineMessage(roleError, err.Error()) + a.rebuildTranscript() + return a, tea.Batch(cmds...) + } + a.openProviderPicker() + return a, tea.Batch(cmds...) case slashCommandModelPicker: + if err := a.refreshModelPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendInlineMessage(roleError, err.Error()) + a.rebuildTranscript() + return a, tea.Batch(cmds...) + } a.openModelPicker() return a, tea.Batch(cmds...) } if strings.HasPrefix(input, slashPrefix) { - a.state.StatusText = statusApplyingCommand - cmds = append(cmds, runLocalCommand(a.configManager, input)) + err := fmt.Sprintf("unknown command %q", input) + a.state.ExecutionError = err + a.state.StatusText = err + a.appendInlineMessage(roleError, err) + a.rebuildTranscript() return a, tea.Batch(cmds...) } @@ -191,22 +224,37 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te return a, tea.Batch(cmds...) } -func (a App) updateModelPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, a.keys.FocusInput): - a.closeModelPicker() + a.closePicker() return a, nil case msg.String() == "enter": - item, ok := a.modelPicker.SelectedItem().(modelItem) - a.closeModelPicker() - if !ok { - return a, nil + switch a.state.ActivePicker { + case pickerProvider: + item, ok := a.providerPicker.SelectedItem().(providerItem) + a.closePicker() + if !ok { + return a, nil + } + return a, runProviderSelection(a.providerSvc, item.id) + case pickerModel: + item, ok := a.modelPicker.SelectedItem().(modelItem) + a.closePicker() + if !ok { + return a, nil + } + return a, runModelSelection(a.providerSvc, item.id) } - return a, runModelSelection(a.configManager, item.name) } var cmd tea.Cmd - a.modelPicker, cmd = a.modelPicker.Update(msg) + switch a.state.ActivePicker { + case pickerProvider: + a.providerPicker, cmd = a.providerPicker.Update(msg) + case pickerModel: + a.modelPicker, cmd = a.modelPicker.Update(msg) + } return a, cmd } @@ -438,7 +486,7 @@ func (a *App) focusPrev() { func (a *App) applyFocus() { a.state.Focus = a.focus - if a.focus == panelInput && !a.state.ShowModelPicker { + if a.focus == panelInput && a.state.ActivePicker == pickerNone { a.input.Focus() return } @@ -459,6 +507,7 @@ func (a *App) resizeComponents() { a.input.Width = max(4, promptInnerWidth-lipgloss.Width("> ")) promptHeight := lipgloss.Height(a.renderPrompt(a.transcript.Width)) a.transcript.Height = max(6, lay.rightHeight-menuHeight-promptHeight) + a.providerPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) a.modelPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) a.rebuildTranscript() } diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index ec2defc5..76f5be4a 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -13,6 +13,8 @@ import ( "github.com/dust/neo-code/internal/config" "github.com/dust/neo-code/internal/provider" + "github.com/dust/neo-code/internal/provider/builtin" + provideropenai "github.com/dust/neo-code/internal/provider/openai" agentruntime "github.com/dust/neo-code/internal/runtime" "github.com/dust/neo-code/internal/tools" ) @@ -67,26 +69,37 @@ func TestAppUpdateComposerCommands(t *testing.T) { assert func(t *testing.T, beforeRuntime *stubRuntime, manager *config.Manager, app App) }{ { - name: "slash set url updates config and does not start runtime", - input: "/set url https://test.com", + name: "unknown slash command stays local and surfaces error", + input: "/unknown", assert: func(t *testing.T, runtime *stubRuntime, manager *config.Manager, app App) { t.Helper() if len(runtime.runInputs) != 0 { t.Fatalf("expected runtime not to run, got %d calls", len(runtime.runInputs)) } - cfg := manager.Get() - selected, err := cfg.SelectedProviderConfig() - if err != nil { - t.Fatalf("SelectedProviderConfig() error = %v", err) - } - if selected.BaseURL != "https://test.com" { - t.Fatalf("expected base url updated to https://test.com, got %q", selected.BaseURL) + if !strings.Contains(app.state.StatusText, "unknown command") { + t.Fatalf("expected unknown command error, got %q", app.state.StatusText) } if app.state.IsAgentRunning { t.Fatalf("expected agent to stay idle") } }, }, + { + name: "provider command opens picker and does not start runtime", + input: "/provider", + assert: func(t *testing.T, runtime *stubRuntime, manager *config.Manager, app App) { + t.Helper() + if len(runtime.runInputs) != 0 { + t.Fatalf("expected runtime not to run, got %d calls", len(runtime.runInputs)) + } + if app.state.ActivePicker != pickerProvider { + t.Fatalf("expected provider picker to open") + } + if app.state.StatusText != statusChooseProvider { + t.Fatalf("expected status %q, got %q", statusChooseProvider, app.state.StatusText) + } + }, + }, { name: "model command opens picker and does not start runtime", input: "/model", @@ -95,7 +108,7 @@ func TestAppUpdateComposerCommands(t *testing.T) { if len(runtime.runInputs) != 0 { t.Fatalf("expected runtime not to run, got %d calls", len(runtime.runInputs)) } - if !app.state.ShowModelPicker { + if app.state.ActivePicker != pickerModel { t.Fatalf("expected model picker to open") } if app.state.StatusText != statusChooseModel { @@ -111,7 +124,7 @@ func TestAppUpdateComposerCommands(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() - app, err := New(nil, manager, runtime) + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -142,13 +155,13 @@ func TestAppUpdateModelPickerAndRuntimeMessages(t *testing.T) { { name: "escape closes model picker", setup: func(t *testing.T, app *App, runtime *stubRuntime) { - app.state.ShowModelPicker = true + app.state.ActivePicker = pickerModel app.focus = panelInput }, msg: tea.KeyMsg{Type: tea.KeyEsc}, assert: func(t *testing.T, runtime *stubRuntime, app App, msgs []tea.Msg) { t.Helper() - if app.state.ShowModelPicker { + if app.state.ActivePicker != pickerNone { t.Fatalf("expected model picker to close") } if app.state.Focus != panelInput { @@ -236,7 +249,7 @@ func TestAppUpdateModelPickerAndRuntimeMessages(t *testing.T) { t.Run(tt.name, func(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() - app, err := New(nil, manager, runtime) + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -267,7 +280,7 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { } runtime.loads[now.ID] = now - app, err := New(nil, manager, runtime) + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -276,8 +289,8 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { } app.openModelPicker() - app.closeModelPicker() - app.selectCurrentModel(config.DefaultOpenAIModel) + app.closePicker() + app.selectCurrentModel(provideropenai.DefaultModel) app.appendAssistantChunk("hello") app.appendAssistantChunk(" world") if !app.lastAssistantMatches("hello world") { @@ -314,11 +327,11 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if app.renderHeader(app.computeLayout().contentWidth) == "" || app.renderBody(app.computeLayout()) == "" { t.Fatalf("expected non-empty render output") } - app.state.ShowModelPicker = true - if app.renderModelPicker(48, 12) == "" || app.renderWaterfall(80, 20) == "" { + app.state.ActivePicker = pickerModel + if app.renderPicker(48, 12) == "" || app.renderWaterfall(80, 20) == "" { t.Fatalf("expected model picker rendering") } - app.state.ShowModelPicker = false + app.state.ActivePicker = pickerNone if app.renderCommandMenu(80) == "" { app.input.SetValue("/") app.state.InputText = "/" @@ -430,7 +443,7 @@ func TestTUIStandaloneHelpers(t *testing.T) { t.Fatalf("expected delegate update to return nil") } var buf bytes.Buffer - model := newModelPicker() + model := newModelPicker([]provider.ModelDescriptor{{ID: "gpt-4.1", Name: "gpt-4.1"}}) sessionList := []list.Item{sItem} listModel := list.New(sessionList, delegate, 30, 10) delegate.Render(&buf, listModel, 0, sItem) @@ -458,7 +471,7 @@ func TestTUIStandaloneHelpers(t *testing.T) { } manager := newTestConfigManager(t) - msg := runModelSelection(manager, "gpt-5.4")() + msg := runModelSelection(newTestProviderService(t, manager), "gpt-5.4")() if result, ok := msg.(localCommandResultMsg); !ok || result.err != nil { t.Fatalf("expected successful localCommandResultMsg, got %+v", msg) } @@ -511,7 +524,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, }, { - name: "local command success updates state", + name: "model selection success updates state", msg: localCommandResultMsg{notice: "[System] ok"}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() @@ -521,7 +534,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, }, { - name: "local command error updates state", + name: "model selection error updates state", msg: localCommandResultMsg{err: context.Canceled}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() @@ -676,7 +689,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { t.Run(tt.name, func(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() - app, err := New(nil, manager, runtime) + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -694,7 +707,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { func TestAppUpdateModelPickerEnterAppliesSelection(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() - app, err := New(nil, manager, runtime) + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -703,12 +716,12 @@ func TestAppUpdateModelPickerEnterAppliesSelection(t *testing.T) { if len(app.modelPicker.Items()) < 2 { t.Fatalf("expected model picker catalog") } - selected := app.modelPicker.Items()[1].(modelItem).name + selected := app.modelPicker.Items()[1].(modelItem).id app.modelPicker.Select(1) model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) app = model.(App) - if app.state.ShowModelPicker { + if app.state.ActivePicker != pickerNone { t.Fatalf("expected picker to close after selection") } @@ -724,6 +737,82 @@ func TestAppUpdateModelPickerEnterAppliesSelection(t *testing.T) { } } +func TestAppUpdateProviderPickerEnterAppliesSelection(t *testing.T) { + manager := newTestConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Providers = append(cfg.Providers, config.ProviderConfig{ + Name: "openai-alt", + Driver: provideropenai.DriverName, + BaseURL: provideropenai.DefaultBaseURL, + Model: "gpt-4o", + APIKeyEnv: provideropenai.DefaultAPIKeyEnv, + }) + cfg.CurrentModel = "unsupported-current" + return nil + }); err != nil { + t.Fatalf("append provider: %v", err) + } + + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.openProviderPicker() + if len(app.providerPicker.Items()) < 2 { + t.Fatalf("expected provider picker catalog") + } + selected := app.providerPicker.Items()[1].(providerItem).id + app.providerPicker.Select(1) + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if app.state.ActivePicker != pickerNone { + t.Fatalf("expected picker to close after selection") + } + + for _, msg := range collectTeaMessages(cmd) { + model, follow := app.Update(msg) + app = model.(App) + _ = collectTeaMessages(follow) + } + + cfg := manager.Get() + if cfg.SelectedProvider != selected { + t.Fatalf("expected selected provider %q, got %q", selected, cfg.SelectedProvider) + } + if cfg.CurrentModel != "gpt-4o" { + t.Fatalf("expected current model to follow provider default, got %q", cfg.CurrentModel) + } +} + +func TestRefreshPickerKeepsSizeAfterRebuild(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.providerPicker.SetSize(32, 9) + app.modelPicker.SetSize(28, 7) + + if err := app.refreshProviderPicker(); err != nil { + t.Fatalf("refreshProviderPicker() error = %v", err) + } + if err := app.refreshModelPicker(); err != nil { + t.Fatalf("refreshModelPicker() error = %v", err) + } + + if app.providerPicker.Width() != 32 || app.providerPicker.Height() != 9 { + t.Fatalf("expected provider picker size 32x9, got %dx%d", app.providerPicker.Width(), app.providerPicker.Height()) + } + if app.modelPicker.Width() != 28 || app.modelPicker.Height() != 7 { + t.Fatalf("expected model picker size 28x7, got %dx%d", app.modelPicker.Width(), app.modelPicker.Height()) + } +} + func TestAppHandleRuntimeEventAdditionalBranches(t *testing.T) { tests := []struct { name string @@ -801,7 +890,7 @@ func TestAppHandleRuntimeEventAdditionalBranches(t *testing.T) { t.Run(tt.name, func(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() - app, err := New(nil, manager, runtime) + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -820,7 +909,7 @@ func TestAppRefreshErrorPaths(t *testing.T) { runtime := newStubRuntime() runtime.listErr = context.DeadlineExceeded - app, err := New(nil, manager, runtime) + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) if err == nil || !strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { t.Fatalf("expected list session error during New, got %v", err) } @@ -832,7 +921,7 @@ func TestAppRefreshErrorPaths(t *testing.T) { runtime := newStubRuntime() runtime.loadErr = context.Canceled - app, err := New(nil, manager, runtime) + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -847,13 +936,22 @@ func TestAppRefreshErrorPaths(t *testing.T) { func newTestConfigManager(t *testing.T) *config.Manager { t.Helper() - manager := config.NewManager(config.NewLoader(t.TempDir())) + manager := config.NewManager(config.NewLoader(t.TempDir(), builtin.DefaultConfig())) if _, err := manager.Load(context.Background()); err != nil { t.Fatalf("load config: %v", err) } return manager } +func newTestProviderService(t *testing.T, manager *config.Manager) *provider.Service { + t.Helper() + registry, err := builtin.NewRegistry() + if err != nil { + t.Fatalf("register provider drivers: %v", err) + } + return provider.NewService(manager, registry) +} + func collectTeaMessages(cmd tea.Cmd) []tea.Msg { if cmd == nil { return nil diff --git a/internal/tui/view.go b/internal/tui/view.go index 970d509f..5c1fed4b 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -117,13 +117,13 @@ func (a App) renderSidebar(width int, height int) string { } func (a App) renderWaterfall(width int, height int) string { - if a.state.ShowModelPicker { + if a.state.ActivePicker != pickerNone { return lipgloss.Place( width, height, lipgloss.Center, lipgloss.Center, - a.renderModelPicker(clamp(width-10, 36, 56), clamp(height-6, 10, 14)), + a.renderPicker(clamp(width-10, 36, 56), clamp(height-6, 10, 14)), ) } @@ -138,13 +138,21 @@ func (a App) renderWaterfall(width int, height int) string { return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, lipgloss.JoinVertical(lipgloss.Left, parts...)) } -func (a App) renderModelPicker(width int, height int) string { +func (a App) renderPicker(width int, height int) string { frameHeight := a.styles.panelFocused.GetVerticalFrameSize() + title := modelPickerTitle + subtitle := modelPickerSubtitle + body := a.modelPicker.View() + if a.state.ActivePicker == pickerProvider { + title = providerPickerTitle + subtitle = providerPickerSubtitle + body = a.providerPicker.View() + } content := lipgloss.JoinVertical( lipgloss.Left, - a.styles.panelTitle.Render(modelPickerTitle), - a.styles.panelSubtitle.Render(modelPickerSubtitle), - a.modelPicker.View(), + a.styles.panelTitle.Render(title), + a.styles.panelSubtitle.Render(subtitle), + body, ) panel := a.styles.panelFocused. Width(max(1, width-2)). @@ -155,7 +163,7 @@ func (a App) renderModelPicker(width int, height int) string { func (a App) renderPrompt(width int) string { box := a.styles.inputBox - if a.focus == panelInput && !a.state.ShowModelPicker { + if a.focus == panelInput && a.state.ActivePicker == pickerNone { box = a.styles.inputBoxFocused } From a429bc44b456a0513f0ee18f56df7752ff2054b9 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sat, 28 Mar 2026 18:19:33 +0800 Subject: [PATCH 09/15] =?UTF-8?q?docs=EF=BC=9A=E5=8E=BB=E9=99=A4=E5=86=97?= =?UTF-8?q?=E4=BD=99=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/flow-design.md | 0 docs/todo-flow-aligned-implementation-plan.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 docs/flow-design.md delete mode 100644 docs/todo-flow-aligned-implementation-plan.md diff --git a/docs/flow-design.md b/docs/flow-design.md deleted file mode 100644 index e69de29b..00000000 diff --git a/docs/todo-flow-aligned-implementation-plan.md b/docs/todo-flow-aligned-implementation-plan.md deleted file mode 100644 index e69de29b..00000000 From b6e15999eb351c2512fb942811e41bd071988f10 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sat, 28 Mar 2026 18:30:01 +0800 Subject: [PATCH 10/15] =?UTF-8?q?=EF=BC=9A=E4=BF=AE=E6=94=B9config/mo?= =?UTF-8?q?del.go=E5=90=88=E5=B9=B6=E5=90=8E=E7=9A=84=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E5=90=8D=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/model.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/model.go b/internal/config/model.go index 66f16c3e..c404972c 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -132,7 +132,7 @@ func (c *Config) ApplyDefaultsFrom(defaults Config) { if c.ToolTimeoutSec <= 0 { c.ToolTimeoutSec = defaults.ToolTimeoutSec } - c.Tools.ApplyDefaults(def.Tools) + c.Tools.ApplyDefaults(defaults.Tools) c.Workdir = normalizeWorkdir(c.Workdir) } From 30a8bad7b2791394356330ea841892ccfae8e7ea Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sat, 28 Mar 2026 18:33:56 +0800 Subject: [PATCH 11/15] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/loader.go | 3 + internal/config/model.go | 139 +++++++++++++++++++++++++++++++++++++- 2 files changed, 139 insertions(+), 3 deletions(-) diff --git a/internal/config/loader.go b/internal/config/loader.go index 7e214594..37c77985 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -31,6 +31,7 @@ type persistedConfig struct { Shell string `yaml:"shell"` MaxLoops int `yaml:"max_loops,omitempty"` ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` + Tools ToolsConfig `yaml:"tools,omitempty"` // Legacy read-only field. New saves never emit it. Providers []ProviderConfig `yaml:"providers,omitempty"` @@ -134,6 +135,7 @@ func (l *Loader) Save(ctx context.Context, cfg *Config) error { Shell: snapshot.Shell, MaxLoops: snapshot.MaxLoops, ToolTimeoutSec: snapshot.ToolTimeoutSec, + Tools: snapshot.Tools, } data, err := yaml.Marshal(&file) @@ -228,6 +230,7 @@ func parseCurrentConfig(data []byte, defaults Config) (*Config, error) { Shell: strings.TrimSpace(file.Shell), MaxLoops: file.MaxLoops, ToolTimeoutSec: file.ToolTimeoutSec, + Tools: file.Tools, } switch { diff --git a/internal/config/model.go b/internal/config/model.go index 10093eeb..0b2c4df6 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -3,6 +3,7 @@ package config import ( "errors" "fmt" + "mime" "os" "path/filepath" goruntime "runtime" @@ -10,11 +11,21 @@ import ( ) const ( - DefaultWorkdir = "." - DefaultMaxLoops = 8 - DefaultToolTimeoutSec = 20 + DefaultWorkdir = "." + DefaultMaxLoops = 8 + DefaultToolTimeoutSec = 20 + DefaultWebFetchMaxResponseBytes int64 = 256 * 1024 ) +var defaultWebFetchSupportedContentTypes = []string{ + "text/html", + "application/xhtml+xml", + "text/plain", + "application/json", + "application/xml", + "text/xml", +} + type Config struct { Providers []ProviderConfig `yaml:"-"` SelectedProvider string `yaml:"selected_provider"` @@ -23,6 +34,7 @@ type Config struct { Shell string `yaml:"shell"` MaxLoops int `yaml:"max_loops,omitempty"` ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` + Tools ToolsConfig `yaml:"tools,omitempty"` } type ProviderConfig struct { @@ -46,12 +58,28 @@ type ProviderOverride struct { APIKeyEnv string `yaml:"api_key_env,omitempty"` } +type ToolsConfig struct { + WebFetch WebFetchConfig `yaml:"webfetch,omitempty"` +} + +type WebFetchConfig struct { + MaxResponseBytes int64 `yaml:"max_response_bytes,omitempty"` + SupportedContentTypes []string `yaml:"supported_content_types,omitempty"` +} + +func DefaultWebFetchSupportedContentTypes() []string { + return append([]string(nil), defaultWebFetchSupportedContentTypes...) +} + func Default() *Config { return &Config{ Workdir: DefaultWorkdir, Shell: defaultShell(), MaxLoops: DefaultMaxLoops, ToolTimeoutSec: DefaultToolTimeoutSec, + Tools: ToolsConfig{ + WebFetch: defaultWebFetchConfig(), + }, } } @@ -62,6 +90,7 @@ func (c *Config) Clone() Config { clone := *c clone.Providers = append([]ProviderConfig(nil), c.Providers...) + clone.Tools = c.Tools.Clone() return clone } @@ -98,6 +127,7 @@ func (c *Config) ApplyDefaultsFrom(defaults Config) { if c.ToolTimeoutSec <= 0 { c.ToolTimeoutSec = defaults.ToolTimeoutSec } + c.Tools.ApplyDefaults(defaults.Tools) c.Workdir = normalizeWorkdir(c.Workdir) } @@ -142,6 +172,9 @@ func (c *Config) Validate() error { if strings.TrimSpace(selected.Model) == "" { return fmt.Errorf("config: selected provider %q has empty model", selected.Name) } + if err := c.Tools.Validate(); err != nil { + return fmt.Errorf("config: tools: %w", err) + } return nil } @@ -405,3 +438,103 @@ func defaultShell() string { } return "bash" } + +func defaultWebFetchConfig() WebFetchConfig { + return WebFetchConfig{ + MaxResponseBytes: DefaultWebFetchMaxResponseBytes, + SupportedContentTypes: DefaultWebFetchSupportedContentTypes(), + } +} + +func (c ToolsConfig) Clone() ToolsConfig { + return ToolsConfig{ + WebFetch: c.WebFetch.Clone(), + } +} + +func (c *ToolsConfig) ApplyDefaults(defaults ToolsConfig) { + if c == nil { + return + } + + c.WebFetch.ApplyDefaults(defaults.WebFetch) +} + +func (c ToolsConfig) Validate() error { + if err := c.WebFetch.Validate(); err != nil { + return fmt.Errorf("webfetch: %w", err) + } + return nil +} + +func (c WebFetchConfig) Clone() WebFetchConfig { + clone := c + clone.SupportedContentTypes = append([]string(nil), c.SupportedContentTypes...) + return clone +} + +func (c *WebFetchConfig) ApplyDefaults(defaults WebFetchConfig) { + if c == nil { + return + } + + if c.MaxResponseBytes <= 0 { + c.MaxResponseBytes = defaults.MaxResponseBytes + } + c.SupportedContentTypes = normalizeContentTypes(c.SupportedContentTypes, defaults.SupportedContentTypes) +} + +func (c WebFetchConfig) Validate() error { + if c.MaxResponseBytes <= 0 { + return errors.New("max_response_bytes must be greater than 0") + } + if len(c.SupportedContentTypes) == 0 { + return errors.New("supported_content_types is empty") + } + + for i, contentType := range c.SupportedContentTypes { + if normalizeContentType(contentType) == "" { + return fmt.Errorf("supported_content_types[%d] is empty", i) + } + } + return nil +} + +func normalizeContentTypes(values []string, defaults []string) []string { + source := values + if len(source) == 0 { + source = defaults + } + + normalized := make([]string, 0, len(source)) + seen := make(map[string]struct{}, len(source)) + for _, value := range source { + contentType := normalizeContentType(value) + if contentType == "" { + continue + } + if _, exists := seen[contentType]; exists { + continue + } + seen[contentType] = struct{}{} + normalized = append(normalized, contentType) + } + return normalized +} + +func normalizeContentType(value string) string { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed == "" { + return "" + } + + mediaType, _, err := mime.ParseMediaType(trimmed) + if err == nil { + return mediaType + } + + if index := strings.Index(trimmed, ";"); index >= 0 { + return strings.TrimSpace(trimmed[:index]) + } + return trimmed +} From 6a13a353f94eea03b1f742c1cb8d397545dcc69b Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sat, 28 Mar 2026 21:44:26 +0800 Subject: [PATCH 12/15] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8Durl=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=E9=94=99=E8=AF=AF=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 35 ++- agents.md | 118 +++++++-- cmd/neocode/main.go | 2 +- docs/config-management-detail-design.md | 17 +- go.mod | 18 +- go.sum | 10 + internal/app/bootstrap.go | 18 +- internal/app/bootstrap_test.go | 4 +- internal/config/config_test.go | 132 ++++++---- internal/config/loader.go | 122 ++------- internal/config/loader_test.go | 20 +- internal/config/model.go | 246 +++++++++---------- internal/provider/builtin/builtin.go | 10 +- internal/provider/openai/defaults.go | 20 -- internal/provider/openai/driver.go | 60 ----- internal/provider/openai/openai.go | 39 ++- internal/provider/openai/openai_test.go | 39 +-- internal/provider/registry.go | 41 ++-- internal/provider/registry_test.go | 211 ++++++++++------ internal/provider/service.go | 155 ++++++------ internal/provider/types.go | 51 ---- internal/runtime/runtime.go | 8 +- internal/runtime/runtime_test.go | 11 +- internal/runtime/session.go | 2 +- internal/runtime/session_test.go | 2 +- internal/tools/bash/tool.go | 2 +- internal/tools/bash/tool_test.go | 2 +- internal/tools/filesystem/edit.go | 2 +- internal/tools/filesystem/edit_test.go | 2 +- internal/tools/filesystem/glob.go | 2 +- internal/tools/filesystem/glob_test.go | 2 +- internal/tools/filesystem/grep.go | 2 +- internal/tools/filesystem/grep_test.go | 2 +- internal/tools/filesystem/read_file.go | 2 +- internal/tools/filesystem/read_file_test.go | 2 +- internal/tools/filesystem/write_file.go | 2 +- internal/tools/filesystem/write_file_test.go | 2 +- internal/tools/registry.go | 2 +- internal/tools/types.go | 2 +- internal/tools/webfetch/tool.go | 4 +- internal/tools/webfetch/tool_test.go | 4 +- internal/tui/app.go | 6 +- internal/tui/commands.go | 15 +- internal/tui/provider_service.go | 2 +- internal/tui/state.go | 2 +- internal/tui/update.go | 10 +- internal/tui/update_test.go | 27 +- internal/tui/view.go | 2 +- 48 files changed, 743 insertions(+), 748 deletions(-) delete mode 100644 internal/provider/openai/defaults.go delete mode 100644 internal/provider/openai/driver.go diff --git a/README.md b/README.md index 86fc240d..1935d7ff 100644 --- a/README.md +++ b/README.md @@ -4,22 +4,24 @@ NeoCode 是一个基于 Go 和 Bubble Tea 的本地编码 Agent。它在终端 ## 当前 Provider 策略 -- 内建 provider 定义跟随代码版本发布 -- `config.yaml` 不再持久化完整 `providers` 列表 -- `config.yaml` 只保存用户状态和显式覆写 -- 运行时的 `providers` 由“内建定义 + 用户 overrides”合并得到 -- API Key 只从环境变量读取,不写入 YAML +- 内建 provider 定义随代码版本发布。 +- `config.yaml` 不再持久化完整 `providers` 列表。 +- `config.yaml` 只保存当前选择状态和通用运行配置。 +- 运行时的 `providers` 完全来自代码内建定义。 +- API Key 只从环境变量读取,不写入 YAML。 +- provider 实例自己定义 `base_url`、默认模型、可选模型列表和 `api_key_env`。 +- `base_url` 不在 TUI 中展示给用户。 +- driver 只负责协议构造与响应解析,不决定 `models`、`base_url` 或 `api_key_env`。 这意味着: -- 新用户启动后会自动拿到当前版本最新的内建 provider -- 未来代码新增 provider 时,新用户不需要修改 YAML -- 老用户如果没有覆写某个字段,也会跟随最新内建默认值 +- 新用户启动后会自动拿到当前版本最新的内建 provider。 +- 未来代码新增 provider 时,新用户不需要修改 YAML。 +- 老配置文件中的 `providers` / `provider_overrides` 会在加载时被清理为新的最小状态格式。 ## 配置文件 默认路径: - [`~/.neocode/config.yaml`](~/.neocode/config.yaml) 当前落盘结构示例: @@ -27,11 +29,6 @@ NeoCode 是一个基于 Go 和 Bubble Tea 的本地编码 Agent。它在终端 ```yaml selected_provider: openai current_model: gpt-5.4 -provider_overrides: - - name: openai - base_url: https://example.com/v1 - model: gpt-5.4 - api_key_env: AI_API_KEY workdir: . shell: powershell max_loops: 8 @@ -40,14 +37,14 @@ tool_timeout_sec: 20 其中: -- `selected_provider` 和 `current_model` 是用户当前选择 -- `provider_overrides` 只保存和内建默认值不同的部分 -- 完整 provider 列表不落盘 +- `selected_provider` 和 `current_model` 是用户当前选择。 +- provider 的 `base_url`、`models`、`api_key_env` 和 `driver` 都由开发者在代码中预设。 +- 完整 provider 列表不落盘,用户不需要在 YAML 中维护供应商元数据。 ## Slash Commands -- `/provider`:打开 provider 选择器 -- `/model`:打开当前 provider 的模型选择器 +- `/provider`:打开 provider 选择器。 +- `/model`:打开当前 provider 的模型选择器。 ## 运行 diff --git a/agents.md b/agents.md index 1a36defa..1b227253 100644 --- a/agents.md +++ b/agents.md @@ -1,39 +1,109 @@ -# NeoCode +# NeoCode Agent Rules -## Name +本文件是本仓库的 AI 协作规则。任何 AI 在本项目中进行改写、续写、重构、修复、补测试或补文档时,都应优先遵守本文件。 -NeoCode +## 1. 任务目标 +- 本仓库的目标是实现 `NeoCode Coding Agent MVP`。 +- 当前主链路必须始终围绕以下闭环保持可用: + `用户输入 -> Agent 推理 -> 调用工具 -> 获取结果 -> 继续推理 -> UI 展示` +- 做改动时,优先保证主链路可运行、模块边界清晰、实现可验证。 -## Description +## 2. 最高优先级规则 +- 不要为了“可能兼容旧版本”破坏当前架构;若新设计已确定,优先直接切换到新实现。 +- 不要跨层直连;新功能默认沿 `TUI -> Runtime -> Provider / Tool Manager` 主链路设计。 +- 不要把模型厂商差异泄漏到 `runtime`、`tui` 或上层调用方。 +- 不要在 `runtime` 或 `tui` 里直接写工具执行逻辑;所有可被模型调用的能力必须进入 `internal/tools`。 +- 不要把会话状态、消息历史、工具调用记录散落到 UI;这些状态优先由 `runtime` 管理。 +- 不要把明文 API Key 写入 YAML、样例配置、测试快照或提交内容。 +- 修改 `config`、`provider`、`runtime`、`tools` 时,默认应同时评估并补充测试。 -NeoCode 是一个基于 Go 和 Bubble Tea 构建的本地编码 Agent。它在终端中运行 ReAct 闭环,可以对话、调用工具、持久化会话,并以流式方式展示推理结果。 +## 3. 代码结构与职责边界 -## Capabilities +### 3.1 关键目录 +- `cmd/neocode`:CLI 入口。 +- `internal/app`:应用装配与 bootstrap,负责连接 config、provider、tools、runtime、tui。 +- `internal/config`:配置模型、YAML 加载、环境变量管理、配置校验和并发安全访问。 +- `internal/provider`:provider 抽象、领域模型和各厂商适配器。 +- `internal/runtime`:ReAct 主循环、事件流、Prompt 编排、Session 持久化。 +- `internal/tools`:工具契约、注册表、参数校验和具体工具实现。 +- `internal/tui`:Bubble Tea 状态机、渲染层、Slash Command 和事件桥接。 +- `docs`:架构、配置、事件流、会话持久化等说明文档。 -- 通过 provider 无关的 runtime 读取、理解并操作当前工作区代码 -- 调用文件系统、Shell 和 Web 工具完成读取、写入、搜索、精准修改等任务 -- 将会话历史持久化到本地,并在 TUI 侧边栏中恢复旧会话 -- 在瀑布流终端界面中实时展示模型输出和工具执行过程 -- 通过 Slash Command 切换当前 provider 和模型 +### 3.2 模块职责 +- `app` 只负责装配和依赖注入,不承载业务规则。 +- `config` 负责 provider 列表、当前 provider、当前 model、workdir、shell 的管理和校验。 +- `provider` 只处理模型协议差异、请求组装、响应解析、流式输出、超时与重试。 +- `runtime` 负责会话、消息上下文、tool schema 传递、tool call 识别、tool result 回灌和停止条件。 +- `tools` 负责统一的 `schema + execute + result` 协议,以及参数校验、错误包装和输出格式收敛。 +- `tui` 只消费 runtime 事件并负责展示,不直接调用 provider,不直接执行 tools。 -## Project Structure +## 4. AI 修改代码时的执行流程 +- 先定位改动所属模块,再检查是否会破坏职责边界。 +- 优先做最小闭环改动,避免无关重构。 +- 如果新增能力会被模型调用,先设计 `tools` 抽象,再接入 `runtime`。 +- 如果改动涉及 provider 协议,差异收敛在 `internal/provider` 内,不向外扩散特定厂商字段。 +- 如果改动涉及配置,统一经由 `ConfigManager` 或现有配置管理入口处理。 +- 如果新增目录、命令、配置项或模块职责,必须同步更新 `README.md`、`docs/` 或本文件。 -- `internal/config`:配置结构、YAML 加载、环境变量管理和并发安全访问 -- `internal/provider`:provider 接口、catalog、driver 注册和厂商适配器 -- `internal/provider/builtin`:内建 provider 定义和注册入口 -- `internal/runtime`:事件流、Prompt 编排与 ReAct 主循环 -- `internal/tui`:Bubble Tea 状态机、渲染层、Slash Command 和事件桥接 +## 5. 测试规则 +- 测试文件命名为 `*_test.go`,测试函数命名为 `TestXxx`。 +- 优先覆盖以下边界: + - 配置校验 + - provider 请求/响应转换 + - tool 参数校验 + - runtime loop 停止条件 + - 事件派发 +- 修改 `runtime` 时,重点覆盖: + - 最大轮数停止 + - tool result 回灌 + - 最终响应输出 + - 错误事件派发 +- 修改 `tools` 时,重点覆盖: + - schema 校验 + - 超时控制 + - 错误包装 + - 工作目录限制 +- 修改 `provider` 时,重点覆盖: + - 请求组装 + - tool call 解析 + - 异常响应处理 + - 认证或限流错误映射 -## Provider Rules +## 6. 编码与实现规则 +- 遵循 Go 惯用风格。 +- 包名使用短小、全小写名词。 +- 导出标识符使用 `PascalCase`,未导出使用 `camelCase`。 +- 使用制表符缩进,尽量将单行控制在约 120 字符内。 +- 核心抽象上的导出类型、函数、接口应补充清晰注释,尤其是 `provider`、`runtime`、`tools`。 +- 避免硬编码路径、URL、模型名、超时、输出长度限制和环境差异项,优先通过配置、参数或具名常量注入。 +- 优先写清晰、可替换的实现,不要为了“未来可能需要”提前引入复杂泛化。 -- 内建 provider 定义属于代码,不属于用户 YAML -- `config.yaml` 只保存 `selected_provider`、`current_model` 和 `provider_overrides` -- 运行时 provider 列表由“内建 provider + overrides”合并得到 -- API Key 不得写入 YAML,只能通过环境变量提供 -- TUI 不提供 `/set url` 或 `/set key` +## 7. 配置与安全规则 +- 配置文件默认路径为 `~/.neocode/config.yaml`。 +- 配置中只保存环境变量名,不保存明文 API Key。 +- `selected_provider`、`current_model`、`workdir`、`shell` 必须在启动阶段完成校验,非法配置应尽早失败。 +- `filesystem` 工具默认限制在工作目录内,禁止越界访问。 +- `bash` 工具必须限制超时、输出长度,并避免交互式阻塞命令。 +- `webfetch` 工具必须限制协议范围和响应大小,防止拉取不受控内容。 +- 本地运行数据、会话数据和真实密钥不得默认入库;相关目录和配置文件应加入 `.gitignore`。 -## Setup Commands +## 8. 文档规则 +- 修改说明性文档时,沿用目标文档当前已有的主语言。 +- 当前文档如果以中文为主,则继续使用中文;若以英文为主,则继续使用英文。 +- 仅在代码标识符、命令、路径、协议名、库名等不可避免的场景下保留英文原词。 +- 文档必须反映真实实现;如果实现和文档冲突,修代码或修文档时至少修正其中一个,不要放任失真。 +## 9. 提交前最低检查 +- 确认改动没有破坏 `TUI / Runtime / Provider / Tools / Config` 的职责分工。 +- 确认新增能力已经接到正确层级,而不是临时跨层实现。 +- 运行必要的格式化和测试。 +- 检查 `git status`,确保没有无关文件、密钥、本地配置或临时数据混入。 + +## 10. 常用命令 - 启动应用:`go run ./cmd/neocode` +- 编译全部包:`go build ./...` - 运行测试:`go test ./...` - 格式化代码:`gofmt -w ./cmd ./internal` + +## 11. 一句话原则 +- 任何 AI 在此仓库中工作时,都应优先保持主链路可用、边界清晰、配置安全、测试可验证,而不是追求无关的大改或过度设计。 diff --git a/cmd/neocode/main.go b/cmd/neocode/main.go index 7f105e46..68d741d3 100644 --- a/cmd/neocode/main.go +++ b/cmd/neocode/main.go @@ -5,7 +5,7 @@ import ( "fmt" "os" - "github.com/dust/neo-code/internal/app" + "neo-code/internal/app" ) func main() { diff --git a/docs/config-management-detail-design.md b/docs/config-management-detail-design.md index 7d3d9701..8975eb90 100644 --- a/docs/config-management-detail-design.md +++ b/docs/config-management-detail-design.md @@ -7,25 +7,26 @@ - 向运行中的系统提供并发安全的配置读写能力 ## 核心类型 -- `Config`:顶层应用配置,包含 Provider 列表、当前选中 Provider、当前模型、工作目录、Shell 和循环限制等信息 -- `ProviderConfig`:单个 Provider 的配置项,包括 Base URL、默认模型和 API Key 环境变量名 +- `Config`:顶层应用配置,运行时包含 Provider 列表、当前选中 Provider、当前模型、工作目录、Shell 和循环限制等信息 +- `ProviderConfig`:单个 Provider 的内建定义,包括 Base URL、默认模型、实例级模型列表和 API Key 环境变量名 - `Manager`:使用 `sync.RWMutex` 保护的配置访问器与修改器 - `Loader`:对 YAML 文件和托管 `.env` 文件的文件系统封装 ## 环境变量策略 -- YAML 只保存 `api_key_env`,不保存真实密钥。 +- YAML 不保存 `api_key_env`,只保存 `selected_provider`、`current_model` 和通用运行配置。 - `Loader.LoadEnvironment` 会尝试加载当前工作目录下的 `.env` 和 NeoCode 托管目录中的 `.env`。 - `ProviderConfig.ResolveAPIKey` 在真正发起请求前通过 `os.Getenv` 读取密钥。 ## 运行时更新 - TUI 只能通过 `ConfigManager.Update` 修改配置。 -- 修改 Base URL 时只更新当前选中 Provider,并立即持久化。 -- 修改 API Key 时写入托管 `.env`,然后重新加载环境变量并刷新配置快照。 -- 修改模型时,同时更新 `current_model` 和当前 Provider 的 `model` 字段,保持状态一致。 +- TUI 只负责切换当前 Provider 和当前模型,不直接修改 Provider 元数据。 +- `base_url`、`api_key_env`、`driver` 和 `models` 由代码内建定义提供,不从 YAML 读写。 +- 修改模型时,只更新 `current_model`;当前 Provider 的 `model` 仍表示默认模型,`models` 负责描述该实例可选模型列表。 ## 默认值治理 -- 默认 Provider 名称、URL、模型和环境变量名统一定义在 `internal/config/model.go` 中。 -- 内建模型目录也收口在 `config` 包中,避免 TUI 自己维护一套零散的临时常量。 +- 默认 Provider 名称、URL、默认模型、模型列表和环境变量名统一由内建 Provider 定义提供。 +- `Loader` 在加载旧配置时会丢弃 `providers` / `provider_overrides`,重新回到“YAML 只保存选择状态”的最小结构。 +- Provider 的可选模型目录属于实例配置,进入运行时 `Config` 后再提供给 TUI 和 runtime,避免 TUI 或 driver 自己维护一套零散常量。 ## 安全约束 - 读操作统一走 `Get`,并返回拷贝后的配置快照。 diff --git a/go.mod b/go.mod index 563ad14d..8241d472 100644 --- a/go.mod +++ b/go.mod @@ -1,37 +1,37 @@ -module github.com/dust/neo-code +module neo-code -go 1.24.2 +go 1.25.0 require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/joho/godotenv v1.5.1 - golang.org/x/net v0.50.0 + golang.org/x/net v0.52.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index 8adf72f8..9a787c6c 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= @@ -24,10 +26,14 @@ github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSg github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -42,6 +48,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -58,6 +66,8 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 28a09fc0..cc4a9e49 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -6,15 +6,15 @@ import ( tea "github.com/charmbracelet/bubbletea" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" - "github.com/dust/neo-code/internal/provider/builtin" - agentruntime "github.com/dust/neo-code/internal/runtime" - "github.com/dust/neo-code/internal/tools" - "github.com/dust/neo-code/internal/tools/bash" - "github.com/dust/neo-code/internal/tools/filesystem" - "github.com/dust/neo-code/internal/tools/webfetch" - "github.com/dust/neo-code/internal/tui" + "neo-code/internal/config" + "neo-code/internal/provider" + "neo-code/internal/provider/builtin" + agentruntime "neo-code/internal/runtime" + "neo-code/internal/tools" + "neo-code/internal/tools/bash" + "neo-code/internal/tools/filesystem" + "neo-code/internal/tools/webfetch" + "neo-code/internal/tui" ) func NewProgram(ctx context.Context) (*tea.Program, error) { diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index 5974d9ac..51dc688a 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -9,8 +9,8 @@ import ( "path/filepath" "testing" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/config" + "neo-code/internal/tools" ) func TestNewProgram(t *testing.T) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 24ea5480..8f8c82d5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -22,6 +22,7 @@ func testDefaultProviderConfig() ProviderConfig { Driver: testProviderName, BaseURL: testBaseURL, Model: testModel, + Models: []string{testModel, "gpt-4o", "gpt-5.4", "gpt-5.3-codex"}, APIKeyEnv: testAPIKeyEnv, } } @@ -44,7 +45,7 @@ func TestParseConfigFormats(t *testing.T) { assert func(t *testing.T, cfg *Config) }{ { - name: "current format with provider overrides", + name: "current format ignores persisted provider metadata", data: ` selected_provider: openai current_model: gpt-5.4 @@ -73,8 +74,11 @@ providers: if err != nil { t.Fatalf("selected provider: %v", err) } - if provider.BaseURL != "https://example.com/v1" { - t.Fatalf("expected custom base url, got %q", provider.BaseURL) + if provider.BaseURL != testBaseURL { + t.Fatalf("expected builtin base url %q, got %q", testBaseURL, provider.BaseURL) + } + if provider.Model != testModel { + t.Fatalf("expected builtin default model %q, got %q", testModel, provider.Model) } if cfg.Tools.WebFetch.MaxResponseBytes != 4096 { t.Fatalf("expected custom max_response_bytes 4096, got %d", cfg.Tools.WebFetch.MaxResponseBytes) @@ -85,7 +89,7 @@ providers: }, }, { - name: "legacy persisted providers list", + name: "legacy persisted providers list keeps selection only", data: ` selected_provider: openai current_model: gpt-5.4 @@ -104,11 +108,14 @@ providers: if err != nil { t.Fatalf("selected provider: %v", err) } - if provider.BaseURL != "https://example.com/v1" { - t.Fatalf("expected migrated base url, got %q", provider.BaseURL) + if provider.BaseURL != testBaseURL { + t.Fatalf("expected builtin base url %q, got %q", testBaseURL, provider.BaseURL) + } + if provider.Model != testModel { + t.Fatalf("expected builtin default model %q, got %q", testModel, provider.Model) } - if provider.Model != "gpt-5.4" { - t.Fatalf("expected migrated model, got %q", provider.Model) + if cfg.CurrentModel != "gpt-5.4" { + t.Fatalf("expected selected current model to stay %q, got %q", "gpt-5.4", cfg.CurrentModel) } }, }, @@ -137,8 +144,14 @@ providers: if err != nil { t.Fatalf("selected provider: %v", err) } - if provider.Model != "gpt-4o" { - t.Fatalf("expected provider model gpt-4o, got %q", provider.Model) + if provider.Model != testModel { + t.Fatalf("expected builtin default model %q, got %q", testModel, provider.Model) + } + if cfg.CurrentModel != "gpt-4o" { + t.Fatalf("expected current model %q, got %q", "gpt-4o", cfg.CurrentModel) + } + if len(provider.Models) != len(testDefaultProviderConfig().Models) { + t.Fatalf("expected builtin provider models, got %+v", provider.Models) } if cfg.Tools.WebFetch.MaxResponseBytes != DefaultWebFetchMaxResponseBytes { t.Fatalf("expected default max_response_bytes %d, got %d", DefaultWebFetchMaxResponseBytes, cfg.Tools.WebFetch.MaxResponseBytes) @@ -357,6 +370,7 @@ func TestManagerConcurrentAccess(t *testing.T) { for k := range next.Providers { if next.Providers[k].Name == next.SelectedProvider { next.Providers[k].Model = model + next.Providers[k].Models = append([]string(nil), models...) } } return nil @@ -493,6 +507,15 @@ func TestConfigValidateFailures(t *testing.T) { }(), expectErr: "supported_content_types[0] is empty", }, + { + name: "current model unsupported by selected provider", + config: func() *Config { + cfg := validConfig.Clone() + cfg.CurrentModel = "unsupported-model" + return &cfg + }(), + expectErr: "current_model \"unsupported-model\" is not supported", + }, } for _, tt := range tests { @@ -554,6 +577,18 @@ func TestProviderConfigValidateFailures(t *testing.T) { }, expectErr: "api_key_env is empty", }, + { + name: "default model missing from models", + provider: ProviderConfig{ + Name: testProviderName, + Driver: testProviderName, + BaseURL: testBaseURL, + Model: testModel, + Models: []string{"gpt-4o"}, + APIKeyEnv: testAPIKeyEnv, + }, + expectErr: "default model \"gpt-4.1\" is not in models", + }, } for _, tt := range tests { @@ -607,11 +642,8 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { } cfg.CurrentModel = "gpt-5.4" - for i := range cfg.Providers { - if cfg.Providers[i].Name == cfg.SelectedProvider { - cfg.Providers[i].Model = "gpt-5.4" - } - } + cfg.Providers[0].Models = []string{"gpt-4.1", "gpt-5.4"} + cfg.Providers[0].BaseURL = "https://ignored.example/v1" cfg.Tools.WebFetch.MaxResponseBytes = 1024 cfg.Tools.WebFetch.SupportedContentTypes = []string{"text/html", "application/json"} if err := loader.Save(context.Background(), cfg); err != nil { @@ -623,11 +655,14 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { t.Fatalf("read config file: %v", err) } text := string(data) - if !strings.Contains(text, "provider_overrides:") { - t.Fatalf("expected persisted provider overrides, got:\n%s", text) - } if strings.Contains(text, "\nproviders:") || strings.HasPrefix(text, "providers:") { - t.Fatalf("expected persisted config to omit full providers list, got:\n%s", text) + t.Fatalf("expected persisted config to omit providers, got:\n%s", text) + } + if strings.Contains(text, "provider_overrides:") { + t.Fatalf("expected persisted config to omit provider overrides, got:\n%s", text) + } + if strings.Contains(text, "models:") || strings.Contains(text, "base_url:") || strings.Contains(text, "api_key_env:") { + t.Fatalf("expected persisted config to keep only selection state and common runtime settings, got:\n%s", text) } reloaded, err := loader.Load(context.Background()) @@ -637,6 +672,19 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { if reloaded.CurrentModel != "gpt-5.4" { t.Fatalf("expected current model %q, got %q", "gpt-5.4", reloaded.CurrentModel) } + provider, err := reloaded.SelectedProviderConfig() + if err != nil { + t.Fatalf("SelectedProviderConfig() reload error = %v", err) + } + if provider.Model != testModel { + t.Fatalf("expected provider default model to stay %q, got %q", testModel, provider.Model) + } + if provider.BaseURL != testBaseURL { + t.Fatalf("expected provider base url to come from builtin definition, got %q", provider.BaseURL) + } + if len(provider.Models) != len(testDefaultProviderConfig().Models) { + t.Fatalf("expected provider models to come from builtin definition, got %+v", provider.Models) + } if reloaded.Tools.WebFetch.MaxResponseBytes != 1024 { t.Fatalf("expected max_response_bytes %d, got %d", 1024, reloaded.Tools.WebFetch.MaxResponseBytes) } @@ -676,45 +724,25 @@ func TestLoaderUsesUpdatedBuiltinProviderWhenUserHasNoOverride(t *testing.T) { } } -func TestDeriveAndMergeProviderOverrides(t *testing.T) { +func TestApplyProviderDefaultsMatchesByNameOnly(t *testing.T) { defaults := []ProviderConfig{testDefaultProviderConfig()} - current := []ProviderConfig{ - { - Name: testProviderName, - Driver: testProviderName, - BaseURL: "https://override.example/v1", - Model: "gpt-5.4", - APIKeyEnv: "AI_API_KEY", - }, - { - Name: "openai-alt", - Driver: testProviderName, - BaseURL: "https://alt.example/v1", - Model: "gpt-4o", - APIKeyEnv: "ALT_KEY", + current := Config{ + Providers: []ProviderConfig{ + { + Name: "openai-alt", + Driver: testProviderName, + }, }, } - overrides := DeriveProviderOverrides(current, defaults) - merged := MergeProviderOverrides(defaults, overrides) - if len(merged) != 2 { - t.Fatalf("expected 2 merged providers, got %d", len(merged)) - } + current.ApplyDefaultsFrom(Config{Providers: defaults}) - base, err := (&Config{Providers: merged, SelectedProvider: testProviderName}).SelectedProviderConfig() - if err != nil { - t.Fatalf("SelectedProviderConfig() error = %v", err) - } - if base.BaseURL != "https://override.example/v1" || base.Model != "gpt-5.4" || base.APIKeyEnv != "AI_API_KEY" { - t.Fatalf("unexpected merged builtin override: %+v", base) - } - - alt, err := (&Config{Providers: merged}).ProviderByName("openai-alt") + provider, err := current.ProviderByName("openai-alt") if err != nil { t.Fatalf("ProviderByName(openai-alt) error = %v", err) } - if alt.Driver != testProviderName || alt.BaseURL != "https://alt.example/v1" { - t.Fatalf("unexpected merged custom provider: %+v", alt) + if provider.BaseURL != "" || provider.Model != "" || provider.APIKeyEnv != "" || len(provider.Models) != 0 { + t.Fatalf("expected provider metadata to stay empty when only driver matches, got %+v", provider) } } @@ -784,10 +812,14 @@ func TestNormalizeWorkdirAndClone(t *testing.T) { cfg := testDefaultConfig() cloned := cfg.Clone() cloned.CurrentModel = "modified" + cloned.Providers[0].Models[0] = "modified-model" cloned.Tools.WebFetch.SupportedContentTypes[0] = "application/json" if cfg.CurrentModel == cloned.CurrentModel { t.Fatalf("expected clone to be independent from source") } + if cfg.Providers[0].Models[0] == cloned.Providers[0].Models[0] { + t.Fatalf("expected provider models to be cloned") + } if cfg.Tools.WebFetch.SupportedContentTypes[0] == cloned.Tools.WebFetch.SupportedContentTypes[0] { t.Fatalf("expected webfetch supported content types to be cloned") } diff --git a/internal/config/loader.go b/internal/config/loader.go index 37c77985..0c40c051 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -24,17 +24,13 @@ type Loader struct { } type persistedConfig struct { - ProviderOverrides []ProviderOverride `yaml:"provider_overrides,omitempty"` - SelectedProvider string `yaml:"selected_provider"` - CurrentModel string `yaml:"current_model"` - Workdir string `yaml:"workdir"` - Shell string `yaml:"shell"` - MaxLoops int `yaml:"max_loops,omitempty"` - ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` - Tools ToolsConfig `yaml:"tools,omitempty"` - - // Legacy read-only field. New saves never emit it. - Providers []ProviderConfig `yaml:"providers,omitempty"` + SelectedProvider string `yaml:"selected_provider"` + CurrentModel string `yaml:"current_model"` + Workdir string `yaml:"workdir"` + Shell string `yaml:"shell"` + MaxLoops int `yaml:"max_loops,omitempty"` + ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` + Tools ToolsConfig `yaml:"tools,omitempty"` } func NewLoader(baseDir string, defaults *Config) *Loader { @@ -128,14 +124,13 @@ func (l *Loader) Save(ctx context.Context, cfg *Config) error { } file := persistedConfig{ - ProviderOverrides: DeriveProviderOverrides(snapshot.Providers, l.defaults.Providers), - SelectedProvider: snapshot.SelectedProvider, - CurrentModel: snapshot.CurrentModel, - Workdir: snapshot.Workdir, - Shell: snapshot.Shell, - MaxLoops: snapshot.MaxLoops, - ToolTimeoutSec: snapshot.ToolTimeoutSec, - Tools: snapshot.Tools, + SelectedProvider: snapshot.SelectedProvider, + CurrentModel: snapshot.CurrentModel, + Workdir: snapshot.Workdir, + Shell: snapshot.Shell, + MaxLoops: snapshot.MaxLoops, + ToolTimeoutSec: snapshot.ToolTimeoutSec, + Tools: snapshot.Tools, } data, err := yaml.Marshal(&file) @@ -172,17 +167,7 @@ func parseConfig(data []byte, defaults Config) (*Config, error) { return &Config{}, nil } - cfg, currentErr := parseCurrentConfig(data, defaults) - if currentErr == nil { - return cfg, nil - } - - legacy, legacyErr := parseLegacyConfig(data, defaults) - if legacyErr == nil { - return legacy, nil - } - - return nil, currentErr + return parseCurrentConfig(data, defaults) } type aliasConfig struct { @@ -190,24 +175,7 @@ type aliasConfig struct { WorkspaceRoot string `yaml:"workspace_root"` } -type legacyConfig struct { - SelectedProvider string `yaml:"selected_provider"` - CurrentModel string `yaml:"current_model"` - MaxLoop int `yaml:"max_loop"` - ToolTimeoutSec int `yaml:"tool_timeout_sec"` - WorkspaceRoot string `yaml:"workspace_root"` - Shell string `yaml:"shell"` - Providers map[string]legacyProviderConfig `yaml:"providers"` -} - -type legacyProviderConfig struct { - Type string `yaml:"type"` - BaseURL string `yaml:"base_url"` - APIKeyEnv string `yaml:"api_key_env"` - Models []string `yaml:"models"` -} - -func parseCurrentConfig(data []byte, defaults Config) (*Config, error) { +func parseCurrentConfig(data []byte, _ Config) (*Config, error) { var file persistedConfig if err := yaml.Unmarshal(data, &file); err != nil { return nil, err @@ -233,72 +201,18 @@ func parseCurrentConfig(data []byte, defaults Config) (*Config, error) { Tools: file.Tools, } - switch { - case len(file.ProviderOverrides) > 0: - cfg.Providers = MergeProviderOverrides(defaults.Providers, file.ProviderOverrides) - case len(file.Providers) > 0: - cfg.Providers = MergeProviderOverrides(defaults.Providers, DeriveProviderOverrides(file.Providers, defaults.Providers)) - } - return cfg, nil } -func parseLegacyConfig(data []byte, defaults Config) (*Config, error) { - var legacy legacyConfig - if err := yaml.Unmarshal(data, &legacy); err != nil { - return nil, err - } - - return convertLegacyConfig(legacy, defaults), nil -} - -func convertLegacyConfig(in legacyConfig, defaults Config) *Config { - out := &Config{ - SelectedProvider: strings.TrimSpace(in.SelectedProvider), - CurrentModel: strings.TrimSpace(in.CurrentModel), - Workdir: strings.TrimSpace(in.WorkspaceRoot), - Shell: strings.TrimSpace(in.Shell), - MaxLoops: in.MaxLoop, - ToolTimeoutSec: in.ToolTimeoutSec, - } - - for name, provider := range in.Providers { - model := firstNonEmpty(provider.Models...) - if strings.EqualFold(name, in.SelectedProvider) && strings.TrimSpace(in.CurrentModel) != "" { - model = strings.TrimSpace(in.CurrentModel) - } - - out.Providers = append(out.Providers, ProviderConfig{ - Name: strings.TrimSpace(name), - Driver: strings.TrimSpace(provider.Type), - BaseURL: strings.TrimSpace(provider.BaseURL), - Model: strings.TrimSpace(model), - APIKeyEnv: strings.TrimSpace(provider.APIKeyEnv), - }) - } - - out.Providers = MergeProviderOverrides(defaults.Providers, DeriveProviderOverrides(out.Providers, defaults.Providers)) - return out -} - -func firstNonEmpty(items ...string) string { - for _, item := range items { - if strings.TrimSpace(item) != "" { - return strings.TrimSpace(item) - } - } - return "" -} - func requiresConfigRewrite(data []byte) bool { text := strings.TrimSpace(string(data)) switch { case text == "": return false - case strings.Contains(text, "provider_overrides:"): - return strings.Contains(text, "workspace_root:") || strings.Contains(text, "max_loop:") case strings.Contains(text, "\nproviders:") || strings.HasPrefix(text, "providers:"): return true + case strings.Contains(text, "provider_overrides:"): + return true case strings.Contains(text, "workspace_root:") || strings.Contains(text, "max_loop:"): return true default: diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 0f37a271..fc20a2d1 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -111,6 +111,9 @@ providers: type: openai base_url: https://example.com/v1 model: gpt-5.4 + models: + - gpt-4.1 + - gpt-5.4 api_key_env: OPENAI_API_KEY ` if err := os.WriteFile(loader.ConfigPath(), []byte(strings.TrimSpace(legacy)+"\n"), 0o644); err != nil { @@ -125,8 +128,14 @@ providers: if err != nil { t.Fatalf("SelectedProviderConfig() error = %v", err) } - if provider.BaseURL != "https://example.com/v1" { - t.Fatalf("expected migrated provider base url, got %q", provider.BaseURL) + if provider.BaseURL != testBaseURL { + t.Fatalf("expected builtin provider base url %q, got %q", testBaseURL, provider.BaseURL) + } + if cfg.CurrentModel != "gpt-5.4" { + t.Fatalf("expected current model to stay %q, got %q", "gpt-5.4", cfg.CurrentModel) + } + if len(provider.Models) != len(testDefaultProviderConfig().Models) { + t.Fatalf("expected builtin provider models, got %+v", provider.Models) } rewritten, err := os.ReadFile(loader.ConfigPath()) @@ -134,10 +143,13 @@ providers: t.Fatalf("read rewritten config: %v", err) } text := string(rewritten) - if !strings.Contains(text, "provider_overrides:") { - t.Fatalf("expected rewritten config to use provider_overrides, got:\n%s", text) + if strings.Contains(text, "provider_overrides:") { + t.Fatalf("expected rewritten config to drop provider overrides, got:\n%s", text) } if strings.Contains(text, "\nproviders:") || strings.HasPrefix(text, "providers:") { t.Fatalf("expected rewritten config to omit providers list, got:\n%s", text) } + if strings.Contains(text, "models:") || strings.Contains(text, "base_url:") || strings.Contains(text, "api_key_env:") { + t.Fatalf("expected rewritten config to omit provider metadata, got:\n%s", text) + } } diff --git a/internal/config/model.go b/internal/config/model.go index 0b2c4df6..6f10dccd 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -38,11 +38,12 @@ type Config struct { } type ProviderConfig struct { - Name string `yaml:"name"` - Driver string `yaml:"type"` - BaseURL string `yaml:"base_url"` - Model string `yaml:"model"` - APIKeyEnv string `yaml:"api_key_env"` + Name string `yaml:"name"` + Driver string `yaml:"type"` + BaseURL string `yaml:"base_url"` + Model string `yaml:"model"` + Models []string `yaml:"models,omitempty"` + APIKeyEnv string `yaml:"api_key_env"` } type ResolvedProviderConfig struct { @@ -50,14 +51,6 @@ type ResolvedProviderConfig struct { APIKey string `yaml:"-"` } -type ProviderOverride struct { - Name string `yaml:"name"` - Driver string `yaml:"type,omitempty"` - BaseURL string `yaml:"base_url,omitempty"` - Model string `yaml:"model,omitempty"` - APIKeyEnv string `yaml:"api_key_env,omitempty"` -} - type ToolsConfig struct { WebFetch WebFetchConfig `yaml:"webfetch,omitempty"` } @@ -89,7 +82,7 @@ func (c *Config) Clone() Config { } clone := *c - clone.Providers = append([]ProviderConfig(nil), c.Providers...) + clone.Providers = cloneProviders(c.Providers) clone.Tools = c.Tools.Clone() return clone } @@ -100,7 +93,7 @@ func (c *Config) ApplyDefaultsFrom(defaults Config) { } if len(c.Providers) == 0 { - c.Providers = append([]ProviderConfig(nil), defaults.Providers...) + c.Providers = cloneProviders(defaults.Providers) } else { c.Providers = applyProviderDefaults(c.Providers, defaults.Providers) } @@ -115,6 +108,9 @@ func (c *Config) ApplyDefaultsFrom(defaults Config) { c.CurrentModel = defaults.CurrentModel } } + if selected, err := c.SelectedProviderConfig(); err == nil && !containsModelID(selected.SupportedModels(), c.CurrentModel) { + c.CurrentModel = selected.Model + } if strings.TrimSpace(c.Workdir) == "" { c.Workdir = defaults.Workdir } @@ -172,6 +168,9 @@ func (c *Config) Validate() error { if strings.TrimSpace(selected.Model) == "" { return fmt.Errorf("config: selected provider %q has empty model", selected.Name) } + if !containsModelID(selected.SupportedModels(), c.CurrentModel) { + return fmt.Errorf("config: current_model %q is not supported by provider %q", c.CurrentModel, selected.Name) + } if err := c.Tools.Validate(); err != nil { return fmt.Errorf("config: tools: %w", err) } @@ -217,9 +216,30 @@ func (p ProviderConfig) Validate() error { if strings.TrimSpace(p.APIKeyEnv) == "" { return fmt.Errorf("provider %q api_key_env is empty", p.Name) } + if models := normalizeModelIDs(p.Models); len(p.Models) > 0 { + if len(models) == 0 { + return fmt.Errorf("provider %q models is empty", p.Name) + } + if !containsModelID(models, p.Model) { + return fmt.Errorf("provider %q default model %q is not in models", p.Name, strings.TrimSpace(p.Model)) + } + } return nil } +func (p ProviderConfig) SupportedModels() []string { + models := normalizeModelIDs(p.Models) + if len(models) > 0 { + return models + } + + model := strings.TrimSpace(p.Model) + if model == "" { + return nil + } + return []string{model} +} + func (p ProviderConfig) ResolveAPIKey() (string, error) { envName := strings.TrimSpace(p.APIKeyEnv) if envName == "" { @@ -272,6 +292,9 @@ func mergeProviderDefaults(provider ProviderConfig, defaults []ProviderConfig) P if strings.TrimSpace(provider.Model) == "" { provider.Model = base.Model } + if len(provider.Models) == 0 { + provider.Models = cloneModelIDs(base.Models) + } if strings.TrimSpace(provider.APIKeyEnv) == "" { provider.APIKeyEnv = base.APIKeyEnv } @@ -281,15 +304,12 @@ func mergeProviderDefaults(provider ProviderConfig, defaults []ProviderConfig) P func matchDefaultProvider(provider ProviderConfig, defaults []ProviderConfig) (ProviderConfig, bool) { name := strings.ToLower(strings.TrimSpace(provider.Name)) - kind := strings.ToLower(strings.TrimSpace(provider.Driver)) - - for _, candidate := range defaults { - if name != "" && strings.ToLower(candidate.Name) == name { - return candidate, true - } + if name == "" { + return ProviderConfig{}, false } + for _, candidate := range defaults { - if kind != "" && strings.ToLower(candidate.Driver) == kind { + if strings.ToLower(candidate.Name) == name { return candidate, true } } @@ -297,117 +317,6 @@ func matchDefaultProvider(provider ProviderConfig, defaults []ProviderConfig) (P return ProviderConfig{}, false } -func MergeProviderOverrides(defaults []ProviderConfig, overrides []ProviderOverride) []ProviderConfig { - merged := append([]ProviderConfig(nil), defaults...) - indexByName := make(map[string]int, len(merged)) - for i, provider := range merged { - indexByName[strings.ToLower(strings.TrimSpace(provider.Name))] = i - } - - for _, override := range overrides { - name := strings.TrimSpace(override.Name) - if name == "" { - continue - } - - key := strings.ToLower(name) - if idx, ok := indexByName[key]; ok { - merged[idx] = applyProviderOverride(merged[idx], override) - continue - } - - custom := applyProviderOverride(ProviderConfig{}, override) - merged = append(merged, custom) - indexByName[key] = len(merged) - 1 - } - - return merged -} - -func DeriveProviderOverrides(providers []ProviderConfig, defaults []ProviderConfig) []ProviderOverride { - if len(providers) == 0 { - return nil - } - - defaultByName := make(map[string]ProviderConfig, len(defaults)) - for _, provider := range defaults { - defaultByName[strings.ToLower(strings.TrimSpace(provider.Name))] = provider - } - - overrides := make([]ProviderOverride, 0, len(providers)) - for _, provider := range providers { - key := strings.ToLower(strings.TrimSpace(provider.Name)) - base, ok := defaultByName[key] - if !ok { - override := ProviderOverride{ - Name: strings.TrimSpace(provider.Name), - Driver: strings.TrimSpace(provider.Driver), - BaseURL: strings.TrimSpace(provider.BaseURL), - Model: strings.TrimSpace(provider.Model), - APIKeyEnv: strings.TrimSpace(provider.APIKeyEnv), - } - if override.Name == "" { - continue - } - overrides = append(overrides, override) - continue - } - - override := diffProviderOverride(base, provider) - if hasProviderOverrideChanges(override) { - overrides = append(overrides, override) - } - } - - return overrides -} - -func applyProviderOverride(base ProviderConfig, override ProviderOverride) ProviderConfig { - if name := strings.TrimSpace(override.Name); name != "" { - base.Name = name - } - if driver := strings.TrimSpace(override.Driver); driver != "" { - base.Driver = driver - } - if baseURL := strings.TrimSpace(override.BaseURL); baseURL != "" { - base.BaseURL = baseURL - } - if model := strings.TrimSpace(override.Model); model != "" { - base.Model = model - } - if apiKeyEnv := strings.TrimSpace(override.APIKeyEnv); apiKeyEnv != "" { - base.APIKeyEnv = apiKeyEnv - } - - return base -} - -func diffProviderOverride(base ProviderConfig, current ProviderConfig) ProviderOverride { - override := ProviderOverride{ - Name: strings.TrimSpace(current.Name), - } - if strings.TrimSpace(current.Driver) != strings.TrimSpace(base.Driver) { - override.Driver = strings.TrimSpace(current.Driver) - } - if strings.TrimSpace(current.BaseURL) != strings.TrimSpace(base.BaseURL) { - override.BaseURL = strings.TrimSpace(current.BaseURL) - } - if strings.TrimSpace(current.Model) != strings.TrimSpace(base.Model) { - override.Model = strings.TrimSpace(current.Model) - } - if strings.TrimSpace(current.APIKeyEnv) != strings.TrimSpace(base.APIKeyEnv) { - override.APIKeyEnv = strings.TrimSpace(current.APIKeyEnv) - } - return override -} - -func hasProviderOverrideChanges(override ProviderOverride) bool { - return strings.TrimSpace(override.Driver) != "" || - strings.TrimSpace(override.BaseURL) != "" || - strings.TrimSpace(override.Model) != "" || - strings.TrimSpace(override.APIKeyEnv) != "" -} - func normalizeWorkdir(workdir string) string { workdir = strings.TrimSpace(workdir) if workdir == "" { @@ -538,3 +447,74 @@ func normalizeContentType(value string) string { } return trimmed } + +func cloneProviders(providers []ProviderConfig) []ProviderConfig { + if len(providers) == 0 { + return nil + } + + cloned := make([]ProviderConfig, 0, len(providers)) + for _, provider := range providers { + next := provider + next.Models = cloneModelIDs(provider.Models) + cloned = append(cloned, next) + } + return cloned +} + +func cloneModelIDs(models []string) []string { + normalized := normalizeModelIDs(models) + if len(normalized) == 0 { + return nil + } + return append([]string(nil), normalized...) +} + +func normalizeModelIDs(models []string) []string { + if len(models) == 0 { + return nil + } + + normalized := make([]string, 0, len(models)) + seen := make(map[string]struct{}, len(models)) + for _, model := range models { + id := strings.TrimSpace(model) + if id == "" { + continue + } + key := strings.ToLower(id) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, id) + } + return normalized +} + +func equalModelIDs(left []string, right []string) bool { + left = normalizeModelIDs(left) + right = normalizeModelIDs(right) + if len(left) != len(right) { + return false + } + for i := range left { + if !strings.EqualFold(left[i], right[i]) { + return false + } + } + return true +} + +func containsModelID(models []string, model string) bool { + target := strings.ToLower(strings.TrimSpace(model)) + if target == "" { + return false + } + for _, candidate := range normalizeModelIDs(models) { + if strings.EqualFold(candidate, target) { + return true + } + } + return false +} diff --git a/internal/provider/builtin/builtin.go b/internal/provider/builtin/builtin.go index 0ddd9c04..00c87b3c 100644 --- a/internal/provider/builtin/builtin.go +++ b/internal/provider/builtin/builtin.go @@ -3,14 +3,14 @@ package builtin import ( "errors" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" - "github.com/dust/neo-code/internal/provider/openai" + "neo-code/internal/config" + "neo-code/internal/provider" + "neo-code/internal/provider/openai" ) func DefaultConfig() *config.Config { cfg := config.Default() - defaultProvider := openai.DefaultConfig() + defaultProvider := openai.BuiltinConfig() cfg.Providers = []config.ProviderConfig{defaultProvider} cfg.SelectedProvider = defaultProvider.Name cfg.CurrentModel = defaultProvider.Model @@ -29,5 +29,5 @@ func Register(registry *provider.Registry) error { if registry == nil { return errors.New("builtin provider registry is nil") } - return registry.Register(openai.DriverDefinition()) + return registry.Register(openai.Driver()) } diff --git a/internal/provider/openai/defaults.go b/internal/provider/openai/defaults.go deleted file mode 100644 index 2a13f440..00000000 --- a/internal/provider/openai/defaults.go +++ /dev/null @@ -1,20 +0,0 @@ -package openai - -import "github.com/dust/neo-code/internal/config" - -const ( - DriverName = "openai" - DefaultBaseURL = "https://api.openai.com/v1" - DefaultModel = "gpt-4.1" - DefaultAPIKeyEnv = "OPENAI_API_KEY" -) - -func DefaultConfig() config.ProviderConfig { - return config.ProviderConfig{ - Name: DriverName, - Driver: DriverName, - BaseURL: DefaultBaseURL, - Model: DefaultModel, - APIKeyEnv: DefaultAPIKeyEnv, - } -} diff --git a/internal/provider/openai/driver.go b/internal/provider/openai/driver.go deleted file mode 100644 index 1c63d803..00000000 --- a/internal/provider/openai/driver.go +++ /dev/null @@ -1,60 +0,0 @@ -package openai - -import ( - "context" - "strings" - - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" -) - -func DriverDefinition() provider.DriverDefinition { - return provider.DriverDefinition{ - Name: DriverName, - Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { - return New(cfg) - }, - Catalog: CatalogItem, - } -} - -func CatalogItem(cfg config.ProviderConfig) (provider.ProviderCatalogItem, error) { - models := []provider.ModelDescriptor{ - { - ID: DefaultModel, - Name: DefaultModel, - Description: "Stable OpenAI-compatible default model", - }, - { - ID: "gpt-4o", - Name: "gpt-4o", - Description: "Fast general-purpose OpenAI-compatible model", - }, - { - ID: "gpt-5.3-codex", - Name: "gpt-5.3-codex", - Description: "Code-focused OpenAI-compatible model", - }, - { - ID: "gpt-5.4", - Name: "gpt-5.4", - Description: "Frontier reasoning and coding model", - }, - } - - if configured := strings.TrimSpace(cfg.Model); configured != "" { - models = append(models, provider.ModelDescriptor{ - ID: configured, - Name: configured, - Description: "Configured default model", - }) - } - - return provider.ProviderCatalogItem{ - ID: cfg.Name, - Name: cfg.Name, - Description: "OpenAI-compatible chat completions provider.", - APIKeyEnv: cfg.APIKeyEnv, - Models: models, - }, nil -} diff --git a/internal/provider/openai/openai.go b/internal/provider/openai/openai.go index d309e67a..b4028d66 100644 --- a/internal/provider/openai/openai.go +++ b/internal/provider/openai/openai.go @@ -13,8 +13,8 @@ import ( "strings" "time" - "github.com/dust/neo-code/internal/config" - domain "github.com/dust/neo-code/internal/provider" + "neo-code/internal/config" + domain "neo-code/internal/provider" ) type Provider struct { @@ -22,6 +22,41 @@ type Provider struct { client *http.Client } +const ( + Name = "openai" + DriverName = "openai" + DefaultBaseURL = "https://api.openai.com/v1" + DefaultModel = "gpt-4.1" + DefaultAPIKeyEnv = "OPENAI_API_KEY" +) + +var builtinModels = []string{ + DefaultModel, + "gpt-4o", + "gpt-5.4", + "gpt-5.3-codex", +} + +func BuiltinConfig() config.ProviderConfig { + return config.ProviderConfig{ + Name: Name, + Driver: DriverName, + BaseURL: DefaultBaseURL, + Model: DefaultModel, + Models: append([]string(nil), builtinModels...), + APIKeyEnv: DefaultAPIKeyEnv, + } +} + +func Driver() domain.DriverDefinition { + return domain.DriverDefinition{ + Name: Name, + Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (domain.Provider, error) { + return New(cfg) + }, + } +} + func New(cfg config.ResolvedProviderConfig) (*Provider, error) { if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("openai provider: %w", err) diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index 4b844e13..e8fa383b 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" - "github.com/dust/neo-code/internal/config" - domain "github.com/dust/neo-code/internal/provider" + "neo-code/internal/config" + domain "neo-code/internal/provider" ) func resolvedConfig(baseURL string, model string) config.ResolvedProviderConfig { @@ -91,25 +91,21 @@ func TestMergeToolCallDeltas(t *testing.T) { } } -func TestCatalogItemIncludesConfiguredModel(t *testing.T) { +func TestBuiltinConfigIncludesProviderModels(t *testing.T) { t.Parallel() - item, err := CatalogItem(config.ProviderConfig{ - Name: DriverName, - Driver: DriverName, - BaseURL: DefaultBaseURL, - Model: "gpt-5.4", - APIKeyEnv: DefaultAPIKeyEnv, - }) - if err != nil { - t.Fatalf("CatalogItem() error = %v", err) + cfg := BuiltinConfig() + if cfg.Name != DriverName { + t.Fatalf("expected provider name %q, got %q", DriverName, cfg.Name) } - - if item.Name != DriverName { - t.Fatalf("expected provider name %q, got %q", DriverName, item.Name) + if cfg.BaseURL != DefaultBaseURL { + t.Fatalf("expected base URL %q, got %q", DefaultBaseURL, cfg.BaseURL) } - if len(item.Models) == 0 { - t.Fatalf("expected at least one model option in catalog item") + if len(cfg.Models) < 3 { + t.Fatalf("expected builtin models to be predeclared, got %+v", cfg.Models) + } + if !containsString(cfg.Models, "gpt-5.4") { + t.Fatalf("expected builtin models to include gpt-5.4, got %+v", cfg.Models) } } @@ -500,3 +496,12 @@ type readCloser struct { func (r *readCloser) Close() error { return nil } + +func containsString(items []string, target string) bool { + for _, item := range items { + if item == target { + return true + } + } + return false +} diff --git a/internal/provider/registry.go b/internal/provider/registry.go index 1f8d7ed9..c017b04c 100644 --- a/internal/provider/registry.go +++ b/internal/provider/registry.go @@ -6,28 +6,34 @@ import ( "fmt" "strings" - "github.com/dust/neo-code/internal/config" + "neo-code/internal/config" ) type Builder func(ctx context.Context, cfg config.ResolvedProviderConfig) (Provider, error) -type CatalogBuilder func(cfg config.ProviderConfig) (ProviderCatalogItem, error) - type DriverDefinition struct { - Name string - Build Builder - Catalog CatalogBuilder + Name string + Build Builder } type Registry struct { drivers map[string]DriverDefinition } +var errDriverAlreadyRegistered = errors.New("provider: driver already registered") + func NewRegistry() *Registry { return &Registry{drivers: map[string]DriverDefinition{}} } func (r *Registry) Register(driver DriverDefinition) error { + if r == nil { + return errors.New("provider: registry is nil") + } + + r.ensureDrivers() + + driver.Name = strings.TrimSpace(driver.Name) driverType := normalizeKey(driver.Name) if driverType == "" { return errors.New("provider: driver name is empty") @@ -35,8 +41,8 @@ func (r *Registry) Register(driver DriverDefinition) error { if driver.Build == nil { return fmt.Errorf("provider: driver %q build func is nil", driver.Name) } - if driver.Catalog == nil { - return fmt.Errorf("provider: driver %q catalog func is nil", driver.Name) + if _, exists := r.drivers[driverType]; exists { + return fmt.Errorf("%w: %s", errDriverAlreadyRegistered, driver.Name) } r.drivers[driverType] = driver return nil @@ -50,19 +56,6 @@ func (r *Registry) Build(ctx context.Context, cfg config.ResolvedProviderConfig) return driver.Build(ctx, cfg) } -func (r *Registry) Catalog(cfg config.ProviderConfig) (ProviderCatalogItem, error) { - driver, err := r.driver(cfg.Driver) - if err != nil { - return ProviderCatalogItem{}, err - } - - item, err := driver.Catalog(cfg) - if err != nil { - return ProviderCatalogItem{}, err - } - return normalizeCatalogItem(item, cfg.Name, cfg.APIKeyEnv), nil -} - func (r *Registry) Supports(driverType string) bool { _, err := r.driver(driverType) return err == nil @@ -82,3 +75,9 @@ func (r *Registry) driver(driverType string) (DriverDefinition, error) { func normalizeKey(value string) string { return strings.ToLower(strings.TrimSpace(value)) } + +func (r *Registry) ensureDrivers() { + if r.drivers == nil { + r.drivers = map[string]DriverDefinition{} + } +} diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index edfc083e..ced095da 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -3,13 +3,12 @@ package provider_test import ( "context" "errors" - "strings" "testing" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" - "github.com/dust/neo-code/internal/provider/builtin" - "github.com/dust/neo-code/internal/provider/openai" + "neo-code/internal/config" + "neo-code/internal/provider" + "neo-code/internal/provider/builtin" + "neo-code/internal/provider/openai" ) type stubProvider struct{} @@ -18,27 +17,12 @@ func (stubProvider) Chat(ctx context.Context, req provider.ChatRequest, events c return provider.ChatResponse{}, nil } -func stubDriver(driverType string, models ...string) provider.DriverDefinition { +func stubDriver(driverType string) provider.DriverDefinition { return provider.DriverDefinition{ Name: driverType, Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { return stubProvider{}, nil }, - Catalog: func(cfg config.ProviderConfig) (provider.ProviderCatalogItem, error) { - items := make([]provider.ModelDescriptor, 0, len(models)) - for _, model := range models { - items = append(items, provider.ModelDescriptor{ - ID: model, - Name: model, - }) - } - return provider.ProviderCatalogItem{ - ID: cfg.Name, - Name: strings.ToUpper(driverType), - APIKeyEnv: cfg.APIKeyEnv, - Models: items, - }, nil - }, } } @@ -46,7 +30,7 @@ func newTestRegistry(t *testing.T) *provider.Registry { t.Helper() registry := provider.NewRegistry() - if err := registry.Register(openai.DriverDefinition()); err != nil { + if err := registry.Register(openai.Driver()); err != nil { t.Fatalf("register openai driver: %v", err) } return registry @@ -55,7 +39,13 @@ func newTestRegistry(t *testing.T) *provider.Registry { func newTestManager(t *testing.T) *config.Manager { t.Helper() - manager := config.NewManager(config.NewLoader(t.TempDir(), builtin.DefaultConfig())) + return newTestManagerWithDefaults(t, builtin.DefaultConfig()) +} + +func newTestManagerWithDefaults(t *testing.T, defaults *config.Config) *config.Manager { + t.Helper() + + manager := config.NewManager(config.NewLoader(t.TempDir(), defaults)) if _, err := manager.Load(context.Background()); err != nil { t.Fatalf("load config: %v", err) } @@ -84,49 +74,31 @@ func TestRegistryBuildsRegisteredDriverCaseInsensitively(t *testing.T) { } } -func TestRegistryCatalogNormalizesConfiguredProvider(t *testing.T) { +func TestRegistryUnknownDriverReturnsTypedError(t *testing.T) { t.Parallel() - registry := newTestRegistry(t) - item, err := registry.Catalog(config.ProviderConfig{ - Name: "openai-main", - Driver: openai.DriverName, - BaseURL: openai.DefaultBaseURL, - Model: "custom-model", - APIKeyEnv: openai.DefaultAPIKeyEnv, + registry := provider.NewRegistry() + _, err := registry.Build(context.Background(), config.ResolvedProviderConfig{ + ProviderConfig: config.ProviderConfig{Driver: "missing"}, }) - if err != nil { - t.Fatalf("Catalog() error = %v", err) - } - if item.ID != "openai-main" { - t.Fatalf("expected catalog id %q, got %q", "openai-main", item.ID) - } - if item.APIKeyEnv != openai.DefaultAPIKeyEnv { - t.Fatalf("expected env key %q, got %q", openai.DefaultAPIKeyEnv, item.APIKeyEnv) - } - - foundCustom := 0 - for _, model := range item.Models { - if model.ID == "custom-model" { - foundCustom++ - } - } - if foundCustom != 1 { - t.Fatalf("expected configured model to appear once, got %d", foundCustom) + if !errors.Is(err, provider.ErrDriverNotFound) { + t.Fatalf("expected ErrDriverNotFound, got %v", err) } } -func TestRegistryUnknownDriverReturnsTypedError(t *testing.T) { +func TestRegistryRejectsDuplicateDriverRegistration(t *testing.T) { t.Parallel() registry := provider.NewRegistry() - _, err := registry.Catalog(config.ProviderConfig{Driver: "missing"}) - if !errors.Is(err, provider.ErrDriverNotFound) { - t.Fatalf("expected ErrDriverNotFound, got %v", err) + if err := registry.Register(stubDriver("custom")); err != nil { + t.Fatalf("initial Register() error = %v", err) + } + if err := registry.Register(stubDriver("CUSTOM")); err == nil { + t.Fatalf("expected duplicate driver registration to fail") } } -func TestServiceListProvidersFiltersUnsupportedDrivers(t *testing.T) { +func TestServiceListProvidersUsesConfiguredMetadata(t *testing.T) { t.Parallel() manager := newTestManager(t) @@ -136,6 +108,7 @@ func TestServiceListProvidersFiltersUnsupportedDrivers(t *testing.T) { Driver: "custom", BaseURL: "https://example.com", Model: "custom-model", + Models: []string{"custom-model"}, APIKeyEnv: "CUSTOM_API_KEY", }) return nil @@ -151,30 +124,40 @@ func TestServiceListProvidersFiltersUnsupportedDrivers(t *testing.T) { if len(items) != 1 { t.Fatalf("expected only supported providers, got %d", len(items)) } - if items[0].ID != openai.DriverName { - t.Fatalf("expected supported provider %q, got %q", openai.DriverName, items[0].ID) + + item := items[0] + if item.ID != openai.DriverName { + t.Fatalf("expected supported provider %q, got %q", openai.DriverName, item.ID) + } + if item.Description != "" { + t.Fatalf("expected provider description to stay empty for hidden metadata, got %q", item.Description) + } + if len(item.Models) != len(openai.BuiltinConfig().Models) { + t.Fatalf("expected provider models to come from config, got %+v", item.Models) } } func TestServiceSelectProviderAndSetCurrentModel(t *testing.T) { - manager := newTestManager(t) + defaults := builtin.DefaultConfig() + defaults.Providers = append(defaults.Providers, config.ProviderConfig{ + Name: "custom-main", + Driver: "custom", + BaseURL: "https://example.com", + Model: "custom-model", + Models: []string{"custom-model", "custom-alt"}, + APIKeyEnv: "CUSTOM_API_KEY", + }) + manager := newTestManagerWithDefaults(t, defaults) registry := newTestRegistry(t) - if err := registry.Register(stubDriver("custom", "custom-model")); err != nil { + if err := registry.Register(stubDriver("custom")); err != nil { t.Fatalf("register stub driver: %v", err) } if err := manager.Update(context.Background(), func(cfg *config.Config) error { cfg.CurrentModel = "gpt-5.4" - cfg.Providers = append(cfg.Providers, config.ProviderConfig{ - Name: "custom-main", - Driver: "custom", - BaseURL: "https://example.com", - Model: "custom-model", - APIKeyEnv: "CUSTOM_API_KEY", - }) return nil }); err != nil { - t.Fatalf("append custom provider: %v", err) + t.Fatalf("seed current model: %v", err) } service := provider.NewService(manager, registry) @@ -191,20 +174,12 @@ func TestServiceSelectProviderAndSetCurrentModel(t *testing.T) { t.Fatalf("expected ErrModelNotFound, got %v", err) } - selection, err = service.SelectProvider(context.Background(), openai.DriverName) - if err != nil { - t.Fatalf("SelectProvider(openai) error = %v", err) - } - if selection.ProviderID != openai.DriverName { - t.Fatalf("expected selected provider %q, got %+v", openai.DriverName, selection) - } - - selection, err = service.SetCurrentModel(context.Background(), "gpt-4o") + selection, err = service.SetCurrentModel(context.Background(), "custom-alt") if err != nil { t.Fatalf("SetCurrentModel() error = %v", err) } - if selection.ModelID != "gpt-4o" { - t.Fatalf("expected selected model %q, got %+v", "gpt-4o", selection) + if selection.ModelID != "custom-alt" { + t.Fatalf("expected selected model %q, got %+v", "custom-alt", selection) } cfg := manager.Get() @@ -212,7 +187,85 @@ func TestServiceSelectProviderAndSetCurrentModel(t *testing.T) { if err != nil { t.Fatalf("SelectedProviderConfig() error = %v", err) } - if selected.Model != "gpt-4o" || cfg.CurrentModel != "gpt-4o" { - t.Fatalf("expected config model to be updated, got provider=%q current=%q", selected.Model, cfg.CurrentModel) + if selected.Name != "custom-main" { + t.Fatalf("expected selected provider %q, got %+v", "custom-main", selected) + } + if selected.Model != "custom-model" || cfg.CurrentModel != "custom-alt" { + t.Fatalf("expected provider default and current model to diverge safely, got provider=%q current=%q", selected.Model, cfg.CurrentModel) + } + + reloaded, err := manager.Reload(context.Background()) + if err != nil { + t.Fatalf("Reload() error = %v", err) + } + selected, err = reloaded.SelectedProviderConfig() + if err != nil { + t.Fatalf("SelectedProviderConfig() after reload error = %v", err) + } + if selected.Model != "custom-model" || reloaded.CurrentModel != "custom-alt" { + t.Fatalf("expected current model persistence without overriding provider default, got provider=%q current=%q", selected.Model, reloaded.CurrentModel) + } +} + +func TestServiceModelOperationsUseProviderConfigEvenWithoutDriver(t *testing.T) { + t.Parallel() + + manager := newTestManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Providers = append(cfg.Providers, config.ProviderConfig{ + Name: "broken-provider", + Driver: "missing-driver", + BaseURL: "https://example.com", + Model: "broken-model", + Models: []string{"broken-model", "broken-alt"}, + APIKeyEnv: "BROKEN_API_KEY", + }) + cfg.SelectedProvider = "broken-provider" + cfg.CurrentModel = "broken-model" + return nil + }); err != nil { + t.Fatalf("append broken provider: %v", err) + } + + service := provider.NewService(manager, newTestRegistry(t)) + + models, err := service.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if len(models) != 2 || models[1].ID != "broken-alt" { + t.Fatalf("expected models from provider config, got %+v", models) + } + + selection, err := service.SetCurrentModel(context.Background(), "broken-alt") + if err != nil { + t.Fatalf("SetCurrentModel() error = %v", err) + } + if selection.ModelID != "broken-alt" { + t.Fatalf("expected current model to update from provider config, got %+v", selection) + } +} + +func TestServiceSelectProviderRejectsUnsupportedDriver(t *testing.T) { + t.Parallel() + + manager := newTestManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Providers = append(cfg.Providers, config.ProviderConfig{ + Name: "broken-provider", + Driver: "missing-driver", + BaseURL: "https://example.com", + Model: "broken-model", + Models: []string{"broken-model"}, + APIKeyEnv: "BROKEN_API_KEY", + }) + return nil + }); err != nil { + t.Fatalf("append broken provider: %v", err) + } + + service := provider.NewService(manager, newTestRegistry(t)) + if _, err := service.SelectProvider(context.Background(), "broken-provider"); !errors.Is(err, provider.ErrDriverNotFound) { + t.Fatalf("expected SelectProvider() to preserve ErrDriverNotFound, got %v", err) } } diff --git a/internal/provider/service.go b/internal/provider/service.go index 646f63db..d0ae3ae6 100644 --- a/internal/provider/service.go +++ b/internal/provider/service.go @@ -5,7 +5,7 @@ import ( "errors" "strings" - "github.com/dust/neo-code/internal/config" + "neo-code/internal/config" ) type Service struct { @@ -13,6 +13,11 @@ type Service struct { registry *Registry } +var ( + errServiceManagerNil = errors.New("provider: config manager is nil") + errServiceRegistryNil = errors.New("provider: registry is nil") +) + func NewService(manager *config.Manager, registry *Registry) *Service { return &Service{ manager: manager, @@ -21,6 +26,9 @@ func NewService(manager *config.Manager, registry *Registry) *Service { } func (s *Service) ListProviders(ctx context.Context) ([]ProviderCatalogItem, error) { + if err := s.validate(); err != nil { + return nil, err + } if err := ctx.Err(); err != nil { return nil, err } @@ -28,44 +36,37 @@ func (s *Service) ListProviders(ctx context.Context) ([]ProviderCatalogItem, err cfg := s.manager.Get() items := make([]ProviderCatalogItem, 0, len(cfg.Providers)) for _, providerCfg := range cfg.Providers { - item, err := s.registry.Catalog(providerCfg) - if errors.Is(err, ErrDriverNotFound) { - continue - } - if err != nil { + if err := ctx.Err(); err != nil { return nil, err } - items = append(items, item) + if !s.registry.Supports(providerCfg.Driver) { + continue + } + items = append(items, catalogItemFromConfig(providerCfg)) } return items, nil } -func (s *Service) SelectProvider(ctx context.Context, providerID string) (ProviderSelection, error) { +func (s *Service) SelectProvider(ctx context.Context, providerName string) (ProviderSelection, error) { + if err := s.validate(); err != nil { + return ProviderSelection{}, err + } + var selection ProviderSelection err := s.manager.Update(ctx, func(cfg *config.Config) error { - item, providerCfg, err := s.catalogForProvider(*cfg, providerID) + providerCfg, err := cfg.ProviderByName(providerName) if err != nil { - return err - } - - cfg.SelectedProvider = providerCfg.Name - if !containsModel(item.Models, cfg.CurrentModel) { - cfg.CurrentModel = providerCfg.Model + return ErrProviderNotFound } - - for i := range cfg.Providers { - if strings.EqualFold(strings.TrimSpace(cfg.Providers[i].Name), strings.TrimSpace(providerCfg.Name)) { - cfg.Providers[i].Model = cfg.CurrentModel - break - } + if !s.registry.Supports(providerCfg.Driver) { + return ErrDriverNotFound } - selection = ProviderSelection{ - ProviderID: cfg.SelectedProvider, - ModelID: cfg.CurrentModel, - } + cfg.SelectedProvider = providerCfg.Name + cfg.CurrentModel = selectModel(cfg.CurrentModel, providerCfg.SupportedModels(), providerCfg.Model) + selection = selectionFromConfig(*cfg) return nil }) if err != nil { @@ -76,6 +77,9 @@ func (s *Service) SelectProvider(ctx context.Context, providerID string) (Provid } func (s *Service) ListModels(ctx context.Context) ([]ModelDescriptor, error) { + if err := s.validate(); err != nil { + return nil, err + } if err := ctx.Err(); err != nil { return nil, err } @@ -86,18 +90,14 @@ func (s *Service) ListModels(ctx context.Context) ([]ModelDescriptor, error) { return nil, err } - item, err := s.registry.Catalog(selected) - if errors.Is(err, ErrDriverNotFound) { - return nil, ErrProviderNotFound - } - if err != nil { - return nil, err - } - - return append([]ModelDescriptor(nil), item.Models...), nil + return modelDescriptors(selected.SupportedModels()), nil } func (s *Service) SetCurrentModel(ctx context.Context, modelID string) (ProviderSelection, error) { + if err := s.validate(); err != nil { + return ProviderSelection{}, err + } + modelID = strings.TrimSpace(modelID) if modelID == "" { return ProviderSelection{}, ErrModelNotFound @@ -110,29 +110,12 @@ func (s *Service) SetCurrentModel(ctx context.Context, modelID string) (Provider return err } - item, err := s.registry.Catalog(selected) - if errors.Is(err, ErrDriverNotFound) { - return ErrProviderNotFound - } - if err != nil { - return err - } - if !containsModel(item.Models, modelID) { + if !containsModel(selected.SupportedModels(), modelID) { return ErrModelNotFound } cfg.CurrentModel = modelID - for i := range cfg.Providers { - if strings.EqualFold(strings.TrimSpace(cfg.Providers[i].Name), strings.TrimSpace(selected.Name)) { - cfg.Providers[i].Model = modelID - break - } - } - - selection = ProviderSelection{ - ProviderID: cfg.SelectedProvider, - ModelID: cfg.CurrentModel, - } + selection = selectionFromConfig(*cfg) return nil }) if err != nil { @@ -142,29 +125,63 @@ func (s *Service) SetCurrentModel(ctx context.Context, modelID string) (Provider return selection, nil } -func (s *Service) catalogForProvider(cfg config.Config, providerID string) (ProviderCatalogItem, config.ProviderConfig, error) { - providerCfg, err := cfg.ProviderByName(providerID) - if err != nil { - return ProviderCatalogItem{}, config.ProviderConfig{}, ErrProviderNotFound +func containsModel(models []string, modelID string) bool { + target := normalizeKey(modelID) + for _, model := range models { + if normalizeKey(model) == target { + return true + } } + return false +} - item, err := s.registry.Catalog(providerCfg) - if errors.Is(err, ErrDriverNotFound) { - return ProviderCatalogItem{}, config.ProviderConfig{}, ErrProviderNotFound +func selectionFromConfig(cfg config.Config) ProviderSelection { + return ProviderSelection{ + ProviderID: cfg.SelectedProvider, + ModelID: cfg.CurrentModel, } - if err != nil { - return ProviderCatalogItem{}, config.ProviderConfig{}, err +} + +func selectModel(currentModel string, models []string, fallback string) string { + if containsModel(models, currentModel) { + return strings.TrimSpace(currentModel) } + return strings.TrimSpace(fallback) +} - return item, providerCfg, nil +func catalogItemFromConfig(cfg config.ProviderConfig) ProviderCatalogItem { + return ProviderCatalogItem{ + ID: strings.TrimSpace(cfg.Name), + Name: strings.TrimSpace(cfg.Name), + Models: modelDescriptors(cfg.SupportedModels()), + } } -func containsModel(models []ModelDescriptor, modelID string) bool { - target := strings.ToLower(strings.TrimSpace(modelID)) - for _, model := range models { - if strings.ToLower(strings.TrimSpace(model.ID)) == target { - return true +func modelDescriptors(models []string) []ModelDescriptor { + if len(models) == 0 { + return nil + } + + descriptors := make([]ModelDescriptor, 0, len(models)) + for _, modelID := range models { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + continue } + descriptors = append(descriptors, ModelDescriptor{ + ID: modelID, + Name: modelID, + }) } - return false + return descriptors +} + +func (s *Service) validate() error { + if s == nil || s.manager == nil { + return errServiceManagerNil + } + if s.registry == nil { + return errServiceRegistryNil + } + return nil } diff --git a/internal/provider/types.go b/internal/provider/types.go index 46fb38f4..d03ff5d0 100644 --- a/internal/provider/types.go +++ b/internal/provider/types.go @@ -2,7 +2,6 @@ package provider import ( "errors" - "strings" ) type Message struct { @@ -60,7 +59,6 @@ type ProviderCatalogItem struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` - APIKeyEnv string `json:"api_key_env,omitempty"` Models []ModelDescriptor `json:"models,omitempty"` } @@ -68,52 +66,3 @@ type ProviderSelection struct { ProviderID string `json:"provider_id"` ModelID string `json:"model_id"` } - -func normalizeCatalogItem(item ProviderCatalogItem, fallbackID string, fallbackEnv string) ProviderCatalogItem { - if strings.TrimSpace(item.ID) == "" { - item.ID = strings.TrimSpace(fallbackID) - } - if strings.TrimSpace(item.Name) == "" { - item.Name = item.ID - } - if strings.TrimSpace(item.APIKeyEnv) == "" { - item.APIKeyEnv = strings.TrimSpace(fallbackEnv) - } - - item.Models = normalizeModels(item.Models) - return item -} - -func normalizeModels(models []ModelDescriptor) []ModelDescriptor { - if len(models) == 0 { - return nil - } - - deduped := make([]ModelDescriptor, 0, len(models)) - seen := make(map[string]struct{}, len(models)) - for _, model := range models { - id := strings.TrimSpace(model.ID) - name := strings.TrimSpace(model.Name) - if id == "" { - id = name - } - if id == "" { - continue - } - if name == "" { - name = id - } - key := strings.ToLower(id) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - deduped = append(deduped, ModelDescriptor{ - ID: id, - Name: name, - Description: strings.TrimSpace(model.Description), - }) - } - - return deduped -} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index e241f482..7aef9b3f 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -6,9 +6,9 @@ import ( "strings" "time" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/config" + "neo-code/internal/provider" + "neo-code/internal/tools" ) const maxContextTurns = 10 @@ -94,7 +94,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { resolvedProvider, err := s.configManager.ResolvedSelectedProvider() if err != nil { - s.emit(EventError, session.ID, err.Error()) + s.emit(EventError, input.RunID, session.ID, err.Error()) return err } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index f7e8d9b5..7f34f781 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -8,11 +8,11 @@ import ( "strings" "testing" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" - "github.com/dust/neo-code/internal/provider/builtin" - "github.com/dust/neo-code/internal/provider/openai" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/config" + "neo-code/internal/provider" + "neo-code/internal/provider/builtin" + "neo-code/internal/provider/openai" + "neo-code/internal/tools" ) type memoryStore struct { @@ -86,6 +86,7 @@ func (s *memoryStore) ListSummaries(ctx context.Context) ([]SessionSummary, erro } type scriptedProvider struct { + name string responses []provider.ChatResponse streams [][]provider.StreamEvent requests []provider.ChatRequest diff --git a/internal/runtime/session.go b/internal/runtime/session.go index e77177b4..0e8b41a3 100644 --- a/internal/runtime/session.go +++ b/internal/runtime/session.go @@ -12,7 +12,7 @@ import ( "sync" "time" - "github.com/dust/neo-code/internal/provider" + "neo-code/internal/provider" ) const sessionsDirName = "sessions" diff --git a/internal/runtime/session_test.go b/internal/runtime/session_test.go index 41574f93..1981719c 100644 --- a/internal/runtime/session_test.go +++ b/internal/runtime/session_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/dust/neo-code/internal/provider" + "neo-code/internal/provider" ) func TestJSONSessionStoreSaveLoadAndListSummaries(t *testing.T) { diff --git a/internal/tools/bash/tool.go b/internal/tools/bash/tool.go index 76a12fd0..f04ea611 100644 --- a/internal/tools/bash/tool.go +++ b/internal/tools/bash/tool.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) type Tool struct { diff --git a/internal/tools/bash/tool_test.go b/internal/tools/bash/tool_test.go index 2002d3dc..90fd6297 100644 --- a/internal/tools/bash/tool_test.go +++ b/internal/tools/bash/tool_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) func TestToolExecute(t *testing.T) { diff --git a/internal/tools/filesystem/edit.go b/internal/tools/filesystem/edit.go index d16e89a5..7d831ada 100644 --- a/internal/tools/filesystem/edit.go +++ b/internal/tools/filesystem/edit.go @@ -8,7 +8,7 @@ import ( "os" "strings" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) type EditTool struct { diff --git a/internal/tools/filesystem/edit_test.go b/internal/tools/filesystem/edit_test.go index 55101312..dac4507d 100644 --- a/internal/tools/filesystem/edit_test.go +++ b/internal/tools/filesystem/edit_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) func TestEditToolExecute(t *testing.T) { diff --git a/internal/tools/filesystem/glob.go b/internal/tools/filesystem/glob.go index 5d5fec57..41bac66a 100644 --- a/internal/tools/filesystem/glob.go +++ b/internal/tools/filesystem/glob.go @@ -10,7 +10,7 @@ import ( "sort" "strings" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) type GlobTool struct { diff --git a/internal/tools/filesystem/glob_test.go b/internal/tools/filesystem/glob_test.go index 41dd7826..c4615ea9 100644 --- a/internal/tools/filesystem/glob_test.go +++ b/internal/tools/filesystem/glob_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) func TestGlobToolExecute(t *testing.T) { diff --git a/internal/tools/filesystem/grep.go b/internal/tools/filesystem/grep.go index 13e4754e..a152dfe0 100644 --- a/internal/tools/filesystem/grep.go +++ b/internal/tools/filesystem/grep.go @@ -10,7 +10,7 @@ import ( "regexp" "strings" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) const defaultGrepResultLimit = 200 diff --git a/internal/tools/filesystem/grep_test.go b/internal/tools/filesystem/grep_test.go index 0bec1178..45763037 100644 --- a/internal/tools/filesystem/grep_test.go +++ b/internal/tools/filesystem/grep_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) func TestGrepToolExecute(t *testing.T) { diff --git a/internal/tools/filesystem/read_file.go b/internal/tools/filesystem/read_file.go index 401f310c..f7902282 100644 --- a/internal/tools/filesystem/read_file.go +++ b/internal/tools/filesystem/read_file.go @@ -8,7 +8,7 @@ import ( "path/filepath" "strings" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) const emitChunkSize = 4 * 1024 diff --git a/internal/tools/filesystem/read_file_test.go b/internal/tools/filesystem/read_file_test.go index 97c7dda0..b325ebbf 100644 --- a/internal/tools/filesystem/read_file_test.go +++ b/internal/tools/filesystem/read_file_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) func TestReadFileToolExecute(t *testing.T) { diff --git a/internal/tools/filesystem/write_file.go b/internal/tools/filesystem/write_file.go index d07d0207..f5220315 100644 --- a/internal/tools/filesystem/write_file.go +++ b/internal/tools/filesystem/write_file.go @@ -8,7 +8,7 @@ import ( "path/filepath" "strings" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) type WriteFileTool struct { diff --git a/internal/tools/filesystem/write_file_test.go b/internal/tools/filesystem/write_file_test.go index 05f27c8b..775062a4 100644 --- a/internal/tools/filesystem/write_file_test.go +++ b/internal/tools/filesystem/write_file_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) func TestWriteFileToolMetadataAndExecute(t *testing.T) { diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 46f8ec9b..ed1906ce 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -6,7 +6,7 @@ import ( "sort" "strings" - "github.com/dust/neo-code/internal/provider" + "neo-code/internal/provider" ) type Registry struct { diff --git a/internal/tools/types.go b/internal/tools/types.go index 11b07e70..b09726f2 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -3,7 +3,7 @@ package tools import ( "context" - "github.com/dust/neo-code/internal/provider" + "neo-code/internal/provider" ) type Tool interface { diff --git a/internal/tools/webfetch/tool.go b/internal/tools/webfetch/tool.go index c87b97f2..dfc889bf 100644 --- a/internal/tools/webfetch/tool.go +++ b/internal/tools/webfetch/tool.go @@ -11,8 +11,8 @@ import ( "strings" "time" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/config" + "neo-code/internal/tools" ) const ( diff --git a/internal/tools/webfetch/tool_test.go b/internal/tools/webfetch/tool_test.go index a655d592..f4c1087b 100644 --- a/internal/tools/webfetch/tool_test.go +++ b/internal/tools/webfetch/tool_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/tools" "golang.org/x/net/html" + "neo-code/internal/config" + "neo-code/internal/tools" ) func TestToolExecute(t *testing.T) { diff --git a/internal/tui/app.go b/internal/tui/app.go index 921b1ce7..fbfcd4bd 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -11,9 +11,9 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" - agentruntime "github.com/dust/neo-code/internal/runtime" + "neo-code/internal/config" + "neo-code/internal/provider" + agentruntime "neo-code/internal/runtime" ) type App struct { diff --git a/internal/tui/commands.go b/internal/tui/commands.go index bd517830..c0f090aa 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -8,7 +8,7 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" - "github.com/dust/neo-code/internal/provider" + "neo-code/internal/provider" ) const ( @@ -90,17 +90,10 @@ func newSelectionPicker(items []list.Item) list.Model { func newProviderPicker(items []provider.ProviderCatalogItem) list.Model { listItems := make([]list.Item, 0, len(items)) for _, item := range items { - description := item.Description - if item.APIKeyEnv != "" { - if description != "" { - description += " | " - } - description += "API key env: " + item.APIKeyEnv - } listItems = append(listItems, providerItem{ id: item.ID, name: item.Name, - description: description, + description: item.Description, }) } return newSelectionPicker(listItems) @@ -210,9 +203,9 @@ func (a App) matchingSlashCommands(input string) []commandSuggestion { return out } -func runProviderSelection(providerSvc ProviderController, providerID string) tea.Cmd { +func runProviderSelection(providerSvc ProviderController, providerName string) tea.Cmd { return func() tea.Msg { - selection, err := providerSvc.SelectProvider(context.Background(), providerID) + selection, err := providerSvc.SelectProvider(context.Background(), providerName) if err != nil { return localCommandResultMsg{err: err} } diff --git a/internal/tui/provider_service.go b/internal/tui/provider_service.go index 21985e73..c2a5ea1e 100644 --- a/internal/tui/provider_service.go +++ b/internal/tui/provider_service.go @@ -3,7 +3,7 @@ package tui import ( "context" - "github.com/dust/neo-code/internal/provider" + "neo-code/internal/provider" ) type ProviderController interface { diff --git a/internal/tui/state.go b/internal/tui/state.go index bceaa5a2..2bd56b35 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -10,7 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - agentruntime "github.com/dust/neo-code/internal/runtime" + agentruntime "neo-code/internal/runtime" ) type panel int diff --git a/internal/tui/update.go b/internal/tui/update.go index 715c1c58..b7d0bc9e 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -11,10 +11,10 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" - agentruntime "github.com/dust/neo-code/internal/runtime" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/config" + "neo-code/internal/provider" + agentruntime "neo-code/internal/runtime" + "neo-code/internal/tools" ) type RuntimeMsg struct{ Event agentruntime.RuntimeEvent } @@ -237,7 +237,7 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if !ok { return a, nil } - return a, runProviderSelection(a.providerSvc, item.id) + return a, runProviderSelection(a.providerSvc, item.name) case pickerModel: item, ok := a.modelPicker.SelectedItem().(modelItem) a.closePicker() diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 76f5be4a..7b483839 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -11,12 +11,12 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" - "github.com/dust/neo-code/internal/provider/builtin" - provideropenai "github.com/dust/neo-code/internal/provider/openai" - agentruntime "github.com/dust/neo-code/internal/runtime" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/config" + "neo-code/internal/provider" + "neo-code/internal/provider/builtin" + provideropenai "neo-code/internal/provider/openai" + agentruntime "neo-code/internal/runtime" + "neo-code/internal/tools" ) type stubRuntime struct { @@ -471,7 +471,7 @@ func TestTUIStandaloneHelpers(t *testing.T) { } manager := newTestConfigManager(t) - msg := runModelSelection(newTestProviderService(t, manager), "gpt-5.4")() + msg := runModelSelection(newTestProviderService(t, manager), provideropenai.DefaultModel)() if result, ok := msg.(localCommandResultMsg); !ok || result.err != nil { t.Fatalf("expected successful localCommandResultMsg, got %+v", msg) } @@ -706,6 +706,12 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { func TestAppUpdateModelPickerEnterAppliesSelection(t *testing.T) { manager := newTestConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.CurrentModel = "unsupported-current" + return nil + }); err != nil { + t.Fatalf("set unsupported current model: %v", err) + } runtime := newStubRuntime() app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) if err != nil { @@ -713,11 +719,11 @@ func TestAppUpdateModelPickerEnterAppliesSelection(t *testing.T) { } app.openModelPicker() - if len(app.modelPicker.Items()) < 2 { + if len(app.modelPicker.Items()) == 0 { t.Fatalf("expected model picker catalog") } - selected := app.modelPicker.Items()[1].(modelItem).id - app.modelPicker.Select(1) + selected := app.modelPicker.Items()[0].(modelItem).id + app.modelPicker.Select(0) model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) app = model.(App) @@ -745,6 +751,7 @@ func TestAppUpdateProviderPickerEnterAppliesSelection(t *testing.T) { Driver: provideropenai.DriverName, BaseURL: provideropenai.DefaultBaseURL, Model: "gpt-4o", + Models: []string{"gpt-4o"}, APIKeyEnv: provideropenai.DefaultAPIKeyEnv, }) cfg.CurrentModel = "unsupported-current" diff --git a/internal/tui/view.go b/internal/tui/view.go index 5c1fed4b..47a6a737 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -6,7 +6,7 @@ import ( "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/lipgloss" - "github.com/dust/neo-code/internal/provider" + "neo-code/internal/provider" ) type layout struct { From 5692d0b1bdb8d2c74385808a348eeafd9bca2fbe Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sat, 28 Mar 2026 23:21:12 +0800 Subject: [PATCH 13/15] =?UTF-8?q?pref=EF=BC=9A=E4=B8=BATUI=E5=B1=82?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=B8=AD=E6=AD=A2=E6=80=9D=E8=80=83=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 + internal/provider/builtin/builtin.go | 8 +- internal/provider/builtin/builtin_test.go | 29 ++ internal/provider/gemini/gemini.go | 31 ++ internal/provider/gemini/gemini_test.go | 31 ++ internal/provider/openll/openll.go | 428 ++++++++++++++++++++++ internal/provider/registry_test.go | 31 +- internal/runtime/runtime.go | 49 +++ internal/runtime/runtime_test.go | 43 +++ internal/tui/commands.go | 2 + internal/tui/keymap.go | 21 +- internal/tui/update.go | 26 +- internal/tui/update_test.go | 108 +++++- internal/tui/view.go | 2 + 14 files changed, 781 insertions(+), 31 deletions(-) create mode 100644 internal/provider/builtin/builtin_test.go create mode 100644 internal/provider/gemini/gemini.go create mode 100644 internal/provider/gemini/gemini_test.go create mode 100644 internal/provider/openll/openll.go diff --git a/README.md b/README.md index 1935d7ff..4398146a 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ NeoCode 是一个基于 Go 和 Bubble Tea 的本地编码 Agent。它在终端 - `config.yaml` 只保存当前选择状态和通用运行配置。 - 运行时的 `providers` 完全来自代码内建定义。 - API Key 只从环境变量读取,不写入 YAML。 +- 当前内建 provider 包括 `openai` 和 `gemini`。 +- `gemini` 复用 OpenAI-compatible driver,请求地址指向 Gemini 的兼容接口。 - provider 实例自己定义 `base_url`、默认模型、可选模型列表和 `api_key_env`。 - `base_url` 不在 TUI 中展示给用户。 - driver 只负责协议构造与响应解析,不决定 `models`、`base_url` 或 `api_key_env`。 @@ -39,6 +41,7 @@ tool_timeout_sec: 20 - `selected_provider` 和 `current_model` 是用户当前选择。 - provider 的 `base_url`、`models`、`api_key_env` 和 `driver` 都由开发者在代码中预设。 +- `openai` 默认读取 `OPENAI_API_KEY`,`gemini` 默认读取 `GEMINI_API_KEY`。 - 完整 provider 列表不落盘,用户不需要在 YAML 中维护供应商元数据。 ## Slash Commands diff --git a/internal/provider/builtin/builtin.go b/internal/provider/builtin/builtin.go index 00c87b3c..3d4fcb61 100644 --- a/internal/provider/builtin/builtin.go +++ b/internal/provider/builtin/builtin.go @@ -5,13 +5,19 @@ import ( "neo-code/internal/config" "neo-code/internal/provider" + "neo-code/internal/provider/gemini" "neo-code/internal/provider/openai" + "neo-code/internal/provider/openll" ) func DefaultConfig() *config.Config { cfg := config.Default() defaultProvider := openai.BuiltinConfig() - cfg.Providers = []config.ProviderConfig{defaultProvider} + cfg.Providers = []config.ProviderConfig{ + defaultProvider, + gemini.BuiltinConfig(), + openll.BuiltinConfig(), + } cfg.SelectedProvider = defaultProvider.Name cfg.CurrentModel = defaultProvider.Model return cfg diff --git a/internal/provider/builtin/builtin_test.go b/internal/provider/builtin/builtin_test.go new file mode 100644 index 00000000..6221639e --- /dev/null +++ b/internal/provider/builtin/builtin_test.go @@ -0,0 +1,29 @@ +package builtin + +import ( + "testing" + + "neo-code/internal/provider/gemini" + "neo-code/internal/provider/openai" +) + +func TestDefaultConfigIncludesOpenAIAndGemini(t *testing.T) { + t.Parallel() + + cfg := DefaultConfig() + if len(cfg.Providers) != 2 { + t.Fatalf("expected 2 builtin providers, got %d", len(cfg.Providers)) + } + if cfg.Providers[0].Name != openai.Name { + t.Fatalf("expected first provider %q, got %q", openai.Name, cfg.Providers[0].Name) + } + if cfg.Providers[1].Name != gemini.Name { + t.Fatalf("expected second provider %q, got %q", gemini.Name, cfg.Providers[1].Name) + } + if cfg.SelectedProvider != openai.Name { + t.Fatalf("expected selected provider %q, got %q", openai.Name, cfg.SelectedProvider) + } + if cfg.CurrentModel != openai.DefaultModel { + t.Fatalf("expected current model %q, got %q", openai.DefaultModel, cfg.CurrentModel) + } +} diff --git a/internal/provider/gemini/gemini.go b/internal/provider/gemini/gemini.go new file mode 100644 index 00000000..0133e3fc --- /dev/null +++ b/internal/provider/gemini/gemini.go @@ -0,0 +1,31 @@ +package gemini + +import ( + "neo-code/internal/config" + "neo-code/internal/provider/openai" +) + +const ( + Name = "gemini" + DriverName = openai.DriverName + DefaultBaseURL = "https://generativelanguage.googleapis.com/v1beta/openai" + DefaultModel = "gemini-2.5-flash" + DefaultAPIKeyEnv = "GEMINI_API_KEY" +) + +var builtinModels = []string{ + DefaultModel, + "gemini-2.5-pro", + "gemini-2.0-flash", +} + +func BuiltinConfig() config.ProviderConfig { + return config.ProviderConfig{ + Name: Name, + Driver: DriverName, + BaseURL: DefaultBaseURL, + Model: DefaultModel, + Models: append([]string(nil), builtinModels...), + APIKeyEnv: DefaultAPIKeyEnv, + } +} diff --git a/internal/provider/gemini/gemini_test.go b/internal/provider/gemini/gemini_test.go new file mode 100644 index 00000000..afab85a0 --- /dev/null +++ b/internal/provider/gemini/gemini_test.go @@ -0,0 +1,31 @@ +package gemini + +import ( + "testing" + + "neo-code/internal/provider/openai" +) + +func TestBuiltinConfigUsesOpenAICompatibleDriver(t *testing.T) { + t.Parallel() + + cfg := BuiltinConfig() + if cfg.Name != Name { + t.Fatalf("expected provider name %q, got %q", Name, cfg.Name) + } + if cfg.Driver != openai.DriverName { + t.Fatalf("expected driver %q, got %q", openai.DriverName, cfg.Driver) + } + if cfg.BaseURL != DefaultBaseURL { + t.Fatalf("expected base URL %q, got %q", DefaultBaseURL, cfg.BaseURL) + } + if cfg.Model != DefaultModel { + t.Fatalf("expected default model %q, got %q", DefaultModel, cfg.Model) + } + if cfg.APIKeyEnv != DefaultAPIKeyEnv { + t.Fatalf("expected API key env %q, got %q", DefaultAPIKeyEnv, cfg.APIKeyEnv) + } + if len(cfg.Models) < 2 { + t.Fatalf("expected builtin models to be predeclared, got %+v", cfg.Models) + } +} diff --git a/internal/provider/openll/openll.go b/internal/provider/openll/openll.go new file mode 100644 index 00000000..7b48ac08 --- /dev/null +++ b/internal/provider/openll/openll.go @@ -0,0 +1,428 @@ +package openll + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "neo-code/internal/config" + domain "neo-code/internal/provider" +) + +type Provider struct { + cfg config.ResolvedProviderConfig + client *http.Client +} + +const ( + Name = "openll" + DriverName = "openai" + DefaultBaseURL = "https://www.openll.top/v1" + DefaultModel = "gpt-4.1" + DefaultAPIKeyEnv = "AI_API_KEY" +) + +var builtinModels = []string{ + DefaultModel, + "gpt-4o", + "gpt-5.4", + "gpt-5.3-codex", +} + +func BuiltinConfig() config.ProviderConfig { + return config.ProviderConfig{ + Name: Name, + Driver: DriverName, + BaseURL: DefaultBaseURL, + Model: DefaultModel, + Models: append([]string(nil), builtinModels...), + APIKeyEnv: DefaultAPIKeyEnv, + } +} + +func Driver() domain.DriverDefinition { + return domain.DriverDefinition{ + Name: Name, + Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (domain.Provider, error) { + return New(cfg) + }, + } +} + +func New(cfg config.ResolvedProviderConfig) (*Provider, error) { + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("openai provider: %w", err) + } + if strings.TrimSpace(cfg.APIKey) == "" { + return nil, errors.New("openai provider: api key is empty") + } + + return &Provider{ + cfg: cfg, + client: &http.Client{ + Timeout: 90 * time.Second, + }, + }, nil +} + +func (p *Provider) Chat(ctx context.Context, req domain.ChatRequest, events chan<- domain.StreamEvent) (domain.ChatResponse, error) { + payload, err := p.buildRequest(req) + if err != nil { + return domain.ChatResponse{}, err + } + + body, err := json.Marshal(payload) + if err != nil { + return domain.ChatResponse{}, fmt.Errorf("openai provider: marshal request: %w", err) + } + + endpoint := strings.TrimRight(p.cfg.BaseURL, "/") + "/chat/completions" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return domain.ChatResponse{}, fmt.Errorf("openai provider: build request: %w", err) + } + httpReq.Header.Set("Authorization", "Bearer "+p.cfg.APIKey) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "text/event-stream") + + resp, err := p.client.Do(httpReq) + if err != nil { + return domain.ChatResponse{}, fmt.Errorf("openai provider: send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= http.StatusBadRequest { + return domain.ChatResponse{}, p.parseError(resp) + } + + return p.consumeStream(ctx, resp.Body, events) +} + +func (p *Provider) buildRequest(req domain.ChatRequest) (chatCompletionRequest, error) { + model := strings.TrimSpace(req.Model) + if model == "" { + model = strings.TrimSpace(p.cfg.Model) + } + if model == "" { + return chatCompletionRequest{}, errors.New("openai provider: model is empty") + } + + payload := chatCompletionRequest{ + Model: model, + Stream: true, + Messages: make([]openAIMessage, 0, len(req.Messages)+1), + } + + if strings.TrimSpace(req.SystemPrompt) != "" { + payload.Messages = append(payload.Messages, openAIMessage{ + Role: "system", + Content: req.SystemPrompt, + }) + } + + for _, message := range req.Messages { + payload.Messages = append(payload.Messages, toOpenAIMessage(message)) + } + + if len(req.Tools) > 0 { + payload.ToolChoice = "auto" + payload.Tools = make([]openAIToolDefinition, 0, len(req.Tools)) + for _, spec := range req.Tools { + payload.Tools = append(payload.Tools, openAIToolDefinition{ + Type: "function", + Function: openAIFunctionDefinition{ + Name: spec.Name, + Description: spec.Description, + Parameters: spec.Schema, + }, + }) + } + } + + return payload, nil +} + +func (p *Provider) consumeStream(ctx context.Context, body io.Reader, events chan<- domain.StreamEvent) (domain.ChatResponse, error) { + reader := bufio.NewReader(body) + + var ( + contentBuilder strings.Builder + finishReason string + usage domain.Usage + done bool + ) + + toolCalls := make(map[int]*domain.ToolCall) + dataLines := make([]string, 0, 4) + + flushEvent := func() error { + if len(dataLines) == 0 { + return nil + } + + payload := strings.Join(dataLines, "\n") + dataLines = dataLines[:0] + + if strings.TrimSpace(payload) == "[DONE]" { + done = true + return nil + } + + var chunk chatCompletionChunk + if err := json.Unmarshal([]byte(payload), &chunk); err != nil { + return fmt.Errorf("openai provider: decode stream chunk: %w", err) + } + + if chunk.Error != nil && strings.TrimSpace(chunk.Error.Message) != "" { + return errors.New(chunk.Error.Message) + } + + if chunk.Usage != nil { + usage = domain.Usage{ + InputTokens: chunk.Usage.PromptTokens, + OutputTokens: chunk.Usage.CompletionTokens, + TotalTokens: chunk.Usage.TotalTokens, + } + } + + for _, choice := range chunk.Choices { + if strings.TrimSpace(choice.FinishReason) != "" { + finishReason = choice.FinishReason + } + + if text := choice.Delta.Content; text != "" { + contentBuilder.WriteString(text) + if err := emitTextDelta(ctx, events, text); err != nil { + return err + } + } + + mergeToolCallDeltas(toolCalls, choice.Delta.ToolCalls) + } + + return nil + } + + for { + line, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return domain.ChatResponse{}, fmt.Errorf("openai provider: read stream: %w", err) + } + + line = strings.TrimRight(line, "\r\n") + trimmed := strings.TrimSpace(line) + + switch { + case strings.HasPrefix(trimmed, "data:"): + dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))) + case trimmed == "": + if flushErr := flushEvent(); flushErr != nil { + return domain.ChatResponse{}, flushErr + } + if done { + return finalizeResponse(contentBuilder.String(), toolCalls, finishReason, usage), nil + } + case strings.HasPrefix(trimmed, ":"): + // SSE comment/heartbeat; ignore it. + } + + if errors.Is(err, io.EOF) { + if flushErr := flushEvent(); flushErr != nil { + return domain.ChatResponse{}, flushErr + } + return finalizeResponse(contentBuilder.String(), toolCalls, finishReason, usage), nil + } + } +} + +func (p *Provider) parseError(resp *http.Response) error { + data, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("openai provider: read error response: %w", readErr) + } + + var parsed openAIErrorResponse + if err := json.Unmarshal(data, &parsed); err == nil && strings.TrimSpace(parsed.Error.Message) != "" { + return errors.New(parsed.Error.Message) + } + + bodyText := strings.TrimSpace(string(data)) + if bodyText == "" { + return errors.New(resp.Status) + } + + return fmt.Errorf("openai provider: %s", bodyText) +} + +func toOpenAIMessage(message domain.Message) openAIMessage { + out := openAIMessage{ + Role: message.Role, + Content: message.Content, + ToolCallID: message.ToolCallID, + } + + if len(message.ToolCalls) > 0 { + out.ToolCalls = make([]openAIToolCall, 0, len(message.ToolCalls)) + for _, call := range message.ToolCalls { + out.ToolCalls = append(out.ToolCalls, openAIToolCall{ + ID: call.ID, + Type: "function", + Function: openAIFunctionCall{ + Name: call.Name, + Arguments: call.Arguments, + }, + }) + } + } + + return out +} + +func emitTextDelta(ctx context.Context, events chan<- domain.StreamEvent, text string) error { + if events == nil || text == "" { + return nil + } + + select { + case events <- domain.StreamEvent{ + Type: domain.StreamEventTextDelta, + Text: text, + }: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func mergeToolCallDeltas(target map[int]*domain.ToolCall, deltas []toolCallDelta) { + for _, delta := range deltas { + call, ok := target[delta.Index] + if !ok { + call = &domain.ToolCall{} + target[delta.Index] = call + } + + if strings.TrimSpace(delta.ID) != "" { + call.ID = delta.ID + } + if strings.TrimSpace(delta.Function.Name) != "" { + call.Name = delta.Function.Name + } + if delta.Function.Arguments != "" { + call.Arguments += delta.Function.Arguments + } + } +} + +func finalizeResponse(content string, toolCalls map[int]*domain.ToolCall, finishReason string, usage domain.Usage) domain.ChatResponse { + ordered := make([]int, 0, len(toolCalls)) + for index := range toolCalls { + ordered = append(ordered, index) + } + sort.Ints(ordered) + + message := domain.Message{ + Role: "assistant", + Content: content, + } + + for _, index := range ordered { + call := toolCalls[index] + if call == nil { + continue + } + message.ToolCalls = append(message.ToolCalls, *call) + } + + if finishReason == "" && len(message.ToolCalls) > 0 { + finishReason = "tool_calls" + } + + return domain.ChatResponse{ + Message: message, + FinishReason: finishReason, + Usage: usage, + } +} + +type chatCompletionRequest struct { + Model string `json:"model"` + Messages []openAIMessage `json:"messages"` + Tools []openAIToolDefinition `json:"tools,omitempty"` + ToolChoice string `json:"tool_choice,omitempty"` + Stream bool `json:"stream"` +} + +type openAIMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolCalls []openAIToolCall `json:"tool_calls,omitempty"` +} + +type openAIToolDefinition struct { + Type string `json:"type"` + Function openAIFunctionDefinition `json:"function"` +} + +type openAIFunctionDefinition struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters map[string]any `json:"parameters,omitempty"` +} + +type openAIToolCall struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function openAIFunctionCall `json:"function"` +} + +type openAIFunctionCall struct { + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` +} + +type chatCompletionChunk struct { + Choices []struct { + Index int `json:"index"` + Delta chunkDelta `json:"delta"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage *openAIUsage `json:"usage,omitempty"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +type chunkDelta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []toolCallDelta `json:"tool_calls,omitempty"` +} + +type toolCallDelta struct { + Index int `json:"index"` + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function openAIFunctionCall `json:"function"` +} + +type openAIUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type openAIErrorResponse struct { + Error struct { + Message string `json:"message"` + } `json:"error"` +} diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index ced095da..4d52df25 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -8,6 +8,7 @@ import ( "neo-code/internal/config" "neo-code/internal/provider" "neo-code/internal/provider/builtin" + "neo-code/internal/provider/gemini" "neo-code/internal/provider/openai" ) @@ -121,19 +122,29 @@ func TestServiceListProvidersUsesConfiguredMetadata(t *testing.T) { if err != nil { t.Fatalf("ListProviders() error = %v", err) } - if len(items) != 1 { + expectedModels := map[string]int{ + openai.Name: len(openai.BuiltinConfig().Models), + gemini.Name: len(gemini.BuiltinConfig().Models), + } + if len(items) != len(expectedModels) { t.Fatalf("expected only supported providers, got %d", len(items)) } - item := items[0] - if item.ID != openai.DriverName { - t.Fatalf("expected supported provider %q, got %q", openai.DriverName, item.ID) - } - if item.Description != "" { - t.Fatalf("expected provider description to stay empty for hidden metadata, got %q", item.Description) - } - if len(item.Models) != len(openai.BuiltinConfig().Models) { - t.Fatalf("expected provider models to come from config, got %+v", item.Models) + for _, item := range items { + wantModels, ok := expectedModels[item.ID] + if !ok { + t.Fatalf("unexpected supported provider %q", item.ID) + } + if item.Description != "" { + t.Fatalf("expected provider description to stay empty for hidden metadata, got %q", item.Description) + } + if len(item.Models) != wantModels { + t.Fatalf("expected provider models to come from config, got %+v", item.Models) + } + delete(expectedModels, item.ID) + } + if len(expectedModels) != 0 { + t.Fatalf("missing supported providers from catalog: %+v", expectedModels) } } diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 7aef9b3f..44a6738c 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -4,6 +4,7 @@ import ( "context" "errors" "strings" + "sync" "time" "neo-code/internal/config" @@ -16,6 +17,7 @@ const maxContextTurns = 10 // Runtime coordinates agent execution, session persistence, and event delivery. type Runtime interface { Run(ctx context.Context, input UserInput) error + CancelActiveRun() bool Events() <-chan RuntimeEvent ListSessions(ctx context.Context) ([]SessionSummary, error) LoadSession(ctx context.Context, id string) (Session, error) @@ -39,6 +41,10 @@ type Service struct { toolRegistry *tools.Registry providerFactory ProviderFactory events chan RuntimeEvent + runMu sync.Mutex + activeRunToken uint64 + nextRunToken uint64 + activeRunCancel context.CancelFunc } func NewWithFactory(configManager *config.Manager, toolRegistry *tools.Registry, sessionStore Store, providerFactory ProviderFactory) *Service { @@ -56,6 +62,14 @@ func NewWithFactory(configManager *config.Manager, toolRegistry *tools.Registry, } func (s *Service) Run(ctx context.Context, input UserInput) error { + runCtx, cancel := context.WithCancel(ctx) + runToken := s.startRun(cancel) + defer func() { + cancel() + s.finishRun(runToken) + }() + ctx = runCtx + if strings.TrimSpace(input.Content) == "" { return errors.New("runtime: input content is empty") } @@ -205,6 +219,18 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { } } +func (s *Service) CancelActiveRun() bool { + s.runMu.Lock() + cancel := s.activeRunCancel + s.runMu.Unlock() + if cancel == nil { + return false + } + + cancel() + return true +} + func (s *Service) Events() <-chan RuntimeEvent { return s.events } @@ -247,6 +273,29 @@ func (s *Service) forwardProviderEvents(runID string, sessionID string, input <- } } +func (s *Service) startRun(cancel context.CancelFunc) uint64 { + s.runMu.Lock() + defer s.runMu.Unlock() + + s.nextRunToken++ + token := s.nextRunToken + s.activeRunToken = token + s.activeRunCancel = cancel + return token +} + +func (s *Service) finishRun(token uint64) { + s.runMu.Lock() + defer s.runMu.Unlock() + + if s.activeRunToken != token { + return + } + + s.activeRunToken = 0 + s.activeRunCancel = nil +} + func (s *Service) handleRunError(runID string, sessionID string, err error) error { if s.isRunCanceled(err) { s.emit(EventRunCanceled, runID, sessionID, nil) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 7f34f781..a9f581fc 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -609,6 +609,49 @@ func TestServiceRunErrorPaths(t *testing.T) { } } +func TestServiceCancelActiveRun(t *testing.T) { + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + started := make(chan struct{}) + scripted := &scriptedProvider{ + name: "cancel-active-run-provider", + chatFn: func(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) (provider.ChatResponse, error) { + close(started) + <-ctx.Done() + return provider.ChatResponse{}, ctx.Err() + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}) + errCh := make(chan error, 1) + input := UserInput{RunID: "run-cancel-active", Content: "hello"} + + go func() { + errCh <- service.Run(context.Background(), input) + }() + + <-started + if !service.CancelActiveRun() { + t.Fatalf("expected active run cancel to return true") + } + + err := <-errCh + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context canceled, got %v", err) + } + if service.CancelActiveRun() { + t.Fatalf("expected no active run after cancellation") + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{EventUserMessage, EventRunCanceled}) + assertNoEventType(t, events, EventError) + assertEventsRunID(t, events, input.RunID) +} + func TestServiceRunCanceledByProvider(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() diff --git a/internal/tui/commands.go b/internal/tui/commands.go index c0f090aa..4aacf6a1 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -36,6 +36,8 @@ const ( statusReady = "Ready" statusRuntimeClosed = "Runtime closed" statusThinking = "Thinking" + statusCanceling = "Canceling" + statusCanceled = "Canceled" statusRunningTool = "Running tool" statusToolFinished = "Tool finished" statusToolError = "Tool error" diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go index c115f686..891be1a8 100644 --- a/internal/tui/keymap.go +++ b/internal/tui/keymap.go @@ -4,6 +4,7 @@ import "github.com/charmbracelet/bubbles/key" type keyMap struct { Send key.Binding + CancelAgent key.Binding NewSession key.Binding NextPanel key.Binding PrevPanel key.Binding @@ -25,6 +26,10 @@ func newKeyMap() keyMap { key.WithKeys("enter", "ctrl+s"), key.WithHelp("Enter/Ctrl+S", "发送(输入框)"), ), + CancelAgent: key.NewBinding( + key.WithKeys("ctrl+w"), + key.WithHelp("Ctrl+w", "中止"), + ), NewSession: key.NewBinding( key.WithKeys("ctrl+n"), key.WithHelp("Ctrl+N", "新会话"), @@ -43,7 +48,7 @@ func newKeyMap() keyMap { ), OpenSession: key.NewBinding( key.WithKeys("enter"), - key.WithHelp("Enter", "打开会话(会话列表)"), + key.WithHelp("Enter", "打开会话"), ), ToggleHelp: key.NewBinding( key.WithKeys("ctrl+q"), @@ -55,11 +60,11 @@ func newKeyMap() keyMap { ), ScrollUp: key.NewBinding( key.WithKeys("up", "k"), - key.WithHelp("↑/k", "向上滚动"), + key.WithHelp("Up/k", "向上滚动"), ), ScrollDown: key.NewBinding( key.WithKeys("down", "j"), - key.WithHelp("↓/j", "向下滚动"), + key.WithHelp("Down/j", "向下滚动"), ), PageUp: key.NewBinding( key.WithKeys("pgup", "b"), @@ -81,14 +86,14 @@ func newKeyMap() keyMap { } func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Send, k.NewSession, k.NextPanel, k.ToggleHelp, k.Quit} + return []key.Binding{k.Send, k.CancelAgent, k.NewSession, k.ToggleHelp, k.Quit} } func (k keyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ - {k.Send, k.NewSession, k.OpenSession, k.FocusInput}, - {k.NextPanel, k.PrevPanel, k.ToggleHelp, k.Quit}, - {k.ScrollUp, k.ScrollDown, k.PageUp, k.PageDown}, - {k.Top, k.Bottom}, + {k.Send, k.CancelAgent, k.NewSession, k.OpenSession}, + {k.FocusInput, k.NextPanel, k.PrevPanel, k.ToggleHelp}, + {k.Quit, k.ScrollUp, k.ScrollDown, k.PageUp}, + {k.PageDown, k.Top, k.Bottom}, } } diff --git a/internal/tui/update.go b/internal/tui/update.go index b7d0bc9e..37692d10 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -2,6 +2,7 @@ package tui import ( "context" + "errors" "fmt" "strings" @@ -58,9 +59,14 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state.IsAgentRunning = false a.state.StreamingReply = false a.state.CurrentTool = "" - a.state.ExecutionError = typed.err.Error() - a.state.StatusText = typed.err.Error() - a.appendInlineMessage(roleError, typed.err.Error()) + if errors.Is(typed.err, context.Canceled) { + a.state.ExecutionError = "" + a.state.StatusText = statusCanceled + } else { + a.state.ExecutionError = typed.err.Error() + a.state.StatusText = typed.err.Error() + a.appendInlineMessage(roleError, typed.err.Error()) + } } _ = a.refreshSessions() a.syncActiveSessionTitle() @@ -103,6 +109,13 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.resizeComponents() return a, tea.Batch(cmds...) } + if a.state.IsAgentRunning && key.Matches(typed, a.keys.CancelAgent) { + if a.runtime.CancelActiveRun() { + a.state.StatusText = statusCanceling + } + a.rebuildTranscript() + return a, tea.Batch(cmds...) + } if a.state.ActivePicker != pickerNone { return a.updatePicker(typed) } @@ -389,6 +402,13 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) { if payload, ok := event.Payload.(provider.Message); ok && strings.TrimSpace(payload.Content) != "" && !a.lastAssistantMatches(payload.Content) { a.activeMessages = append(a.activeMessages, provider.Message{Role: roleAssistant, Content: payload.Content}) } + case agentruntime.EventRunCanceled: + a.state.IsAgentRunning = false + a.state.StreamingReply = false + a.state.CurrentTool = "" + a.state.ExecutionError = "" + a.state.StatusText = statusCanceled + a.appendInlineMessage(roleSystem, "Canceled current run.") case agentruntime.EventError: a.state.StatusText = statusError a.state.IsAgentRunning = false diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 7b483839..5a62ad72 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -20,13 +20,15 @@ import ( ) type stubRuntime struct { - runInputs []agentruntime.UserInput - events chan agentruntime.RuntimeEvent - sessions []agentruntime.SessionSummary - loads map[string]agentruntime.Session - runErr error - listErr error - loadErr error + runInputs []agentruntime.UserInput + events chan agentruntime.RuntimeEvent + sessions []agentruntime.SessionSummary + loads map[string]agentruntime.Session + runErr error + listErr error + loadErr error + cancelCalls int + cancelResult bool } func newStubRuntime() *stubRuntime { @@ -45,6 +47,11 @@ func (r *stubRuntime) Events() <-chan agentruntime.RuntimeEvent { return r.events } +func (r *stubRuntime) CancelActiveRun() bool { + r.cancelCalls++ + return r.cancelResult +} + func (r *stubRuntime) ListSessions(ctx context.Context) ([]agentruntime.SessionSummary, error) { if r.listErr != nil { return nil, r.listErr @@ -218,6 +225,26 @@ func TestAppUpdateModelPickerAndRuntimeMessages(t *testing.T) { } }, }, + { + name: "runtime canceled clears running state without error", + setup: func(t *testing.T, app *App, runtime *stubRuntime) { + app.state.IsAgentRunning = true + app.state.ActiveSessionID = "session-cancel" + }, + msg: RuntimeMsg{Event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventRunCanceled, + SessionID: "session-cancel", + }}, + assert: func(t *testing.T, runtime *stubRuntime, app App, msgs []tea.Msg) { + t.Helper() + if app.state.IsAgentRunning { + t.Fatalf("expected agent to stop running") + } + if app.state.ExecutionError != "" || app.state.StatusText != statusCanceled { + t.Fatalf("expected canceled status without error, got %+v", app.state) + } + }, + }, { name: "runtime tool result error is surfaced", setup: func(t *testing.T, app *App, runtime *stubRuntime) { @@ -510,6 +537,20 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { } }, }, + { + name: "run finished canceled is not surfaced as error", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.state.IsAgentRunning = true + app.state.StatusText = statusCanceling + }, + msg: runFinishedMsg{err: context.Canceled}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if app.state.IsAgentRunning || app.state.ExecutionError != "" || app.state.StatusText != statusCanceled { + t.Fatalf("expected canceled run to stop without error, got %+v", app.state) + } + }, + }, { name: "run finished error is surfaced", setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { @@ -543,6 +584,25 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { } }, }, + { + name: "cancel shortcut interrupts running agent", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.state.IsAgentRunning = true + app.state.StatusText = statusThinking + runtime.cancelResult = true + app.keys.CancelAgent.SetKeys("ctrl+@") + }, + msg: tea.KeyMsg{Type: tea.KeyCtrlAt}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if runtime.cancelCalls != 1 { + t.Fatalf("expected cancel to be called once, got %d", runtime.cancelCalls) + } + if app.state.StatusText != statusCanceling { + t.Fatalf("expected canceling status, got %q", app.state.StatusText) + } + }, + }, { name: "toggle help flips state", msg: tea.KeyMsg{Type: tea.KeyCtrlQ}, @@ -770,8 +830,23 @@ func TestAppUpdateProviderPickerEnterAppliesSelection(t *testing.T) { if len(app.providerPicker.Items()) < 2 { t.Fatalf("expected provider picker catalog") } - selected := app.providerPicker.Items()[1].(providerItem).id - app.providerPicker.Select(1) + selectedIndex := -1 + selected := "" + for idx, item := range app.providerPicker.Items() { + candidate, ok := item.(providerItem) + if !ok { + continue + } + if candidate.id == "openai-alt" { + selectedIndex = idx + selected = candidate.id + break + } + } + if selectedIndex < 0 { + t.Fatalf("expected provider picker to include openai-alt") + } + app.providerPicker.Select(selectedIndex) model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) app = model.(App) @@ -890,6 +965,21 @@ func TestAppHandleRuntimeEventAdditionalBranches(t *testing.T) { } }, }, + { + name: "run canceled event clears current tool and error state", + setup: func(app *App) { + app.state.IsAgentRunning = true + app.state.CurrentTool = "filesystem_edit" + app.state.ExecutionError = "old" + }, + event: agentruntime.RuntimeEvent{Type: agentruntime.EventRunCanceled, SessionID: "s1"}, + assert: func(t *testing.T, app App) { + t.Helper() + if app.state.IsAgentRunning || app.state.CurrentTool != "" || app.state.ExecutionError != "" || app.state.StatusText != statusCanceled { + t.Fatalf("unexpected canceled state: %+v", app.state) + } + }, + }, } for _, tt := range tests { diff --git a/internal/tui/view.go b/internal/tui/view.go index 47a6a737..a20bc2c4 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -356,6 +356,8 @@ func (a App) statusBadge(text string) string { switch { case strings.Contains(lower, "error") || strings.Contains(lower, "failed"): return a.styles.badgeError.Render(text) + case strings.Contains(lower, "cancel"): + return a.styles.badgeWarning.Render(text) case a.state.IsAgentRunning || strings.Contains(lower, "running") || strings.Contains(lower, "thinking"): return a.styles.badgeWarning.Render(text) default: From e941f63fb9fd57c721a6864a703554b79a44a503 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sat, 28 Mar 2026 23:27:24 +0800 Subject: [PATCH 14/15] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E5=A4=8D=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=96=AD=E8=A8=80=E6=9C=89=E8=AF=AF=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/builtin/builtin_test.go | 10 +++++++--- internal/provider/registry_test.go | 2 ++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/internal/provider/builtin/builtin_test.go b/internal/provider/builtin/builtin_test.go index 6221639e..c047c335 100644 --- a/internal/provider/builtin/builtin_test.go +++ b/internal/provider/builtin/builtin_test.go @@ -5,14 +5,15 @@ import ( "neo-code/internal/provider/gemini" "neo-code/internal/provider/openai" + "neo-code/internal/provider/openll" ) -func TestDefaultConfigIncludesOpenAIAndGemini(t *testing.T) { +func TestDefaultConfigIncludesBuiltinProviders(t *testing.T) { t.Parallel() cfg := DefaultConfig() - if len(cfg.Providers) != 2 { - t.Fatalf("expected 2 builtin providers, got %d", len(cfg.Providers)) + if len(cfg.Providers) != 3 { + t.Fatalf("expected 3 builtin providers, got %d", len(cfg.Providers)) } if cfg.Providers[0].Name != openai.Name { t.Fatalf("expected first provider %q, got %q", openai.Name, cfg.Providers[0].Name) @@ -20,6 +21,9 @@ func TestDefaultConfigIncludesOpenAIAndGemini(t *testing.T) { if cfg.Providers[1].Name != gemini.Name { t.Fatalf("expected second provider %q, got %q", gemini.Name, cfg.Providers[1].Name) } + if cfg.Providers[2].Name != openll.Name { + t.Fatalf("expected third provider %q, got %q", openll.Name, cfg.Providers[2].Name) + } if cfg.SelectedProvider != openai.Name { t.Fatalf("expected selected provider %q, got %q", openai.Name, cfg.SelectedProvider) } diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index 4d52df25..3bda125d 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -10,6 +10,7 @@ import ( "neo-code/internal/provider/builtin" "neo-code/internal/provider/gemini" "neo-code/internal/provider/openai" + "neo-code/internal/provider/openll" ) type stubProvider struct{} @@ -125,6 +126,7 @@ func TestServiceListProvidersUsesConfiguredMetadata(t *testing.T) { expectedModels := map[string]int{ openai.Name: len(openai.BuiltinConfig().Models), gemini.Name: len(gemini.BuiltinConfig().Models), + openll.Name: len(openll.BuiltinConfig().Models), } if len(items) != len(expectedModels) { t.Fatalf("expected only supported providers, got %d", len(items)) From 1285e6c8fef8214c444d25c4e7dc6a24639e489f Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sat, 28 Mar 2026 23:40:22 +0800 Subject: [PATCH 15/15] =?UTF-8?q?test=EF=BC=9A=E8=A1=A5=E5=85=85openll?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/openll/openll_test.go | 513 ++++++++++++++++++++++++ 1 file changed, 513 insertions(+) create mode 100644 internal/provider/openll/openll_test.go diff --git a/internal/provider/openll/openll_test.go b/internal/provider/openll/openll_test.go new file mode 100644 index 00000000..ac77cfbb --- /dev/null +++ b/internal/provider/openll/openll_test.go @@ -0,0 +1,513 @@ +package openll + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "neo-code/internal/config" + domain "neo-code/internal/provider" +) + +func resolvedConfig(baseURL string, model string) config.ResolvedProviderConfig { + if strings.TrimSpace(baseURL) == "" { + baseURL = DefaultBaseURL + } + if strings.TrimSpace(model) == "" { + model = DefaultModel + } + + return config.ResolvedProviderConfig{ + ProviderConfig: config.ProviderConfig{ + Name: Name, + Driver: DriverName, + BaseURL: baseURL, + Model: model, + APIKeyEnv: DefaultAPIKeyEnv, + }, + APIKey: "test-key", + } +} + +func TestMergeToolCallDeltas(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + deltas []toolCallDelta + assert func(t *testing.T, calls map[int]*domain.ToolCall) + }{ + { + name: "single tool call fragments are merged by index", + deltas: []toolCallDelta{ + {Index: 0, ID: "call_1", Function: openAIFunctionCall{Name: "filesystem_edit", Arguments: `{"path":"a.go",`}}, + {Index: 0, Function: openAIFunctionCall{Arguments: `"search_string":"old",`}}, + {Index: 0, Function: openAIFunctionCall{Arguments: `"replace_string":"new"}`}}, + }, + assert: func(t *testing.T, calls map[int]*domain.ToolCall) { + t.Helper() + call := calls[0] + if call == nil { + t.Fatalf("expected call at index 0") + } + if call.Name != "filesystem_edit" { + t.Fatalf("expected name filesystem_edit, got %q", call.Name) + } + if call.Arguments != `{"path":"a.go","search_string":"old","replace_string":"new"}` { + t.Fatalf("unexpected arguments: %q", call.Arguments) + } + }, + }, + { + name: "multiple indices stay isolated", + deltas: []toolCallDelta{ + {Index: 0, ID: "call_1", Function: openAIFunctionCall{Name: "filesystem_read_file", Arguments: `{"path":"a.go"}`}}, + {Index: 1, ID: "call_2", Function: openAIFunctionCall{Name: "filesystem_write_file", Arguments: `{"path":"b.go"`}}, + {Index: 1, Function: openAIFunctionCall{Arguments: `,"content":"ok"}`}}, + }, + assert: func(t *testing.T, calls map[int]*domain.ToolCall) { + t.Helper() + if len(calls) != 2 { + t.Fatalf("expected 2 calls, got %d", len(calls)) + } + if calls[1].Arguments != `{"path":"b.go","content":"ok"}` { + t.Fatalf("unexpected second arguments: %q", calls[1].Arguments) + } + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + calls := map[int]*domain.ToolCall{} + mergeToolCallDeltas(calls, tt.deltas) + tt.assert(t, calls) + }) + } +} + +func TestBuiltinConfigIncludesProviderModels(t *testing.T) { + t.Parallel() + + cfg := BuiltinConfig() + if cfg.Name != Name { + t.Fatalf("expected provider name %q, got %q", Name, cfg.Name) + } + if cfg.Driver != DriverName { + t.Fatalf("expected driver %q, got %q", DriverName, cfg.Driver) + } + if cfg.BaseURL != DefaultBaseURL { + t.Fatalf("expected base URL %q, got %q", DefaultBaseURL, cfg.BaseURL) + } + if cfg.APIKeyEnv != DefaultAPIKeyEnv { + t.Fatalf("expected api key env %q, got %q", DefaultAPIKeyEnv, cfg.APIKeyEnv) + } + if len(cfg.Models) < 3 { + t.Fatalf("expected builtin models to be predeclared, got %+v", cfg.Models) + } + if !containsString(cfg.Models, "gpt-5.4") { + t.Fatalf("expected builtin models to include gpt-5.4, got %+v", cfg.Models) + } +} + +func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { + t.Setenv(DefaultAPIKeyEnv, "test-key") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Fatalf("unexpected auth header: %s", got) + } + + var payload chatCompletionRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode request: %v", err) + } + if payload.Model != "gpt-5.4" { + t.Fatalf("expected model gpt-5.4, got %q", payload.Model) + } + if !containsToolRoleMessage(payload.Messages, "call_1", "tool finished") { + t.Fatalf("expected tool role message with tool_call_id in payload: %+v", payload.Messages) + } + + w.Header().Set("Content-Type", "text/event-stream") + writeSSEChunk(t, w, map[string]any{ + "choices": []map[string]any{ + { + "index": 0, + "delta": map[string]any{ + "content": "Hello ", + }, + }, + }, + }) + writeSSEChunk(t, w, map[string]any{ + "choices": []map[string]any{ + { + "index": 0, + "delta": map[string]any{ + "tool_calls": []map[string]any{ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": map[string]any{ + "name": "filesystem_edit", + "arguments": `{"path":"main.go",`, + }, + }, + }, + }, + }, + }, + }) + writeSSEChunk(t, w, map[string]any{ + "choices": []map[string]any{ + { + "index": 0, + "delta": map[string]any{ + "content": "world", + "tool_calls": []map[string]any{ + { + "index": 0, + "function": map[string]any{ + "arguments": `"search_string":"old",`, + }, + }, + }, + }, + }, + }, + }) + writeSSEChunk(t, w, map[string]any{ + "choices": []map[string]any{ + { + "index": 0, + "finish_reason": "tool_calls", + "delta": map[string]any{ + "tool_calls": []map[string]any{ + { + "index": 0, + "function": map[string]any{ + "arguments": `"replace_string":"new"}`, + }, + }, + }, + }, + }, + }, + "usage": map[string]any{ + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + }, + }) + _, _ = w.Write([]byte("data: [DONE]\n\n")) + })) + defer server.Close() + + provider, err := New(resolvedConfig(server.URL, "gpt-5.4")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + provider.client = server.Client() + + events := make(chan domain.StreamEvent, 8) + response, err := provider.Chat(context.Background(), domain.ChatRequest{ + Model: "gpt-5.4", + Messages: []domain.Message{ + {Role: "user", Content: "please edit the file"}, + { + Role: "assistant", + ToolCalls: []domain.ToolCall{ + { + ID: "call_1", + Name: "filesystem_edit", + Arguments: `{"path":"main.go","search_string":"old","replace_string":"new"}`, + }, + }, + }, + {Role: "tool", ToolCallID: "call_1", Content: "tool finished"}, + }, + Tools: []domain.ToolSpec{ + { + Name: "filesystem_edit", + Description: "Edit one matching block in a file", + Schema: map[string]any{ + "type": "object", + }, + }, + }, + }, events) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if response.Message.Content != "Hello world" { + t.Fatalf("expected content %q, got %q", "Hello world", response.Message.Content) + } + if response.FinishReason != "tool_calls" { + t.Fatalf("expected finish reason tool_calls, got %q", response.FinishReason) + } + if response.Usage.TotalTokens != 15 { + t.Fatalf("expected total tokens 15, got %d", response.Usage.TotalTokens) + } + if len(response.Message.ToolCalls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(response.Message.ToolCalls)) + } + + call := response.Message.ToolCalls[0] + if call.ID != "call_1" || call.Name != "filesystem_edit" { + t.Fatalf("unexpected tool call: %+v", call) + } + if call.Arguments != `{"path":"main.go","search_string":"old","replace_string":"new"}` { + t.Fatalf("unexpected merged arguments: %q", call.Arguments) + } + + var chunks []string + for { + select { + case event := <-events: + chunks = append(chunks, event.Text) + default: + if strings.Join(chunks, "") != "Hello world" { + t.Fatalf("expected streamed chunks to form %q, got %q", "Hello world", strings.Join(chunks, "")) + } + return + } + } +} + +func TestProviderChatHTTPErrorResponses(t *testing.T) { + t.Setenv(DefaultAPIKeyEnv, "test-key") + + tests := []struct { + name string + status int + body string + expectErr string + }{ + { + name: "http 401 json error", + status: http.StatusUnauthorized, + body: `{"error":{"message":"invalid api key"}}`, + expectErr: "invalid api key", + }, + { + name: "http 500 empty body falls back to status", + status: http.StatusInternalServerError, + body: ``, + expectErr: "500 Internal Server Error", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.status) + if tt.body != "" { + _, _ = w.Write([]byte(tt.body)) + } + })) + defer server.Close() + + provider, err := New(resolvedConfig(server.URL, DefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + provider.client = server.Client() + + _, err = provider.Chat(context.Background(), domain.ChatRequest{ + Model: DefaultModel, + }, make(chan domain.StreamEvent, 1)) + if err == nil || !strings.Contains(err.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) + } + }) + } +} + +func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { + t.Parallel() + + provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + payload, err := provider.buildRequest(domain.ChatRequest{ + SystemPrompt: "system prompt", + Messages: []domain.Message{ + {Role: "user", Content: "hello"}, + { + Role: "assistant", + ToolCalls: []domain.ToolCall{ + { + ID: "call_1", + Name: "filesystem_edit", + Arguments: `{"path":"main.go"}`, + }, + }, + }, + {Role: "tool", ToolCallID: "call_1", Content: "tool finished"}, + }, + Tools: []domain.ToolSpec{ + { + Name: "filesystem_edit", + Description: "Edit file content", + Schema: map[string]any{ + "type": "object", + }, + }, + }, + }) + if err != nil { + t.Fatalf("buildRequest() error = %v", err) + } + + if payload.Model != DefaultModel { + t.Fatalf("expected default model %q, got %q", DefaultModel, payload.Model) + } + if !payload.Stream { + t.Fatalf("expected stream=true") + } + if payload.ToolChoice != "auto" { + t.Fatalf("expected tool choice auto, got %q", payload.ToolChoice) + } + if len(payload.Tools) != 1 || payload.Tools[0].Function.Name != "filesystem_edit" { + t.Fatalf("unexpected tool payload: %+v", payload.Tools) + } + if payload.Messages[0].Role != "system" || payload.Messages[0].Content != "system prompt" { + t.Fatalf("unexpected system message: %+v", payload.Messages[0]) + } + if !containsToolRoleMessage(payload.Messages, "call_1", "tool finished") { + t.Fatalf("expected tool role message, got %+v", payload.Messages) + } + if len(payload.Messages[2].ToolCalls) != 1 || payload.Messages[2].ToolCalls[0].Function.Name != "filesystem_edit" { + t.Fatalf("expected assistant tool call payload, got %+v", payload.Messages[2]) + } +} + +func TestParseErrorAndEmitTextDelta(t *testing.T) { + t.Parallel() + + provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + tests := []struct { + name string + status string + body string + expectErr string + }{ + { + name: "json error payload", + status: "400 Bad Request", + body: `{"error":{"message":"invalid request"}}`, + expectErr: "invalid request", + }, + { + name: "plain text fallback", + status: "502 Bad Gateway", + body: `gateway timeout`, + expectErr: "gateway timeout", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + resp := &http.Response{ + Status: tt.status, + Body: ioNopCloser(tt.body), + } + err := provider.parseError(resp) + if err == nil || !strings.Contains(err.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) + } + }) + } + + eventCh := make(chan domain.StreamEvent, 1) + if err := emitTextDelta(context.Background(), eventCh, "chunk"); err != nil { + t.Fatalf("emitTextDelta() error = %v", err) + } + if got := <-eventCh; got.Text != "chunk" || got.Type != domain.StreamEventTextDelta { + t.Fatalf("unexpected stream event: %+v", got) + } + + cancelledCtx, cancel := context.WithCancel(context.Background()) + cancel() + if err := emitTextDelta(cancelledCtx, make(chan domain.StreamEvent), "chunk"); err == nil { + t.Fatalf("expected cancellation error") + } +} + +func TestProviderConsumeStreamRejectsDirtyJSON(t *testing.T) { + t.Parallel() + + provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + _, err = provider.consumeStream(context.Background(), strings.NewReader("data: {not-json}\n\n"), make(chan domain.StreamEvent, 1)) + if err == nil || !strings.Contains(err.Error(), "decode stream chunk") { + t.Fatalf("expected dirty JSON decode error, got %v", err) + } +} + +func containsToolRoleMessage(messages []openAIMessage, toolCallID string, content string) bool { + for _, message := range messages { + if message.Role == "tool" && message.ToolCallID == toolCallID && message.Content == content { + return true + } + } + return false +} + +func writeSSEChunk(t *testing.T, w http.ResponseWriter, payload any) { + t.Helper() + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal SSE payload: %v", err) + } + if _, err := w.Write([]byte("data: " + string(data) + "\n\n")); err != nil { + t.Fatalf("write SSE payload: %v", err) + } + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } +} + +func ioNopCloser(body string) *readCloser { + return &readCloser{Reader: strings.NewReader(body)} +} + +type readCloser struct { + *strings.Reader +} + +func (r *readCloser) Close() error { + return nil +} + +func containsString(items []string, target string) bool { + for _, item := range items { + if item == target { + return true + } + } + return false +}