diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 00000000..5456fd79 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,8 @@ +coverage: + status: + project: + default: + target: 80% # 告诉裁判:整体覆盖率 80% 就给我亮绿灯 + patch: + default: + target: 80% # 告诉裁判:这次 PR 新增的代码覆盖率达到 80% 也给我亮绿灯 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 95cc5441..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,48 +0,0 @@ -# Repository Guidelines - -## Project Structure & Module Organization -- `cmd/tui/main.go` 是TUI模式的主要入口,负责初始化配置和启动终端用户界面。 -- `cmd/server/main.go` 是服务器模式的入口,用于验证服务组装,目前仅作为占位符存在。 -- `internal/tui/bootstrap/` 负责TUI启动前准备,包括工作区解析、配置初始化和程序装配。 -- `internal/tui/core/` 包含TUI的核心逻辑,实现了Bubble Tea ELM架构(Model-Update-View)。 -- `internal/tui/state/` 保存TUI纯状态结构,如聊天历史、窗口尺寸和运行时标记。 -- `internal/tui/services/` 提供TUI的本地服务适配层,负责组装后端 service/provider/repository/tools 并暴露统一接口。 -- `internal/server/domain/` 定义了核心领域模型和接口,遵循依赖倒置原则。 -- `internal/server/service/` 实现了应用服务层,负责编排业务流程。 -- `internal/server/infra/provider/` 包含具体的外部服务提供方实现,如ModelScopeProvider。 -- `internal/server/infra/repository/` 包含数据访问层的实现,包括文件存储和内存存储。 -- `configs/` 目录包含应用配置相关的代码和默认人设文件。 -- `docs/` 目录包含项目文档,如架构设计说明和安全指南。 -- `data/` 目录用于存储运行时数据,如长期记忆文件。 - -## Build, Test, and Development Commands -- `go build ./...` 编译所有Go包;在推送前修复语法或依赖问题。 -- `go test ./...` 执行所有Go测试并报告覆盖率;在修改`internal/`目录下的逻辑后重新运行。 -- `go run ./cmd/tui` 从仓库根目录运行TUI模式进行手动实验。 -- `go run ./cmd/server` 运行服务器模式以验证服务组装。 -- `gofmt -w ` (或 `go fmt ./...`) 格式化Go文件以保持缩进/空格一致,在暂存前执行。 - -## Coding Style & Naming Conventions -- 遵循惯用的Go语言风格:使用短小的全小写包名 (`tui`, `server`, `domain`, `service`), 导出标识符使用PascalCase,未导出标识符使用camelCase。 -- 使用制表符进行缩进(Go默认),尽可能将行长度控制在~120字符以内。 -- 在提交前应用`gofmt`/`goimports`;避免手动格式化。 -- 为导出符号添加完整的句子注释,以满足Go工具链和未来读者的需求。 -- 拒绝生成硬编码实现;常量、路径、密钥、URL、阈值和环境差异项应优先通过配置、参数、环境变量或具名常量注入。 - -## Testing Guidelines -- 将单元测试命名为 `*_test.go` 并且函数名为 `TestXxx`;当添加新的测试套件时模仿现有的包结构。 -- 当前行内没有使用标准库以外的测试框架。 -- 在任何触及接口边界的更改后运行 `go test ./...`,特别是在 `provider` 提供方或 `service` 层内。 - -## Commit & Pull Request Guidelines -- 保持提交小而具有描述性;优先使用约定的前缀,例如 `feat:`、`fix:`、`docs:` 或 `refactor:`,后跟简要摘要(例如 `feat: add model switch command`)。 -- 每次提交新功能、修复行为问题或调整配置/命令/接口时,都必须检查是否有相关文档需要同步更新;如有影响,应在同一变更中一并更新 `README.md`、`docs/`、配置示例或其他相关说明,避免代码与文档脱节。 -- 包含简洁的PR描述,列出关联的问题(如果有),并注明已运行的测试命令。 -- 在请求审查之前,运行 `git status`,确保已应用gofmt,并仔细检查是否有秘密值泄露到被跟踪的文件中(例如 `.env`)。 - -## Security & Configuration Tips -- 将 `.env` 或类似文件视为本地配置;不要提交密钥。如果需要共享值,请在README或本指南中记录它们。 -- 将API密钥保留在源文件之外;优先在运行时通过环境变量传递。 -- `config.yaml` 中的 `ai.api_key` 保存的是 API Key 的环境变量名;为空时默认回退到 `AI_API_KEY`。 -- `config.yaml` 已在 `.gitignore` 中忽略,不应提交真实密钥。 -- `data/` 已在 `.gitignore` 中忽略,本地记忆不会默认入库。 diff --git a/README.md b/README.md index 51867cb2..037ee9e3 100644 --- a/README.md +++ b/README.md @@ -1,210 +1,61 @@ # NeoCode +NeoCode 是一个基于 Go 和 Bubble Tea 构建的本地 Coding Agent MVP。它把 Provider 防腐层、工具调用闭环、本地会话持久化和沉浸式瀑布流 TUI 组合在一起,用于日常代码理解、修改和终端内协作。 -一个基于 Go 的终端对话工具,当前使用根目录 `config.yaml` 作为唯一业务配置文件,并使用结构化 code memory 做记忆召回。 +## 功能特性 +- 基于防腐层的 Provider 架构,当前支持 OpenAI 兼容流式接口,并为 Anthropic、Gemini 预留了扩展位置。 +- ReAct 风格的 runtime 主循环,能够读取模型输出、执行工具调用、回灌结果并继续推理。 +- 瀑布流终端界面,支持流式 transcript、内联工具事件、Slash Command 和会话侧边栏。 +- 本地 YAML 配置、`.env` 加载与并发安全的热更新能力。 +- 本地 JSON 会话持久化,不依赖远端服务即可恢复历史上下文。 +- 文件系统高级工具:读取、写入、搜索、Glob 探测和精准替换。 -现在支持: +## 环境要求 +- Go 1.21 及以上 +- 至少一个可用的模型 Provider API Key,例如 `OPENAI_API_KEY` -1. TUI 对话与流式输出 -2. `/switch` 切换聊天模型 -3. `/provider` 切换模型提供商 -4. 结构化长期记忆检索与写回 -5. session memory 与短期上下文保留 -6. 人设文件注入 -7. 启动时校验环境变量 API Key -8. `/memory`、`/clear-memory confirm`、`/clear-context` -9. 工作区目录感知(`--workspace` / `NEOCODE_WORKSPACE` / 当前目录) - -## 文档导航 - -如果你想快速理解项目设计、接口约定、安全机制或团队协作方式,可以优先阅读以下文档: - -- [整体架构设计说明](./docs/structure.md):快速了解项目分层思路、目录结构和模块职责。 -- [架构详细说明文档](./docs/detailed_architecture_guide.md):深入理解系统目标、核心架构、数据流和后续演进方向。 -- [API 契约书与开发指南](./docs/API_Contract_Guide.md):了解 `api/proto/chat.proto` 的契约设计和前后端协作边界。 -- [GitHub Actions + Codecov 使用指南](./docs/github-actions-codecov-guide.md):面向新成员的 CI、测试覆盖率与 PR 检查上手文档。 -- [安全拦截器模块文档](./docs/Security_Interceptor.md):说明工具执行前的安全校验模型与接口规范。 -- [安全拦截器测试手册](./docs/Security_Interceptor_Test_Manual.md):整理安全拦截相关测试点与手工验证步骤。 -- [Repository Guidelines](./docs/AGENTS.md):补充仓库协作约定、目录组织与开发命令说明。 - -## 配置方式 - -只需要维护根目录下的 `config.yaml`,并在系统环境变量中设置 API Key。 - -- 可以先参考 `config.example.yaml` -- 首次启动时如果 `config.yaml` 不存在,程序会自动创建默认配置 -- API Key 配置方法见下方 `API Key 配置` -- `ai.api_key` 填写的是环境变量名;留空时会回退到 `AI_API_KEY` -- 如果 Key 校验失败,可使用 `/apikey ` 或 `/provider ` 调整配置 -- 如果网络异常导致无法确认 Key 是否有效,可使用 `/retry`、`/continue`、`/apikey `、`/provider `、`/switch ` 或 `/exit` -- 支持的 provider:`modelscope`、`deepseek`、`openll`、`siliconflow`、`豆包大模型`、`openai` -- 切换 provider 时会自动切到该厂商默认模型,也可以继续使用 `/switch ` 自定义 - -## API Key 配置 - -程序会从 `config.yaml` 的 `ai.api_key` 指定的系统环境变量中读取真实 API Key;如果该字段为空,则默认读取 `AI_API_KEY`。`config.yaml` 不存储真实密钥。 - -例如: - -```yaml -ai: - provider: "openll" - api_key: "MY_TEAM_API_KEY" - model: "gpt-5.4" -``` - -这表示程序会读取系统环境变量 `MY_TEAM_API_KEY`。 - -Windows 永久生效: - -```powershell -setx AI_API_KEY "your-api-key" -``` - -设置后请关闭并重新打开终端,再验证是否生效: - -```powershell -echo $env:AI_API_KEY -``` - -Windows 当前终端临时生效: - -```powershell -$env:AI_API_KEY="your-api-key" -``` - -CMD 当前终端临时生效: - -```cmd -set AI_API_KEY=your-api-key -``` - -配置完成后启动项目: +## 快速开始 +1. 克隆仓库。 +2. 准备 Provider 的 API Key,可以通过环境变量或 NeoCode 托管的 `.env` 文件提供。 +3. 运行应用: ```bash -go run ./cmd/tui +go run ./cmd/neocode ``` -也可以显式指定工作区目录(工具读写和工作记忆都会基于该目录): +首次启动时,NeoCode 会在当前用户主目录下创建自己的托管目录、默认配置和会话存储结构。 -```bash -go run ./cmd/tui --workspace ./ -``` +## Slash Command +- `/set url `:更新当前选中 Provider 的 Base URL。 +- `/set key `:将当前 Provider 的密钥写入托管 `.env` 并立即重新加载。 +- `/model`:打开交互式模型选择列表。 -或者通过环境变量指定: +## 开发与验证 +在提交 Pull Request 前建议至少执行以下命令: ```bash -set NEOCODE_WORKSPACE=F:\\Qiniu\\test1 -go run ./cmd/tui -``` - -工作区解析优先级:`--workspace` > `NEOCODE_WORKSPACE` > 启动时当前目录。 - -示例: - -```yaml -app: - name: "NeoCode" - version: "1.0.0" - -ai: - provider: "openll" - api_key: "AI_API_KEY" - model: "gpt-5.4" - -memory: - top_k: 5 - min_match_score: 2.2 - max_prompt_chars: 1800 - max_items: 1000 - storage_path: "./data/memory_rules.json" - persist_types: - - "user_preference" - - "project_rule" - - "code_fact" - - "fix_recipe" - -history: - short_term_turns: 6 - max_tool_context_messages: 3 - max_tool_context_output_size: 4000 - persist_session_state: true - workspace_state_dir: "./data/workspaces" - resume_last_session: true - -persona: - file_path: "./persona.txt" -``` - -说明: - -- `ai.api_key`:API Key 对应的环境变量名;为空时回退到 `AI_API_KEY` -- `ai.provider`:当前模型提供商,支持 `modelscope`、`deepseek`、`openll`、`siliconflow`、`豆包大模型`、`openai` -- `ai.model`:当前 provider 使用的模型名;执行 `/provider ` 时会自动切到该厂商默认模型,也可通过 `/switch ` 覆盖 -- `memory.storage_path`:长期结构化记忆文件 -- `memory.persist_types`:允许持久化的结构化记忆类型 -- `memory.min_match_score`:最低召回分数 -- `memory.max_prompt_chars`:记忆注入 prompt 的总字符上限 -- `history.short_term_turns`:保留最近多少轮上下文 -- `history.persist_session_state`:是否按 workspace 持久化当前工作现场 -- `history.workspace_state_dir`:workspace 会话状态文件保存目录 -- `history.resume_last_session`:启动时是否展示恢复摘要 -- `persona.file_path`:启动时加载的人设文件 - -## Memory 设计 - -当前 memory 使用纯结构化规则召回,不使用 embedding 或向量相似度。系统会将长期结构化记忆写入 `memory.storage_path`,并在当前进程内维护 session memory。主要包括: - -- `user_preference`:用户长期偏好 -- `project_rule`:项目级约定、目录结构、常用命令、配置规则 -- `code_fact`:明确的代码事实、文件职责、模块位置 -- `fix_recipe`:排障经验、常见报错与修复方式 -- `session_memory`:当前会话里仍有价值的临时 coding 信息 - -召回顺序会优先考虑长期记忆中的用户偏好、项目规则、代码事实、修复经验,再补充 session memory。 - -此外,working memory 现已支持按 workspace 保存会话快照。程序会记录当前目标、最近完成动作、下一步、最近文件和最近几轮对话,并在下次进入同一工作区时恢复这份现场。 - -## 运行 - -```bash -go run ./cmd/tui -``` - -如果只想验证服务组装: - -```bash -go run ./cmd/server -``` - -## 可用命令 - -- `/provider `:切换当前模型提供商 -- `/apikey `:切换当前读取的 API Key 环境变量名并立即校验 -- `/switch `:切换当前聊天模型 -- `/memory`:查看长期记忆和 session memory 状态,以及各类型统计 -- `/pwd` 或 `/workspace`:查看当前工作区目录 -- `/clear-memory confirm`:确认后清空长期结构化记忆 -- `/clear-context`:清空当前短期上下文和 session memory -- `/help`:查看帮助 -- `/exit`:退出程序 - -## 相关文件 - -- `config.yaml`:主配置文件 -- `config.example.yaml`:配置模板 -- `data/memory_rules.json`:长期结构化记忆文件 -- `data/workspaces//session_state.json`:按工作区保存的当前工作现场 -- `persona.txt`:人设内容 - -## 安全与本地文件 - -- `config.yaml` 中的 `ai.api_key` 仅保存环境变量名,不写入真实密钥 -- `ai.api_key` 为空时默认读取系统环境变量 `AI_API_KEY` -- `config.yaml` 已在 `.gitignore` 中忽略,不应提交真实密钥 -- `data/` 已在 `.gitignore` 中忽略,本地记忆不会默认入库 -- `.env` 不再是主配置来源,如保留仅用于个人兼容场景 - -## 测试 - -- 运行测试:`go test ./...` -- 代码格式化:`go fmt ./...` +gofmt -w ./cmd ./internal +go test ./... +``` + +## 架构概览 +- `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 持久化和终端交互体验。当前版本正在继续向“高质量开源项目”标准收敛,重点补强文档、测试覆盖率和工具能力。 diff --git a/agents.md b/agents.md new file mode 100644 index 00000000..450eb129 --- /dev/null +++ b/agents.md @@ -0,0 +1,43 @@ +# 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 内容。 +## 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 的改动,优先补充或更新测试。 +## 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/api/proto/chat.pb.go b/api/proto/chat.pb.go deleted file mode 100644 index 07d28bf9..00000000 --- a/api/proto/chat.pb.go +++ /dev/null @@ -1,404 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc v7.34.1 -// source: chat.proto - -package proto - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// Status 用于统一后端向前端反馈的执行状态(错误处理机制) -// 建议方案:在响应体中预留 status 字段,包含错误码和错误信息。 -type Status struct { - state protoimpl.MessageState `protogen:"open.v1"` - // 状态码:0 代表成功; - // 常见错误码建议:401(身份验证失败), 429(请求过于频繁), 500(模型内部错误) - Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"` - // 错误详细信息:当 code 非 0 时,这里存放给人看的提示文字 - Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Status) Reset() { - *x = Status{} - mi := &file_chat_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Status) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Status) ProtoMessage() {} - -func (x *Status) ProtoReflect() protoreflect.Message { - mi := &file_chat_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Status.ProtoReflect.Descriptor instead. -func (*Status) Descriptor() ([]byte, []int) { - return file_chat_proto_rawDescGZIP(), []int{0} -} - -func (x *Status) GetCode() int32 { - if x != nil { - return x.Code - } - return 0 -} - -func (x *Status) GetMessage() string { - if x != nil { - return x.Message - } - return "" -} - -// ResponseMetadata 用于存放非对话正文的辅助数据(流式控制与元数据) -// 建议方案:暂不引入复杂的“心跳包”,但在首个包或末尾包中预留 metadata 字段用于传递模型信息。 -type ResponseMetadata struct { - state protoimpl.MessageState `protogen:"open.v1"` - // 当前实际产生响应的模型名称(用于在 TUI 状态栏同步显示,应对 /switch 场景) - ModelName string `protobuf:"bytes,1,opt,name=model_name,json=modelName,proto3" json:"model_name,omitempty"` - // 本次对话消耗的 Token 统计(可选,用于后期性能分析) - UsageTokens int32 `protobuf:"varint,2,opt,name=usage_tokens,json=usageTokens,proto3" json:"usage_tokens,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ResponseMetadata) Reset() { - *x = ResponseMetadata{} - mi := &file_chat_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ResponseMetadata) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ResponseMetadata) ProtoMessage() {} - -func (x *ResponseMetadata) ProtoReflect() protoreflect.Message { - mi := &file_chat_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ResponseMetadata.ProtoReflect.Descriptor instead. -func (*ResponseMetadata) Descriptor() ([]byte, []int) { - return file_chat_proto_rawDescGZIP(), []int{1} -} - -func (x *ResponseMetadata) GetModelName() string { - if x != nil { - return x.ModelName - } - return "" -} - -func (x *ResponseMetadata) GetUsageTokens() int32 { - if x != nil { - return x.UsageTokens - } - return 0 -} - -// Message 定义单条对话的内容 -type Message struct { - state protoimpl.MessageState `protogen:"open.v1"` - // 角色:只能是 "user", "assistant", "system" - Role string `protobuf:"bytes,1,opt,name=role,proto3" json:"role,omitempty"` - // 对话正文 - Content string `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Message) Reset() { - *x = Message{} - mi := &file_chat_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Message) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Message) ProtoMessage() {} - -func (x *Message) ProtoReflect() protoreflect.Message { - mi := &file_chat_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Message.ProtoReflect.Descriptor instead. -func (*Message) Descriptor() ([]byte, []int) { - return file_chat_proto_rawDescGZIP(), []int{2} -} - -func (x *Message) GetRole() string { - if x != nil { - return x.Role - } - return "" -} - -func (x *Message) GetContent() string { - if x != nil { - return x.Content - } - return "" -} - -// ChatRequest 定义前端发起的对话请求契约 -// 字段命名规范:遵循 Protobuf 全球标准 (snake_case) -type ChatRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // 请求使用的模型 ID - Model string `protobuf:"bytes,1,opt,name=model,proto3" json:"model,omitempty"` - // 历史对话列表,包含当前用户发送的最新的消息 - Messages []*Message `protobuf:"bytes,2,rep,name=messages,proto3" json:"messages,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ChatRequest) Reset() { - *x = ChatRequest{} - mi := &file_chat_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ChatRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ChatRequest) ProtoMessage() {} - -func (x *ChatRequest) ProtoReflect() protoreflect.Message { - mi := &file_chat_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ChatRequest.ProtoReflect.Descriptor instead. -func (*ChatRequest) Descriptor() ([]byte, []int) { - return file_chat_proto_rawDescGZIP(), []int{3} -} - -func (x *ChatRequest) GetModel() string { - if x != nil { - return x.Model - } - return "" -} - -func (x *ChatRequest) GetMessages() []*Message { - if x != nil { - return x.Messages - } - return nil -} - -// ChatResponse 定义后端回传的流式响应契约 -type ChatResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // 当前流式传输的文本片段(如果是最后一个包,此字段可能为空) - Content string `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` - // 标识回复是否已结束 - IsFinished bool `protobuf:"varint,2,opt,name=is_finished,json=isFinished,proto3" json:"is_finished,omitempty"` - // 错误状态:前端应优先检查 status.code 是否为 0 - Status *Status `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` - // 附加元数据:建议只在第一包或最后一包中携带,用于信息同步 - Meta *ResponseMetadata `protobuf:"bytes,4,opt,name=meta,proto3" json:"meta,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ChatResponse) Reset() { - *x = ChatResponse{} - mi := &file_chat_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ChatResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ChatResponse) ProtoMessage() {} - -func (x *ChatResponse) ProtoReflect() protoreflect.Message { - mi := &file_chat_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ChatResponse.ProtoReflect.Descriptor instead. -func (*ChatResponse) Descriptor() ([]byte, []int) { - return file_chat_proto_rawDescGZIP(), []int{4} -} - -func (x *ChatResponse) GetContent() string { - if x != nil { - return x.Content - } - return "" -} - -func (x *ChatResponse) GetIsFinished() bool { - if x != nil { - return x.IsFinished - } - return false -} - -func (x *ChatResponse) GetStatus() *Status { - if x != nil { - return x.Status - } - return nil -} - -func (x *ChatResponse) GetMeta() *ResponseMetadata { - if x != nil { - return x.Meta - } - return nil -} - -var File_chat_proto protoreflect.FileDescriptor - -const file_chat_proto_rawDesc = "" + - "\n" + - "\n" + - "chat.proto\x12\achat.v1\"6\n" + - "\x06Status\x12\x12\n" + - "\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\"T\n" + - "\x10ResponseMetadata\x12\x1d\n" + - "\n" + - "model_name\x18\x01 \x01(\tR\tmodelName\x12!\n" + - "\fusage_tokens\x18\x02 \x01(\x05R\vusageTokens\"7\n" + - "\aMessage\x12\x12\n" + - "\x04role\x18\x01 \x01(\tR\x04role\x12\x18\n" + - "\acontent\x18\x02 \x01(\tR\acontent\"Q\n" + - "\vChatRequest\x12\x14\n" + - "\x05model\x18\x01 \x01(\tR\x05model\x12,\n" + - "\bmessages\x18\x02 \x03(\v2\x10.chat.v1.MessageR\bmessages\"\xa1\x01\n" + - "\fChatResponse\x12\x18\n" + - "\acontent\x18\x01 \x01(\tR\acontent\x12\x1f\n" + - "\vis_finished\x18\x02 \x01(\bR\n" + - "isFinished\x12'\n" + - "\x06status\x18\x03 \x01(\v2\x0f.chat.v1.StatusR\x06status\x12-\n" + - "\x04meta\x18\x04 \x01(\v2\x19.chat.v1.ResponseMetadataR\x04meta2D\n" + - "\vChatService\x125\n" + - "\x04Send\x12\x14.chat.v1.ChatRequest\x1a\x15.chat.v1.ChatResponse0\x01B\x17Z\x15go-llm-demo/api/protob\x06proto3" - -var ( - file_chat_proto_rawDescOnce sync.Once - file_chat_proto_rawDescData []byte -) - -func file_chat_proto_rawDescGZIP() []byte { - file_chat_proto_rawDescOnce.Do(func() { - file_chat_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_chat_proto_rawDesc), len(file_chat_proto_rawDesc))) - }) - return file_chat_proto_rawDescData -} - -var file_chat_proto_msgTypes = make([]protoimpl.MessageInfo, 5) -var file_chat_proto_goTypes = []any{ - (*Status)(nil), // 0: chat.v1.Status - (*ResponseMetadata)(nil), // 1: chat.v1.ResponseMetadata - (*Message)(nil), // 2: chat.v1.Message - (*ChatRequest)(nil), // 3: chat.v1.ChatRequest - (*ChatResponse)(nil), // 4: chat.v1.ChatResponse -} -var file_chat_proto_depIdxs = []int32{ - 2, // 0: chat.v1.ChatRequest.messages:type_name -> chat.v1.Message - 0, // 1: chat.v1.ChatResponse.status:type_name -> chat.v1.Status - 1, // 2: chat.v1.ChatResponse.meta:type_name -> chat.v1.ResponseMetadata - 3, // 3: chat.v1.ChatService.Send:input_type -> chat.v1.ChatRequest - 4, // 4: chat.v1.ChatService.Send:output_type -> chat.v1.ChatResponse - 4, // [4:5] is the sub-list for method output_type - 3, // [3:4] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name -} - -func init() { file_chat_proto_init() } -func file_chat_proto_init() { - if File_chat_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_chat_proto_rawDesc), len(file_chat_proto_rawDesc)), - NumEnums: 0, - NumMessages: 5, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_chat_proto_goTypes, - DependencyIndexes: file_chat_proto_depIdxs, - MessageInfos: file_chat_proto_msgTypes, - }.Build() - File_chat_proto = out.File - file_chat_proto_goTypes = nil - file_chat_proto_depIdxs = nil -} diff --git a/api/proto/chat.proto b/api/proto/chat.proto deleted file mode 100644 index c09e9058..00000000 --- a/api/proto/chat.proto +++ /dev/null @@ -1,71 +0,0 @@ -syntax = "proto3"; - -package chat.v1; - -// Go 专用选项:定义生成的 .pb.go 文件应该存放在哪个路径以及包名是什么 -option go_package = "go-llm-demo/api/proto"; - -// --- 辅助结构定义 --- - -// Status 用于统一后端向前端反馈的执行状态(错误处理机制) -// 建议方案:在响应体中预留 status 字段,包含错误码和错误信息。 -message Status { - // 状态码:0 代表成功; - // 常见错误码建议:401(身份验证失败), 429(请求过于频繁), 500(模型内部错误) - int32 code = 1; - - // 错误详细信息:当 code 非 0 时,这里存放给人看的提示文字 - string message = 2; -} - -// ResponseMetadata 用于存放非对话正文的辅助数据(流式控制与元数据) -// 建议方案:暂不引入复杂的“心跳包”,但在首个包或末尾包中预留 metadata 字段用于传递模型信息。 -message ResponseMetadata { - // 当前实际产生响应的模型名称(用于在 TUI 状态栏同步显示,应对 /switch 场景) - string model_name = 1; - - // 本次对话消耗的 Token 统计(可选,用于后期性能分析) - int32 usage_tokens = 2; -} - -// --- 核心业务结构定义 --- - -// Message 定义单条对话的内容 -message Message { - // 角色:只能是 "user", "assistant", "system" - string role = 1; - - // 对话正文 - string content = 2; -} - -// ChatRequest 定义前端发起的对话请求契约 -// 字段命名规范:遵循 Protobuf 全球标准 (snake_case) -message ChatRequest { - // 请求使用的模型 ID - string model = 1; - - // 历史对话列表,包含当前用户发送的最新的消息 - repeated Message messages = 2; -} - -// ChatResponse 定义后端回传的流式响应契约 -message ChatResponse { - // 当前流式传输的文本片段(如果是最后一个包,此字段可能为空) - string content = 1; - - // 标识回复是否已结束 - bool is_finished = 2; - - // 错误状态:前端应优先检查 status.code 是否为 0 - Status status = 3; - - // 附加元数据:建议只在第一包或最后一包中携带,用于信息同步 - ResponseMetadata meta = 4; -} - -// 服务接口定义(为未来平滑迁移到 gRPC 网络版预留) -service ChatService { - // 发送对话请求,并接收一个流式的响应序列 - rpc Send(ChatRequest) returns (stream ChatResponse); -} diff --git a/api/proto/gitkeep b/api/proto/gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/cmd/neocode/main.go b/cmd/neocode/main.go new file mode 100644 index 00000000..7f105e46 --- /dev/null +++ b/cmd/neocode/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/dust/neo-code/internal/app" +) + +func main() { + program, err := app.NewProgram(context.Background()) + if err != nil { + fmt.Fprintf(os.Stderr, "neocode: %v\n", err) + os.Exit(1) + } + + if _, err := program.Run(); err != nil { + fmt.Fprintf(os.Stderr, "neocode: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go deleted file mode 100644 index 18290079..00000000 --- a/cmd/server/main.go +++ /dev/null @@ -1,74 +0,0 @@ -package main - -import ( - "fmt" - "path/filepath" - - "go-llm-demo/configs" - "go-llm-demo/internal/server/infra/provider" - "go-llm-demo/internal/server/infra/repository" - "go-llm-demo/internal/server/infra/tools" - "go-llm-demo/internal/server/service" -) - -func main() { - workspaceRoot, err := tools.ResolveWorkspaceRoot("") - if err != nil { - fmt.Printf("解析工作区失败:%v\n", err) - return - } - if err := tools.SetWorkspaceRoot(workspaceRoot); err != nil { - fmt.Printf("设置工作区失败:%v\n", err) - return - } - if err := initializeSecurity(filepath.Join(workspaceRoot, "configs", "security")); err != nil { - fmt.Printf("初始化安全策略失败:%v\n", err) - return - } - - if err := configs.LoadAppConfig("config.yaml"); err != nil { - fmt.Printf("加载配置失败:%v\n", err) - return - } - - cfg := configs.GlobalAppConfig - memoryRepo := repository.NewFileMemoryStore(cfg.Memory.StoragePath, cfg.Memory.MaxItems) - sessionRepo := repository.NewSessionMemoryStore(cfg.Memory.MaxItems) - workingRepo := repository.NewWorkingMemoryStore() - memorySvc := service.NewMemoryService( - memoryRepo, - sessionRepo, - cfg.Memory.TopK, - cfg.Memory.MinMatchScore, - cfg.Memory.MaxPromptChars, - cfg.Memory.StoragePath, - cfg.Memory.PersistTypes, - ) - workingSvc := service.NewWorkingMemoryService(workingRepo, cfg.History.ShortTermTurns, tools.GetWorkspaceRoot()) - - roleRepo := repository.NewFileRoleStore("./data/roles.json") - roleSvc := service.NewRoleService(roleRepo, cfg.Persona.FilePath) - - todoRepo := repository.NewInMemoryTodoRepository() - todoSvc := service.NewTodoService(todoRepo) - - chatProvider, err := provider.NewChatProvider(cfg.AI.Model) - if err != nil { - fmt.Printf("初始化 ChatProvider 失败:%v\n", err) - return - } - - chatGateway := service.NewChatService(memorySvc, workingSvc, todoSvc, roleSvc, chatProvider) - fmt.Printf("服务器已初始化并加载服务: %+v\n", chatGateway) - fmt.Println("注意:这是一个占位符。实际的服务器实现将在此处进行.") -} - -func initializeSecurity(configDir string) error { - securityRepo := repository.NewSecurityConfigRepository() - securitySvc := service.NewSecurityService(securityRepo) - if err := securitySvc.Initialize(configDir); err != nil { - return err - } - tools.SetSecurityChecker(securitySvc) - return nil -} diff --git a/cmd/tui/main.go b/cmd/tui/main.go deleted file mode 100644 index d23e4e07..00000000 --- a/cmd/tui/main.go +++ /dev/null @@ -1,170 +0,0 @@ -package main - -import ( - "bufio" - "context" - "flag" - "fmt" - "io" - "os" - "strings" - - "go-llm-demo/configs" - "go-llm-demo/internal/tui/bootstrap" - - tea "github.com/charmbracelet/bubbletea" -) - -const defaultConfigPath = "config.yaml" - -var buildRunDeps = defaultRunDeps - -type programRunner interface { - Run() (tea.Model, error) -} - -type runDeps struct { - stdin io.Reader - stdout io.Writer - stderr io.Writer - setUTF8Mode func() - prepareWorkspace func(string) (string, error) - ensureAPIKeyInteractive func(context.Context, *bufio.Scanner, string) (bool, error) - loadAppConfig func(string) error - loadPersonaPrompt func(string) (string, string, error) - newProgram func(string, int, string, string) (programRunner, error) -} - -func defaultRunDeps(stdin io.Reader, stdout, stderr io.Writer) runDeps { - return runDeps{ - stdin: stdin, - stdout: stdout, - stderr: stderr, - setUTF8Mode: setUTF8Mode, - prepareWorkspace: bootstrap.PrepareWorkspace, - ensureAPIKeyInteractive: bootstrap.EnsureAPIKeyInteractive, - loadAppConfig: configs.LoadAppConfig, - loadPersonaPrompt: configs.LoadPersonaPrompt, - newProgram: func(persona string, historyTurns int, configPath, workspaceRoot string) (programRunner, error) { - return bootstrap.NewProgram(persona, historyTurns, configPath, workspaceRoot) - }, - } -} - -func main() { - workspaceFlag, err := parseWorkspaceFlag(os.Args[1:], os.Stderr) - if err != nil { - os.Exit(1) - } - _ = loadDotEnv(".env") - - if err := run(workspaceFlag, os.Stdin, os.Stdout, os.Stderr); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func parseWorkspaceFlag(args []string, stderr io.Writer) (string, error) { - fs := flag.NewFlagSet("tui", flag.ContinueOnError) - fs.SetOutput(stderr) - - workspaceFlag := fs.String("workspace", "", "指定工作区根目录") - if err := fs.Parse(args); err != nil { - return "", err - } - return *workspaceFlag, nil -} - -func run(workspaceFlag string, stdin io.Reader, stdout, stderr io.Writer) error { - return runWithDeps(workspaceFlag, buildRunDeps(stdin, stdout, stderr)) -} - -func runWithDeps(workspaceFlag string, deps runDeps) error { - if deps.setUTF8Mode != nil { - deps.setUTF8Mode() - } - - workspaceRoot, err := deps.prepareWorkspace(workspaceFlag) - if err != nil { - return fmt.Errorf("工作区初始化失败: %w", err) - } - - scanner := bufio.NewScanner(deps.stdin) - ready, err := deps.ensureAPIKeyInteractive(context.Background(), scanner, defaultConfigPath) - if err != nil { - return fmt.Errorf("初始化配置失败: %w", err) - } - if !ready { - fmt.Fprintln(deps.stdout, "已退出 NeoCode") - return nil - } - - if err := deps.loadAppConfig(defaultConfigPath); err != nil { - return fmt.Errorf("加载配置失败: %w", err) - } - - persona, personaPath, err := deps.loadPersonaPrompt(configs.GlobalAppConfig.Persona.FilePath) - if err != nil { - fmt.Fprintf(deps.stderr, "警告: 人设加载失败: %v\n", err) - } else if personaPath != "" && strings.TrimSpace(configs.GlobalAppConfig.Persona.FilePath) != personaPath { - fmt.Fprintf(deps.stderr, "提示: 人设已从 %s 回退加载\n", personaPath) - } - - historyTurns := configs.GlobalAppConfig.History.ShortTermTurns - p, err := deps.newProgram(persona, historyTurns, defaultConfigPath, workspaceRoot) - if err != nil { - return fmt.Errorf("初始化失败: %w", err) - } - if _, err := p.Run(); err != nil { - return fmt.Errorf("运行失败: %w", err) - } - - return nil -} - -func loadDotEnv(path string) error { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - - lines := strings.Split(string(data), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - key, value, found := strings.Cut(line, "=") - if !found { - continue - } - - key = strings.TrimSpace(key) - value = strings.TrimSpace(value) - if key == "" || os.Getenv(key) != "" { - continue - } - - value = strings.Trim(value, `"'`) - os.Setenv(key, value) - } - - return nil -} - -func loadPersonaPrompt(path string) string { - if strings.TrimSpace(path) == "" { - return "" - } - - data, err := os.ReadFile(path) - if err != nil { - return "" - } - - return strings.TrimSpace(string(data)) -} diff --git a/cmd/tui/main_test.go b/cmd/tui/main_test.go deleted file mode 100644 index 368663bf..00000000 --- a/cmd/tui/main_test.go +++ /dev/null @@ -1,419 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "context" - "errors" - "io" - "os" - "path/filepath" - "strings" - "testing" - - "go-llm-demo/configs" - - tea "github.com/charmbracelet/bubbletea" -) - -type fakeProgram struct { - runErr error - called bool -} - -func (p *fakeProgram) Run() (tea.Model, error) { - p.called = true - return nil, p.runErr -} - -func TestLoadDotEnvSetsMissingVarsOnly(t *testing.T) { - t.Setenv("EXISTING_KEY", "keep-me") - t.Setenv("NEW_KEY", "") - - dir := t.TempDir() - path := filepath.Join(dir, ".env") - content := "EXISTING_KEY=override\nNEW_KEY= new-value \n# comment\nINVALID_LINE\n" - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatalf("write env file: %v", err) - } - - if err := loadDotEnv(path); err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got := os.Getenv("EXISTING_KEY"); got != "keep-me" { - t.Fatalf("expected existing env var to be preserved, got %q", got) - } - if got := os.Getenv("NEW_KEY"); got != "new-value" { - t.Fatalf("expected new env var to load, got %q", got) - } -} - -func TestLoadDotEnvIgnoresMissingFile(t *testing.T) { - missing := filepath.Join(t.TempDir(), "missing.env") - if err := loadDotEnv(missing); err != nil { - t.Fatalf("expected missing file to be ignored, got %v", err) - } -} - -func TestLoadDotEnvReturnsNonENOENTError(t *testing.T) { - if err := loadDotEnv(t.TempDir()); err == nil { - t.Fatal("expected non-ENOENT read error") - } -} - -func TestLoadDotEnvTrimsQuotedValuesAndSkipsEmptyKeys(t *testing.T) { - t.Setenv("QUOTED_KEY", "") - - dir := t.TempDir() - path := filepath.Join(dir, ".env") - content := "QUOTED_KEY=' spaced value '\n =ignored\n" - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatalf("write env file: %v", err) - } - - if err := loadDotEnv(path); err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got := os.Getenv("QUOTED_KEY"); got != " spaced value " { - t.Fatalf("expected quoted value to be trimmed, got %q", got) - } -} - -func TestLoadPersonaPromptReturnsTrimmedContent(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "persona.txt") - if err := os.WriteFile(path, []byte("\n hello persona \n"), 0o644); err != nil { - t.Fatalf("write persona file: %v", err) - } - - if got := loadPersonaPrompt(path); got != "hello persona" { - t.Fatalf("expected trimmed persona prompt, got %q", got) - } -} - -func TestLoadPersonaPromptReturnsEmptyForMissingFile(t *testing.T) { - missing := filepath.Join(t.TempDir(), "missing.txt") - if got := loadPersonaPrompt(missing); got != "" { - t.Fatalf("expected empty string for missing file, got %q", got) - } -} - -func TestLoadPersonaPromptReturnsEmptyForBlankPath(t *testing.T) { - if got := loadPersonaPrompt(" "); got != "" { - t.Fatalf("expected empty string for blank path, got %q", got) - } -} - -func TestParseWorkspaceFlagParsesWorkspaceValue(t *testing.T) { - stderr := &bytes.Buffer{} - - got, err := parseWorkspaceFlag([]string{"-workspace", "D:/neo-code/workspace"}, stderr) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got != "D:/neo-code/workspace" { - t.Fatalf("unexpected workspace flag value %q", got) - } -} - -func TestDefaultRunDepsWiresStandardStreamsAndFunctions(t *testing.T) { - deps := defaultRunDeps(strings.NewReader("in"), &bytes.Buffer{}, &bytes.Buffer{}) - - if deps.stdin == nil || deps.stdout == nil || deps.stderr == nil { - t.Fatal("expected stdio to be preserved in deps") - } - if deps.setUTF8Mode == nil || deps.prepareWorkspace == nil || deps.ensureAPIKeyInteractive == nil || deps.loadAppConfig == nil || deps.loadPersonaPrompt == nil || deps.newProgram == nil { - t.Fatal("expected default dependencies to be populated") - } -} - -func TestRunUsesInjectableDepBuilder(t *testing.T) { - origBuildRunDeps := buildRunDeps - t.Cleanup(func() { buildRunDeps = origBuildRunDeps }) - - called := false - buildRunDeps = func(stdin io.Reader, stdout, stderr io.Writer) runDeps { - called = true - return runDeps{ - stdin: stdin, - stdout: stdout, - stderr: stderr, - setUTF8Mode: func() {}, - prepareWorkspace: func(string) (string, error) { return "D:/neo-code", nil }, - ensureAPIKeyInteractive: func(context.Context, *bufio.Scanner, string) (bool, error) { - return false, nil - }, - } - } - - err := run("D:/neo-code", strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if !called { - t.Fatal("expected run to use buildRunDeps") - } -} - -func TestRunWithDepsReturnsWorkspacePreparationError(t *testing.T) { - stderr := &bytes.Buffer{} - - err := runWithDeps("", runDeps{ - stdin: strings.NewReader(""), - stdout: &bytes.Buffer{}, - stderr: stderr, - setUTF8Mode: func() {}, - prepareWorkspace: func(string) (string, error) { return "", errors.New("workspace failed") }, - }) - if err == nil || !strings.Contains(err.Error(), "workspace failed") { - t.Fatalf("expected workspace error, got %v", err) - } -} - -func TestRunWithDepsStopsCleanlyWhenSetupNotReady(t *testing.T) { - stdout := &bytes.Buffer{} - loadCalled := false - - err := runWithDeps("D:/neo-code", runDeps{ - stdin: strings.NewReader(""), - stdout: stdout, - stderr: &bytes.Buffer{}, - setUTF8Mode: func() {}, - prepareWorkspace: func(string) (string, error) { return "D:/neo-code", nil }, - ensureAPIKeyInteractive: func(_ context.Context, _ *bufio.Scanner, path string) (bool, error) { - if path != defaultConfigPath { - t.Fatalf("expected config path %q, got %q", defaultConfigPath, path) - } - return false, nil - }, - loadAppConfig: func(string) error { - loadCalled = true - return nil - }, - }) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if loadCalled { - t.Fatal("loadAppConfig should not run when setup is not ready") - } - if !strings.Contains(stdout.String(), "NeoCode") { - t.Fatalf("expected exit message in stdout, got %q", stdout.String()) - } -} - -func TestRunWithDepsReturnsBootstrapError(t *testing.T) { - err := runWithDeps("D:/neo-code", runDeps{ - stdin: strings.NewReader(""), - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - setUTF8Mode: func() {}, - prepareWorkspace: func(string) (string, error) { - return "D:/neo-code", nil - }, - ensureAPIKeyInteractive: func(context.Context, *bufio.Scanner, string) (bool, error) { - return false, errors.New("bootstrap failed") - }, - }) - if err == nil || !strings.Contains(err.Error(), "bootstrap failed") { - t.Fatalf("expected bootstrap error, got %v", err) - } -} - -func TestRunWithDepsReturnsLoadAppConfigError(t *testing.T) { - err := runWithDeps("D:/neo-code", runDeps{ - stdin: strings.NewReader(""), - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - setUTF8Mode: func() {}, - prepareWorkspace: func(string) (string, error) { - return "D:/neo-code", nil - }, - ensureAPIKeyInteractive: func(context.Context, *bufio.Scanner, string) (bool, error) { - return true, nil - }, - loadAppConfig: func(string) error { return errors.New("load failed") }, - }) - if err == nil || !strings.Contains(err.Error(), "load failed") { - t.Fatalf("expected load error, got %v", err) - } -} - -func TestRunWithDepsPrintsPersonaFallbackHintAndRunsProgram(t *testing.T) { - origGlobalConfig := configs.GlobalAppConfig - t.Cleanup(func() { configs.GlobalAppConfig = origGlobalConfig }) - - stdout := &bytes.Buffer{} - stderr := &bytes.Buffer{} - cfg := configs.DefaultAppConfig() - cfg.Persona.FilePath = "./persona.txt" - - program := &fakeProgram{} - newProgramCalled := false - err := runWithDeps("D:/neo-code", runDeps{ - stdin: strings.NewReader(""), - stdout: stdout, - stderr: stderr, - setUTF8Mode: func() {}, - prepareWorkspace: func(string) (string, error) { return "D:/neo-code", nil }, - ensureAPIKeyInteractive: func(context.Context, *bufio.Scanner, string) (bool, error) { return true, nil }, - loadAppConfig: func(string) error { - configs.GlobalAppConfig = cfg - return nil - }, - loadPersonaPrompt: func(path string) (string, string, error) { - if path != "./persona.txt" { - t.Fatalf("expected configured persona path, got %q", path) - } - return "persona text", "./configs/persona.txt", nil - }, - newProgram: func(persona string, historyTurns int, configPath, workspaceRoot string) (programRunner, error) { - newProgramCalled = true - if persona != "persona text" { - t.Fatalf("unexpected persona %q", persona) - } - if historyTurns != cfg.History.ShortTermTurns { - t.Fatalf("unexpected history turns %d", historyTurns) - } - if configPath != defaultConfigPath || workspaceRoot != "D:/neo-code" { - t.Fatalf("unexpected program args: %q %q", configPath, workspaceRoot) - } - return program, nil - }, - }) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if !newProgramCalled || !program.called { - t.Fatal("expected program to be created and run") - } - if !strings.Contains(stderr.String(), "./configs/persona.txt") { - t.Fatalf("expected fallback persona hint, got %q", stderr.String()) - } -} - -func TestRunWithDepsContinuesWhenPersonaLoadFails(t *testing.T) { - origGlobalConfig := configs.GlobalAppConfig - t.Cleanup(func() { configs.GlobalAppConfig = origGlobalConfig }) - - cfg := configs.DefaultAppConfig() - stderr := &bytes.Buffer{} - program := &fakeProgram{} - - err := runWithDeps("D:/neo-code", runDeps{ - stdin: strings.NewReader(""), - stdout: &bytes.Buffer{}, - stderr: stderr, - setUTF8Mode: func() {}, - prepareWorkspace: func(string) (string, error) { return "D:/neo-code", nil }, - ensureAPIKeyInteractive: func(context.Context, *bufio.Scanner, string) (bool, error) { return true, nil }, - loadAppConfig: func(string) error { - configs.GlobalAppConfig = cfg - return nil - }, - loadPersonaPrompt: func(string) (string, string, error) { - return "", "", errors.New("persona failed") - }, - newProgram: func(persona string, historyTurns int, configPath, workspaceRoot string) (programRunner, error) { - if persona != "" { - t.Fatalf("expected empty persona on load failure, got %q", persona) - } - return program, nil - }, - }) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if !program.called { - t.Fatal("expected program to still run") - } - if !strings.Contains(stderr.String(), "persona failed") { - t.Fatalf("expected persona warning, got %q", stderr.String()) - } -} - -func TestRunWithDepsReturnsNewProgramError(t *testing.T) { - origGlobalConfig := configs.GlobalAppConfig - t.Cleanup(func() { configs.GlobalAppConfig = origGlobalConfig }) - - cfg := configs.DefaultAppConfig() - - err := runWithDeps("D:/neo-code", runDeps{ - stdin: strings.NewReader(""), - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - setUTF8Mode: func() {}, - prepareWorkspace: func(string) (string, error) { return "D:/neo-code", nil }, - ensureAPIKeyInteractive: func(context.Context, *bufio.Scanner, string) (bool, error) { return true, nil }, - loadAppConfig: func(string) error { - configs.GlobalAppConfig = cfg - return nil - }, - loadPersonaPrompt: func(string) (string, string, error) { return "persona", "", nil }, - newProgram: func(string, int, string, string) (programRunner, error) { return nil, errors.New("new program failed") }, - }) - if err == nil || !strings.Contains(err.Error(), "new program failed") { - t.Fatalf("expected new program error, got %v", err) - } -} - -func TestRunWithDepsReturnsProgramRunError(t *testing.T) { - origGlobalConfig := configs.GlobalAppConfig - t.Cleanup(func() { configs.GlobalAppConfig = origGlobalConfig }) - - cfg := configs.DefaultAppConfig() - program := &fakeProgram{runErr: errors.New("program failed")} - - err := runWithDeps("D:/neo-code", runDeps{ - stdin: strings.NewReader(""), - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - setUTF8Mode: func() {}, - prepareWorkspace: func(string) (string, error) { return "D:/neo-code", nil }, - ensureAPIKeyInteractive: func(context.Context, *bufio.Scanner, string) (bool, error) { return true, nil }, - loadAppConfig: func(string) error { - configs.GlobalAppConfig = cfg - return nil - }, - loadPersonaPrompt: func(string) (string, string, error) { return "", "", nil }, - newProgram: func(string, int, string, string) (programRunner, error) { return program, nil }, - }) - if err == nil || !strings.Contains(err.Error(), "program failed") { - t.Fatalf("expected run error, got %v", err) - } -} - -func TestRunWithDepsHappyPathCallsUTF8Hook(t *testing.T) { - origGlobalConfig := configs.GlobalAppConfig - t.Cleanup(func() { configs.GlobalAppConfig = origGlobalConfig }) - - cfg := configs.DefaultAppConfig() - utf8Called := false - program := &fakeProgram{} - - err := runWithDeps("D:/neo-code", runDeps{ - stdin: strings.NewReader(""), - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - setUTF8Mode: func() { - utf8Called = true - }, - prepareWorkspace: func(string) (string, error) { return "D:/neo-code", nil }, - ensureAPIKeyInteractive: func(context.Context, *bufio.Scanner, string) (bool, error) { return true, nil }, - loadAppConfig: func(string) error { - configs.GlobalAppConfig = cfg - return nil - }, - loadPersonaPrompt: func(string) (string, string, error) { return "persona", "", nil }, - newProgram: func(string, int, string, string) (programRunner, error) { return program, nil }, - }) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if !utf8Called { - t.Fatal("expected UTF8 hook to be called") - } - if !program.called { - t.Fatal("expected program to run") - } -} diff --git a/cmd/tui/utf8_other.go b/cmd/tui/utf8_other.go deleted file mode 100644 index d5c5aaab..00000000 --- a/cmd/tui/utf8_other.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !windows - -package main - -// setUTF8Mode 在非 Windows 系统上是空操作。 -// Linux 和 macOS 默认使用 UTF-8 编码,无需额外设置。 -func setUTF8Mode() {} diff --git a/cmd/tui/utf8_windows.go b/cmd/tui/utf8_windows.go deleted file mode 100644 index ccbea618..00000000 --- a/cmd/tui/utf8_windows.go +++ /dev/null @@ -1,21 +0,0 @@ -//go:build windows - -package main - -import ( - "fmt" - "os" - - "golang.org/x/sys/windows" -) - -const utf8CodePage = 65001 - -func setUTF8Mode() { - if err := windows.SetConsoleOutputCP(utf8CodePage); err != nil { - fmt.Fprintf(os.Stderr, "警告: 设置控制台输出编码失败: %v\n", err) - } - if err := windows.SetConsoleCP(utf8CodePage); err != nil { - fmt.Fprintf(os.Stderr, "警告: 设置控制台输入编码失败: %v\n", err) - } -} diff --git a/config-cover b/config-cover new file mode 100644 index 00000000..191a2c5a --- /dev/null +++ b/config-cover @@ -0,0 +1,222 @@ +mode: set +github.com/dust/neo-code/internal/config/loader.go:25.40,26.38 1 1 +github.com/dust/neo-code/internal/config/loader.go:26.38,28.3 1 0 +github.com/dust/neo-code/internal/config/loader.go:29.2,29.34 1 1 +github.com/dust/neo-code/internal/config/loader.go:32.35,34.2 1 1 +github.com/dust/neo-code/internal/config/loader.go:36.38,38.2 1 1 +github.com/dust/neo-code/internal/config/loader.go:40.35,42.2 1 1 +github.com/dust/neo-code/internal/config/loader.go:44.61,45.34 1 1 +github.com/dust/neo-code/internal/config/loader.go:45.34,47.3 1 0 +github.com/dust/neo-code/internal/config/loader.go:49.2,51.54 2 1 +github.com/dust/neo-code/internal/config/loader.go:51.54,53.3 1 0 +github.com/dust/neo-code/internal/config/loader.go:54.2,54.59 1 1 +github.com/dust/neo-code/internal/config/loader.go:54.59,55.48 1 1 +github.com/dust/neo-code/internal/config/loader.go:55.48,57.4 1 0 +github.com/dust/neo-code/internal/config/loader.go:60.2,61.16 2 1 +github.com/dust/neo-code/internal/config/loader.go:61.16,63.3 1 0 +github.com/dust/neo-code/internal/config/loader.go:65.2,66.16 2 1 +github.com/dust/neo-code/internal/config/loader.go:66.16,68.3 1 0 +github.com/dust/neo-code/internal/config/loader.go:69.2,70.39 2 1 +github.com/dust/neo-code/internal/config/loader.go:70.39,72.3 1 0 +github.com/dust/neo-code/internal/config/loader.go:73.2,73.17 1 1 +github.com/dust/neo-code/internal/config/loader.go:76.63,77.34 1 1 +github.com/dust/neo-code/internal/config/loader.go:77.34,79.3 1 0 +github.com/dust/neo-code/internal/config/loader.go:81.2,81.54 1 1 +github.com/dust/neo-code/internal/config/loader.go:81.54,83.3 1 0 +github.com/dust/neo-code/internal/config/loader.go:85.2,87.44 3 1 +github.com/dust/neo-code/internal/config/loader.go:87.44,89.3 1 0 +github.com/dust/neo-code/internal/config/loader.go:91.2,92.16 2 1 +github.com/dust/neo-code/internal/config/loader.go:92.16,94.3 1 0 +github.com/dust/neo-code/internal/config/loader.go:96.2,96.49 1 1 +github.com/dust/neo-code/internal/config/loader.go:96.49,98.3 1 0 +github.com/dust/neo-code/internal/config/loader.go:100.2,100.66 1 1 +github.com/dust/neo-code/internal/config/loader.go:100.66,102.3 1 0 +github.com/dust/neo-code/internal/config/loader.go:104.2,104.12 1 1 +github.com/dust/neo-code/internal/config/loader.go:107.36,110.2 2 1 +github.com/dust/neo-code/internal/config/loader.go:112.53,114.2 1 1 +github.com/dust/neo-code/internal/config/loader.go:116.30,118.16 2 0 +github.com/dust/neo-code/internal/config/loader.go:118.16,120.3 1 0 +github.com/dust/neo-code/internal/config/loader.go:121.2,121.37 1 0 +github.com/dust/neo-code/internal/config/loader.go:124.48,125.37 1 1 +github.com/dust/neo-code/internal/config/loader.go:125.37,127.3 1 0 +github.com/dust/neo-code/internal/config/loader.go:129.2,130.23 2 1 +github.com/dust/neo-code/internal/config/loader.go:130.23,132.3 1 1 +github.com/dust/neo-code/internal/config/loader.go:134.2,135.22 2 1 +github.com/dust/neo-code/internal/config/loader.go:135.22,137.3 1 1 +github.com/dust/neo-code/internal/config/loader.go:139.2,139.24 1 0 +github.com/dust/neo-code/internal/config/loader.go:164.55,166.50 2 1 +github.com/dust/neo-code/internal/config/loader.go:166.50,168.3 1 1 +github.com/dust/neo-code/internal/config/loader.go:170.2,171.55 2 1 +github.com/dust/neo-code/internal/config/loader.go:171.55,172.47 1 1 +github.com/dust/neo-code/internal/config/loader.go:172.47,174.4 1 0 +github.com/dust/neo-code/internal/config/loader.go:175.3,175.93 1 1 +github.com/dust/neo-code/internal/config/loader.go:175.93,177.4 1 0 +github.com/dust/neo-code/internal/config/loader.go:180.2,180.17 1 1 +github.com/dust/neo-code/internal/config/loader.go:183.54,185.54 2 1 +github.com/dust/neo-code/internal/config/loader.go:185.54,187.3 1 0 +github.com/dust/neo-code/internal/config/loader.go:189.2,189.41 1 1 +github.com/dust/neo-code/internal/config/loader.go:192.51,202.43 2 1 +github.com/dust/neo-code/internal/config/loader.go:202.43,204.95 2 1 +github.com/dust/neo-code/internal/config/loader.go:204.95,206.4 1 1 +github.com/dust/neo-code/internal/config/loader.go:208.3,214.5 1 1 +github.com/dust/neo-code/internal/config/loader.go:217.2,217.12 1 1 +github.com/dust/neo-code/internal/config/loader.go:220.44,221.29 1 1 +github.com/dust/neo-code/internal/config/loader.go:221.29,222.36 1 1 +github.com/dust/neo-code/internal/config/loader.go:222.36,224.4 1 1 +github.com/dust/neo-code/internal/config/loader.go:226.2,226.11 1 0 +github.com/dust/neo-code/internal/config/manager.go:18.42,19.19 1 1 +github.com/dust/neo-code/internal/config/manager.go:19.19,21.3 1 0 +github.com/dust/neo-code/internal/config/manager.go:23.2,26.3 1 1 +github.com/dust/neo-code/internal/config/manager.go:29.61,31.16 2 1 +github.com/dust/neo-code/internal/config/manager.go:31.16,33.3 1 0 +github.com/dust/neo-code/internal/config/manager.go:35.2,41.22 5 1 +github.com/dust/neo-code/internal/config/manager.go:44.63,46.2 1 0 +github.com/dust/neo-code/internal/config/manager.go:48.32,53.2 3 1 +github.com/dust/neo-code/internal/config/manager.go:55.51,61.2 4 0 +github.com/dust/neo-code/internal/config/manager.go:63.81,64.19 1 1 +github.com/dust/neo-code/internal/config/manager.go:64.19,66.3 1 0 +github.com/dust/neo-code/internal/config/manager.go:68.2,72.38 4 1 +github.com/dust/neo-code/internal/config/manager.go:72.38,74.3 1 0 +github.com/dust/neo-code/internal/config/manager.go:76.2,77.40 2 1 +github.com/dust/neo-code/internal/config/manager.go:77.40,79.3 1 0 +github.com/dust/neo-code/internal/config/manager.go:80.2,80.50 1 1 +github.com/dust/neo-code/internal/config/manager.go:80.50,82.3 1 0 +github.com/dust/neo-code/internal/config/manager.go:84.2,85.12 2 1 +github.com/dust/neo-code/internal/config/manager.go:88.62,91.2 2 1 +github.com/dust/neo-code/internal/config/manager.go:93.78,95.16 2 1 +github.com/dust/neo-code/internal/config/manager.go:95.16,97.3 1 0 +github.com/dust/neo-code/internal/config/manager.go:99.2,99.27 1 1 +github.com/dust/neo-code/internal/config/manager.go:102.36,104.2 1 1 +github.com/dust/neo-code/internal/config/manager.go:106.39,108.2 1 0 +github.com/dust/neo-code/internal/config/manager.go:110.36,112.2 1 1 +github.com/dust/neo-code/internal/config/manager.go:114.39,116.2 1 0 +github.com/dust/neo-code/internal/config/manager.go:118.54,120.2 1 0 +github.com/dust/neo-code/internal/config/manager.go:122.61,124.15 2 1 +github.com/dust/neo-code/internal/config/manager.go:124.15,126.3 1 0 +github.com/dust/neo-code/internal/config/manager.go:128.2,129.55 2 1 +github.com/dust/neo-code/internal/config/manager.go:129.55,131.22 2 1 +github.com/dust/neo-code/internal/config/manager.go:131.22,133.4 1 1 +github.com/dust/neo-code/internal/config/manager.go:136.2,137.29 2 1 +github.com/dust/neo-code/internal/config/manager.go:137.29,139.55 2 1 +github.com/dust/neo-code/internal/config/manager.go:139.55,140.12 1 0 +github.com/dust/neo-code/internal/config/manager.go:142.3,142.42 1 1 +github.com/dust/neo-code/internal/config/manager.go:142.42,145.4 2 1 +github.com/dust/neo-code/internal/config/manager.go:147.2,147.15 1 1 +github.com/dust/neo-code/internal/config/manager.go:147.15,149.3 1 1 +github.com/dust/neo-code/internal/config/manager.go:151.2,152.39 2 1 +github.com/dust/neo-code/internal/config/manager.go:152.39,154.3 1 1 +github.com/dust/neo-code/internal/config/manager.go:156.2,156.56 1 1 +github.com/dust/neo-code/internal/config/manager.go:156.56,158.3 1 0 +github.com/dust/neo-code/internal/config/manager.go:159.2,159.58 1 1 +github.com/dust/neo-code/internal/config/model.go:63.24,95.2 1 1 +github.com/dust/neo-code/internal/config/model.go:97.42,106.2 1 0 +github.com/dust/neo-code/internal/config/model.go:108.33,109.14 1 1 +github.com/dust/neo-code/internal/config/model.go:109.14,111.3 1 1 +github.com/dust/neo-code/internal/config/model.go:113.2,115.14 3 1 +github.com/dust/neo-code/internal/config/model.go:118.34,119.14 1 1 +github.com/dust/neo-code/internal/config/model.go:119.14,121.3 1 0 +github.com/dust/neo-code/internal/config/model.go:123.2,125.27 2 1 +github.com/dust/neo-code/internal/config/model.go:125.27,127.3 1 0 +github.com/dust/neo-code/internal/config/model.go:127.8,129.3 1 1 +github.com/dust/neo-code/internal/config/model.go:131.2,131.49 1 1 +github.com/dust/neo-code/internal/config/model.go:131.49,133.3 1 0 +github.com/dust/neo-code/internal/config/model.go:134.2,134.45 1 1 +github.com/dust/neo-code/internal/config/model.go:134.45,135.62 1 1 +github.com/dust/neo-code/internal/config/model.go:135.62,137.4 1 1 +github.com/dust/neo-code/internal/config/model.go:139.2,139.40 1 1 +github.com/dust/neo-code/internal/config/model.go:139.40,141.3 1 0 +github.com/dust/neo-code/internal/config/model.go:142.2,142.38 1 1 +github.com/dust/neo-code/internal/config/model.go:142.38,144.3 1 1 +github.com/dust/neo-code/internal/config/model.go:145.2,145.21 1 1 +github.com/dust/neo-code/internal/config/model.go:145.21,147.3 1 1 +github.com/dust/neo-code/internal/config/model.go:148.2,148.27 1 1 +github.com/dust/neo-code/internal/config/model.go:148.27,150.3 1 1 +github.com/dust/neo-code/internal/config/model.go:152.2,152.41 1 1 +github.com/dust/neo-code/internal/config/model.go:155.35,156.14 1 1 +github.com/dust/neo-code/internal/config/model.go:156.14,158.3 1 1 +github.com/dust/neo-code/internal/config/model.go:159.2,159.27 1 1 +github.com/dust/neo-code/internal/config/model.go:159.27,161.3 1 1 +github.com/dust/neo-code/internal/config/model.go:163.2,164.39 2 1 +github.com/dust/neo-code/internal/config/model.go:164.39,165.45 1 1 +github.com/dust/neo-code/internal/config/model.go:165.45,167.4 1 1 +github.com/dust/neo-code/internal/config/model.go:169.3,170.37 2 1 +github.com/dust/neo-code/internal/config/model.go:170.37,172.4 1 1 +github.com/dust/neo-code/internal/config/model.go:173.3,173.25 1 1 +github.com/dust/neo-code/internal/config/model.go:176.2,176.49 1 1 +github.com/dust/neo-code/internal/config/model.go:176.49,178.3 1 0 +github.com/dust/neo-code/internal/config/model.go:179.2,180.16 2 1 +github.com/dust/neo-code/internal/config/model.go:180.16,182.3 1 0 +github.com/dust/neo-code/internal/config/model.go:183.2,183.45 1 1 +github.com/dust/neo-code/internal/config/model.go:183.45,185.3 1 0 +github.com/dust/neo-code/internal/config/model.go:186.2,186.40 1 1 +github.com/dust/neo-code/internal/config/model.go:186.40,188.3 1 0 +github.com/dust/neo-code/internal/config/model.go:189.2,189.32 1 1 +github.com/dust/neo-code/internal/config/model.go:189.32,191.3 1 1 +github.com/dust/neo-code/internal/config/model.go:192.2,192.45 1 1 +github.com/dust/neo-code/internal/config/model.go:192.45,194.3 1 0 +github.com/dust/neo-code/internal/config/model.go:196.2,196.12 1 1 +github.com/dust/neo-code/internal/config/model.go:199.67,200.14 1 1 +github.com/dust/neo-code/internal/config/model.go:200.14,202.3 1 0 +github.com/dust/neo-code/internal/config/model.go:203.2,203.45 1 1 +github.com/dust/neo-code/internal/config/model.go:206.70,207.14 1 1 +github.com/dust/neo-code/internal/config/model.go:207.14,209.3 1 0 +github.com/dust/neo-code/internal/config/model.go:211.2,212.39 2 1 +github.com/dust/neo-code/internal/config/model.go:212.39,213.66 1 1 +github.com/dust/neo-code/internal/config/model.go:213.66,215.4 1 1 +github.com/dust/neo-code/internal/config/model.go:218.2,218.76 1 0 +github.com/dust/neo-code/internal/config/model.go:221.42,222.37 1 1 +github.com/dust/neo-code/internal/config/model.go:222.37,224.3 1 1 +github.com/dust/neo-code/internal/config/model.go:225.2,225.37 1 1 +github.com/dust/neo-code/internal/config/model.go:225.37,227.3 1 1 +github.com/dust/neo-code/internal/config/model.go:228.2,228.40 1 1 +github.com/dust/neo-code/internal/config/model.go:228.40,230.3 1 1 +github.com/dust/neo-code/internal/config/model.go:231.2,231.38 1 1 +github.com/dust/neo-code/internal/config/model.go:231.38,233.3 1 1 +github.com/dust/neo-code/internal/config/model.go:234.2,234.42 1 1 +github.com/dust/neo-code/internal/config/model.go:234.42,236.3 1 1 +github.com/dust/neo-code/internal/config/model.go:237.2,237.12 1 1 +github.com/dust/neo-code/internal/config/model.go:240.57,242.19 2 1 +github.com/dust/neo-code/internal/config/model.go:242.19,244.3 1 0 +github.com/dust/neo-code/internal/config/model.go:246.2,247.17 2 1 +github.com/dust/neo-code/internal/config/model.go:247.17,249.3 1 1 +github.com/dust/neo-code/internal/config/model.go:251.2,251.19 1 1 +github.com/dust/neo-code/internal/config/model.go:254.67,256.16 2 1 +github.com/dust/neo-code/internal/config/model.go:256.16,258.3 1 0 +github.com/dust/neo-code/internal/config/model.go:260.2,263.8 1 1 +github.com/dust/neo-code/internal/config/model.go:266.100,268.37 2 1 +github.com/dust/neo-code/internal/config/model.go:268.37,270.3 1 1 +github.com/dust/neo-code/internal/config/model.go:271.2,271.12 1 1 +github.com/dust/neo-code/internal/config/model.go:274.95,276.9 2 1 +github.com/dust/neo-code/internal/config/model.go:276.9,278.3 1 0 +github.com/dust/neo-code/internal/config/model.go:280.2,280.44 1 1 +github.com/dust/neo-code/internal/config/model.go:280.44,282.3 1 0 +github.com/dust/neo-code/internal/config/model.go:283.2,283.44 1 1 +github.com/dust/neo-code/internal/config/model.go:283.44,285.3 1 1 +github.com/dust/neo-code/internal/config/model.go:286.2,286.47 1 1 +github.com/dust/neo-code/internal/config/model.go:286.47,288.3 1 1 +github.com/dust/neo-code/internal/config/model.go:289.2,289.45 1 1 +github.com/dust/neo-code/internal/config/model.go:289.45,291.3 1 1 +github.com/dust/neo-code/internal/config/model.go:292.2,292.49 1 1 +github.com/dust/neo-code/internal/config/model.go:292.49,294.3 1 1 +github.com/dust/neo-code/internal/config/model.go:296.2,296.17 1 1 +github.com/dust/neo-code/internal/config/model.go:299.102,303.37 3 1 +github.com/dust/neo-code/internal/config/model.go:303.37,304.60 1 1 +github.com/dust/neo-code/internal/config/model.go:304.60,306.4 1 1 +github.com/dust/neo-code/internal/config/model.go:308.2,308.37 1 0 +github.com/dust/neo-code/internal/config/model.go:308.37,309.60 1 0 +github.com/dust/neo-code/internal/config/model.go:309.60,311.4 1 0 +github.com/dust/neo-code/internal/config/model.go:314.2,314.32 1 0 +github.com/dust/neo-code/internal/config/model.go:317.46,319.19 2 1 +github.com/dust/neo-code/internal/config/model.go:319.19,321.3 1 0 +github.com/dust/neo-code/internal/config/model.go:323.2,323.20 1 1 +github.com/dust/neo-code/internal/config/model.go:323.20,324.40 1 1 +github.com/dust/neo-code/internal/config/model.go:324.40,326.4 1 1 +github.com/dust/neo-code/internal/config/model.go:327.3,327.17 1 0 +github.com/dust/neo-code/internal/config/model.go:330.2,330.29 1 1 +github.com/dust/neo-code/internal/config/model.go:330.29,332.3 1 1 +github.com/dust/neo-code/internal/config/model.go:334.2,334.39 1 1 +github.com/dust/neo-code/internal/config/model.go:334.39,336.3 1 1 +github.com/dust/neo-code/internal/config/model.go:338.2,338.32 1 0 +github.com/dust/neo-code/internal/config/model.go:341.28,342.33 1 1 +github.com/dust/neo-code/internal/config/model.go:342.33,344.3 1 1 +github.com/dust/neo-code/internal/config/model.go:345.2,345.15 1 0 diff --git a/config.example.yaml b/config.example.yaml deleted file mode 100644 index b96c2618..00000000 --- a/config.example.yaml +++ /dev/null @@ -1,36 +0,0 @@ -# 应用配置文件示例 -app: - name: "NeoCode" - version: "1.0.0" - -ai: - provider: "openll" # modelscope | deepseek | openll | siliconflow | 豆包大模型 | openai - api_key: "AI_API_KEY" # environment variable name; falls back to AI_API_KEY when empty - model: "gpt-5.4" - -memory: - top_k: 5 - min_match_score: 2.2 - max_prompt_chars: 1800 - max_items: 1000 - storage_path: "./data/memory_rules.json" - persist_types: - - "user_preference" - - "project_rule" - - "code_fact" - - "fix_recipe" - -history: - short_term_turns: 6 - max_tool_context_messages: 3 - max_tool_context_output_size: 4000 - persist_session_state: true - workspace_state_dir: "./data/workspaces" - resume_last_session: true - -persona: - file_path: "./configs/persona.txt" - -# Notes: -# - Switching provider with /provider resets ai.model to that provider's default model. -# - You can still override the current model at any time with /switch . diff --git a/configs/app_config.go b/configs/app_config.go deleted file mode 100644 index b5681684..00000000 --- a/configs/app_config.go +++ /dev/null @@ -1,267 +0,0 @@ -package configs - -import ( - "errors" - "fmt" - "os" - "strings" - - "gopkg.in/yaml.v3" -) - -const DefaultAPIKeyEnvVar = "AI_API_KEY" - -type AppConfiguration struct { - App struct { - Name string `yaml:"name"` - Version string `yaml:"version"` - } `yaml:"app"` - - AI struct { - Provider string `yaml:"provider"` - APIKey string `yaml:"api_key"` - Model string `yaml:"model"` - } `yaml:"ai"` - - Memory struct { - TopK int `yaml:"top_k"` - MinMatchScore float64 `yaml:"min_match_score"` - MaxPromptChars int `yaml:"max_prompt_chars"` - MaxItems int `yaml:"max_items"` - StoragePath string `yaml:"storage_path"` - PersistTypes []string `yaml:"persist_types"` - } `yaml:"memory"` - - History struct { - ShortTermTurns int `yaml:"short_term_turns"` - MaxToolContextMessages int `yaml:"max_tool_context_messages"` - MaxToolContextOutputSize int `yaml:"max_tool_context_output_size"` - PersistSessionState bool `yaml:"persist_session_state"` - WorkspaceStateDir string `yaml:"workspace_state_dir"` - ResumeLastSession bool `yaml:"resume_last_session"` - } `yaml:"history"` - - Persona struct { - FilePath string `yaml:"file_path"` - } `yaml:"persona"` -} - -var GlobalAppConfig *AppConfiguration - -// DefaultAppConfig 返回内置的应用默认配置。 -func DefaultAppConfig() *AppConfiguration { - cfg := &AppConfiguration{} - cfg.App.Name = "NeoCode" - cfg.App.Version = "1.0.0" - cfg.AI.Provider = "openll" - cfg.AI.APIKey = DefaultAPIKeyEnvVar - cfg.AI.Model = "gpt-5.4" - cfg.Memory.TopK = 5 - cfg.Memory.MinMatchScore = 2.2 - cfg.Memory.MaxPromptChars = 1800 - cfg.Memory.MaxItems = 1000 - cfg.Memory.StoragePath = "./data/memory_rules.json" - cfg.Memory.PersistTypes = []string{"user_preference", "project_rule", "code_fact", "fix_recipe"} - cfg.History.ShortTermTurns = 6 - cfg.History.MaxToolContextMessages = 3 - cfg.History.MaxToolContextOutputSize = 4000 - cfg.History.PersistSessionState = true - cfg.History.WorkspaceStateDir = "./data/workspaces" - cfg.History.ResumeLastSession = true - cfg.Persona.FilePath = DefaultPersonaFilePath - return cfg -} - -// LoadAppConfig 加载运行时配置并保存到全局变量。 -func LoadAppConfig(filePath string) error { - cfg, err := LoadBootstrapConfig(filePath) - if err != nil { - return err - } - if err := cfg.ValidateRuntime(); err != nil { - return err - } - GlobalAppConfig = cfg - return nil -} - -// LoadBootstrapConfig 加载不依赖运行时密钥的基础配置。 -func LoadBootstrapConfig(filePath string) (*AppConfiguration, error) { - data, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("读取配置文件时出错: %w", err) - } - - cfg := DefaultAppConfig() - //解析data数据覆盖到cfg上 - if err := yaml.Unmarshal(data, cfg); err != nil { - return nil, fmt.Errorf("解析yaml信息失败: %w", err) - } - if err := cfg.ValidateBase(); err != nil { - return nil, err - } - return cfg, nil -} - -// EnsureConfigFile 加载已有配置文件,或在缺失时写入默认配置。 -func EnsureConfigFile(filePath string) (*AppConfiguration, bool, error) { - if _, err := os.Stat(filePath); err == nil { - cfg, loadErr := LoadBootstrapConfig(filePath) - return cfg, false, loadErr - } else if !errors.Is(err, os.ErrNotExist) { - return nil, false, fmt.Errorf("文件不存在: %w", err) - } - - cfg := DefaultAppConfig() - if err := WriteAppConfig(filePath, cfg); err != nil { - return nil, false, err - } - return cfg, true, nil -} - -// WriteAppConfig 将应用配置写入磁盘。 -func WriteAppConfig(filePath string, cfg *AppConfiguration) error { - if cfg == nil { - return fmt.Errorf("应用配置不能为空") - } - cfgCopy := *cfg - cfgCopy.AI.APIKey = strings.TrimSpace(cfgCopy.AI.APIKey) - data, err := yaml.Marshal(&cfgCopy) - if err != nil { - return fmt.Errorf("序列化yaml信息时错误: %w", err) - } - if err := os.WriteFile(filePath, data, 0o644); err != nil { - return fmt.Errorf("向yaml文件写入配置信息时错误: %w", err) - } - return nil -} - -// Validate 检查配置是否满足运行时要求。 -func (c *AppConfiguration) Validate() error { - return c.ValidateRuntime() -} - -// ValidateBase 检查不包含密钥的基础配置是否合法。 -func (c *AppConfiguration) ValidateBase() error { - if c == nil { - return fmt.Errorf("应用配置不能为空") - } - providerName := normalizeProviderName(c.AI.Provider) - if providerName == "" { - return fmt.Errorf("配置无效:需要 ai.provider") - } - if !isSupportedProvider(providerName) { - return fmt.Errorf("配置无效:不支持的 ai.provider %q", strings.TrimSpace(c.AI.Provider)) - } - c.AI.Provider = providerName - if strings.TrimSpace(c.AI.Model) == "" { - return fmt.Errorf("配置无效:需要 ai.model") - } - if c.Memory.TopK <= 0 { - return fmt.Errorf("配置无效:memory.top_k 必须大于 0") - } - if c.Memory.MinMatchScore < 0 { - return fmt.Errorf("配置无效:memory.min_match_score 不能为负数") - } - if c.Memory.MaxPromptChars <= 0 { - return fmt.Errorf("配置无效:memory.max_prompt_chars 必须大于 0") - } - if c.Memory.MaxItems <= 0 { - return fmt.Errorf("配置无效:memory.max_items 必须大于 0") - } - if strings.TrimSpace(c.Memory.StoragePath) == "" { - return fmt.Errorf("配置无效:需要 memory.storage_path") - } - if c.History.ShortTermTurns <= 0 { - return fmt.Errorf("配置无效:history.short_term_turns 必须大于 0") - } - if c.History.MaxToolContextMessages < 0 { - return fmt.Errorf("配置无效:history.max_tool_context_messages 不能为负数") - } - if c.History.MaxToolContextOutputSize <= 0 { - return fmt.Errorf("配置无效:history.max_tool_context_output_size 必须大于 0") - } - if c.History.PersistSessionState && strings.TrimSpace(c.History.WorkspaceStateDir) == "" { - return fmt.Errorf("配置无效:history.workspace_state_dir 不能为空") - } - return nil -} - -// ValidateRuntime 检查配置字段和运行时必需的环境变量。 -func (c *AppConfiguration) ValidateRuntime() error { - if err := c.ValidateBase(); err != nil { - return err - } - envVarName := c.APIKeyEnvVarName() - if c.RuntimeAPIKey() == "" { - return fmt.Errorf("运行时无效:需要 %s 环境变量", envVarName) - } - return nil -} - -// APIKeyEnvVarName 返回当前配置使用的 API Key 环境变量名。 -func (c *AppConfiguration) APIKeyEnvVarName() string { - if c == nil { - return DefaultAPIKeyEnvVar - } - if name := strings.TrimSpace(c.AI.APIKey); name != "" { - return name - } - return DefaultAPIKeyEnvVar -} - -// RuntimeAPIKey 返回配置指向的环境变量中的 API Key,并去掉首尾空白。 -func (c *AppConfiguration) RuntimeAPIKey() string { - return strings.TrimSpace(os.Getenv(c.APIKeyEnvVarName())) -} - -// RuntimeAPIKeyEnvVarName 返回全局配置当前使用的 API Key 环境变量名。 -func RuntimeAPIKeyEnvVarName() string { - if GlobalAppConfig != nil { - return GlobalAppConfig.APIKeyEnvVarName() - } - return DefaultAPIKeyEnvVar -} - -// RuntimeAPIKey 返回全局配置指向的环境变量中的 API Key,并去掉首尾空白。 -func RuntimeAPIKey() string { - if GlobalAppConfig != nil { - return GlobalAppConfig.RuntimeAPIKey() - } - return strings.TrimSpace(os.Getenv(DefaultAPIKeyEnvVar)) -} - -func normalizeProviderName(name string) string { - trimmed := strings.TrimSpace(name) - if trimmed == "" { - return "" - } - if strings.EqualFold(trimmed, "modelscope") { - return "modelscope" - } - if strings.EqualFold(trimmed, "deepseek") { - return "deepseek" - } - if strings.EqualFold(trimmed, "openll") { - return "openll" - } - if strings.EqualFold(trimmed, "siliconflow") { - return "siliconflow" - } - if strings.EqualFold(trimmed, "openai") { - return "openai" - } - if trimmed == "豆包大模型" { - return "豆包大模型" - } - return trimmed -} - -func isSupportedProvider(name string) bool { - switch normalizeProviderName(name) { - case "modelscope", "deepseek", "openll", "siliconflow", "豆包大模型", "openai": - return true - default: - return false - } -} diff --git a/configs/app_config_test.go b/configs/app_config_test.go deleted file mode 100644 index 82d58c91..00000000 --- a/configs/app_config_test.go +++ /dev/null @@ -1,242 +0,0 @@ -package configs - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestAppConfigurationValidate(t *testing.T) { - t.Setenv(DefaultAPIKeyEnvVar, "env-chat-key") - cfg := validConfig() - if err := cfg.Validate(); err != nil { - t.Fatalf("expected valid config, got error: %v", err) - } -} - -func TestAppConfigurationValidateMissingEnvAPIKey(t *testing.T) { - t.Setenv(DefaultAPIKeyEnvVar, "") - cfg := validConfig() - - err := cfg.Validate() - if err == nil || !strings.Contains(err.Error(), DefaultAPIKeyEnvVar) { - t.Fatalf("expected %s validation error, got: %v", DefaultAPIKeyEnvVar, err) - } -} - -func TestAppConfigurationValidateUsesCustomEnvVarName(t *testing.T) { - t.Setenv(DefaultAPIKeyEnvVar, "") - t.Setenv("CUSTOM_CHAT_KEY", "env-chat-key") - cfg := validConfig() - cfg.AI.APIKey = "CUSTOM_CHAT_KEY" - - if err := cfg.Validate(); err != nil { - t.Fatalf("expected custom env var to validate, got: %v", err) - } - if got := cfg.RuntimeAPIKey(); got != "env-chat-key" { - t.Fatalf("expected runtime api key from custom env, got %q", got) - } -} - -func TestAppConfigurationValidateFallsBackToDefaultEnvVarName(t *testing.T) { - t.Setenv(DefaultAPIKeyEnvVar, "fallback-key") - cfg := validConfig() - cfg.AI.APIKey = "" - - if got := cfg.APIKeyEnvVarName(); got != DefaultAPIKeyEnvVar { - t.Fatalf("expected fallback env var name %q, got %q", DefaultAPIKeyEnvVar, got) - } - if got := cfg.RuntimeAPIKey(); got != "fallback-key" { - t.Fatalf("expected fallback runtime api key, got %q", got) - } -} - -func TestAppConfigurationValidateBaseAllowsMissingAIKey(t *testing.T) { - cfg := validConfig() - cfg.AI.APIKey = "" - - if err := cfg.ValidateBase(); err != nil { - t.Fatalf("expected base validation to allow missing api key, got: %v", err) - } -} - -func TestAppConfigurationValidateBaseAllowsProviderWithoutLegacyCatalog(t *testing.T) { - cfg := validConfig() - cfg.AI.Provider = "deepseek" - cfg.AI.Model = "deepseek-chat" - - if err := cfg.ValidateBase(); err != nil { - t.Fatalf("expected provider config without legacy catalog to validate, got: %v", err) - } -} - -func TestAppConfigurationValidateBaseRejectsUnsupportedProvider(t *testing.T) { - cfg := validConfig() - cfg.AI.Provider = "unknown" - - err := cfg.ValidateBase() - if err == nil || !strings.Contains(err.Error(), "不支持的 ai.provider") { - t.Fatalf("expected unsupported provider error, got: %v", err) - } -} - -func TestLoadAppConfig(t *testing.T) { - t.Setenv("CUSTOM_CHAT_KEY", "env-chat-key") - dir := t.TempDir() - path := filepath.Join(dir, "config.yaml") - content := []byte(`app: - name: "NeoCode" - version: "1.0.0" -ai: - provider: "openll" - api_key: "CUSTOM_CHAT_KEY" - model: "gpt-5.4" -memory: - top_k: 5 - min_match_score: 2.2 - max_prompt_chars: 1800 - max_items: 1000 - storage_path: "./data/memory_rules.json" -history: - short_term_turns: 6 - max_tool_context_messages: 3 - max_tool_context_output_size: 4000 - persist_session_state: true - workspace_state_dir: "./data/workspaces" - resume_last_session: true -persona: - file_path: "./configs/persona.txt" -`) - if err := os.WriteFile(path, content, 0o644); err != nil { - t.Fatalf("write config: %v", err) - } - - GlobalAppConfig = nil - if err := LoadAppConfig(path); err != nil { - t.Fatalf("load config: %v", err) - } - - if GlobalAppConfig == nil || GlobalAppConfig.AI.Model != "gpt-5.4" { - t.Fatalf("expected loaded config, got %+v", GlobalAppConfig) - } - if GlobalAppConfig.AI.APIKey != "CUSTOM_CHAT_KEY" { - t.Fatalf("expected config api key env name to persist, got %q", GlobalAppConfig.AI.APIKey) - } - if got := GlobalAppConfig.RuntimeAPIKey(); got != "env-chat-key" { - t.Fatalf("expected runtime api key from custom env, got %q", got) - } -} - -func TestAppConfigurationValidateMissingMemoryStoragePath(t *testing.T) { - t.Setenv(DefaultAPIKeyEnvVar, "env-chat-key") - cfg := validConfig() - cfg.Memory.StoragePath = "" - err := cfg.Validate() - if err == nil || !strings.Contains(err.Error(), "memory.storage_path") { - t.Fatalf("expected storage path validation error, got: %v", err) - } -} - -func TestEnsureConfigFileCreatesDefault(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.yaml") - - cfg, created, err := EnsureConfigFile(path) - if err != nil { - t.Fatalf("ensure config: %v", err) - } - if !created { - t.Fatal("expected config file to be created") - } - if cfg == nil || cfg.AI.Provider == "" { - t.Fatalf("expected default config, got %+v", cfg) - } - if _, err := os.Stat(path); err != nil { - t.Fatalf("expected config file on disk: %v", err) - } - if strings.TrimSpace(cfg.AI.APIKey) != DefaultAPIKeyEnvVar { - t.Fatalf("expected default api key env name %q, got %q", DefaultAPIKeyEnvVar, cfg.AI.APIKey) - } -} - -func TestWriteAppConfigRoundTrip(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.yaml") - want := validConfig() - - if err := WriteAppConfig(path, want); err != nil { - t.Fatalf("write config: %v", err) - } - - got, err := LoadBootstrapConfig(path) - if err != nil { - t.Fatalf("load bootstrap config: %v", err) - } - if got.AI.APIKey != want.AI.APIKey { - t.Fatalf("expected written config api key env name %q, got %q", want.AI.APIKey, got.AI.APIKey) - } - if got.AI.Model != want.AI.Model { - t.Fatalf("expected model %q, got %q", want.AI.Model, got.AI.Model) - } - if got.Memory.StoragePath != want.Memory.StoragePath { - t.Fatalf("expected storage path %q, got %q", want.Memory.StoragePath, got.Memory.StoragePath) - } -} - -func validConfig() *AppConfiguration { - cfg := &AppConfiguration{} - cfg.AI.Provider = "openll" - cfg.AI.APIKey = DefaultAPIKeyEnvVar - cfg.AI.Model = "gpt-5.4" - cfg.Memory.TopK = 5 - cfg.Memory.MinMatchScore = 2.2 - cfg.Memory.MaxPromptChars = 1800 - cfg.Memory.MaxItems = 1000 - cfg.Memory.StoragePath = "./data/memory_rules.json" - cfg.Memory.PersistTypes = []string{"user_preference", "project_rule", "code_fact", "fix_recipe"} - cfg.History.ShortTermTurns = 6 - cfg.History.MaxToolContextMessages = 3 - cfg.History.MaxToolContextOutputSize = 4000 - cfg.History.PersistSessionState = true - cfg.History.WorkspaceStateDir = "./data/workspaces" - cfg.History.ResumeLastSession = true - cfg.Persona.FilePath = DefaultPersonaFilePath - return cfg -} - -func TestDefaultAppConfigUsesCheckedInPersonaPath(t *testing.T) { - cfg := DefaultAppConfig() - if cfg.Persona.FilePath != DefaultPersonaFilePath { - t.Fatalf("expected default persona path %q, got %q", DefaultPersonaFilePath, cfg.Persona.FilePath) - } -} - -func TestResolvePersonaFilePathFallsBackToCheckedInFile(t *testing.T) { - wd, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) - } - tmpDir := t.TempDir() - configsDir := filepath.Join(tmpDir, "configs") - if err := os.MkdirAll(configsDir, 0o755); err != nil { - t.Fatalf("mkdir configs: %v", err) - } - if err := os.WriteFile(filepath.Join(configsDir, "persona.txt"), []byte("persona"), 0o644); err != nil { - t.Fatalf("write persona: %v", err) - } - if err := os.Chdir(tmpDir); err != nil { - t.Fatalf("chdir temp dir: %v", err) - } - defer func() { - _ = os.Chdir(wd) - }() - - resolved := ResolvePersonaFilePath("./persona.txt") - if resolved != DefaultPersonaFilePath { - t.Fatalf("expected legacy persona path to resolve to %q, got %q", DefaultPersonaFilePath, resolved) - } - if _, err := os.Stat(resolved); err != nil { - t.Fatalf("expected resolved persona file to exist: %v", err) - } -} diff --git a/configs/persona.go b/configs/persona.go deleted file mode 100644 index ab6758e3..00000000 --- a/configs/persona.go +++ /dev/null @@ -1,49 +0,0 @@ -package configs - -import ( - "fmt" - "os" - "strings" -) - -const ( - DefaultPersonaFilePath = "./configs/persona.txt" - legacyPersonaFilePath = "./persona.txt" -) - -func ResolvePersonaFilePath(path string) string { - trimmed := strings.TrimSpace(path) - if trimmed == "" { - return "" - } - - candidates := []string{trimmed} - if trimmed == legacyPersonaFilePath || trimmed == "persona.txt" { - candidates = append(candidates, DefaultPersonaFilePath, "configs/persona.txt") - } - - for _, candidate := range candidates { - if candidate == "" { - continue - } - if _, err := os.Stat(candidate); err == nil { - return candidate - } - } - - return trimmed -} - -func LoadPersonaPrompt(path string) (string, string, error) { - resolvedPath := ResolvePersonaFilePath(path) - if resolvedPath == "" { - return "", "", nil - } - - data, err := os.ReadFile(resolvedPath) - if err != nil { - return "", resolvedPath, fmt.Errorf("read persona file %q: %w", resolvedPath, err) - } - - return strings.TrimSpace(string(data)), resolvedPath, nil -} diff --git a/configs/persona.txt b/configs/persona.txt deleted file mode 100644 index cfd90216..00000000 --- a/configs/persona.txt +++ /dev/null @@ -1,61 +0,0 @@ -你是 NeoCode,一个专业、可靠、简洁的 AI 编程助手。 - -要求: -- 优先给出可执行、可落地的工程方案 -- 回答简洁清晰,必要时给出步骤 -- 对不确定的内容明确说明假设 -- 默认使用中文回答 - -安全与工具执行要求(必须遵守): -- 对可能有副作用的操作(尤其是 bash、写文件、网络请求),先进行风险判断,再执行。 -- 当命中安全策略为 deny 时,必须明确拒绝并解释原因,不得尝试绕过。 -- 当命中安全策略为 ask 时,先向用户确认,再继续执行。 -- 执行 bash 前必须说明命令目的、影响范围与关键参数;优先使用只读、可回滚、最小权限命令。 -- 严禁执行高危破坏性命令(如无边界删除、系统级破坏、恶意下载执行等)。 -- 涉及路径操作时,默认限定在当前工作区内,不对工作区外路径进行读写。 - -你可以调用edit,grep,list,read,write,bash工具,规范和opencode保持一致 -你可以调用edit,grep,list,read,write,“todo”工具,规范和opencode保持一致 - -当需要管理任务清单以追踪复杂任务进度时,调用 todo 工具。 -操作类型(action): -- add: 添加新任务。参数:content(任务内容), priority(优先级:high/medium/low) -- update: 更新任务状态。参数:id(任务ID,如todo-1), status(pending/in_progress/completed) -- list: 列出所有任务。 -- remove: 移除特定任务。参数:id -- clear: 清空所有任务。 - -当需要查看指定目录下的文件 / 目录结构时,调用 list 工具。 -必填参数:path(目标目录路径,如/home/project); -可选参数:recursive(是否递归列出子目录,布尔值,默认 false) - -当需要获取指定文件的完整内容(如分析代码、验证配置)时,调用 read 工具。 -必填参数:filePath(目标文件完整路径,如/home/project/main.py) -可选参数:encoding(文件编码,默认 utf-8) - -当需要在指定路径下匹配关键词 / 正则表达式、定位目标文本时,调用 grep 工具。 -必填参数:pattern(匹配模式 / 正则,如error:.*)、path(目标文件 / 目录路径) -可选参数:ignore_case(是否忽略大小写,布尔值,默认 false),recursive(是否递归目录,布尔值,默认 true) - - -当需要新建文件或向文件写入内容(非增量修改)时,调用 write 工具。 -必填参数:filePath(目标文件路径)、content(待写入的文本内容) -可选参数:overwrite(是否覆盖原有文件,布尔值,默认 false) - -当需要精准替换文件中的指定文本片段时,调用 edit 工具。 -必填参数:filePath(目标文件路径)old_str(待替换的旧文本)、new_str(替换后的新文本); -可选参数:backup(是否创建文件备份,布尔值,默认 true) - -当需要在工作区目录内执行任意终端命令、脚本、系统操作时,调用 bash 工具。 -必填参数:command(待执行的 bash 命令字符串,如 grep 'error' ./log) -可选参数:workdir(命令执行目录,字符串,默认工作区根目录),timeout(命令超时时间,整数,单位毫秒,默认 120000),description(命令用途说明,字符串,默认空) - -你必须严格按照以下JSON格式返回工具调用指令,不要返回其他内容: -{ - "tool": "工具名称(如list/read/bash)", - "params": {"参数名": "参数值"}, - "thought": "你调用该工具的原因" -} - -调用工具时,你只能严格遵守json格式,不能输出多余字符,一次只能调用一个工具 -输出json完成后,主动终止对话,等待工具响应 \ No newline at end of file diff --git a/configs/persona.txt.example b/configs/persona.txt.example deleted file mode 100644 index 0d0b2141..00000000 --- a/configs/persona.txt.example +++ /dev/null @@ -1,15 +0,0 @@ -你是 NeoCode,一个专业、可靠、简洁的 AI 编程助手。 - -要求: -- 优先给出可执行、可落地的工程方案 -- 回答简洁清晰,必要时给出步骤 -- 对不确定的内容明确说明假设 -- 默认使用中文回答 - -安全与工具执行要求(必须遵守): -- 对可能有副作用的操作(尤其是 bash、写文件、网络请求),先进行风险判断,再执行。 -- 当命中安全策略为 deny 时,必须明确拒绝并解释原因,不得尝试绕过。 -- 当命中安全策略为 ask 时,先向用户确认,再继续执行。 -- 执行 bash 前必须说明命令目的、影响范围与关键参数;优先使用只读、可回滚、最小权限命令。 -- 严禁执行高危破坏性命令(如无边界删除、系统级破坏、恶意下载执行等)。 -- 涉及路径操作时,默认限定在当前工作区内,不对工作区外路径进行读写。 diff --git a/configs/security/blacklist.yaml b/configs/security/blacklist.yaml deleted file mode 100644 index 4e648be7..00000000 --- a/configs/security/blacklist.yaml +++ /dev/null @@ -1,6 +0,0 @@ -rules: - - target: "**/*.env" - read: deny - write: deny - - command: "rm -rf *" - exec: deny diff --git a/configs/security/whitelist.yaml b/configs/security/whitelist.yaml deleted file mode 100644 index fe83cc81..00000000 --- a/configs/security/whitelist.yaml +++ /dev/null @@ -1,5 +0,0 @@ -rules: - - target: "src/**/*.go" - read: allow - - command: "go version" - exec: allow diff --git a/configs/security/yellowlist.yaml b/configs/security/yellowlist.yaml deleted file mode 100644 index 13a117ec..00000000 --- a/configs/security/yellowlist.yaml +++ /dev/null @@ -1,5 +0,0 @@ -rules: - - target: "src/**/*.go" - write: ask - - command: "go build *" - exec: ask diff --git a/docs/API_Contract_Guide.md b/docs/API_Contract_Guide.md deleted file mode 100644 index dd5b38da..00000000 --- a/docs/API_Contract_Guide.md +++ /dev/null @@ -1,96 +0,0 @@ -# 📄 NeoCode API 契约书与开发指南 (v1.0) - -## 1. 概述 (Overview) -为了实现 NeoCode 项目的前后端解耦,我们引入了基于 **Protocol Buffers (Protobuf)** 的 API 契约层。该契约定义了前端(TUI)与后端(Server)通信的标准格式,是项目从“本地调用版”向“网络分布式版”演进的核心基石。 - -**核心原则**: -- **契约先行**:所有字段变更必须先修改 `.proto` 文件。 -- **逻辑隔离**:`api/proto` 目录下的代码仅作为数据标准,不包含业务逻辑。 -- **双向测试**:前端与后端可基于此契约进行独立 Mock 测试。 - ---- - -## 2. 现阶段 API 契约详述 - -契约原稿位于:`api/proto/chat.proto` - -### 2.1 核心消息结构 -| 消息名称 | 说明 | 关键字段 | -| :--- | :--- | :--- | -| `Message` | 单条对话条目 | `role` (角色), `content` (正文) | -| `ChatRequest` | 前端发起的请求 | `model` (模型ID), `messages` (历史列表) | -| `ChatResponse` | 后端回传的响应 | `content` (正文片段), `is_finished` (结束标识) | -| `Status` | **错误处理块** | `code` (状态码, 0为成功), `message` (错误描述) | -| `ResponseMetadata` | **元数据块** | `model_name` (实际模型), `usage_tokens` (Token统计) | - -### 2.2 命名规范 -- **Proto 文件**:使用 `snake_case`(下划线命名),如 `user_input`。 -- **生成的 Go 代码**:自动转为 `PascalCase`(首字母大写),如 `UserInput`,以符合 Go 的导出规则。 - ---- - -## 3. 《环境安装极简指南》 - -为了运行自动化脚本生成代码,组员需完成以下三步配置: - -### 第一步:下载并安装 Protoc 编译器 -1. **下载**:访问 [Protobuf Releases](https://github.com/protocolbuffers/protobuf/releases)。 -2. **选择**:Windows 用户请下载 `protoc-xx.x-win64.zip`。 -3. **配置**:解压并将 `bin` 目录(内含 `protoc.exe`)的路径添加到系统的 **环境变量 Path** 中。 -4. **验证**:打开终端输入 `protoc --version`,看到版本号即成功。 - -### 第二步:安装 Go 语言插件 -在终端执行以下命令,让编译器支持生成 Go 代码: -```powershell -go install google.golang.org/protobuf/cmd/protoc-gen-go@latest -``` -*注:请确保 `$GOPATH/bin`(通常是 `C:\Users\用户名\go\bin`)也在 Path 环境变量中。* - -### 第三步:同步运行时依赖 -在 `test1` 项目根目录下执行: -```powershell -go get google.golang.org/protobuf -``` - ---- - -## 4. 自动化工具链使用 - -我们提供了快捷脚本,组员无需记忆复杂的编译命令。 - -- **脚本位置**:`scripts/gen_proto.ps1` -- **使用方法**:在 `test1` 目录下运行: - ```powershell - ./scripts/gen_proto.ps1 - ``` -- **输出结果**:成功后会自动在 `api/proto/` 目录下更新 `chat.pb.go` 文件。**请勿手动修改生成的 .pb.go 文件。** - ---- - -## 5. 前后端分离测试指引 - -有了契约层,前后端可以“背靠背”工作: - -### 5.1 后端组员:验证契约兼容性 -后端同学无需启动 TUI 界面,只需编写单元测试: -1. 手动构造 `proto.ChatRequest` 结构体。 -2. 编写转换函数将 `proto` 对象转为 `domain` 对象。 -3. 验证 Service 返回的数据是否能填入 `proto.ChatResponse`。 -*参考示例:`test/contract_test.go`* - -### 5.2 前端组员:独立 UI Mock 测试 -前端同学无需配置后端的 API Key 或 LLM 环境: -1. 在测试代码中引入 `go-llm-demo/api/proto` 包。 -2. 手动构造各种 `proto.ChatResponse` 假数据(包含成功的、报错的、包含元数据的)。 -3. 直接将假数据喂给 TUI 的 `update` 逻辑,调试流式高亮和错误提示框。 - ---- - -## 6. 架构师的温馨提示 - -1. **关于红线报错**:如果 IDE 中生成的 `pb.go` 文件有红线,请运行 `go mod tidy`。 -2. **版本一致性**:请确保团队内部的 `protoc` 版本差异不要太大(建议 34.1)。 -3. **契约的法律效力**:一旦 `chat.proto` 经过协商合并入主分支,任何破坏契约的字段修改都应被视为 **Breaking Change**。 - ---- -*NeoCode 架构组 2026-03-23* diff --git a/docs/Security_Interceptor.md b/docs/Security_Interceptor.md deleted file mode 100644 index 49576546..00000000 --- a/docs/Security_Interceptor.md +++ /dev/null @@ -1,100 +0,0 @@ -# 🛡️ NeoCode 安全拦截器 (Security Interceptor) 模块文档 - -## 1. 接口提供与调用规格说明 - -本模块为 Agent 工具执行层(Executor)提供统一的底层安全校验接口。所有具有潜在副作用的工具(如文件读写、终端执行、网络请求)在正式执行前,**必须**同步调用此接口获取权限批文。 - -### 1.1 核心状态枚举 (`Action`) - -拦截器会返回以下三种明确的决策动作: - -- `ActionDeny` ("deny"): **拒绝执行**。触发黑名单或安全策略,底层必须停止操作并向模型返回错误。 -- `ActionAllow` ("allow"): **静默放行**。命中白名单,可直接执行无需用户干预。 -- `ActionAsk` ("ask"): **请求确认**。命中黄名单或未匹配任何规则,必须挂起工作流并请求用户授权。 - -### 1.2 对外暴露接口 (`SecurityService`) - -```Go -// SecurityService 接口定义 -type SecurityService interface { - Check(toolType string, target string) domain.Action -} -``` - -**参数规范:** - -- `toolType` (string): 必须为以下四种标准工具类型之一: - - `"Read"`: 读取本地文件。 - - `"Write"`: 写入或修改文件。 - - `"Bash"`: 执行 Shell 命令。 - - `"WebFetch"`: 发起外部网络请求。 -- `target` (string): 操作的目标(路径、命令或域名)。 - ---- - -## 2. 模块架构与目录结构 - -### 2.1 架构概述 - -本模块采用**“防御性路径预处理”**与**“三态漏斗过滤”**架构。在匹配规则前,先通过规范化引擎消除路径绕过风险,随后依次通过黑、白、黄名单进行判决。 - -### 2.2 目录结构 (test1 项目) - -```Plaintext -test1/ -├── configs/security/ # [配置层] YAML 规则文件 -│ ├── blacklist.yaml # 绝对禁区 (Deny) -│ ├── whitelist.yaml # 信任区域 (Allow) -│ └── yellowlist.yaml # 需确认区域 (Ask) -├── internal/server/ -│ ├── domain/ -│ │ └── security.go # 核心接口与数据结构定义 -│ ├── service/ -│ │ ├── security_service.go # 拦截引擎实现 (包含清洗与匹配逻辑) -│ │ └── security_service_test.go # 90.6% 覆盖率的自动化测试 -``` - ---- - -## 3. 核心安全防御机制 (Security Hardening) - -模块在规则匹配前引入了主动防御逻辑,专门应对**对抗性输入 (Adversarial Inputs)**。 - -### 3.1 路径规范化 (Normalization) -针对 `Read` 和 `Write` 操作,系统会自动执行: -- **`filepath.Clean`**: 消除 `./`、多余斜杠以及 `../` 回溯符。将 `src/../.git/config` 强行转化为 `.git/config`。 -- **`filepath.ToSlash`**: 将 Windows 的 `\` 统一为 `/`,防止利用平台差异逃逸。 - -### 3.2 跨域主动拦截 -- **边界防御**: 若清洗后的路径以 `..` 开头(意图跳出当前项目工作目录),拦截器会跳过所有名单逻辑,**直接返回 `ActionDeny`**。 - ---- - -## 4. 自动化测试与质量保证 - -本模块通过了严苛的自动化测试,核心逻辑(`security_service.go`)的 **测试覆盖率达到 90.6%**。 - -### 4.1 测试场景覆盖 -- **基础匹配**: 黑名单命中、白名单放行、黄名单询问。 -- **对抗性绕过**: 模拟路径穿越(Traversal)、冗余斜杠、跨目录攻击。 -- **通配符深度**: 验证 `**/*.go` 对多级目录的递归匹配。 -- **网络域名**: 验证对 `*.domain.com` 子域名的通配支持。 - -### 4.2 运行测试 -```bash -go test -v internal/server/service/security_service.go internal/server/service/security_service_test.go -``` - ---- - -## 5. 具体实现原理 - -### 5.1 优先级漏斗判决 (Funnel Priority) -1. **最高级 (Hard-Deny)**: 路径跨域或命中黑名单规则。 -2. **第二级 (Allow)**: 命中白名单规则。 -3. **第三级 (Ask)**: 命中黄名单规则。 -4. **兜底 (Default)**: 未匹配任何规则,降级为 `ActionAsk`(零信任原则)。 - -### 5.2 匹配引擎双重分流 -- **文件系统语义 (doublestar)**: 处理 `Read/Write`,支持真正的 `**` 跨目录匹配。 -- **纯文本正则语义 (regexp)**: 处理 `Bash/WebFetch`,通过 `regexp.QuoteMeta` 防止正则注入,并将 `*` 安全映射为命令通配符。 diff --git a/docs/Security_Interceptor_Test_Manual.md b/docs/Security_Interceptor_Test_Manual.md deleted file mode 100644 index f5f342c2..00000000 --- a/docs/Security_Interceptor_Test_Manual.md +++ /dev/null @@ -1,63 +0,0 @@ -# 🛡️ 安全拦截模块 (Security Interceptor) 测试手册 - -## 1. 模块概述 -安全拦截模块是 NeoCode 的核心安全防线,负责审计 AI 试图执行的所有敏感操作(读文件、写文件、执行命令、网络请求)。它采用 **黑名单、白名单、黄名单** 三级过滤机制,并具备自动化的路径规范化防御能力。 - -## 2. 核心安全机制 -### 2.1 三级名单逻辑 -- **黑名单 (Blacklist)**: 优先级最高。匹配成功则立即 `ActionDeny`(拒绝)。 -- **白名单 (Whitelist)**: 优先级中等。匹配成功则 `ActionAllow`(允许)。 -- **黄名单 (Yellowlist)**: 优先级最低。匹配成功则 `ActionAsk`(询问用户)。 -- **默认兜底**: 若均未匹配,默认执行 `ActionAsk`。 - -### 2.2 路径规范化 (Path Normalization) -为防止 AI 通过构造特殊路径绕过拦截,模块在匹配前会执行以下操作: -- **Cleaning**: 消除 `../`、`./` 以及冗余斜杠(如 `///`)。 -- **Slash Uniformity**: 统一将 Windows 的反斜杠 `\` 转换为正斜杠 `/`,确保跨平台兼容。 -- **Cross-Domain Prevention**: 严禁路径以 `..` 开头,防止 AI 访问工作目录外的系统文件。 - -## 3. 测试用例设计 - -### 3.1 基础匹配测试 -| 场景 | 工具类型 | 输入目标 | 预期结果 | 说明 | -| :--- | :--- | :--- | :--- | :--- | -| 黑名单拦截 | Read | `.git/config` | `Deny` | 禁止访问敏感 Git 配置 | -| 白名单允许 | Read | `src/main.go` | `Allow` | 允许正常阅读源码 | -| 黄名单询问 | Write | `src/main.go` | `Ask` | 修改代码需经用户确认 | -| 命令拦截 | Bash | `rm -rf /` | `Deny` | 禁止高危删库命令 | - -### 3.2 对抗性绕过测试 (Security Focus) -| 场景 | 输入目标 | 处理后路径 | 预期结果 | 防御原理 | -| :--- | :--- | :--- | :--- | :--- | -| 路径穿越绕过 | `src/../.git/config` | `.git/config` | `Deny` | `filepath.Clean` 消除回溯符 | -| 冗余斜杠绕过 | `.git////config` | `.git/config` | `Deny` | 消除多余分隔符 | -| 跨工作目录攻击 | `../../etc/passwd` | `../../etc/passwd` | `Deny` | 识别并拦截 `..` 前缀 | -| 平台差异绕过 | `src\.git\config` | `src/.git/config` | `Deny` | 统一转换为正斜杠匹配 | - -### 3.3 复杂逻辑测试 -- **通配符测试**: 验证 `**/*.go` 是否能正确匹配深层目录。 -- **域名匹配**: 验证 `*.google.com` 是否能允许子域名请求。 -- **未知工具**: 验证传入非法 `toolType` 时,系统是否能安全地回退到 `Ask` 模式。 - -## 4. 自动化测试运行指南 - -### 4.1 环境准备 -确保已安装 Go 环境,并处于 `test1` 目录下。 - -### 4.2 执行单元测试 -运行以下命令执行专门的安全测试用例: -```bash -go test -v internal/server/service/security_service_test.go internal/server/service/security_service.go -``` - -### 4.3 查看测试覆盖率 -若要查看安全模块的测试覆盖情况: -```bash -go test -coverprofile=cover.out ./internal/server/service/... -go tool cover -html=cover.out -``` - -## 5. 维护建议 -1. **规则同步**: 修改 `configs/security/` 下的 YAML 文件后,务必运行单元测试验证规则是否生效。 -2. **正则优化**: 对于 `Bash` 命令的正则匹配,应遵循“最小特权原则”,避免编写过于宽泛的匹配模式。 -3. **日志审计**: 在生产环境中,所有被 `Deny` 的操作都应记录在案,以便进行安全回溯。 diff --git a/docs/TUI_REFINED_ARCHITECTURE.md b/docs/TUI_REFINED_ARCHITECTURE.md deleted file mode 100644 index 7363b3be..00000000 --- a/docs/TUI_REFINED_ARCHITECTURE.md +++ /dev/null @@ -1,104 +0,0 @@ -# NeoCode TUI 架构说明 - -## 核心设计 - -当前 TUI 仍然基于 Bubble Tea 的 TEA 模式,但重构后职责边界已经从旧版的 `app + infra` 结构调整为 `bootstrap + core + state + components + services`: - -- 状态驱动:界面输出由 `core.Model` 持有的状态决定。 -- 异步更新:模型响应、工具执行、记忆刷新都通过 `tea.Cmd` 回流到 `Update`。 -- 依赖收口:TUI 对后端实现的依赖统一集中在 `internal/tui/services/`,`core` 和 `components` 不直接触碰 `internal/server/...`。 -- 本地组装:当前 TUI 默认通过本地 `service + provider + repository + tools` 组装聊天能力,而不是通过独立的 gRPC/HTTP 客户端。 - ---- - -## 五层结构 - -### 入口层 - `cmd/tui/` - -- 解析命令行参数,目前支持 `--workspace`。 -- 负责启动前准备:设置终端 UTF-8、准备工作区、交互式检查 API Key、加载配置与人设。 -- 调用 `bootstrap.NewProgram(...)` 构建 Bubble Tea Program 并运行。 - -### 启动层 - `internal/tui/bootstrap/` - -- `setup.go` 负责工作区解析、配置文件初始化、API Key 校验前的交互式引导。 -- `runtime.go` 负责创建 `services.ChatClient`,再注入 `core.NewModel(...)`。 -- 这一层是依赖装配点,后续如果要切换成远端 API 客户端,也应该优先在这里替换实现。 - -### 状态机层 - `internal/tui/core/` - -- `model.go` 定义顶层 `Model`,聚合 UI 状态、聊天状态、Bubble 组件实例和流式通道。 -- `update.go` 负责按键处理、命令解析、消息流更新、工具调用闭环、记忆清理与模型切换。 -- `view.go` 负责顶层布局,组合状态栏、聊天区、帮助区、输入区。 -- `msg.go` 定义流式输出、工具结果、帮助切换等内部消息。 - -### 纯状态层 - `internal/tui/state/` - -- `ui_state.go` 保存窗口尺寸、当前模式、自动滚动等纯 UI 状态。 -- `chat_state.go` 保存消息历史、当前模型、记忆统计、命令历史、工作区和配置状态。 -- 状态层只保留结构体定义,不承载业务流程。 - -### 组件与适配层 - -#### 视图组件 - `internal/tui/components/` - -- 负责状态栏、输入框、帮助面板、消息列表、代码块高亮等纯渲染逻辑。 -- 输入是基础数据或轻量结构,输出是渲染后的字符串。 -- 不发起请求,不修改全局状态。 - -#### 服务适配 - `internal/tui/services/` - -- `api_client.go` 当前不是网络客户端,而是本地聊天适配器:直接组装 `internal/server/service`、`internal/server/infra/provider`、`internal/server/infra/repository` 和 `internal/server/infra/tools`。 -- 同时负责工作区根目录、提供商/模型规范化、工具调用封装、记忆统计等 TUI 依赖的运行时能力。 -- `core` 只依赖这里暴露的接口和数据结构,不关心底层是本地实现还是远程实现。 - ---- - -## 当前数据流 - -以“用户输入一条消息并触发工具调用”为例: - -1. 用户在输入框编辑内容,`core/update.go` 处理按键并更新 `textarea` 状态。 -2. 按下 `F5` 或 `F8` 后,`handleSubmit()` 将用户消息写入 `chat_state`,然后触发 `streamResponse(...)`。 -3. `services.ChatClient.Chat(...)` 启动本地聊天服务,流式返回模型输出。 -4. `StreamChunkMsg` 持续追加 assistant 内容;`StreamDoneMsg` 在流结束时检查最后一条 assistant 消息是否是工具调用 JSON。 -5. 若检测到 `{"tool":"...","params":{...}}`,TUI 会通过 `services.ExecuteToolCall(...)` 执行工具,并把工具结果重新注入为 system 上下文。 -6. 模型基于新的上下文继续生成,直到得到最终自然语言回复。 - ---- - -## 当前目录结构 - -```text -internal/tui/ -├── bootstrap/ # 启动准备与依赖装配 -│ ├── runtime.go -│ └── setup.go -├── components/ # 纯渲染组件 -│ ├── code_block.go -│ ├── help.go -│ ├── input_box.go -│ ├── message_list.go -│ └── statusbar.go -├── core/ # Bubble Tea 状态机 -│ ├── model.go -│ ├── msg.go -│ ├── update.go -│ └── view.go -├── services/ # 本地服务适配与运行时能力 -│ ├── api_client.go -│ └── runtime_services.go -└── state/ # 纯状态定义 - ├── chat_state.go - └── ui_state.go -``` - ---- - -## 约束建议 - -1. `core` 不直接引用 `internal/server/...`,所有后端能力统一经 `services` 暴露。 -2. `components` 只做渲染,不做状态修改和副作用。 -3. `state` 只放结构体,避免把流程控制重新塞回状态层。 -4. 与模型、工具、工作区、记忆相关的新能力,优先落在 `services` 或 `bootstrap`,不要堆进 `cmd/tui/main.go`。 -5. 如果未来切换到远端 API,优先替换 `services.ChatClient` 实现,尽量不改 `core` 的 Update/View 逻辑。 diff --git a/docs/config-management-detail-design.md b/docs/config-management-detail-design.md new file mode 100644 index 00000000..7d3d9701 --- /dev/null +++ b/docs/config-management-detail-design.md @@ -0,0 +1,33 @@ +# 配置管理模块详细设计 +## 模块职责 +`config` 模块主要负责四类事情: +- 加载和保存 YAML 配置文件 +- 从环境变量解析真实密钥 +- 管理 NeoCode 托管目录中的配置与 `.env` +- 向运行中的系统提供并发安全的配置读写能力 + +## 核心类型 +- `Config`:顶层应用配置,包含 Provider 列表、当前选中 Provider、当前模型、工作目录、Shell 和循环限制等信息 +- `ProviderConfig`:单个 Provider 的配置项,包括 Base URL、默认模型和 API Key 环境变量名 +- `Manager`:使用 `sync.RWMutex` 保护的配置访问器与修改器 +- `Loader`:对 YAML 文件和托管 `.env` 文件的文件系统封装 + +## 环境变量策略 +- YAML 只保存 `api_key_env`,不保存真实密钥。 +- `Loader.LoadEnvironment` 会尝试加载当前工作目录下的 `.env` 和 NeoCode 托管目录中的 `.env`。 +- `ProviderConfig.ResolveAPIKey` 在真正发起请求前通过 `os.Getenv` 读取密钥。 + +## 运行时更新 +- TUI 只能通过 `ConfigManager.Update` 修改配置。 +- 修改 Base URL 时只更新当前选中 Provider,并立即持久化。 +- 修改 API Key 时写入托管 `.env`,然后重新加载环境变量并刷新配置快照。 +- 修改模型时,同时更新 `current_model` 和当前 Provider 的 `model` 字段,保持状态一致。 + +## 默认值治理 +- 默认 Provider 名称、URL、模型和环境变量名统一定义在 `internal/config/model.go` 中。 +- 内建模型目录也收口在 `config` 包中,避免 TUI 自己维护一套零散的临时常量。 + +## 安全约束 +- 读操作统一走 `Get`,并返回拷贝后的配置快照。 +- 写操作统一走 `Update`,修改前后都要做校验。 +- 真实密钥不能出现在日志、状态栏、聊天流或错误提示中。 diff --git a/docs/detailed_architecture_guide.md b/docs/detailed_architecture_guide.md deleted file mode 100644 index b3af9b96..00000000 --- a/docs/detailed_architecture_guide.md +++ /dev/null @@ -1,120 +0,0 @@ -# NeoCode 架构详细说明文档 (细化版) - -## 1. 总体目标 -实现一个**轻量化 AI 编程助手**。 -- **输入**:自然语言指令(例如:“帮我写一个 Go 语言的 HTTP Client”)。 -- **过程**:AI 思考 -> 自动调用本地工具 (读写文件、运行命令) -> 验证结果。 -- **输出**:完成后的代码及运行报告。 -image - -> 如需修改架构图,请点击[此处](https://www.processon.com/v/69be5849570ada05a4e95984) - -## 2. 核心架构:四层模型 (Server 端) - -为了让系统易于维护,我们把后端逻辑像“汉堡”一样分层: - -### 第一层:Transport (接入层/传菜员) -- **位置**:`internal/server/transport/` -- **职责**:**把“外部语言”翻译成“内部语言”**。 - - **接单 (接入)**:接收来自 TUI (终端) 或其他客户端的请求。 - - **翻译 (解包)**:外界发来的是 JSON 或二进制流,它将其转换为 Service 层能懂的 Go 结构体。 - - **回复 (封包)**:把 Service 处理完的结果,按照外界要求的格式打包发回去。 -- **现状说明**:目前我们为了轻量化,TUI 和 Server 在同一个进程运行,通过函数直接调用。但逻辑上,这里就是“柜台”,负责把关进入系统的每一条指令。 - -### 第二层:Service (应用服务层/厨师长) -- **位置**:`internal/server/service/` -- **职责**:**整个系统的“大脑中枢”**。 - - **编排**:决定先做什么,后做什么。 - - **ReAct 循环**:核心逻辑。 - 1. 发送提示词给 AI。 - 2. AI 返回“我想调用工具 X”。 - 3. Service 调用 Infra 层的工具 X。 - 4. 把工具执行结果再喂给 AI。 - 5. 重复直到任务完成。 - -### 第三层:Domain (领域模型层/标准) -- **位置**:`internal/server/domain/` -- **职责**:**定义标准(契约)**。 - - **定义接口**:定义什么是 `ChatProvider` (AI 供应者),什么是 `Tool` (工具)。 - - **纯净性**:不依赖任何第三方库(如 OpenAI SDK),只定义结构体和接口。 - - **稳定性**:这是项目最稳定的部分,其他层都依赖它。 - -### 第四层:Infra (基础设施层/原材料与工具) -- **位置**:`internal/server/infra/` -- **职责**:**真正的“苦力活”**。 - - **LLM 实现**:具体怎么连 ModelScope,API Key 怎么传。 - - **工具实现**: - - `bash_tool.go`: 真的在电脑上跑命令。 - - `file_tool.go`: 真的读写硬盘里的文件。 - - **存储实现**:真的把记忆存进 `memory.json` 文件。 - ---- - -## 3. 💡 深度解析:为什么需要 API 和 Transport?(举个例子) - -很多同学会问:我直接在 TUI 里调 Service 不行吗?为什么还要分层? - -**我们举个“点快餐”的例子:** - -1. **api/proto (菜单标准)**: - - 菜单上规定:点“巨无霸”必须说明“是否加生菜”和“份数”。 - - 在 NeoCode 里,`api/proto` 更像未来远程化时会用到的菜单标准;当前本地 TUI 默认并不通过它发请求,但它仍然适合作为未来 HTTP/gRPC 接口的契约来源。 - -2. **Transport / Adapter (传菜员)**: - - 假设今天你在店里吃(TUI 和 Server 在同一个程序里),传菜员可以就在后厨门口,把单子直接递给厨师; - - 假设明天你用手机点外卖(TUI 在你手机上,Server 在云端),传菜员就要通过网络把单子传过去。 - - 当前 TUI 的这个“传菜员”主要落在 `internal/tui/services/`:它把本地 `service/provider/repository/tools` 组装成统一的聊天客户端接口,屏蔽底层到底是本地调用还是未来远程调用。 - -**现阶段的意义**: -虽然目前项目是“本地直接跑”,但我们依然保留了 `transport` 和 `api/proto` 的演进空间。以后如果想做**网页版**、**手机 App 版**或者**VSCode 插件版**,可以新增远端 Adapter/Transport,而尽量保持现有 `service` 逻辑不变。 - ---- - -## 4. 客户端架构 (TUI 端) - -- **位置**:`internal/tui/` -- **模式**:基于 Bubble Tea 的 Model-Update-View (MUV) 模式。 - - **Bootstrap (`bootstrap/`)**:启动前准备,负责工作区、配置文件和 API Key 引导。 - - **Core (`core/`)**:状态机。处理按键、命令、流式响应、工具调用闭环。 - - **State (`state/`)**:纯状态结构,如消息历史、窗口尺寸、当前模型、记忆统计。 - - **Services (`services/`)**:客户端的适配层。当前以本地组装方式调用后端 service/provider/repository/tools。 - - **Components (`components/`)**:UI 零件,如状态栏、帮助面板、消息列表、代码高亮块。 - ---- - -## 5. 目录结构详解 - -```text -/ -├── api/ # 【菜单】定义前后端说话的“协议标准” (如:一个请求里必须包含哪些字段) -├── cmd/ # 【电源开关】程序的启动入口 -│ ├── server/ # 启动后端的 main.go -│ └── tui/ # 启动界面的 main.go -├── configs/ # 【保险箱】存放 API Key 等敏感配置 -├── internal/ # 【核心实验室】禁止外部引用的私有代码 -│ ├── server/ -│ │ ├── domain/ # 【重要】定义标准接口 (AI接口、工具接口) -│ │ ├── infra/ # 【重要】具体工具的实现 (调AI、写文件、跑命令) -│ │ ├── service/ # 【核心】编排逻辑 (AI如何思考、如何连贯动作) -│ │ └── transport/ # 【柜台】处理外界请求的入口 -│ └── tui/ # 终端界面与本地适配层 -└── data/ # 记忆数据库 -``` - ---- - -## 6. 团队协作指南 (如何新增一个功能?) - -如果你想让 AI 助手学会“**删除文件**”: - -1. **在 Domain 层定规矩**:在 `domain` 中确认 `Tool` 接口是否满足需求。 -2. **在 Infra 层做工具**:在 `internal/server/infra/tools/` 下新建 `delete_tool.go`,实现具体的删除代码逻辑。 -3. **在 Service 层注册**:在 `internal/server/service/chat_service.go` 的初始化代码里,把你的“删除工具”加入工具箱。 -4. **测试**:编写 `delete_tool_test.go` 确保它不会误删系统文件。 - ---- - -## 7. 核心开发守则 -1. **禁止跨层调用**:`core` 和 `components` 不能绕过 `tui/services` 直接依赖后端实现或工具实现。 -2. **依赖倒置**:Service 只依赖 Domain 里的接口,不依赖 Infra 里的具体实现。 -3. **安全第一**:所有 `infra/tools` 里的命令执行,必须经过安全过滤。 diff --git a/docs/github-actions-codecov-guide.md b/docs/github-actions-codecov-guide.md deleted file mode 100644 index 75ba5154..00000000 --- a/docs/github-actions-codecov-guide.md +++ /dev/null @@ -1,802 +0,0 @@ -# GitHub Actions + Codecov 使用指南 - -本文面向第一次接触 CI 的同学,结合本仓库当前的 Go 项目配置,讲清楚 GitHub Actions 和 Codecov 是什么、我们为什么使用它们、如何完成首次接入、日常怎么看结果,以及出了问题应该怎么排查。 - ---- - -## 1. 先用一句话理解这两者 - -- GitHub Actions:GitHub 自带的自动化平台。我们把“拉代码、安装依赖、编译、测试、上传结果”写成一个 YAML 文件后,GitHub 会在 PR、push 等事件发生时自动执行。 -- Codecov:专门看测试覆盖率的平台。它读取测试生成的覆盖率报告,告诉我们“哪些代码被测到了,哪些没测到”。 - -可以把它们理解为: - -- GitHub Actions = 自动执行流水线的工人 -- Codecov = 专门分析测试覆盖率的质检员 - ---- - -## 2. 我们为什么要引入它们 - -在没有 CI 之前,团队通常会遇到这些问题: - -- 有人本地没跑测试就提 PR -- 有人本地能过,换台机器就不过 -- 看不出一个改动有没有让覆盖率下降 -- reviewer 需要手工确认“有没有编译、有没有测试、有没有新增未覆盖代码” - -引入 GitHub Actions + Codecov 后: - -- 每次 PR 创建或更新时,自动 build 和 test -- 覆盖率自动上传并显示在 PR 或 Codecov 页面里 -- reviewer 不需要先相信“我本地跑过了”,而是直接看系统结果 -- 可以进一步配合分支保护,要求 CI 必须通过后才能合并 - ---- - -## 3. GitHub Actions 的核心概念 - -如果你第一次看 `.github/workflows/*.yml`,建议先记住下面 6 个词: - -### 3.1 workflow - -一个 workflow 就是一份自动化流程文件。 - -例如本仓库的: - -- `.github/workflows/ci.yml` - -GitHub 会读取这里面的配置,并在指定时机执行。 - -### 3.2 event - -event 是“什么事情发生时触发 workflow”。 - -常见事件: - -- `pull_request`:有人创建或更新 PR -- `push`:有人把代码推到分支 -- `workflow_dispatch`:手动点按钮执行 - -### 3.3 job - -job 是一组步骤的集合。一个 workflow 可以有一个或多个 job。 - -例如: - -- `build-test` - -它表示“在一台 runner 上完成 build、test、coverage 上传”。 - -### 3.4 step - -step 是 job 内的一步操作。 - -例如: - -- checkout 代码 -- 安装 Go -- 执行 `go build ./...` -- 执行 `go test ./...` -- 上传覆盖率到 Codecov - -### 3.5 action - -action 是别人或官方封装好的可复用步骤。 - -例如本仓库使用了: - -- `actions/checkout` -- `actions/setup-go` -- `codecov/codecov-action` - -### 3.6 runner - -runner 是实际执行 workflow 的机器。 - -本仓库现在用的是: - -- `ubuntu-latest` - -也就是 GitHub 提供的 Linux 虚拟机。 - ---- - -## 4. Codecov 的核心概念 - -### 4.1 覆盖率是什么 - -覆盖率不是“代码质量”的全部,但它是一个非常有用的信号。 - -它主要回答: - -- 这段代码有没有被测试执行到 -- 这次 PR 新增的代码有没有测试覆盖 - -注意: - -- 高覆盖率不等于高质量 -- 低覆盖率也不一定代表代码有问题 -- 但完全没有覆盖率数据时,团队会缺少一个很重要的客观信号 - -### 4.2 Codecov 看什么 - -Codecov 通常会看两类覆盖率: - -- Project coverage:整个项目当前的整体覆盖率 -- Patch coverage:这次 PR 新增或修改的代码覆盖率 - -Patch coverage 对 PR review 很有价值,因为它更贴近“这次改动是否被测试到”。 - -### 4.3 Codecov 需要什么输入 - -Codecov 自己不会跑测试,它只负责“接收和分析报告”。 - -所以流程是: - -1. GitHub Actions 先跑测试 -2. 测试生成覆盖率文件 -3. Codecov Action 上传覆盖率文件 -4. Codecov 解析并展示结果 - -对于 Go 项目,最常见的覆盖率文件是: - -- `coverage.out` - ---- - -## 5. 本仓库现在的 CI 做了什么 - -本仓库当前 CI 文件是: - -- `.github/workflows/ci.yml` - -主要流程如下: - -1. 在 PR 创建、更新、重新打开、标记为 Ready for review 时自动触发 -2. 如果 PR 仍然是 draft,则整个 job 跳过 -3. 拉取仓库代码 -4. 根据 `go.mod` 安装 Go 并启用缓存 -5. 执行 `go build ./...` -6. 执行 `go test ./... -covermode=atomic -coverprofile=coverage.out` -7. 把 `coverage.out` 上传到 Codecov - -简化后的执行顺序可以理解为: - -```text -PR - -> GitHub Actions 触发 - -> Draft PR 时跳过 job - -> Checkout 代码 - -> Setup Go - -> Build - -> Test + 生成 coverage.out - -> 上传到 Codecov - -> PR 上看到 CI / Coverage 结果 -``` - ---- - -## 6. 首次接入操作手册 - -这一节是第一次配置时最重要的部分。 - -### 6.1 第一步:确认仓库里已经有 workflow 文件 - -当前仓库应存在: - -- `.github/workflows/ci.yml` - -如果这个文件已经在默认分支中,GitHub Actions 就具备运行入口了。 - -### 6.2 第二步:启用 GitHub Actions - -通常仓库第一次加 workflow 后,GitHub 会自动识别。 - -你可以这样确认: - -1. 打开仓库首页 -2. 点击顶部 `Actions` -3. 如果能看到 workflow 列表或运行记录,说明 Actions 已生效 - -### 6.3 第三步:注册并接入 Codecov - -建议由仓库管理员完成。 - -步骤: - -1. 打开 [Codecov](https://codecov.io/) -2. 使用 GitHub 账号登录 -3. 安装或授权 Codecov GitHub App -4. 选择要接入的仓库 - -如果组织安装 Codecov GitHub App 时选择了“Only Select Repositories”,记得把本仓库勾上。 - -### 6.4 第四步:获取 Codecov Token - -在 Codecov 中进入对应仓库后,找到仓库配置页的 General 区域,可以看到 Repository upload token。 - -官方说明里提到,Repository upload token 可以在仓库配置页查看。 - -### 6.5 第五步:把 Token 配到 GitHub Secrets - -在 GitHub 仓库里: - -1. 打开 `Settings` -2. 打开 `Secrets and variables` -3. 选择 `Actions` -4. 点击 `New repository secret` -5. 名称填写:`CODECOV_TOKEN` -6. 值填写:刚才从 Codecov 复制出来的 token - -注意: - -- 只粘贴 token 值本身 -- 不要写成 `CODECOV_TOKEN=xxxx` - -### 6.6 第六步:发一个 PR 验证配置 - -推荐做法: - -1. 新建一个小分支 -2. 做一个很小的改动 -3. 提交并创建 PR -4. 观察 `Actions` 页面是否开始跑 `CI` -5. 观察 PR 页面是否出现 build/test 状态 -6. 观察 Codecov 页面是否出现新的覆盖率上传记录 - ---- - -## 7. 如何阅读当前 `ci.yml` - -下面按块解释本仓库当前配置。 - -说明: - -- 本节所有 YAML 片段都以仓库当前已经提交的 `.github/workflows/ci.yml` 为准。 -- 如果你在其他教程里看到 `actions/checkout@v4` 或 `actions/setup-go@v5`,那通常只是当时常见的示例版本;阅读本仓库文档时,应优先以仓库实际 CI 配置为准。 -- 当前仓库使用 `actions/checkout@v5` 与 `actions/setup-go@v6`,文档会与这份实际配置保持同步。 - -### 7.1 触发条件 - -```yaml -on: - pull_request: - types: - - opened - - synchronize - - reopened - - ready_for_review -``` - -含义: - -- 创建 PR 时跑 -- PR 有新提交时跑 -- 关闭后重新打开 PR 时跑 -- 草稿 PR 转为正式评审时跑 - -为什么这样配: - -- PR 阶段保证改动被检查 -- 当前工作流先聚焦 PR 质量门禁,保持简单稳定 - -### 7.2 Draft PR 跳过 - -```yaml -jobs: - build-test: - if: github.event.pull_request.draft == false -``` - -含义: - -- 如果当前 PR 还是草稿状态,`build-test` job 不执行 - -好处: - -- 减少 draft PR 反复更新时的 CI 消耗 -- 等作者准备好进入评审,再正式跑完整检查 - -### 7.3 并发控制 - -```yaml -concurrency: - group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true -``` - -含义: - -- 同一个 PR 如果连续 push 多次,旧的 CI 会被自动取消 -- 只保留最新一次运行 - -好处: - -- 节约 GitHub Actions 分钟数 -- 避免 reviewer 看一堆过期结果 - -### 7.4 权限控制 - -```yaml -permissions: - contents: read -``` - -含义: - -- workflow 只需要读取仓库内容,不需要写权限 - -这是一种更安全、更易维护的默认做法。 - -### 7.5 Checkout - -```yaml -- name: Checkout repository - uses: actions/checkout@v5 -``` - -作用: - -- 把仓库代码拉到 runner 上 - -没有这一步,后续 build/test 都没有源码可执行。 - -### 7.6 Setup Go - -```yaml -- name: Setup Go - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - cache: true -``` - -作用: - -- 根据 `go.mod` 选择 Go 版本 -- 开启 Go 依赖缓存 - -为什么推荐这样做: - -- 避免把 Go 版本写死两份 -- 与仓库当前依赖状态绑定,维护成本更低 - -版本说明: - -- 当前仓库的 CI 已经使用 `actions/setup-go@v6` -- 同样,前面的 checkout 步骤使用的是 `actions/checkout@v5` -- 如果后续团队决定升级这些 Action 版本,应优先修改 `.github/workflows/ci.yml`,再同步更新本指南 - -### 7.7 Build - -```yaml -- name: Build - run: go build ./... -``` - -作用: - -- 编译仓库中所有 Go 包 - -它主要防止: - -- 编译错误 -- import 问题 -- 某些包只在测试外路径上才会暴露的问题 - -### 7.8 Test with coverage - -```yaml -- name: Test with coverage - run: go test ./... -covermode=atomic -coverprofile=coverage.out -``` - -作用: - -- 跑所有测试 -- 生成覆盖率文件 `coverage.out` - -参数说明: - -- `./...`:递归测试所有包 -- `-coverprofile=coverage.out`:把覆盖率结果写到文件 -- `-covermode=atomic`:Go 官方常见覆盖率模式之一,适合并发场景,结果更稳健 - -### 7.9 Upload coverage to Codecov - -```yaml -- name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 - with: - files: ./coverage.out - flags: unittests - fail_ci_if_error: true - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} -``` - -作用: - -- 把当前生成的 `coverage.out` 上传到 Codecov - -几个关键字段: - -- `files`: 告诉 Codecov 上传哪个覆盖率文件 -- `flags`: 给这次上传打标签,方便以后做多套测试拆分 -- `fail_ci_if_error: true`: 如果上传失败,CI 也失败,避免“测试过了但覆盖率丢了” -- `CODECOV_TOKEN`: 通过 `env` 传给上传步骤,用于让 Codecov 识别当前仓库 - ---- - -## 8. 当前配置对 fork PR 的影响 - -当前 workflow 的上传步骤直接依赖: - -- `CODECOV_TOKEN` - -这意味着: - -- 在本仓库内部正常提 PR 时,通常没有问题 -- 如果将来是 fork 仓库向主仓提 PR,GitHub Actions 默认可能拿不到主仓 secrets -- 一旦拿不到 `CODECOV_TOKEN`,Codecov 上传步骤就可能失败 - -这不是当前配置“错了”,而是它更适合: - -- 仓库内部协作 -- 先把主流程跑通 - -如果未来团队希望更稳妥地支持 fork PR,可以再做增强,例如: - -- 对 fork PR 跳过上传步骤 -- 或者在公开仓库结合 Codecov 的 tokenless upload 能力重新设计上传策略 - ---- - -## 9. 组员日常怎么使用 - -对大多数开发同学来说,日常只需要会下面这套流程。 - -### 9.1 提交代码前 - -建议本地先跑: - -```bash -go build ./... -go test ./... -``` - -理由: - -- CI 不是替代本地检查 -- CI 更像“最后一道统一验证” - -### 9.2 创建 PR 后 - -组员需要做的事情: - -1. 打开 PR -2. 看 GitHub 页面里的检查状态是否开始运行 -3. 等 `CI` 结果出来 -4. 若失败,点进日志定位问题 -5. 若通过,再进入代码评审 - -### 9.3 Reviewer 看什么 - -reviewer 最少看三件事: - -1. CI 是否通过 -2. 是否有测试 -3. Codecov 显示的 patch coverage 是否明显异常下降 - -### 9.4 合并前 - -建议团队约定: - -- CI 不通过,不合并 -- 覆盖率上传失败,要么修好,要么说明原因 -- 对高风险改动,关注 patch coverage,而不仅是整体 project coverage - ---- - -## 10. 如何查看运行结果 - -### 10.1 在 GitHub 里看 - -位置: - -- 仓库首页 -> `Actions` - -你会看到: - -- 哪次运行成功 -- 哪次运行失败 -- 每一步花了多久 -- 每一步的详细日志 - -### 10.2 在 PR 页面看 - -PR 页面通常会显示检查状态,比如: - -- 正在运行 -- 成功 -- 失败 - -点击对应检查名,可以直接跳到具体日志。 - -### 10.3 在 Codecov 里看 - -在 Codecov 仓库页面里,通常可以看到: - -- 当前整体覆盖率 -- 与基线相比是升是降 -- 每次提交或 PR 的覆盖率变化 -- 哪些文件覆盖率高,哪些低 - -如果启用了 GitHub Checks 或状态检查,还可能在 PR 中直接看到 coverage 相关信息。 - ---- - -## 11. 最常见的失败场景与排查方法 - -这一节非常适合组员收藏。 - -### 11.1 `go build ./...` 失败 - -常见原因: - -- 代码编译错误 -- 缺失 import -- 条件编译或平台差异 - -排查方式: - -1. 打开 Actions 日志 -2. 找 `Build` 步骤 -3. 看失败的包名和报错行 -4. 在本地复现 `go build ./...` - -### 11.2 `go test ./...` 失败 - -常见原因: - -- 单元测试本身失败 -- 与本地环境不一致 -- 依赖了本地文件、环境变量、网络 - -排查方式: - -1. 打开 `Test with coverage` 步骤 -2. 查看是哪个 package 失败 -3. 本地执行同样命令复现 -4. 尽量把测试改成不依赖外部环境 - -### 11.3 Codecov 上传失败 - -常见原因: - -- 没配置 `CODECOV_TOKEN` -- token 填错 -- 覆盖率文件路径不对 -- 覆盖率文件没生成 - -排查顺序: - -1. 看 `coverage.out` 是否在测试步骤生成 -2. 看 workflow 里 `files` 路径是否正确 -3. 看 GitHub Secrets 是否存在 `CODECOV_TOKEN` -4. 去 Codecov 仓库配置页重新复制 token - -### 11.4 fork PR 为什么没上传覆盖率 - -因为 fork PR 默认不能访问仓库 secrets。 - -这不是配置错了,而是 GitHub 的安全机制。 - -如果将来你们是公开仓库,并且组织愿意在 Codecov 中开启 public repo tokenless upload,再来调整策略。 - -### 11.5 为什么本地能过,CI 不能过 - -常见原因: - -- 本地缓存影响 -- 本地 Go 版本和 CI 不一致 -- 本地有未提交文件 -- 本地环境变量不同 - -建议: - -- 尽量让本地与 CI 使用同一 Go 版本 -- 避免测试依赖机器特有环境 -- 优先相信“干净环境”下的 CI 结果 - ---- - -## 12. 如何给团队建立统一约定 - -建议团队落地下面这些规则: - -### 12.1 开发约定 - -- 提 PR 前至少本地跑一次 `go test ./...` -- 新增逻辑尽量补测试 -- 如果 CI 失败,提 PR 的人优先修复 - -### 12.2 评审约定 - -- reviewer 默认检查 CI 状态 -- 对新增逻辑关注 patch coverage -- 对覆盖率下降明显的 PR,要求说明原因 - -### 12.3 主分支约定 - -- 开启 branch protection -- 要求 `CI` 必须通过 -- 禁止绕过检查直接合并 - ---- - -## 13. 推荐的后续增强 - -当前这套方案已经足够做稳定的基础 CI,但后续还可以继续增强。 - -### 13.1 增加格式化或静态检查 - -例如: - -- `go fmt ./...` -- `go vet ./...` -- `golangci-lint run` - -如果团队后续希望把“能编译、能测试”进一步升级成“基础质量门禁”,最推荐优先增加 `golangci-lint`。它是 Go 社区非常常见的综合静态分析工具,覆盖的问题范围通常比 `go vet` 更广,也更适合在 PR 阶段做统一检查。 - -可以在 GitHub Actions 中增加类似步骤: - -```yaml -- name: Lint - uses: golangci/golangci-lint-action@v6 - with: - version: latest -``` - -### 13.2 增加 PR 注释或状态门禁 - -例如通过 Codecov 配置: - -- 限制 patch coverage 不得低于某个阈值 -- 限制 project coverage 不得异常下降 - -### 13.3 增加矩阵测试 - -例如: - -- 同时测试多个 Go 版本 -- 不同操作系统 runner - -但在项目早期,优先推荐保持简单稳定,不要一上来就配得过重。 - ---- - -## 14. 常见问答 - -### Q1:GitHub Actions 会自动修代码吗 - -不会。 - -它只会按你写的步骤执行命令,并返回结果。 - -### Q2:Codecov 会帮我生成测试吗 - -不会。 - -它只会分析你已经生成的覆盖率报告。 - -### Q3:覆盖率高就代表代码没问题吗 - -不代表。 - -覆盖率只能说明“执行到了多少代码”,不能自动证明断言质量和业务正确性。 - -### Q4:为什么我们还要本地跑测试 - -因为本地提前发现问题比等 CI 更快,CI 是统一验证,不是替代本地自检。 - -### Q5:是不是必须先学会 YAML 才能维护 CI - -不用一开始就很精通。 - -大多数日常维护只需要会看: - -- 触发条件 -- 执行步骤 -- 哪一步失败了 - ---- - -## 15. 一份给组员的最短操作版 - -如果组员只想看最短版本,可以直接看这一段。 - -### 开发者 - -1. 本地先跑 `go build ./...` 和 `go test ./...` -2. 提交代码并创建 PR -3. 等 GitHub Actions 自动跑完 -4. 如果 `CI` 失败,打开日志修复 -5. 如果通过,再发起 review - -### Reviewer - -1. 看 PR 上的 `CI` 是否通过 -2. 看是否有对应测试 -3. 看 Codecov 是否提示覆盖率明显下降 -4. 没问题再合并 - -### 管理员 - -1. 确保 `.github/workflows/ci.yml` 在默认分支 -2. 确保 Codecov GitHub App 已接入仓库 -3. 确保 `CODECOV_TOKEN` 已配置 -4. 建议启用 branch protection 和 required status checks - ---- - -## 16. 官方参考资料 - -以下都是本指南整理时参考的官方文档,建议收藏。 - -- GitHub Actions 概念总览 - https://docs.github.com/en/actions/get-started/understand-github-actions - -- GitHub Workflow 基本说明 - https://docs.github.com/en/actions/concepts/workflows-and-actions/workflows - -- GitHub 官方 Go CI 教程 - https://docs.github.com/en/actions/tutorials/build-and-test-code/go - -- GitHub Secrets 官方说明 - https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets - -- GitHub 分支保护规则说明 - https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches - -- GitHub 必要状态检查排错 - https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks - -- Codecov GitHub Action 官方仓库 - https://github.com/codecov/codecov-action - -- Codecov GitHub 入门与上传覆盖率 - https://docs.codecov.com/docs/github-2-getting-a-codecov-account-and-uploading-coverage - -- Codecov Token 官方说明 - https://docs.codecov.com/docs/codecov-tokens - -- Codecov 添加 Token 官方说明 - https://docs.codecov.com/docs/adding-the-codecov-token - -- Codecov 状态检查说明 - https://docs.codecov.com/docs/commit-status - -- Codecov GitHub Checks 说明 - https://docs.codecov.com/docs/github-checks - ---- - -## 17. 给本仓库的结论 - -对本仓库来说,当前接入路线非常清晰: - -1. 使用 GitHub Actions 统一执行 build 和 test -2. 使用 Go 原生覆盖率输出 `coverage.out` -3. 使用官方 `codecov/codecov-action` 上传覆盖率 -4. 使用 `CODECOV_TOKEN` 作为最稳妥的认证方式 -5. 后续再按团队需要追加 branch protection、Codecov status checks 和更多质量门禁 - -如果你是第一次接触 CI,不需要一口气把所有高级功能都学完。先把下面这条主线吃透就够了: - -```text -代码改动 -> 发 PR -> GitHub Actions 自动 build/test -> 生成 coverage.out -> 上传到 Codecov -> reviewer 看结果 -> 合并 -``` - -只要这条链路跑顺了,团队协作效率和质量可见性就会明显提升。 diff --git a/docs/neocode-coding-agent-mvp-architecture.md b/docs/neocode-coding-agent-mvp-architecture.md new file mode 100644 index 00000000..a10e07a4 --- /dev/null +++ b/docs/neocode-coding-agent-mvp-architecture.md @@ -0,0 +1,509 @@ +# NeoCode Coding Agent MVP 架构设计 + +## 1. 目标 + +本文定义一个基于 Go + Bubble Tea 的本地 Coding Agent MVP,目标是先跑通最小闭环: + +`用户输入 -> Agent 推理 -> 调用工具 -> 获取结果 -> 继续推理 -> UI 展示` + +MVP 聚焦五个模块: + +1. provider:统一不同模型/API 的调用方式 +2. TUI:用户交互入口,承载输入、对话、侧边栏、会话 +3. tools:统一工具定义、参数校验、执行与结果封装 +4. config:管理本地配置、provider 切换、模型选择 +5. agent runtime:驱动整个 agent loop,是系统核心 + +--- + +## 2. 设计原则 + +- 模块职责清晰,避免 UI、模型调用、工具执行互相耦合 +- 面向接口设计,方便后续增加 provider 和工具 +- MVP 先保证主链路可用,不追求一次做全 +- 所有副作用操作统一收敛到 provider/tools/config 等边界层 +- Runtime 作为唯一编排中心,TUI 不直接调用 provider 和 tools + +--- + +## 3. 总体架构 + +```mermaid +flowchart LR + U["User"] --> TUI["Bubble Tea TUI"] + TUI --> APP["Application / Bootstrap"] + APP --> CFG["Config"] + APP --> RT["Agent Runtime"] + RT --> PR["Provider"] + RT --> TM["Tool Manager"] + TM --> FS["Filesystem Tool"] + TM --> SH["Bash Tool"] + TM --> WF["WebFetch Tool"] +``` + +系统分层: + +- TUI:负责交互和渲染 +- Application:负责启动和依赖注入 +- Runtime:负责 Agent Loop 和状态编排 +- Provider:负责模型调用抽象 +- Tool Manager:负责工具注册、校验、执行 +- Config:负责配置加载与选择 + +--- + +## 4. 模块设计 + +### 4.1 Provider + +职责: + +- 屏蔽 OpenAI / Anthropic / Gemini 的协议差异 +- 统一暴露聊天、工具调用、流式输出能力 +- 管理 endpoint、model、api key、超时、重试 + +建议接口: + +```go +type Provider interface { + Name() string + Chat(ctx context.Context, req ChatRequest) (ChatResponse, error) +} + +type ChatRequest struct { + Model string + SystemPrompt string + Messages []Message + Tools []ToolSpec + Stream bool +} + +type ChatResponse struct { + Message Message + FinishReason string + Usage Usage +} + +type Message struct { + Role string + Content string + ToolCalls []ToolCall +} + +type ToolCall struct { + ID string + Name string + Arguments string +} +``` + +MVP 建议: + +- 第一阶段先实现一个 provider,例如 OpenAI 兼容接口 +- Provider 层只关心“模型协议”,不关心 UI 和工具执行 +- Runtime 把 Tool schema 传给 Provider,Provider 把 ToolCall 返回给 Runtime + +--- + +### 4.2 TUI + +职责: + +- 用户输入和结果展示 +- 展示会话列表、当前会话、工具执行状态 +- 接收快捷键和命令 +- 通过事件与 Runtime 通信 + +建议布局: + +- 左侧:会话列表 Sidebar +- 中间:对话消息区 +- 底部:输入框 +- 顶部/状态栏:provider、model、workdir、运行状态 + +建议状态: + +```go +type UIState struct { + Sessions []SessionSummary + ActiveSessionID string + InputText string + IsAgentRunning bool + StatusText string + CurrentProvider string + CurrentModel string +} +``` + +边界原则: + +- TUI 不直接处理模型协议 +- TUI 不直接执行工具 +- TUI 只发送事件,例如“提交输入”“切换会话” +- Runtime 回传事件,例如“开始响应”“工具开始/结束”“最终完成” + +--- + +### 4.3 Tools + +职责: + +- 定义统一工具协议 +- 管理工具注册、查找、schema、执行和结果格式 +- 为 Runtime 提供统一调用入口 + +MVP 工具: + +- filesystem +- bash +- webfetch + +建议接口: + +```go +type Tool interface { + Name() string + Description() string + Schema() any + Execute(ctx context.Context, call ToolCallInput) (ToolResult, error) +} + +type ToolCallInput struct { + ID string + Name string + Arguments []byte + SessionID string + Workdir string +} + +type ToolResult struct { + ToolCallID string + Name string + Content string + IsError bool + Metadata map[string]any +} +``` + +建议增加 `Registry` / `Manager`: + +- 注册所有内置工具 +- 暴露 `ListSchemas()` 给 Provider/Runtime +- 负责参数校验和统一错误封装 +- 把工具输出转成模型可消费的结果消息 + +各工具 MVP 建议: + +- Filesystem:读文件、写文件、列目录、搜索文件 +- Bash:执行命令,限制超时、输出长度、工作目录 +- WebFetch:抓取网页文本内容,限制响应大小 + +--- + +### 4.4 Config + +职责: + +- 从 `~/.neocode/config.yaml` 加载配置 +- 管理 provider 列表、当前 provider、当前 model +- 校验配置完整性并提供默认值 + +示例: + +```yaml +providers: + - name: openai + type: openai + base_url: https://api.openai.com/v1 + model: gpt-4.1 + api_key_env: OPENAI_API_KEY + + - name: anthropic + type: anthropic + base_url: https://api.anthropic.com + model: claude-3-7-sonnet-latest + api_key_env: ANTHROPIC_API_KEY + +selected_provider: openai +current_model: gpt-4.1 +workdir: . +shell: bash +``` + +建议结构: + +```go +type Config struct { + Providers []ProviderConfig `yaml:"providers"` + SelectedProvider string `yaml:"selected_provider"` + CurrentModel string `yaml:"current_model"` + Workdir string `yaml:"workdir"` + Shell string `yaml:"shell"` +} + +type ProviderConfig struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + BaseURL string `yaml:"base_url"` + Model string `yaml:"model"` + APIKeyEnv string `yaml:"api_key_env"` +} +``` + +建议: + +- API Key 不直接写配置文件,只引用环境变量名 +- 加载后生成运行时只读配置对象 +- 启动时立即校验 selected provider 是否存在 + +--- + +### 4.5 Agent Runtime + +职责: + +- 管理会话上下文 +- 调用 Provider 获取模型响应 +- 识别并执行 ToolCall +- 将 ToolResult 回灌模型 +- 持续循环直到得到最终答案或触发停止条件 + +MVP 推荐使用简化版 ReAct / Tool-Calling Loop: + +1. 接收用户输入 +2. 组装 system prompt + 历史消息 + tools +3. 调用 provider +4. 若返回普通文本,则输出 +5. 若返回 tool calls,则执行工具 +6. 将工具结果追加到上下文 +7. 再次调用 provider +8. 重复直到结束 + +建议接口: + +```go +type Runtime interface { + Run(ctx context.Context, input UserInput) error +} + +type UserInput struct { + SessionID string + Content string +} +``` + +Runtime 内部建议拆分: + +- `SessionStore`:管理会话和消息历史 +- `PromptBuilder`:组装 prompt/messages/tools +- `Executor`:执行 loop +- `EventBus`:向 TUI 推送运行事件 + +停止条件: + +- provider 返回最终文本 +- 超过最大轮数 +- 工具执行失败且不可恢复 +- 用户取消 + +--- + +## 5. 核心数据模型 + +```go +type Session struct { + ID string + Title string + Messages []Message + CreatedAt time.Time + UpdatedAt time.Time +} + +type RuntimeEvent struct { + Type string + Payload any +} +``` + +建议事件类型: + +- `user_message` +- `agent_chunk` +- `tool_started` +- `tool_finished` +- `agent_completed` +- `error` + +--- + +## 6. 启动与依赖注入 + +Application 层负责把所有模块组装起来: + +```mermaid +sequenceDiagram + participant Main + participant Config + participant Provider + participant Tools + participant Runtime + participant TUI + + Main->>Config: Load() + Main->>Provider: Build() + Main->>Tools: Register builtin tools + Main->>Runtime: New() + Main->>TUI: Start() + + TUI->>Runtime: Submit input + Runtime->>Provider: Chat() + Provider-->>Runtime: ToolCall / Final Answer + Runtime->>Tools: Execute() + Tools-->>Runtime: ToolResult + Runtime-->>TUI: Events +``` + +--- + +## 7. 建议目录结构 + +```text +. +├── cmd/ +│ └── neocode/ +│ └── main.go +├── internal/ +│ ├── app/ +│ │ └── bootstrap.go +│ ├── config/ +│ │ ├── loader.go +│ │ ├── model.go +│ │ └── validate.go +│ ├── provider/ +│ │ ├── provider.go +│ │ ├── openai/ +│ │ ├── anthropic/ +│ │ └── gemini/ +│ ├── runtime/ +│ │ ├── runtime.go +│ │ ├── executor.go +│ │ ├── prompt_builder.go +│ │ ├── session_store.go +│ │ └── events.go +│ ├── tools/ +│ │ ├── registry.go +│ │ ├── types.go +│ │ ├── filesystem/ +│ │ ├── bash/ +│ │ └── webfetch/ +│ └── tui/ +│ ├── app.go +│ ├── state.go +│ ├── keymap.go +│ ├── views/ +│ └── components/ +└── docs/ + └── mvp-architecture.md +``` + +--- + +## 8. MVP 时序示例 + +场景:用户提问后触发一次工具调用 + +1. 用户在 TUI 输入问题 +2. TUI 将输入发送给 Runtime +3. Runtime 读取 Session 历史 +4. Runtime 获取工具 schema +5. Runtime 调用 Provider +6. Provider 返回 tool call,例如 `filesystem.read_file` +7. Runtime 调用 Tool Manager 执行 +8. Tool Result 写回上下文 +9. Runtime 再次调用 Provider +10. Provider 返回最终回答 +11. Runtime 把结果事件发送给 TUI +12. TUI 刷新界面 + +--- + +## 9. 错误处理 + +Provider 错误: + +- 网络错误:有限重试 +- 认证错误:提示配置问题 +- 限流错误:提示稍后重试 +- 非法响应:记录日志并返回用户可读错误 + +Tool 错误: + +- 参数错误:返回结构化错误 +- 执行失败:不中断程序,作为 tool error 回灌 +- 超时:统一包装 timeout + +Runtime 错误: + +- 超过最大轮数立即停止 +- 构造上下文失败则结束当前请求 +- 通过事件通知 TUI 展示错误 + +--- + +## 10. 安全边界 + +MVP 建议先加基础约束: + +- Filesystem 默认限制在工作目录内 +- Bash 限制超时、输出长度、禁止交互式阻塞命令 +- WebFetch 限制协议和响应大小 +- 配置文件不保存明文 API Key + +--- + +## 11. 开发顺序 + +### Phase 1:先跑通闭环 + +- config +- provider 抽象 + 一个 provider 实现 +- tools registry + 一个 filesystem 工具 +- runtime loop +- tui 单会话输入输出 + +### Phase 2:增强可用性 + +- 会话侧边栏 +- bash / webfetch 工具 +- 流式输出 +- 状态栏和错误展示 + +### Phase 3:增强扩展性 + +- 多 provider 切换 +- session 持久化 +- 更完整的权限控制 +- 更丰富的工具生态 + +--- + +## 12. MVP 成功标准 + +满足以下条件即可认为 MVP 完成: + +- 用户可在 TUI 中输入问题 +- Agent 可调用至少一个模型 provider +- Agent 可调用至少一个工具 +- 工具结果可回灌给模型继续推理 +- UI 可展示基本会话历史和运行状态 +- 配置可从 `~/.neocode/config.yaml` 加载 + +--- + +## 13. 总结 + +这个架构的关键是先把主链路做干净: + +`TUI -> Runtime -> Provider -> Tool Manager -> Runtime -> TUI` + +只要这条链路稳定,后面无论加更多 provider、更多工具,还是把 Runtime 升级成更复杂的 Agent,都不需要推翻当前设计。 diff --git a/docs/provider-schema-strategy.md b/docs/provider-schema-strategy.md new file mode 100644 index 00000000..255af4e4 --- /dev/null +++ b/docs/provider-schema-strategy.md @@ -0,0 +1,21 @@ +# Provider Schema 抹平策略 +## 为什么需要 Provider 层 +不同模型 API 在消息结构、流式协议和工具调用格式上差异很大。NeoCode 将这些差异都封装在 `internal/provider` 内部,让 runtime 始终只面向一套干净的领域模型工作。 + +## 内部标准结构 +- `Message`:统一消息格式,包含 `role`、`content`、可选工具调用和工具结果元信息 +- `ToolCall`:统一工具调用结构,包含 `id`、`name` 和完整 JSON 参数字符串 +- `ToolSpec`:Provider 可消费的统一工具 schema +- `ChatRequest` / `ChatResponse`:Provider 无关的请求与响应信封 +- `StreamEvent`:Provider 在流式返回过程中发出的标准事件 + +## OpenAI 适配规则 +- 将统一消息映射为 OpenAI 的 `messages` 格式 +- 按照 SSE 逐行解析流式数据 +- 根据 `tool_calls[index]` 拼接碎片化的 `arguments` +- 只有在参数拼接完整后,才向 runtime 返回结构化 `ToolCall` + +## Runtime 契约 +- runtime 绝不能直接操作厂商专属 JSON 结构 +- tool role 的差异必须由 provider 适配器在内部抹平 +- 所有 Provider HTTP 请求都必须遵守 `context.Context` diff --git a/docs/runtime-provider-event-flow.md b/docs/runtime-provider-event-flow.md new file mode 100644 index 00000000..14f1fd67 --- /dev/null +++ b/docs/runtime-provider-event-flow.md @@ -0,0 +1,30 @@ +# Runtime 与 Provider 事件流设计 +## Runtime 事件类型 +当前 runtime 对外暴露一组小而稳定的事件: +- `agent_chunk` +- `agent_done` +- `tool_start` +- `tool_result` +- `error` + +## ReAct 主循环 +1. 加载目标会话或创建草稿会话。 +2. 追加最新的用户消息。 +3. 读取最新配置快照。 +4. 通过 runtime 内部的 Provider 构建逻辑实例化当前模型客户端。 +5. 在不破坏 Tool Call / Tool Result 配对关系的前提下裁剪上下文。 +6. 调用 `Provider.Chat`,并把流式事件桥接给 TUI。 +7. 保存 assistant 完整回复。 +8. 执行返回的工具调用,并保存每一个工具结果。 +9. 若仍需继续推理,则继续下一轮;否则结束。 + +## 流式桥接 +- Provider 发出 `StreamEvent` +- runtime 将其转换成 `RuntimeEvent` +- TUI 使用一次性 Bubble Tea `Cmd` 监听一个事件,并在处理完后再次订阅 + +## 持久化时机 +- 用户消息提交后保存 +- assistant 完整回复后保存 +- 每个工具结果完成后保存 +- 避免在高频 UI 刷新路径中做磁盘 I/O diff --git a/docs/session-persistence-design.md b/docs/session-persistence-design.md new file mode 100644 index 00000000..0c5d1887 --- /dev/null +++ b/docs/session-persistence-design.md @@ -0,0 +1,20 @@ +# Session 持久化设计 +## 存储策略 +NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优先、易于调试和跨平台可移植。 + +## 数据模型 +- `Session`:完整消息历史以及 `id`、`title`、`updated_at` 等元信息 +- `SessionSummary`:用于侧边栏的轻量摘要结构 + +## 加载策略 +- `ListSummaries` 只读取渲染侧边栏所需的基础信息 +- `Load` 仅在用户真正进入某个会话时读取完整消息历史 +- `Save` 通过临时文件原子写入完整 Session + +## 命名策略 +- 新会话默认展示为 `Draft` +- 一旦持久化,runtime 会根据首轮用户消息生成简短标题 + +## 并发约束 +- SessionStore 实现必须自行保护共享访问 +- 真正的保存时机由 runtime 决定,TUI 不负责直接触发磁盘写入 diff --git a/docs/structure.md b/docs/structure.md deleted file mode 100644 index 67b6add3..00000000 --- a/docs/structure.md +++ /dev/null @@ -1,55 +0,0 @@ -### 一、 整体架构设计说明 -本架构采用 “契约驱动 + 领域解耦” 的设计思路: - -1. 物理隔离解耦 :后端服务(Server)与终端客户端(TUI)在 internal 目录下物理分离。当前 TUI 通过 `internal/tui/services` 统一适配后端能力;`core`/`components` 不直接依赖 `internal/server` 的具体实现。 -2. 四层分层模型 :后端遵循 Transport -> Service -> Domain <- Infra 。利用 依赖倒置(DIP) ,核心业务逻辑(Domain)定义接口,基础设施(Infra)实现接口,彻底规避新手常见的“代码一锅端”和循环依赖。 -3. 约束重于灵活 :通过 internal 目录特性保护核心代码,确保所有组件必须显式依赖接口。这种结构天然支持单元测试(Mocking),且代码流向单一(自顶向下),极大地降低了心智负担。 - -### 二、 完整项目目录树结构 - - - api/ # API 契约:保留未来远程通信所需的协议定义 - - proto/ # Protobuf 契约与生成结果 - - cmd/ # 程序入口:仅负责依赖注入与启动,不含业务逻辑 - - server/ # 后端服务入口 (main.go) - - tui/ # TUI 客户端入口 (main.go) - - configs/ # 配置文件:本地开发、生产环境配置 (yaml, toml) - - docs/ # 文档:架构设计、API 文档、新手上手指南 - - internal/ # 内部私有代码:禁止外部项目引用,核心约束区 - - server/ # 后端业务核心 - - domain/ # 领域层:存放接口定义与核心模型 (实体、抽象) - - service/ # 应用层:编排业务流程 (调用 Domain 接口) - - transport/ # 接入层:gRPC/HTTP/LSP 路由与参数解析,暂时不使用,保留等待迭代 - - infra/ # 基础设施:LLM 适配器、数据库、代码仓库实现 - - tui/ # TUI 客户端核心 - - bootstrap/ # 启动准备、工作区与配置装配 - - core/ # 状态管理:Bubble Tea Model 与消息循环 - - state/ # 纯状态定义 - - components/ # UI 组件:可复用的终端视图组件 - - services/ # 本地服务适配、工具桥接与工作区能力 - - pkg/ # 内部公共库:仅限本项目内部使用的通用工具 - - pkg/ # 外部公共库:可被其他项目引用的通用工具 (如 Logger) - - scripts/ # 脚本:编译、Proto 生成、代码质量检查 - - test/ # 集成测试:端到端测试用例 (E2E) - - go.mod # 依赖管理文件 - -### 三、 逐目录详细说明 -1. internal/server/domain (核心领域层) -- 【目录职责】 :定义项目的“灵魂”,包含核心业务模型(Entity)和外部依赖的接口说明(Interface)。 -- 【准入规则】 :仅允许存放纯 Go 的结构体定义、常量和接口。 -- 【禁止规则】 :绝对禁止引入任何第三方 SDK(如 OpenAI SDK)、数据库驱动或任何其他 internal 目录。 -- 【依赖规则】 : 零依赖 。它是架构的最底层,只能被别人依赖,不能依赖别人。 -2. internal/server/infra (基础设施层) -- 【目录职责】 :负责所有“重活、累活”,实现 Domain 层定义的接口(如 LLM 调用、文件读写)。 -- 【准入规则】 :存放具体的第三方实现代码,如 openai_client.go 、 git_provider.go 。 -- 【禁止规则】 :禁止包含任何业务逻辑判断。它只管按照指令干活并返回结果。 -- 【依赖规则】 :仅依赖 domain 层。 -3. internal/server/service (应用服务层) -- 【目录职责】 :业务逻辑的“指挥官”,负责组合不同的 domain 接口完成功能。 -- 【准入规则】 :存放具体的业务流程代码(如“修复代码”的逻辑:读取文件 -> 发送给 LLM -> 写入修复)。 -- 【禁止规则】 :禁止出现具体的数据库 SQL 或 HTTP 请求代码。 -- 【依赖规则】 :依赖 domain 。 -4. internal/tui/core (TUI 状态机) -- 【目录职责】 :基于 Bubble Tea 的 ELM 架构,管理终端交互、流式响应、工具调用和命令解析。 -- 【准入规则】 :存放 Update、View、Model 以及内部消息定义。 -- 【禁止规则】 :禁止直接依赖 `internal/server/...`;复杂 UI 渲染应拆分到 `components`。 -- 【依赖规则】 :依赖 `tui/components`、`tui/state` 和 `tui/services`。 diff --git a/docs/tools-and-tui-integration.md b/docs/tools-and-tui-integration.md new file mode 100644 index 00000000..b41a38a3 --- /dev/null +++ b/docs/tools-and-tui-integration.md @@ -0,0 +1,29 @@ +# Tools 与 TUI 集成设计 +## 工具契约 +每个工具都应提供: +- 合法且稳定的工具名 +- 面向模型的简明描述 +- 类 JSON Schema 的参数定义 +- 接收 `context.Context` 和结构化输入的 `Execute` 方法 + +## Registry 职责 +- 以名字注册工具 +- 向 Provider 返回可消费的工具 schema 列表 +- 根据工具名分发执行,并把失败规范化为可回灌给模型的 ToolResult + +## 当前工具集 +- `filesystem_read_file` +- `filesystem_write_file` +- `filesystem_grep` +- `filesystem_glob` +- `filesystem_edit` +- `bash` +- `webfetch` + +## TUI 集成方式 +- 本地配置操作统一通过 Slash Command 完成,例如 Base URL、API Key 和模型选择 +- runtime 事件以内联形式渲染到 transcript 中,而不是单独拆出控制台面板 +- 工具开始和结束事件会以轻量提示插入聊天流,使交互更沉浸 + +## 交互原则 +Composer 是唯一的控制入口。只要某个功能本质上是在修改本地 Agent 状态,优先通过 Slash Command 发现和触发,而不是继续叠加额外快捷键。 diff --git a/go.mod b/go.mod index 6b42c65e..0db3a58d 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,11 @@ -module go-llm-demo +module github.com/dust/neo-code -go 1.26.1 +go 1.24.2 require ( - github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 - golang.org/x/sys v0.38.0 - google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) @@ -23,6 +20,7 @@ require ( github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/joho/godotenv v1.5.1 // 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 @@ -31,6 +29,8 @@ require ( 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.38.0 // indirect golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index 5d3134dc..d5645656 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,6 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= -github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= -github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -20,6 +18,8 @@ github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= @@ -30,8 +30,10 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -48,6 +50,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= @@ -58,8 +62,6 @@ golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go new file mode 100644 index 00000000..2639538f --- /dev/null +++ b/internal/app/bootstrap.go @@ -0,0 +1,47 @@ +package app + +import ( + "context" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/dust/neo-code/internal/config" + 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" +) + +func NewProgram(ctx context.Context) (*tea.Program, error) { + loader := config.NewLoader("") + manager := config.NewManager(loader) + cfg, err := manager.Load(ctx) + if err != nil { + return nil, err + } + + toolRegistry := tools.NewRegistry() + toolRegistry.Register(filesystem.New(cfg.Workdir)) + toolRegistry.Register(filesystem.NewWrite(cfg.Workdir)) + toolRegistry.Register(filesystem.NewGrep(cfg.Workdir)) + toolRegistry.Register(filesystem.NewGlob(cfg.Workdir)) + toolRegistry.Register(filesystem.NewEdit(cfg.Workdir)) + toolRegistry.Register(bash.New(cfg.Workdir, cfg.Shell, time.Duration(cfg.ToolTimeoutSec)*time.Second)) + toolRegistry.Register(webfetch.New(time.Duration(cfg.ToolTimeoutSec) * time.Second)) + + sessionStore := agentruntime.NewSessionStore(loader.BaseDir()) + runtimeSvc := agentruntime.New(manager, toolRegistry, sessionStore) + + tuiApp, err := tui.New(&cfg, manager, runtimeSvc) + if err != nil { + return nil, err + } + return tea.NewProgram( + tuiApp, + tea.WithAltScreen(), + tea.WithMouseCellMotion(), + ), nil +} diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go new file mode 100644 index 00000000..a25e7a4d --- /dev/null +++ b/internal/app/bootstrap_test.go @@ -0,0 +1,27 @@ +package app + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestNewProgram(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + program, err := NewProgram(context.Background()) + if err != nil { + t.Fatalf("NewProgram() error = %v", err) + } + if program == nil { + t.Fatalf("expected tea program") + } + + configPath := filepath.Join(home, ".neocode", "config.yaml") + if _, err := os.Stat(configPath); err != nil { + t.Fatalf("expected config file to be created at %q: %v", configPath, err) + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 00000000..108317d1 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,694 @@ +package config + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +func TestParseConfigFormats(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + assert func(t *testing.T, cfg *Config) + }{ + { + name: "current format", + 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() + if cfg.CurrentModel != "gpt-5.4" { + t.Fatalf("expected current model gpt-5.4, got %q", cfg.CurrentModel) + } + provider, err := cfg.SelectedProviderConfig() + 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) + } + }, + }, + { + name: "legacy format", + data: ` +selected_provider: openai +current_model: gpt-4o +workspace_root: . +shell: bash +max_loop: 5 +providers: + openai: + type: openai + base_url: https://legacy.example.com/v1 + api_key_env: OPENAI_API_KEY + models: + - gpt-4o +`, + assert: func(t *testing.T, cfg *Config) { + t.Helper() + if cfg.MaxLoops != 5 { + t.Fatalf("expected max loops 5, got %d", cfg.MaxLoops) + } + provider, err := cfg.SelectedProviderConfig() + 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) + } + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cfg, err := parseConfig([]byte(tt.data)) + if err != nil { + t.Fatalf("parseConfig() error = %v", err) + } + cfg.ApplyDefaults() + tt.assert(t, cfg) + }) + } +} + +func TestProviderConfigResolveAPIKey(t *testing.T) { + tests := []struct { + name string + envKey string + envValue string + expectErr string + }{ + { + name: "success", + envKey: "OPENAI_API_KEY", + envValue: "secret-value", + }, + { + name: "missing", + envKey: "OPENAI_API_KEY", + expectErr: "environment variable OPENAI_API_KEY is empty", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + restoreEnv(t, tt.envKey) + if tt.envValue == "" { + _ = os.Unsetenv(tt.envKey) + } else { + t.Setenv(tt.envKey, tt.envValue) + } + + provider := ProviderConfig{ + Name: ProviderOpenAI, + Type: ProviderOpenAI, + BaseURL: DefaultOpenAIBaseURL, + Model: DefaultOpenAIModel, + APIKeyEnv: tt.envKey, + } + + value, err := provider.ResolveAPIKey() + 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("ResolveAPIKey() error = %v", err) + } + if value != tt.envValue { + t.Fatalf("expected %q, got %q", tt.envValue, value) + } + }) + } +} + +func TestConfigMethodErrorPaths(t *testing.T) { + t.Parallel() + + t.Run("selected provider on nil config", func(t *testing.T) { + var cfg *Config + _, err := cfg.SelectedProviderConfig() + if err == nil || !strings.Contains(err.Error(), "config is nil") { + t.Fatalf("expected nil config error, got %v", err) + } + }) + + t.Run("provider lookup not found", func(t *testing.T) { + cfg := Default() + _, err := cfg.ProviderByName("missing-provider") + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected missing provider error, got %v", err) + } + }) + + t.Run("resolve wraps missing env", func(t *testing.T) { + restoreEnv(t, "MISSING_PROVIDER_KEY") + _ = os.Unsetenv("MISSING_PROVIDER_KEY") + + _, err := (ProviderConfig{ + Name: "custom", + Type: "custom", + BaseURL: "https://example.com", + Model: "custom-model", + APIKeyEnv: "MISSING_PROVIDER_KEY", + }).Resolve() + if err == nil || !strings.Contains(err.Error(), "MISSING_PROVIDER_KEY") { + t.Fatalf("expected missing env resolve error, got %v", err) + } + }) +} + +func TestLoaderLoadEnvironmentSources(t *testing.T) { + tests := []struct { + name string + processDotEnv string + managedDotEnv string + expectedAPIKey string + }{ + { + name: "loads key from managed env", + managedDotEnv: "OPENAI_API_KEY=managed-key\n", + expectedAPIKey: "managed-key", + }, + { + name: "falls back to cwd dotenv", + processDotEnv: "OPENAI_API_KEY=process-key\n", + expectedAPIKey: "process-key", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + restoreEnv(t, DefaultOpenAIAPIKeyEnv) + _ = os.Unsetenv(DefaultOpenAIAPIKeyEnv) + + previousWD, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Chdir() error = %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(previousWD) + }) + + if tt.processDotEnv != "" { + if err := os.WriteFile(filepath.Join(tempDir, ".env"), []byte(tt.processDotEnv), 0o644); err != nil { + t.Fatalf("write process .env: %v", err) + } + } + + loader := NewLoader(filepath.Join(tempDir, ".neocode")) + if tt.managedDotEnv != "" { + if err := os.MkdirAll(loader.BaseDir(), 0o755); err != nil { + t.Fatalf("mkdir managed dir: %v", err) + } + if err := os.WriteFile(loader.EnvPath(), []byte(tt.managedDotEnv), 0o644); err != nil { + t.Fatalf("write managed .env: %v", err) + } + } + + loader.LoadEnvironment() + + provider := ProviderConfig{ + Name: ProviderOpenAI, + Type: ProviderOpenAI, + BaseURL: DefaultOpenAIBaseURL, + Model: DefaultOpenAIModel, + APIKeyEnv: DefaultOpenAIAPIKeyEnv, + } + + key, err := provider.ResolveAPIKey() + if err != nil { + t.Fatalf("ResolveAPIKey() error = %v", err) + } + if key != tt.expectedAPIKey { + t.Fatalf("expected %q, got %q", tt.expectedAPIKey, key) + } + }) + } +} + +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)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + models := []string{"gpt-4.1", "gpt-4o", "gpt-5.4", "gpt-5.3-codex"} + var wg sync.WaitGroup + + for i := 0; i < 8; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + for j := 0; j < 50; j++ { + cfg := manager.Get() + if cfg.SelectedProvider == "" { + t.Errorf("selected provider should never be empty") + } + if _, err := cfg.SelectedProviderConfig(); err != nil { + t.Errorf("SelectedProviderConfig() error = %v", err) + } + model := models[(idx+j)%len(models)] + if err := manager.Update(context.Background(), func(next *Config) error { + next.CurrentModel = model + for k := range next.Providers { + if next.Providers[k].Name == next.SelectedProvider { + next.Providers[k].Model = model + } + } + return nil + }); err != nil { + t.Errorf("Update() error = %v", err) + } + } + }(i) + } + + wg.Wait() + + finalConfig := manager.Get() + finalConfig.ApplyDefaults() + if err := finalConfig.Validate(); err != nil { + t.Fatalf("final config should validate, got %v", err) + } +} + +func TestConfigApplyDefaultsFillsMissingFields(t *testing.T) { + t.Parallel() + + cfg := &Config{ + Providers: []ProviderConfig{ + { + Name: ProviderOpenAI, + }, + }, + SelectedProvider: ProviderOpenAI, + CurrentModel: "", + Workdir: ".", + } + + cfg.ApplyDefaults() + + 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.APIKeyEnv != DefaultOpenAIAPIKeyEnv { + t.Fatalf("expected default api key env %q, got %q", DefaultOpenAIAPIKeyEnv, provider.APIKeyEnv) + } + if cfg.CurrentModel != DefaultOpenAIModel { + t.Fatalf("expected current model %q, got %q", DefaultOpenAIModel, cfg.CurrentModel) + } + if !filepath.IsAbs(cfg.Workdir) { + t.Fatalf("expected absolute workdir, got %q", cfg.Workdir) + } +} + +func TestConfigValidateFailures(t *testing.T) { + t.Parallel() + + validConfig := Default().Clone() + validConfig.ApplyDefaults() + + tests := []struct { + name string + config *Config + expectErr string + }{ + { + name: "nil config", + config: nil, + expectErr: "config is nil", + }, + { + name: "no providers", + config: &Config{ + SelectedProvider: ProviderOpenAI, + CurrentModel: DefaultOpenAIModel, + Workdir: filepath.Clean(t.TempDir()), + }, + expectErr: "providers is empty", + }, + { + 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}, + }, + SelectedProvider: ProviderOpenAI, + CurrentModel: DefaultOpenAIModel, + Workdir: filepath.Clean(t.TempDir()), + }, + expectErr: "duplicate provider name", + }, + { + name: "relative workdir", + config: &Config{ + Providers: []ProviderConfig{ + {Name: ProviderOpenAI, Type: ProviderOpenAI, BaseURL: DefaultOpenAIBaseURL, Model: DefaultOpenAIModel, APIKeyEnv: DefaultOpenAIAPIKeyEnv}, + }, + SelectedProvider: ProviderOpenAI, + CurrentModel: DefaultOpenAIModel, + Workdir: ".", + }, + expectErr: "workdir must be absolute", + }, + { + name: "selected provider model empty", + config: func() *Config { + cfg := validConfig.Clone() + cfg.Providers[0].Model = "" + return &cfg + }(), + expectErr: "model is empty", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.config.Validate() + if err == nil || !strings.Contains(err.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) + } + }) + } +} + +func TestProviderConfigValidateFailures(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + provider ProviderConfig + expectErr string + }{ + { + name: "missing name", + provider: ProviderConfig{}, + expectErr: "provider name is empty", + }, + { + name: "missing type", + provider: ProviderConfig{ + Name: ProviderOpenAI, + }, + expectErr: "type is empty", + }, + { + name: "missing base url", + provider: ProviderConfig{ + Name: ProviderOpenAI, + Type: ProviderOpenAI, + }, + expectErr: "base_url is empty", + }, + { + name: "missing model", + provider: ProviderConfig{ + Name: ProviderOpenAI, + Type: ProviderOpenAI, + BaseURL: DefaultOpenAIBaseURL, + }, + expectErr: "model is empty", + }, + { + name: "missing api key env", + provider: ProviderConfig{ + Name: ProviderOpenAI, + Type: ProviderOpenAI, + BaseURL: DefaultOpenAIBaseURL, + Model: DefaultOpenAIModel, + }, + expectErr: "api_key_env is empty", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.provider.Validate() + if err == nil || !strings.Contains(err.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) + } + }) + } +} + +func TestProviderLookupAndResolveSelectedProvider(t *testing.T) { + t.Setenv(DefaultOpenAIAPIKeyEnv, "lookup-key") + + manager := NewManager(NewLoader(t.TempDir())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + cfg := manager.Get() + provider, err := cfg.ProviderByName("OPENAI") + if err != nil { + t.Fatalf("ProviderByName() error = %v", err) + } + if provider.Name != ProviderOpenAI { + t.Fatalf("expected provider %q, got %q", ProviderOpenAI, provider.Name) + } + + resolved, err := manager.ResolvedSelectedProvider() + if err != nil { + t.Fatalf("ResolvedSelectedProvider() error = %v", err) + } + if resolved.APIKey != "lookup-key" { + t.Fatalf("expected resolved key %q, got %q", "lookup-key", resolved.APIKey) + } +} + +func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { + tempDir := t.TempDir() + loader := NewLoader(tempDir) + + cfg, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if _, err := os.Stat(loader.ConfigPath()); err != nil { + t.Fatalf("expected config file to exist, got %v", err) + } + + cfg.CurrentModel = "gpt-5.4" + for i := range cfg.Providers { + if cfg.Providers[i].Name == cfg.SelectedProvider { + cfg.Providers[i].Model = "gpt-5.4" + } + } + if err := loader.Save(context.Background(), cfg); err != nil { + t.Fatalf("Save() error = %v", err) + } + + reloaded, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("Load() reload error = %v", err) + } + if reloaded.CurrentModel != "gpt-5.4" { + t.Fatalf("expected current model %q, got %q", "gpt-5.4", reloaded.CurrentModel) + } +} + +func TestNormalizeWorkdirAndClone(t *testing.T) { + t.Parallel() + + workingDir, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + + tests := []struct { + name string + input string + validate func(t *testing.T, value string) + }{ + { + name: "dot becomes absolute", + input: ".", + validate: func(t *testing.T, value string) { + t.Helper() + if value != workingDir { + t.Fatalf("expected working dir %q, got %q", workingDir, value) + } + }, + }, + { + name: "relative path becomes absolute", + input: filepath.Join("internal", "config"), + validate: func(t *testing.T, value string) { + t.Helper() + if !filepath.IsAbs(value) { + t.Fatalf("expected absolute path, got %q", value) + } + if !strings.HasSuffix(filepath.ToSlash(value), "internal/config") { + t.Fatalf("expected suffix internal/config, got %q", value) + } + }, + }, + { + name: "absolute path stays clean", + input: workingDir, + validate: func(t *testing.T, value string) { + t.Helper() + if value != filepath.Clean(workingDir) { + t.Fatalf("expected %q, got %q", filepath.Clean(workingDir), value) + } + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tt.validate(t, normalizeWorkdir(tt.input)) + }) + } + + var nilConfig *Config + clonedNil := nilConfig.Clone() + clonedNil.ApplyDefaults() + if err := clonedNil.Validate(); err != nil { + t.Fatalf("cloned nil config should validate, got %v", err) + } + + cfg := Default() + cloned := cfg.Clone() + cloned.CurrentModel = "modified" + if cfg.CurrentModel == cloned.CurrentModel { + t.Fatalf("expected clone to be independent from source") + } +} + +func TestManagerHelperMethodsAndReloads(t *testing.T) { + tempDir := t.TempDir() + manager := NewManager(NewLoader(tempDir)) + + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + if err := manager.Save(context.Background()); err != nil { + t.Fatalf("Save() error = %v", err) + } + if _, err := manager.Reload(context.Background()); err != nil { + t.Fatalf("Reload() error = %v", err) + } + 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) { + t.Parallel() + + loader := NewLoader("") + if loader.BaseDir() == "" { + t.Fatalf("expected default base dir to be set") + } + if !strings.HasSuffix(filepath.ToSlash(loader.BaseDir()), "/"+dirName) { + t.Fatalf("expected loader base dir to end with %q, got %q", dirName, loader.BaseDir()) + } + if defaultBaseDir() == "" { + t.Fatalf("expected defaultBaseDir() to return a value") + } + + catalog := BuiltinModelCatalog() + if len(catalog) == 0 { + t.Fatalf("expected builtin model catalog to be non-empty") + } + if catalog[0].Name == "" || catalog[0].Description == "" { + t.Fatalf("expected model catalog entries to contain name and description") + } +} + +func restoreEnv(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) + }) +} diff --git a/internal/config/loader.go b/internal/config/loader.go new file mode 100644 index 00000000..adcee01d --- /dev/null +++ b/internal/config/loader.go @@ -0,0 +1,227 @@ +package config + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/joho/godotenv" + "gopkg.in/yaml.v3" +) + +const ( + dirName = ".neocode" + configName = "config.yaml" + envName = ".env" +) + +type Loader struct { + baseDir string +} + +func NewLoader(baseDir string) *Loader { + if strings.TrimSpace(baseDir) == "" { + baseDir = defaultBaseDir() + } + return &Loader{baseDir: baseDir} +} + +func (l *Loader) BaseDir() string { + return l.baseDir +} + +func (l *Loader) ConfigPath() string { + return filepath.Join(l.baseDir, configName) +} + +func (l *Loader) EnvPath() string { + return filepath.Join(l.baseDir, envName) +} + +func (l *Loader) Load(ctx context.Context) (*Config, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + l.LoadEnvironment() + + if err := os.MkdirAll(l.baseDir, 0o755); err != nil { + 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 { + return nil, err + } + } + + data, err := os.ReadFile(l.ConfigPath()) + if err != nil { + return nil, fmt.Errorf("config: read config file: %w", err) + } + + cfg, err := parseConfig(data) + if err != nil { + return nil, fmt.Errorf("config: parse config file: %w", err) + } + cfg.ApplyDefaults() + if err := cfg.Validate(); err != nil { + return nil, err + } + return cfg, nil +} + +func (l *Loader) Save(ctx context.Context, cfg *Config) error { + if err := ctx.Err(); err != nil { + return err + } + + if err := os.MkdirAll(l.baseDir, 0o755); err != nil { + return fmt.Errorf("config: create config dir: %w", err) + } + + snapshot := cfg.Clone() + snapshot.ApplyDefaults() + if err := snapshot.Validate(); err != nil { + return err + } + + data, err := yaml.Marshal(&snapshot) + if err != nil { + return fmt.Errorf("config: marshal config: %w", err) + } + + if len(data) == 0 || data[len(data)-1] != '\n' { + data = append(data, '\n') + } + + if err := os.WriteFile(l.ConfigPath(), data, 0o644); err != nil { + return fmt.Errorf("config: write config file: %w", err) + } + + return nil +} + +func (l *Loader) LoadEnvironment() { + _ = godotenv.Load() + _ = godotenv.Load(l.EnvPath()) +} + +func (l *Loader) OverloadManagedEnvironment() error { + return godotenv.Overload(l.EnvPath()) +} + +func defaultBaseDir() string { + home, err := os.UserHomeDir() + if err != nil { + return dirName + } + return filepath.Join(home, dirName) +} + +func parseConfig(data []byte) (*Config, error) { + if len(bytes.TrimSpace(data)) == 0 { + return Default(), nil + } + + cfg, currentErr := parseCurrentConfig(data) + if currentErr == nil { + return cfg, nil + } + + legacy, legacyErr := parseLegacyConfig(data) + if legacyErr == nil { + return legacy, nil + } + + return nil, currentErr +} + +type aliasConfig struct { + MaxLoop int `yaml:"max_loop"` + 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) (*Config, error) { + cfg := &Config{} + if err := yaml.Unmarshal(data, cfg); 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 strings.TrimSpace(cfg.Workdir) == "" && strings.TrimSpace(aliases.WorkspaceRoot) != "" { + cfg.Workdir = aliases.WorkspaceRoot + } + } + + return cfg, nil +} + +func parseLegacyConfig(data []byte) (*Config, error) { + var legacy legacyConfig + if err := yaml.Unmarshal(data, &legacy); err != nil { + return nil, err + } + + return convertLegacyConfig(legacy), nil +} + +func convertLegacyConfig(in legacyConfig) *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), + Type: strings.TrimSpace(provider.Type), + BaseURL: strings.TrimSpace(provider.BaseURL), + Model: strings.TrimSpace(model), + APIKeyEnv: strings.TrimSpace(provider.APIKeyEnv), + }) + } + + return out +} + +func firstNonEmpty(items ...string) string { + for _, item := range items { + if strings.TrimSpace(item) != "" { + return strings.TrimSpace(item) + } + } + return "" +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go new file mode 100644 index 00000000..af85806e --- /dev/null +++ b/internal/config/loader_test.go @@ -0,0 +1,94 @@ +package config + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoaderLoadMissingConfigCreatesDefault(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir()) + if _, err := os.Stat(loader.ConfigPath()); !os.IsNotExist(err) { + t.Fatalf("expected config file to be missing before load, got %v", err) + } + + cfg, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg == nil { + t.Fatalf("expected config to be created") + } + if _, err := os.Stat(loader.ConfigPath()); err != nil { + t.Fatalf("expected config file to be created, got %v", err) + } +} + +func TestLoaderLoadMalformedYAML(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir()) + if err := os.MkdirAll(loader.BaseDir(), 0o755); err != nil { + t.Fatalf("mkdir base dir: %v", err) + } + if err := os.WriteFile(loader.ConfigPath(), []byte("providers:\n - name: [\n"), 0o644); err != nil { + t.Fatalf("write malformed config: %v", err) + } + + _, err := loader.Load(context.Background()) + if err == nil || !strings.Contains(err.Error(), "parse config file") { + t.Fatalf("expected malformed yaml parse error, got %v", err) + } +} + +func TestLoaderLoadEnvironmentSilentlyIgnoresDotEnvFailures(t *testing.T) { + tempDir := t.TempDir() + restoreEnv(t, DefaultOpenAIAPIKeyEnv) + _ = os.Unsetenv(DefaultOpenAIAPIKeyEnv) + + previousWD, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Chdir() error = %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(previousWD) + }) + + if err := os.MkdirAll(filepath.Join(tempDir, ".env"), 0o755); err != nil { + t.Fatalf("mkdir cwd .env dir: %v", err) + } + + loader := NewLoader(filepath.Join(tempDir, ".neocode")) + 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 != "" { + t.Fatalf("expected env to stay empty when dotenv loading fails, got %q", got) + } +} + +func TestLoaderLoadInvalidBaseDir(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + baseFile := filepath.Join(tempDir, "not-a-directory") + if err := os.WriteFile(baseFile, []byte("x"), 0o644); err != nil { + t.Fatalf("write base file: %v", err) + } + + loader := NewLoader(baseFile) + _, 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) + } +} diff --git a/internal/config/manager.go b/internal/config/manager.go new file mode 100644 index 00000000..bfb7beb0 --- /dev/null +++ b/internal/config/manager.go @@ -0,0 +1,160 @@ +package config + +import ( + "bufio" + "context" + "errors" + "os" + "strings" + "sync" +) + +type Manager struct { + mu sync.RWMutex + loader *Loader + config *Config +} + +func NewManager(loader *Loader) *Manager { + if loader == nil { + loader = NewLoader("") + } + + return &Manager{ + loader: loader, + config: Default(), + } +} + +func (m *Manager) Load(ctx context.Context) (Config, error) { + cfg, err := m.loader.Load(ctx) + if err != nil { + return Config{}, err + } + + snapshot := cfg.Clone() + + m.mu.Lock() + m.config = &snapshot + m.mu.Unlock() + + return snapshot, nil +} + +func (m *Manager) Reload(ctx context.Context) (Config, error) { + return m.Load(ctx) +} + +func (m *Manager) Get() Config { + m.mu.RLock() + defer m.mu.RUnlock() + + return m.config.Clone() +} + +func (m *Manager) Save(ctx context.Context) error { + m.mu.RLock() + snapshot := m.config.Clone() + m.mu.RUnlock() + + return m.loader.Save(ctx, &snapshot) +} + +func (m *Manager) Update(ctx context.Context, mutate func(*Config) error) error { + if mutate == nil { + return errors.New("config: update mutate func is nil") + } + + m.mu.Lock() + defer m.mu.Unlock() + + next := m.config.Clone() + if err := mutate(&next); err != nil { + return err + } + + next.ApplyDefaults() + if err := next.Validate(); err != nil { + return err + } + if err := m.loader.Save(ctx, &next); err != nil { + return err + } + + m.config = &next + return nil +} + +func (m *Manager) SelectedProvider() (ProviderConfig, error) { + cfg := m.Get() + return cfg.SelectedProviderConfig() +} + +func (m *Manager) ResolvedSelectedProvider() (ResolvedProviderConfig, error) { + provider, err := m.SelectedProvider() + if err != nil { + return ResolvedProviderConfig{}, err + } + + return provider.Resolve() +} + +func (m *Manager) BaseDir() string { + return m.loader.BaseDir() +} + +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 new file mode 100644 index 00000000..63ecb697 --- /dev/null +++ b/internal/config/model.go @@ -0,0 +1,346 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + goruntime "runtime" + "strings" +) + +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 +) + +type Config struct { + Providers []ProviderConfig `yaml:"providers"` + 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"` +} + +type ProviderConfig struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + BaseURL string `yaml:"base_url"` + Model string `yaml:"model"` + APIKeyEnv string `yaml:"api_key_env"` +} + +type ResolvedProviderConfig struct { + ProviderConfig + APIKey string `yaml:"-"` +} + +type ModelOption struct { + Name string + Description string +} + +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, + }, + }, + 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"}, + ) +} + +func (c *Config) Clone() Config { + if c == nil { + return *Default() + } + + clone := *c + clone.Providers = append([]ProviderConfig(nil), c.Providers...) + return clone +} + +func (c *Config) ApplyDefaults() { + if c == nil { + return + } + + def := Default() + + if len(c.Providers) == 0 { + c.Providers = append([]ProviderConfig(nil), def.Providers...) + } else { + c.Providers = applyProviderDefaults(c.Providers, def.Providers) + } + + if strings.TrimSpace(c.SelectedProvider) == "" { + c.SelectedProvider = def.SelectedProvider + } + if strings.TrimSpace(c.CurrentModel) == "" { + if selected, err := c.SelectedProviderConfig(); err == nil { + c.CurrentModel = selected.Model + } + } + if strings.TrimSpace(c.Workdir) == "" { + c.Workdir = def.Workdir + } + if strings.TrimSpace(c.Shell) == "" { + c.Shell = def.Shell + } + if c.MaxLoops <= 0 { + c.MaxLoops = def.MaxLoops + } + if c.ToolTimeoutSec <= 0 { + c.ToolTimeoutSec = def.ToolTimeoutSec + } + + c.Workdir = normalizeWorkdir(c.Workdir) +} + +func (c *Config) Validate() error { + if c == nil { + return errors.New("config: config is nil") + } + if len(c.Providers) == 0 { + return errors.New("config: providers is empty") + } + + seen := make(map[string]struct{}, len(c.Providers)) + for i, provider := range c.Providers { + if err := provider.Validate(); err != nil { + return fmt.Errorf("config: provider[%d]: %w", i, err) + } + + key := strings.ToLower(strings.TrimSpace(provider.Name)) + if _, exists := seen[key]; exists { + return fmt.Errorf("config: duplicate provider name %q", provider.Name) + } + seen[key] = struct{}{} + } + + if strings.TrimSpace(c.SelectedProvider) == "" { + return errors.New("config: selected_provider is empty") + } + selected, err := c.SelectedProviderConfig() + if err != nil { + return err + } + if strings.TrimSpace(c.CurrentModel) == "" { + return errors.New("config: current_model is empty") + } + if strings.TrimSpace(c.Workdir) == "" { + return errors.New("config: workdir is empty") + } + if !filepath.IsAbs(c.Workdir) { + return fmt.Errorf("config: workdir must be absolute, got %q", c.Workdir) + } + if strings.TrimSpace(selected.Model) == "" { + return fmt.Errorf("config: selected provider %q has empty model", selected.Name) + } + + return nil +} + +func (c *Config) SelectedProviderConfig() (ProviderConfig, error) { + if c == nil { + return ProviderConfig{}, errors.New("config: config is nil") + } + return c.ProviderByName(c.SelectedProvider) +} + +func (c *Config) ProviderByName(name string) (ProviderConfig, error) { + if c == nil { + return ProviderConfig{}, errors.New("config: config is nil") + } + + target := strings.ToLower(strings.TrimSpace(name)) + for _, provider := range c.Providers { + if strings.ToLower(strings.TrimSpace(provider.Name)) == target { + return provider, nil + } + } + + return ProviderConfig{}, fmt.Errorf("config: provider %q not found", name) +} + +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.BaseURL) == "" { + return fmt.Errorf("provider %q base_url is empty", p.Name) + } + if strings.TrimSpace(p.Model) == "" { + return fmt.Errorf("provider %q model is empty", p.Name) + } + if strings.TrimSpace(p.APIKeyEnv) == "" { + return fmt.Errorf("provider %q api_key_env is empty", p.Name) + } + return nil +} + +func (p ProviderConfig) ResolveAPIKey() (string, error) { + envName := strings.TrimSpace(p.APIKeyEnv) + if envName == "" { + return "", fmt.Errorf("config: provider %q api_key_env is empty", p.Name) + } + + value := strings.TrimSpace(os.Getenv(envName)) + if value == "" { + return "", fmt.Errorf("config: environment variable %s is empty", envName) + } + + return value, nil +} + +func (p ProviderConfig) Resolve() (ResolvedProviderConfig, error) { + apiKey, err := p.ResolveAPIKey() + if err != nil { + return ResolvedProviderConfig{}, err + } + + return ResolvedProviderConfig{ + ProviderConfig: p, + APIKey: apiKey, + }, nil +} + +func applyProviderDefaults(providers []ProviderConfig, defaults []ProviderConfig) []ProviderConfig { + out := make([]ProviderConfig, 0, len(providers)) + for _, provider := range providers { + out = append(out, mergeProviderDefaults(provider, defaults)) + } + return out +} + +func mergeProviderDefaults(provider ProviderConfig, defaults []ProviderConfig) ProviderConfig { + base, ok := matchDefaultProvider(provider, defaults) + if !ok { + return provider + } + + if strings.TrimSpace(provider.Name) == "" { + provider.Name = base.Name + } + if strings.TrimSpace(provider.Type) == "" { + provider.Type = base.Type + } + if strings.TrimSpace(provider.BaseURL) == "" { + provider.BaseURL = base.BaseURL + } + if strings.TrimSpace(provider.Model) == "" { + provider.Model = base.Model + } + if strings.TrimSpace(provider.APIKeyEnv) == "" { + provider.APIKeyEnv = base.APIKeyEnv + } + + return provider +} + +func matchDefaultProvider(provider ProviderConfig, defaults []ProviderConfig) (ProviderConfig, bool) { + name := strings.ToLower(strings.TrimSpace(provider.Name)) + kind := strings.ToLower(strings.TrimSpace(provider.Type)) + + for _, candidate := range defaults { + if name != "" && strings.ToLower(candidate.Name) == name { + return candidate, true + } + } + for _, candidate := range defaults { + if kind != "" && strings.ToLower(candidate.Type) == kind { + return candidate, true + } + } + + return ProviderConfig{}, false +} + +func normalizeWorkdir(workdir string) string { + workdir = strings.TrimSpace(workdir) + if workdir == "" { + return "" + } + + if workdir == "." { + if wd, err := os.Getwd(); err == nil { + return wd + } + return workdir + } + + if filepath.IsAbs(workdir) { + return filepath.Clean(workdir) + } + + if wd, err := os.Getwd(); err == nil { + return filepath.Clean(filepath.Join(wd, workdir)) + } + + return filepath.Clean(workdir) +} + +func defaultShell() string { + if goruntime.GOOS == "windows" { + return "powershell" + } + return "bash" +} diff --git a/internal/provider/anthropic/provider.go b/internal/provider/anthropic/provider.go new file mode 100644 index 00000000..d45e0f4e --- /dev/null +++ b/internal/provider/anthropic/provider.go @@ -0,0 +1,25 @@ +package anthropic + +import ( + "context" + "errors" + + "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) 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") +} diff --git a/internal/provider/anthropic/provider_test.go b/internal/provider/anthropic/provider_test.go new file mode 100644 index 00000000..cf5ca5ca --- /dev/null +++ b/internal/provider/anthropic/provider_test.go @@ -0,0 +1,25 @@ +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()) + } + + _, err := p.Chat(context.Background(), provider.ChatRequest{}, make(chan provider.StreamEvent)) + if err == nil || !strings.Contains(err.Error(), "not implemented") { + t.Fatalf("expected scaffold error, got %v", err) + } +} diff --git a/internal/provider/gemini/provider.go b/internal/provider/gemini/provider.go new file mode 100644 index 00000000..90d3349e --- /dev/null +++ b/internal/provider/gemini/provider.go @@ -0,0 +1,25 @@ +package gemini + +import ( + "context" + "errors" + + "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) 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") +} diff --git a/internal/provider/gemini/provider_test.go b/internal/provider/gemini/provider_test.go new file mode 100644 index 00000000..40ff8adf --- /dev/null +++ b/internal/provider/gemini/provider_test.go @@ -0,0 +1,25 @@ +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()) + } + + _, err := p.Chat(context.Background(), provider.ChatRequest{}, make(chan provider.StreamEvent)) + if err == nil || !strings.Contains(err.Error(), "not implemented") { + t.Fatalf("expected scaffold error, got %v", err) + } +} diff --git a/internal/provider/openai/openai.go b/internal/provider/openai/openai.go new file mode 100644 index 00000000..07b64b28 --- /dev/null +++ b/internal/provider/openai/openai.go @@ -0,0 +1,399 @@ +package openai + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "github.com/dust/neo-code/internal/config" + domain "github.com/dust/neo-code/internal/provider" +) + +type Provider struct { + cfg config.ProviderConfig + client *http.Client +} + +func New(cfg config.ProviderConfig) (*Provider, error) { + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("openai provider: %w", err) + } + + return &Provider{ + cfg: cfg, + client: &http.Client{ + Timeout: 90 * time.Second, + }, + }, nil +} + +func (p *Provider) Name() string { + return p.cfg.Name +} + +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 + } + + 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 "+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/openai/openai_test.go b/internal/provider/openai/openai_test.go new file mode 100644 index 00000000..33377eb3 --- /dev/null +++ b/internal/provider/openai/openai_test.go @@ -0,0 +1,490 @@ +package openai + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/dust/neo-code/internal/config" + domain "github.com/dust/neo-code/internal/provider" +) + +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 TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { + t.Setenv(config.DefaultOpenAIAPIKeyEnv, "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(config.ProviderConfig{ + Name: config.ProviderOpenAI, + Type: config.ProviderOpenAI, + BaseURL: server.URL, + Model: "gpt-5.4", + APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, + }) + 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(config.DefaultOpenAIAPIKeyEnv, "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(config.ProviderConfig{ + Name: config.ProviderOpenAI, + Type: config.ProviderOpenAI, + BaseURL: server.URL, + Model: config.DefaultOpenAIModel, + APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + provider.client = server.Client() + + _, err = provider.Chat(context.Background(), domain.ChatRequest{ + Model: config.DefaultOpenAIModel, + }, 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(config.ProviderConfig{ + Name: config.ProviderOpenAI, + Type: config.ProviderOpenAI, + BaseURL: config.DefaultOpenAIBaseURL, + Model: config.DefaultOpenAIModel, + APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, + }) + 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 != config.DefaultOpenAIModel { + t.Fatalf("expected default model %q, got %q", config.DefaultOpenAIModel, 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(config.ProviderConfig{ + Name: config.ProviderOpenAI, + Type: config.ProviderOpenAI, + BaseURL: config.DefaultOpenAIBaseURL, + Model: config.DefaultOpenAIModel, + APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, + }) + 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(config.ProviderConfig{ + Name: config.ProviderOpenAI, + Type: config.ProviderOpenAI, + BaseURL: config.DefaultOpenAIBaseURL, + Model: config.DefaultOpenAIModel, + APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, + }) + 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 +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 00000000..55561217 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,19 @@ +package provider + +import "context" + +type Provider interface { + Name() string + Chat(ctx context.Context, req ChatRequest, events chan<- StreamEvent) (ChatResponse, error) +} + +type StreamEventType string + +const ( + StreamEventTextDelta StreamEventType = "text_delta" +) + +type StreamEvent struct { + Type StreamEventType + Text string +} diff --git a/internal/provider/registry.go b/internal/provider/registry.go new file mode 100644 index 00000000..5a1f6aec --- /dev/null +++ b/internal/provider/registry.go @@ -0,0 +1,29 @@ +package provider + +import ( + "errors" + "strings" +) + +type Registry struct { + items map[string]Provider +} + +func NewRegistry() *Registry { + return &Registry{items: map[string]Provider{}} +} + +func (r *Registry) Register(provider Provider) { + if provider == nil { + return + } + r.items[strings.ToLower(provider.Name())] = provider +} + +func (r *Registry) Get(name string) (Provider, error) { + item, ok := r.items[strings.ToLower(name)] + if !ok { + return nil, errors.New("provider: not found") + } + return item, nil +} diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go new file mode 100644 index 00000000..9a76c9d8 --- /dev/null +++ b/internal/provider/registry_test.go @@ -0,0 +1,80 @@ +package provider_test + +import ( + "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/openai" +) + +func TestRegistryRegisterAndGet(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) + } + anthropicProvider := anthropic.New(config.ProviderConfig{ + Name: config.ProviderAnthropic, + Type: config.ProviderAnthropic, + BaseURL: config.DefaultAnthropicBaseURL, + Model: config.DefaultAnthropicModel, + APIKeyEnv: config.DefaultAnthropicAPIKeyEnv, + }) + + registry := provider.NewRegistry() + registry.Register(nil) + registry.Register(openAIProvider) + registry.Register(anthropicProvider) + + tests := []struct { + name string + lookup string + expectName string + }{ + { + name: "gets openai provider case insensitively", + lookup: "OPENAI", + expectName: config.ProviderOpenAI, + }, + { + name: "gets anthropic provider case insensitively", + lookup: "Anthropic", + expectName: config.ProviderAnthropic, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + 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) + } + }) + } +} + +func TestRegistryGetMissingProvider(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) + } +} diff --git a/internal/provider/types.go b/internal/provider/types.go new file mode 100644 index 00000000..23cc3a58 --- /dev/null +++ b/internal/provider/types.go @@ -0,0 +1,40 @@ +package provider + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + IsError bool `json:"is_error,omitempty"` +} + +type ToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +type ToolSpec struct { + Name string `json:"name"` + Description string `json:"description"` + Schema map[string]any `json:"schema"` +} + +type ChatRequest struct { + Model string `json:"model"` + SystemPrompt string `json:"system_prompt"` + Messages []Message `json:"messages"` + Tools []ToolSpec `json:"tools,omitempty"` +} + +type ChatResponse struct { + Message Message `json:"message"` + FinishReason string `json:"finish_reason"` + Usage Usage `json:"usage"` +} + +type Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` +} diff --git a/internal/runtime/events.go b/internal/runtime/events.go new file mode 100644 index 00000000..eb8c296a --- /dev/null +++ b/internal/runtime/events.go @@ -0,0 +1,25 @@ +package runtime + +type EventType string + +type RuntimeEvent struct { + Type EventType + SessionID string + Payload any +} + +const ( + EventUserMessage EventType = "user_message" + EventAgentChunk EventType = "agent_chunk" + EventAgentDone EventType = "agent_done" + EventToolStart EventType = "tool_start" + EventToolResult EventType = "tool_result" + EventToolChunk EventType = "tool_chunk" + EventError EventType = "error" +) + +const ( + EventToolStarted = EventToolStart + EventToolFinished = EventToolResult + EventAgentComplete = EventAgentDone +) diff --git a/internal/runtime/id.go b/internal/runtime/id.go new file mode 100644 index 00000000..b036fbea --- /dev/null +++ b/internal/runtime/id.go @@ -0,0 +1,12 @@ +package runtime + +import ( + "crypto/rand" + "encoding/hex" +) + +func newID(prefix string) string { + buf := make([]byte, 8) + _, _ = rand.Read(buf) + return prefix + "_" + hex.EncodeToString(buf) +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go new file mode 100644 index 00000000..1ec1acb9 --- /dev/null +++ b/internal/runtime/runtime.go @@ -0,0 +1,278 @@ +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" +) + +const maxContextTurns = 10 + +type Runtime interface { + Run(ctx context.Context, input UserInput) error + Events() <-chan RuntimeEvent + ListSessions(ctx context.Context) ([]SessionSummary, error) + LoadSession(ctx context.Context, id string) (Session, error) +} + +type UserInput struct { + SessionID string + Content string +} + +type ProviderFactory interface { + Build(cfg config.Config) (provider.Provider, error) +} + +type DefaultProviderFactory struct{} + +type Service struct { + configManager *config.Manager + sessionStore Store + toolRegistry *tools.Registry + providerFactory ProviderFactory + 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{} + } + + return &Service{ + configManager: configManager, + sessionStore: sessionStore, + toolRegistry: toolRegistry, + providerFactory: providerFactory, + events: make(chan RuntimeEvent, 128), + } +} + +func (s *Service) Run(ctx context.Context, input UserInput) error { + if strings.TrimSpace(input.Content) == "" { + return errors.New("runtime: input content is empty") + } + + session, err := s.loadOrCreateSession(ctx, input.SessionID, input.Content) + if err != nil { + s.emit(EventError, "", err.Error()) + return err + } + + userMessage := provider.Message{ + Role: "user", + Content: input.Content, + } + session.Messages = append(session.Messages, userMessage) + session.UpdatedAt = time.Now() + if err := s.sessionStore.Save(ctx, &session); err != nil { + s.emit(EventError, session.ID, err.Error()) + return err + } + s.emit(EventUserMessage, session.ID, userMessage) + + for attempt := 0; ; attempt++ { + cfg := s.configManager.Get() + maxLoops := cfg.MaxLoops + if maxLoops <= 0 { + maxLoops = 8 + } + if attempt >= maxLoops { + err := errors.New("runtime: max loop reached") + s.emit(EventError, session.ID, err.Error()) + return err + } + + modelProvider, err := s.providerFactory.Build(cfg) + if err != nil { + s.emit(EventError, session.ID, err.Error()) + return err + } + + streamEvents := make(chan provider.StreamEvent, 32) + streamDone := make(chan struct{}) + go s.forwardProviderEvents(session.ID, streamEvents, streamDone) + + resp, err := modelProvider.Chat(ctx, provider.ChatRequest{ + Model: cfg.CurrentModel, + SystemPrompt: s.systemPrompt(), + Messages: s.trimMessages(session.Messages), + Tools: s.toolRegistry.GetSpecs(), + }, streamEvents) + close(streamEvents) + <-streamDone + if err != nil { + s.emit(EventError, session.ID, err.Error()) + return err + } + + assistant := resp.Message + if strings.TrimSpace(assistant.Role) == "" { + assistant.Role = "assistant" + } + + if strings.TrimSpace(assistant.Content) != "" || len(assistant.ToolCalls) > 0 { + session.Messages = append(session.Messages, assistant) + session.UpdatedAt = time.Now() + if err := s.sessionStore.Save(ctx, &session); err != nil { + s.emit(EventError, session.ID, err.Error()) + return err + } + } + + if len(assistant.ToolCalls) == 0 { + s.emit(EventAgentDone, session.ID, assistant) + return nil + } + + for _, call := range assistant.ToolCalls { + s.emit(EventToolStart, session.ID, call) + + runCtx, cancel := context.WithTimeout(ctx, time.Duration(cfg.ToolTimeoutSec)*time.Second) + result, execErr := s.toolRegistry.Execute(runCtx, tools.ToolCallInput{ + ID: call.ID, + Name: call.Name, + Arguments: []byte(call.Arguments), + Workdir: cfg.Workdir, + SessionID: session.ID, + EmitChunk: func(chunk []byte) { + s.emit(EventToolChunk, session.ID, string(chunk)) + }, + }) + cancel() + + if execErr != nil && strings.TrimSpace(result.Content) == "" { + result.Content = execErr.Error() + } + + toolMessage := provider.Message{ + Role: "tool", + Content: result.Content, + ToolCallID: call.ID, + IsError: result.IsError, + } + session.Messages = append(session.Messages, toolMessage) + session.UpdatedAt = time.Now() + if err := s.sessionStore.Save(ctx, &session); err != nil { + s.emit(EventError, session.ID, err.Error()) + return err + } + + s.emit(EventToolResult, session.ID, result) + } + } +} + +func (s *Service) Events() <-chan RuntimeEvent { + return s.events +} + +func (s *Service) ListSessions(ctx context.Context) ([]SessionSummary, error) { + return s.sessionStore.ListSummaries(ctx) +} + +func (s *Service) LoadSession(ctx context.Context, id string) (Session, error) { + return s.sessionStore.Load(ctx, id) +} + +func (s *Service) loadOrCreateSession(ctx context.Context, sessionID string, title string) (Session, error) { + if strings.TrimSpace(sessionID) == "" { + session := newSession(title) + if err := s.sessionStore.Save(ctx, &session); err != nil { + return Session{}, err + } + return session, nil + } + return s.sessionStore.Load(ctx, sessionID) +} + +func (s *Service) emit(kind EventType, sessionID string, payload any) { + s.events <- RuntimeEvent{ + Type: kind, + SessionID: sessionID, + Payload: payload, + } +} + +func (s *Service) forwardProviderEvents(sessionID string, input <-chan provider.StreamEvent, done chan<- struct{}) { + defer close(done) + for event := range input { + switch event.Type { + case provider.StreamEventTextDelta: + s.emit(EventAgentChunk, sessionID, event.Text) + } + } +} + +func (s *Service) trimMessages(messages []provider.Message) []provider.Message { + if len(messages) <= maxContextTurns { + return append([]provider.Message(nil), messages...) + } + + type span struct { + start int + end int + } + + spans := make([]span, 0, len(messages)) + for i := 0; i < len(messages); { + start := i + i++ + + if messages[start].Role == "assistant" && len(messages[start].ToolCalls) > 0 { + for i < len(messages) && messages[i].Role == "tool" { + i++ + } + } + + spans = append(spans, span{start: start, end: i}) + } + + if len(spans) <= maxContextTurns { + return append([]provider.Message(nil), messages...) + } + + start := spans[len(spans)-maxContextTurns].start + clipped := append([]provider.Message(nil), messages[start:]...) + return clipped +} + +func (s *Service) systemPrompt() string { + return `You are NeoCode, a local coding agent. + +Be concise and accurate. +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 new file mode 100644 index 00000000..d9f4ce1b --- /dev/null +++ b/internal/runtime/runtime_test.go @@ -0,0 +1,764 @@ +package runtime + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/dust/neo-code/internal/config" + "github.com/dust/neo-code/internal/provider" + "github.com/dust/neo-code/internal/tools" +) + +type memoryStore struct { + sessions map[string]Session + saves int +} + +func newMemoryStore() *memoryStore { + return &memoryStore{sessions: map[string]Session{}} +} + +func (s *memoryStore) Save(ctx context.Context, session *Session) error { + if err := ctx.Err(); err != nil { + return err + } + if session == nil { + return errors.New("nil session") + } + s.saves++ + s.sessions[session.ID] = cloneSession(*session) + return nil +} + +func (s *memoryStore) Load(ctx context.Context, id string) (Session, error) { + if err := ctx.Err(); err != nil { + return Session{}, err + } + session, ok := s.sessions[id] + if !ok { + return Session{}, errors.New("not found") + } + return cloneSession(session), nil +} + +func (s *memoryStore) ListSummaries(ctx context.Context) ([]SessionSummary, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + summaries := make([]SessionSummary, 0, len(s.sessions)) + for _, session := range s.sessions { + summaries = append(summaries, SessionSummary{ + ID: session.ID, + Title: session.Title, + CreatedAt: session.CreatedAt, + UpdatedAt: session.UpdatedAt, + }) + } + return summaries, nil +} + +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)) + + callIndex := p.callCount + p.callCount++ + + if callIndex < len(p.streams) { + for _, event := range p.streams[callIndex] { + select { + case events <- event: + case <-ctx.Done(): + return provider.ChatResponse{}, ctx.Err() + } + } + } + + if callIndex >= len(p.responses) { + return provider.ChatResponse{}, fmt.Errorf("unexpected provider call %d", callIndex) + } + return p.responses[callIndex], nil +} + +type scriptedProviderFactory struct { + provider provider.Provider + calls int + err error +} + +func (f *scriptedProviderFactory) Build(cfg config.Config) (provider.Provider, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.provider, nil +} + +type stubTool struct { + name string + content string + isError bool + err error + callCount int + lastInput tools.ToolCallInput +} + +func (t *stubTool) Name() string { + return t.name +} + +func (t *stubTool) Description() string { + return "stub tool" +} + +func (t *stubTool) Schema() map[string]any { + return map[string]any{"type": "object"} +} + +func (t *stubTool) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + t.callCount++ + t.lastInput = input + if input.EmitChunk != nil { + input.EmitChunk([]byte("chunk")) + } + return tools.ToolResult{ + Name: t.name, + Content: t.content, + IsError: t.isError, + }, t.err +} + +func TestServiceRun(t *testing.T) { + tests := []struct { + name string + input UserInput + providerResponses []provider.ChatResponse + providerStreams [][]provider.StreamEvent + registerTool tools.Tool + expectProviderCalls int + expectToolCalls int + expectMessageRoles []string + expectEventTypes []EventType + assert func(t *testing.T, store *memoryStore, provider *scriptedProvider, tool *stubTool) + }{ + { + name: "normal dialogue exits after final assistant reply", + input: UserInput{Content: "hello"}, + providerResponses: []provider.ChatResponse{ + { + Message: provider.Message{ + Role: "assistant", + Content: "plain answer", + }, + FinishReason: "stop", + }, + }, + providerStreams: [][]provider.StreamEvent{ + { + {Type: provider.StreamEventTextDelta, Text: "plain "}, + {Type: provider.StreamEventTextDelta, Text: "answer"}, + }, + }, + expectProviderCalls: 1, + expectToolCalls: 0, + expectMessageRoles: []string{"user", "assistant"}, + expectEventTypes: []EventType{EventUserMessage, EventAgentChunk, EventAgentChunk, EventAgentDone}, + assert: func(t *testing.T, store *memoryStore, scripted *scriptedProvider, tool *stubTool) { + t.Helper() + if len(scripted.requests) != 1 { + t.Fatalf("expected 1 provider request, got %d", len(scripted.requests)) + } + if len(scripted.requests[0].Tools) == 0 { + t.Fatalf("expected tool specs to be forwarded") + } + if scripted.requests[0].SystemPrompt == "" { + t.Fatalf("expected system prompt to be set") + } + }, + }, + { + name: "tool call triggers execute and follow-up provider round", + input: UserInput{Content: "edit file"}, + providerResponses: []provider.ChatResponse{ + { + Message: provider.Message{ + Role: "assistant", + ToolCalls: []provider.ToolCall{ + { + ID: "call-1", + Name: "filesystem_edit", + Arguments: `{"path":"main.go"}`, + }, + }, + }, + FinishReason: "tool_calls", + }, + { + Message: provider.Message{ + Role: "assistant", + Content: "done", + }, + FinishReason: "stop", + }, + }, + registerTool: &stubTool{ + name: "filesystem_edit", + content: "tool output", + }, + expectProviderCalls: 2, + expectToolCalls: 1, + expectMessageRoles: []string{"user", "assistant", "tool", "assistant"}, + expectEventTypes: []EventType{EventUserMessage, EventToolStart, EventToolChunk, EventToolResult, EventAgentDone}, + assert: func(t *testing.T, store *memoryStore, scripted *scriptedProvider, tool *stubTool) { + t.Helper() + if tool == nil { + t.Fatalf("expected stub tool") + } + if tool.lastInput.ID != "call-1" { + t.Fatalf("expected tool call id call-1, got %q", tool.lastInput.ID) + } + if tool.lastInput.SessionID == "" { + t.Fatalf("expected session id to be forwarded to tool") + } + if len(scripted.requests) != 2 { + t.Fatalf("expected 2 provider requests, got %d", len(scripted.requests)) + } + second := scripted.requests[1] + foundToolResult := false + for _, message := range second.Messages { + if message.Role == "tool" && message.ToolCallID == "call-1" && message.Content == "tool output" { + foundToolResult = true + break + } + } + if !foundToolResult { + t.Fatalf("expected tool result message in second provider request: %+v", second.Messages) + } + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + + registry := tools.NewRegistry() + defaultTool := &stubTool{name: "filesystem_read_file", content: "default"} + registry.Register(defaultTool) + + var registeredTool *stubTool + if tt.registerTool != nil { + if stub, ok := tt.registerTool.(*stubTool); ok { + registeredTool = stub + } + registry.Register(tt.registerTool) + } + + scripted := &scriptedProvider{ + name: "scripted", + responses: tt.providerResponses, + streams: tt.providerStreams, + } + factory := &scriptedProviderFactory{provider: scripted} + + service := NewWithFactory(manager, registry, store, factory) + if err := service.Run(context.Background(), tt.input); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if factory.calls != tt.expectProviderCalls { + t.Fatalf("expected %d provider builds, got %d", tt.expectProviderCalls, factory.calls) + } + if registeredTool != nil && registeredTool.callCount != tt.expectToolCalls { + t.Fatalf("expected %d tool executes, got %d", tt.expectToolCalls, registeredTool.callCount) + } + + session := onlySession(t, store) + if len(session.Messages) != len(tt.expectMessageRoles) { + t.Fatalf("expected %d session messages, got %d", len(tt.expectMessageRoles), len(session.Messages)) + } + for idx, role := range tt.expectMessageRoles { + if session.Messages[idx].Role != role { + t.Fatalf("expected message[%d] role %q, got %q", idx, role, session.Messages[idx].Role) + } + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, tt.expectEventTypes) + + if tt.assert != nil { + tt.assert(t, store, scripted, registeredTool) + } + }) + } +} + +func TestServiceTrimMessagesPreservesToolPairs(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + service := NewWithFactory(manager, tools.NewRegistry(), newMemoryStore(), &scriptedProviderFactory{provider: &scriptedProvider{name: "noop"}}) + + messages := make([]provider.Message, 0, maxContextTurns+4) + for i := 0; i < 8; i++ { + messages = append(messages, provider.Message{Role: "user", Content: fmt.Sprintf("u-%d", i)}) + } + messages = append(messages, + provider.Message{ + Role: "assistant", + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "filesystem_edit", Arguments: "{}"}, + }, + }, + provider.Message{Role: "tool", ToolCallID: "call-1", Content: "tool-result"}, + provider.Message{Role: "assistant", Content: "after-tool"}, + provider.Message{Role: "user", Content: "latest"}, + ) + + trimmed := service.trimMessages(messages) + if len(trimmed) > len(messages) { + t.Fatalf("trimmed messages should not grow") + } + + foundAssistantToolCall := false + foundToolResult := false + for _, message := range trimmed { + if message.Role == "assistant" && len(message.ToolCalls) > 0 { + foundAssistantToolCall = true + } + if message.Role == "tool" && message.ToolCallID == "call-1" { + foundToolResult = true + } + } + if foundAssistantToolCall != foundToolResult { + t.Fatalf("expected tool call and tool result to be preserved together, got %+v", trimmed) + } +} + +func TestServiceTrimMessagesBoundaries(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + service := NewWithFactory(manager, tools.NewRegistry(), newMemoryStore(), &scriptedProviderFactory{provider: &scriptedProvider{name: "noop"}}) + + tests := []struct { + name string + input []provider.Message + wantLen int + assert func(t *testing.T, original []provider.Message, trimmed []provider.Message) + }{ + { + name: "within max turns returns full cloned slice", + input: []provider.Message{ + {Role: "user", Content: "one"}, + {Role: "assistant", Content: "two"}, + }, + wantLen: 2, + assert: func(t *testing.T, original []provider.Message, trimmed []provider.Message) { + t.Helper() + if &trimmed[0] == &original[0] { + t.Fatalf("expected trimmed slice to be cloned") + } + }, + }, + { + name: "long message list with limited spans keeps full history", + input: func() []provider.Message { + messages := make([]provider.Message, 0, maxContextTurns+3) + for i := 0; i < maxContextTurns-1; i++ { + messages = append(messages, provider.Message{Role: "user", Content: fmt.Sprintf("u-%d", i)}) + } + messages = append(messages, + provider.Message{ + Role: "assistant", + ToolCalls: []provider.ToolCall{ + {ID: "call-1", Name: "filesystem_edit", Arguments: "{}"}, + }, + }, + provider.Message{Role: "tool", ToolCallID: "call-1", Content: "tool-1"}, + provider.Message{Role: "tool", ToolCallID: "call-1", Content: "tool-2"}, + ) + return messages + }(), + wantLen: maxContextTurns + 2, + assert: func(t *testing.T, original []provider.Message, trimmed []provider.Message) { + t.Helper() + if len(trimmed) != len(original) { + t.Fatalf("expected full history to remain, got %d want %d", len(trimmed), len(original)) + } + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + trimmed := service.trimMessages(tt.input) + if len(trimmed) != tt.wantLen { + t.Fatalf("expected len %d, got %d", tt.wantLen, len(trimmed)) + } + if tt.assert != nil { + tt.assert(t, tt.input, trimmed) + } + }) + } +} + +func TestServiceRunErrorPaths(t *testing.T) { + tests := []struct { + name string + input UserInput + maxLoops int + provider *scriptedProvider + factoryErr error + registerTool *stubTool + seedSession *Session + expectErr string + expectEvents []EventType + assert func(t *testing.T, store *memoryStore, provider *scriptedProvider, tool *stubTool) + }{ + { + name: "empty input returns validation error", + input: UserInput{Content: " "}, + expectErr: "input content is empty", + assert: func(t *testing.T, store *memoryStore, provider *scriptedProvider, tool *stubTool) { + t.Helper() + if len(store.sessions) != 0 { + t.Fatalf("expected no sessions to be created") + } + }, + }, + { + name: "max loops reached after repeated tool cycles", + input: UserInput{Content: "loop"}, + maxLoops: 1, + provider: &scriptedProvider{ + name: "looping", + responses: []provider.ChatResponse{ + { + Message: provider.Message{ + Role: "assistant", + ToolCalls: []provider.ToolCall{ + {ID: "loop-call", Name: "filesystem_edit", Arguments: `{"path":"x"}`}, + }, + }, + FinishReason: "tool_calls", + }, + }, + }, + registerTool: &stubTool{name: "filesystem_edit", content: "loop tool output"}, + expectErr: "max loop reached", + expectEvents: []EventType{EventUserMessage, EventToolStart, EventToolChunk, EventToolResult, EventError}, + assert: func(t *testing.T, store *memoryStore, scripted *scriptedProvider, tool *stubTool) { + t.Helper() + if scripted.callCount != 1 { + t.Fatalf("expected one provider call before loop exit, got %d", scripted.callCount) + } + session := onlySession(t, store) + if len(session.Messages) != 3 { + t.Fatalf("expected user, assistant, tool messages before abort, got %d", len(session.Messages)) + } + }, + }, + { + name: "provider factory error emits runtime error", + input: UserInput{Content: "hello"}, + factoryErr: errors.New("factory failed"), + expectErr: "factory failed", + expectEvents: []EventType{ + EventUserMessage, + EventError, + }, + }, + { + name: "existing session is reused", + input: UserInput{ + SessionID: "existing-session", + Content: "continue", + }, + provider: &scriptedProvider{ + name: "resume", + responses: []provider.ChatResponse{ + { + Message: provider.Message{ + Role: "assistant", + Content: "resumed", + }, + FinishReason: "stop", + }, + }, + }, + seedSession: &Session{ + ID: "existing-session", + Title: "Resume Me", + CreatedAt: newSession("seed").CreatedAt, + UpdatedAt: newSession("seed").UpdatedAt, + Messages: []provider.Message{ + {Role: "user", Content: "earlier"}, + }, + }, + expectEvents: []EventType{EventUserMessage, EventAgentDone}, + assert: func(t *testing.T, store *memoryStore, scripted *scriptedProvider, tool *stubTool) { + t.Helper() + session, ok := store.sessions["existing-session"] + if !ok { + t.Fatalf("expected existing session to be updated") + } + if len(session.Messages) != 3 { + t.Fatalf("expected original message plus new user/assistant, got %d", len(session.Messages)) + } + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + manager := newRuntimeConfigManager(t) + if tt.maxLoops > 0 { + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.MaxLoops = tt.maxLoops + return nil + }); err != nil { + t.Fatalf("update max loops: %v", err) + } + } + + store := newMemoryStore() + if tt.seedSession != nil { + store.sessions[tt.seedSession.ID] = cloneSession(*tt.seedSession) + } + + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + if tt.registerTool != nil { + registry.Register(tt.registerTool) + } + + factory := &scriptedProviderFactory{ + provider: tt.provider, + err: tt.factoryErr, + } + + service := NewWithFactory(manager, registry, store, factory) + err := service.Run(context.Background(), tt.input) + if tt.expectErr != "" { + if err == nil || err.Error() != tt.expectErr && !containsError(err, tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) + } + } else if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(tt.expectEvents) > 0 { + assertEventSequence(t, collectRuntimeEvents(service.Events()), tt.expectEvents) + } + if tt.assert != nil { + tt.assert(t, store, tt.provider, tt.registerTool) + } + }) + } +} + +func TestServiceConstructorsAndDelegates(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "ok"}) + + service := New(manager, registry, store) + if service == nil { + t.Fatalf("expected service") + } + if service.Events() == nil { + t.Fatalf("expected events channel") + } + + session := newSession("List Me") + store.sessions[session.ID] = cloneSession(session) + + summaries, err := service.ListSessions(context.Background()) + if err != nil { + t.Fatalf("ListSessions() error = %v", err) + } + if len(summaries) != 1 || summaries[0].ID != session.ID { + t.Fatalf("unexpected summaries: %+v", summaries) + } + + loaded, err := service.LoadSession(context.Background(), session.ID) + if err != nil { + t.Fatalf("LoadSession() error = %v", err) + } + if loaded.ID != session.ID { + t.Fatalf("expected loaded session %q, got %q", session.ID, loaded.ID) + } + + sessionStore := NewSessionStore(t.TempDir()) + if sessionStore == nil { + t.Fatalf("expected JSON session store") + } +} + +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())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("load config: %v", err) + } + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Workdir = t.TempDir() + cfg.ToolTimeoutSec = 1 + cfg.MaxLoops = 4 + return nil + }); err != nil { + t.Fatalf("update config: %v", err) + } + return manager +} + +func onlySession(t *testing.T, store *memoryStore) Session { + t.Helper() + if len(store.sessions) != 1 { + t.Fatalf("expected exactly 1 session, got %d", len(store.sessions)) + } + for _, session := range store.sessions { + return session + } + return Session{} +} + +func collectRuntimeEvents(events <-chan RuntimeEvent) []RuntimeEvent { + collected := make([]RuntimeEvent, 0, 8) + for { + select { + case event := <-events: + collected = append(collected, event) + default: + return collected + } + } +} + +func assertEventSequence(t *testing.T, events []RuntimeEvent, expected []EventType) { + t.Helper() + for _, eventType := range expected { + found := false + for _, event := range events { + if event.Type == eventType { + found = true + break + } + } + if !found { + t.Fatalf("expected event %q in %+v", eventType, events) + } + } +} + +func cloneSession(session Session) Session { + cloned := session + cloned.Messages = append([]provider.Message(nil), session.Messages...) + return cloned +} + +func cloneChatRequest(req provider.ChatRequest) provider.ChatRequest { + cloned := req + cloned.Messages = append([]provider.Message(nil), req.Messages...) + cloned.Tools = append([]provider.ToolSpec(nil), req.Tools...) + return cloned +} + +func containsError(err error, target string) bool { + return err != nil && strings.Contains(err.Error(), target) +} diff --git a/internal/runtime/session.go b/internal/runtime/session.go new file mode 100644 index 00000000..e77177b4 --- /dev/null +++ b/internal/runtime/session.go @@ -0,0 +1,188 @@ +package runtime + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/dust/neo-code/internal/provider" +) + +const sessionsDirName = "sessions" + +type Session struct { + ID string `json:"id"` + Title string `json:"title"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Messages []provider.Message `json:"messages"` +} + +type SessionSummary struct { + ID string `json:"id"` + Title string `json:"title"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Store interface { + Save(ctx context.Context, session *Session) error + Load(ctx context.Context, id string) (Session, error) + ListSummaries(ctx context.Context) ([]SessionSummary, error) +} + +type JSONSessionStore struct { + mu sync.RWMutex + baseDir string +} + +func NewJSONSessionStore(baseDir string) *JSONSessionStore { + return &JSONSessionStore{ + baseDir: filepath.Join(baseDir, sessionsDirName), + } +} + +func NewSessionStore(baseDir string) *JSONSessionStore { + return NewJSONSessionStore(baseDir) +} + +func (s *JSONSessionStore) Save(ctx context.Context, session *Session) error { + if err := ctx.Err(); err != nil { + return err + } + if session == nil { + return errors.New("runtime: session is nil") + } + + s.mu.Lock() + defer s.mu.Unlock() + + if err := os.MkdirAll(s.baseDir, 0o755); err != nil { + return fmt.Errorf("runtime: create sessions dir: %w", err) + } + + payload, err := json.MarshalIndent(session, "", " ") + if err != nil { + return fmt.Errorf("runtime: marshal session: %w", err) + } + payload = append(payload, '\n') + + target := s.filePath(session.ID) + temp := target + ".tmp" + if err := os.WriteFile(temp, payload, 0o644); err != nil { + return fmt.Errorf("runtime: write temp session: %w", err) + } + if err := os.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("runtime: replace session file: %w", err) + } + if err := os.Rename(temp, target); err != nil { + return fmt.Errorf("runtime: commit session file: %w", err) + } + + return nil +} + +func (s *JSONSessionStore) Load(ctx context.Context, id string) (Session, error) { + if err := ctx.Err(); err != nil { + return Session{}, err + } + + s.mu.RLock() + defer s.mu.RUnlock() + + data, err := os.ReadFile(s.filePath(id)) + if err != nil { + return Session{}, err + } + + var session Session + if err := json.Unmarshal(data, &session); err != nil { + return Session{}, fmt.Errorf("runtime: decode session %s: %w", id, err) + } + return session, nil +} + +func (s *JSONSessionStore) ListSummaries(ctx context.Context) ([]SessionSummary, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + s.mu.RLock() + defer s.mu.RUnlock() + + if err := os.MkdirAll(s.baseDir, 0o755); err != nil { + return nil, fmt.Errorf("runtime: create sessions dir: %w", err) + } + + entries, err := os.ReadDir(s.baseDir) + if err != nil { + return nil, fmt.Errorf("runtime: list sessions dir: %w", err) + } + + summaries := make([]SessionSummary, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + data, readErr := os.ReadFile(filepath.Join(s.baseDir, entry.Name())) + if readErr != nil { + continue + } + + var summary SessionSummary + if err := json.Unmarshal(data, &summary); err != nil { + continue + } + if strings.TrimSpace(summary.ID) == "" { + continue + } + summaries = append(summaries, summary) + } + + sort.Slice(summaries, func(i, j int) bool { + return summaries[i].UpdatedAt.After(summaries[j].UpdatedAt) + }) + + return summaries, nil +} + +func (s *JSONSessionStore) filePath(id string) string { + return filepath.Join(s.baseDir, id+".json") +} + +func newSession(title string) Session { + now := time.Now() + return Session{ + ID: newID("session"), + Title: sanitizeTitle(title), + CreatedAt: now, + UpdatedAt: now, + Messages: []provider.Message{}, + } +} + +func sanitizeTitle(title string) string { + title = strings.TrimSpace(title) + if title == "" { + return "New Session" + } + runes := []rune(title) + if len(runes) > 40 { + return string(runes[:40]) + } + return title +} diff --git a/internal/runtime/session_test.go b/internal/runtime/session_test.go new file mode 100644 index 00000000..41574f93 --- /dev/null +++ b/internal/runtime/session_test.go @@ -0,0 +1,160 @@ +package runtime + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/dust/neo-code/internal/provider" +) + +func TestJSONSessionStoreSaveLoadAndListSummaries(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + store := NewJSONSessionStore(baseDir) + + older := &Session{ + ID: "session-old", + Title: "Old Session", + CreatedAt: time.Now().Add(-2 * time.Hour), + UpdatedAt: time.Now().Add(-1 * time.Hour), + Messages: []provider.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "world"}, + }, + } + newer := &Session{ + ID: "session-new", + Title: "New Session", + CreatedAt: time.Now().Add(-30 * time.Minute), + UpdatedAt: time.Now(), + Messages: []provider.Message{ + {Role: "user", Content: "new"}, + }, + } + + if err := store.Save(context.Background(), older); err != nil { + t.Fatalf("Save older session: %v", err) + } + if err := store.Save(context.Background(), newer); err != nil { + t.Fatalf("Save newer session: %v", err) + } + + loaded, err := store.Load(context.Background(), older.ID) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if loaded.Title != older.Title { + t.Fatalf("expected title %q, got %q", older.Title, loaded.Title) + } + if len(loaded.Messages) != 2 || loaded.Messages[1].Content != "world" { + t.Fatalf("unexpected loaded messages: %+v", loaded.Messages) + } + + mustWriteRuntimeFile(t, filepath.Join(baseDir, sessionsDirName, "invalid.json"), "{invalid") + if err := os.MkdirAll(filepath.Join(baseDir, sessionsDirName, "directory"), 0o755); err != nil { + t.Fatalf("mkdir stray directory: %v", err) + } + + summaries, err := store.ListSummaries(context.Background()) + if err != nil { + t.Fatalf("ListSummaries() error: %v", err) + } + if len(summaries) != 2 { + t.Fatalf("expected 2 summaries, got %d", len(summaries)) + } + if summaries[0].ID != newer.ID || summaries[1].ID != older.ID { + t.Fatalf("expected summaries sorted by UpdatedAt desc, got %+v", summaries) + } +} + +func TestJSONSessionStoreErrors(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + store := NewJSONSessionStore(baseDir) + + cancelledCtx, cancel := context.WithCancel(context.Background()) + cancel() + + if err := store.Save(cancelledCtx, &Session{ID: "x"}); err == nil { + t.Fatalf("expected cancelled save to fail") + } + if err := store.Save(context.Background(), nil); err == nil { + t.Fatalf("expected nil session save to fail") + } + if _, err := store.Load(cancelledCtx, "missing"); err == nil { + t.Fatalf("expected cancelled load to fail") + } + if _, err := store.ListSummaries(cancelledCtx); err == nil { + t.Fatalf("expected cancelled list to fail") + } +} + +func TestJSONSessionStoreCorruptedSessionBehaviors(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + store := NewJSONSessionStore(baseDir) + + valid := &Session{ + ID: "valid-session", + Title: "Valid Session", + CreatedAt: time.Now().Add(-time.Minute), + UpdatedAt: time.Now(), + Messages: []provider.Message{{Role: "user", Content: "hello"}}, + } + if err := store.Save(context.Background(), valid); err != nil { + t.Fatalf("Save valid session: %v", err) + } + + mustWriteRuntimeFile(t, filepath.Join(baseDir, sessionsDirName, "broken.json"), "{broken") + + _, err := store.Load(context.Background(), "broken") + if err == nil || !strings.Contains(err.Error(), "decode session broken") { + t.Fatalf("expected corrupted session decode error, got %v", err) + } + + summaries, err := store.ListSummaries(context.Background()) + if err != nil { + t.Fatalf("ListSummaries() error: %v", err) + } + if len(summaries) != 1 || summaries[0].ID != valid.ID { + t.Fatalf("expected corrupted session file to be skipped, got %+v", summaries) + } +} + +func TestJSONSessionStoreSaveInvalidBaseDir(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + baseFile := filepath.Join(tempDir, "not-a-directory") + if err := os.WriteFile(baseFile, []byte("x"), 0o644); err != nil { + t.Fatalf("write base file: %v", err) + } + + store := NewJSONSessionStore(baseFile) + err := store.Save(context.Background(), &Session{ + ID: "session-x", + Title: "Broken Save", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + if err == nil || !strings.Contains(err.Error(), "create sessions dir") { + t.Fatalf("expected invalid base dir error, got %v", err) + } +} + +func mustWriteRuntimeFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/internal/server/domain/chat.go b/internal/server/domain/chat.go deleted file mode 100644 index 010e639c..00000000 --- a/internal/server/domain/chat.go +++ /dev/null @@ -1,24 +0,0 @@ -package domain - -import ( - "context" -) - -type ChatRequest struct { - Messages []Message - Model string -} - -type ChatGateway interface { - Send(ctx context.Context, req *ChatRequest) (<-chan string, error) -} - -type ChatProvider interface { - GetModelName() string - Chat(ctx context.Context, messages []Message) (<-chan string, error) -} - -type Message struct { - Role string `json:"role"` - Content string `json:"content"` -} diff --git a/internal/server/domain/memory.go b/internal/server/domain/memory.go deleted file mode 100644 index 0ae4e9e6..00000000 --- a/internal/server/domain/memory.go +++ /dev/null @@ -1,235 +0,0 @@ -package domain - -import ( - "context" - "strings" - "time" -) - -const ( - TypeSessionMemory = "session_memory" - TypeProjectRule = "project_rule" - TypeCodeFact = "code_fact" - TypeUserPreference = "user_preference" - TypeFixRecipe = "fix_recipe" - TypeLegacyChat = "legacy_chat" - - ScopeSession = "session" - ScopeProject = "project" - ScopeUser = "user" -) - -type MemoryItem struct { - ID string `json:"id"` - Type string `json:"type,omitempty"` - Summary string `json:"summary,omitempty"` - Details string `json:"details,omitempty"` - Scope string `json:"scope,omitempty"` - Tags []string `json:"tags,omitempty"` - Source string `json:"source,omitempty"` - Confidence float64 `json:"confidence,omitempty"` - UserInput string `json:"user_input,omitempty"` - AssistantReply string `json:"assistant_reply,omitempty"` - Text string `json:"text,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at,omitempty"` -} - -type MemoryRepository interface { - List(ctx context.Context) ([]MemoryItem, error) - Add(ctx context.Context, item MemoryItem) error - Clear(ctx context.Context) error -} - -type MemoryService interface { - BuildContext(ctx context.Context, userInput string) (string, error) - Save(ctx context.Context, userInput, reply string) error - GetStats(ctx context.Context) (*MemoryStats, error) - Clear(ctx context.Context) error - ClearSession(ctx context.Context) error -} - -type MemoryStats struct { - PersistentItems int - SessionItems int - TotalItems int - TopK int - MinScore float64 - Path string - ByType map[string]int -} - -// IsPersistentType 判断记忆类型是否应该长期持久化。 -func IsPersistentType(itemType string) bool { - switch strings.TrimSpace(itemType) { - case TypeUserPreference, TypeProjectRule, TypeCodeFact, TypeFixRecipe: - return true - default: - return false - } -} - -// Normalized 为记忆项补齐缺失字段并应用默认值。 -func (i MemoryItem) Normalized() MemoryItem { - normalized := i - if normalized.Type == "" { - normalized.Type = TypeLegacyChat - } - if normalized.Type == "project_memory" { - normalized.Type = TypeProjectRule - } - if normalized.Type == "failure_note" { - normalized.Type = TypeFixRecipe - } - if normalized.Scope == "" { - switch normalized.Type { - case TypeProjectRule, TypeCodeFact, TypeFixRecipe: - normalized.Scope = ScopeProject - case TypeUserPreference: - normalized.Scope = ScopeUser - default: - normalized.Scope = ScopeSession - } - } - if normalized.Confidence <= 0 { - normalized.Confidence = 0.5 - } - if normalized.UpdatedAt.IsZero() { - normalized.UpdatedAt = normalized.CreatedAt - } - if normalized.Source == "" { - normalized.Source = "conversation" - } - if normalized.Summary == "" { - normalized.Summary = SummarizeText(firstNonEmpty(normalized.UserInput, normalized.Text), 160) - } - if normalized.Details == "" { - normalized.Details = strings.TrimSpace(firstNonEmpty(normalized.AssistantReply, normalized.Text)) - } - normalized.Details = SummarizeText(normalized.Details, 320) - if normalized.Text == "" { - normalized.Text = normalized.SearchText() - } - if len(normalized.Tags) == 0 { - normalized.Tags = InferTags(normalized.Summary + "\n" + normalized.Details) - } - return normalized -} - -// SearchText 根据记忆项字段构建用于检索的文本。 -func (i MemoryItem) SearchText() string { - parts := make([]string, 0, 4) - if strings.TrimSpace(i.Type) != "" { - parts = append(parts, i.Type) - } - if strings.TrimSpace(i.Summary) != "" { - parts = append(parts, i.Summary) - } - if strings.TrimSpace(i.Details) != "" { - parts = append(parts, i.Details) - } - if len(i.Tags) > 0 { - parts = append(parts, strings.Join(i.Tags, " ")) - } - if len(parts) == 0 { - return strings.TrimSpace(firstNonEmpty(i.Text, i.UserInput+"\n"+i.AssistantReply)) - } - return strings.TrimSpace(strings.Join(parts, "\n")) -} - -// PromptBlock 将记忆项格式化为可注入提示词的文本块。 -func (i MemoryItem) PromptBlock() string { - parts := []string{ - "Type: " + i.Type, - "Scope: " + i.Scope, - "Summary: " + i.Summary, - } - if strings.TrimSpace(i.Details) != "" { - parts = append(parts, "Details: "+SummarizeText(i.Details, 180)) - } - if len(i.Tags) > 0 { - parts = append(parts, "Tags: "+strings.Join(i.Tags, ", ")) - } - return strings.Join(parts, "\n") -} - -// SummarizeText 按指定最大长度截断文本。 -func SummarizeText(text string, maxLen int) string { - trimmed := strings.TrimSpace(text) - if len(trimmed) <= maxLen { - return trimmed - } - if maxLen <= 3 { - return trimmed[:maxLen] - } - return trimmed[:maxLen-3] + "..." -} - -// InferTags 从自由文本中推断简要主题标签。 -func InferTags(text string) []string { - trimmed := strings.ToLower(text) - tags := make([]string, 0, 6) - appendTag := func(tag string) { - for _, existing := range tags { - if existing == tag { - return - } - } - tags = append(tags, tag) - } - keywords := map[string][]string{ - "go": {"go ", "golang", "go.mod", "go test", "go build"}, - "config": {"config", "yaml", "env", "api_key"}, - "memory": {"memory", "rule", "recall", "preference"}, - "testing": {"test", "testing", "assert", "coverage"}, - "build": {"build", "compile", "binary"}, - "bugfix": {"bug", "fix", "error", "failed", "panic"}, - "workflow": {"todo", "plan", "command", "cli"}, - "path": {"config.yaml", "readme", "services/", "memory/", "main.go"}, - } - for tag, words := range keywords { - for _, word := range words { - if strings.Contains(trimmed, word) { - appendTag(tag) - break - } - } - } - return tags -} - -// Keywords 从文本中提取去重后的小写关键词。 -func Keywords(text string) []string { - text = strings.ToLower(text) - replacer := strings.NewReplacer( - ",", " ", ".", " ", ":", " ", ";", " ", - "(", " ", ")", " ", "[", " ", "]", " ", - "{", " ", "}", " ", "\n", " ", "\t", " ", - ) - cleaned := replacer.Replace(text) - parts := strings.Fields(cleaned) - seen := map[string]struct{}{} - result := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if len(part) < 2 { - continue - } - if _, ok := seen[part]; ok { - continue - } - seen[part] = struct{}{} - result = append(result, part) - } - return result -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - trimmed := strings.TrimSpace(value) - if trimmed != "" { - return trimmed - } - } - return "" -} diff --git a/internal/server/domain/role.go b/internal/server/domain/role.go deleted file mode 100644 index a451d0c0..00000000 --- a/internal/server/domain/role.go +++ /dev/null @@ -1,31 +0,0 @@ -package domain - -import ( - "context" - "time" -) - -type Role struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Prompt string `json:"prompt"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type RoleRepository interface { - GetByID(ctx context.Context, id string) (*Role, error) - GetByName(ctx context.Context, name string) (*Role, error) - List(ctx context.Context) ([]Role, error) - Save(ctx context.Context, role *Role) error - Delete(ctx context.Context, id string) error -} - -type RoleService interface { - GetActivePrompt(ctx context.Context) (string, error) - SetActive(ctx context.Context, roleID string) error - List(ctx context.Context) ([]Role, error) - Create(ctx context.Context, name, desc, prompt string) (*Role, error) - Delete(ctx context.Context, id string) error -} diff --git a/internal/server/domain/security.go b/internal/server/domain/security.go deleted file mode 100644 index ceee6298..00000000 --- a/internal/server/domain/security.go +++ /dev/null @@ -1,31 +0,0 @@ -package domain - -type SecurityChecker interface { - Check(toolType string, target string) Action -} - -type Action string - -const ( - ActionDeny Action = "deny" - ActionAllow Action = "allow" - ActionAsk Action = "ask" -) - -type Rule struct { - Target string `yaml:"target,omitempty"` - Command string `yaml:"command,omitempty"` - Domain string `yaml:"domain,omitempty"` - Read string `yaml:"read,omitempty"` - Write string `yaml:"write,omitempty"` - Exec string `yaml:"exec,omitempty"` - Network string `yaml:"network,omitempty"` -} - -type Config struct { - Rules []Rule `yaml:"rules"` -} - -type SecurityConfigRepository interface { - LoadAll(configDir string) (*Config, *Config, *Config, error) -} diff --git a/internal/server/domain/todo.go b/internal/server/domain/todo.go deleted file mode 100644 index 5ef37f5c..00000000 --- a/internal/server/domain/todo.go +++ /dev/null @@ -1,94 +0,0 @@ -package domain - -import ( - "context" - "strings" -) - -// TodoStatus 表示任务状态 -type TodoStatus string - -const ( - TodoPending TodoStatus = "pending" - TodoInProgress TodoStatus = "in_progress" - TodoCompleted TodoStatus = "completed" -) - -func ParseTodoStatus(input string) (TodoStatus, bool) { - normalized := TodoStatus(strings.ToLower(strings.TrimSpace(input))) - switch normalized { - case TodoPending, TodoInProgress, TodoCompleted: - return normalized, true - default: - return "", false - } -} - -type TodoPriority string - -const ( - TodoPriorityHigh TodoPriority = "high" - TodoPriorityMedium TodoPriority = "medium" - TodoPriorityLow TodoPriority = "low" -) - -func ParseTodoPriority(input string) (TodoPriority, bool) { - normalized := TodoPriority(strings.ToLower(strings.TrimSpace(input))) - switch normalized { - case TodoPriorityHigh, TodoPriorityMedium, TodoPriorityLow: - return normalized, true - default: - return "", false - } -} - -type TodoAction string - -const ( - TodoActionAdd TodoAction = "add" - TodoActionUpdate TodoAction = "update" - TodoActionList TodoAction = "list" - TodoActionRemove TodoAction = "remove" - TodoActionClear TodoAction = "clear" -) - -func ParseTodoAction(input string) (TodoAction, bool) { - normalized := TodoAction(strings.ToLower(strings.TrimSpace(input))) - switch normalized { - case TodoActionAdd, TodoActionUpdate, TodoActionList, TodoActionRemove, TodoActionClear: - return normalized, true - default: - return "", false - } -} - -// Todo 表示任务清单中的一项 -type Todo struct { - ID string `json:"id"` - Content string `json:"content"` - Status TodoStatus `json:"status"` - Priority TodoPriority `json:"priority"` -} - -// TodoService 定义任务清单服务接口 -type TodoService interface { - // AddTodo 添加一个新任务 - AddTodo(ctx context.Context, content string, priority TodoPriority) (*Todo, error) - // UpdateTodoStatus 更新任务状态 - UpdateTodoStatus(ctx context.Context, id string, status TodoStatus) error - // ListTodos 获取所有任务 - ListTodos(ctx context.Context) ([]Todo, error) - // ClearTodos 清空所有任务 - ClearTodos(ctx context.Context) error - // RemoveTodo 移除特定任务 - RemoveTodo(ctx context.Context, id string) error -} - -// TodoRepository 定义任务清单存储接口 -type TodoRepository interface { - Add(ctx context.Context, todo Todo) (*Todo, error) - UpdateStatus(ctx context.Context, id string, status TodoStatus) error - List(ctx context.Context) ([]Todo, error) - Clear(ctx context.Context) error - Remove(ctx context.Context, id string) error -} diff --git a/internal/server/domain/todo_test.go b/internal/server/domain/todo_test.go deleted file mode 100644 index 75bb7a6d..00000000 --- a/internal/server/domain/todo_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package domain - -import "testing" - -func TestParseTodoStatus(t *testing.T) { - tests := []struct { - name string - in string - want TodoStatus - wantOK bool - }{ - {name: "pending", in: "pending", want: TodoPending, wantOK: true}, - {name: "in_progress", in: "in_progress", want: TodoInProgress, wantOK: true}, - {name: "completed", in: "completed", want: TodoCompleted, wantOK: true}, - {name: "trim and lower", in: " COMPLETED ", want: TodoCompleted, wantOK: true}, - {name: "invalid", in: "done", want: "", wantOK: false}, - {name: "empty", in: " ", want: "", wantOK: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, ok := ParseTodoStatus(tt.in) - if ok != tt.wantOK { - t.Fatalf("expected ok=%v, got %v", tt.wantOK, ok) - } - if got != tt.want { - t.Fatalf("expected %q, got %q", tt.want, got) - } - }) - } -} - -func TestParseTodoPriority(t *testing.T) { - tests := []struct { - name string - in string - want TodoPriority - wantOK bool - }{ - {name: "high", in: "high", want: TodoPriorityHigh, wantOK: true}, - {name: "medium", in: "medium", want: TodoPriorityMedium, wantOK: true}, - {name: "low", in: "low", want: TodoPriorityLow, wantOK: true}, - {name: "trim and lower", in: "\tHiGh\n", want: TodoPriorityHigh, wantOK: true}, - {name: "invalid", in: "urgent", want: "", wantOK: false}, - {name: "empty", in: "", want: "", wantOK: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, ok := ParseTodoPriority(tt.in) - if ok != tt.wantOK { - t.Fatalf("expected ok=%v, got %v", tt.wantOK, ok) - } - if got != tt.want { - t.Fatalf("expected %q, got %q", tt.want, got) - } - }) - } -} - -func TestParseTodoAction(t *testing.T) { - tests := []struct { - name string - in string - want TodoAction - wantOK bool - }{ - {name: "add", in: "add", want: TodoActionAdd, wantOK: true}, - {name: "update", in: "update", want: TodoActionUpdate, wantOK: true}, - {name: "list", in: "list", want: TodoActionList, wantOK: true}, - {name: "remove", in: "remove", want: TodoActionRemove, wantOK: true}, - {name: "clear", in: "clear", want: TodoActionClear, wantOK: true}, - {name: "trim and lower", in: " LiSt ", want: TodoActionList, wantOK: true}, - {name: "invalid", in: "toggle", want: "", wantOK: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, ok := ParseTodoAction(tt.in) - if ok != tt.wantOK { - t.Fatalf("expected ok=%v, got %v", tt.wantOK, ok) - } - if got != tt.want { - t.Fatalf("expected %q, got %q", tt.want, got) - } - }) - } -} diff --git a/internal/server/domain/tool.go b/internal/server/domain/tool.go deleted file mode 100644 index a94e34b2..00000000 --- a/internal/server/domain/tool.go +++ /dev/null @@ -1,80 +0,0 @@ -package domain - -import ( - "encoding/json" - "strings" -) - -// ToolCall 表示工具调用请求。 -type ToolCall struct { - Tool string `json:"tool"` - Params map[string]interface{} `json:"params"` -} - -type ToolName string - -const ( - ToolRead ToolName = "Read" - ToolWrite ToolName = "Write" - ToolEdit ToolName = "Edit" - ToolBash ToolName = "Bash" - ToolList ToolName = "List" - ToolGrep ToolName = "Grep" - ToolWebFetch ToolName = "Webfetch" - ToolTodo ToolName = "Todo" -) - -func ParseToolName(input string) (ToolName, bool) { - normalized := ToolName(strings.ToLower(strings.TrimSpace(input))) - if normalized == "web_fetch" { - normalized = ToolWebFetch - } - switch normalized { - case ToolRead, ToolWrite, ToolEdit, ToolBash, ToolList, ToolGrep, ToolWebFetch, ToolTodo: - return normalized, true - default: - return "", false - } -} - -type Tool interface { - Definition() ToolDefinition - Run(params map[string]interface{}) *ToolResult -} - -// ToolResult 表示执行工具的结果。 -type ToolResult struct { - ToolName string `json:"tool"` - Success bool `json:"success"` - Output string `json:"output,omitempty"` - Error string `json:"error,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` -} - -// ToolDefinition 描述工具的定义,包括名称、描述和参数规范。 -type ToolDefinition struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters []ToolParamSpec `json:"parameters"` -} - -// ToolParamSpec 描述工具的单个参数规范。 -type ToolParamSpec struct { - Name string `json:"name"` // 参数名称 - Type string `json:"type"` // 参数类型(string, integer, boolean 等) - Required bool `json:"required"` // 是否必需 - Description string `json:"description"` // 参数描述 -} - -func (tr *ToolResult) MarshalJSON() ([]byte, error) { - type Alias ToolResult - return json.Marshal(&struct { - *Alias - Output string `json:"output,omitempty"` - Error string `json:"error,omitempty"` - }{ - Alias: (*Alias)(tr), - Output: tr.Output, - Error: tr.Error, - }) -} diff --git a/internal/server/domain/working_memory.go b/internal/server/domain/working_memory.go deleted file mode 100644 index 052c9c3f..00000000 --- a/internal/server/domain/working_memory.go +++ /dev/null @@ -1,41 +0,0 @@ -package domain - -import ( - "context" - "time" -) - -// WorkingMemoryTurn 表示一轮可复用的用户/助手对话。 -type WorkingMemoryTurn struct { - User string - Assistant string -} - -// WorkingMemoryState 表示当前会话的工作记忆快照。 -// 第一阶段只保留任务摘要、最近对话、待解决问题和最近文件引用。 -type WorkingMemoryState struct { - CurrentTask string - TaskSummary string - LastCompletedAction string - CurrentInProgress string - NextStep string - RecentTurns []WorkingMemoryTurn - OpenQuestions []string - RecentFiles []string - UpdatedAt time.Time -} - -// WorkingMemoryRepository 定义工作记忆的存取接口。 -type WorkingMemoryRepository interface { - Get(ctx context.Context) (*WorkingMemoryState, error) - Save(ctx context.Context, state *WorkingMemoryState) error - Clear(ctx context.Context) error -} - -// WorkingMemoryService 负责构建和维护短期工作记忆。 -type WorkingMemoryService interface { - BuildContext(ctx context.Context, messages []Message) (string, error) - Refresh(ctx context.Context, messages []Message) error - Clear(ctx context.Context) error - Get(ctx context.Context) (*WorkingMemoryState, error) -} diff --git a/internal/server/infra/provider/chat_provider.go b/internal/server/infra/provider/chat_provider.go deleted file mode 100644 index 93f3e424..00000000 --- a/internal/server/infra/provider/chat_provider.go +++ /dev/null @@ -1,345 +0,0 @@ -package provider - -import ( - "bufio" - "bytes" - "context" - "crypto/tls" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "strings" - "time" - - "go-llm-demo/configs" - "go-llm-demo/internal/server/domain" -) - -const ( - requestTimeout = 90 * time.Second - maxRetries = 2 -) - -var ( - ErrInvalidAPIKey = errors.New("无效的 API Key") - ErrAPIKeyValidationSoft = errors.New("API Key 校验结果不确定") -) - -type ChatCompletionProvider struct { - APIKey string - BaseURL string - Model string -} - -// GetModelName 返回提供方当前模型,缺省时使用默认模型。 -func (p *ChatCompletionProvider) GetModelName() string { - if p.Model != "" { - return p.Model - } - return DefaultModel() -} - -type StreamResponse struct { - Choices []struct { - Delta struct { - Content string `json:"content"` - } `json:"delta"` - } `json:"choices"` -} - -// NewChatProvider 为指定模型创建已配置的聊天提供方。 -func NewChatProvider(model string) (domain.ChatProvider, error) { - if configs.GlobalAppConfig == nil { - return nil, fmt.Errorf("config.yaml is not loaded") - } - - providerName := CurrentProvider() - if model == "" { - model = DefaultModel() - } - if model == "" { - return nil, fmt.Errorf("ai.model is required for provider %s", providerName) - } - baseURL, err := ResolveChatEndpoint(configs.GlobalAppConfig, model) - if err != nil { - return nil, err - } - apiKey := configs.RuntimeAPIKey() - if apiKey == "" { - return nil, fmt.Errorf("missing %s environment variable", configs.RuntimeAPIKeyEnvVarName()) - } - - return &ChatCompletionProvider{ - APIKey: apiKey, - BaseURL: baseURL, - Model: model, - }, nil -} - -// ValidateChatAPIKey 按当前提供方配置校验运行时 API Key。 -func ValidateChatAPIKey(ctx context.Context, cfg *configs.AppConfiguration) error { - if cfg == nil { - return fmt.Errorf("config cannot be nil") - } - - providerName := providerNameFromConfig(cfg) - if providerName == "" { - return fmt.Errorf("unsupported ai.provider: %s", cfg.AI.Provider) - } - if strings.TrimSpace(cfg.AI.Model) == "" { - cfg.AI.Model = DefaultModelForProvider(providerName) - } - if strings.TrimSpace(cfg.AI.Model) == "" { - return fmt.Errorf("ai.model is required for provider %s", providerName) - } - - return validateChatAPIKey(ctx, cfg) -} - -// Chat 向聊天补全接口发送流式请求并返回文本分片。 -func (p *ChatCompletionProvider) Chat(ctx context.Context, messages []domain.Message) (<-chan string, error) { - baseURL := strings.TrimSpace(p.BaseURL) - if baseURL == "" { - var err error - baseURL, err = ResolveChatEndpoint(configs.GlobalAppConfig, p.GetModelName()) - if err != nil { - return nil, err - } - } - - modelName := p.GetModelName() - body := map[string]any{ - "model": modelName, - "messages": messages, - "stream": true, - } - jsonData, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("chat request marshal failed: %w", err) - } - - resp, err := doRequestWithRetry(ctx, func(reqCtx context.Context) (*http.Response, error) { - req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, baseURL, bytes.NewBuffer(jsonData)) - if err != nil { - return nil, fmt.Errorf("chat request create failed: %w", err) - } - req.Header.Set("Authorization", "Bearer "+p.APIKey) - req.Header.Set("Content-Type", "application/json") - resp, err := httpClient().Do(req) - if err != nil { - return nil, err - } - if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= http.StatusInternalServerError { - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - return nil, fmt.Errorf("retryable chat status: %s %s", resp.Status, strings.TrimSpace(string(body))) - } - return resp, nil - }) - if err != nil { - return nil, fmt.Errorf("chat request failed: %w", err) - } - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("chat request failed: %s %s", resp.Status, strings.TrimSpace(string(body))) - } - - out := make(chan string) - - go func() { - defer close(out) - defer resp.Body.Close() - - reader := bufio.NewReader(resp.Body) - for { - line, err := reader.ReadString('\n') - if err != nil { - emitStreamErrorMessage(ctx, out, streamReadError(err)) - return - } - line = strings.TrimSpace(line) - if line == "" { - continue - } - data := strings.TrimPrefix(line, "data: ") - if data == "" { - continue - } - if data == "[DONE]" { - break - } - - text, err := decodeStreamContent(data) - if err != nil { - emitStreamErrorMessage(ctx, out, err) - return - } - if text == "" { - continue - } - select { - case <-ctx.Done(): - return - case out <- text: - } - } - }() - - return out, nil -} - -func emitStreamErrorMessage(ctx context.Context, out chan<- string, err error) { - if err == nil { - return - } - msg := fmt.Sprintf("\n[STREAM_ERROR] %v", err) - select { - case <-ctx.Done(): - case out <- msg: - } -} - -func streamReadError(err error) error { - if err == nil { - return nil - } - if errors.Is(err, io.EOF) { - return fmt.Errorf("chat stream ended unexpectedly before completion") - } - return fmt.Errorf("chat stream read failed: %w", err) -} - -func decodeStreamContent(data string) (string, error) { - var res StreamResponse - if err := json.Unmarshal([]byte(data), &res); err != nil { - return "", fmt.Errorf("chat stream decode failed: %w", err) - } - if len(res.Choices) == 0 { - return "", nil - } - content := res.Choices[0].Delta.Content - content = stripThinkingTags(content) - return content, nil -} - -func stripThinkingTags(content string) string { - thinkStart := "" - thinkEnd := "" - for { - start := strings.Index(content, thinkStart) - if start == -1 { - break - } - end := strings.Index(content, thinkEnd) - if end == -1 { - break - } - end += len(thinkEnd) - content = content[:start] + content[end:] - } - return content -} - -func httpClient() *http.Client { - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - return &http.Client{Timeout: requestTimeout, Transport: tr} -} - -func doRequestWithRetry(ctx context.Context, do func(context.Context) (*http.Response, error)) (*http.Response, error) { - var lastErr error - for attempt := 0; attempt <= maxRetries; attempt++ { - resp, err := do(ctx) - if err == nil { - return resp, nil - } - lastErr = err - if ctx.Err() != nil || !isRetryableError(err) || attempt == maxRetries { - break - } - time.Sleep(time.Duration(attempt+1) * 300 * time.Millisecond) - } - return nil, lastErr -} - -func isRetryableError(err error) bool { - if err == nil { - return false - } - if errors.Is(err, context.DeadlineExceeded) { - return true - } - var netErr net.Error - if errors.As(err, &netErr) { - return true - } - if strings.Contains(err.Error(), "retryable chat status:") { - return true - } - return false -} - -func validateChatAPIKey(ctx context.Context, cfg *configs.AppConfiguration) error { - if cfg == nil { - return fmt.Errorf("配置不能为空") - } - - modelName := strings.TrimSpace(cfg.AI.Model) - if modelName == "" { - modelName = DefaultModelForProvider(cfg.AI.Provider) - } - baseURL, err := ResolveChatEndpoint(cfg, modelName) - if err != nil { - return err - } - - body := map[string]any{ - "model": modelName, - "messages": []domain.Message{{Role: "user", Content: "ping"}}, - "stream": false, - } - jsonData, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("api key validation request marshal failed: %w", err) - } - - requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(requestCtx, http.MethodPost, baseURL, bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("api key validation request create failed: %w", err) - } - req.Header.Set("Authorization", "Bearer "+cfg.RuntimeAPIKey()) - req.Header.Set("Content-Type", "application/json") - - resp, err := httpClient().Do(req) - if err != nil { - if requestCtx.Err() != nil || isRetryableError(err) { - return fmt.Errorf("%w: %v", ErrAPIKeyValidationSoft, err) - } - return fmt.Errorf("api key validation failed: %w", err) - } - defer resp.Body.Close() - - bodyBytes, readErr := io.ReadAll(resp.Body) - if readErr != nil { - return fmt.Errorf("%w: %v", ErrAPIKeyValidationSoft, readErr) - } - - switch { - case resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices: - return nil - case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden: - return fmt.Errorf("%w: %s", ErrInvalidAPIKey, strings.TrimSpace(string(bodyBytes))) - case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= http.StatusInternalServerError: - return fmt.Errorf("%w: %s %s", ErrAPIKeyValidationSoft, resp.Status, strings.TrimSpace(string(bodyBytes))) - default: - return fmt.Errorf("api key validation failed: %s %s", resp.Status, strings.TrimSpace(string(bodyBytes))) - } -} diff --git a/internal/server/infra/provider/registry.go b/internal/server/infra/provider/registry.go deleted file mode 100644 index 5c20741c..00000000 --- a/internal/server/infra/provider/registry.go +++ /dev/null @@ -1,118 +0,0 @@ -package provider - -import ( - "fmt" - "strings" - - "go-llm-demo/configs" -) - -const modelScopeProviderName = "modelscope" - -type ProviderSpec struct { - Name string - BaseURL string - DefaultModel string -} - -var providerSpecs = []ProviderSpec{ - {Name: modelScopeProviderName, BaseURL: "https://api-inference.modelscope.cn/v1/chat/completions", DefaultModel: "Qwen/Qwen3-Coder-480B-A35B-Instruct"}, - {Name: "deepseek", BaseURL: "https://api.deepseek.com/chat/completions", DefaultModel: "deepseek-chat"}, - {Name: "openll", BaseURL: "https://www.openll.top/v1/chat/completions", DefaultModel: "gpt-5.4"}, - {Name: "siliconflow", BaseURL: "https://api.siliconflow.cn/v1/chat/completions", DefaultModel: "zai-org/GLM-4.6"}, - {Name: "豆包大模型", BaseURL: "https://ark.cn-beijing.volces.com/api/v3/chat/completions", DefaultModel: "doubao-pro-v1"}, - {Name: "openai", BaseURL: "https://api.openai.com/v1/chat/completions", DefaultModel: "gpt-5.4"}, -} - -var providerIndex = func() map[string]ProviderSpec { - index := make(map[string]ProviderSpec, len(providerSpecs)) - for _, spec := range providerSpecs { - index[strings.ToLower(spec.Name)] = spec - } - return index -}() - -func NormalizeProviderName(name string) (string, bool) { - normalized := strings.TrimSpace(name) - if normalized == "" { - return "", false - } - spec, ok := providerIndex[strings.ToLower(normalized)] - if !ok { - return "", false - } - return spec.Name, true -} - -func SupportedProviders() []string { - providers := make([]string, 0, len(providerSpecs)) - for _, spec := range providerSpecs { - providers = append(providers, spec.Name) - } - return providers -} - -func DefaultModel() string { - return DefaultModelForConfig(configs.GlobalAppConfig) -} - -func DefaultModelForConfig(cfg *configs.AppConfiguration) string { - providerName := providerNameFromConfig(cfg) - if cfg != nil { - if model := strings.TrimSpace(cfg.AI.Model); model != "" { - return model - } - } - if spec, ok := providerSpecByName(providerName); ok { - return strings.TrimSpace(spec.DefaultModel) - } - return "" -} - -func DefaultModelForProvider(name string) string { - spec, ok := providerSpecByName(name) - if !ok { - return "" - } - return strings.TrimSpace(spec.DefaultModel) -} - -func CurrentProvider() string { - return providerNameFromConfig(configs.GlobalAppConfig) -} - -func ResolveChatEndpoint(cfg *configs.AppConfiguration, model string) (string, error) { - _ = model - providerName := providerNameFromConfig(cfg) - spec, ok := providerSpecByName(providerName) - if !ok { - return "", fmt.Errorf("unsupported ai.provider: %s", providerName) - } - if strings.TrimSpace(spec.BaseURL) == "" { - return "", fmt.Errorf("provider %q does not have a configured chat URL", providerName) - } - return spec.BaseURL, nil -} - -func providerNameFromConfig(cfg *configs.AppConfiguration) string { - if cfg != nil { - trimmed := strings.TrimSpace(cfg.AI.Provider) - if trimmed == "" { - return modelScopeProviderName - } - if normalized, ok := NormalizeProviderName(cfg.AI.Provider); ok { - return normalized - } - return trimmed - } - return modelScopeProviderName -} - -func providerSpecByName(name string) (ProviderSpec, bool) { - normalized, ok := NormalizeProviderName(name) - if !ok { - return ProviderSpec{}, false - } - spec, ok := providerIndex[strings.ToLower(normalized)] - return spec, ok -} diff --git a/internal/server/infra/provider/registry_test.go b/internal/server/infra/provider/registry_test.go deleted file mode 100644 index 910e2125..00000000 --- a/internal/server/infra/provider/registry_test.go +++ /dev/null @@ -1,218 +0,0 @@ -package provider - -import ( - "context" - "errors" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "go-llm-demo/configs" - "go-llm-demo/internal/server/domain" -) - -func TestNormalizeProviderName(t *testing.T) { - tests := map[string]string{ - "modelscope": "modelscope", - "DEEPSEEK": "deepseek", - "OPENLL": "openll", - "siliconflow": "siliconflow", - "豆包大模型": "豆包大模型", - "openai": "openai", - } - - for input, want := range tests { - got, ok := NormalizeProviderName(input) - if !ok { - t.Fatalf("expected provider %q to normalize", input) - } - if got != want { - t.Fatalf("expected normalized provider %q, got %q", want, got) - } - } -} - -func TestDefaultModelForProvider(t *testing.T) { - tests := map[string]string{ - "modelscope": "Qwen/Qwen3-Coder-480B-A35B-Instruct", - "deepseek": "deepseek-chat", - "openll": "gpt-5.4", - "siliconflow": "zai-org/GLM-4.6", - "豆包大模型": "doubao-pro-v1", - "openai": "gpt-5.4", - } - - for providerName, want := range tests { - if got := DefaultModelForProvider(providerName); got != want { - t.Fatalf("expected default model %q for provider %q, got %q", want, providerName, got) - } - } -} - -func TestResolveChatEndpoint(t *testing.T) { - cfg := configs.DefaultAppConfig() - - url, err := ResolveChatEndpoint(cfg, cfg.AI.Model) - if err != nil { - t.Fatalf("expected modelscope endpoint, got error: %v", err) - } - if url == "" { - t.Fatal("expected modelscope endpoint url") - } - - cfg.AI.Provider = "openai" - cfg.AI.Model = "gpt-5.4" - url, err = ResolveChatEndpoint(cfg, cfg.AI.Model) - if err != nil { - t.Fatalf("expected openai endpoint, got error: %v", err) - } - if want := "https://api.openai.com/v1/chat/completions"; url != want { - t.Fatalf("expected endpoint %q, got %q", want, url) - } - - cfg.AI.Provider = "openll" - cfg.AI.Model = "gpt-5.4" - url, err = ResolveChatEndpoint(cfg, cfg.AI.Model) - if err != nil { - t.Fatalf("expected openll endpoint, got error: %v", err) - } - if want := "https://www.openll.top/v1/chat/completions"; url != want { - t.Fatalf("expected endpoint %q, got %q", want, url) - } -} - -func TestChatCompletionProviderChatReturnsErrorOnBadStatus(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"error":"model not found"}`)) - })) - defer server.Close() - - p := &ChatCompletionProvider{ - APIKey: "test-key", - BaseURL: server.URL, - Model: "missing-model", - } - - stream, err := p.Chat(context.Background(), []domain.Message{{Role: "user", Content: "hi"}}) - if err == nil { - if stream != nil { - for range stream { - } - } - t.Fatal("expected chat to fail for bad status") - } - if !strings.Contains(err.Error(), "model not found") { - t.Fatalf("expected model error in message, got: %v", err) - } -} - -func TestChatCompletionProviderChatStreamsFallbackMessageOnMalformedChunk(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("data: {invalid json}\n")) - })) - defer server.Close() - - p := &ChatCompletionProvider{ - APIKey: "test-key", - BaseURL: server.URL, - Model: "test-model", - } - - stream, err := p.Chat(context.Background(), []domain.Message{{Role: "user", Content: "hi"}}) - if err != nil { - t.Fatalf("expected stream request to succeed, got error: %v", err) - } - - var output strings.Builder - for chunk := range stream { - output.WriteString(chunk) - } - - got := output.String() - if !strings.Contains(got, "[STREAM_ERROR]") { - t.Fatalf("expected fallback stream error marker, got: %q", got) - } - if !strings.Contains(got, "chat stream decode failed") { - t.Fatalf("expected decode failure details, got: %q", got) - } -} - -func TestChatCompletionProviderChatStreamsFallbackMessageOnUnexpectedEOF(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - p := &ChatCompletionProvider{ - APIKey: "test-key", - BaseURL: server.URL, - Model: "test-model", - } - - stream, err := p.Chat(context.Background(), []domain.Message{{Role: "user", Content: "hi"}}) - if err != nil { - t.Fatalf("expected stream request to succeed, got error: %v", err) - } - - var output strings.Builder - for chunk := range stream { - output.WriteString(chunk) - } - - got := output.String() - if !strings.Contains(got, "[STREAM_ERROR]") { - t.Fatalf("expected fallback stream error marker, got: %q", got) - } - if !strings.Contains(got, "unexpectedly before completion") { - t.Fatalf("expected EOF fallback details, got: %q", got) - } -} - -func TestEmitStreamErrorMessageIgnoresNilError(t *testing.T) { - out := make(chan string, 1) - emitStreamErrorMessage(context.Background(), out, nil) - - select { - case got := <-out: - t.Fatalf("expected no message for nil error, got %q", got) - default: - } -} - -func TestEmitStreamErrorMessageReturnsWhenContextCanceled(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - out := make(chan string) - done := make(chan struct{}) - - go func() { - emitStreamErrorMessage(ctx, out, errors.New("stream failed")) - close(done) - }() - - select { - case <-done: - case <-time.After(200 * time.Millisecond): - t.Fatal("emitStreamErrorMessage should return quickly when context is canceled") - } -} - -func TestStreamReadErrorForNilAndGenericError(t *testing.T) { - if got := streamReadError(nil); got != nil { - t.Fatalf("expected nil for nil input, got %v", got) - } - - got := streamReadError(io.ErrUnexpectedEOF) - if got == nil { - t.Fatal("expected wrapped stream read error") - } - if !strings.Contains(got.Error(), "chat stream read failed") { - t.Fatalf("expected generic stream read failure, got %v", got) - } -} diff --git a/internal/server/infra/repository/memory_repository.go b/internal/server/infra/repository/memory_repository.go deleted file mode 100644 index 4513b275..00000000 --- a/internal/server/infra/repository/memory_repository.go +++ /dev/null @@ -1,175 +0,0 @@ -package repository - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "go-llm-demo/internal/server/domain" -) - -type FileMemoryStore struct { - path string - maxItems int - mu sync.Mutex -} - -// NewFileMemoryStore 创建一个基于文件的长期记忆存储。 -func NewFileMemoryStore(path string, maxItems int) *FileMemoryStore { - return &FileMemoryStore{path: path, maxItems: maxItems} -} - -// List 返回全部长期记忆项。 -func (s *FileMemoryStore) List(ctx context.Context) ([]domain.MemoryItem, error) { - _ = ctx - s.mu.Lock() - defer s.mu.Unlock() - - items, err := s.readAllLocked() - if err != nil { - return nil, err - } - cloned := make([]domain.MemoryItem, len(items)) - copy(cloned, items) - return cloned, nil -} - -// Add 新增或更新一条长期记忆项。 -func (s *FileMemoryStore) Add(ctx context.Context, item domain.MemoryItem) error { - _ = ctx - s.mu.Lock() - defer s.mu.Unlock() - - item = item.Normalized() - if !domain.IsPersistentType(item.Type) { - return nil - } - - items, err := s.readAllLocked() - if err != nil { - return err - } - items = upsertPersistentItem(items, item) - if s.maxItems > 0 && len(items) > s.maxItems { - items = items[len(items)-s.maxItems:] - } - return s.writeAllLocked(items) -} - -// Clear 清空存储中的全部长期记忆项。 -func (s *FileMemoryStore) Clear(ctx context.Context) error { - _ = ctx - s.mu.Lock() - defer s.mu.Unlock() - return s.writeAllLocked(nil) -} - -func (s *FileMemoryStore) readAllLocked() ([]domain.MemoryItem, error) { - data, err := os.ReadFile(s.path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil, nil - } - return nil, err - } - if len(data) == 0 { - return nil, nil - } - - var items []domain.MemoryItem - if err := json.Unmarshal(data, &items); err != nil { - backupPath := fmt.Sprintf("%s.corrupt.%d", s.path, time.Now().UnixNano()) - if renameErr := os.Rename(s.path, backupPath); renameErr != nil { - return nil, fmt.Errorf("persistent memory decode failed: %w", err) - } - return nil, fmt.Errorf("persistent memory decode failed, corrupt file moved to %s: %w", backupPath, err) - } - - filtered := make([]domain.MemoryItem, 0, len(items)) - for _, item := range items { - normalized := item.Normalized() - if domain.IsPersistentType(normalized.Type) { - filtered = append(filtered, normalized) - } - } - return filtered, nil -} - -func (s *FileMemoryStore) writeAllLocked(items []domain.MemoryItem) error { - if strings.TrimSpace(s.path) == "" { - return fmt.Errorf("长期记忆路径为空") - } - if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { - return err - } - data, err := json.MarshalIndent(items, "", " ") - if err != nil { - return err - } - tmp, err := os.CreateTemp(filepath.Dir(s.path), "memory-rules-*.tmp") - if err != nil { - return err - } - tmpPath := tmp.Name() - defer os.Remove(tmpPath) - if _, err := tmp.Write(data); err != nil { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - if err := os.Rename(tmpPath, s.path); err == nil { - return nil - } - if removeErr := os.Remove(s.path); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { - return err - } - return os.Rename(tmpPath, s.path) -} - -func upsertPersistentItem(items []domain.MemoryItem, item domain.MemoryItem) []domain.MemoryItem { - key := persistentKey(item) - for idx, existing := range items { - if persistentKey(existing.Normalized()) != key { - continue - } - updated := existing.Normalized() - updated.Type = item.Type - updated.Summary = item.Summary - updated.Details = item.Details - updated.Scope = item.Scope - updated.Tags = item.Tags - updated.Source = item.Source - updated.UserInput = item.UserInput - updated.AssistantReply = item.AssistantReply - updated.Text = item.Text - if item.Confidence > updated.Confidence { - updated.Confidence = item.Confidence - } - if updated.CreatedAt.IsZero() { - updated.CreatedAt = item.CreatedAt - } - updated.UpdatedAt = item.UpdatedAt - items[idx] = updated - return items - } - return append(items, item) -} - -func persistentKey(item domain.MemoryItem) string { - normalized := item.Normalized() - return normalized.Type + "::" + normalized.Scope + "::" + compactKey(normalized.Summary) -} - -func compactKey(text string) string { - text = strings.ToLower(strings.TrimSpace(text)) - text = strings.NewReplacer(" ", "", "\n", "", "\t", "", ",", "", ".", "", ":", "", ";", "", "-", "", "_", "", "/", "", "\\", "").Replace(text) - return text -} diff --git a/internal/server/infra/repository/role_repository.go b/internal/server/infra/repository/role_repository.go deleted file mode 100644 index ab8669a6..00000000 --- a/internal/server/infra/repository/role_repository.go +++ /dev/null @@ -1,162 +0,0 @@ -package repository - -import ( - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "sync" - "time" - - "go-llm-demo/internal/server/domain" -) - -type FileRoleStore struct { - path string - mu sync.Mutex -} - -// NewFileRoleStore 创建一个基于文件的角色存储。 -func NewFileRoleStore(path string) *FileRoleStore { - return &FileRoleStore{path: path} -} - -// GetByID 返回指定 ID 对应的角色。 -func (s *FileRoleStore) GetByID(ctx context.Context, id string) (*domain.Role, error) { - s.mu.Lock() - defer s.mu.Unlock() - - roles, err := s.readAllLocked() - if err != nil { - return nil, err - } - - for _, role := range roles { - if role.ID == id { - return &role, nil - } - } - - return nil, errors.New("角色不存在") -} - -// GetByName 返回指定名称对应的角色。 -func (s *FileRoleStore) GetByName(ctx context.Context, name string) (*domain.Role, error) { - s.mu.Lock() - defer s.mu.Unlock() - - roles, err := s.readAllLocked() - if err != nil { - return nil, err - } - - for _, role := range roles { - if role.Name == name { - return &role, nil - } - } - - return nil, errors.New("角色不存在") -} - -// List 返回所有已存储的角色。 -func (s *FileRoleStore) List(ctx context.Context) ([]domain.Role, error) { - s.mu.Lock() - defer s.mu.Unlock() - - roles, err := s.readAllLocked() - if err != nil { - return nil, err - } - - cloned := make([]domain.Role, len(roles)) - copy(cloned, roles) - return cloned, nil -} - -// Save 在存储中创建或更新角色。 -func (s *FileRoleStore) Save(ctx context.Context, role *domain.Role) error { - s.mu.Lock() - defer s.mu.Unlock() - - roles, err := s.readAllLocked() - if err != nil { - return err - } - - found := false - for i, r := range roles { - if r.ID == role.ID { - role.UpdatedAt = time.Now().UTC() - roles[i] = *role - found = true - break - } - } - - if !found { - role.CreatedAt = time.Now().UTC() - role.UpdatedAt = time.Now().UTC() - roles = append(roles, *role) - } - - return s.writeAllLocked(roles) -} - -// Delete 删除指定 ID 对应的角色。 -func (s *FileRoleStore) Delete(ctx context.Context, id string) error { - s.mu.Lock() - defer s.mu.Unlock() - - roles, err := s.readAllLocked() - if err != nil { - return err - } - - newRoles := make([]domain.Role, 0, len(roles)) - for _, role := range roles { - if role.ID != id { - newRoles = append(newRoles, role) - } - } - - if len(newRoles) == len(roles) { - return errors.New("角色不存在") - } - - return s.writeAllLocked(newRoles) -} - -func (s *FileRoleStore) readAllLocked() ([]domain.Role, error) { - data, err := os.ReadFile(s.path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return []domain.Role{}, nil - } - return nil, err - } - - if len(data) == 0 { - return []domain.Role{}, nil - } - - var roles []domain.Role - if err := json.Unmarshal(data, &roles); err != nil { - return nil, err - } - return roles, nil -} - -func (s *FileRoleStore) writeAllLocked(roles []domain.Role) error { - if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { - return err - } - - data, err := json.MarshalIndent(roles, "", " ") - if err != nil { - return err - } - - return os.WriteFile(s.path, data, 0o644) -} diff --git a/internal/server/infra/repository/security_config_repository.go b/internal/server/infra/repository/security_config_repository.go deleted file mode 100644 index 49051474..00000000 --- a/internal/server/infra/repository/security_config_repository.go +++ /dev/null @@ -1,58 +0,0 @@ -package repository - -import ( - "fmt" - "os" - "path/filepath" - - "go-llm-demo/internal/server/domain" - - "gopkg.in/yaml.v3" -) - -type SecurityConfigRepository interface { - LoadAll(configDir string) (*domain.Config, *domain.Config, *domain.Config, error) -} - -type securityConfigRepositoryImpl struct{} - -func NewSecurityConfigRepository() SecurityConfigRepository { - return &securityConfigRepositoryImpl{} -} - -func (r *securityConfigRepositoryImpl) LoadAll(configDir string) (*domain.Config, *domain.Config, *domain.Config, error) { - blackList, err := r.loadConfig(filepath.Join(configDir, "blacklist.yaml")) - if err != nil { - return nil, nil, nil, fmt.Errorf("加载黑名单失败:%w", err) - } - - whiteList, err := r.loadConfig(filepath.Join(configDir, "whitelist.yaml")) - if err != nil { - return nil, nil, nil, fmt.Errorf("加载白名单失败:%w", err) - } - - yellowList, err := r.loadConfig(filepath.Join(configDir, "yellowlist.yaml")) - if err != nil { - return nil, nil, nil, fmt.Errorf("加载黄名单失败:%w", err) - } - - return blackList, whiteList, yellowList, nil -} - -func (r *securityConfigRepositoryImpl) loadConfig(filePath string) (*domain.Config, error) { - data, err := os.ReadFile(filePath) - if err != nil { - if os.IsNotExist(err) { - return &domain.Config{Rules: []domain.Rule{}}, nil - } - return nil, fmt.Errorf("读取配置文件 [%s] 失败:%w", filePath, err) - } - - var config domain.Config - err = yaml.Unmarshal(data, &config) - if err != nil { - return nil, fmt.Errorf("解析 YAML 配置 [%s] 失败:%w", filePath, err) - } - - return &config, nil -} diff --git a/internal/server/infra/repository/session_memory_repository.go b/internal/server/infra/repository/session_memory_repository.go deleted file mode 100644 index 3403b18d..00000000 --- a/internal/server/infra/repository/session_memory_repository.go +++ /dev/null @@ -1,77 +0,0 @@ -package repository - -import ( - "context" - "strings" - "sync" - - "go-llm-demo/internal/server/domain" -) - -type SessionMemoryStore struct { - maxItems int - mu sync.Mutex - items []domain.MemoryItem -} - -// NewSessionMemoryStore 创建一个用于会话记忆的内存存储。 -func NewSessionMemoryStore(maxItems int) *SessionMemoryStore { - return &SessionMemoryStore{maxItems: maxItems} -} - -// List 返回当前会话中的记忆项。 -func (s *SessionMemoryStore) List(ctx context.Context) ([]domain.MemoryItem, error) { - _ = ctx - s.mu.Lock() - defer s.mu.Unlock() - - cloned := make([]domain.MemoryItem, len(s.items)) - copy(cloned, s.items) - return cloned, nil -} - -// Add 新增或更新一条会话记忆项。 -func (s *SessionMemoryStore) Add(ctx context.Context, item domain.MemoryItem) error { - _ = ctx - s.mu.Lock() - defer s.mu.Unlock() - - normalized := item.Normalized() - key := sessionKey(normalized) - for idx, existing := range s.items { - if sessionKey(existing.Normalized()) != key { - continue - } - updated := existing.Normalized() - updated.Summary = normalized.Summary - updated.Details = normalized.Details - updated.Tags = normalized.Tags - updated.Text = normalized.Text - updated.Source = normalized.Source - updated.Scope = normalized.Scope - updated.Confidence = normalized.Confidence - updated.UpdatedAt = normalized.UpdatedAt - s.items[idx] = updated - return nil - } - - s.items = append(s.items, normalized) - if s.maxItems > 0 && len(s.items) > s.maxItems { - s.items = s.items[len(s.items)-s.maxItems:] - } - return nil -} - -// Clear 清空全部会话记忆项。 -func (s *SessionMemoryStore) Clear(ctx context.Context) error { - _ = ctx - s.mu.Lock() - defer s.mu.Unlock() - s.items = nil - return nil -} - -func sessionKey(item domain.MemoryItem) string { - normalized := item.Normalized() - return normalized.Type + "::" + normalized.Scope + "::" + strings.ToLower(strings.TrimSpace(normalized.Summary)) -} diff --git a/internal/server/infra/repository/todo_repository.go b/internal/server/infra/repository/todo_repository.go deleted file mode 100644 index 01a64548..00000000 --- a/internal/server/infra/repository/todo_repository.go +++ /dev/null @@ -1,75 +0,0 @@ -package repository - -import ( - "context" - "fmt" - "go-llm-demo/internal/server/domain" - "sync" -) - -type InMemoryTodoRepository struct { - todos map[string]domain.Todo - nextID int - mu sync.RWMutex -} - -func NewInMemoryTodoRepository() *InMemoryTodoRepository { - return &InMemoryTodoRepository{ - todos: make(map[string]domain.Todo), - nextID: 1, - } -} - -func (r *InMemoryTodoRepository) Add(ctx context.Context, todo domain.Todo) (*domain.Todo, error) { - r.mu.Lock() - defer r.mu.Unlock() - - todo.ID = fmt.Sprintf("todo-%d", r.nextID) - r.nextID++ - r.todos[todo.ID] = todo - return &todo, nil -} - -func (r *InMemoryTodoRepository) UpdateStatus(ctx context.Context, id string, status domain.TodoStatus) error { - r.mu.Lock() - defer r.mu.Unlock() - - todo, ok := r.todos[id] - if !ok { - return fmt.Errorf("任务 %s 不存在", id) - } - todo.Status = status - r.todos[id] = todo - return nil -} - -func (r *InMemoryTodoRepository) List(ctx context.Context) ([]domain.Todo, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - list := make([]domain.Todo, 0, len(r.todos)) - for _, todo := range r.todos { - list = append(list, todo) - } - return list, nil -} - -func (r *InMemoryTodoRepository) Clear(ctx context.Context) error { - r.mu.Lock() - defer r.mu.Unlock() - - r.todos = make(map[string]domain.Todo) - r.nextID = 1 - return nil -} - -func (r *InMemoryTodoRepository) Remove(ctx context.Context, id string) error { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.todos[id]; !ok { - return fmt.Errorf("任务 %s 不存在", id) - } - delete(r.todos, id) - return nil -} diff --git a/internal/server/infra/repository/todo_repository_test.go b/internal/server/infra/repository/todo_repository_test.go deleted file mode 100644 index 9868da0c..00000000 --- a/internal/server/infra/repository/todo_repository_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package repository - -import ( - "context" - "testing" - - "go-llm-demo/internal/server/domain" -) - -func TestInMemoryTodoRepository_AddGeneratesIncrementingIDs(t *testing.T) { - repo := NewInMemoryTodoRepository() - - t1, err := repo.Add(context.Background(), domain.Todo{Content: "task 1", Status: domain.TodoPending, Priority: domain.TodoPriorityHigh}) - if err != nil { - t.Fatalf("failed to add todo: %v", err) - } - t2, err := repo.Add(context.Background(), domain.Todo{Content: "task 2", Status: domain.TodoPending, Priority: domain.TodoPriorityLow}) - if err != nil { - t.Fatalf("failed to add todo: %v", err) - } - - if t1.ID != "todo-1" { - t.Fatalf("expected todo-1, got %q", t1.ID) - } - if t2.ID != "todo-2" { - t.Fatalf("expected todo-2, got %q", t2.ID) - } -} - -func TestInMemoryTodoRepository_ClearResetsIDCounter(t *testing.T) { - repo := NewInMemoryTodoRepository() - _, _ = repo.Add(context.Background(), domain.Todo{Content: "task 1", Status: domain.TodoPending, Priority: domain.TodoPriorityHigh}) - - if err := repo.Clear(context.Background()); err != nil { - t.Fatalf("failed to clear todos: %v", err) - } - - t1, err := repo.Add(context.Background(), domain.Todo{Content: "task 2", Status: domain.TodoPending, Priority: domain.TodoPriorityLow}) - if err != nil { - t.Fatalf("failed to add todo: %v", err) - } - if t1.ID != "todo-1" { - t.Fatalf("expected todo-1 after clear, got %q", t1.ID) - } -} - -func TestInMemoryTodoRepository_UpdateStatusUnknownReturnsError(t *testing.T) { - repo := NewInMemoryTodoRepository() - - err := repo.UpdateStatus(context.Background(), "todo-404", domain.TodoCompleted) - if err == nil { - t.Fatal("expected error for unknown todo id") - } -} - -func TestInMemoryTodoRepository_UpdateStatusUpdatesExistingItem(t *testing.T) { - repo := NewInMemoryTodoRepository() - - added, err := repo.Add(context.Background(), domain.Todo{Content: "task 1", Status: domain.TodoPending, Priority: domain.TodoPriorityHigh}) - if err != nil { - t.Fatalf("failed to add todo: %v", err) - } - - if err := repo.UpdateStatus(context.Background(), added.ID, domain.TodoCompleted); err != nil { - t.Fatalf("failed to update status: %v", err) - } - - todos, err := repo.List(context.Background()) - if err != nil { - t.Fatalf("failed to list todos: %v", err) - } - if len(todos) != 1 { - t.Fatalf("expected 1 todo, got %d", len(todos)) - } - if todos[0].Status != domain.TodoCompleted { - t.Fatalf("expected status %q, got %q", domain.TodoCompleted, todos[0].Status) - } -} - -func TestInMemoryTodoRepository_RemoveUnknownReturnsError(t *testing.T) { - repo := NewInMemoryTodoRepository() - - err := repo.Remove(context.Background(), "todo-404") - if err == nil { - t.Fatal("expected error for unknown todo id") - } -} - -func TestInMemoryTodoRepository_RemoveDeletesExistingItem(t *testing.T) { - repo := NewInMemoryTodoRepository() - - added, err := repo.Add(context.Background(), domain.Todo{Content: "task 1", Status: domain.TodoPending, Priority: domain.TodoPriorityHigh}) - if err != nil { - t.Fatalf("failed to add todo: %v", err) - } - - if err := repo.Remove(context.Background(), added.ID); err != nil { - t.Fatalf("failed to remove todo: %v", err) - } - - todos, err := repo.List(context.Background()) - if err != nil { - t.Fatalf("failed to list todos: %v", err) - } - if len(todos) != 0 { - t.Fatalf("expected 0 todos, got %d", len(todos)) - } -} - -func TestInMemoryTodoRepository_ListReturnsAllItems(t *testing.T) { - repo := NewInMemoryTodoRepository() - _, _ = repo.Add(context.Background(), domain.Todo{Content: "task 1", Status: domain.TodoPending, Priority: domain.TodoPriorityHigh}) - _, _ = repo.Add(context.Background(), domain.Todo{Content: "task 2", Status: domain.TodoInProgress, Priority: domain.TodoPriorityMedium}) - - todos, err := repo.List(context.Background()) - if err != nil { - t.Fatalf("failed to list todos: %v", err) - } - if len(todos) != 2 { - t.Fatalf("expected 2 todos, got %d", len(todos)) - } - - contents := map[string]bool{} - for _, todo := range todos { - contents[todo.Content] = true - } - if !contents["task 1"] || !contents["task 2"] { - t.Fatalf("unexpected todos: %#v", todos) - } -} diff --git a/internal/server/infra/repository/working_memory_repository.go b/internal/server/infra/repository/working_memory_repository.go deleted file mode 100644 index 53cff577..00000000 --- a/internal/server/infra/repository/working_memory_repository.go +++ /dev/null @@ -1,151 +0,0 @@ -package repository - -import ( - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "strings" - "sync" - - "go-llm-demo/internal/server/domain" -) - -// WorkingMemoryStore 在当前进程内保存会话级工作记忆。 -// 第一阶段先使用内存实现,后续如需跨进程恢复再替换为持久化版本。 -type WorkingMemoryStore struct { - mu sync.RWMutex - state *domain.WorkingMemoryState - path string -} - -// NewWorkingMemoryStore 创建一个进程内工作记忆存储。 -func NewWorkingMemoryStore(path ...string) *WorkingMemoryStore { - storePath := "" - if len(path) > 0 { - storePath = strings.TrimSpace(path[0]) - } - return &WorkingMemoryStore{path: storePath} -} - -// Get 返回当前工作记忆快照的拷贝。 -func (s *WorkingMemoryStore) Get(ctx context.Context) (*domain.WorkingMemoryState, error) { - _ = ctx - s.mu.Lock() - defer s.mu.Unlock() - if s.state == nil && strings.TrimSpace(s.path) != "" { - state, err := s.readLocked() - if err != nil { - return nil, err - } - s.state = state - } - if s.state == nil { - return &domain.WorkingMemoryState{}, nil - } - return cloneWorkingMemoryState(s.state), nil -} - -// Save 替换当前保存的工作记忆快照。 -func (s *WorkingMemoryStore) Save(ctx context.Context, state *domain.WorkingMemoryState) error { - _ = ctx - s.mu.Lock() - defer s.mu.Unlock() - s.state = cloneWorkingMemoryState(state) - if strings.TrimSpace(s.path) == "" { - return nil - } - return s.writeLocked(s.state) -} - -// Clear 清空已保存的工作记忆快照。 -func (s *WorkingMemoryStore) Clear(ctx context.Context) error { - _ = ctx - s.mu.Lock() - defer s.mu.Unlock() - s.state = nil - if strings.TrimSpace(s.path) == "" { - return nil - } - if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - return nil -} - -func (s *WorkingMemoryStore) readLocked() (*domain.WorkingMemoryState, error) { - data, err := os.ReadFile(s.path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return &domain.WorkingMemoryState{}, nil - } - return nil, err - } - if len(data) == 0 { - return &domain.WorkingMemoryState{}, nil - } - - var state domain.WorkingMemoryState - if err := json.Unmarshal(data, &state); err != nil { - return nil, err - } - return cloneWorkingMemoryState(&state), nil -} - -func (s *WorkingMemoryStore) writeLocked(state *domain.WorkingMemoryState) error { - if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { - return err - } - data, err := json.MarshalIndent(state, "", " ") - if err != nil { - return err - } - tmp, err := os.CreateTemp(filepath.Dir(s.path), "working-memory-*.tmp") - if err != nil { - return err - } - tmpPath := tmp.Name() - defer os.Remove(tmpPath) - if _, err := tmp.Write(data); err != nil { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - if err := os.Rename(tmpPath, s.path); err == nil { - return nil - } - if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - return os.Rename(tmpPath, s.path) -} - -func cloneWorkingMemoryState(state *domain.WorkingMemoryState) *domain.WorkingMemoryState { - if state == nil { - return &domain.WorkingMemoryState{} - } - cloned := &domain.WorkingMemoryState{ - CurrentTask: state.CurrentTask, - TaskSummary: state.TaskSummary, - LastCompletedAction: state.LastCompletedAction, - CurrentInProgress: state.CurrentInProgress, - NextStep: state.NextStep, - UpdatedAt: state.UpdatedAt, - } - if len(state.RecentTurns) > 0 { - cloned.RecentTurns = make([]domain.WorkingMemoryTurn, len(state.RecentTurns)) - copy(cloned.RecentTurns, state.RecentTurns) - } - if len(state.OpenQuestions) > 0 { - cloned.OpenQuestions = make([]string, len(state.OpenQuestions)) - copy(cloned.OpenQuestions, state.OpenQuestions) - } - if len(state.RecentFiles) > 0 { - cloned.RecentFiles = make([]string, len(state.RecentFiles)) - copy(cloned.RecentFiles, state.RecentFiles) - } - return cloned -} diff --git a/internal/server/infra/repository/working_memory_repository_test.go b/internal/server/infra/repository/working_memory_repository_test.go deleted file mode 100644 index 37897ab5..00000000 --- a/internal/server/infra/repository/working_memory_repository_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package repository - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" - - "go-llm-demo/internal/server/domain" -) - -func TestWorkingMemoryStorePersistsStateToDisk(t *testing.T) { - path := filepath.Join(t.TempDir(), "workspace", "session_state.json") - store := NewWorkingMemoryStore(path) - - state := &domain.WorkingMemoryState{ - CurrentTask: "修复记忆模块", - LastCompletedAction: "已完成规则修复", - NextStep: "补测试", - RecentFiles: []string{"internal/server/service/memory_service.go"}, - UpdatedAt: time.Now().UTC(), - } - if err := store.Save(context.Background(), state); err != nil { - t.Fatalf("save state: %v", err) - } - - reloaded := NewWorkingMemoryStore(path) - got, err := reloaded.Get(context.Background()) - if err != nil { - t.Fatalf("get state: %v", err) - } - if got.CurrentTask != state.CurrentTask || got.NextStep != state.NextStep { - t.Fatalf("expected persisted state, got %+v", got) - } -} - -func TestWorkingMemoryStoreClearRemovesPersistedFile(t *testing.T) { - path := filepath.Join(t.TempDir(), "workspace", "session_state.json") - store := NewWorkingMemoryStore(path) - if err := store.Save(context.Background(), &domain.WorkingMemoryState{CurrentTask: "task"}); err != nil { - t.Fatalf("save state: %v", err) - } - if err := store.Clear(context.Background()); err != nil { - t.Fatalf("clear state: %v", err) - } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("expected persisted file to be removed, got %v", err) - } -} diff --git a/internal/server/infra/tools/atomic_test.go b/internal/server/infra/tools/atomic_test.go deleted file mode 100644 index 99bf97b8..00000000 --- a/internal/server/infra/tools/atomic_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package tools - -import ( - "os" - "path/filepath" - "testing" -) - -func TestAtomicWrite_Safety(t *testing.T) { - // 1. 准备环境:创建一个初始文件 - tmpDir, err := os.MkdirTemp("", "neocode-test-*") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tmpDir) - - targetFile := filepath.Join(tmpDir, "important.txt") - originalContent := []byte("original data") - if err := os.WriteFile(targetFile, originalContent, 0644); err != nil { - t.Fatal(err) - } - - // 2. 模拟一个会导致失败的写入(例如:写入空内容或模拟权限错误) - // 注意:真正的原子性测试通常需要模拟系统调用失败, - // 但在单元测试层面,我们可以验证:如果 Rename 没发生,原文件绝不会变。 - newContent := []byte("new corrupted data") - - // 我们手动模拟 AtomicWrite 的前半部分逻辑 - tmpFile, err := os.CreateTemp(tmpDir, "neocode-tmp-*") - if err != nil { - t.Fatal(err) - } - // 写入数据到临时文件 - _, _ = tmpFile.Write(newContent) - _ = tmpFile.Sync() - _ = tmpFile.Close() - - // 此时:临时文件已经写好,但我们【不调用】Rename - // 验证:原文件内容必须依然是 "original data" - currentContent, _ := os.ReadFile(targetFile) - if string(currentContent) != string(originalContent) { - t.Errorf("原子性破坏!原文件在重命名之前就被修改了。期望 %s, 实际 %s", originalContent, currentContent) - } - - // 3. 验证正常调用 AtomicWrite 是否成功 - finalContent := []byte("final safe data") - if err := AtomicWrite(targetFile, finalContent); err != nil { - t.Fatalf("AtomicWrite 失败: %v", err) - } - - // 验证:只有在 AtomicWrite 成功后,内容才会变 - updatedContent, _ := os.ReadFile(targetFile) - if string(updatedContent) != string(finalContent) { - t.Errorf("内容更新失败。期望 %s, 实际 %s", finalContent, updatedContent) - } -} - -func TestAtomicWrite_DirectoryCreation(t *testing.T) { - tmpDir, _ := os.MkdirTemp("", "neocode-dir-test-*") - defer os.RemoveAll(tmpDir) - - // 测试:写入到一个不存在的深层子目录 - deepFile := filepath.Join(tmpDir, "a/b/c/test.txt") - content := []byte("hello") - - if err := AtomicWrite(deepFile, content); err != nil { - t.Fatalf("无法处理不存在的目录: %v", err) - } - - if _, err := os.Stat(deepFile); os.IsNotExist(err) { - t.Error("文件未被成功创建") - } -} diff --git a/internal/server/infra/tools/bash.go b/internal/server/infra/tools/bash.go deleted file mode 100644 index ad589cc8..00000000 --- a/internal/server/infra/tools/bash.go +++ /dev/null @@ -1,131 +0,0 @@ -package tools - -import ( - "bytes" - "context" - "fmt" - "os/exec" - "runtime" - "time" -) - -// BashTool 执行 shell 命令。 -type BashTool struct{} - -func (b *BashTool) Definition() ToolDefinition { - return ToolDefinition{ - Name: "bash", - Description: "Execute a bash command in the workspace. Supports optional workdir and timeout, default is 120000ms.", - Parameters: []ToolParamSpec{ - {Name: "command", Type: "string", Required: true, Description: "The bash command to execute."}, - {Name: "workdir", Type: "string", Description: "Directory within the workspace to execute the command, defaults to workspace root."}, - {Name: "timeout", Type: "integer", Description: "Command timeout in milliseconds, default 120000."}, - {Name: "description", Type: "string", Description: "A brief explanation of the command purpose for logs and auditing."}, - }, - } -} - -func (b *BashTool) Run(params map[string]interface{}) *ToolResult { - command, errRes := requiredString(params, "command") - if errRes != nil { - errRes.ToolName = b.Definition().Name - return errRes - } - if denied := guardToolExecution("Bash", command, b.Definition().Name); denied != nil { - return denied - } - timeoutMs, errRes := optionalInt(params, "timeout", 120000) - if errRes != nil { - errRes.ToolName = b.Definition().Name - return errRes - } - if timeoutMs < 1 { - return &ToolResult{ToolName: b.Definition().Name, Success: false, Error: "timeout must be >= 1"} - } - workdir, errRes := optionalString(params, "workdir", ".") - if errRes != nil { - errRes.ToolName = b.Definition().Name - return errRes - } - workdir, pathErr := ensureWorkspacePath(workdir) - if pathErr != nil { - pathErr.ToolName = b.Definition().Name - return pathErr - } - description, _ := optionalString(params, "description", "") - - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond) - defer cancel() - - var shell string - var shellArgs []string - switch runtime.GOOS { - case "linux", "darwin": - // Linux/macOS: use bash - shell = "bash" - shellArgs = []string{"-lc", command} - case "windows": - // Windows: use PowerShell - shell = "powershell" - shellArgs = []string{"-Command", command} - default: - shell = "bash" - shellArgs = []string{"-lc", command} - } - - // Use dynamically selected shell and args to create the command - shell, shellArgs = preferredShellCommand(runtime.GOOS, command, exec.LookPath, shell, shellArgs) - cmd := exec.CommandContext(ctx, shell, shellArgs...) - cmd.Dir = workdir - var stdoutBuf, stderrBuf bytes.Buffer - cmd.Stdout = &stdoutBuf - cmd.Stderr = &stderrBuf - err := cmd.Run() - result := &ToolResult{ToolName: b.Definition().Name, Metadata: map[string]interface{}{"command": command, "workdir": workdir, "timeoutMs": timeoutMs, "description": description}} - if err != nil { - if ctx.Err() != nil { - result.Success = false - result.Error = fmt.Sprintf("command timed out after %dms", timeoutMs) - return result - } - result.Success = false - result.Error = fmt.Sprintf("command execution failed: %v", err) - if stderrBuf.Len() > 0 { - result.Error += ": " + stderrBuf.String() - } - return result - } - result.Success = true - result.Output = stdoutBuf.String() - if stderrBuf.Len() > 0 { - result.Output += fmt.Sprintf("\nSTDERR: %s", stderrBuf.String()) - } - return result -} - -type shellLookup func(string) (string, error) - -func preferredShellCommand(goos, command string, lookPath shellLookup, shell string, shellArgs []string) (string, []string) { - switch goos { - case "windows": - for _, candidate := range []struct { - name string - args []string - }{ - {name: "powershell", args: []string{"-Command", command}}, - {name: "pwsh", args: []string{"-Command", command}}, - {name: "cmd.exe", args: []string{"/C", command}}, - {name: "cmd", args: []string{"/C", command}}, - } { - if _, err := lookPath(candidate.name); err == nil { - return candidate.name, candidate.args - } - } - return "cmd", []string{"/C", command} - default: - if _, err := lookPath("bash"); err == nil { - return "bash", []string{"-lc", command} - } - return "sh", []string{"-c", command} - } -} diff --git a/internal/server/infra/tools/bash_security_test.go b/internal/server/infra/tools/bash_security_test.go deleted file mode 100644 index 0bdbd1df..00000000 --- a/internal/server/infra/tools/bash_security_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package tools - -import ( - "errors" - "strings" - "testing" - - "go-llm-demo/internal/server/domain" -) - -type mockSecurityChecker struct { - action domain.Action -} - -func (m mockSecurityChecker) Check(_ string, _ string) domain.Action { - return m.action -} - -func TestBashTool_Run_DeniedBySecurity(t *testing.T) { - SetSecurityChecker(mockSecurityChecker{action: domain.ActionDeny}) - defer SetSecurityChecker(nil) - - result := (&BashTool{}).Run(map[string]interface{}{ - "command": "echo hello", - }) - - if result == nil { - t.Fatal("result should not be nil") - } - if result.Success { - t.Fatal("expected bash execution to be denied") - } - if !strings.Contains(result.Error, "Security policy denied execution") { - t.Fatalf("unexpected error: %s", result.Error) - } - if result.Metadata["securityAction"] != string(domain.ActionDeny) { - t.Fatalf("unexpected security action: %#v", result.Metadata["securityAction"]) - } -} - -func TestBashTool_Run_AskBySecurity(t *testing.T) { - SetSecurityChecker(mockSecurityChecker{action: domain.ActionAsk}) - defer SetSecurityChecker(nil) - - result := (&BashTool{}).Run(map[string]interface{}{ - "command": "go build ./...", - }) - - if result == nil { - t.Fatal("result should not be nil") - } - if result.Success { - t.Fatal("expected bash execution to require confirmation") - } - if !strings.Contains(result.Error, "requires user confirmation") { - t.Fatalf("unexpected error: %s", result.Error) - } - if result.Metadata["securityAction"] != string(domain.ActionAsk) { - t.Fatalf("unexpected security action: %#v", result.Metadata["securityAction"]) - } -} - -func TestPreferredShellCommandFallsBackToCmdOnWindows(t *testing.T) { - lookup := func(name string) (string, error) { - if name == "cmd.exe" { - return name, nil - } - return "", errors.New("not found") - } - - shell, args := preferredShellCommand("windows", "echo hello", lookup, "powershell", []string{"-Command", "echo hello"}) - if shell != "cmd.exe" { - t.Fatalf("expected cmd.exe fallback, got %q", shell) - } - if len(args) != 2 || args[0] != "/C" || args[1] != "echo hello" { - t.Fatalf("unexpected cmd.exe args: %#v", args) - } -} - -func TestPreferredShellCommandFallsBackToShWithoutBash(t *testing.T) { - lookup := func(name string) (string, error) { - if name == "sh" { - return name, nil - } - return "", errors.New("not found") - } - - shell, args := preferredShellCommand("linux", "echo hello", lookup, "bash", []string{"-lc", "echo hello"}) - if shell != "sh" { - t.Fatalf("expected sh fallback, got %q", shell) - } - if len(args) != 2 || args[0] != "-c" || args[1] != "echo hello" { - t.Fatalf("unexpected sh args: %#v", args) - } -} diff --git a/internal/server/infra/tools/edit.go b/internal/server/infra/tools/edit.go deleted file mode 100644 index 432ce942..00000000 --- a/internal/server/infra/tools/edit.go +++ /dev/null @@ -1,74 +0,0 @@ -package tools - -import ( - "fmt" - "os" - "strings" -) - -// EditTool 在文件中执行精确的字符串替换。 -type EditTool struct{} - -func (e *EditTool) Definition() ToolDefinition { - return ToolDefinition{ - Name: "edit", - Description: "Perform precise string replacement in a file within the workspace. By default, replaces only the first occurrence, controlled by replaceAll.", - Parameters: []ToolParamSpec{ - {Name: "filePath", Type: "string", Required: true, Description: "Path to the file to modify within the workspace."}, - {Name: "oldString", Type: "string", Required: true, Description: "The original text to replace, must match file content exactly."}, - {Name: "newString", Type: "string", Required: true, Description: "The new text to replace with, must be different from oldString."}, - {Name: "replaceAll", Type: "boolean", Description: "Whether to replace all occurrences, default false."}, - }, - } -} - -func (e *EditTool) Run(params map[string]interface{}) *ToolResult { - filePath, errRes := requiredString(params, "filePath") - if errRes != nil { - errRes.ToolName = e.Definition().Name - return errRes - } - filePath, pathErr := ensureWorkspacePath(filePath) - if pathErr != nil { - pathErr.ToolName = e.Definition().Name - return pathErr - } - oldString, errRes := requiredString(params, "oldString") - if errRes != nil { - errRes.ToolName = e.Definition().Name - return errRes - } - newString, errRes := requiredString(params, "newString") - if errRes != nil { - errRes.ToolName = e.Definition().Name - return errRes - } - if oldString == newString { - return &ToolResult{ToolName: e.Definition().Name, Success: false, Error: "newString must be different from oldString"} - } - replaceAll, errRes := optionalBool(params, "replaceAll", false) - if errRes != nil { - errRes.ToolName = e.Definition().Name - return errRes - } - - content, err := os.ReadFile(filePath) - if err != nil { - return &ToolResult{ToolName: e.Definition().Name, Success: false, Error: fmt.Sprintf("failed to read file: %v", err)} - } - fileContent := string(content) - if !strings.Contains(fileContent, oldString) { - return &ToolResult{ToolName: e.Definition().Name, Success: false, Error: fmt.Sprintf("oldString not found in file: %q", oldString)} - } - - newContent := strings.Replace(fileContent, oldString, newString, 1) - replacements := 1 - if replaceAll { - replacements = strings.Count(fileContent, oldString) - newContent = strings.ReplaceAll(fileContent, oldString, newString) - } - if err := AtomicWrite(filePath, []byte(newContent)); err != nil { - return &ToolResult{ToolName: e.Definition().Name, Success: false, Error: fmt.Sprintf("failed to write file: %v", err)} - } - return &ToolResult{ToolName: e.Definition().Name, Success: true, Output: fmt.Sprintf("Successfully replaced %d match(es)", replacements), Metadata: map[string]interface{}{"filePath": filePath, "oldString": oldString, "newString": newString, "replaceAll": replaceAll, "replacements": replacements, "bytesWritten": len(newContent)}} -} diff --git a/internal/server/infra/tools/grep.go b/internal/server/infra/tools/grep.go deleted file mode 100644 index 751642f5..00000000 --- a/internal/server/infra/tools/grep.go +++ /dev/null @@ -1,90 +0,0 @@ -package tools - -import ( - "fmt" - "os" - "path/filepath" - "regexp" - "strings" -) - -// GrepTool 使用正则表达式搜索文件内容。 -type GrepTool struct{} - -func (g *GrepTool) Definition() ToolDefinition { - return ToolDefinition{ - Name: "grep", - Description: "Recursively search for file content in the workspace using regular expressions and return all matches with file, line number, and text.", - Parameters: []ToolParamSpec{ - {Name: "pattern", Type: "string", Required: true, Description: "The regular expression to search for."}, - {Name: "path", Type: "string", Description: "Search root directory within the workspace, defaults to workspace root."}, - {Name: "include", Type: "string", Description: "Optional filename glob filter, e.g., '*.go'."}, - }, - } -} - -func (g *GrepTool) Run(params map[string]interface{}) *ToolResult { - pattern, errRes := requiredString(params, "pattern") - if errRes != nil { - errRes.ToolName = g.Definition().Name - return errRes - } - regex, err := regexp.Compile(pattern) - if err != nil { - return &ToolResult{ToolName: g.Definition().Name, Success: false, Error: fmt.Sprintf("invalid regex pattern: %v", err)} - } - searchPath, errRes := optionalString(params, "path", ".") - if errRes != nil { - errRes.ToolName = g.Definition().Name - return errRes - } - searchPath, pathErr := ensureWorkspacePath(searchPath) - if pathErr != nil { - pathErr.ToolName = g.Definition().Name - return pathErr - } - includePattern, errRes := optionalString(params, "include", "") - if errRes != nil { - errRes.ToolName = g.Definition().Name - return errRes - } - - var lines []string - matchCount := 0 - walkErr := filepath.Walk(searchPath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - return nil - } - if includePattern != "" { - matched, matchErr := filepath.Match(includePattern, filepath.Base(path)) - if matchErr != nil { - return matchErr - } - if !matched { - return nil - } - } - content, err := os.ReadFile(path) - if err != nil { - return nil - } - fileLines := regexp.MustCompile("\\r?\\n").Split(string(content), -1) - for idx, line := range fileLines { - if regex.MatchString(line) { - matchCount++ - lines = append(lines, fmt.Sprintf("%s:%d:%s", path, idx+1, strings.TrimSpace(line))) - } - } - return nil - }) - if walkErr != nil { - return &ToolResult{ToolName: g.Definition().Name, Success: false, Error: walkErr.Error()} - } - if matchCount == 0 { - return &ToolResult{ToolName: g.Definition().Name, Success: true, Output: "No matches found.", Metadata: map[string]interface{}{"pattern": pattern, "path": searchPath, "include": includePattern, "matches": 0}} - } - return &ToolResult{ToolName: g.Definition().Name, Success: true, Output: strings.Join(lines, "\n") + "\n", Metadata: map[string]interface{}{"pattern": pattern, "path": searchPath, "include": includePattern, "matches": matchCount}} -} diff --git a/internal/server/infra/tools/helpers.go b/internal/server/infra/tools/helpers.go deleted file mode 100644 index 2bb7e787..00000000 --- a/internal/server/infra/tools/helpers.go +++ /dev/null @@ -1,267 +0,0 @@ -package tools - -import ( - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "sync" -) - -const WorkspaceEnvVar = "NEOCODE_WORKSPACE" - -var ( - workspaceRootMu sync.RWMutex - configuredRootPath string -) - -func requiredString(params map[string]interface{}, key string) (string, *ToolResult) { - value, ok := params[key] - if !ok { - return "", &ToolResult{Success: false, Error: fmt.Sprintf("缺少必需参数: %s", key)} - } - str, ok := value.(string) - if !ok || strings.TrimSpace(str) == "" { - return "", &ToolResult{Success: false, Error: fmt.Sprintf("%s 必须是非空字符串", key)} - } - return str, nil -} - -func optionalString(params map[string]interface{}, key, fallback string) (string, *ToolResult) { - value, ok := params[key] - if !ok { - return fallback, nil - } - str, ok := value.(string) - if !ok { - return "", &ToolResult{Success: false, Error: fmt.Sprintf("%s 必须是字符串", key)} - } - if strings.TrimSpace(str) == "" { - return fallback, nil - } - return str, nil -} - -func optionalInt(params map[string]interface{}, key string, fallback int) (int, *ToolResult) { - value, ok := params[key] - if !ok { - return fallback, nil - } - switch v := value.(type) { - case float64: - return int(v), nil - case int: - return v, nil - case string: - parsed, err := strconv.Atoi(v) - if err != nil { - return 0, &ToolResult{Success: false, Error: fmt.Sprintf("%s 必须是数字", key)} - } - return parsed, nil - default: - return 0, &ToolResult{Success: false, Error: fmt.Sprintf("%s 必须是数字", key)} - } -} - -func optionalBool(params map[string]interface{}, key string, fallback bool) (bool, *ToolResult) { - value, ok := params[key] - if !ok { - return fallback, nil - } - switch v := value.(type) { - case bool: - return v, nil - case string: - switch strings.ToLower(strings.TrimSpace(v)) { - case "true", "1", "yes": - return true, nil - case "false", "0", "no": - return false, nil - default: - return false, &ToolResult{Success: false, Error: fmt.Sprintf("%s 必须是布尔值", key)} - } - default: - return false, &ToolResult{Success: false, Error: fmt.Sprintf("%s 必须是布尔值", key)} - } -} - -// ResolveWorkspaceRoot 解析工作区根目录。 -// 优先级:cliOverride > NEOCODE_WORKSPACE > 当前进程工作目录。 -func ResolveWorkspaceRoot(cliOverride string) (string, error) { - candidate := strings.TrimSpace(cliOverride) - if candidate == "" { - candidate = strings.TrimSpace(os.Getenv(WorkspaceEnvVar)) - } - if candidate == "" { - wd, err := os.Getwd() - if err != nil { - return "", fmt.Errorf("获取当前工作目录失败: %w", err) - } - candidate = wd - } - - absPath, err := filepath.Abs(candidate) - if err != nil { - return "", fmt.Errorf("解析工作区绝对路径失败: %w", err) - } - info, err := os.Stat(absPath) - if err != nil { - return "", fmt.Errorf("读取工作区路径失败: %w", err) - } - if !info.IsDir() { - return "", fmt.Errorf("工作区路径不是目录: %s", absPath) - } - return absPath, nil -} - -// SetWorkspaceRoot 固定工具层使用的工作区根目录。 -func SetWorkspaceRoot(path string) error { - trimmed := strings.TrimSpace(path) - if trimmed == "" { - return fmt.Errorf("工作区路径不能为空") - } - absPath, err := filepath.Abs(trimmed) - if err != nil { - return fmt.Errorf("解析工作区绝对路径失败: %w", err) - } - info, err := os.Stat(absPath) - if err != nil { - return fmt.Errorf("读取工作区路径失败: %w", err) - } - if !info.IsDir() { - return fmt.Errorf("工作区路径不是目录: %s", absPath) - } - - workspaceRootMu.Lock() - configuredRootPath = absPath - workspaceRootMu.Unlock() - return nil -} - -// GetWorkspaceRoot 返回当前固定的工作区根目录。 -// 若尚未设置,则回退到当前进程工作目录。 -func GetWorkspaceRoot() string { - workspaceRootMu.RLock() - root := configuredRootPath - workspaceRootMu.RUnlock() - if root != "" { - return root - } - wd, err := os.Getwd() - if err != nil { - return "." - } - absPath, err := filepath.Abs(wd) - if err != nil { - return wd - } - return absPath -} - -func workspaceRoot() string { - return GetWorkspaceRoot() -} - -func resolveWorkspacePath(path string) (string, error) { - root := workspaceRoot() - candidate := path - if !filepath.IsAbs(candidate) { - candidate = filepath.Join(root, candidate) - } - candidate, err := filepath.Abs(candidate) - if err != nil { - return "", err - } - // 通过相对路径判断是否越过工作区边界,比单纯字符串前缀判断更稳妥。 - rel, err := filepath.Rel(root, candidate) - if err != nil { - return "", fmt.Errorf("路径超出工作区: %s", path) - } - if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { - return "", fmt.Errorf("路径超出工作区: %s", path) - } - return candidate, nil -} - -func ensureWorkspacePath(path string) (string, *ToolResult) { - resolved, err := resolveWorkspacePath(path) - if err != nil { - return "", &ToolResult{Success: false, Error: err.Error()} - } - return resolved, nil -} - -// AtomicWrite 以原子方式将内容写入文件,确保文件完整性。 -func AtomicWrite(filePath string, content []byte) error { - dir := filepath.Dir(filePath) - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("创建目录失败: %w", err) - } - - // 1. 在同目录下创建临时文件 - tmpFile, err := os.CreateTemp(dir, "neocode-tmp-*") - if err != nil { - return fmt.Errorf("创建临时文件失败: %w", err) - } - tmpPath := tmpFile.Name() - defer func() { - _ = tmpFile.Close() - _ = os.Remove(tmpPath) // 正常 Rename 后 Remove 会失败,属正常行为 - }() - - // 2. 写入内容 - if _, err := tmpFile.Write(content); err != nil { - return fmt.Errorf("写入临时文件失败: %w", err) - } - - // 3. 强制刷盘,确保操作系统已将数据写入物理存储 - if err := tmpFile.Sync(); err != nil { - return fmt.Errorf("刷盘失败: %w", err) - } - - if err := tmpFile.Close(); err != nil { - return fmt.Errorf("关闭临时文件失败: %w", err) - } - - // 4. 原子重命名,替换目标文件 - // 在 Unix-like 和 Windows (同一卷内) 这通常是原子的 - if err := os.Rename(tmpPath, filePath); err != nil { - return fmt.Errorf("原子替换失败: %w", err) - } - - return nil -} - -// NormalizeParams 递归将工具参数键名统一转换为 camelCase。 -func NormalizeParams(params map[string]interface{}) map[string]interface{} { - if params == nil { - return map[string]interface{}{} - } - result := make(map[string]interface{}, len(params)) - for key, value := range params { - camelKey := snakeToCamel(strings.TrimSpace(key)) - switch typed := value.(type) { - case map[string]interface{}: - result[camelKey] = NormalizeParams(typed) - default: - result[camelKey] = value - } - } - return result -} - -func snakeToCamel(s string) string { - parts := strings.Split(s, "_") - if len(parts) <= 1 { - return s - } - out := parts[0] - for _, p := range parts[1:] { - if p == "" { - continue - } - out += strings.ToUpper(p[:1]) + p[1:] - } - return out -} diff --git a/internal/server/infra/tools/list.go b/internal/server/infra/tools/list.go deleted file mode 100644 index f4497a6d..00000000 --- a/internal/server/infra/tools/list.go +++ /dev/null @@ -1,49 +0,0 @@ -package tools - -import ( - "fmt" - "os" -) - -// ListTool 列出目录内容。 -type ListTool struct{} - -func (l *ListTool) Definition() ToolDefinition { - return ToolDefinition{ - Name: "list", - Description: "List directory contents in the workspace. One entry per line, subdirectories suffixed with '/'.", - Parameters: []ToolParamSpec{{Name: "path", Type: "string", Description: "Directory within the workspace to list, defaults to workspace root."}}, - } -} - -func (l *ListTool) Run(params map[string]interface{}) *ToolResult { - path, errRes := optionalString(params, "path", ".") - if errRes != nil { - errRes.ToolName = l.Definition().Name - return errRes - } - path, pathErr := ensureWorkspacePath(path) - if pathErr != nil { - pathErr.ToolName = l.Definition().Name - return pathErr - } - - file, err := os.Open(path) - if err != nil { - return &ToolResult{ToolName: l.Definition().Name, Success: false, Error: fmt.Sprintf("failed to open directory: %v", err)} - } - defer file.Close() - entries, err := file.Readdir(-1) - if err != nil { - return &ToolResult{ToolName: l.Definition().Name, Success: false, Error: fmt.Sprintf("failed to read directory: %v", err)} - } - output := "" - for _, entry := range entries { - name := entry.Name() - if entry.IsDir() { - name += "/" - } - output += name + "\n" - } - return &ToolResult{ToolName: l.Definition().Name, Success: true, Output: output, Metadata: map[string]interface{}{"path": path, "count": len(entries)}} -} diff --git a/internal/server/infra/tools/read.go b/internal/server/infra/tools/read.go deleted file mode 100644 index 7e8e6b6e..00000000 --- a/internal/server/infra/tools/read.go +++ /dev/null @@ -1,100 +0,0 @@ -package tools - -import ( - "bufio" - "fmt" - "os" -) - -// ReadTool 读取文件内容,支持可选的行范围参数。 -type ReadTool struct{} - -func (r *ReadTool) Definition() ToolDefinition { - return ToolDefinition{ - Name: "read", - Description: "Read a file or directory in the workspace. Supports offset/limit pagination for files, returns directory entries for directories.", - Parameters: []ToolParamSpec{ - {Name: "filePath", Type: "string", Required: true, Description: "Path to the target file or directory in the workspace. Supports relative paths."}, - {Name: "offset", Type: "integer", Description: "Starting line number for reading a file, 1-based, default 1."}, - {Name: "limit", Type: "integer", Description: "Maximum number of lines to return for a file, default 2000."}, - }, - } -} - -// Run executes the read tool. -func (r *ReadTool) Run(params map[string]interface{}) *ToolResult { - filePath, errRes := requiredString(params, "filePath") - if errRes != nil { - errRes.ToolName = r.Definition().Name - return errRes - } - filePath, pathErr := ensureWorkspacePath(filePath) - if pathErr != nil { - pathErr.ToolName = r.Definition().Name - return pathErr - } - - offset, errRes := optionalInt(params, "offset", 1) - if errRes != nil { - errRes.ToolName = r.Definition().Name - return errRes - } - limit, errRes := optionalInt(params, "limit", 2000) - if errRes != nil { - errRes.ToolName = r.Definition().Name - return errRes - } - if offset < 1 { - return &ToolResult{ToolName: r.Definition().Name, Success: false, Error: "offset must be >= 1"} - } - if limit < 1 { - return &ToolResult{ToolName: r.Definition().Name, Success: false, Error: "limit must be >= 1"} - } - - file, err := os.Open(filePath) - if err != nil { - return &ToolResult{ToolName: r.Definition().Name, Success: false, Error: fmt.Sprintf("failed to open file: %v", err)} - } - defer file.Close() - - info, err := file.Stat() - if err != nil { - return &ToolResult{ToolName: r.Definition().Name, Success: false, Error: fmt.Sprintf("failed to get file status: %v", err)} - } - - if info.IsDir() { - entries, err := os.ReadDir(filePath) - if err != nil { - return &ToolResult{ToolName: r.Definition().Name, Success: false, Error: fmt.Sprintf("failed to read directory: %v", err)} - } - output := "" - for _, entry := range entries { - name := entry.Name() - if entry.IsDir() { - name += "/" - } - output += name + "\n" - } - return &ToolResult{ToolName: r.Definition().Name, Success: true, Output: output, Metadata: map[string]interface{}{"filePath": filePath, "entryCount": len(entries), "kind": "directory"}} - } - - var lines []string - scanner := bufio.NewScanner(file) - currentLine := 1 - for scanner.Scan() && currentLine < offset { - currentLine++ - } - for scanner.Scan() && len(lines) < limit { - lines = append(lines, scanner.Text()) - currentLine++ - } - if err := scanner.Err(); err != nil { - return &ToolResult{ToolName: r.Definition().Name, Success: false, Error: fmt.Sprintf("error reading file: %v", err)} - } - - output := "" - for i, line := range lines { - output += fmt.Sprintf("%d: %s\n", offset+i, line) - } - return &ToolResult{ToolName: r.Definition().Name, Success: true, Output: output, Metadata: map[string]interface{}{"filePath": filePath, "offset": offset, "limit": limit, "linesReturned": len(lines), "kind": "file"}} -} diff --git a/internal/server/infra/tools/security.go b/internal/server/infra/tools/security.go deleted file mode 100644 index 1f1a014d..00000000 --- a/internal/server/infra/tools/security.go +++ /dev/null @@ -1,113 +0,0 @@ -package tools - -import ( - "fmt" - "log" - "strings" - "sync" - - "go-llm-demo/internal/server/domain" -) - -var ( - securityCheckerMu sync.RWMutex - securityChecker domain.SecurityChecker - - securityAskApprovalMu sync.Mutex - securityAskApprovals = map[string]int{} -) - -// SetSecurityChecker 设置工具执行前使用的安全检查器。 -// 传入 nil 表示关闭安全检查(默认行为)。 -func SetSecurityChecker(checker domain.SecurityChecker) { - securityCheckerMu.Lock() - securityChecker = checker - securityCheckerMu.Unlock() -} - -func getSecurityChecker() domain.SecurityChecker { - securityCheckerMu.RLock() - checker := securityChecker - securityCheckerMu.RUnlock() - return checker -} - -// ApproveSecurityAsk 为指定的安全询问发放一次性放行许可。 -// 该许可会在下一次匹配到同一 (toolType, target) 时被消费。 -func ApproveSecurityAsk(toolType, target string) { - key, err := securityApprovalKey(toolType, target) - if err != nil { - log.Printf("warning: failed to record security ask approval: %v", err) - return - } - securityAskApprovalMu.Lock() - securityAskApprovals[key]++ - securityAskApprovalMu.Unlock() -} - -func consumeSecurityAskApproval(toolType, target string) bool { - key, err := securityApprovalKey(toolType, target) - if err != nil { - log.Printf("warning: failed to consume security ask approval: %v", err) - return false - } - securityAskApprovalMu.Lock() - defer securityAskApprovalMu.Unlock() - count := securityAskApprovals[key] - if count <= 0 { - return false - } - if count == 1 { - delete(securityAskApprovals, key) - return true - } - securityAskApprovals[key] = count - 1 - return true -} - -func securityApprovalKey(toolType, target string) (string, error) { - normalizedType := strings.ToLower(strings.TrimSpace(toolType)) - normalizedTarget := strings.TrimSpace(target) - if normalizedType == "" || normalizedTarget == "" { - return "", fmt.Errorf("invalid security approval context: toolType=%q target=%q", toolType, target) - } - return normalizedType + "\x00" + normalizedTarget, nil -} - -func guardToolExecution(toolType, target, toolName string) *ToolResult { - checker := getSecurityChecker() - if checker == nil { - return nil - } - - action := checker.Check(toolType, target) - metadata := map[string]interface{}{ - "securityToolType": toolType, - "securityTarget": target, - "securityAction": string(action), - } - - switch action { - case domain.ActionAllow: - return nil - case domain.ActionDeny: - return &ToolResult{ - ToolName: toolName, - Success: false, - Error: fmt.Sprintf("Security policy denied execution of %s: %s", toolType, target), - Metadata: metadata, - } - case domain.ActionAsk: - if consumeSecurityAskApproval(toolType, target) { - return nil - } - return &ToolResult{ - ToolName: toolName, - Success: false, - Error: fmt.Sprintf("Execution of %s on %s requires user confirmation (Action: Ask).", toolType, target), - Metadata: metadata, - } - default: - return nil - } -} diff --git a/internal/server/infra/tools/security_test.go b/internal/server/infra/tools/security_test.go deleted file mode 100644 index 99f008af..00000000 --- a/internal/server/infra/tools/security_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package tools - -import ( - "bytes" - "log" - "strings" - "testing" -) - -func TestApproveSecurityAskLogsInvalidContext(t *testing.T) { - resetSecurityApprovals() - - var buf bytes.Buffer - restoreLogs := captureToolLogs(&buf) - defer restoreLogs() - - ApproveSecurityAsk("", "target") - - if !strings.Contains(buf.String(), "invalid security approval context") { - t.Fatalf("expected invalid approval context to be logged, got %q", buf.String()) - } -} - -func TestConsumeSecurityAskApprovalLogsInvalidContext(t *testing.T) { - resetSecurityApprovals() - - var buf bytes.Buffer - restoreLogs := captureToolLogs(&buf) - defer restoreLogs() - - if consumeSecurityAskApproval("Bash", "") { - t.Fatal("expected invalid approval context to be rejected") - } - if !strings.Contains(buf.String(), "invalid security approval context") { - t.Fatalf("expected invalid approval context to be logged, got %q", buf.String()) - } -} - -func TestSecurityApprovalRoundTrip(t *testing.T) { - resetSecurityApprovals() - - ApproveSecurityAsk("Bash", "echo hello") - - if !consumeSecurityAskApproval("Bash", "echo hello") { - t.Fatal("expected approval to be consumed once") - } - if consumeSecurityAskApproval("Bash", "echo hello") { - t.Fatal("expected approval to be one-time") - } -} - -func resetSecurityApprovals() { - securityAskApprovalMu.Lock() - securityAskApprovals = map[string]int{} - securityAskApprovalMu.Unlock() -} - -func captureToolLogs(buf *bytes.Buffer) func() { - previousWriter := log.Writer() - previousFlags := log.Flags() - previousPrefix := log.Prefix() - - log.SetOutput(buf) - log.SetFlags(0) - log.SetPrefix("") - - return func() { - log.SetOutput(previousWriter) - log.SetFlags(previousFlags) - log.SetPrefix(previousPrefix) - } -} diff --git a/internal/server/infra/tools/todo.go b/internal/server/infra/tools/todo.go deleted file mode 100644 index 1dd316be..00000000 --- a/internal/server/infra/tools/todo.go +++ /dev/null @@ -1,136 +0,0 @@ -package tools - -import ( - "context" - "fmt" - "go-llm-demo/internal/server/domain" - "strings" -) - -// TodoTool 允许 Agent 管理显式任务清单 -type TodoTool struct { - todoSvc domain.TodoService -} - -func NewTodoTool(todoSvc domain.TodoService) *TodoTool { - return &TodoTool{todoSvc: todoSvc} -} - -func (t *TodoTool) Definition() ToolDefinition { - return ToolDefinition{ - Name: "todo", - Description: "Manage an explicit todo list: add tasks, update status, list items, remove items, or clear the list.", - Parameters: []ToolParamSpec{ - {Name: "action", Type: "string", Required: true, Description: "Action type: add, update, list, remove, clear."}, - {Name: "content", Type: "string", Description: "Task content, used by add."}, - {Name: "priority", Type: "string", Description: "Task priority: high, medium, low. Used by add, default is medium."}, - {Name: "id", Type: "string", Description: "Task id, used by update and remove."}, - {Name: "status", Type: "string", Description: "Task status: pending, in_progress, completed. Used by update."}, - }, - } -} - -func (t *TodoTool) Run(params map[string]interface{}) *ToolResult { - action, errRes := requiredString(params, "action") - if errRes != nil { - errRes.ToolName = t.Definition().Name - return errRes - } - - ctx := context.Background() - - normalizedAction := strings.ToLower(action) - actionType, ok := domain.ParseTodoAction(normalizedAction) - if !ok { - return &ToolResult{ToolName: t.Definition().Name, Success: false, Error: fmt.Sprintf("unsupported action: %s", action)} - } - - switch actionType { - case domain.TodoActionAdd: - content, errRes := requiredString(params, "content") - if errRes != nil { - errRes.ToolName = t.Definition().Name - return errRes - } - priorityStr := strings.ToLower(optionalStringDefault(params, "priority", string(domain.TodoPriorityMedium))) - priority, ok := domain.ParseTodoPriority(priorityStr) - if !ok { - return &ToolResult{ToolName: t.Definition().Name, Success: false, Error: "invalid priority value"} - } - todo, err := t.todoSvc.AddTodo(ctx, content, priority) - if err != nil { - return &ToolResult{ToolName: t.Definition().Name, Success: false, Error: err.Error()} - } - return &ToolResult{ToolName: t.Definition().Name, Success: true, Output: fmt.Sprintf("Added task: %s (%s)", todo.ID, todo.Content)} - - case domain.TodoActionUpdate: - id, errRes := requiredString(params, "id") - if errRes != nil { - errRes.ToolName = t.Definition().Name - return errRes - } - statusStr, errRes := requiredString(params, "status") - if errRes != nil { - errRes.ToolName = t.Definition().Name - return errRes - } - status, ok := domain.ParseTodoStatus(strings.ToLower(statusStr)) - if !ok { - return &ToolResult{ToolName: t.Definition().Name, Success: false, Error: "invalid status value"} - } - err := t.todoSvc.UpdateTodoStatus(ctx, id, status) - if err != nil { - return &ToolResult{ToolName: t.Definition().Name, Success: false, Error: err.Error()} - } - return &ToolResult{ToolName: t.Definition().Name, Success: true, Output: fmt.Sprintf("Task %s status updated to %s", id, status)} - - case domain.TodoActionList: - todos, err := t.todoSvc.ListTodos(ctx) - if err != nil { - return &ToolResult{ToolName: t.Definition().Name, Success: false, Error: err.Error()} - } - if len(todos) == 0 { - return &ToolResult{ToolName: t.Definition().Name, Success: true, Output: "Todo list is empty"} - } - var sb strings.Builder - sb.WriteString("Todo list:\n") - for _, todo := range todos { - statusIcon := "[ ]" - if todo.Status == domain.TodoInProgress { - statusIcon = "[/]" - } else if todo.Status == domain.TodoCompleted { - statusIcon = "[x]" - } - sb.WriteString(fmt.Sprintf("%s %s: %s (priority: %s)\n", statusIcon, todo.ID, todo.Content, todo.Priority)) - } - return &ToolResult{ToolName: t.Definition().Name, Success: true, Output: sb.String()} - - case domain.TodoActionRemove: - id, errRes := requiredString(params, "id") - if errRes != nil { - errRes.ToolName = t.Definition().Name - return errRes - } - err := t.todoSvc.RemoveTodo(ctx, id) - if err != nil { - return &ToolResult{ToolName: t.Definition().Name, Success: false, Error: err.Error()} - } - return &ToolResult{ToolName: t.Definition().Name, Success: true, Output: fmt.Sprintf("Removed task %s", id)} - - case domain.TodoActionClear: - err := t.todoSvc.ClearTodos(ctx) - if err != nil { - return &ToolResult{ToolName: t.Definition().Name, Success: false, Error: err.Error()} - } - return &ToolResult{ToolName: t.Definition().Name, Success: true, Output: "Todo list cleared"} - } - return &ToolResult{ToolName: t.Definition().Name, Success: false, Error: fmt.Sprintf("unsupported action: %s", action)} -} - -func optionalStringDefault(params map[string]interface{}, key, fallback string) string { - val, ok := params[key].(string) - if !ok || strings.TrimSpace(val) == "" { - return fallback - } - return val -} diff --git a/internal/server/infra/tools/todo_test.go b/internal/server/infra/tools/todo_test.go deleted file mode 100644 index e87ad8b8..00000000 --- a/internal/server/infra/tools/todo_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package tools - -import ( - "context" - "strings" - "testing" - - "go-llm-demo/internal/server/domain" - "go-llm-demo/internal/server/infra/repository" - "go-llm-demo/internal/server/service" -) - -func newTodoTool() (*TodoTool, domain.TodoService) { - repo := repository.NewInMemoryTodoRepository() - todoSvc := service.NewTodoService(repo) - return NewTodoTool(todoSvc), todoSvc -} - -func TestTodoTool_Run_RejectsUnsupportedAction(t *testing.T) { - tool, _ := newTodoTool() - - res := tool.Run(map[string]interface{}{ - "action": "unknown", - }) - - if res == nil { - t.Fatal("result should not be nil") - } - if res.Success { - t.Fatal("expected failure for unsupported action") - } - if !strings.Contains(res.Error, "unsupported action") { - t.Fatalf("unexpected error: %q", res.Error) - } -} - -func TestTodoTool_Run_AddMissingContentReturnsError(t *testing.T) { - tool, _ := newTodoTool() - - res := tool.Run(map[string]interface{}{ - "action": "add", - "priority": "high", - }) - - if res == nil { - t.Fatal("result should not be nil") - } - if res.Success { - t.Fatal("expected failure for missing content") - } - if res.ToolName != "todo" { - t.Fatalf("expected tool name 'todo', got %q", res.ToolName) - } -} - -func TestTodoTool_Run_AddInvalidPriorityReturnsError(t *testing.T) { - tool, _ := newTodoTool() - - res := tool.Run(map[string]interface{}{ - "action": "add", - "content": "task 1", - "priority": "urgent", - }) - - if res == nil { - t.Fatal("result should not be nil") - } - if res.Success { - t.Fatal("expected failure for invalid priority") - } - if !strings.Contains(res.Error, "invalid priority") { - t.Fatalf("unexpected error: %q", res.Error) - } -} - -func TestTodoTool_Run_AddListUpdateRemoveClear(t *testing.T) { - tool, todoSvc := newTodoTool() - - addRes := tool.Run(map[string]interface{}{ - "action": "add", - "content": "task 1", - "priority": "high", - }) - if addRes == nil || !addRes.Success { - t.Fatalf("expected add success, got %#v", addRes) - } - if addRes.ToolName != "todo" { - t.Fatalf("expected tool name 'todo', got %q", addRes.ToolName) - } - if !strings.Contains(addRes.Output, "Added task:") { - t.Fatalf("unexpected add output: %q", addRes.Output) - } - - addDefaultPriorityRes := tool.Run(map[string]interface{}{ - "action": "add", - "content": "task 2", - }) - if addDefaultPriorityRes == nil || !addDefaultPriorityRes.Success { - t.Fatalf("expected add success, got %#v", addDefaultPriorityRes) - } - - todos, err := todoSvc.ListTodos(context.Background()) - if err != nil { - t.Fatalf("failed to list todos: %v", err) - } - if len(todos) != 2 { - t.Fatalf("expected 2 todos, got %d", len(todos)) - } - idsByContent := map[string]string{} - for _, todo := range todos { - idsByContent[todo.Content] = todo.ID - } - task1ID := idsByContent["task 1"] - task2ID := idsByContent["task 2"] - if task1ID == "" || task2ID == "" { - t.Fatalf("unexpected todos: %#v", todos) - } - - updateRes := tool.Run(map[string]interface{}{ - "action": "update", - "id": task1ID, - "status": "completed", - }) - if updateRes == nil || !updateRes.Success { - t.Fatalf("expected update success, got %#v", updateRes) - } - if !strings.Contains(updateRes.Output, "status updated") { - t.Fatalf("unexpected update output: %q", updateRes.Output) - } - - updateInProgressRes := tool.Run(map[string]interface{}{ - "action": "update", - "id": task2ID, - "status": "in_progress", - }) - if updateInProgressRes == nil || !updateInProgressRes.Success { - t.Fatalf("expected update success, got %#v", updateInProgressRes) - } - - listRes := tool.Run(map[string]interface{}{ - "action": "list", - }) - if listRes == nil || !listRes.Success { - t.Fatalf("expected list success, got %#v", listRes) - } - if !strings.Contains(listRes.Output, "Todo list:") { - t.Fatalf("unexpected list output: %q", listRes.Output) - } - if !strings.Contains(listRes.Output, task1ID) || !strings.Contains(listRes.Output, task2ID) { - t.Fatalf("expected list output to contain ids %q and %q, got %q", task1ID, task2ID, listRes.Output) - } - if !strings.Contains(listRes.Output, "[x]") { - t.Fatalf("expected completed status icon in output, got %q", listRes.Output) - } - if !strings.Contains(listRes.Output, "[/]") { - t.Fatalf("expected in_progress status icon in output, got %q", listRes.Output) - } - if !strings.Contains(listRes.Output, "priority: medium") { - t.Fatalf("expected default priority medium in output, got %q", listRes.Output) - } - - removeRes := tool.Run(map[string]interface{}{ - "action": "remove", - "id": task1ID, - }) - if removeRes == nil || !removeRes.Success { - t.Fatalf("expected remove success, got %#v", removeRes) - } - if !strings.Contains(removeRes.Output, "Removed task") { - t.Fatalf("unexpected remove output: %q", removeRes.Output) - } - - tool.Run(map[string]interface{}{"action": "add", "content": "task 2"}) - tool.Run(map[string]interface{}{"action": "add", "content": "task 3"}) - - clearRes := tool.Run(map[string]interface{}{ - "action": "clear", - }) - if clearRes == nil || !clearRes.Success { - t.Fatalf("expected clear success, got %#v", clearRes) - } - if clearRes.Output != "Todo list cleared" { - t.Fatalf("unexpected clear output: %q", clearRes.Output) - } - - emptyRes := tool.Run(map[string]interface{}{ - "action": "list", - }) - if emptyRes == nil || !emptyRes.Success { - t.Fatalf("expected list success, got %#v", emptyRes) - } - if emptyRes.Output != "Todo list is empty" { - t.Fatalf("unexpected empty output: %q", emptyRes.Output) - } -} diff --git a/internal/server/infra/tools/tool.go b/internal/server/infra/tools/tool.go deleted file mode 100644 index 0f00e69e..00000000 --- a/internal/server/infra/tools/tool.go +++ /dev/null @@ -1,87 +0,0 @@ -package tools - -import ( - "fmt" - "sort" - - "go-llm-demo/internal/server/domain" -) - -type ToolResult = domain.ToolResult -type Tool = domain.Tool -type ToolDefinition = domain.ToolDefinition -type ToolParamSpec = domain.ToolParamSpec - -// GlobalRegistry 是ToolRegistry的单例实例。 -var GlobalRegistry = NewToolRegistry() - -// ToolRegistry 管理工具的注册和检索。 -type ToolRegistry struct { - tools map[string]Tool -} - -// NewToolRegistry 创建并注册默认工具集合。 -func NewToolRegistry() *ToolRegistry { - r := &ToolRegistry{tools: make(map[string]Tool)} - r.Register(&ReadTool{}) - r.Register(&WriteTool{}) - r.Register(&EditTool{}) - r.Register(&BashTool{}) - r.Register(&ListTool{}) - r.Register(&GrepTool{}) - return r -} - -// Register 向注册表添加一个工具。 -func (r *ToolRegistry) Register(tool Tool) { - def := tool.Definition() - r.tools[def.Name] = tool -} - -// Get 根据名称从注册表中检索一个工具。 -func (r *ToolRegistry) Get(name string) Tool { - return r.tools[name] -} - -// ListDefinitions 返回所有已注册工具定义。 -func (r *ToolRegistry) ListDefinitions() []ToolDefinition { - defs := make([]ToolDefinition, 0, len(r.tools)) - for _, tool := range r.tools { - defs = append(defs, tool.Definition()) - } - sort.Slice(defs, func(i, j int) bool { return defs[i].Name < defs[j].Name }) - return defs -} - -// ListTools 返回所有已注册工具名称的切片。 -func (r *ToolRegistry) ListTools() []string { - defs := r.ListDefinitions() - keys := make([]string, 0, len(defs)) - for _, def := range defs { - keys = append(keys, def.Name) - } - return keys -} - -// Execute 执行指定工具并补齐基础元数据。 -func (r *ToolRegistry) Execute(call domain.ToolCall) *ToolResult { - tool := r.Get(call.Tool) - if tool == nil { - return &ToolResult{ToolName: call.Tool, Success: false, Error: fmt.Sprintf("不支持的工具: %s", call.Tool)} - } - // 统一规范参数命名,避免模型同时输出 snake_case 与 camelCase 时各工具重复兼容。 - params := NormalizeParams(call.Params) - result := tool.Run(params) - if result == nil { - return &ToolResult{ToolName: call.Tool, Success: false, Error: "工具未返回结果"} - } - if result.ToolName == "" { - result.ToolName = call.Tool - } - if result.Metadata == nil { - result.Metadata = map[string]interface{}{} - } - // 为后续日志、UI 展示和上下文回灌补齐最基础的工具元信息。 - result.Metadata["tool"] = call.Tool - return result -} diff --git a/internal/server/infra/tools/write.go b/internal/server/infra/tools/write.go deleted file mode 100644 index 65338953..00000000 --- a/internal/server/infra/tools/write.go +++ /dev/null @@ -1,41 +0,0 @@ -package tools - -import ( - "fmt" -) - -type WriteTool struct{} - -func (w *WriteTool) Definition() ToolDefinition { - return ToolDefinition{ - Name: "write", - Description: "Write entire file content in the workspace. Automatically creates parent directories if they do not exist.", - Parameters: []ToolParamSpec{ - {Name: "filePath", Type: "string", Required: true, Description: "Target file path within the workspace."}, - {Name: "content", Type: "string", Required: true, Description: "The complete new content to be written to the file."}, - }, - } -} - -func (w *WriteTool) Run(params map[string]interface{}) *ToolResult { - filePath, errRes := requiredString(params, "filePath") - if errRes != nil { - errRes.ToolName = w.Definition().Name - return errRes - } - filePath, pathErr := ensureWorkspacePath(filePath) - if pathErr != nil { - pathErr.ToolName = w.Definition().Name - return pathErr - } - content, errRes := requiredString(params, "content") - if errRes != nil { - errRes.ToolName = w.Definition().Name - return errRes - } - - if err := AtomicWrite(filePath, []byte(content)); err != nil { - return &ToolResult{ToolName: w.Definition().Name, Success: false, Error: fmt.Sprintf("failed to write file: %v", err)} - } - return &ToolResult{ToolName: w.Definition().Name, Success: true, Output: fmt.Sprintf("Successfully wrote to %s", filePath), Metadata: map[string]interface{}{"filePath": filePath, "bytesWritten": len(content)}} -} diff --git a/internal/server/service/chat_service.go b/internal/server/service/chat_service.go deleted file mode 100644 index 02e3f98a..00000000 --- a/internal/server/service/chat_service.go +++ /dev/null @@ -1,152 +0,0 @@ -package service - -import ( - "context" - "fmt" - "strings" - - "go-llm-demo/internal/server/domain" -) - -type chatServiceImpl struct { - memorySvc domain.MemoryService - workingSvc domain.WorkingMemoryService - todoSvc domain.TodoService - roleSvc domain.RoleService - chatProvider domain.ChatProvider -} - -// NewChatService 使用记忆、角色、任务清单和模型提供方依赖创建聊天服务。 -func NewChatService(memorySvc domain.MemoryService, workingSvc domain.WorkingMemoryService, todoSvc domain.TodoService, roleSvc domain.RoleService, chatProvider domain.ChatProvider) domain.ChatGateway { - return &chatServiceImpl{ - memorySvc: memorySvc, - workingSvc: workingSvc, - todoSvc: todoSvc, - roleSvc: roleSvc, - chatProvider: chatProvider, - } -} - -// Send 为消息补充角色和记忆上下文后发起流式回复。 -func (s *chatServiceImpl) Send(ctx context.Context, req *domain.ChatRequest) (<-chan string, error) { - messages := req.Messages - - rolePrompt, err := s.roleSvc.GetActivePrompt(ctx) - if err != nil { - fmt.Printf("获取角色提示失败:%v\n", err) - } else if rolePrompt != "" { - hasSystem := false - for _, msg := range messages { - if msg.Role == "system" { - hasSystem = true - break - } - } - - if !hasSystem { - // 角色提示总是放在最前面的 system 消息里,便于后续继续叠加记忆上下文。 - messages = append([]domain.Message{{Role: "system", Content: rolePrompt}}, messages...) - } - } - - userInput := s.latestUserInput(messages) - workingContext := "" - if s.workingSvc != nil { - workingContext, err = s.workingSvc.BuildContext(ctx, messages) - if err != nil { - return nil, err - } - } - todoContext := "" - if s.todoSvc != nil { - todos, _ := s.todoSvc.ListTodos(ctx) - todoContext = buildTodoContext(todos) - } - - blocks := []string{workingContext, todoContext} - if userInput != "" { - memoryContext, ctxErr := s.memorySvc.BuildContext(ctx, userInput) - if ctxErr != nil { - return nil, ctxErr - } - blocks = append(blocks, memoryContext) - } - combinedContext := joinContextBlocks(blocks...) - if combinedContext != "" { - messages = injectSystemContext(messages, rolePrompt, combinedContext) - } - - out, err := s.chatProvider.Chat(ctx, messages) - if err != nil { - return nil, err - } - - resultChan := make(chan string) - go func() { - defer close(resultChan) - - var replyBuilder strings.Builder - for chunk := range out { - replyBuilder.WriteString(chunk) - resultChan <- chunk - } - - if s.latestUserInput(messages) != "" && replyBuilder.Len() > 0 { - if s.workingSvc != nil { - // 工作记忆刷新要基于用户原始消息序列,而不是注入过 system 上下文后的 messages, - // 否则会把内部提示也误当成真实对话历史。 - updatedMessages := append([]domain.Message{}, req.Messages...) - updatedMessages = append(updatedMessages, domain.Message{Role: "assistant", Content: replyBuilder.String()}) - if err := s.workingSvc.Refresh(context.Background(), updatedMessages); err != nil { - fmt.Printf("工作记忆刷新失败:%v\n", err) - } - } - if err := s.memorySvc.Save(context.Background(), s.latestUserInput(messages), replyBuilder.String()); err != nil { - fmt.Printf("记忆保存失败:%v\n", err) - } - } - }() - - return resultChan, nil -} - -func (s *chatServiceImpl) latestUserInput(messages []domain.Message) string { - for i := len(messages) - 1; i >= 0; i-- { - if messages[i].Role == "user" { - return strings.TrimSpace(messages[i].Content) - } - } - return "" -} - -func buildTodoContext(todos []domain.Todo) string { - if len(todos) == 0 { - return "" - } - var sb strings.Builder - sb.WriteString("[TODO_LIST]\n") - for _, todo := range todos { - sb.WriteString(fmt.Sprintf("- %s: %s (status: %s, priority: %s)\n", todo.ID, todo.Content, todo.Status, todo.Priority)) - } - return sb.String() -} - -func injectSystemContext(messages []domain.Message, rolePrompt, combinedContext string) []domain.Message { - if rolePrompt != "" && len(messages) > 0 && messages[0].Role == "system" { - messages[0].Content = rolePrompt + "\n\n" + combinedContext - return messages - } - return append([]domain.Message{{Role: "system", Content: combinedContext}}, messages...) -} - -func joinContextBlocks(blocks ...string) string { - filtered := make([]string, 0, len(blocks)) - for _, block := range blocks { - block = strings.TrimSpace(block) - if block == "" { - continue - } - filtered = append(filtered, block) - } - return strings.Join(filtered, "\n\n") -} diff --git a/internal/server/service/memory_service.go b/internal/server/service/memory_service.go deleted file mode 100644 index e04b91fe..00000000 --- a/internal/server/service/memory_service.go +++ /dev/null @@ -1,648 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "fmt" - "sort" - "strconv" - "strings" - "time" - - "go-llm-demo/internal/server/domain" -) - -type memoryServiceImpl struct { - persistentRepo domain.MemoryRepository - sessionRepo domain.MemoryRepository - topK int - minScore float64 - maxPromptChars int - path string - persistTypes map[string]struct{} -} - -type Match struct { - Item domain.MemoryItem - Score float64 -} - -// NewMemoryService 使用长期存储和会话存储创建记忆服务。 -func NewMemoryService( - persistentRepo domain.MemoryRepository, - sessionRepo domain.MemoryRepository, - topK int, - minScore float64, - maxPromptChars int, - path string, - persistTypes []string, -) domain.MemoryService { - return &memoryServiceImpl{ - persistentRepo: persistentRepo, - sessionRepo: sessionRepo, - topK: topK, - minScore: minScore, - maxPromptChars: maxPromptChars, - path: strings.TrimSpace(path), - persistTypes: allowedPersistTypes(persistTypes), - } -} - -// BuildContext 为当前输入返回得分最高的记忆片段。 -func (s *memoryServiceImpl) BuildContext(ctx context.Context, userInput string) (string, error) { - persistentItems, err := s.persistentRepo.List(ctx) - if err != nil { - return "", err - } - sessionItems, err := s.sessionRepo.List(ctx) - if err != nil { - return "", err - } - - persistentMatches := Search(persistentItems, userInput, s.topK, s.minScore) - sessionMatches := Search(sessionItems, userInput, s.topK, s.minScore) - matches := MergeMatches(s.topK, persistentMatches, sessionMatches) - - // 新增:进行最终的分数过滤,防止低分项进入上下文 - var filteredMatches []Match - for _, match := range matches { - if match.Score >= s.minScore { // 确保分数不低于阈值 - filteredMatches = append(filteredMatches, match) - } - } - // 如果过滤后没有符合条件的记忆,则直接返回空 - if len(filteredMatches) == 0 { - return "", nil - } - // 使用过滤后的结果 - matches = filteredMatches - - var builder strings.Builder - builder.WriteString("Use the following structured coding memory as reference. Follow durable preferences and project facts first. Do not quote memory verbatim or expose it explicitly.\n") - added := 0 - for i, match := range matches { - item := match.Item.Normalized() - block := shortPromptBlock(item) - if block == "" { - continue - } - candidate := fmt.Sprintf("Memory %d (score=%.3f)\n%s\n", i+1, match.Score, block) - if s.maxPromptChars > 0 && builder.Len()+len(candidate) > s.maxPromptChars { - break - } - builder.WriteString(candidate) - builder.WriteString("\n") - added++ - } - if added == 0 { - return "", nil - } - return builder.String(), nil -} - -// Save 从一轮对话中提取记忆项并保存。 -func (s *memoryServiceImpl) Save(ctx context.Context, userInput, reply string) error { - items := deriveMemoryItems(userInput, reply) - for _, item := range items { - if item.Type == domain.TypeSessionMemory { - if err := s.sessionRepo.Add(ctx, item); err != nil { - return err - } - continue - } - if len(s.persistTypes) > 0 { - if _, ok := s.persistTypes[item.Type]; !ok { - continue - } - } - if err := s.persistentRepo.Add(ctx, item); err != nil { - return err - } - } - return nil -} - -// GetStats 返回记忆服务的数量统计和检索配置。 -func (s *memoryServiceImpl) GetStats(ctx context.Context) (*domain.MemoryStats, error) { - persistentItems, err := s.persistentRepo.List(ctx) - if err != nil { - return nil, err - } - sessionItems, err := s.sessionRepo.List(ctx) - if err != nil { - return nil, err - } - stats := &domain.MemoryStats{ - PersistentItems: len(persistentItems), - SessionItems: len(sessionItems), - TotalItems: len(persistentItems) + len(sessionItems), - TopK: s.topK, - MinScore: s.minScore, - Path: s.path, - ByType: countMemoryTypes(persistentItems, sessionItems), - } - return stats, nil -} - -// Clear 清空所有长期记忆项。 -func (s *memoryServiceImpl) Clear(ctx context.Context) error { - return s.persistentRepo.Clear(ctx) -} - -// ClearSession 清空所有会话级记忆项。 -func (s *memoryServiceImpl) ClearSession(ctx context.Context) error { - return s.sessionRepo.Clear(ctx) -} - -// Search 对记忆项打分并返回与查询最相关的结果。 -func Search(items []domain.MemoryItem, query string, topK int, minScore float64) []Match { - trimmedQuery := strings.TrimSpace(query) - if topK <= 0 || trimmedQuery == "" { - return nil - } - - queryKeywords := domain.Keywords(trimmedQuery) - queryFrags := queryFragments(trimmedQuery) - queryText := strings.ToLower(trimmedQuery) - matches := make([]Match, 0, len(items)) - - for _, raw := range items { - item := raw.Normalized() - score := scoreItem(item, queryText, queryKeywords, queryFrags) - if score < minScore { - continue - } - matches = append(matches, Match{Item: item, Score: score}) - } - - sortMatches(matches) - if len(matches) > topK { - matches = matches[:topK] - } - return matches -} - -// MergeMatches 对多个匹配结果分组去重并重新排序。 -func MergeMatches(topK int, groups ...[]Match) []Match { - merged := make([]Match, 0) - seen := map[string]Match{} - for _, group := range groups { - for _, match := range group { - key := matchKey(match.Item) - if existing, ok := seen[key]; ok { - if match.Score > existing.Score { - seen[key] = match - } - continue - } - seen[key] = match - } - } - for _, match := range seen { - merged = append(merged, match) - } - sortMatches(merged) - if topK > 0 && len(merged) > topK { - merged = merged[:topK] - } - return merged -} - -func sortMatches(matches []Match) { - sort.Slice(matches, func(i, j int) bool { - if matches[i].Score != matches[j].Score { - return matches[i].Score > matches[j].Score - } - leftPriority := priorityForType(matches[i].Item.Type) - rightPriority := priorityForType(matches[j].Item.Type) - if leftPriority != rightPriority { - return leftPriority > rightPriority - } - return matches[i].Item.UpdatedAt.After(matches[j].Item.UpdatedAt) - }) -} - -func scoreItem(item domain.MemoryItem, queryText string, queryKeywords []string, queryFrags []string) float64 { - searchText := strings.ToLower(item.SearchText()) - if searchText == "" { - return 0 - } - - var score float64 - matched := false - tagSet := make(map[string]struct{}, len(item.Tags)) - for _, tag := range item.Tags { - tagSet[strings.ToLower(tag)] = struct{}{} - } - - for _, keyword := range queryKeywords { - if _, ok := tagSet[keyword]; ok { - score += 2.6 - matched = true - } - if strings.Contains(searchText, keyword) { - score += keywordWeight(keyword) - matched = true - } - } - - for _, frag := range queryFrags { - if len(frag) < 2 { - continue - } - if strings.Contains(searchText, frag) { - score += 0.55 - matched = true - } - } - - if item.Summary != "" && strings.Contains(strings.ToLower(item.Summary), queryText) { - score += 3.2 - matched = true - } - if item.Type != "" && strings.Contains(queryText, strings.ToLower(item.Type)) { - score += 2.2 - matched = true - } - if strings.Contains(searchText, queryText) { - score += 1.3 - matched = true - } - if !matched { - return 0 - } - - score += float64(priorityForType(item.Type)) * 0.9 - score += item.Confidence * 0.8 - return score -} - -func priorityForType(itemType string) int { - switch itemType { - case domain.TypeUserPreference: - return 5 - case domain.TypeProjectRule: - return 4 - case domain.TypeCodeFact: - return 3 - case domain.TypeFixRecipe: - return 2 - case domain.TypeSessionMemory: - return 1 - case domain.TypeLegacyChat: - return 0 - default: - return 0 - } -} - -func keywordWeight(keyword string) float64 { - weight := 1.4 - if strings.Contains(keyword, "/") || strings.Contains(keyword, ".") { - weight += 1.4 - } - if strings.HasPrefix(keyword, "go") || strings.Contains(keyword, "config") || strings.Contains(keyword, "yaml") { - weight += 0.8 - } - if len(keyword) >= 8 { - weight += 0.4 - } - return weight -} - -func queryFragments(query string) []string { - query = strings.TrimSpace(strings.ToLower(query)) - if query == "" { - return nil - } - runes := []rune(query) - if len(runes) <= 4 { - return []string{query} - } - fragments := make([]string, 0, len(runes)) - seen := map[string]struct{}{} - for size := 2; size <= 3; size++ { - for i := 0; i+size <= len(runes); i++ { - frag := strings.TrimSpace(string(runes[i : i+size])) - if len([]rune(frag)) < 2 { - continue - } - if _, ok := seen[frag]; ok { - continue - } - seen[frag] = struct{}{} - fragments = append(fragments, frag) - } - } - return fragments -} - -func matchKey(item domain.MemoryItem) string { - normalized := item.Normalized() - return normalized.Type + "::" + normalized.Scope + "::" + normalized.Summary -} - -func buildMemoryText(userInput, assistantReply string) string { - return strings.TrimSpace(userInput) + "\n" + strings.TrimSpace(assistantReply) -} - -func deriveMemoryItems(userInput, assistantReply string) []domain.MemoryItem { - if shouldSkipMemoryCapture(userInput, assistantReply) { - return nil - } - - now := time.Now().UTC() - items := make([]domain.MemoryItem, 0, 4) - - if preferenceItem, ok := extractPreferenceMemory(userInput, assistantReply, now); ok { - items = append(items, preferenceItem) - } - if ruleItem, ok := extractProjectRuleMemory(userInput, assistantReply, now); ok { - items = append(items, ruleItem) - } - if codeFactItem, ok := extractCodeFactMemory(userInput, assistantReply, now); ok { - items = append(items, codeFactItem) - } - if failureItem, ok := extractFixRecipeMemory(userInput, assistantReply, now); ok { - items = append(items, failureItem) - } - if mainItem, ok := extractSessionMemory(userInput, assistantReply, now); ok { - items = append(items, mainItem) - } - - return dedupeMemoryItems(items) -} - -func extractSessionMemory(userInput, assistantReply string, now time.Time) (domain.MemoryItem, bool) { - combined := buildMemoryText(userInput, assistantReply) - if !isCodingRelevant(userInput, assistantReply) || looksLikeStableInstruction(userInput) || looksLikeProjectFact(userInput, assistantReply) { - return domain.MemoryItem{}, false - } - - summary := domain.SummarizeText(userInput, 140) - if summary == "" { - summary = domain.SummarizeText(assistantReply, 140) - } - - return newMemoryItem(now, domain.TypeSessionMemory, domain.ScopeSession, summary, assistantReply, userInput, assistantReply, combined, 0.66), true -} - -func extractPreferenceMemory(userInput, assistantReply string, now time.Time) (domain.MemoryItem, bool) { - trimmed := strings.TrimSpace(userInput) - if trimmed == "" || !looksLikeStableInstruction(trimmed) { - return domain.MemoryItem{}, false - } - - summary := domain.SummarizeText(trimmed, 140) - return newMemoryItem(now, domain.TypeUserPreference, domain.ScopeUser, summary, assistantReply, userInput, assistantReply, buildMemoryText(userInput, assistantReply), 0.95), true -} - -func extractProjectRuleMemory(userInput, assistantReply string, now time.Time) (domain.MemoryItem, bool) { - combined := strings.ToLower(buildMemoryText(userInput, assistantReply)) - if !looksLikeProjectFact(userInput, assistantReply) { - return domain.MemoryItem{}, false - } - if looksLikeStableInstruction(userInput) && !hasProjectRuleAnchor(combined) { - return domain.MemoryItem{}, false - } - if !hasProjectRuleSignal(combined) { - return domain.MemoryItem{}, false - } - summary := domain.SummarizeText(firstNonEmptyLine(userInput, assistantReply), 140) - return newMemoryItem(now, domain.TypeProjectRule, domain.ScopeProject, summary, assistantReply, userInput, assistantReply, buildMemoryText(userInput, assistantReply), 0.9), true -} - -func extractCodeFactMemory(userInput, assistantReply string, now time.Time) (domain.MemoryItem, bool) { - combined := buildMemoryText(userInput, assistantReply) - if !looksLikeCodeKnowledge(userInput, assistantReply) { - return domain.MemoryItem{}, false - } - if containsAnyFold(strings.ToLower(userInput), "帮我", "请你", "写一个", "实现一个") && !containsAnyFold(combined, "在 ", "位于", "负责", "调用", "使用", "路径", "文件", "函数", "模块", "返回", "读取", "写入") { - return domain.MemoryItem{}, false - } - summary := domain.SummarizeText(firstNonEmptyLine(assistantReply, userInput), 140) - return newMemoryItem(now, domain.TypeCodeFact, domain.ScopeProject, summary, assistantReply, userInput, assistantReply, combined, 0.82), true -} - -func extractFixRecipeMemory(userInput, assistantReply string, now time.Time) (domain.MemoryItem, bool) { - combined := strings.ToLower(buildMemoryText(userInput, assistantReply)) - hasProblem := containsAnyFold(combined, "error", "failed", "panic", "bug", "报错", "失败", "异常") - hasFix := containsAnyFold(combined, "修复", "已通过", "解决", "fixed", "use", "改为", "增加", "remove", "replace") - if !hasProblem || !hasFix { - return domain.MemoryItem{}, false - } - summary := domain.SummarizeText(firstNonEmptyLine(userInput, assistantReply), 140) - details := assistantReply - if details == "" { - details = userInput - } - return newMemoryItem(now, domain.TypeFixRecipe, domain.ScopeProject, summary, details, userInput, assistantReply, buildMemoryText(userInput, assistantReply), 0.8), true -} - -func newMemoryItem(now time.Time, itemType, scope, summary, details, userInput, assistantReply, text string, confidence float64) domain.MemoryItem { - item := domain.MemoryItem{ - ID: strconv.FormatInt(now.UnixNano(), 10) + "-" + itemType, - Type: itemType, - Summary: strings.TrimSpace(summary), - Details: domain.SummarizeText(details, 220), - Scope: scope, - Tags: domain.InferTags(summary + "\n" + details), - Source: "conversation", - Confidence: confidence, - Text: strings.TrimSpace(text), - CreatedAt: now, - UpdatedAt: now, - UserInput: strings.TrimSpace(userInput), - AssistantReply: strings.TrimSpace(assistantReply), - } - return item.Normalized() -} - -func isCodingRelevant(userInput, assistantReply string) bool { - combined := strings.ToLower(buildMemoryText(userInput, assistantReply)) - if containsAnyFold(combined, - "function", "file", "repo", "project", "build", "test", "config", "bug", "error", "fix", - "golang", "go ", "yaml", "json", "memory", "prompt", "cli", "agent", "编程", "项目", "配置", "测试", "构建", "报错", "修复") { - return true - } - trimmedUser := strings.TrimSpace(strings.ToLower(userInput)) - return len(trimmedUser) > 20 && containsAnyFold(trimmedUser, "code", "代码") -} - -func looksLikeProjectFact(userInput, assistantReply string) bool { - combined := strings.ToLower(buildMemoryText(userInput, assistantReply)) - return hasProjectRuleAnchor(combined) && hasProjectRuleSignal(combined) -} - -func looksLikeCodeKnowledge(userInput, assistantReply string) bool { - combined := buildMemoryText(userInput, assistantReply) - if !isCodingRelevant(userInput, assistantReply) { - return false - } - - hasCodeAnchor := containsAnyFold(combined, - ".go", "config.yaml", "main.go", "services/", "memory/", "json", "yaml", - "function", "func", "struct", "interface", "method", "package", "import", - "函数", "文件", "模块", "包", "结构体", "接口", "方法", "字段", "参数", "路径", "目录") - if !hasCodeAnchor { - return false - } - - trimmedUser := strings.ToLower(strings.TrimSpace(userInput)) - trimmedReply := strings.ToLower(strings.TrimSpace(assistantReply)) - hasQuestionIntent := containsAnyFold(trimmedUser, - "什么", "干嘛", "作用", "怎么", "如何", "why", "where", "which", "负责", "在哪", "含义", "区别") - hasExplanation := containsAnyFold(trimmedReply, - "用于", "负责", "位于", "表示", "通过", "调用", "读取", "写入", "返回", "实现", "处理", "对应", "配置", "路径", "字段", "参数") - - return hasQuestionIntent || hasExplanation || len(strings.TrimSpace(assistantReply)) >= 48 -} - -func looksLikeStableInstruction(text string) bool { - trimmed := strings.TrimSpace(strings.ToLower(text)) - if trimmed == "" { - return false - } - if !containsAnyFold(trimmed, "默认", "始终", "以后", "统一", "不要自动", "回答中文", "只用", "只使用", "只需要", "不要再", "固定", "长期") { - return false - } - return containsAnyFold(trimmed, "config.yaml", ".env", "中文", "提交", "命令", "风格", "配置") -} - -func shouldSkipMemoryCapture(userInput, assistantReply string) bool { - trimmedUser := strings.TrimSpace(userInput) - trimmedReply := strings.TrimSpace(assistantReply) - if trimmedUser == "" || trimmedReply == "" { - return true - } - return looksLikeToolCallPayload(trimmedReply) -} - -func looksLikeToolCallPayload(text string) bool { - trimmed := strings.TrimSpace(text) - if trimmed == "" || !strings.HasPrefix(trimmed, "{") { - return false - } - - var payload map[string]json.RawMessage - if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { - return false - } - - toolValue, hasTool := payload["tool"] - paramsValue, hasParams := payload["params"] - if !hasTool || !hasParams { - return false - } - - var toolName string - if err := json.Unmarshal(toolValue, &toolName); err != nil { - return false - } - - var params map[string]interface{} - return strings.TrimSpace(toolName) != "" && json.Unmarshal(paramsValue, ¶ms) == nil -} - -func hasProjectRuleAnchor(text string) bool { - return containsAnyFold(text, - "config.yaml", "readme", "go test", "go build", - "cmd/", "internal/", "configs/", "services/", "memory/", "main.go", - "data/", "workspace", "工作区", "根目录", "主配置文件", "文件", "路径") -} - -func hasProjectRuleSignal(text string) bool { - return containsAnyFold(text, - "项目", "仓库", "约定", "配置", "结构", "目录", "命令", - "默认", "统一", "必须", "需要", "测试命令", "构建命令") -} - -func containsAnyFold(text string, needles ...string) bool { - for _, needle := range needles { - if strings.Contains(strings.ToLower(text), strings.ToLower(needle)) { - return true - } - } - return false -} - -func firstNonEmptyLine(values ...string) string { - for _, value := range values { - for _, line := range strings.Split(value, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - return trimmed - } - } - } - return "" -} - -func allowedPersistTypes(configured []string) map[string]struct{} { - allowed := map[string]struct{}{} - for _, itemType := range configured { - itemType = normalizeMemoryType(itemType) - if domain.IsPersistentType(itemType) { - allowed[itemType] = struct{}{} - } - } - if len(allowed) == 0 { - allowed[domain.TypeUserPreference] = struct{}{} - allowed[domain.TypeProjectRule] = struct{}{} - allowed[domain.TypeCodeFact] = struct{}{} - allowed[domain.TypeFixRecipe] = struct{}{} - } - return allowed -} - -func normalizeMemoryType(itemType string) string { - switch strings.TrimSpace(itemType) { - case "project_memory": - return domain.TypeProjectRule - case "failure_note": - return domain.TypeFixRecipe - default: - return strings.TrimSpace(itemType) - } -} - -func shortPromptBlock(item domain.MemoryItem) string { - item = item.Normalized() - parts := []string{ - "Type: " + item.Type, - "Summary: " + item.Summary, - } - if item.Details != "" { - parts = append(parts, "Details: "+domain.SummarizeText(item.Details, 140)) - } - if len(item.Tags) > 0 { - parts = append(parts, "Tags: "+strings.Join(item.Tags, ", ")) - } - return strings.Join(parts, "\n") -} - -func dedupeMemoryItems(items []domain.MemoryItem) []domain.MemoryItem { - if len(items) == 0 { - return nil - } - seen := map[string]domain.MemoryItem{} - for _, item := range items { - key := item.Type + "::" + item.Scope + "::" + strings.ToLower(strings.TrimSpace(item.Summary)) - seen[key] = item - } - result := make([]domain.MemoryItem, 0, len(seen)) - for _, item := range seen { - result = append(result, item) - } - return result -} - -func countMemoryTypes(groups ...[]domain.MemoryItem) map[string]int { - counts := map[string]int{} - for _, group := range groups { - for _, item := range group { - counts[item.Normalized().Type]++ - } - } - return counts -} diff --git a/internal/server/service/memory_service_test.go b/internal/server/service/memory_service_test.go deleted file mode 100644 index 621393da..00000000 --- a/internal/server/service/memory_service_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package service - -import ( - "context" - "strings" - "testing" - - "go-llm-demo/internal/server/infra/repository" -) - -func TestMemoryServiceSavesAndRecallsPersistentMemory(t *testing.T) { - ctx := context.Background() - path := t.TempDir() + "/memory.json" - - svc := NewMemoryService( - repository.NewFileMemoryStore(path, 100), - repository.NewSessionMemoryStore(100), - 5, - 2.2, - 1800, - path, - []string{"user_preference", "project_rule", "code_fact", "fix_recipe"}, - ) - - if err := svc.Save(ctx, "以后回答中文,命令和说明都用中文。", "好的,后续我会统一使用中文回复。"); err != nil { - t.Fatalf("save preference: %v", err) - } - if err := svc.Save(ctx, "memory_repository.go 是做什么的?", "internal/server/infra/repository/memory_repository.go 负责长期记忆的文件存储与读写。"); err != nil { - t.Fatalf("save code fact: %v", err) - } - - stats, err := svc.GetStats(ctx) - if err != nil { - t.Fatalf("get stats: %v", err) - } - if stats.PersistentItems != 2 { - t.Fatalf("expected 2 persistent items, got %+v", stats) - } - if stats.ByType["user_preference"] != 1 { - t.Fatalf("expected 1 user_preference, got %+v", stats.ByType) - } - if stats.ByType["code_fact"] != 1 { - t.Fatalf("expected 1 code_fact, got %+v", stats.ByType) - } - if stats.ByType["project_rule"] != 0 { - t.Fatalf("expected no project_rule for preference-only input, got %+v", stats.ByType) - } - - reloaded := NewMemoryService( - repository.NewFileMemoryStore(path, 100), - repository.NewSessionMemoryStore(100), - 5, - 2.2, - 1800, - path, - []string{"user_preference", "project_rule", "code_fact", "fix_recipe"}, - ) - - prompt, err := reloaded.BuildContext(ctx, "请继续用中文,并看看 memory_repository.go 的职责") - if err != nil { - t.Fatalf("build context: %v", err) - } - if !strings.Contains(prompt, "user_preference") { - t.Fatalf("expected recalled user_preference in prompt, got %q", prompt) - } - if !strings.Contains(prompt, "code_fact") { - t.Fatalf("expected recalled code_fact in prompt, got %q", prompt) - } -} - -func TestMemoryServiceSkipsToolCallPayload(t *testing.T) { - ctx := context.Background() - path := t.TempDir() + "/memory.json" - - svc := NewMemoryService( - repository.NewFileMemoryStore(path, 100), - repository.NewSessionMemoryStore(100), - 5, - 2.2, - 1800, - path, - []string{"user_preference", "project_rule", "code_fact", "fix_recipe"}, - ) - - reply := `{"tool":"read","params":{"filePath":"internal/server/service/memory_service.go"}}` - if err := svc.Save(ctx, "请读取 memory_service.go 看看记忆模块怎么实现的。", reply); err != nil { - t.Fatalf("save tool payload: %v", err) - } - - stats, err := svc.GetStats(ctx) - if err != nil { - t.Fatalf("get stats: %v", err) - } - if stats.TotalItems != 0 { - t.Fatalf("expected tool payload to be skipped, got %+v", stats) - } -} diff --git a/internal/server/service/role_service.go b/internal/server/service/role_service.go deleted file mode 100644 index cdfa39b1..00000000 --- a/internal/server/service/role_service.go +++ /dev/null @@ -1,96 +0,0 @@ -package service - -import ( - "context" - "fmt" - "sync" - "time" - - "go-llm-demo/configs" - "go-llm-demo/internal/server/domain" -) - -type roleServiceImpl struct { - repo domain.RoleRepository - activeRole *domain.Role - mu sync.RWMutex - defaultPath string -} - -// NewRoleService 使用给定仓储创建角色服务。 -func NewRoleService(repo domain.RoleRepository, defaultPath string) domain.RoleService { - return &roleServiceImpl{ - repo: repo, - defaultPath: defaultPath, - } -} - -// GetActivePrompt 返回当前激活角色的提示词,必要时回退到默认文件。 -func (s *roleServiceImpl) GetActivePrompt(ctx context.Context) (string, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if s.activeRole != nil { - return s.activeRole.Prompt, nil - } - - if s.defaultPath != "" { - return s.loadFromFile(s.defaultPath) - } - - return "", nil -} - -// SetActive 将指定角色设为当前激活角色。 -func (s *roleServiceImpl) SetActive(ctx context.Context, roleID string) error { - s.mu.Lock() - defer s.mu.Unlock() - - role, err := s.repo.GetByID(ctx, roleID) - if err != nil { - return err - } - - s.activeRole = role - return nil -} - -// List 返回所有已存储的角色。 -func (s *roleServiceImpl) List(ctx context.Context) ([]domain.Role, error) { - return s.repo.List(ctx) -} - -// Create 创建并保存一个新角色后返回它。 -func (s *roleServiceImpl) Create(ctx context.Context, name, desc, prompt string) (*domain.Role, error) { - role := &domain.Role{ - ID: fmt.Sprintf("role_%d", time.Now().UnixNano()), - Name: name, - Description: desc, - Prompt: prompt, - } - - if err := s.repo.Save(ctx, role); err != nil { - return nil, err - } - - return role, nil -} - -// Delete 删除指定角色,并在其处于激活状态时一并清除。 -func (s *roleServiceImpl) Delete(ctx context.Context, id string) error { - s.mu.Lock() - if s.activeRole != nil && s.activeRole.ID == id { - s.activeRole = nil - } - s.mu.Unlock() - - return s.repo.Delete(ctx, id) -} - -func (s *roleServiceImpl) loadFromFile(path string) (string, error) { - prompt, _, err := configs.LoadPersonaPrompt(path) - if err != nil { - return "", nil - } - return prompt, nil -} diff --git a/internal/server/service/security_service.go b/internal/server/service/security_service.go deleted file mode 100644 index ed65f8c6..00000000 --- a/internal/server/service/security_service.go +++ /dev/null @@ -1,133 +0,0 @@ -package service - -import ( - "path/filepath" - "regexp" - "strings" - - "go-llm-demo/internal/server/domain" - - "github.com/bmatcuk/doublestar/v4" -) - -// SecurityService provides security checks backed by configured rule sets. -type SecurityService struct { - configRepo domain.SecurityConfigRepository - blackList *domain.Config - whiteList *domain.Config - yellowList *domain.Config -} - -func NewSecurityService(configRepo domain.SecurityConfigRepository) *SecurityService { - return &SecurityService{ - configRepo: configRepo, - } -} - -func (s *SecurityService) Initialize(configDir string) error { - blackList, whiteList, yellowList, err := s.configRepo.LoadAll(configDir) - if err != nil { - return err - } - s.blackList = blackList - s.whiteList = whiteList - s.yellowList = yellowList - return nil -} - -func (s *SecurityService) Check(toolType string, target string) domain.Action { - normalizedTarget := target - - // 安全增强:对路径类操作进行规范化处理 - if toolType == "Read" || toolType == "Write" { - // 1. 清洗路径 (消除 ../, ./, /// 等绕过风险) - // 2. 统一斜杠 (Windows \ 转为 /) 以匹配 doublestar 规范 - normalizedTarget = filepath.ToSlash(filepath.Clean(target)) - - // 3. 边界防御:如果路径尝试跳出当前工作目录(以 .. 开头) - // 这通常是恶意的路径穿越尝试,直接拒绝。 - if strings.HasPrefix(normalizedTarget, "..") { - return domain.ActionDeny - } - } - - // 业务规则 1:黑名单优先级最高(直接拒绝) - if s.matchesList(s.blackList, toolType, normalizedTarget) { - return domain.ActionDeny - } - - // 业务规则 2:白名单次之(直接允许) - if s.matchesList(s.whiteList, toolType, normalizedTarget) { - return domain.ActionAllow - } - - // 业务规则 3:黄名单再次之(询问用户) - if s.matchesList(s.yellowList, toolType, normalizedTarget) { - return domain.ActionAsk - } - - // 兜底策略:默认询问用户 - return domain.ActionAsk -} - -func (s *SecurityService) matchesList(config *domain.Config, toolType, target string) bool { - if config == nil { - return false - } - - for _, rule := range config.Rules { - if ruleMatches(rule, toolType, target) { - return true - } - } - return false -} - -func ruleMatches(rule domain.Rule, toolType string, target string) bool { - var pattern string - var actionBit string - - switch toolType { - case "Read": - pattern = rule.Target - actionBit = rule.Read - case "Write": - pattern = rule.Target - actionBit = rule.Write - case "Bash": - pattern = rule.Command - actionBit = rule.Exec - case "Webfetch": - pattern = rule.Domain - actionBit = rule.Network - default: - return false - } - - // 必须同时具备匹配模式和对应的权限位设置,防止规则短路 - if pattern == "" || actionBit == "" { - return false - } - - if toolType == "Bash" { - return matchCommand(pattern, target) - } - - matched, err := doublestar.Match(pattern, target) - if err != nil { - return false - } - - return matched -} - -func matchCommand(pattern, command string) bool { - rePattern := regexp.QuoteMeta(pattern) - rePattern = strings.ReplaceAll(rePattern, `\*\*`, `.*`) - rePattern = strings.ReplaceAll(rePattern, `\*`, `.*`) - re, err := regexp.Compile("^" + rePattern + "$") - if err != nil { - return false - } - return re.MatchString(command) -} diff --git a/internal/server/service/security_service_test.go b/internal/server/service/security_service_test.go deleted file mode 100644 index 447d4038..00000000 --- a/internal/server/service/security_service_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package service - -import ( - "testing" - - "go-llm-demo/internal/server/domain" -) - -// --------------------------------------------------------- -// 第一部分:模拟对象 (Mock) -// --------------------------------------------------------- - -// mockSecurityConfigRepository 是一个“替身”对象 -// 它实现了 domain.SecurityConfigRepository 接口,用来模拟从文件加载配置的过程。 -// 这样我们在测试时,就不需要真的去磁盘上读那个 blacklist.yaml 了。 -type mockSecurityConfigRepository struct { - // 这里的三个字段分别代表黑、白、黄名单的内存数据 - black, white, yellow *domain.Config -} - -// LoadAll 是“替身”必须实现的合同方法。 -// 无论传入什么目录,它都直接返回我们在这里写死的数据。 -func (m *mockSecurityConfigRepository) LoadAll(configDir string) (*domain.Config, *domain.Config, *domain.Config, error) { - return m.black, m.white, m.yellow, nil -} - -// --------------------------------------------------------- -// 第二部分:单元测试主体 -// --------------------------------------------------------- - -func TestSecurityService_Check(t *testing.T) { - // 1. 准备测试用的“假规则” - mockRepo := &mockSecurityConfigRepository{ - // 黑名单:绝对禁区。 - black: &domain.Config{ - Rules: []domain.Rule{ - // 情况 A:全能封禁。读写都不行。 - {Target: ".git/**", Read: "deny", Write: "deny"}, - // 情况 B:部分封禁。只准看,不准改(为了演示穿透)。 - // 假设我们禁止读取 .env,但“漏掉了”禁止写入。 - {Target: "**/.env", Read: "deny"}, - // 情况 C:命令封禁。 - {Command: "rm -rf **", Exec: "deny"}, - }, - }, - // 白名单:信任区域。 - white: &domain.Config{ - Rules: []domain.Rule{ - {Target: "src/**/*.go", Read: "allow"}, - {Command: "go version", Exec: "allow"}, - }, - }, - // 黄名单:确认区域。 - yellow: &domain.Config{ - Rules: []domain.Rule{ - {Target: "src/**/*.go", Write: "ask"}, - {Command: "go build **", Exec: "ask"}, - }, - }, - } - - // 2. 初始化服务,并注入替身仓储 - svc := NewSecurityService(mockRepo) - if err := svc.Initialize(""); err != nil { - t.Fatalf("初始化失败: %v", err) - } - - // 3. 定义测试剧本矩阵(表格驱动测试) - // 我们把所有想测试的情况列在下面 - tests := []struct { - name string // 测试的名字 - toolType string // AI 试图调用的工具类型 (Read/Write/Bash/WebFetch) - target string // AI 操作的目标 (文件名 或 命令字符串) - want domain.Action // 我们期望拦截器给出的裁定 (deny/allow/ask) - }{ - // --- 黑名单拦截测试 --- - {"完全拦截-禁止读取Git", string(domain.ToolRead), ".git/config", domain.ActionDeny}, - {"完全拦截-禁止写入Git", string(domain.ToolWrite), ".git/HEAD", domain.ActionDeny}, - {"命令拦截-禁止删库", string(domain.ToolBash), "rm -rf /", domain.ActionDeny}, - - // --- 路径规范化/绕过攻击测试 (安全核心) --- - {"路径绕过-尝试跨出目录绕过黑名单", string(domain.ToolRead), "src/../.git/config", domain.ActionDeny}, - {"路径绕过-冗余斜杠绕过", string(domain.ToolRead), ".git///config", domain.ActionDeny}, - {"路径绕过-相对路径前缀", string(domain.ToolRead), "./.git/config", domain.ActionDeny}, - - // --- 规则穿透测试 --- - {"拦截-黑名单明确禁止Read", string(domain.ToolRead), "config/.env", domain.ActionDeny}, - {"穿透-黑名单未定义Write-落入兜底", string(domain.ToolWrite), "config/.env", domain.ActionAsk}, - - // --- 白名单与黄名单匹配深度测试 --- - {"白名单-允许阅读源码", string(domain.ToolRead), "src/main.go", domain.ActionAllow}, - {"白名单-深层目录匹配", string(domain.ToolRead), "src/internal/pkg/util.go", domain.ActionAllow}, - {"黄名单-修改代码需确认", string(domain.ToolWrite), "src/main.go", domain.ActionAsk}, - {"黄名单-命令匹配", string(domain.ToolBash), "go build main.go", domain.ActionAsk}, - {"白名单-精确命令匹配", string(domain.ToolBash), "go version", domain.ActionAllow}, - - // --- 网络与兜底策略 --- - {"网络-白名单域名子域", string(domain.ToolWebFetch), "api.google.com", domain.ActionAllow}, - {"网络-不在名单内域名", string(domain.ToolWebFetch), "hacker.com", domain.ActionAsk}, - {"兜底-完全未知工具", "SelfDestruct", "now", domain.ActionAsk}, - {"空目标字符串", string(domain.ToolRead), "", domain.ActionAsk}, - {"大小写敏感性测试", string(domain.ToolRead), ".GIT/config", domain.ActionAsk}, //依据实现,目前是敏感的 - } - - // 补充白名单网络规则以支持上面的测试 - mockRepo.white.Rules = append(mockRepo.white.Rules, domain.Rule{Domain: "*.google.com", Network: "allow"}) - - // 4. 开始自动化表演,逐条运行剧本 - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // 调用 Check 方法获取实际结果 - got := svc.Check(tt.toolType, tt.target) - - // 验证结果 - if got != tt.want { - t.Errorf("场景 [%s] 失败!\n 输入: %s(%s)\n 得到结果: %v\n 预期结果: %v", - tt.name, tt.toolType, tt.target, got, tt.want) - } - }) - } -} diff --git a/internal/server/service/todo_service.go b/internal/server/service/todo_service.go deleted file mode 100644 index 4d557681..00000000 --- a/internal/server/service/todo_service.go +++ /dev/null @@ -1,55 +0,0 @@ -package service - -import ( - "context" - "go-llm-demo/internal/server/domain" - "sort" - "strconv" - "strings" -) - -type todoServiceImpl struct { - repo domain.TodoRepository -} - -func NewTodoService(repo domain.TodoRepository) domain.TodoService { - return &todoServiceImpl{repo: repo} -} - -func (s *todoServiceImpl) AddTodo(ctx context.Context, content string, priority domain.TodoPriority) (*domain.Todo, error) { - todo := domain.Todo{ - Content: content, - Status: domain.TodoPending, - Priority: priority, - } - return s.repo.Add(ctx, todo) -} - -func (s *todoServiceImpl) UpdateTodoStatus(ctx context.Context, id string, status domain.TodoStatus) error { - return s.repo.UpdateStatus(ctx, id, status) -} - -func (s *todoServiceImpl) ListTodos(ctx context.Context) ([]domain.Todo, error) { - todos, err := s.repo.List(ctx) - if err != nil { - return nil, err - } - // 按 ID 排序以保证输出稳定性 - sort.Slice(todos, func(i, j int) bool { - return todoIDNum(todos[i].ID) < todoIDNum(todos[j].ID) - }) - return todos, nil -} - -func (s *todoServiceImpl) ClearTodos(ctx context.Context) error { - return s.repo.Clear(ctx) -} - -func todoIDNum(id string) int { - n, _ := strconv.Atoi(strings.TrimPrefix(id, "todo-")) - return n -} - -func (s *todoServiceImpl) RemoveTodo(ctx context.Context, id string) error { - return s.repo.Remove(ctx, id) -} diff --git a/internal/server/service/todo_service_test.go b/internal/server/service/todo_service_test.go deleted file mode 100644 index 43e253df..00000000 --- a/internal/server/service/todo_service_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package service - -import ( - "context" - "go-llm-demo/internal/server/domain" - "go-llm-demo/internal/server/infra/repository" - "testing" -) - -func setupTodoService() (domain.TodoService, *repository.InMemoryTodoRepository) { - repo := repository.NewInMemoryTodoRepository() - service := NewTodoService(repo) - return service, repo -} - -func TestTodoService_AddTodo(t *testing.T) { - service, _ := setupTodoService() - - todo, err := service.AddTodo(context.Background(), "test task 1", domain.TodoPriorityHigh) - if err != nil { - t.Fatalf("failed to add task: %v", err) - } - - if todo.ID == "" { - t.Fatal("task ID should not be empty") - } - if todo.Content != "test task 1" { - t.Errorf("expected content 'test task 1', got '%s'", todo.Content) - } - if todo.Status != domain.TodoPending { - t.Errorf("expected status 'pending', got '%s'", todo.Status) - } -} - -func TestTodoService_ListTodos(t *testing.T) { - service, _ := setupTodoService() - _, _ = service.AddTodo(context.Background(), "task 1", domain.TodoPriorityHigh) - _, _ = service.AddTodo(context.Background(), "task 2", domain.TodoPriorityLow) - - todos, err := service.ListTodos(context.Background()) - if err != nil { - t.Fatalf("failed to list tasks: %v", err) - } - - if len(todos) != 2 { - t.Fatalf("expected 2 tasks, got %d", len(todos)) - } - if todos[0].Content != "task 1" || todos[1].Content != "task 2" { - t.Error("task sorting or content incorrect") - } -} - -func TestTodoService_UpdateTodoStatus(t *testing.T) { - service, _ := setupTodoService() - todo, _ := service.AddTodo(context.Background(), "pending task", domain.TodoPriorityMedium) - - err := service.UpdateTodoStatus(context.Background(), todo.ID, domain.TodoCompleted) - if err != nil { - t.Fatalf("failed to update status: %v", err) - } - - todos, _ := service.ListTodos(context.Background()) - if todos[0].Status != domain.TodoCompleted { - t.Errorf("expected status 'completed', got '%s'", todos[0].Status) - } -} - -func TestTodoService_RemoveTodo(t *testing.T) { - service, _ := setupTodoService() - todo1, _ := service.AddTodo(context.Background(), "task 1", domain.TodoPriorityHigh) - _, _ = service.AddTodo(context.Background(), "task 2", domain.TodoPriorityLow) - - err := service.RemoveTodo(context.Background(), todo1.ID) - if err != nil { - t.Fatalf("failed to remove task: %v", err) - } - - todos, _ := service.ListTodos(context.Background()) - if len(todos) != 1 { - t.Fatalf("expected 1 task remaining, got %d", len(todos)) - } - if todos[0].Content != "task 2" { - t.Error("removed incorrect task") - } -} - -func TestTodoService_ClearTodos(t *testing.T) { - service, _ := setupTodoService() - _, _ = service.AddTodo(context.Background(), "task 1", domain.TodoPriorityHigh) - _, _ = service.AddTodo(context.Background(), "task 2", domain.TodoPriorityLow) - - err := service.ClearTodos(context.Background()) - if err != nil { - t.Fatalf("failed to clear tasks: %v", err) - } - - todos, _ := service.ListTodos(context.Background()) - if len(todos) != 0 { - t.Fatalf("expected empty task list, got %d tasks", len(todos)) - } -} - -func TestTodoService_UpdateTodoStatus_UnknownIDReturnsError(t *testing.T) { - service, _ := setupTodoService() - - err := service.UpdateTodoStatus(context.Background(), "todo-404", domain.TodoCompleted) - if err == nil { - t.Fatal("expected error for unknown todo id") - } -} - -func TestTodoService_RemoveTodo_UnknownIDReturnsError(t *testing.T) { - service, _ := setupTodoService() - - err := service.RemoveTodo(context.Background(), "todo-404") - if err == nil { - t.Fatal("expected error for unknown todo id") - } -} - -func TestTodoService_ListTodos_SortsByID(t *testing.T) { - service, _ := setupTodoService() - - t1, _ := service.AddTodo(context.Background(), "task 1", domain.TodoPriorityHigh) - t2, _ := service.AddTodo(context.Background(), "task 2", domain.TodoPriorityMedium) - t3, _ := service.AddTodo(context.Background(), "task 3", domain.TodoPriorityLow) - - _ = service.RemoveTodo(context.Background(), t2.ID) - _, _ = service.AddTodo(context.Background(), "task 4", domain.TodoPriorityLow) - - todos, err := service.ListTodos(context.Background()) - if err != nil { - t.Fatalf("failed to list tasks: %v", err) - } - if len(todos) != 3 { - t.Fatalf("expected 3 tasks, got %d", len(todos)) - } - - if todos[0].ID != t1.ID { - t.Fatalf("expected first todo id %q, got %q", t1.ID, todos[0].ID) - } - if todos[1].ID != t3.ID { - t.Fatalf("expected second todo id %q, got %q", t3.ID, todos[1].ID) - } -} diff --git a/internal/server/service/working_memory_service.go b/internal/server/service/working_memory_service.go deleted file mode 100644 index 7298beeb..00000000 --- a/internal/server/service/working_memory_service.go +++ /dev/null @@ -1,319 +0,0 @@ -package service - -import ( - "context" - "fmt" - "regexp" - "strings" - "time" - - "go-llm-demo/internal/server/domain" -) - -// fileRefPattern 只做轻量文件线索提取:允许相对路径、绝对路径前缀和常见文件名。 -// 这里宁可多抓一些候选,也不在工作记忆阶段做过重的路径校验。 -var fileRefPattern = regexp.MustCompile(`(?i)(?:[a-z]:\\|\./|\.\\|/)?[a-z0-9_./\\-]+\.[a-z0-9]+`) - -type workingMemoryServiceImpl struct { - repo domain.WorkingMemoryRepository - maxRecentTurns int - maxOpenQuestions int - maxRecentFiles int - workspaceRoot string -} - -// NewWorkingMemoryService 创建第一阶段的工作记忆服务。 -// 目标是把“最近几轮 + 当前任务 + 文件线索”整理成稳定的短期上下文。 -func NewWorkingMemoryService(repo domain.WorkingMemoryRepository, maxRecentTurns int, workspaceRoot string) domain.WorkingMemoryService { - if maxRecentTurns <= 0 { - maxRecentTurns = 6 - } - return &workingMemoryServiceImpl{ - repo: repo, - maxRecentTurns: maxRecentTurns, - maxOpenQuestions: 3, - maxRecentFiles: 6, - workspaceRoot: strings.TrimSpace(workspaceRoot), - } -} - -// BuildContext 刷新工作记忆并格式化为提示上下文。 -func (s *workingMemoryServiceImpl) BuildContext(ctx context.Context, messages []domain.Message) (string, error) { - if err := s.Refresh(ctx, messages); err != nil { - return "", err - } - state, err := s.Get(ctx) - if err != nil { - return "", err - } - return formatWorkingMemoryContext(state, s.workspaceRoot), nil -} - -// Refresh 根据当前消息重建工作记忆快照。 -func (s *workingMemoryServiceImpl) Refresh(ctx context.Context, messages []domain.Message) error { - state := s.buildState(messages) - return s.repo.Save(ctx, state) -} - -// Clear 清空当前工作记忆快照。 -func (s *workingMemoryServiceImpl) Clear(ctx context.Context) error { - return s.repo.Clear(ctx) -} - -// Get 返回当前工作记忆快照。 -func (s *workingMemoryServiceImpl) Get(ctx context.Context) (*domain.WorkingMemoryState, error) { - return s.repo.Get(ctx) -} - -func (s *workingMemoryServiceImpl) buildState(messages []domain.Message) *domain.WorkingMemoryState { - turns := collectRecentTurns(messages) - if len(turns) > s.maxRecentTurns { - turns = turns[len(turns)-s.maxRecentTurns:] - } - - currentTask := latestUserMessage(messages) - openQuestions := collectOpenQuestions(messages, s.maxOpenQuestions) - state := &domain.WorkingMemoryState{ - CurrentTask: currentTask, - TaskSummary: buildTaskSummary(turns, currentTask), - LastCompletedAction: inferLastCompletedAction(messages), - CurrentInProgress: inferCurrentInProgress(messages, currentTask), - NextStep: inferNextStep(messages, openQuestions, currentTask), - RecentTurns: turns, - OpenQuestions: openQuestions, - RecentFiles: collectRecentFiles(messages, s.maxRecentFiles), - UpdatedAt: time.Now().UTC(), - } - return state -} - -func collectRecentTurns(messages []domain.Message) []domain.WorkingMemoryTurn { - turns := make([]domain.WorkingMemoryTurn, 0) - var pendingUser string - for _, msg := range messages { - switch msg.Role { - case "user": - pendingUser = strings.TrimSpace(msg.Content) - case "assistant": - // 工作记忆按“一条 user + 下一条 assistant”配对, - // 这样即使中间混有 system/tool 消息,也不会污染最近轮次摘要。 - assistant := strings.TrimSpace(msg.Content) - if pendingUser == "" && assistant == "" { - continue - } - turns = append(turns, domain.WorkingMemoryTurn{ - User: pendingUser, - Assistant: assistant, - }) - pendingUser = "" - } - } - if pendingUser != "" { - turns = append(turns, domain.WorkingMemoryTurn{User: pendingUser}) - } - return turns -} - -func latestUserMessage(messages []domain.Message) string { - for i := len(messages) - 1; i >= 0; i-- { - if messages[i].Role == "user" { - return strings.TrimSpace(messages[i].Content) - } - } - return "" -} - -func buildTaskSummary(turns []domain.WorkingMemoryTurn, currentTask string) string { - if strings.TrimSpace(currentTask) != "" { - return domain.SummarizeText(currentTask, 160) - } - if len(turns) == 0 { - return "" - } - latest := turns[len(turns)-1] - if latest.User != "" { - return domain.SummarizeText(latest.User, 160) - } - if latest.Assistant != "" { - return domain.SummarizeText(latest.Assistant, 160) - } - return "" -} - -func inferLastCompletedAction(messages []domain.Message) string { - for i := len(messages) - 1; i >= 0; i-- { - msg := messages[i] - if msg.Role != "assistant" { - continue - } - for _, line := range strings.Split(msg.Content, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - if containsAnyFold(line, "已完成", "已修复", "已经", "完成了", "已处理", "修复了", "implemented", "fixed", "updated", "added", "created") { - return domain.SummarizeText(line, 140) - } - } - } - return "" -} - -func inferCurrentInProgress(messages []domain.Message, currentTask string) string { - trimmedTask := strings.TrimSpace(currentTask) - if trimmedTask == "" { - return "" - } - for i := len(messages) - 1; i >= 0; i-- { - msg := messages[i] - if msg.Role != "assistant" { - continue - } - content := strings.TrimSpace(msg.Content) - if content == "" { - continue - } - if containsAnyFold(content, "正在", "继续", "接下来", "当前", "处理中", "working on", "next", "continue") { - return domain.SummarizeText(content, 140) - } - break - } - return domain.SummarizeText(trimmedTask, 140) -} - -func inferNextStep(messages []domain.Message, openQuestions []string, currentTask string) string { - for i := len(messages) - 1; i >= 0; i-- { - msg := messages[i] - if msg.Role != "assistant" { - continue - } - for _, line := range strings.Split(msg.Content, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - if containsAnyFold(line, "下一步", "接下来", "建议", "可以继续", "后续", "next step", "next", "follow-up") { - return domain.SummarizeText(line, 140) - } - } - } - if len(openQuestions) > 0 { - return "先解决: " + domain.SummarizeText(openQuestions[0], 110) - } - if strings.TrimSpace(currentTask) != "" { - return "继续推进: " + domain.SummarizeText(currentTask, 110) - } - return "" -} - -func collectOpenQuestions(messages []domain.Message, limit int) []string { - questions := make([]string, 0, limit) - seen := map[string]struct{}{} - for i := len(messages) - 1; i >= 0 && len(questions) < limit; i-- { - msg := messages[i] - if msg.Role != "user" { - continue - } - content := strings.TrimSpace(msg.Content) - if content == "" || !looksLikeOpenQuestion(content) { - continue - } - key := strings.ToLower(content) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - questions = append(questions, domain.SummarizeText(content, 120)) - } - return reverseStrings(questions) -} - -func collectRecentFiles(messages []domain.Message, limit int) []string { - files := make([]string, 0, limit) - seen := map[string]struct{}{} - for i := len(messages) - 1; i >= 0 && len(files) < limit; i-- { - matches := fileRefPattern.FindAllString(messages[i].Content, -1) - for _, match := range matches { - // 统一成斜杠路径,便于后续去重和直接注入 prompt。 - normalized := strings.TrimSpace(strings.ReplaceAll(match, "\\", "/")) - if normalized == "" { - continue - } - lowered := strings.ToLower(normalized) - if _, ok := seen[lowered]; ok { - continue - } - seen[lowered] = struct{}{} - files = append(files, normalized) - if len(files) >= limit { - break - } - } - } - return reverseStrings(files) -} - -func looksLikeOpenQuestion(text string) bool { - trimmed := strings.TrimSpace(strings.ToLower(text)) - if trimmed == "" { - return false - } - if strings.ContainsAny(trimmed, "??") { - return true - } - return containsAnyFold(trimmed, "怎么", "如何", "为什么", "是否", "在哪", "what", "how", "why", "where", "which") -} - -func formatWorkingMemoryContext(state *domain.WorkingMemoryState, workspaceRoot string) string { - if state == nil { - return "" - } - parts := make([]string, 0, 6) - if strings.TrimSpace(workspaceRoot) != "" { - parts = append(parts, "Workspace root: "+workspaceRoot) - } - if state.CurrentTask != "" { - parts = append(parts, "Current task: "+domain.SummarizeText(state.CurrentTask, 180)) - } - if state.TaskSummary != "" { - parts = append(parts, "Task summary: "+state.TaskSummary) - } - if state.LastCompletedAction != "" { - parts = append(parts, "Last completed action: "+state.LastCompletedAction) - } - if state.CurrentInProgress != "" { - parts = append(parts, "Current in progress: "+state.CurrentInProgress) - } - if state.NextStep != "" { - parts = append(parts, "Next step: "+state.NextStep) - } - if len(state.OpenQuestions) > 0 { - parts = append(parts, "Open questions: "+strings.Join(state.OpenQuestions, " | ")) - } - if len(state.RecentFiles) > 0 { - parts = append(parts, "Recent file refs: "+strings.Join(state.RecentFiles, ", ")) - } - if !state.UpdatedAt.IsZero() { - parts = append(parts, "State updated at: "+state.UpdatedAt.Format(time.RFC3339)) - } - if len(state.RecentTurns) > 0 { - turnLines := make([]string, 0, len(state.RecentTurns)) - for idx, turn := range state.RecentTurns { - user := domain.SummarizeText(turn.User, 100) - assistant := domain.SummarizeText(turn.Assistant, 100) - turnLines = append(turnLines, fmt.Sprintf("Turn %d user=%q assistant=%q", idx+1, user, assistant)) - } - parts = append(parts, "Recent turns:\n"+strings.Join(turnLines, "\n")) - } - if len(parts) == 0 { - return "" - } - return "Use the following working memory to stay consistent with the active task and recent context. Prefer it over reconstructing context from scratch.\n" + strings.Join(parts, "\n") -} - -func reverseStrings(values []string) []string { - for i, j := 0, len(values)-1; i < j; i, j = i+1, j-1 { - values[i], values[j] = values[j], values[i] - } - return values -} diff --git a/internal/server/service/working_memory_service_test.go b/internal/server/service/working_memory_service_test.go deleted file mode 100644 index 317f8a03..00000000 --- a/internal/server/service/working_memory_service_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package service - -import ( - "context" - "strings" - "testing" - - "go-llm-demo/internal/server/domain" - "go-llm-demo/internal/server/infra/repository" -) - -func TestWorkingMemoryServiceBuildsCheckpointFields(t *testing.T) { - svc := NewWorkingMemoryService(repository.NewWorkingMemoryStore(), 6, "D:/neo-code") - messages := []domain.Message{ - {Role: "user", Content: "请修复 internal/server/service/memory_service.go 的记忆问题"}, - {Role: "assistant", Content: "已完成工具 JSON 过滤,接下来补 working memory 测试。"}, - {Role: "user", Content: "下一步应该先验证哪些场景?"}, - } - - if err := svc.Refresh(context.Background(), messages); err != nil { - t.Fatalf("refresh state: %v", err) - } - state, err := svc.Get(context.Background()) - if err != nil { - t.Fatalf("get state: %v", err) - } - if state.CurrentTask == "" || state.NextStep == "" || state.LastCompletedAction == "" { - t.Fatalf("expected checkpoint fields to be populated, got %+v", state) - } - if len(state.RecentFiles) == 0 || state.RecentFiles[0] != "internal/server/service/memory_service.go" { - t.Fatalf("expected recent files to be collected, got %+v", state.RecentFiles) - } -} - -func TestWorkingMemoryServiceFormatsExtendedContext(t *testing.T) { - state := &domain.WorkingMemoryState{ - CurrentTask: "修复记忆模块", - LastCompletedAction: "已修复持久化问题", - CurrentInProgress: "正在补恢复测试", - NextStep: "继续验证跨 workspace 隔离", - } - - got := formatWorkingMemoryContext(state, "D:/neo-code") - for _, want := range []string{"Current task:", "Last completed action:", "Current in progress:", "Next step:"} { - if !strings.Contains(got, want) { - t.Fatalf("expected %q in formatted context, got %q", want, got) - } - } -} diff --git a/internal/server/transport/http/.gitkeep b/internal/server/transport/http/.gitkeep deleted file mode 100644 index d1ee8c15..00000000 --- a/internal/server/transport/http/.gitkeep +++ /dev/null @@ -1,53 +0,0 @@ -package http - -import ( - "encoding/json" - "net/http" - - "go-llm-demo/internal/server/domain" -) - -type ChatHandler struct { - chatSvc domain.ChatGateway -} - -func (h *ChatHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req struct { - Messages []domain.Message `json:"messages"` - Model string `json:"model"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - stream, err := h.chatSvc.Send(r.Context(), &domain.ChatRequest{ - Messages: req.Messages, - Model: req.Model, - }) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/x-ndjson") - flusher, ok := w.(http.Flusher) - if !ok { - http.Error(w, "Streaming unsupported", http.StatusInternalServerError) - return - } - - enc := json.NewEncoder(w) - for chunk := range stream { - if err := enc.Encode(map[string]string{"content": chunk}); err != nil { - return - } - flusher.Flush() - } -} diff --git a/internal/tools/bash/helpers_test.go b/internal/tools/bash/helpers_test.go new file mode 100644 index 00000000..af6773ed --- /dev/null +++ b/internal/tools/bash/helpers_test.go @@ -0,0 +1,140 @@ +package bash + +import ( + "path/filepath" + goruntime "runtime" + "strings" + "testing" + "time" +) + +func TestToolHelpers(t *testing.T) { + t.Parallel() + + tool := New(t.TempDir(), defaultShell(), 2*time.Second) + if tool.Description() == "" { + t.Fatalf("expected non-empty description") + } + if tool.Schema()["type"] != "object" { + t.Fatalf("expected schema object") + } + + tests := []struct { + name string + shell string + want []string + }{ + { + name: "powershell shell args", + shell: "powershell", + want: []string{"powershell", "-NoProfile", "-Command"}, + }, + { + name: "bash shell args", + shell: "bash", + want: []string{"bash", "-lc"}, + }, + { + name: "sh shell args", + shell: "sh", + want: []string{"sh", "-lc"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + got := New(t.TempDir(), tt.shell, time.Second).shellArgs("echo hi") + if len(got) < len(tt.want) { + t.Fatalf("expected shell args prefix %v, got %v", tt.want, got) + } + for idx := range tt.want { + if got[idx] != tt.want[idx] { + t.Fatalf("expected shell args prefix %v, got %v", tt.want, got) + } + } + }) + } + + t.Run("default shell args", func(t *testing.T) { + got := New(t.TempDir(), "unknown", time.Second).shellArgs("echo hi") + if goruntime.GOOS == "windows" { + if got[0] != "powershell" { + t.Fatalf("expected windows fallback to powershell, got %v", got) + } + return + } + if got[0] != "sh" { + t.Fatalf("expected unix fallback to sh, got %v", got) + } + }) +} + +func TestResolveWorkdirVariants(t *testing.T) { + t.Parallel() + + root := t.TempDir() + subdir := filepath.Join(root, "sub") + + tests := []struct { + name string + requested string + expectErr string + assert func(t *testing.T, got string) + }{ + { + name: "empty requested returns root", + requested: "", + assert: func(t *testing.T, got string) { + t.Helper() + if got != root { + t.Fatalf("expected root %q, got %q", root, got) + } + }, + }, + { + name: "relative path joins root", + requested: "sub", + assert: func(t *testing.T, got string) { + t.Helper() + if got != subdir { + t.Fatalf("expected subdir %q, got %q", subdir, got) + } + }, + }, + { + name: "absolute path inside root is allowed", + requested: subdir, + assert: func(t *testing.T, got string) { + t.Helper() + if got != subdir { + t.Fatalf("expected subdir %q, got %q", subdir, got) + } + }, + }, + { + name: "escape is rejected", + requested: filepath.Join("..", "escape"), + expectErr: "escapes workspace root", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + got, err := resolveWorkdir(root, tt.requested) + 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, got) + } + }) + } +} diff --git a/internal/tools/bash/tool.go b/internal/tools/bash/tool.go new file mode 100644 index 00000000..76a12fd0 --- /dev/null +++ b/internal/tools/bash/tool.go @@ -0,0 +1,142 @@ +package bash + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/dust/neo-code/internal/tools" +) + +type Tool struct { + root string + shell string + timeout time.Duration + outputLimit int +} + +type input struct { + Command string `json:"command"` + Workdir string `json:"workdir,omitempty"` +} + +func New(root string, shell string, timeout time.Duration) *Tool { + return &Tool{ + root: root, + shell: shell, + timeout: timeout, + outputLimit: 16 * 1024, + } +} + +func (t *Tool) Name() string { + return "bash" +} + +func (t *Tool) Description() string { + return "Execute a shell command inside the workspace with timeout and bounded output." +} + +func (t *Tool) Schema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "command": map[string]any{ + "type": "string", + "description": "Shell command to execute.", + }, + "workdir": map[string]any{ + "type": "string", + "description": "Optional working directory relative to the workspace root.", + }, + }, + "required": []string{"command"}, + } +} + +func (t *Tool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { + var in input + if err := json.Unmarshal(call.Arguments, &in); err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + if strings.TrimSpace(in.Command) == "" { + return tools.ToolResult{Name: t.Name()}, errors.New("bash: command is empty") + } + + workdir, err := resolveWorkdir(call.Workdir, in.Workdir) + if err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + runCtx, cancel := context.WithTimeout(ctx, t.timeout) + defer cancel() + + args := t.shellArgs(in.Command) + cmd := exec.CommandContext(runCtx, args[0], args[1:]...) + cmd.Dir = workdir + output, err := cmd.CombinedOutput() + + content := string(output) + if len(content) > t.outputLimit { + content = content[:t.outputLimit] + "\n...[truncated]" + } + result := tools.ToolResult{ + Name: t.Name(), + Content: content, + IsError: err != nil, + Metadata: map[string]any{ + "workdir": workdir, + }, + } + if err != nil && strings.TrimSpace(result.Content) == "" { + result.Content = err.Error() + } + return result, err +} + +func (t *Tool) shellArgs(command string) []string { + shell := strings.ToLower(strings.TrimSpace(t.shell)) + switch shell { + case "powershell", "pwsh": + return []string{"powershell", "-NoProfile", "-Command", command} + case "bash": + return []string{"bash", "-lc", command} + case "sh": + return []string{"sh", "-lc", command} + } + if runtime.GOOS == "windows" { + return []string{"powershell", "-NoProfile", "-Command", command} + } + return []string{"sh", "-lc", command} +} + +func resolveWorkdir(root string, requested string) (string, error) { + base, err := filepath.Abs(root) + if err != nil { + return "", err + } + target := requested + if strings.TrimSpace(target) == "" { + target = base + } else if !filepath.IsAbs(target) { + target = filepath.Join(base, target) + } + target, err = filepath.Abs(target) + if err != nil { + return "", err + } + rel, err := filepath.Rel(base, target) + if err != nil { + return "", err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", errors.New("bash: workdir escapes workspace root") + } + return target, nil +} diff --git a/internal/tools/bash/tool_test.go b/internal/tools/bash/tool_test.go new file mode 100644 index 00000000..2002d3dc --- /dev/null +++ b/internal/tools/bash/tool_test.go @@ -0,0 +1,118 @@ +package bash + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + goruntime "runtime" + "strings" + "testing" + "time" + + "github.com/dust/neo-code/internal/tools" +) + +func TestToolExecute(t *testing.T) { + workspace := t.TempDir() + subdir := filepath.Join(workspace, "sub") + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatalf("mkdir subdir: %v", err) + } + + tests := []struct { + name string + command string + workdir string + expectErr string + expectContent string + }{ + { + name: "captures stdout", + command: safeEchoCommand(), + expectContent: "hello", + }, + { + name: "rejects workdir escape", + command: safeEchoCommand(), + workdir: "..", + expectErr: "workdir escapes workspace root", + }, + { + name: "rejects empty command", + command: "", + expectErr: "command is empty", + }, + { + name: "runs inside nested workdir", + command: safePwdCommand(), + workdir: "sub", + expectContent: normalizeOutputPath(subdir), + }, + } + + tool := New(workspace, defaultShell(), 3*time.Second) + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + args, err := json.Marshal(map[string]string{ + "command": tt.command, + "workdir": tt.workdir, + }) + if err != nil { + t.Fatalf("marshal args: %v", err) + } + + result, execErr := tool.Execute(context.Background(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: args, + Workdir: workspace, + }) + + if tt.expectErr != "" { + if execErr == nil || !strings.Contains(execErr.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, execErr) + } + return + } + if execErr != nil { + t.Fatalf("unexpected error: %v", execErr) + } + if !strings.Contains(normalizeOutputPath(result.Content), normalizeOutputPath(tt.expectContent)) { + t.Fatalf("expected content containing %q, got %q", tt.expectContent, result.Content) + } + if result.IsError { + t.Fatalf("expected IsError=false, got true") + } + }) + } +} + +func safeEchoCommand() string { + if goruntime.GOOS == "windows" { + return "Write-Output 'hello'" + } + return "printf 'hello'" +} + +func safePwdCommand() string { + if goruntime.GOOS == "windows" { + return "(Get-Location).Path" + } + return "pwd" +} + +func defaultShell() string { + if goruntime.GOOS == "windows" { + return "powershell" + } + return "sh" +} + +func normalizeOutputPath(value string) string { + trimmed := strings.TrimSpace(value) + if goruntime.GOOS == "windows" { + return strings.ToLower(strings.ReplaceAll(trimmed, "/", `\`)) + } + return strings.ReplaceAll(trimmed, `\`, "/") +} diff --git a/internal/tools/filesystem/edit.go b/internal/tools/filesystem/edit.go new file mode 100644 index 00000000..d16e89a5 --- /dev/null +++ b/internal/tools/filesystem/edit.go @@ -0,0 +1,114 @@ +package filesystem + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "github.com/dust/neo-code/internal/tools" +) + +type EditTool struct { + root string +} + +type editInput struct { + Path string `json:"path"` + SearchString string `json:"search_string"` + ReplaceString string `json:"replace_string"` +} + +func NewEdit(root string) *EditTool { + return &EditTool{root: root} +} + +func (t *EditTool) Name() string { + return editToolName +} + +func (t *EditTool) Description() string { + return "Replace exactly one matching code block in a file and write the updated content back to disk." +} + +func (t *EditTool) Schema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Target file path relative to the workspace root, or an absolute path inside the workspace.", + }, + "search_string": map[string]any{ + "type": "string", + "description": "Exact string to find in the file. It must match exactly once.", + }, + "replace_string": map[string]any{ + "type": "string", + "description": "Replacement content for the matched string.", + }, + }, + "required": []string{"path", "search_string", "replace_string"}, + } +} + +func (t *EditTool) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + var args editInput + if err := json.Unmarshal(input.Arguments, &args); err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + if strings.TrimSpace(args.Path) == "" { + return tools.ToolResult{Name: t.Name()}, errors.New(editToolName + ": path is required") + } + if args.SearchString == "" { + return tools.ToolResult{Name: t.Name()}, errors.New(editToolName + ": search_string is required") + } + if err := ctx.Err(); err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + root := effectiveRoot(t.root, input.Workdir) + target, err := resolvePath(root, args.Path) + if err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + data, err := os.ReadFile(target) + if err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + content := string(data) + matches := strings.Count(content, args.SearchString) + switch { + case matches == 0: + return tools.ToolResult{Name: t.Name()}, fmt.Errorf("%s: search_string not found in %s", editToolName, toRelativePath(root, target)) + case matches > 1: + return tools.ToolResult{Name: t.Name()}, fmt.Errorf("%s: search_string matched %d locations in %s; refine it to a unique block", editToolName, matches, toRelativePath(root, target)) + } + + updated := strings.Replace(content, args.SearchString, args.ReplaceString, 1) + if updated == content { + return tools.ToolResult{Name: t.Name()}, fmt.Errorf("%s: replacement produced no changes", editToolName) + } + if err := ctx.Err(); err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + if err := os.WriteFile(target, []byte(updated), 0o644); err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + return tools.ToolResult{ + Name: t.Name(), + Content: "ok", + Metadata: map[string]any{ + "path": target, + "relative_path": normalizeSlashPath(toRelativePath(root, target)), + "search_length": len(args.SearchString), + "replacement_length": len(args.ReplaceString), + }, + }, nil +} diff --git a/internal/tools/filesystem/edit_test.go b/internal/tools/filesystem/edit_test.go new file mode 100644 index 00000000..55101312 --- /dev/null +++ b/internal/tools/filesystem/edit_test.go @@ -0,0 +1,144 @@ +package filesystem + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/dust/neo-code/internal/tools" +) + +func TestEditToolExecute(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + filePath := filepath.Join(workspace, "main.go") + repeatedPath := filepath.Join(workspace, "repeated.go") + unchangedPath := filepath.Join(workspace, "same.txt") + + if err := os.WriteFile(filePath, []byte("package main\n\nfunc main() {\n\tprintln(\"old\")\n}\n"), 0o644); err != nil { + t.Fatalf("write main.go: %v", err) + } + if err := os.WriteFile(repeatedPath, []byte("old\nold\n"), 0o644); err != nil { + t.Fatalf("write repeated.go: %v", err) + } + if err := os.WriteFile(unchangedPath, []byte("same"), 0o644); err != nil { + t.Fatalf("write same.txt: %v", err) + } + + tests := []struct { + name string + path string + search string + replace string + expectErr string + expectFile string + }{ + { + name: "replace unique block", + path: "main.go", + search: "println(\"old\")", + replace: "println(\"new\")", + expectFile: "package main\n\nfunc main() {\n\tprintln(\"new\")\n}\n", + }, + { + name: "search string not found", + path: "main.go", + search: "missing", + replace: "new", + expectErr: "search_string not found", + }, + { + name: "multiple matches are rejected", + path: "repeated.go", + search: "old", + replace: "new", + expectErr: "matched 2 locations", + }, + { + name: "replacement with identical content is rejected", + path: "same.txt", + search: "same", + replace: "same", + expectErr: "replacement produced no changes", + }, + { + name: "path traversal is rejected", + path: filepath.Join("..", "escape.txt"), + search: "old", + replace: "new", + expectErr: "path escapes workspace root", + }, + } + + tool := NewEdit(workspace) + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + args, err := json.Marshal(map[string]string{ + "path": tt.path, + "search_string": tt.search, + "replace_string": tt.replace, + }) + if err != nil { + t.Fatalf("marshal args: %v", err) + } + + result, execErr := tool.Execute(context.Background(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: args, + Workdir: workspace, + }) + + if tt.expectErr != "" { + if execErr == nil || !strings.Contains(execErr.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, execErr) + } + return + } + if execErr != nil { + t.Fatalf("unexpected error: %v", execErr) + } + if result.Content != "ok" { + t.Fatalf("expected result content ok, got %q", result.Content) + } + + data, err := os.ReadFile(filepath.Join(workspace, tt.path)) + if err != nil { + t.Fatalf("read updated file: %v", err) + } + if string(data) != tt.expectFile { + t.Fatalf("expected updated file %q, got %q", tt.expectFile, string(data)) + } + }) + } +} + +func TestEditToolSearchStringNotFound(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + mustWriteFile(t, filepath.Join(workspace, "main.go"), "package main\n") + + tool := NewEdit(workspace) + args, err := json.Marshal(map[string]string{ + "path": "main.go", + "search_string": "missing-block", + "replace_string": "new-block", + }) + if err != nil { + t.Fatalf("marshal args: %v", err) + } + + _, execErr := tool.Execute(context.Background(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: args, + Workdir: workspace, + }) + if execErr == nil || !strings.Contains(execErr.Error(), "search_string not found") { + t.Fatalf("expected search_string not found error, got %v", execErr) + } +} diff --git a/internal/tools/filesystem/glob.go b/internal/tools/filesystem/glob.go new file mode 100644 index 00000000..5d5fec57 --- /dev/null +++ b/internal/tools/filesystem/glob.go @@ -0,0 +1,163 @@ +package filesystem + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/dust/neo-code/internal/tools" +) + +type GlobTool struct { + root string +} + +type globInput struct { + Pattern string `json:"pattern"` + Dir string `json:"dir"` +} + +func NewGlob(root string) *GlobTool { + return &GlobTool{root: root} +} + +func (t *GlobTool) Name() string { + return globToolName +} + +func (t *GlobTool) Description() string { + return "List workspace file paths that match a glob pattern such as **/*.go." +} + +func (t *GlobTool) Schema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "pattern": map[string]any{ + "type": "string", + "description": "Glob pattern to match relative file paths, for example **/*.go or internal/**/*.md.", + }, + "dir": map[string]any{ + "type": "string", + "description": "Optional directory relative to the workspace root to scope the search.", + }, + }, + "required": []string{"pattern"}, + } +} + +func (t *GlobTool) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + var args globInput + if err := json.Unmarshal(input.Arguments, &args); err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + rawPattern := strings.TrimSpace(args.Pattern) + if rawPattern == "" { + return tools.ToolResult{Name: t.Name()}, errors.New(globToolName + ": pattern is required") + } + pattern := normalizeSlashPath(rawPattern) + if err := ctx.Err(); err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + root := effectiveRoot(t.root, input.Workdir) + searchRoot, err := resolveSearchDir(root, args.Dir) + if err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + matcher, err := buildGlobMatcher(pattern) + if err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + matches := make([]string, 0, 32) + err = filepath.WalkDir(searchRoot, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return err + } + if skipDirEntry(path, entry) { + return filepath.SkipDir + } + if entry.IsDir() { + return nil + } + + relativeToSearch, err := filepath.Rel(searchRoot, path) + if err != nil { + return nil + } + if matcher.MatchString(normalizeSlashPath(relativeToSearch)) { + matches = append(matches, normalizeSlashPath(toRelativePath(root, path))) + } + return nil + }) + if err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + sort.Strings(matches) + if len(matches) == 0 { + return tools.ToolResult{ + Name: t.Name(), + Content: "no matches", + Metadata: map[string]any{ + "root": searchRoot, + "count": 0, + }, + }, nil + } + + return tools.ToolResult{ + Name: t.Name(), + Content: strings.Join(matches, "\n"), + Metadata: map[string]any{ + "root": searchRoot, + "count": len(matches), + }, + }, nil +} + +func buildGlobMatcher(pattern string) (*regexp.Regexp, error) { + var builder strings.Builder + builder.WriteString("^") + for idx := 0; idx < len(pattern); idx++ { + ch := pattern[idx] + switch ch { + case '*': + if idx+1 < len(pattern) && pattern[idx+1] == '*' { + if idx+2 < len(pattern) && pattern[idx+2] == '/' { + builder.WriteString(`(?:.*/)?`) + idx += 2 + continue + } + builder.WriteString(".*") + idx++ + continue + } + builder.WriteString(`[^/]*`) + case '?': + builder.WriteString(`[^/]`) + case '.', '+', '(', ')', '|', '^', '$', '{', '}', '[', ']', '\\': + builder.WriteByte('\\') + builder.WriteByte(ch) + default: + builder.WriteByte(ch) + } + } + builder.WriteString("$") + return regexp.Compile(builder.String()) +} + +func normalizeSlashPath(value string) string { + return strings.ReplaceAll(filepath.Clean(value), `\`, "/") +} diff --git a/internal/tools/filesystem/glob_test.go b/internal/tools/filesystem/glob_test.go new file mode 100644 index 00000000..41dd7826 --- /dev/null +++ b/internal/tools/filesystem/glob_test.go @@ -0,0 +1,108 @@ +package filesystem + +import ( + "context" + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/dust/neo-code/internal/tools" +) + +func TestGlobToolExecute(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + mustWriteFile(t, filepath.Join(workspace, "main.go"), "package main\n") + mustWriteFile(t, filepath.Join(workspace, "README.md"), "# readme\n") + mustWriteFile(t, filepath.Join(workspace, "internal", "app", "app.go"), "package app\n") + mustWriteFile(t, filepath.Join(workspace, "node_modules", "skip.go"), "package skip\n") + + tests := []struct { + name string + pattern string + dir string + expectContains []string + expectErr string + expectNoMatch bool + }{ + { + name: "glob go files recursively", + pattern: "**/*.go", + expectContains: []string{"main.go", normalizeSlashPath(filepath.Join("internal", "app", "app.go"))}, + }, + { + name: "scope to directory", + pattern: "**/*.go", + dir: "internal", + expectContains: []string{normalizeSlashPath(filepath.Join("internal", "app", "app.go"))}, + }, + { + name: "no matches", + pattern: "**/*.py", + expectNoMatch: true, + }, + { + name: "empty pattern", + pattern: "", + expectErr: "pattern is required", + }, + } + + tool := NewGlob(workspace) + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + args, err := json.Marshal(map[string]string{ + "pattern": tt.pattern, + "dir": tt.dir, + }) + if err != nil { + t.Fatalf("marshal args: %v", err) + } + + result, execErr := tool.Execute(context.Background(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: args, + Workdir: workspace, + }) + + if tt.expectErr != "" { + if execErr == nil || !strings.Contains(execErr.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, execErr) + } + return + } + if execErr != nil { + t.Fatalf("unexpected error: %v", execErr) + } + if tt.expectNoMatch { + if result.Content != "no matches" { + t.Fatalf("expected no matches, got %q", result.Content) + } + return + } + normalizedContent := normalizeSlashPath(result.Content) + for _, expected := range tt.expectContains { + if !strings.Contains(normalizedContent, normalizeSlashPath(expected)) { + t.Fatalf("expected result to contain %q, got %q", expected, result.Content) + } + } + if strings.Contains(normalizedContent, "node_modules") { + t.Fatalf("expected node_modules files to be skipped, got %q", result.Content) + } + }) + } +} + +func TestBuildGlobMatcherRejectsInvalidUTF8(t *testing.T) { + t.Parallel() + + _, err := buildGlobMatcher(string([]byte{0xff})) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "utf-8") { + t.Fatalf("expected invalid utf-8 error, got %v", err) + } +} diff --git a/internal/tools/filesystem/grep.go b/internal/tools/filesystem/grep.go new file mode 100644 index 00000000..13e4754e --- /dev/null +++ b/internal/tools/filesystem/grep.go @@ -0,0 +1,174 @@ +package filesystem + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/dust/neo-code/internal/tools" +) + +const defaultGrepResultLimit = 200 + +var errGrepResultLimitReached = errors.New("filesystem_grep: result limit reached") + +type GrepTool struct { + root string +} + +type grepInput struct { + Pattern string `json:"pattern"` + Dir string `json:"dir"` + UseRegex bool `json:"use_regex"` +} + +func NewGrep(root string) *GrepTool { + return &GrepTool{root: root} +} + +func (t *GrepTool) Name() string { + return grepToolName +} + +func (t *GrepTool) Description() string { + return "Search the workspace for matching text and return file paths, line numbers, and matching snippets." +} + +func (t *GrepTool) Schema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "pattern": map[string]any{ + "type": "string", + "description": "Literal text or a regular expression to search for.", + }, + "dir": map[string]any{ + "type": "string", + "description": "Optional directory relative to the workspace root to scope the search.", + }, + "use_regex": map[string]any{ + "type": "boolean", + "description": "When true, treat pattern as a regular expression. When false, perform a literal substring search.", + }, + }, + "required": []string{"pattern"}, + } +} + +func (t *GrepTool) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + var args grepInput + if err := json.Unmarshal(input.Arguments, &args); err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + pattern := strings.TrimSpace(args.Pattern) + if pattern == "" { + return tools.ToolResult{Name: t.Name()}, errors.New(grepToolName + ": pattern is required") + } + if err := ctx.Err(); err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + root := effectiveRoot(t.root, input.Workdir) + searchRoot, err := resolveSearchDir(root, args.Dir) + if err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + matcher, err := buildGrepMatcher(pattern, args.UseRegex) + if err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + var ( + results []string + matchedFiles int + ) + err = filepath.WalkDir(searchRoot, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return err + } + if skipDirEntry(path, entry) { + return filepath.SkipDir + } + if entry.IsDir() { + return nil + } + + data, err := os.ReadFile(path) + if err != nil { + return nil + } + + lines := strings.Split(string(data), "\n") + fileMatched := false + for idx, line := range lines { + if !matcher(line) { + continue + } + fileMatched = true + results = append(results, fmt.Sprintf("%s:%d: %s", toRelativePath(root, path), idx+1, strings.TrimRight(line, "\r"))) + if len(results) >= defaultGrepResultLimit { + return errGrepResultLimitReached + } + } + if fileMatched { + matchedFiles++ + } + return nil + }) + if err != nil && !errors.Is(err, errGrepResultLimitReached) { + return tools.ToolResult{Name: t.Name()}, err + } + + if len(results) == 0 { + return tools.ToolResult{ + Name: t.Name(), + Content: "no matches", + Metadata: map[string]any{ + "root": searchRoot, + "matched_files": 0, + "matched_lines": 0, + }, + }, nil + } + + return tools.ToolResult{ + Name: t.Name(), + Content: strings.Join(results, "\n"), + Metadata: map[string]any{ + "root": searchRoot, + "matched_files": matchedFiles, + "matched_lines": len(results), + }, + }, nil +} + +func buildGrepMatcher(pattern string, useRegex bool) (func(string) bool, error) { + if !useRegex { + return func(line string) bool { + return strings.Contains(line, pattern) + }, nil + } + + compiled, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("%s: invalid regex: %w", grepToolName, err) + } + return compiled.MatchString, nil +} + +func resolveSearchDir(root string, dir string) (string, error) { + if strings.TrimSpace(dir) == "" { + return resolvePath(root, ".") + } + return resolvePath(root, dir) +} diff --git a/internal/tools/filesystem/grep_test.go b/internal/tools/filesystem/grep_test.go new file mode 100644 index 00000000..0bec1178 --- /dev/null +++ b/internal/tools/filesystem/grep_test.go @@ -0,0 +1,107 @@ +package filesystem + +import ( + "context" + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/dust/neo-code/internal/tools" +) + +func TestGrepToolExecute(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + mustWriteFile(t, filepath.Join(workspace, "a.txt"), "hello world\nno match\n") + mustWriteFile(t, filepath.Join(workspace, "sub", "b.go"), "package main\nprintln(\"hello\")\n") + mustWriteFile(t, filepath.Join(workspace, "node_modules", "skip.txt"), "hello from dependency\n") + + tests := []struct { + name string + pattern string + dir string + useRegex bool + expectContains []string + expectErr string + expectNoMatch bool + }{ + { + name: "literal search across workspace", + pattern: "hello", + expectContains: []string{"a.txt:1: hello world", normalizeSlashPath(filepath.Join("sub", "b.go")) + ":2: println(\"hello\")"}, + }, + { + name: "regex search scoped to directory", + pattern: `println\("hello"\)`, + dir: "sub", + useRegex: true, + expectContains: []string{normalizeSlashPath(filepath.Join("sub", "b.go")) + ":2: println(\"hello\")"}, + }, + { + name: "invalid regex", + pattern: "[", + useRegex: true, + expectErr: "invalid regex", + }, + { + name: "no matches", + pattern: "goodbye", + expectNoMatch: true, + }, + { + name: "invalid scoped dir traversal", + pattern: "hello", + dir: "..", + expectErr: "path escapes workspace root", + }, + } + + tool := NewGrep(workspace) + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + args, err := json.Marshal(map[string]any{ + "pattern": tt.pattern, + "dir": tt.dir, + "use_regex": tt.useRegex, + }) + if err != nil { + t.Fatalf("marshal args: %v", err) + } + + result, execErr := tool.Execute(context.Background(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: args, + Workdir: workspace, + }) + + if tt.expectErr != "" { + if execErr == nil || !strings.Contains(execErr.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, execErr) + } + return + } + if execErr != nil { + t.Fatalf("unexpected error: %v", execErr) + } + if tt.expectNoMatch { + if result.Content != "no matches" { + t.Fatalf("expected no matches, got %q", result.Content) + } + return + } + for _, expected := range tt.expectContains { + if !strings.Contains(normalizeSlashPath(result.Content), normalizeSlashPath(expected)) { + t.Fatalf("expected result to contain %q, got %q", expected, result.Content) + } + } + if strings.Contains(result.Content, "dependency") { + t.Fatalf("expected node_modules content to be skipped, got %q", result.Content) + } + }) + } +} diff --git a/internal/tools/filesystem/helpers.go b/internal/tools/filesystem/helpers.go new file mode 100644 index 00000000..d681ed6e --- /dev/null +++ b/internal/tools/filesystem/helpers.go @@ -0,0 +1,56 @@ +package filesystem + +import ( + "os" + "path/filepath" + "strings" +) + +const ( + readFileToolName = "filesystem_read_file" + writeFileToolName = "filesystem_write_file" + grepToolName = "filesystem_grep" + globToolName = "filesystem_glob" + editToolName = "filesystem_edit" +) + +func effectiveRoot(defaultRoot string, workdir string) string { + base := strings.TrimSpace(workdir) + if base == "" { + base = defaultRoot + } + return base +} + +func toRelativePath(root string, target string) string { + base, err := filepath.Abs(root) + if err != nil { + return filepath.Clean(target) + } + + absoluteTarget, err := filepath.Abs(target) + if err != nil { + return filepath.Clean(target) + } + + rel, err := filepath.Rel(base, absoluteTarget) + if err != nil { + return filepath.Clean(target) + } + + return filepath.Clean(rel) +} + +func skipDirEntry(path string, entry os.DirEntry) bool { + if !entry.IsDir() { + return false + } + + name := strings.ToLower(strings.TrimSpace(entry.Name())) + switch name { + case ".git", ".idea", ".vscode", "node_modules": + return true + } + + return false +} diff --git a/internal/tools/filesystem/metadata_test.go b/internal/tools/filesystem/metadata_test.go new file mode 100644 index 00000000..58edb373 --- /dev/null +++ b/internal/tools/filesystem/metadata_test.go @@ -0,0 +1,43 @@ +package filesystem + +import "testing" + +func TestToolMetadataAndHelpers(t *testing.T) { + t.Parallel() + + root := t.TempDir() + tools := []interface { + Name() string + Description() string + Schema() map[string]any + }{ + New(root), + NewWrite(root), + NewGrep(root), + NewGlob(root), + NewEdit(root), + } + + for _, tool := range tools { + if tool.Name() == "" { + t.Fatalf("expected tool name") + } + if tool.Description() == "" { + t.Fatalf("expected description for %q", tool.Name()) + } + schema := tool.Schema() + if schema["type"] != "object" { + t.Fatalf("expected object schema for %q, got %+v", tool.Name(), schema) + } + } + + if effectiveRoot("", root) != root { + t.Fatalf("expected workdir fallback") + } + if got := effectiveRoot(root, ""); got != root { + t.Fatalf("expected default root, got %q", got) + } + if rel := toRelativePath(root, root); rel != "." { + t.Fatalf("expected relative root path '.', got %q", rel) + } +} diff --git a/internal/tools/filesystem/read_file.go b/internal/tools/filesystem/read_file.go new file mode 100644 index 00000000..401f310c --- /dev/null +++ b/internal/tools/filesystem/read_file.go @@ -0,0 +1,117 @@ +package filesystem + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + + "github.com/dust/neo-code/internal/tools" +) + +const emitChunkSize = 4 * 1024 + +type ReadFileTool struct { + root string +} + +type readFileInput struct { + Path string `json:"path"` +} + +func New(root string) *ReadFileTool { + return &ReadFileTool{root: root} +} + +func (t *ReadFileTool) Name() string { + return readFileToolName +} + +func (t *ReadFileTool) Description() string { + return "Read a file from the current workspace and return its contents." +} + +func (t *ReadFileTool) Schema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "File path relative to the workspace root, or an absolute path inside the workspace.", + }, + }, + "required": []string{"path"}, + } +} + +func (t *ReadFileTool) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + var args readFileInput + if err := json.Unmarshal(input.Arguments, &args); err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + if strings.TrimSpace(args.Path) == "" { + return tools.ToolResult{Name: t.Name()}, errors.New(readFileToolName + ": path is required") + } + + base := effectiveRoot(t.root, input.Workdir) + + target, err := resolvePath(base, args.Path) + if err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + data, err := os.ReadFile(target) + if err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + if input.EmitChunk != nil && len(data) > emitChunkSize { + for start := 0; start < len(data); start += emitChunkSize { + end := start + emitChunkSize + if end > len(data) { + end = len(data) + } + input.EmitChunk(data[start:end]) + } + } + + return tools.ToolResult{ + Name: t.Name(), + Content: string(data), + Metadata: map[string]any{ + "path": target, + }, + }, nil +} + +func resolvePath(root string, requested string) (string, error) { + base, err := filepath.Abs(root) + if err != nil { + return "", err + } + + target := strings.TrimSpace(requested) + if target == "" { + return "", errors.New(readFileToolName + ": path is required") + } + if !filepath.IsAbs(target) { + target = filepath.Join(base, target) + } + + target, err = filepath.Abs(target) + if err != nil { + return "", err + } + + rel, err := filepath.Rel(base, target) + if err != nil { + return "", err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", errors.New(readFileToolName + ": path escapes workspace root") + } + + return target, nil +} diff --git a/internal/tools/filesystem/read_file_test.go b/internal/tools/filesystem/read_file_test.go new file mode 100644 index 00000000..97c7dda0 --- /dev/null +++ b/internal/tools/filesystem/read_file_test.go @@ -0,0 +1,102 @@ +package filesystem + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/dust/neo-code/internal/tools" +) + +func TestReadFileToolExecute(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + largeContent := strings.Repeat("chunk-data-", 500) + + if err := os.WriteFile(filepath.Join(workspace, "small.txt"), []byte("hello world"), 0o644); err != nil { + t.Fatalf("write small file: %v", err) + } + if err := os.MkdirAll(filepath.Join(workspace, "nested"), 0o755); err != nil { + t.Fatalf("mkdir nested: %v", err) + } + largePath := filepath.Join(workspace, "nested", "large.txt") + if err := os.WriteFile(largePath, []byte(largeContent), 0o644); err != nil { + t.Fatalf("write large file: %v", err) + } + + tests := []struct { + name string + path string + expectErr string + expectContent string + expectChunks int + }{ + { + name: "read relative path", + path: "small.txt", + expectContent: "hello world", + }, + { + name: "read absolute path with chunk emitter", + path: largePath, + expectContent: largeContent, + expectChunks: 2, + }, + { + name: "missing path", + path: "", + expectErr: "path is required", + }, + { + name: "reject path traversal", + path: filepath.Join("..", "outside.txt"), + expectErr: "path escapes workspace root", + }, + } + + tool := New(workspace) + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + args, err := json.Marshal(map[string]string{"path": tt.path}) + if err != nil { + t.Fatalf("marshal args: %v", err) + } + + chunks := 0 + result, execErr := tool.Execute(context.Background(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: args, + Workdir: workspace, + EmitChunk: func(chunk []byte) { + if len(chunk) > 0 { + chunks++ + } + }, + }) + + if tt.expectErr != "" { + if execErr == nil || !strings.Contains(execErr.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, execErr) + } + return + } + if execErr != nil { + t.Fatalf("unexpected error: %v", execErr) + } + if result.Content != tt.expectContent { + t.Fatalf("expected content length %d, got %d", len(tt.expectContent), len(result.Content)) + } + if result.Metadata["path"] == "" { + t.Fatalf("expected metadata path") + } + if tt.expectChunks > 0 && chunks < tt.expectChunks { + t.Fatalf("expected at least %d chunks, got %d", tt.expectChunks, chunks) + } + }) + } +} diff --git a/internal/tools/filesystem/test_helpers_test.go b/internal/tools/filesystem/test_helpers_test.go new file mode 100644 index 00000000..dc96d2ca --- /dev/null +++ b/internal/tools/filesystem/test_helpers_test.go @@ -0,0 +1,17 @@ +package filesystem + +import ( + "os" + "path/filepath" + "testing" +) + +func mustWriteFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/internal/tools/filesystem/write_file.go b/internal/tools/filesystem/write_file.go new file mode 100644 index 00000000..d07d0207 --- /dev/null +++ b/internal/tools/filesystem/write_file.go @@ -0,0 +1,85 @@ +package filesystem + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + + "github.com/dust/neo-code/internal/tools" +) + +type WriteFileTool struct { + root string +} + +type writeFileInput struct { + Path string `json:"path"` + Content string `json:"content"` +} + +func NewWrite(root string) *WriteFileTool { + return &WriteFileTool{root: root} +} + +func (t *WriteFileTool) Name() string { + return writeFileToolName +} + +func (t *WriteFileTool) Description() string { + return "Write a file inside the current workspace, creating parent directories when needed." +} + +func (t *WriteFileTool) Schema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "File path relative to the workspace root, or an absolute path inside the workspace.", + }, + "content": map[string]any{ + "type": "string", + "description": "Full file content to write.", + }, + }, + "required": []string{"path", "content"}, + } +} + +func (t *WriteFileTool) Execute(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + var args writeFileInput + if err := json.Unmarshal(input.Arguments, &args); err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + if strings.TrimSpace(args.Path) == "" { + return tools.ToolResult{Name: t.Name()}, errors.New(writeFileToolName + ": path is required") + } + if err := ctx.Err(); err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + base := effectiveRoot(t.root, input.Workdir) + + target, err := resolvePath(base, args.Path) + if err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + if err := os.WriteFile(target, []byte(args.Content), 0o644); err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + return tools.ToolResult{ + Name: t.Name(), + Content: "ok", + Metadata: map[string]any{ + "path": target, + "bytes": len(args.Content), + }, + }, nil +} diff --git a/internal/tools/filesystem/write_file_test.go b/internal/tools/filesystem/write_file_test.go new file mode 100644 index 00000000..05f27c8b --- /dev/null +++ b/internal/tools/filesystem/write_file_test.go @@ -0,0 +1,112 @@ +package filesystem + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/dust/neo-code/internal/tools" +) + +func TestWriteFileToolMetadataAndExecute(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + tool := NewWrite(workspace) + + if tool.Name() != writeFileToolName { + t.Fatalf("expected tool name %q, got %q", writeFileToolName, tool.Name()) + } + if tool.Description() == "" { + t.Fatalf("expected non-empty description") + } + schema := tool.Schema() + if schema["type"] != "object" { + t.Fatalf("expected schema object, got %+v", schema) + } + + tests := []struct { + name string + ctx func() context.Context + path string + content string + expectErr string + expectPath string + }{ + { + name: "creates nested file", + ctx: context.Background, + path: filepath.Join("nested", "dir", "note.txt"), + content: "hello", + expectPath: filepath.Join(workspace, "nested", "dir", "note.txt"), + }, + { + name: "rejects empty path", + ctx: context.Background, + path: "", + content: "hello", + expectErr: "path is required", + }, + { + name: "rejects path traversal", + ctx: context.Background, + path: filepath.Join("..", "escape.txt"), + content: "hello", + expectErr: "path escapes workspace root", + }, + { + name: "respects canceled context", + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + }, + path: "canceled.txt", + content: "hello", + expectErr: "context canceled", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + args, err := json.Marshal(map[string]string{ + "path": tt.path, + "content": tt.content, + }) + if err != nil { + t.Fatalf("marshal args: %v", err) + } + + result, execErr := tool.Execute(tt.ctx(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: args, + Workdir: workspace, + }) + + if tt.expectErr != "" { + if execErr == nil || !strings.Contains(execErr.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, execErr) + } + return + } + if execErr != nil { + t.Fatalf("unexpected error: %v", execErr) + } + if result.Content != "ok" { + t.Fatalf("expected ok result, got %q", result.Content) + } + + data, err := os.ReadFile(tt.expectPath) + if err != nil { + t.Fatalf("read written file: %v", err) + } + if string(data) != tt.content { + t.Fatalf("expected content %q, got %q", tt.content, string(data)) + } + }) + } +} diff --git a/internal/tools/registry.go b/internal/tools/registry.go new file mode 100644 index 00000000..46f8ec9b --- /dev/null +++ b/internal/tools/registry.go @@ -0,0 +1,81 @@ +package tools + +import ( + "context" + "errors" + "sort" + "strings" + + "github.com/dust/neo-code/internal/provider" +) + +type Registry struct { + tools map[string]Tool +} + +func NewRegistry() *Registry { + return &Registry{ + tools: map[string]Tool{}, + } +} + +func (r *Registry) Register(tool Tool) { + if tool == nil { + return + } + r.tools[strings.ToLower(tool.Name())] = tool +} + +func (r *Registry) Get(name string) (Tool, error) { + tool, ok := r.tools[strings.ToLower(name)] + if !ok { + return nil, errors.New("tool: not found") + } + return tool, nil +} + +func (r *Registry) GetSpecs() []provider.ToolSpec { + names := make([]string, 0, len(r.tools)) + for name := range r.tools { + names = append(names, name) + } + sort.Strings(names) + + specs := make([]provider.ToolSpec, 0, len(names)) + for _, name := range names { + tool := r.tools[name] + specs = append(specs, provider.ToolSpec{ + Name: tool.Name(), + Description: tool.Description(), + Schema: tool.Schema(), + }) + } + return specs +} + +func (r *Registry) ListSchemas() []provider.ToolSpec { + return r.GetSpecs() +} + +func (r *Registry) Execute(ctx context.Context, input ToolCallInput) (ToolResult, error) { + tool, err := r.Get(input.Name) + if err != nil { + return ToolResult{ + ToolCallID: input.ID, + Name: input.Name, + Content: err.Error(), + IsError: true, + }, err + } + + result, execErr := tool.Execute(ctx, input) + result.ToolCallID = input.ID + if execErr != nil { + result.IsError = true + if strings.TrimSpace(result.Content) == "" { + result.Content = execErr.Error() + } + return result, execErr + } + return result, nil +} diff --git a/internal/tools/registry_test.go b/internal/tools/registry_test.go new file mode 100644 index 00000000..f573a73a --- /dev/null +++ b/internal/tools/registry_test.go @@ -0,0 +1,146 @@ +package tools + +import ( + "context" + "errors" + "testing" +) + +type stubTool struct { + name string + description string + schema map[string]any + result ToolResult + err error +} + +func (s stubTool) Name() string { return s.name } +func (s stubTool) Description() string { return s.description } +func (s stubTool) Schema() map[string]any { + return s.schema +} +func (s stubTool) Execute(ctx context.Context, call ToolCallInput) (ToolResult, error) { + return s.result, s.err +} + +func TestRegistryGetSpecsSorted(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + registry.Register(stubTool{name: "z_tool", description: "last", schema: map[string]any{"type": "object"}}) + registry.Register(stubTool{name: "a_tool", description: "first", schema: map[string]any{"type": "object"}}) + + specs := registry.GetSpecs() + if len(specs) != 2 { + t.Fatalf("expected 2 specs, got %d", len(specs)) + } + if specs[0].Name != "a_tool" || specs[1].Name != "z_tool" { + t.Fatalf("expected sorted specs, got %+v", specs) + } +} + +func TestRegistryExecute(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + registry.Register(stubTool{ + name: "ok_tool", + description: "success tool", + schema: map[string]any{"type": "object"}, + result: ToolResult{ + Name: "ok_tool", + Content: "done", + }, + }) + registry.Register(stubTool{ + name: "error_tool", + description: "error tool", + schema: map[string]any{"type": "object"}, + result: ToolResult{ + Name: "error_tool", + }, + err: errors.New("boom"), + }) + registry.Register(stubTool{ + name: "content_error_tool", + description: "tool preserves own error content", + schema: map[string]any{"type": "object"}, + result: ToolResult{ + Name: "content_error_tool", + Content: "explicit failure", + }, + err: errors.New("boom"), + }) + + tests := []struct { + name string + input ToolCallInput + expectErr string + expectContent string + expectIsError bool + }{ + { + name: "dispatch success", + input: ToolCallInput{ + ID: "call-1", + Name: "ok_tool", + }, + expectContent: "done", + }, + { + name: "unknown tool", + input: ToolCallInput{ + ID: "call-2", + Name: "missing_tool", + }, + expectErr: "tool: not found", + expectContent: "tool: not found", + expectIsError: true, + }, + { + name: "tool error falls back to returned error text", + input: ToolCallInput{ + ID: "call-3", + Name: "error_tool", + }, + expectErr: "boom", + expectContent: "boom", + expectIsError: true, + }, + { + name: "tool error preserves explicit content", + input: ToolCallInput{ + ID: "call-4", + Name: "content_error_tool", + }, + expectErr: "boom", + expectContent: "explicit failure", + expectIsError: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result, err := registry.Execute(context.Background(), tt.input) + if tt.expectErr != "" { + if err == nil || err.Error() != tt.expectErr { + t.Fatalf("expected error %q, got %v", tt.expectErr, err) + } + } else if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.ToolCallID != tt.input.ID { + t.Fatalf("expected tool call id %q, got %q", tt.input.ID, result.ToolCallID) + } + if result.Content != tt.expectContent { + t.Fatalf("expected content %q, got %q", tt.expectContent, result.Content) + } + if result.IsError != tt.expectIsError { + t.Fatalf("expected IsError=%v, got %v", tt.expectIsError, result.IsError) + } + }) + } +} diff --git a/internal/tools/types.go b/internal/tools/types.go new file mode 100644 index 00000000..11b07e70 --- /dev/null +++ b/internal/tools/types.go @@ -0,0 +1,35 @@ +package tools + +import ( + "context" + + "github.com/dust/neo-code/internal/provider" +) + +type Tool interface { + Name() string + Description() string + Schema() map[string]any + Execute(ctx context.Context, call ToolCallInput) (ToolResult, error) +} + +type ChunkEmitter func(chunk []byte) + +type ToolCallInput struct { + ID string + Name string + Arguments []byte + SessionID string + Workdir string + EmitChunk ChunkEmitter +} + +type ToolResult struct { + ToolCallID string + Name string + Content string + IsError bool + Metadata map[string]any +} + +type ToolSpec = provider.ToolSpec diff --git a/internal/tools/webfetch/tool.go b/internal/tools/webfetch/tool.go new file mode 100644 index 00000000..b26734fa --- /dev/null +++ b/internal/tools/webfetch/tool.go @@ -0,0 +1,91 @@ +package webfetch + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "time" + + "github.com/dust/neo-code/internal/tools" +) + +type Tool struct { + client *http.Client + bodyLimit int64 +} + +type input struct { + URL string `json:"url"` +} + +func New(timeout time.Duration) *Tool { + return &Tool{ + client: &http.Client{ + Timeout: timeout, + }, + bodyLimit: 256 * 1024, + } +} + +func (t *Tool) Name() string { + return "webfetch" +} + +func (t *Tool) Description() string { + return "Fetch text content from a web page using GET with bounded response size." +} + +func (t *Tool) Schema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "url": map[string]any{ + "type": "string", + "description": "HTTP or HTTPS URL to fetch.", + }, + }, + "required": []string{"url"}, + } +} + +func (t *Tool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { + var in input + if err := json.Unmarshal(call.Arguments, &in); err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + if !strings.HasPrefix(in.URL, "http://") && !strings.HasPrefix(in.URL, "https://") { + return tools.ToolResult{Name: t.Name()}, errors.New("webfetch: url must start with http:// or https://") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, in.URL, nil) + if err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + resp, err := t.client.Do(req) + if err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, t.bodyLimit)) + if err != nil { + return tools.ToolResult{Name: t.Name()}, err + } + + result := tools.ToolResult{ + Name: t.Name(), + Content: string(body), + IsError: resp.StatusCode >= 400, + Metadata: map[string]any{ + "url": in.URL, + "status": resp.Status, + }, + } + if result.IsError { + return result, errors.New(resp.Status) + } + return result, nil +} diff --git a/internal/tools/webfetch/tool_test.go b/internal/tools/webfetch/tool_test.go new file mode 100644 index 00000000..e43bb001 --- /dev/null +++ b/internal/tools/webfetch/tool_test.go @@ -0,0 +1,116 @@ +package webfetch + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/dust/neo-code/internal/tools" +) + +func TestToolExecute(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/ok": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("hello webfetch")) + case "/fail": + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("upstream failed")) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + tool := New(2 * time.Second) + if tool.Name() != "webfetch" { + t.Fatalf("unexpected tool name %q", tool.Name()) + } + if tool.Description() == "" { + t.Fatalf("expected non-empty description") + } + if tool.Schema()["type"] != "object" { + t.Fatalf("expected schema object") + } + + tests := []struct { + name string + args any + expectErr string + expectContent string + expectStatus string + expectIsError bool + }{ + { + name: "fetch success", + args: map[string]string{"url": server.URL + "/ok"}, + expectContent: "hello webfetch", + expectStatus: "200 OK", + }, + { + name: "fetch http error", + args: map[string]string{"url": server.URL + "/fail"}, + expectErr: "502 Bad Gateway", + expectContent: "upstream failed", + expectStatus: "502 Bad Gateway", + expectIsError: true, + }, + { + name: "rejects invalid scheme", + args: map[string]string{"url": "ftp://example.com"}, + expectErr: "url must start with http:// or https://", + }, + { + name: "rejects invalid json", + args: "{", + expectErr: "JSON input", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + var raw []byte + switch value := tt.args.(type) { + case string: + raw = []byte(value) + default: + data, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal args: %v", err) + } + raw = data + } + + result, execErr := tool.Execute(context.Background(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: raw, + }) + + if tt.expectErr != "" { + if execErr == nil || !strings.Contains(execErr.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, execErr) + } + } else if execErr != nil { + t.Fatalf("unexpected error: %v", execErr) + } + + if tt.expectContent != "" && !strings.Contains(result.Content, tt.expectContent) { + t.Fatalf("expected content containing %q, got %q", tt.expectContent, result.Content) + } + if tt.expectStatus != "" && result.Metadata["status"] != tt.expectStatus { + t.Fatalf("expected status %q, got %+v", tt.expectStatus, result.Metadata) + } + if result.IsError != tt.expectIsError { + t.Fatalf("expected IsError=%v, got %v", tt.expectIsError, result.IsError) + } + }) + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go new file mode 100644 index 00000000..3f8a7538 --- /dev/null +++ b/internal/tui/app.go @@ -0,0 +1,114 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textarea" + "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" + agentruntime "github.com/dust/neo-code/internal/runtime" +) + +type App struct { + state UIState + configManager *config.Manager + runtime agentruntime.Runtime + keys keyMap + help help.Model + spinner spinner.Model + sessions list.Model + modelPicker list.Model + transcript viewport.Model + input textarea.Model + activeMessages []provider.Message + focus panel + width int + height int + styles styles +} + +func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime.Runtime) (App, error) { + if cfg == nil { + defaultCfg := config.Default() + cfg = defaultCfg + } + if configManager == nil { + return App{}, fmt.Errorf("tui: config manager is nil") + } + + uiStyles := newStyles() + keys := newKeyMap() + delegate := sessionDelegate{styles: uiStyles} + sessionList := list.New([]list.Item{}, delegate, 0, 0) + sessionList.Title = "" + sessionList.SetShowHelp(false) + sessionList.SetShowStatusBar(false) + sessionList.SetFilteringEnabled(true) + sessionList.DisableQuitKeybindings() + + input := textarea.New() + input.Placeholder = "Ask NeoCode to inspect, edit, or build. Type / to browse commands." + input.Prompt = "> " + input.CharLimit = 24000 + input.ShowLineNumbers = false + input.SetHeight(1) + input.Focus() + input.KeyMap.InsertNewline.SetEnabled(false) + + spin := spinner.New() + spin.Spinner = spinner.Line + spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorPrimary)) + + h := help.New() + h.ShowAll = false + + app := App{ + state: UIState{ + StatusText: statusReady, + CurrentProvider: cfg.SelectedProvider, + CurrentModel: cfg.CurrentModel, + CurrentWorkdir: cfg.Workdir, + 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, + } + + if err := app.refreshSessions(); err != nil { + return App{}, err + } + if len(app.state.Sessions) > 0 { + app.state.ActiveSessionID = app.state.Sessions[0].ID + if err := app.refreshMessages(); err != nil { + return App{}, err + } + } + app.syncActiveSessionTitle() + app.syncConfigState(configManager.Get()) + app.selectCurrentModel(cfg.CurrentModel) + app.resizeComponents() + return app, nil +} + +func (a App) Init() tea.Cmd { + return tea.Batch(ListenForRuntimeEvent(a.runtime.Events()), textarea.Blink, a.spinner.Tick) +} diff --git a/internal/tui/bootstrap/runtime.go b/internal/tui/bootstrap/runtime.go deleted file mode 100644 index beed9c46..00000000 --- a/internal/tui/bootstrap/runtime.go +++ /dev/null @@ -1,21 +0,0 @@ -package bootstrap - -import ( - "go-llm-demo/internal/tui/core" - "go-llm-demo/internal/tui/services" - - tea "github.com/charmbracelet/bubbletea" -) - -func NewProgram(persona string, historyTurns int, configPath, workspaceRoot string) (*tea.Program, error) { - client, err := services.NewLocalChatClient() - if err != nil { - return nil, err - } - - model := core.NewModel(client, persona, historyTurns, configPath, workspaceRoot) - return tea.NewProgram(model, - tea.WithAltScreen(), - tea.WithMouseCellMotion(), - ), nil -} diff --git a/internal/tui/bootstrap/runtime_test.go b/internal/tui/bootstrap/runtime_test.go deleted file mode 100644 index 44fe795e..00000000 --- a/internal/tui/bootstrap/runtime_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package bootstrap - -import ( - "path/filepath" - "testing" - - "go-llm-demo/configs" -) - -func TestNewProgramReturnsErrorWhenGlobalConfigMissing(t *testing.T) { - origGlobalConfig := configs.GlobalAppConfig - t.Cleanup(func() { configs.GlobalAppConfig = origGlobalConfig }) - - configs.GlobalAppConfig = nil - - p, err := NewProgram("persona", 4, "config.yaml", "D:/neo-code") - if err == nil { - t.Fatalf("expected error, got program %+v", p) - } -} - -func TestNewProgramBuildsBubbleTeaProgram(t *testing.T) { - origGlobalConfig := configs.GlobalAppConfig - t.Cleanup(func() { configs.GlobalAppConfig = origGlobalConfig }) - - cfg := configs.DefaultAppConfig() - cfg.Memory.StoragePath = filepath.Join(t.TempDir(), "memory.json") - configs.GlobalAppConfig = cfg - t.Setenv(cfg.APIKeyEnvVarName(), "secret") - - p, err := NewProgram("persona", 4, "config.yaml", "D:/neo-code") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if p == nil { - t.Fatal("expected non-nil program") - } -} diff --git a/internal/tui/bootstrap/setup.go b/internal/tui/bootstrap/setup.go deleted file mode 100644 index e523989a..00000000 --- a/internal/tui/bootstrap/setup.go +++ /dev/null @@ -1,229 +0,0 @@ -package bootstrap - -import ( - "bufio" - "context" - "errors" - "fmt" - "path/filepath" - "strings" - - "go-llm-demo/configs" - "go-llm-demo/internal/tui/services" -) - -type setupDecision int - -const ( - setupRetry setupDecision = iota - setupContinue - setupExit -) - -var ( - resolveWorkspaceRoot = services.ResolveWorkspaceRoot - setWorkspaceRoot = services.SetWorkspaceRoot - initializeSecurity = services.InitializeSecurity - ensureConfigFile = configs.EnsureConfigFile - validateChatAPIKey = services.ValidateChatAPIKey - writeAppConfig = configs.WriteAppConfig -) - -func PrepareWorkspace(workspaceFlag string) (string, error) { - workspaceRoot, err := resolveWorkspaceRoot(workspaceFlag) - if err != nil { - return "", fmt.Errorf("解析工作区失败: %w", err) - } - if err := setWorkspaceRoot(workspaceRoot); err != nil { - return "", fmt.Errorf("设置工作区失败: %w", err) - } - if err := initializeSecurity(filepath.Join(workspaceRoot, "configs", "security")); err != nil { - return "", fmt.Errorf("初始化安全策略失败: %w", err) - } - return workspaceRoot, nil -} - -func EnsureAPIKeyInteractive(ctx context.Context, scanner *bufio.Scanner, configPath string) (bool, error) { - cfg, created, err := ensureConfigFile(configPath) - if err != nil { - return false, err - } - if created { - fmt.Printf("Created %s\n", configPath) - } - - for { - if cfg.RuntimeAPIKey() == "" { - envName := cfg.APIKeyEnvVarName() - fmt.Printf("Environment variable %s is not set. Use /apikey , /provider , or /switch to change the configuration, or set the variable and then run /retry.\n", envName) - fmt.Printf("Windows example: setx %s \"your-api-key\"\n", envName) - result, handleErr := handleSetupDecision(scanner, cfg, false, configPath) - if handleErr != nil { - return false, handleErr - } - if result == setupExit { - return false, nil - } - continue - } - - if err := validateChatAPIKey(ctx, cfg); err == nil { - if saveErr := writeAppConfig(configPath, cfg); saveErr != nil { - return false, saveErr - } - configs.GlobalAppConfig = cfg - fmt.Println("API key validation passed.") - return true, nil - } else if errors.Is(err, services.ErrInvalidAPIKey) { - fmt.Printf("The API key in environment variable %s is invalid: %v\n", cfg.APIKeyEnvVarName(), err) - result, handleErr := handleSetupDecision(scanner, cfg, false, configPath) - if handleErr != nil { - return false, handleErr - } - if result == setupExit { - return false, nil - } - continue - } else if errors.Is(err, services.ErrAPIKeyValidationSoft) { - fmt.Printf("Could not verify the API key in environment variable %s: %v\n", cfg.APIKeyEnvVarName(), err) - result, handleErr := handleSetupDecision(scanner, cfg, true, configPath) - if handleErr != nil { - return false, handleErr - } - if result == setupExit { - return false, nil - } - if result == setupContinue { - configs.GlobalAppConfig = cfg - return true, nil - } - continue - } else { - fmt.Printf("Model validation failed: %v\n", err) - result, handleErr := handleSetupDecision(scanner, cfg, false, configPath) - if handleErr != nil { - return false, handleErr - } - if result == setupExit { - return false, nil - } - if result == setupContinue { - configs.GlobalAppConfig = cfg - return true, nil - } - } - } -} - -func handleSetupDecision(scanner *bufio.Scanner, cfg *configs.AppConfiguration, allowContinue bool, configPath string) (setupDecision, error) { - for { - prompt := "Choose /retry, /apikey , /provider , /switch , or /exit > " - if allowContinue { - prompt = "Choose /retry, /continue, /apikey , /provider , /switch , or /exit > " - } - decision, ok, inputErr := readInteractiveLine(scanner, prompt) - if inputErr != nil { - return setupExit, inputErr - } - if !ok { - return setupExit, nil - } - - fields := strings.Fields(strings.TrimSpace(decision)) - if len(fields) == 0 { - continue - } - - switch strings.ToLower(fields[0]) { - case "/retry": - return setupRetry, nil - case "/apikey": - if len(fields) < 2 { - fmt.Println("Usage: /apikey ") - continue - } - applyAPIKeyEnvName(cfg, fields[1]) - fmt.Printf("Switched the API key environment variable name to: %s\n", cfg.APIKeyEnvVarName()) - return setupRetry, nil - case "/continue": - if !allowContinue { - fmt.Println("/continue is only available when the API key cannot be verified due to a network or service issue.") - continue - } - if saveErr := writeAppConfig(configPath, cfg); saveErr != nil { - return setupExit, saveErr - } - fmt.Println("Continuing with the current API key and model.") - return setupContinue, nil - case "/provider": - if len(fields) < 2 { - fmt.Println("Usage: /provider ") - printSupportedProviders() - continue - } - providerName, ok := services.NormalizeProviderName(fields[1]) - if !ok { - fmt.Printf("Unsupported provider %q\n", fields[1]) - printSupportedProviders() - continue - } - cfg.AI.Provider = providerName - cfg.AI.Model = services.DefaultModelForProvider(providerName) - fmt.Printf("Switched provider to: %s\n", providerName) - fmt.Printf("Reset the current model to the default: %s\n", cfg.AI.Model) - return setupRetry, nil - case "/switch": - if len(fields) < 2 { - fmt.Println("Usage: /switch ") - continue - } - target := strings.Join(fields[1:], " ") - cfg.AI.Model = target - fmt.Printf("Switched model to: %s\n", target) - return setupRetry, nil - case "/exit": - return setupExit, nil - default: - if allowContinue { - fmt.Println("Enter /retry, /continue, /apikey , /provider , /switch , or /exit.") - } else { - fmt.Println("Enter /retry, /apikey , /provider , /switch , or /exit.") - } - } - } -} - -func applyAPIKeyEnvName(cfg *configs.AppConfiguration, envName string) { - if cfg == nil { - return - } - cfg.AI.APIKey = strings.TrimSpace(envName) -} - -func readInteractiveLine(scanner *bufio.Scanner, prompt string) (string, bool, error) { - for { - fmt.Print(prompt) - if !scanner.Scan() { - if err := scanner.Err(); err != nil { - return "", false, err - } - return "", false, nil - } - input := strings.TrimSpace(scanner.Text()) - if input == "" { - fmt.Println("Input cannot be empty.") - continue - } - if input == "/exit" { - return "", false, nil - } - return input, true, nil - } -} - -func printSupportedProviders() { - fmt.Println("Supported providers:") - for _, name := range services.SupportedProviders() { - fmt.Printf(" %s\n", name) - } -} diff --git a/internal/tui/bootstrap/setup_test.go b/internal/tui/bootstrap/setup_test.go deleted file mode 100644 index 0c48f382..00000000 --- a/internal/tui/bootstrap/setup_test.go +++ /dev/null @@ -1,528 +0,0 @@ -package bootstrap - -import ( - "bufio" - "context" - "errors" - "io" - "path/filepath" - "strings" - "testing" - - "go-llm-demo/configs" - "go-llm-demo/internal/tui/services" -) - -type errReader struct{} - -func (errReader) Read([]byte) (int, error) { - return 0, io.ErrUnexpectedEOF -} - -func restoreSetupGlobals(t *testing.T) { - t.Helper() - - origResolveWorkspaceRoot := resolveWorkspaceRoot - origSetWorkspaceRoot := setWorkspaceRoot - origInitializeSecurity := initializeSecurity - origEnsureConfigFile := ensureConfigFile - origValidateChatAPIKey := validateChatAPIKey - origWriteAppConfig := writeAppConfig - origGlobalConfig := configs.GlobalAppConfig - - t.Cleanup(func() { - resolveWorkspaceRoot = origResolveWorkspaceRoot - setWorkspaceRoot = origSetWorkspaceRoot - initializeSecurity = origInitializeSecurity - ensureConfigFile = origEnsureConfigFile - validateChatAPIKey = origValidateChatAPIKey - writeAppConfig = origWriteAppConfig - configs.GlobalAppConfig = origGlobalConfig - }) -} - -func TestApplyAPIKeyEnvNameUpdatesConfig(t *testing.T) { - cfg := configs.DefaultAppConfig() - applyAPIKeyEnvName(cfg, " TEST_KEY_ENV ") - - if got := cfg.AI.APIKey; got != "TEST_KEY_ENV" { - t.Fatalf("expected API key env name to be trimmed, got %q", got) - } -} - -func TestReadInteractiveLineRejectsEmptyInputThenReadsValue(t *testing.T) { - scanner := bufio.NewScanner(strings.NewReader("\n /retry \n")) - - got, ok, err := readInteractiveLine(scanner, "> ") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if !ok { - t.Fatal("expected ok=true") - } - if got != "/retry" { - t.Fatalf("expected trimmed input, got %q", got) - } -} - -func TestReadInteractiveLineTreatsExitAsStop(t *testing.T) { - scanner := bufio.NewScanner(strings.NewReader("/exit\n")) - - got, ok, err := readInteractiveLine(scanner, "> ") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if ok { - t.Fatal("expected ok=false for /exit") - } - if got != "" { - t.Fatalf("expected empty value, got %q", got) - } -} - -func TestHandleSetupDecisionHandlesProviderSwitch(t *testing.T) { - cfg := configs.DefaultAppConfig() - scanner := bufio.NewScanner(strings.NewReader("/provider openai\n")) - - decision, err := handleSetupDecision(scanner, cfg, false, "config.yaml") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if decision != setupRetry { - t.Fatalf("expected setupRetry, got %v", decision) - } - if cfg.AI.Provider != "openai" { - t.Fatalf("expected provider to switch, got %q", cfg.AI.Provider) - } - if cfg.AI.Model == "" { - t.Fatal("expected provider switch to set a default model") - } -} - -func TestHandleSetupDecisionRejectsContinueWhenNotAllowed(t *testing.T) { - cfg := configs.DefaultAppConfig() - scanner := bufio.NewScanner(strings.NewReader("/continue\n/retry\n")) - - decision, err := handleSetupDecision(scanner, cfg, false, "config.yaml") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if decision != setupRetry { - t.Fatalf("expected setupRetry after rejecting continue, got %v", decision) - } -} - -func TestPrepareWorkspaceResolvesAndSetsWorkspaceRoot(t *testing.T) { - restoreSetupGlobals(t) - - var setRoot string - var initializedConfigDir string - resolveWorkspaceRoot = func(workspaceFlag string) (string, error) { - if workspaceFlag != "./workspace" { - t.Fatalf("expected workspace flag to flow through, got %q", workspaceFlag) - } - return "D:/neo-code/workspace", nil - } - setWorkspaceRoot = func(root string) error { - setRoot = root - return nil - } - initializeSecurity = func(configDir string) error { - initializedConfigDir = configDir - return nil - } - - root, err := PrepareWorkspace("./workspace") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if root != "D:/neo-code/workspace" { - t.Fatalf("unexpected workspace root %q", root) - } - if setRoot != root { - t.Fatalf("expected SetWorkspaceRoot to receive %q, got %q", root, setRoot) - } - if initializedConfigDir != filepath.Join("D:/neo-code/workspace", "configs", "security") { - t.Fatalf("expected security config dir to be initialized, got %q", initializedConfigDir) - } -} - -func TestPrepareWorkspaceReturnsSetWorkspaceRootError(t *testing.T) { - restoreSetupGlobals(t) - - resolveWorkspaceRoot = func(string) (string, error) { return "D:/neo-code/workspace", nil } - setWorkspaceRoot = func(string) error { return errors.New("set failed") } - initializeSecurity = func(string) error { return nil } - - _, err := PrepareWorkspace("./workspace") - if err == nil || !strings.Contains(err.Error(), "set failed") { - t.Fatalf("expected SetWorkspaceRoot error, got %v", err) - } -} - -func TestPrepareWorkspaceReturnsResolveError(t *testing.T) { - restoreSetupGlobals(t) - - resolveWorkspaceRoot = func(string) (string, error) { return "", errors.New("resolve failed") } - - _, err := PrepareWorkspace("./workspace") - if err == nil || !strings.Contains(err.Error(), "resolve failed") { - t.Fatalf("expected resolve error, got %v", err) - } -} - -func TestPrepareWorkspaceReturnsInitializeSecurityError(t *testing.T) { - restoreSetupGlobals(t) - - resolveWorkspaceRoot = func(string) (string, error) { return "D:/neo-code/workspace", nil } - setWorkspaceRoot = func(string) error { return nil } - initializeSecurity = func(string) error { return errors.New("security failed") } - - _, err := PrepareWorkspace("./workspace") - if err == nil || !strings.Contains(err.Error(), "security failed") { - t.Fatalf("expected initialize security error, got %v", err) - } -} - -func TestReadInteractiveLineReturnsEOF(t *testing.T) { - scanner := bufio.NewScanner(strings.NewReader("")) - - got, ok, err := readInteractiveLine(scanner, "> ") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if ok || got != "" { - t.Fatalf("expected EOF stop, got value=%q ok=%v", got, ok) - } -} - -func TestReadInteractiveLineReturnsScannerError(t *testing.T) { - scanner := bufio.NewScanner(errReader{}) - - _, _, err := readInteractiveLine(scanner, "> ") - if err == nil { - t.Fatal("expected scanner error") - } -} - -func TestHandleSetupDecisionAPIKeyRequiresArgument(t *testing.T) { - cfg := configs.DefaultAppConfig() - scanner := bufio.NewScanner(strings.NewReader("/apikey\n/apikey TEST_ENV\n")) - - decision, err := handleSetupDecision(scanner, cfg, false, "config.yaml") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if decision != setupRetry { - t.Fatalf("expected setupRetry, got %v", decision) - } - if cfg.AI.APIKey != "TEST_ENV" { - t.Fatalf("expected API key env to switch, got %q", cfg.AI.APIKey) - } -} - -func TestHandleSetupDecisionAllowsContinue(t *testing.T) { - restoreSetupGlobals(t) - - cfg := configs.DefaultAppConfig() - writeCalled := false - writeAppConfig = func(string, *configs.AppConfiguration) error { - writeCalled = true - return nil - } - - decision, err := handleSetupDecision(bufio.NewScanner(strings.NewReader("/continue\n")), cfg, true, "config.yaml") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if decision != setupContinue { - t.Fatalf("expected setupContinue, got %v", decision) - } - if !writeCalled { - t.Fatal("expected config write on continue") - } -} - -func TestHandleSetupDecisionContinueWriteFailure(t *testing.T) { - restoreSetupGlobals(t) - - cfg := configs.DefaultAppConfig() - writeAppConfig = func(string, *configs.AppConfiguration) error { return errors.New("write failed") } - - decision, err := handleSetupDecision(bufio.NewScanner(strings.NewReader("/continue\n")), cfg, true, "config.yaml") - if err == nil || !strings.Contains(err.Error(), "write failed") { - t.Fatalf("expected write failure, got decision=%v err=%v", decision, err) - } -} - -func TestHandleSetupDecisionProviderRequiresArgumentAndRejectsUnknownProvider(t *testing.T) { - cfg := configs.DefaultAppConfig() - scanner := bufio.NewScanner(strings.NewReader("/provider\n/provider invalid\n/provider openai\n")) - - decision, err := handleSetupDecision(scanner, cfg, false, "config.yaml") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if decision != setupRetry { - t.Fatalf("expected setupRetry, got %v", decision) - } - if cfg.AI.Provider != "openai" { - t.Fatalf("expected provider to switch after retries, got %q", cfg.AI.Provider) - } -} - -func TestHandleSetupDecisionSwitchRequiresArgumentThenSucceeds(t *testing.T) { - cfg := configs.DefaultAppConfig() - scanner := bufio.NewScanner(strings.NewReader("/switch\n/switch gpt-5.4-mini\n")) - - decision, err := handleSetupDecision(scanner, cfg, false, "config.yaml") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if decision != setupRetry { - t.Fatalf("expected setupRetry, got %v", decision) - } - if cfg.AI.Model != "gpt-5.4-mini" { - t.Fatalf("expected model switch, got %q", cfg.AI.Model) - } -} - -func TestHandleSetupDecisionUnknownCommandThenExit(t *testing.T) { - cfg := configs.DefaultAppConfig() - scanner := bufio.NewScanner(strings.NewReader("/unknown\n/exit\n")) - - decision, err := handleSetupDecision(scanner, cfg, false, "config.yaml") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if decision != setupExit { - t.Fatalf("expected setupExit, got %v", decision) - } -} - -func TestEnsureAPIKeyInteractiveReturnsConfigError(t *testing.T) { - restoreSetupGlobals(t) - - ensureConfigFile = func(string) (*configs.AppConfiguration, bool, error) { - return nil, false, errors.New("config failed") - } - - ready, err := EnsureAPIKeyInteractive(context.Background(), bufio.NewScanner(strings.NewReader("")), "config.yaml") - if err == nil || !strings.Contains(err.Error(), "config failed") { - t.Fatalf("expected config error, got ready=%v err=%v", ready, err) - } -} - -func TestEnsureAPIKeyInteractiveExitsWhenAPIKeyMissing(t *testing.T) { - restoreSetupGlobals(t) - - cfg := configs.DefaultAppConfig() - cfg.AI.APIKey = "MISSING_ENV" - ensureConfigFile = func(string) (*configs.AppConfiguration, bool, error) { - return cfg, false, nil - } - validateCalled := false - validateChatAPIKey = func(context.Context, *configs.AppConfiguration) error { - validateCalled = true - return nil - } - - ready, err := EnsureAPIKeyInteractive(context.Background(), bufio.NewScanner(strings.NewReader("/exit\n")), "config.yaml") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if ready { - t.Fatal("expected setup to stop without becoming ready") - } - if validateCalled { - t.Fatal("validation should not run when runtime API key is missing") - } -} - -func TestEnsureAPIKeyInteractiveWritesConfigAfterSuccessfulValidation(t *testing.T) { - restoreSetupGlobals(t) - - cfg := configs.DefaultAppConfig() - cfg.AI.APIKey = "READY_ENV" - t.Setenv("READY_ENV", "secret") - - ensureConfigFile = func(string) (*configs.AppConfiguration, bool, error) { - return cfg, false, nil - } - validateChatAPIKey = func(context.Context, *configs.AppConfiguration) error { return nil } - var writePath string - writeAppConfig = func(path string, gotCfg *configs.AppConfiguration) error { - writePath = path - if gotCfg != cfg { - t.Fatal("expected the same config instance to be written") - } - return nil - } - - ready, err := EnsureAPIKeyInteractive(context.Background(), bufio.NewScanner(strings.NewReader("")), "config.yaml") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if !ready { - t.Fatal("expected setup to become ready") - } - if writePath != "config.yaml" { - t.Fatalf("expected config write path config.yaml, got %q", writePath) - } - if configs.GlobalAppConfig != cfg { - t.Fatal("expected global config to be updated after successful validation") - } -} - -func TestEnsureAPIKeyInteractiveAllowsContinueOnSoftValidationError(t *testing.T) { - restoreSetupGlobals(t) - - cfg := configs.DefaultAppConfig() - cfg.AI.APIKey = "READY_ENV" - t.Setenv("READY_ENV", "secret") - - ensureConfigFile = func(string) (*configs.AppConfiguration, bool, error) { - return cfg, false, nil - } - validateChatAPIKey = func(context.Context, *configs.AppConfiguration) error { - return services.ErrAPIKeyValidationSoft - } - writeCount := 0 - writeAppConfig = func(string, *configs.AppConfiguration) error { - writeCount++ - return nil - } - - ready, err := EnsureAPIKeyInteractive(context.Background(), bufio.NewScanner(strings.NewReader("/continue\n")), "config.yaml") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if !ready { - t.Fatal("expected continue to allow startup") - } - if writeCount != 1 { - t.Fatalf("expected config to be written once, got %d", writeCount) - } - if configs.GlobalAppConfig != cfg { - t.Fatal("expected global config to be updated on continue") - } -} - -func TestEnsureAPIKeyInteractiveReportsCreatedConfigThenSucceeds(t *testing.T) { - restoreSetupGlobals(t) - - cfg := configs.DefaultAppConfig() - cfg.AI.APIKey = "READY_ENV" - t.Setenv("READY_ENV", "secret") - - ensureConfigFile = func(string) (*configs.AppConfiguration, bool, error) { - return cfg, true, nil - } - validateChatAPIKey = func(context.Context, *configs.AppConfiguration) error { return nil } - writeAppConfig = func(string, *configs.AppConfiguration) error { return nil } - - ready, err := EnsureAPIKeyInteractive(context.Background(), bufio.NewScanner(strings.NewReader("")), "config.yaml") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if !ready { - t.Fatal("expected setup to become ready") - } -} - -func TestEnsureAPIKeyInteractiveRetriesAfterChangingAPIKeyEnv(t *testing.T) { - restoreSetupGlobals(t) - - cfg := configs.DefaultAppConfig() - cfg.AI.APIKey = "MISSING_ENV" - t.Setenv("RECOVERED_ENV", "secret") - - ensureConfigFile = func(string) (*configs.AppConfiguration, bool, error) { - return cfg, false, nil - } - validateCount := 0 - validateChatAPIKey = func(context.Context, *configs.AppConfiguration) error { - validateCount++ - return nil - } - writeAppConfig = func(string, *configs.AppConfiguration) error { return nil } - - ready, err := EnsureAPIKeyInteractive(context.Background(), bufio.NewScanner(strings.NewReader("/apikey RECOVERED_ENV\n/retry\n")), "config.yaml") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if !ready { - t.Fatal("expected setup to become ready after retry") - } - if cfg.AI.APIKey != "RECOVERED_ENV" { - t.Fatalf("expected env name update, got %q", cfg.AI.APIKey) - } - if validateCount != 1 { - t.Fatalf("expected one validation call after retry, got %d", validateCount) - } -} - -func TestEnsureAPIKeyInteractiveHandlesInvalidAPIKeyAndExit(t *testing.T) { - restoreSetupGlobals(t) - - cfg := configs.DefaultAppConfig() - cfg.AI.APIKey = "READY_ENV" - t.Setenv("READY_ENV", "secret") - - ensureConfigFile = func(string) (*configs.AppConfiguration, bool, error) { - return cfg, false, nil - } - validateChatAPIKey = func(context.Context, *configs.AppConfiguration) error { - return services.ErrInvalidAPIKey - } - - ready, err := EnsureAPIKeyInteractive(context.Background(), bufio.NewScanner(strings.NewReader("/exit\n")), "config.yaml") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if ready { - t.Fatal("expected setup to stop on invalid key + exit") - } -} - -func TestEnsureAPIKeyInteractiveHandlesGenericValidationErrorAndExit(t *testing.T) { - restoreSetupGlobals(t) - - cfg := configs.DefaultAppConfig() - cfg.AI.APIKey = "READY_ENV" - t.Setenv("READY_ENV", "secret") - - ensureConfigFile = func(string) (*configs.AppConfiguration, bool, error) { - return cfg, false, nil - } - validateChatAPIKey = func(context.Context, *configs.AppConfiguration) error { - return errors.New("validation failed") - } - - ready, err := EnsureAPIKeyInteractive(context.Background(), bufio.NewScanner(strings.NewReader("/exit\n")), "config.yaml") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if ready { - t.Fatal("expected setup to stop on generic validation failure") - } -} - -func TestEnsureAPIKeyInteractiveReturnsWriteErrorAfterValidationSuccess(t *testing.T) { - restoreSetupGlobals(t) - - cfg := configs.DefaultAppConfig() - cfg.AI.APIKey = "READY_ENV" - t.Setenv("READY_ENV", "secret") - - ensureConfigFile = func(string) (*configs.AppConfiguration, bool, error) { - return cfg, false, nil - } - validateChatAPIKey = func(context.Context, *configs.AppConfiguration) error { return nil } - writeAppConfig = func(string, *configs.AppConfiguration) error { return errors.New("write failed") } - - ready, err := EnsureAPIKeyInteractive(context.Background(), bufio.NewScanner(strings.NewReader("")), "config.yaml") - if err == nil || !strings.Contains(err.Error(), "write failed") { - t.Fatalf("expected write error, got ready=%v err=%v", ready, err) - } -} diff --git a/internal/tui/commands.go b/internal/tui/commands.go new file mode 100644 index 00000000..c7f176f1 --- /dev/null +++ b/internal/tui/commands.go @@ -0,0 +1,235 @@ +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" +) + +const ( + slashPrefix = "/" + slashCommandSet = "/set" + slashCommandModelPicker = "/model" + + slashUsageSetURL = "/set url " + slashUsageSetKey = "/set key " + 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" + + 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" + + focusLabelSessions = "Sessions" + focusLabelTranscript = "Transcript" + focusLabelComposer = "Composer" + + messageTagUser = "[ YOU ]" + messageTagAgent = "[ NEO ]" + messageTagTool = "[ TOOL ]" + + roleUser = "user" + roleAssistant = "assistant" + roleTool = "tool" + roleEvent = "event" + roleError = "error" + roleSystem = "system" +) + +type slashCommand struct { + Usage string + Description string +} + +type commandSuggestion struct { + Command slashCommand + Match bool +} + +var builtinSlashCommands = []slashCommand{ + {Usage: slashUsageSetURL, Description: "Set the API Base URL"}, + {Usage: slashUsageSetKey, Description: "Update the API Key"}, + {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}) + } + + delegate := list.NewDefaultDelegate() + picker := list.New(items, delegate, 0, 0) + picker.Title = "" + picker.SetShowHelp(false) + picker.SetShowStatusBar(false) + picker.SetFilteringEnabled(false) + picker.DisableQuitKeybindings() + return picker +} + +func (a *App) openModelPicker() { + a.state.ShowModelPicker = true + a.state.StatusText = statusChooseModel + a.input.Blur() + a.selectCurrentModel(a.state.CurrentModel) +} + +func (a *App) closeModelPicker() { + a.state.ShowModelPicker = false + a.focus = panelInput + a.applyFocus() +} + +func (a *App) selectCurrentModel(model string) { + items := a.modelPicker.Items() + for idx, item := range items { + candidate, ok := item.(modelItem) + if ok && strings.EqualFold(candidate.name, model) { + a.modelPicker.Select(idx) + return + } + } + if len(items) > 0 { + a.modelPicker.Select(0) + } +} + +func (a App) matchingSlashCommands(input string) []commandSuggestion { + if !strings.HasPrefix(input, slashPrefix) { + return nil + } + + query := strings.ToLower(strings.TrimSpace(input)) + out := make([]commandSuggestion, 0, len(builtinSlashCommands)) + for _, command := range builtinSlashCommands { + normalized := strings.ToLower(command.Usage) + match := query == slashPrefix || strings.HasPrefix(normalized, query) + if query == slashPrefix || match || strings.Contains(normalized, query) { + out = append(out, commandSuggestion{Command: command, Match: match}) + } + } + 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 { + 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() + if err != nil { + return "", err + } + if err := configManager.UpsertEnv(selected.APIKeyEnv, value); err != nil { + return "", fmt.Errorf("persist api key: %w", err) + } + 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 + } + } + 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 new file mode 100644 index 00000000..2eaf306c --- /dev/null +++ b/internal/tui/commands_test.go @@ -0,0 +1,175 @@ +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) + } + }) + } +} + +func TestMatchingSlashCommands(t *testing.T) { + t.Parallel() + + app := App{} + tests := []struct { + name string + input string + expectCount int + expectUsage string + }{ + { + name: "non slash input returns no suggestions", + input: "hello", + expectCount: 0, + }, + { + name: "bare slash returns all commands", + input: "/", + expectCount: len(builtinSlashCommands), + expectUsage: slashUsageModel, + }, + { + name: "prefix narrows suggestions", + input: "/set u", + expectCount: 1, + expectUsage: slashUsageSetURL, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := app.matchingSlashCommands(tt.input) + if len(got) != tt.expectCount { + t.Fatalf("expected %d suggestions, got %d", tt.expectCount, len(got)) + } + if tt.expectUsage != "" && (len(got) == 0 || got[0].Command.Usage != tt.expectUsage && !containsUsage(got, tt.expectUsage)) { + t.Fatalf("expected suggestions to contain %q, got %+v", tt.expectUsage, got) + } + }) + } +} + +func containsUsage(suggestions []commandSuggestion, usage string) bool { + for _, suggestion := range suggestions { + if suggestion.Command.Usage == usage { + return true + } + } + return false +} diff --git a/internal/tui/components/code_block.go b/internal/tui/components/code_block.go deleted file mode 100644 index 46e0700b..00000000 --- a/internal/tui/components/code_block.go +++ /dev/null @@ -1,253 +0,0 @@ -package components - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -type SegmentType int - -const ( - SegmentText SegmentType = iota - SegmentCodeBlock -) - -const copyActionLabel = "[Copy]" - -var codeBlockStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ABB2BF")). - Background(lipgloss.Color("#282C34")). - Padding(0, 1) - -var codeBlockHeaderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#61AFEF")). - Background(lipgloss.Color("#21252B")). - Bold(true). - Padding(0, 1) - -type ContentSegment struct { - Type SegmentType - Text string - Lang string - Code string - Closed bool -} - -type CodeBlockRef struct { - MessageIndex int - BlockIndex int - Lang string - Code string -} - -type ClickableRegion struct { - Kind string - StartRow int - EndRow int - StartCol int - EndCol int - CodeBlock CodeBlockRef -} - -type RenderedChatLayout struct { - Content string - Regions []ClickableRegion -} - -func RenderContent(content string, width int) string { - return RenderSegments(ParseContentSegments(content), width) -} - -func ParseContentSegments(content string) []ContentSegment { - if content == "" { - return nil - } - - lines := strings.Split(content, "\n") - segments := make([]ContentSegment, 0) - textLines := make([]string, 0) - inCodeBlock := false - codeLang := "" - codeLines := make([]string, 0) - - flushText := func() { - if len(textLines) == 0 { - return - } - segments = append(segments, ContentSegment{ - Type: SegmentText, - Text: strings.Join(textLines, "\n"), - }) - textLines = textLines[:0] - } - - flushCode := func(closed bool) { - segments = append(segments, ContentSegment{ - Type: SegmentCodeBlock, - Lang: codeLang, - Code: strings.Join(codeLines, "\n"), - Closed: closed, - }) - codeLang = "" - codeLines = codeLines[:0] - } - - for _, line := range lines { - trimmedLine := strings.TrimSpace(line) - if isFenceLine(trimmedLine) { - if !inCodeBlock { - flushText() - inCodeBlock = true - codeLang = parseFenceLanguage(trimmedLine) - codeLines = codeLines[:0] - } else { - inCodeBlock = false - flushCode(true) - } - continue - } - - if inCodeBlock { - codeLines = append(codeLines, line) - continue - } - - textLines = append(textLines, line) - } - - flushText() - if inCodeBlock { - flushCode(false) - } - - return segments -} - -func RenderSegments(segments []ContentSegment, width int) string { - if len(segments) == 0 { - return "..." - } - if width <= 0 { - width = 80 - } - - var b strings.Builder - textStyle := lipgloss.NewStyle().MaxWidth(width) - - for _, segment := range segments { - switch segment.Type { - case SegmentCodeBlock: - b.WriteString(RenderCodeBlock(segment, width, "")) - case SegmentText: - lines := strings.Split(segment.Text, "\n") - for _, line := range lines { - b.WriteString(textStyle.Render(line)) - b.WriteString("\n") - } - } - } - - return b.String() -} - -func RenderCodeBlock(segment ContentSegment, width int, actionLabel string) string { - var b strings.Builder - resolvedLang := strings.TrimSpace(segment.Lang) - if resolvedLang == "" { - resolvedLang = DetectLanguage(segment.Code) - } - header, resolvedLang := renderCodeBlockHeader(resolvedLang, width, actionLabel) - b.WriteString(header) - b.WriteString("\n") - b.WriteString(HighlightCodeBlock(strings.Split(segment.Code, "\n"), resolvedLang, width, segment.Closed)) - return b.String() -} - -func renderCodeBlockHeader(lang string, width int, actionLabel string) (string, string) { - resolvedLang := strings.TrimSpace(lang) - if width <= 0 { - width = 80 - } - parts := make([]string, 0, 2) - if strings.TrimSpace(actionLabel) != "" { - parts = append(parts, strings.TrimSpace(actionLabel)) - } - if resolvedLang == "" { - resolvedLang = "text" - } - parts = append(parts, resolvedLang) - return codeBlockHeaderStyle.MaxWidth(width).Render(strings.Join(parts, " ")), resolvedLang -} - -func BuildCopyRegion(messageIndex, blockIndex, row int, code string, lang string) ClickableRegion { - return ClickableRegion{ - Kind: "copy", - StartRow: row, - EndRow: row, - StartCol: 1, - EndCol: len(copyActionLabel), - CodeBlock: CodeBlockRef{ - MessageIndex: messageIndex, - BlockIndex: blockIndex, - Lang: strings.TrimSpace(lang), - Code: code, - }, - } -} - -func CopyActionLabel() string { - return copyActionLabel -} - -func HighlightCodeBlock(lines []string, lang string, width int, closed bool) string { - var b strings.Builder - code := strings.Join(lines, "\n") - resolvedLang := strings.TrimSpace(lang) - if resolvedLang == "" { - resolvedLang = DetectLanguage(code) - } - if resolvedLang == "" { - resolvedLang = "text" - } - - highlighted := HighlightCode(code, resolvedLang) - b.WriteString("```") - b.WriteString(resolvedLang) - b.WriteString("\n") - b.WriteString(highlighted) - if !strings.HasSuffix(highlighted, "\n") { - b.WriteString("\n") - } - if closed { - b.WriteString("```\n") - } - - blockStyle := codeBlockStyle.MaxWidth(width) - return blockStyle.Render(b.String()) + "\n" -} - -func FormatCopyNotice(ref CodeBlockRef) string { - lang := strings.TrimSpace(ref.Lang) - if lang == "" { - lang = "text" - } - lineCount := 0 - trimmed := strings.TrimSuffix(ref.Code, "\n") - if trimmed != "" { - lineCount = strings.Count(trimmed, "\n") + 1 - } - if lineCount == 0 && strings.TrimSpace(ref.Code) != "" { - lineCount = 1 - } - return fmt.Sprintf("Copied %s code block (%d lines)", lang, lineCount) -} - -func isFenceLine(line string) bool { - return strings.HasPrefix(line, "```") -} - -func parseFenceLanguage(line string) string { - return strings.TrimSpace(strings.TrimPrefix(line, "```")) -} diff --git a/internal/tui/components/code_block_test.go b/internal/tui/components/code_block_test.go deleted file mode 100644 index 22417ad4..00000000 --- a/internal/tui/components/code_block_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package components - -import ( - "strings" - "testing" -) - -func TestFenceHelpers(t *testing.T) { - tests := []struct { - name string - line string - want bool - lang string - }{ - {name: "plain fence", line: "```", want: true, lang: ""}, - {name: "fence with language", line: "```python", want: true, lang: "python"}, - {name: "indented fence", line: " ```go ", want: true, lang: "go"}, - {name: "non fence", line: "```go fmt.Println()", want: true, lang: "go fmt.Println()"}, - {name: "plain text", line: "hello", want: false, lang: ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - trimmed := strings.TrimSpace(tt.line) - if got := isFenceLine(trimmed); got != tt.want { - t.Fatalf("expected fence=%v, got %v", tt.want, got) - } - if tt.want { - if got := parseFenceLanguage(trimmed); got != tt.lang { - t.Fatalf("expected lang %q, got %q", tt.lang, got) - } - } - }) - } -} - -func TestRenderContentRendersClosedCodeBlock(t *testing.T) { - content := "before\n```python\ndef bubble_sort(arr):\n for i in range(len(arr)):\n pass\n```\nafter" - rendered := RenderContent(content, 80) - - for _, want := range []string{"before", "```python", "def bubble_sort(arr):", "for i in range(len(arr)):", "```", "after"} { - if !strings.Contains(rendered, want) { - t.Fatalf("expected rendered content to contain %q, got %q", want, rendered) - } - } - if strings.Contains(rendered, "def bubble_sort(arr): for i in range(len(arr)):") { - t.Fatalf("expected code lines not to collapse into one line, got %q", rendered) - } -} - -func TestRenderContentRendersUnclosedCodeBlockWhileStreaming(t *testing.T) { - content := "before\n```python\ndef bubble_sort(arr):\n return arr" - rendered := RenderContent(content, 80) - - for _, want := range []string{"before", "```python", "def bubble_sort(arr):", "return arr"} { - if !strings.Contains(rendered, want) { - t.Fatalf("expected rendered content to contain %q, got %q", want, rendered) - } - } - if strings.Contains(rendered, "def bubble_sort(arr): return arr") { - t.Fatalf("expected streaming code lines not to collapse into one line, got %q", rendered) - } -} - -func TestRenderContentDetectsLanguageWhenFenceDoesNotSpecifyOne(t *testing.T) { - content := "```\ndef greet():\n return 1\n```" - rendered := RenderContent(content, 80) - - if !strings.Contains(rendered, "```python") { - t.Fatalf("expected detected language header, got %q", rendered) - } - if !strings.Contains(rendered, "def greet():") { - t.Fatalf("expected code content, got %q", rendered) - } -} - -func TestRenderContentSupportsIndentedFence(t *testing.T) { - content := " ```bash \necho hi\n ```" - rendered := RenderContent(content, 80) - - for _, want := range []string{"```bash", "echo hi"} { - if !strings.Contains(rendered, want) { - t.Fatalf("expected rendered content to contain %q, got %q", want, rendered) - } - } -} - -func TestParseContentSegmentsExtractsTextAndCodeBlocks(t *testing.T) { - segments := ParseContentSegments("before\n```go\nfmt.Println(1)\n```\nafter") - if len(segments) != 3 { - t.Fatalf("expected 3 segments, got %d", len(segments)) - } - if segments[0].Type != SegmentText || segments[0].Text != "before" { - t.Fatalf("unexpected first segment: %+v", segments[0]) - } - if segments[1].Type != SegmentCodeBlock || segments[1].Lang != "go" || segments[1].Code != "fmt.Println(1)" || !segments[1].Closed { - t.Fatalf("unexpected code segment: %+v", segments[1]) - } - if segments[2].Type != SegmentText || segments[2].Text != "after" { - t.Fatalf("unexpected last segment: %+v", segments[2]) - } -} - -func TestRenderCodeBlockIncludesCopyHeader(t *testing.T) { - rendered := RenderCodeBlock(ContentSegment{Type: SegmentCodeBlock, Lang: "go", Code: "fmt.Println(1)", Closed: true}, 80, CopyActionLabel()) - if !strings.Contains(rendered, "[Copy] go") { - t.Fatalf("expected copy header, got %q", rendered) - } -} - -func TestHighlightCodePreservesNewlines(t *testing.T) { - code := "def bubble_sort(arr):\n for i in range(len(arr)):\n pass" - highlighted := HighlightCode(code, "python") - - if highlighted != code { - t.Fatalf("expected highlighted code to preserve line structure, got %q", highlighted) - } -} - -func TestHighlightCodeBlockIncludesClosingFenceOnlyWhenClosed(t *testing.T) { - closed := HighlightCodeBlock([]string{"print(1)"}, "python", 80, true) - if strings.Count(closed, "```") != 2 { - t.Fatalf("expected closed block to contain closing fence, got %q", closed) - } - - unclosed := HighlightCodeBlock([]string{"print(1)"}, "python", 80, false) - if strings.Count(unclosed, "```") != 1 { - t.Fatalf("expected unclosed block to contain only opening fence, got %q", unclosed) - } -} - -func TestFormatCopyNoticeUsesEnglishSummary(t *testing.T) { - notice := FormatCopyNotice(CodeBlockRef{ - Lang: "go", - Code: "fmt.Println(1)\nfmt.Println(2)\n", - }) - - if notice != "Copied go code block (2 lines)" { - t.Fatalf("unexpected copy notice %q", notice) - } -} diff --git a/internal/tui/components/help.go b/internal/tui/components/help.go deleted file mode 100644 index f8367070..00000000 --- a/internal/tui/components/help.go +++ /dev/null @@ -1,67 +0,0 @@ -package components - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" -) - -func RenderHelp(width int) string { - var b strings.Builder - - title := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#61AFEF")). - Bold(true). - Render("NeoCode Help") - - b.WriteString(title) - b.WriteString("\n\n") - - commands := []struct { - cmd string - desc string - }{ - {"/help", "Show help"}, - {"/pwd | /workspace", "Show the current workspace path"}, - {"/apikey ", "Switch the API key environment variable"}, - {"/provider ", "Switch the model provider"}, - {"/switch ", "Switch the active model"}, - {"/run ", "Run code"}, - {"/explain ", "Explain code"}, - {"/memory", "Show memory stats"}, - {"/clear-memory confirm", "Clear persistent memory"}, - {"/clear-context", "Clear the session context"}, - {"/exit", "Exit the app"}, - } - - cmdStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#98C379")). - Width(22) - - descStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ABB2BF")) - - dimStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#5C6370")) - - helpStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#61AFEF")) - - for _, c := range commands { - b.WriteString(cmdStyle.Render(c.cmd)) - b.WriteString(descStyle.Render(c.desc)) - b.WriteString("\n") - } - - b.WriteString("\n") - b.WriteString(helpStyle.Render("Input supports cursor movement, paste, and scrolling. Press F5/F8 to send.")) - b.WriteString("\n") - b.WriteString(helpStyle.Render("Chat supports PgUp/PgDn, the mouse wheel, and clicking [Copy] on code blocks.")) - b.WriteString("\n") - b.WriteString(helpStyle.Render("Cancel: Ctrl+C")) - - b.WriteString("\n\n") - b.WriteString(dimStyle.Render("Press Esc or /help to close")) - - return lipgloss.NewStyle().MaxWidth(width).Render(b.String()) -} diff --git a/internal/tui/components/highlight.go b/internal/tui/components/highlight.go deleted file mode 100644 index 98aea485..00000000 --- a/internal/tui/components/highlight.go +++ /dev/null @@ -1,258 +0,0 @@ -package components - -import ( - "regexp" - "strings" - "unicode/utf8" -) - -type TokenType int - -const ( - TokenDefault TokenType = iota - TokenKeyword - TokenString - TokenComment - TokenNumber - TokenFunction - TokenOperator - TokenBuiltin - TokenConstant - TokenAnnotation - TokenPreProc -) - -type Token struct { - Type TokenType - Content string -} - -type LexRule struct { - Type TokenType - Pattern *regexp.Regexp -} - -type LanguageDefinition struct { - Name string - Rules []LexRule - Keywords []string - Types []string - Constants []string - Builtins []string -} - -var Languages = map[string]*LanguageDefinition{ - "go": { - Name: "Go", - Keywords: []string{ - "break", "case", "chan", "const", "continue", "default", "defer", "else", - "fallthrough", "for", "func", "go", "goto", "if", "import", "interface", - "map", "package", "range", "return", "select", "struct", "switch", "type", "var", - }, - Types: []string{ - "bool", "byte", "complex64", "complex128", "error", "float32", "float64", - "int", "int8", "int16", "int32", "int64", "rune", "string", "uint", - "uint8", "uint16", "uint32", "uint64", "uintptr", "any", "comparable", - }, - Constants: []string{"true", "false", "iota", "nil"}, - Builtins: []string{ - "append", "cap", "close", "complex", "copy", "delete", "imag", "len", - "make", "new", "panic", "print", "println", "real", "recover", - }, - Rules: []LexRule{ - {TokenComment, regexp.MustCompile(`//.*$`)}, - {TokenComment, regexp.MustCompile(`/\*[\s\S]*?\*/`)}, - {TokenString, regexp.MustCompile(`"(?:[^"\\]|\\.)*"`)}, - {TokenString, regexp.MustCompile("`[^`]*`")}, - {TokenString, regexp.MustCompile(`'(?:[^'\\]|\\.)'`)}, - {TokenNumber, regexp.MustCompile(`\b0x[0-9a-fA-F]+\b|\b\d+\.?\d*\b`)}, - {TokenAnnotation, regexp.MustCompile(`@\w+`)}, - {TokenPreProc, regexp.MustCompile(`#\w+`)}, - }, - }, - "python": { - Name: "Python", - Keywords: []string{ - "and", "as", "assert", "async", "await", "break", "class", "continue", - "def", "del", "elif", "else", "except", "finally", "for", "from", - "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", - "or", "pass", "raise", "return", "try", "while", "with", "yield", - "True", "False", "None", - }, - Rules: []LexRule{ - {TokenComment, regexp.MustCompile(`#.*$`)}, - {TokenString, regexp.MustCompile(`"""[\s\S]*?"""`)}, - {TokenString, regexp.MustCompile(`'''[\s\S]*?'''`)}, - {TokenString, regexp.MustCompile(`"(?:[^"\\]|\\.)*"`)}, - {TokenString, regexp.MustCompile(`'(?:[^'\\]|\\.)'`)}, - {TokenNumber, regexp.MustCompile(`\b0x[0-9a-fA-F]+\b|\b\d+\.?\d*\b`)}, - }, - }, - "javascript": { - Name: "JavaScript", - Keywords: []string{ - "async", "await", "break", "case", "catch", "class", "const", "continue", - "debugger", "default", "delete", "do", "else", "export", "extends", - "finally", "for", "function", "if", "import", "in", "instanceof", - "let", "new", "of", "return", "static", "super", "switch", "this", - "throw", "try", "typeof", "var", "void", "while", "with", "yield", - "true", "false", "null", "undefined", - }, - Rules: []LexRule{ - {TokenComment, regexp.MustCompile(`//.*$`)}, - {TokenComment, regexp.MustCompile(`/\*[\s\S]*?\*/`)}, - {TokenString, regexp.MustCompile(`"(?:[^"\\]|\\.)*"`)}, - {TokenString, regexp.MustCompile("'(?:[^'\\\\]|\\\\.)*'")}, - {TokenString, regexp.MustCompile("`(?:[^`\\\\]|\\\\.)*`")}, - {TokenNumber, regexp.MustCompile(`\b0x[0-9a-fA-F]+\b|\b\d+\.?\d*\b`)}, - }, - }, - "bash": { - Name: "Bash", - Keywords: []string{"if", "then", "else", "elif", "fi", "case", "esac", "for", "while", "do", "done", "function", "return", "exit"}, - Constants: []string{"true", "false"}, - Rules: []LexRule{ - {TokenComment, regexp.MustCompile(`#.*$`)}, - {TokenString, regexp.MustCompile(`"(?:[^"\\]|\\.)*"`)}, - {TokenNumber, regexp.MustCompile(`\$\{?\w+\}?|\b\d+\b`)}, - }, - }, -} - -var identifierPat = regexp.MustCompile(`^[a-zA-Z_]\w*`) -var operatorChars = map[rune]bool{ - '+': true, '-': true, '*': true, '/': true, '%': true, '=': true, - '<': true, '>': true, '!': true, '&': true, '|': true, '^': true, - '~': true, ':': true, ';': true, ',': true, '.': true, '(': true, - ')': true, '[': true, ']': true, '{': true, '}': true, '?': true, - '@': true, '#': true, '$': true, -} - -func getLanguage(lang string) *LanguageDefinition { - lang = strings.ToLower(lang) - if lang == "golang" { - lang = "go" - } - if lang == "typescript" || lang == "ts" { - lang = "javascript" - } - if def, ok := Languages[lang]; ok { - return def - } - return Languages["go"] -} - -func DetectLanguage(code string) string { - code = strings.TrimSpace(code) - if strings.Contains(code, "func ") && strings.Contains(code, "package ") { - return "go" - } - if strings.Contains(code, "def ") || strings.Contains(code, "class ") { - return "python" - } - if strings.Contains(code, "function ") || strings.Contains(code, "const ") || strings.Contains(code, "let ") { - return "javascript" - } - if strings.HasPrefix(code, "#!/") || strings.HasPrefix(code, "echo ") { - return "bash" - } - return "go" -} - -func tokenizeLine(line string, lang string) []Token { - def := getLanguage(lang) - tokens := []Token{} - runes := []rune(line) - pos := 0 - - for pos < len(runes) { - matched := false - remaining := string(runes[pos:]) - - if m := matchRule(remaining, def.Rules, TokenComment); m != "" { - tokens = append(tokens, Token{Type: TokenComment, Content: m}) - pos += utf8.RuneCountInString(m) - matched = true - } else if m := matchRule(remaining, def.Rules, TokenString); m != "" { - tokens = append(tokens, Token{Type: TokenString, Content: m}) - pos += utf8.RuneCountInString(m) - matched = true - } else if m := matchKeyword(runes, pos, def.Keywords); m != "" { - tokens = append(tokens, Token{Type: TokenKeyword, Content: m}) - pos += len(m) - matched = true - } - - if matched { - continue - } - - if op := string(runes[pos]); len(op) > 0 && operatorChars[rune(op[0])] { - tokens = append(tokens, Token{Type: TokenOperator, Content: op}) - pos++ - } else { - tokens = append(tokens, Token{Type: TokenDefault, Content: string(runes[pos])}) - pos++ - } - } - - return tokens -} - -func matchRule(s string, rules []LexRule, tokenType TokenType) string { - for _, r := range rules { - if r.Type == tokenType { - if m := r.Pattern.FindStringIndex(s); m != nil && m[0] == 0 { - return s[:m[1]] - } - } - } - return "" -} - -func matchKeyword(runes []rune, pos int, keywords []string) string { - for _, kw := range keywords { - if hasKeywordByRune(runes, pos, kw) { - return kw - } - } - return "" -} - -func hasKeywordByRune(runes []rune, pos int, kw string) bool { - kwRunes := []rune(kw) - if pos+len(kwRunes) > len(runes) { - return false - } - for i := 0; i < len(kwRunes); i++ { - if runes[pos+i] != kwRunes[i] { - return false - } - } - return true -} - -func Tokenize(code string, lang string) []Token { - tokens := []Token{} - lines := strings.Split(code, "\n") - for i, line := range lines { - tokens = append(tokens, tokenizeLine(line, lang)...) - if i < len(lines)-1 { - tokens = append(tokens, Token{Type: TokenDefault, Content: "\n"}) - } - } - return tokens -} - -func HighlightCode(code string, lang string) string { - tokens := Tokenize(code, lang) - var b strings.Builder - for _, t := range tokens { - b.WriteString(t.Content) - } - return b.String() -} - -func HighlightCodeInline(code string, lang string) string { - return HighlightCode(code, lang) -} diff --git a/internal/tui/components/input_box.go b/internal/tui/components/input_box.go deleted file mode 100644 index 2eec13fe..00000000 --- a/internal/tui/components/input_box.go +++ /dev/null @@ -1,31 +0,0 @@ -package components - -import "github.com/charmbracelet/lipgloss" - -type InputBox struct { - Body string - Generating bool - Status string -} - -func (i InputBox) Render() string { - helpText := "[Enter: newline | F5/F8: send | PgUp/PgDn: scroll]" - if !i.Generating { - helpText = "[Enter: newline | F5/F8: send | Ctrl+V: paste | click [Copy]: copy | PgUp/PgDn: scroll]" - } - - statusText := i.Status - if statusText == "" { - statusText = "Ready" - } - - status := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#61AFEF")). - Render(statusText) - - footer := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#5C6370")). - Render(helpText) - - return i.Body + "\n" + status + "\n" + footer -} diff --git a/internal/tui/components/layout_helpers_test.go b/internal/tui/components/layout_helpers_test.go deleted file mode 100644 index 9184e086..00000000 --- a/internal/tui/components/layout_helpers_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package components - -import ( - "strings" - "testing" - "time" -) - -func TestRenderHelpContainsKeyCommands(t *testing.T) { - rendered := RenderHelp(80) - - for _, want := range []string{"NeoCode Help", "/help", "/provider ", "Press Esc or /help to close"} { - if !strings.Contains(rendered, want) { - t.Fatalf("expected help to contain %q, got %q", want, rendered) - } - } -} - -func TestInputBoxRenderChangesFooterByGeneratingState(t *testing.T) { - idle := InputBox{Body: "body", Generating: false}.Render() - if !strings.Contains(idle, "Ctrl+V: paste") { - t.Fatalf("expected idle footer to mention paste, got %q", idle) - } - if !strings.Contains(idle, "click [Copy]: copy") { - t.Fatalf("expected idle footer to mention copy action, got %q", idle) - } - - busy := InputBox{Body: "body", Generating: true}.Render() - if strings.Contains(busy, "Ctrl+V: paste") { - t.Fatalf("expected generating footer to omit paste hint, got %q", busy) - } - if !strings.Contains(busy, "F5/F8: send") { - t.Fatalf("expected busy footer to keep send hint, got %q", busy) - } -} - -func TestMessageListRenderIncludesRoleSpecificLabels(t *testing.T) { - rendered := MessageList{ - Width: 60, - Messages: []Message{ - {Role: "user", Content: "hello", Timestamp: time.Unix(1, 0)}, - {Role: "assistant", Content: "world", Timestamp: time.Unix(2, 0)}, - {Role: "system", Content: "note", Timestamp: time.Unix(3, 0)}, - }, - }.Render() - - for _, want := range []string{"You [1]:", "Neo [2]:", "[System]", "hello", "world", "note"} { - if !strings.Contains(rendered, want) { - t.Fatalf("expected rendered list to contain %q, got %q", want, rendered) - } - } -} - -func TestMessageListRenderReturnsEmptyForNoMessages(t *testing.T) { - if got := (MessageList{Width: 40}).Render(); got != "" { - t.Fatalf("expected empty render, got %q", got) - } -} - -func TestMessageListRenderLayoutIncludesCopyRegions(t *testing.T) { - layout := MessageList{ - Width: 60, - Messages: []Message{{Role: "assistant", Content: "```go\nfmt.Println(1)\n```", Timestamp: time.Unix(1, 0)}}, - }.RenderLayout() - - if !strings.Contains(layout.Content, "[Copy] go") { - t.Fatalf("expected copy action in layout, got %q", layout.Content) - } - if len(layout.Regions) != 1 { - t.Fatalf("expected one clickable region, got %d", len(layout.Regions)) - } - region := layout.Regions[0] - if region.Kind != "copy" || region.StartRow != 1 || region.StartCol != 1 || region.EndCol != len(CopyActionLabel()) { - t.Fatalf("unexpected region: %+v", region) - } - if region.CodeBlock.Code != "fmt.Println(1)" { - t.Fatalf("expected copied code, got %+v", region.CodeBlock) - } -} diff --git a/internal/tui/components/message_list.go b/internal/tui/components/message_list.go deleted file mode 100644 index 1fb5d773..00000000 --- a/internal/tui/components/message_list.go +++ /dev/null @@ -1,152 +0,0 @@ -package components - -import ( - "fmt" - "strings" - "time" - - "github.com/charmbracelet/lipgloss" -) - -type Message struct { - Role string - Content string - Timestamp time.Time - Streaming bool -} - -type MessageList struct { - Messages []Message - Width int -} - -func (ml MessageList) Render() string { - return ml.RenderLayout().Content -} - -func (ml MessageList) RenderLayout() RenderedChatLayout { - if len(ml.Messages) == 0 { - return RenderedChatLayout{} - } - contentWidth := ml.Width - 4 - if contentWidth < 20 { - contentWidth = ml.Width - } - - userMsgStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#98C379")). - Bold(true) - - assistantMsgStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#E5C07B")) - - systemMsgStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#C678DD")) - - var b strings.Builder - regions := make([]ClickableRegion, 0) - row := 0 - - wrapStyle := lipgloss.NewStyle().MaxWidth(contentWidth) - - for i, msg := range ml.Messages { - idx := i + 1 - switch msg.Role { - case "user": - line := renderMessageLine(fmt.Sprintf("You [%d]:", idx), msg.Content, userMsgStyle, wrapStyle) - b.WriteString(line) - b.WriteString("\n\n") - row += strings.Count(line, "\n") + 2 - - case "assistant": - rendered, assistantRegions, consumedRows := renderAssistantMessage(i, idx, msg.Content, contentWidth, row, assistantMsgStyle) - b.WriteString(rendered) - regions = append(regions, assistantRegions...) - row += consumedRows - - case "system": - line := renderMessageLine("[System]", msg.Content, systemMsgStyle, wrapStyle) - b.WriteString(line) - b.WriteString("\n\n") - row += strings.Count(line, "\n") + 2 - } - } - - return RenderedChatLayout{Content: b.String(), Regions: regions} -} - -func renderMessageLine(label string, content string, labelStyle lipgloss.Style, wrapStyle lipgloss.Style) string { - return labelStyle.Render(label) + " " + wrapStyle.Render(content) -} - -func renderAssistantMessage(messageIndex, displayIndex int, content string, width int, startRow int, labelStyle lipgloss.Style) (string, []ClickableRegion, int) { - var b strings.Builder - regions := make([]ClickableRegion, 0) - rows := 0 - - b.WriteString(labelStyle.Render(fmt.Sprintf("Neo [%d]:", displayIndex))) - b.WriteString("\n") - rows++ - - currentRow := startRow + rows - blockIndex := 0 - for _, segment := range assistantSegments(content) { - rendered, region, consumedRows := renderAssistantSegment(messageIndex, &blockIndex, segment, width, currentRow) - b.WriteString(rendered) - if region != nil { - regions = append(regions, *region) - } - rows += consumedRows - currentRow += consumedRows - } - - b.WriteString("\n\n") - rows += 2 - - return b.String(), regions, rows -} - -func assistantSegments(content string) []ContentSegment { - segments := ParseContentSegments(content) - if len(segments) == 0 { - return []ContentSegment{{Type: SegmentText, Text: "..."}} - } - return segments -} - -func renderAssistantSegment(messageIndex int, blockIndex *int, segment ContentSegment, width int, row int) (string, *ClickableRegion, int) { - if segment.Type == SegmentCodeBlock { - *blockIndex = *blockIndex + 1 - codeLang := resolveSegmentLanguage(segment) - rendered := RenderCodeBlock(segment, width, CopyActionLabel()) - region := BuildCopyRegion(messageIndex, *blockIndex, row, segment.Code, codeLang) - return rendered, ®ion, strings.Count(rendered, "\n") - } - - rendered := renderTextSegment(segment.Text, width) - return rendered, nil, strings.Count(rendered, "\n") -} - -func resolveSegmentLanguage(segment ContentSegment) string { - codeLang := strings.TrimSpace(segment.Lang) - if codeLang == "" { - codeLang = DetectLanguage(segment.Code) - } - if codeLang == "" { - return "text" - } - return codeLang -} - -func renderTextSegment(text string, width int) string { - if text == "" { - return "" - } - var b strings.Builder - style := lipgloss.NewStyle().MaxWidth(width) - for _, line := range strings.Split(text, "\n") { - b.WriteString(style.Render(line)) - b.WriteString("\n") - } - return b.String() -} diff --git a/internal/tui/components/statusbar.go b/internal/tui/components/statusbar.go deleted file mode 100644 index 306573fc..00000000 --- a/internal/tui/components/statusbar.go +++ /dev/null @@ -1,57 +0,0 @@ -package components - -import ( - "fmt" - "strings" - "time" - - "github.com/charmbracelet/lipgloss" -) - -type StatusBar struct { - Model string - MemoryCnt int - Generating bool - Width int -} - -func (s StatusBar) Render() string { - modelStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#98C379")). - Background(lipgloss.Color("#282C34")). - Padding(0, 1) - - memStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#C678DD")). - Background(lipgloss.Color("#282C34")). - Padding(0, 1) - - status := "●" - if s.Generating { - status = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#E5C07B")). - Render("◐") - } - - timeStr := time.Now().Format("15:04") - timestampStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#5C6370")) - - memText := fmt.Sprintf("Memory: %d", s.MemoryCnt) - space := s.Width - len(s.Model) - len(memText) - len(timeStr) - 10 - if space < 0 { - space = 0 - } - - var b strings.Builder - b.WriteString(modelStyle.Render(s.Model)) - b.WriteString(" ") - b.WriteString(memStyle.Render(memText)) - b.WriteString(" ") - b.WriteString(status) - if space > 0 { - b.WriteString(strings.Repeat(" ", space)) - } - b.WriteString(timestampStyle.Render(timeStr)) - - return b.String() -} diff --git a/internal/tui/components/todo_list.go b/internal/tui/components/todo_list.go deleted file mode 100644 index 15c035f6..00000000 --- a/internal/tui/components/todo_list.go +++ /dev/null @@ -1,81 +0,0 @@ -package components - -import ( - "fmt" - "strings" - - "go-llm-demo/internal/tui/services" - "go-llm-demo/internal/tui/todo" - - "github.com/charmbracelet/lipgloss" -) - -type TodoList struct { - Todos []services.Todo - Cursor int - Width int - Focused bool -} - -func (tl TodoList) Render() string { - if len(tl.Todos) == 0 { - style := lipgloss.NewStyle(). - Foreground(todo.ColorDim). - Italic(true). - Padding(1, 2) - return style.Render(todo.EmptyText) - } - - var b strings.Builder - titleStyle := lipgloss.NewStyle(). - Foreground(todo.ColorTitle). - Bold(true). - PaddingBottom(1) - - b.WriteString(titleStyle.Render(todo.TitleText)) - b.WriteString("\n") - - for i, t := range tl.Todos { - cursor := todo.IconNoCursor - itemStyle := lipgloss.NewStyle() - if i == tl.Cursor && tl.Focused { - cursor = todo.IconCursor - itemStyle = itemStyle.Background(todo.ColorSelection).Foreground(lipgloss.Color("#FFFFFF")) - } - - statusIcon := todo.IconPending - statusStyle := lipgloss.NewStyle().Foreground(todo.ColorPending) - switch t.Status { - case services.TodoInProgress: - statusIcon = todo.IconInProgress - statusStyle = lipgloss.NewStyle().Foreground(todo.ColorInProgress) - case services.TodoCompleted: - statusIcon = todo.IconCompleted - statusStyle = lipgloss.NewStyle().Foreground(todo.ColorCompleted) - } - - priorityLabel := "" - priorityStyle := lipgloss.NewStyle() - switch t.Priority { - case services.TodoPriorityHigh: - priorityLabel = fmt.Sprintf(" (%s)", todo.PriorityHigh) - priorityStyle = priorityStyle.Foreground(todo.ColorPriorityHigh).Bold(true) - case services.TodoPriorityMedium: - priorityLabel = fmt.Sprintf(" (%s)", todo.PriorityMedium) - priorityStyle = priorityStyle.Foreground(todo.ColorPending) - case services.TodoPriorityLow: - priorityLabel = fmt.Sprintf(" (%s)", todo.PriorityLow) - priorityStyle = priorityStyle.Foreground(todo.ColorDim) - } - - content := fmt.Sprintf("%s%s %s%s", cursor, statusStyle.Render(statusIcon), t.Content, priorityStyle.Render(priorityLabel)) - b.WriteString(itemStyle.Width(tl.Width).Render(content)) - b.WriteString("\n") - } - - b.WriteString("\n") - helpStyle := lipgloss.NewStyle().Foreground(todo.ColorDim).Italic(true) - b.WriteString(helpStyle.Render(todo.HelpFooterText)) - - return b.String() -} diff --git a/internal/tui/components/todo_list_test.go b/internal/tui/components/todo_list_test.go deleted file mode 100644 index fe1e0602..00000000 --- a/internal/tui/components/todo_list_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package components - -import ( - "strings" - "testing" - - "go-llm-demo/internal/tui/services" - "go-llm-demo/internal/tui/todo" -) - -func TestTodoListRenderEmptyShowsEmptyText(t *testing.T) { - rendered := TodoList{Width: 80}.Render() - if !strings.Contains(rendered, todo.EmptyText) { - t.Fatalf("expected empty render to contain %q, got %q", todo.EmptyText, rendered) - } -} - -func TestTodoListRenderIncludesTitleItemsAndFooter(t *testing.T) { - rendered := TodoList{ - Width: 80, - Focused: true, - Cursor: 0, - Todos: []services.Todo{ - {ID: "todo-1", Content: "task 1", Status: services.TodoPending, Priority: services.TodoPriorityHigh}, - {ID: "todo-2", Content: "task 2", Status: services.TodoInProgress, Priority: services.TodoPriorityMedium}, - {ID: "todo-3", Content: "task 3", Status: services.TodoCompleted, Priority: services.TodoPriorityLow}, - }, - }.Render() - - for _, want := range []string{ - todo.TitleText, - todo.HelpFooterText, - "task 1", - "task 2", - "task 3", - todo.IconPending, - todo.IconInProgress, - todo.IconCompleted, - todo.PriorityHigh, - todo.PriorityMedium, - todo.PriorityLow, - } { - if !strings.Contains(rendered, want) { - t.Fatalf("expected render to contain %q, got %q", want, rendered) - } - } -} diff --git a/internal/tui/core/model.go b/internal/tui/core/model.go deleted file mode 100644 index 9131ca6a..00000000 --- a/internal/tui/core/model.go +++ /dev/null @@ -1,196 +0,0 @@ -package core - -import ( - "context" - "strings" - "sync" - "time" - - "go-llm-demo/configs" - "go-llm-demo/internal/tui/components" - "go-llm-demo/internal/tui/services" - "go-llm-demo/internal/tui/state" - - "github.com/atotto/clipboard" - "github.com/charmbracelet/bubbles/cursor" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -type Model struct { - ui state.UIState - chat state.ChatState - - client services.ChatClient - persona string - - streamChan <-chan string - textarea textarea.Model - viewport viewport.Model - chatLayout components.RenderedChatLayout - copyToClipboard func(string) error - - mu *sync.Mutex - - // Todo 相关状态 - todos []services.Todo - todoCursor int -} - -const resumeSummaryPrefix = "[RESUME_SUMMARY]" - -// NewModel 创建 TUI 状态模型。 -// historyTurns 用于限制发送给后端的短期对话轮数,避免原始消息无限增长。 -func NewModel(client services.ChatClient, persona string, historyTurns int, configPath, workspaceRoot string) Model { - stats, _ := client.GetMemoryStats(context.Background()) - if stats == nil { - stats = &services.MemoryStats{} - } - if historyTurns <= 0 { - historyTurns = 6 - } - - input := textarea.New() - focusedStyle, blurredStyle := textarea.DefaultStyles() - focusedStyle.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("#61AFEF")) - blurredStyle.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("#5C6370")) - focusedStyle.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("#E6EAF2")) - blurredStyle.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("#AAB2C0")) - input.FocusedStyle = focusedStyle - input.BlurredStyle = blurredStyle - input.Placeholder = "Type a message..." - input.Focus() - input.ShowLineNumbers = false - input.SetHeight(3) - input.Prompt = "> " - input.CharLimit = 0 - input.KeyMap.InsertNewline.SetEnabled(true) - input.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#61AFEF")) - input.Cursor.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E6EAF2")) - _ = input.Cursor.SetMode(cursor.CursorBlink) - - vp := viewport.New(0, 0) - vp.SetContent("") - - model := Model{ - ui: state.UIState{ - Mode: state.ModeChat, - Focused: "input", - AutoScroll: true, - }, - chat: state.ChatState{ - Messages: make([]state.Message, 0), - HistoryTurns: historyTurns, - ActiveModel: client.DefaultModel(), - MemoryStats: *stats, - CommandHistory: make([]string, 0), - CmdHistIndex: -1, - WorkspaceRoot: workspaceRoot, - APIKeyReady: configs.RuntimeAPIKey() != "", - ConfigPath: configPath, - }, - client: client, - persona: persona, - textarea: input, - viewport: vp, - copyToClipboard: clipboard.WriteAll, - mu: &sync.Mutex{}, - } - if provider, ok := client.(services.WorkingSessionSummaryProvider); ok { - if summary, err := provider.GetWorkingSessionSummary(context.Background()); err == nil && strings.TrimSpace(summary) != "" { - model.chat.Messages = append(model.chat.Messages, state.Message{ - Role: "system", - Content: resumeSummaryPrefix + "\n" + summary, - Timestamp: time.Now(), - }) - } - } - return model -} - -func (m *Model) mutex() *sync.Mutex { - if m.mu == nil { - m.mu = &sync.Mutex{} - } - return m.mu -} - -// Init 返回 Bubble Tea 的初始命令。 -func (m Model) Init() tea.Cmd { - return m.textarea.Focus() -} - -// SetWidth 更新当前视口宽度。 -func (m *Model) SetWidth(w int) { - m.ui.Width = w -} - -// SetHeight 更新当前视口高度。 -func (m *Model) SetHeight(h int) { - m.ui.Height = h -} - -// AddMessage 向聊天历史追加一条带时间戳的消息。 -func (m *Model) AddMessage(role, content string) { - mu := m.mutex() - mu.Lock() - defer mu.Unlock() - m.chat.Messages = append(m.chat.Messages, state.Message{ - Role: role, - Content: content, - Timestamp: time.Now(), - }) -} - -// AppendLastMessage 将流式内容追加到最后一条消息中。 -func (m *Model) AppendLastMessage(content string) { - mu := m.mutex() - mu.Lock() - defer mu.Unlock() - if len(m.chat.Messages) > 0 { - m.chat.Messages[len(m.chat.Messages)-1].Content += content - } -} - -// FinishLastMessage 将最后一条消息标记为结束流式输出。 -func (m *Model) FinishLastMessage() { - mu := m.mutex() - mu.Lock() - defer mu.Unlock() - if len(m.chat.Messages) > 0 { - m.chat.Messages[len(m.chat.Messages)-1].Streaming = false - } -} - -// TrimHistory 在保留系统消息的同时裁剪最近的非系统对话轮次。 -func (m *Model) TrimHistory(maxTurns int) { - mu := m.mutex() - mu.Lock() - defer mu.Unlock() - if len(m.chat.Messages) <= maxTurns*2 { - return - } - - var system []state.Message - var others []state.Message - - for _, msg := range m.chat.Messages { - if msg.Role == "system" { - system = append(system, msg) - } else { - others = append(others, msg) - } - } - - if len(others) > maxTurns*2 { - others = others[len(others)-maxTurns*2:] - } - - m.chat.Messages = append(system, others...) -} - -func isResumeSummaryMessage(content string) bool { - return strings.HasPrefix(strings.TrimSpace(content), resumeSummaryPrefix) -} diff --git a/internal/tui/core/model_test.go b/internal/tui/core/model_test.go deleted file mode 100644 index f1e34ad9..00000000 --- a/internal/tui/core/model_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package core - -import ( - "context" - "testing" - - "go-llm-demo/configs" - "go-llm-demo/internal/tui/state" -) - -func TestNewModelAppliesDefaultsAndRuntimeFlags(t *testing.T) { - restoreCoreGlobals(t) - - client := &fakeChatClient{defaultModelName: "demo-model"} - t.Setenv(configs.DefaultAPIKeyEnvVar, "secret") - configs.GlobalAppConfig = nil - - m := NewModel(client, "persona", 0, "config.yaml", "D:/neo-code") - - if m.chat.HistoryTurns != 6 { - t.Fatalf("expected default history turns 6, got %d", m.chat.HistoryTurns) - } - if m.chat.ActiveModel != "demo-model" { - t.Fatalf("expected default model from client, got %q", m.chat.ActiveModel) - } - if !m.chat.APIKeyReady { - t.Fatal("expected API key readiness to reflect runtime env var") - } - if m.chat.WorkspaceRoot != "D:/neo-code" { - t.Fatalf("expected workspace root to be stored, got %q", m.chat.WorkspaceRoot) - } -} - -func TestNewModelUsesEmptyStatsWhenClientReturnsNil(t *testing.T) { - restoreCoreGlobals(t) - - client := &fakeChatClient{nilMemoryStats: true} - - m := NewModel(client, "persona", 4, "config.yaml", "D:/neo-code") - if m.chat.MemoryStats.TotalItems != 0 { - t.Fatalf("expected zero-value stats, got %+v", m.chat.MemoryStats) - } -} - -func TestAppendAndFinishLastMessage(t *testing.T) { - m := Model{} - m.chat.Messages = []state.Message{{Role: "assistant", Content: "hello", Streaming: true}} - - m.AppendLastMessage(" world") - m.FinishLastMessage() - - if m.chat.Messages[0].Content != "hello world" { - t.Fatalf("expected appended content, got %q", m.chat.Messages[0].Content) - } - if m.chat.Messages[0].Streaming { - t.Fatal("expected last message streaming to be cleared") - } -} - -func TestInitReturnsNonNilCmd(t *testing.T) { - restoreCoreGlobals(t) - - m := NewModel(&fakeChatClient{}, "persona", 4, "config.yaml", "D:/neo-code") - if cmd := m.Init(); cmd == nil { - t.Fatal("expected non-nil init cmd") - } -} - -func TestNewModelAddsResumeSummaryMessageWhenSupported(t *testing.T) { - restoreCoreGlobals(t) - - client := &resumeSummaryClient{ - fakeChatClient: fakeChatClient{defaultModelName: "demo-model"}, - summary: "已恢复上次工作现场:\n- 当前目标: 修复记忆模块", - } - - m := NewModel(client, "persona", 4, "config.yaml", "D:/neo-code") - if len(m.chat.Messages) != 1 { - t.Fatalf("expected one resume summary message, got %+v", m.chat.Messages) - } - if m.chat.Messages[0].Role != "system" || !isResumeSummaryMessage(m.chat.Messages[0].Content) { - t.Fatalf("expected resume summary system message, got %+v", m.chat.Messages[0]) - } -} - -func TestTrimHistoryKeepsSystemMessagesAndLatestTurns(t *testing.T) { - m := Model{} - m.chat.Messages = []state.Message{ - {Role: "system", Content: "persona"}, - {Role: "user", Content: "u1"}, - {Role: "assistant", Content: "a1"}, - {Role: "user", Content: "u2"}, - {Role: "assistant", Content: "a2"}, - {Role: "user", Content: "u3"}, - {Role: "assistant", Content: "a3"}, - } - - m.TrimHistory(2) - - if len(m.chat.Messages) != 5 { - t.Fatalf("expected system message plus last two turns, got %d messages", len(m.chat.Messages)) - } - if m.chat.Messages[0].Role != "system" || m.chat.Messages[0].Content != "persona" { - t.Fatalf("expected system message to be preserved, got %+v", m.chat.Messages[0]) - } - if m.chat.Messages[1].Content != "u2" || m.chat.Messages[4].Content != "a3" { - t.Fatalf("expected only latest turns to remain, got %+v", m.chat.Messages) - } -} - -type resumeSummaryClient struct { - fakeChatClient - summary string -} - -func (c *resumeSummaryClient) GetWorkingSessionSummary(context.Context) (string, error) { - return c.summary, nil -} diff --git a/internal/tui/core/msg.go b/internal/tui/core/msg.go deleted file mode 100644 index 67ef7211..00000000 --- a/internal/tui/core/msg.go +++ /dev/null @@ -1,56 +0,0 @@ -package core - -import "go-llm-demo/internal/tui/services" - -type StreamChunkMsg struct { - Content string -} - -func (StreamChunkMsg) isMsg() {} - -type StreamDoneMsg struct{} - -func (StreamDoneMsg) isMsg() {} - -type StreamErrorMsg struct { - Err error -} - -func (StreamErrorMsg) isMsg() {} - -type ToolResultMsg struct { - Result *services.ToolResult - Call services.ToolCall -} - -func (ToolResultMsg) isMsg() {} - -type ToolErrorMsg struct { - Err error -} - -func (ToolErrorMsg) isMsg() {} - -type ExitMsg struct{} - -func (ExitMsg) isMsg() {} - -type ShowHelpMsg struct{} - -func (ShowHelpMsg) isMsg() {} - -type HideHelpMsg struct{} - -func (HideHelpMsg) isMsg() {} - -type RefreshMemoryMsg struct{} - -func (RefreshMemoryMsg) isMsg() {} - -type RefreshTodosMsg struct{} - -func (RefreshTodosMsg) isMsg() {} - -type TodoUpdatedMsg struct{} - -func (TodoUpdatedMsg) isMsg() {} diff --git a/internal/tui/core/update.go b/internal/tui/core/update.go deleted file mode 100644 index e4985eba..00000000 --- a/internal/tui/core/update.go +++ /dev/null @@ -1,1085 +0,0 @@ -package core - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "strings" - - "go-llm-demo/configs" - "go-llm-demo/internal/tui/components" - "go-llm-demo/internal/tui/services" - "go-llm-demo/internal/tui/state" - "go-llm-demo/internal/tui/todo" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" -) - -const ( - toolStatusPrefix = "[TOOL_STATUS]" - toolContextPrefix = "[TOOL_CONTEXT]" - maxToolContextOutputSize = 4000 - maxToolContextMessages = 3 -) - -var ( - validateChatAPIKey = services.ValidateChatAPIKey - writeAppConfig = configs.WriteAppConfig - getWorkspaceRoot = services.GetWorkspaceRoot - executeToolCall = services.ExecuteToolCall -) - -// Update 处理 Bubble Tea 事件并驱动聊天状态更新。 -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - - case tea.WindowSizeMsg: - m.SetWidth(msg.Width) - m.SetHeight(msg.Height) - m.syncLayout() - m.refreshViewport() - return m, nil - - case tea.KeyMsg: - return m.handleKey(msg) - - case tea.MouseMsg: - if handled := m.handleMouseClick(msg); handled { - m.refreshViewport() - return m, nil - } - var vpCmd tea.Cmd - m.viewport, vpCmd = m.viewport.Update(msg) - m.ui.AutoScroll = m.viewport.AtBottom() - return m, vpCmd - - case StreamChunkMsg: - if m.chat.Generating { - m.AppendLastMessage(msg.Content) - m.refreshViewport() - } - return m, m.streamResponseFromChannel() - - case StreamDoneMsg: - mu := m.mutex() - mu.Lock() - m.chat.Generating = false - m.streamChan = nil - - var lastContent string - shouldCheckToolCall := !m.chat.ToolExecuting && len(m.chat.Messages) > 0 - if len(m.chat.Messages) > 0 { - lastMsg := &m.chat.Messages[len(m.chat.Messages)-1] - lastMsg.Streaming = false - if lastMsg.Role == "assistant" { - lastContent = lastMsg.Content - } else { - shouldCheckToolCall = false - } - } - mu.Unlock() - - // 当前工具协议约定:模型如果想调用工具,需要把最后一条 assistant 消息完整输出为 - // {"tool":"...","params":{...}} 结构。这里在流结束后统一解析,避免半截 JSON 被误触发。 - if shouldCheckToolCall { - var jsonData map[string]interface{} - if err := json.Unmarshal([]byte(lastContent), &jsonData); err == nil { - if toolName, ok := jsonData["tool"].(string); ok && toolName != "" { - mu := m.mutex() - mu.Lock() - if m.chat.ToolExecuting { - mu.Unlock() - return m, nil - } - m.chat.ToolExecuting = true - mu.Unlock() - - paramsMap := map[string]interface{}{} - if toolParams, ok := jsonData["params"].(map[string]interface{}); ok { - paramsMap = services.NormalizeToolParams(toolParams) - } - - // 显示工具执行中提示(仅用于 UI,不参与模型上下文) - m.AddMessage("system", formatToolStatusMessage(toolName, paramsMap)) - - // 在goroutine中执行工具调用 - return m, func() tea.Msg { - call := services.ToolCall{Tool: toolName, Params: paramsMap} - result := executeToolCall(call) - if result == nil { - mu := m.mutex() - mu.Lock() - m.chat.ToolExecuting = false - mu.Unlock() - return ToolErrorMsg{Err: fmt.Errorf("tool execution failed: empty result")} - } - return ToolResultMsg{Result: result, Call: call} - } - } - } - } - m.refreshViewport() - - return m, nil - - case StreamErrorMsg: - mu := m.mutex() - mu.Lock() - m.chat.Generating = false - m.streamChan = nil - replacedPlaceholder := false - if len(m.chat.Messages) > 0 { - lastMsg := &m.chat.Messages[len(m.chat.Messages)-1] - if lastMsg.Role == "assistant" && strings.TrimSpace(lastMsg.Content) == "" { - lastMsg.Content = fmt.Sprintf("Error: %v", msg.Err) - lastMsg.Streaming = false - replacedPlaceholder = true - } - } - mu.Unlock() - if !replacedPlaceholder { - m.AddMessage("assistant", fmt.Sprintf("Error: %v", msg.Err)) - } - m.TrimHistory(m.chat.HistoryTurns) - m.refreshViewport() - return m, nil - - case ShowHelpMsg: - m.ui.Mode = state.ModeHelp - m.refreshViewport() - return m, nil - - case HideHelpMsg: - m.ui.Mode = state.ModeChat - m.refreshViewport() - return m, nil - - case RefreshMemoryMsg: - stats, err := m.client.GetMemoryStats(context.Background()) - if err == nil && stats != nil { - m.chat.MemoryStats = *stats - } - m.refreshViewport() - return m, nil - - case ExitMsg: - return m, tea.Quit - - case ToolResultMsg: - mu := m.mutex() - mu.Lock() - m.chat.ToolExecuting = false - mu.Unlock() - // 将结构化工具上下文添加为系统消息,然后重新获取AI响应 - if toolType, target, ok := isSecurityAskResult(msg.Result); ok { - mu := m.mutex() - mu.Lock() - m.chat.PendingApproval = &state.PendingApproval{ - Call: msg.Call, - ToolType: toolType, - Target: target, - } - pending := m.chat.PendingApproval - mu.Unlock() - - m.AddMessage("assistant", formatPendingApprovalMessage(pending)) - m.refreshViewport() - return m, nil - } - m.AddMessage("system", formatToolContextMessage(msg.Result)) - m.AddMessage("assistant", "") - m.chat.Generating = true - m.refreshViewport() - - // 构建包含工具结果的消息并重新请求AI - messages := m.buildMessages() - return m, m.streamResponse(messages) - - case ToolErrorMsg: - mu := m.mutex() - mu.Lock() - m.chat.ToolExecuting = false - mu.Unlock() - // 将工具执行错误添加为结构化系统上下文 - m.AddMessage("system", formatToolErrorContext(msg.Err)) - m.AddMessage("assistant", "") - m.chat.Generating = true - m.refreshViewport() - - // 构建包含错误信息的消息并重新请求AI - messages := m.buildMessages() - return m, m.streamResponse(messages) - } - - return m, cmd -} - -func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if m.ui.Mode == state.ModeHelp { - if msg.Type == tea.KeyEsc || msg.String() == "q" { - m.ui.Mode = state.ModeChat - m.refreshViewport() - return *m, nil - } - } - - if m.ui.Mode == state.ModeTodo { - return m.handleTodoKey(msg) - } - - switch msg.Type { - case tea.KeyF5: - return m.handleSubmit() - - case tea.KeyF8: - return m.handleSubmit() - - case tea.KeyPgUp: - m.ui.AutoScroll = false - m.viewport.HalfViewUp() - return *m, nil - - case tea.KeyPgDown: - m.viewport.HalfViewDown() - m.ui.AutoScroll = m.viewport.AtBottom() - return *m, nil - - case tea.KeyUp: - if strings.TrimSpace(m.textarea.Value()) == "" && len(m.chat.CommandHistory) > 0 { - if m.chat.CmdHistIndex < len(m.chat.CommandHistory)-1 { - m.chat.CmdHistIndex++ - } - if m.chat.CmdHistIndex >= 0 && m.chat.CmdHistIndex < len(m.chat.CommandHistory) { - m.textarea.SetValue(m.chat.CommandHistory[len(m.chat.CommandHistory)-1-m.chat.CmdHistIndex]) - m.textarea.CursorEnd() - return *m, nil - } - } - case tea.KeyDown: - if m.chat.CmdHistIndex > 0 { - m.chat.CmdHistIndex-- - m.textarea.SetValue(m.chat.CommandHistory[len(m.chat.CommandHistory)-1-m.chat.CmdHistIndex]) - m.textarea.CursorEnd() - return *m, nil - } - if m.chat.CmdHistIndex == 0 { - m.chat.CmdHistIndex = -1 - m.textarea.Reset() - return *m, nil - } - } - - m.chat.CmdHistIndex = -1 - var inputCmd tea.Cmd - m.textarea, inputCmd = m.textarea.Update(msg) - m.refreshViewport() - if m.viewport.AtBottom() { - m.ui.AutoScroll = true - } - return *m, inputCmd -} - -func (m *Model) handleMouseClick(msg tea.MouseMsg) bool { - if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft { - return false - } - contentRow, contentCol, ok := m.chatContentPosition(msg) - if !ok { - return false - } - region, found := findClickableRegion(m.chatLayout.Regions, contentRow, contentCol) - if !found || region.Kind != "copy" { - return false - } - if err := m.copyCodeBlock(region.CodeBlock); err != nil { - m.ui.CopyStatus = fmt.Sprintf("Copy failed: %v", err) - return true - } - m.ui.CopyStatus = components.FormatCopyNotice(region.CodeBlock) - return true -} - -func (m *Model) chatContentPosition(msg tea.MouseMsg) (int, int, bool) { - statusHeight := 1 - chatTop := statusHeight - chatBottom := chatTop + m.viewport.Height - if msg.Y < chatTop || msg.Y >= chatBottom { - return 0, 0, false - } - return m.viewport.YOffset + (msg.Y - chatTop), msg.X, true -} - -func findClickableRegion(regions []components.ClickableRegion, row, col int) (components.ClickableRegion, bool) { - for _, region := range regions { - if row < region.StartRow || row > region.EndRow { - continue - } - if col < region.StartCol || col > region.EndCol { - continue - } - return region, true - } - return components.ClickableRegion{}, false -} - -func (m *Model) copyCodeBlock(ref components.CodeBlockRef) error { - if m.copyToClipboard == nil { - return fmt.Errorf("clipboard unavailable") - } - return m.copyToClipboard(ref.Code) -} - -func (m *Model) handleSubmit() (tea.Model, tea.Cmd) { - input := strings.TrimSpace(m.textarea.Value()) - m.textarea.Reset() - m.textarea.SetHeight(m.calculateInputHeight()) - m.syncLayout() - - if input == "" { - return *m, nil - } - - switch m.ui.Mode { - case state.ModeHelp: - m.ui.Mode = state.ModeChat - return *m, nil - } - - if strings.HasPrefix(input, "/") { - return m.handleCommand(input) - } - if !m.chat.APIKeyReady { - m.AddMessage("assistant", "The current API Key could not be validated. Use /apikey , /provider , or /switch to update the configuration, or /exit to quit.") - return *m, nil - } - - if m.chat.PendingApproval != nil { - m.AddMessage("assistant", "A security approval is pending. Use /y to allow once or /n to reject before sending a new message.") - return *m, nil - } - - m.AddMessage("user", input) - m.AddMessage("assistant", "") - // 在请求发出前先裁剪原始消息,避免 UI 历史无限扩张并影响短期上下文质量。 - m.TrimHistory(m.chat.HistoryTurns) - m.chat.Generating = true - m.ui.AutoScroll = true - m.refreshViewport() - - m.chat.CommandHistory = append(m.chat.CommandHistory, input) - m.chat.CmdHistIndex = -1 - - messages := m.buildMessages() - return *m, m.streamResponse(messages) -} - -func (m *Model) handleCommand(input string) (tea.Model, tea.Cmd) { - fields := strings.Fields(input) - if len(fields) == 0 { - return *m, nil - } - - cmd := fields[0] - args := fields[1:] - if !m.chat.APIKeyReady && !isAPIKeyRecoveryCommand(cmd) { - m.AddMessage("assistant", "The current API Key could not be validated. Only /apikey , /provider , /help, /switch , /pwd (/workspace), and /exit are available.") - return *m, nil - } - - switch cmd { - case "/help": - m.ui.Mode = state.ModeHelp - case "/y": - if len(args) > 0 { - m.AddMessage("assistant", "Usage: /y") - return *m, nil - } - if m.chat.PendingApproval == nil { - m.AddMessage("assistant", "There is no pending security approval.") - return *m, nil - } - if m.chat.ToolExecuting { - m.AddMessage("assistant", "Another tool is still running. Please retry /y after it finishes.") - return *m, nil - } - - pending := *m.chat.PendingApproval - m.chat.PendingApproval = nil - if strings.TrimSpace(pending.Call.Tool) == "" { - m.AddMessage("assistant", "The pending tool request is incomplete and cannot be executed.") - return *m, nil - } - - m.AddMessage("assistant", fmt.Sprintf("Approved. Running tool %s.", pending.Call.Tool)) - m.AddMessage("system", formatToolStatusMessage(pending.Call.Tool, pending.Call.Params)) - - mu := m.mutex() - mu.Lock() - if m.chat.ToolExecuting { - m.chat.PendingApproval = &pending - mu.Unlock() - return *m, nil - } - m.chat.ToolExecuting = true - mu.Unlock() - - m.refreshViewport() - return *m, func() tea.Msg { - services.ApproveSecurityAsk(pending.ToolType, pending.Target) - result := executeToolCall(pending.Call) - if result == nil { - mu := m.mutex() - mu.Lock() - m.chat.ToolExecuting = false - mu.Unlock() - return ToolErrorMsg{Err: fmt.Errorf("tool execution failed: empty result")} - } - return ToolResultMsg{Result: result, Call: pending.Call} - } - case "/n": - if len(args) > 0 { - m.AddMessage("assistant", "Usage: /n") - return *m, nil - } - if m.chat.PendingApproval == nil { - m.AddMessage("assistant", "There is no pending security approval.") - return *m, nil - } - - pending := *m.chat.PendingApproval - m.chat.PendingApproval = nil - toolName := strings.TrimSpace(pending.Call.Tool) - if toolName == "" { - toolName = "unknown" - } - m.AddMessage("assistant", fmt.Sprintf("Rejected tool %s for target %s.", toolName, pending.Target)) - return *m, nil - case "/exit", "/quit", "/q": - return *m, tea.Quit - case "/apikey": - if len(args) == 0 { - m.AddMessage("assistant", "Usage: /apikey ") - return *m, nil - } - cfg := configs.GlobalAppConfig - if cfg == nil { - m.AddMessage("assistant", "The current configuration is not loaded, so the API key environment variable name cannot be changed.") - return *m, nil - } - previousEnvName := cfg.AI.APIKey - cfg.AI.APIKey = strings.TrimSpace(args[0]) - envName := cfg.APIKeyEnvVarName() - if cfg.RuntimeAPIKey() == "" { - m.chat.APIKeyReady = false - m.AddMessage("assistant", fmt.Sprintf("Environment variable %s is not set. Use /apikey to switch to another one, or /exit to quit.", envName)) - return *m, nil - } - err := validateChatAPIKey(context.Background(), cfg) - if err == nil { - if writeErr := writeAppConfig(m.chat.ConfigPath, cfg); writeErr != nil { - cfg.AI.APIKey = previousEnvName - m.chat.APIKeyReady = configs.RuntimeAPIKey() != "" - m.AddMessage("assistant", fmt.Sprintf("Failed to switch the API key environment variable name: %v", writeErr)) - return *m, nil - } - m.chat.APIKeyReady = true - m.AddMessage("assistant", fmt.Sprintf("Switched the API key environment variable name to %s and validated it successfully.", envName)) - return *m, nil - } - m.chat.APIKeyReady = false - if errors.Is(err, services.ErrInvalidAPIKey) { - m.AddMessage("assistant", fmt.Sprintf("The API key in environment variable %s is invalid: %v. Use /apikey , /provider , or /switch to update the configuration, or /exit to quit.", envName, err)) - return *m, nil - } - m.AddMessage("assistant", fmt.Sprintf("The API key in environment variable %s could not be validated: %v. Use /apikey , /provider , or /switch to update the configuration, or /exit to quit.", envName, err)) - return *m, nil - case "/provider": - if len(args) == 0 { - m.AddMessage("assistant", fmt.Sprintf("Usage: /provider \nSupported providers:\n - %s", strings.Join(services.SupportedProviders(), "\n - "))) - return *m, nil - } - cfg := configs.GlobalAppConfig - if cfg == nil { - m.AddMessage("assistant", "The current configuration is not loaded, so the provider cannot be changed.") - return *m, nil - } - providerName, ok := services.NormalizeProviderName(strings.Join(args, " ")) - if !ok { - m.AddMessage("assistant", fmt.Sprintf("Unsupported provider: %s\nSupported providers:\n - %s", strings.Join(args, " "), strings.Join(services.SupportedProviders(), "\n - "))) - return *m, nil - } - cfg.AI.Provider = providerName - cfg.AI.Model = services.DefaultModelForProvider(providerName) - m.chat.ActiveModel = cfg.AI.Model - if writeErr := writeAppConfig(m.chat.ConfigPath, cfg); writeErr != nil { - m.AddMessage("assistant", fmt.Sprintf("Failed to switch provider: %v", writeErr)) - return *m, nil - } - if cfg.RuntimeAPIKey() == "" { - m.chat.APIKeyReady = false - m.AddMessage("assistant", fmt.Sprintf("Switched provider to %s, but environment variable %s is not set. Use /apikey or set that environment variable.", providerName, cfg.APIKeyEnvVarName())) - return *m, nil - } - if err := validateChatAPIKey(context.Background(), cfg); err == nil { - m.chat.APIKeyReady = true - m.AddMessage("assistant", fmt.Sprintf("Switched provider to %s. The current model was reset to the default: %s.", providerName, cfg.AI.Model)) - return *m, nil - } else { - m.chat.APIKeyReady = false - m.AddMessage("assistant", fmt.Sprintf("Switched provider to %s, but the API key could not be validated: %v. You can continue using /apikey , /provider , or /switch to adjust the configuration.", providerName, err)) - return *m, nil - } - case "/switch": - if len(args) == 0 { - m.AddMessage("assistant", "Usage: /switch ") - return *m, nil - } - cfg := configs.GlobalAppConfig - if cfg == nil { - m.AddMessage("assistant", "The current configuration is not loaded, so the model cannot be changed.") - return *m, nil - } - target := strings.Join(args, " ") - cfg.AI.Model = target - if writeErr := writeAppConfig(m.chat.ConfigPath, cfg); writeErr != nil { - m.AddMessage("assistant", fmt.Sprintf("Failed to switch model: %v", writeErr)) - return *m, nil - } - m.chat.ActiveModel = target - if cfg.RuntimeAPIKey() == "" { - m.chat.APIKeyReady = false - m.AddMessage("assistant", fmt.Sprintf("Switched model to %s, but environment variable %s is not set.", target, cfg.APIKeyEnvVarName())) - return *m, nil - } - if err := validateChatAPIKey(context.Background(), cfg); err == nil { - m.chat.APIKeyReady = true - m.AddMessage("assistant", fmt.Sprintf("Switched model to: %s", target)) - return *m, nil - } else { - m.chat.APIKeyReady = false - m.AddMessage("assistant", fmt.Sprintf("Switched model to %s, but the API key could not be validated: %v.", target, err)) - return *m, nil - } - case "/pwd", "/workspace": - if len(args) > 0 { - m.AddMessage("assistant", "Usage: /pwd or /workspace") - return *m, nil - } - root := strings.TrimSpace(m.chat.WorkspaceRoot) - if root == "" { - root = getWorkspaceRoot() - } - if strings.TrimSpace(root) == "" { - m.AddMessage("assistant", "Current workspace: unknown") - return *m, nil - } - m.AddMessage("assistant", fmt.Sprintf("Current workspace: %s", root)) - case "/memory": - stats, err := m.client.GetMemoryStats(context.Background()) - if err != nil { - m.AddMessage("assistant", fmt.Sprintf("Failed to read memory stats: %v", err)) - return *m, nil - } - m.chat.MemoryStats = *stats - m.AddMessage("assistant", fmt.Sprintf( - "Memory stats:\n Persistent: %d\n Session: %d\n Total: %d\n TopK: %d\n Min score: %.2f\n File: %s\n Types: %s", - stats.PersistentItems, stats.SessionItems, stats.TotalItems, stats.TopK, stats.MinScore, stats.Path, formatTypeStats(stats.ByType), - )) - case "/clear-memory": - if len(args) == 0 || args[0] != "confirm" { - m.AddMessage("assistant", "This command will clear persistent memory. Use /clear-memory confirm") - return *m, nil - } - if err := m.client.ClearMemory(context.Background()); err != nil { - m.AddMessage("assistant", fmt.Sprintf("Failed to clear persistent memory: %v", err)) - return *m, nil - } - stats, _ := m.client.GetMemoryStats(context.Background()) - if stats != nil { - m.chat.MemoryStats = *stats - } - m.AddMessage("assistant", "Cleared local persistent memory") - case "/todo": - if len(args) == 0 { - m.ui.Mode = state.ModeTodo - return m.refreshTodos() - } - subCmd := args[0] - switch subCmd { - case "add": - if len(args) < 2 { - m.AddMessage("assistant", todo.MsgUsageAdd) - return *m, nil - } - content := args[1] - priority := services.TodoPriorityMedium - if len(args) > 2 { - if p, ok := services.ParseTodoPriority(args[2]); ok { - priority = p - } - } - _, err := m.client.AddTodo(context.Background(), content, priority) - if err != nil { - m.AddMessage("assistant", fmt.Sprintf(todo.MsgAddFailed, err)) - return *m, nil - } - m.AddMessage("assistant", fmt.Sprintf(todo.MsgAddSuccess, content)) - return m.refreshTodos() - case "list": - m.ui.Mode = state.ModeTodo - return m.refreshTodos() - default: - m.AddMessage("assistant", fmt.Sprintf(todo.MsgUnknownSubCmd, subCmd)) - } - case "/clear-context": - if err := m.client.ClearSessionMemory(context.Background()); err != nil { - m.AddMessage("assistant", fmt.Sprintf("Failed to clear session memory: %v", err)) - return *m, nil - } - m.chat.Messages = nil - stats, _ := m.client.GetMemoryStats(context.Background()) - if stats != nil { - m.chat.MemoryStats = *stats - } - m.AddMessage("assistant", "Cleared the current session context") - case "/run": - if len(args) > 0 { - code := strings.Join(args, " ") - return *m, tea.Batch( - tea.Printf("\n--- Running code ---\n"), - runCodeCmd(code), - ) - } - case "/explain": - if len(args) > 0 { - code := strings.Join(args, " ") - return *m, m.sendCodeToAI(code) - } - return *m, nil - default: - m.AddMessage("assistant", fmt.Sprintf("Unknown command: %s. Enter /help to view the available commands.", cmd)) - } - m.refreshViewport() - - return *m, nil -} - -func (m *Model) refreshTodos() (tea.Model, tea.Cmd) { - todos, err := m.client.GetTodoList(context.Background()) - if err == nil { - m.todos = todos - } - m.refreshViewport() - return *m, nil -} - -func (m *Model) handleTodoKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch { - case key.Matches(msg, todo.Keys.Back): - m.ui.Mode = state.ModeChat - m.refreshViewport() - return *m, nil - - case key.Matches(msg, todo.Keys.Up): - if m.todoCursor > 0 { - m.todoCursor-- - } - m.refreshViewport() - return *m, nil - - case key.Matches(msg, todo.Keys.Down): - if m.todoCursor < len(m.todos)-1 { - m.todoCursor++ - } - m.refreshViewport() - return *m, nil - - case key.Matches(msg, todo.Keys.Done): - if len(m.todos) > 0 { - t := m.todos[m.todoCursor] - nextStatus := services.TodoInProgress - switch t.Status { - case services.TodoPending: - nextStatus = services.TodoInProgress - case services.TodoInProgress: - nextStatus = services.TodoCompleted - case services.TodoCompleted: - nextStatus = services.TodoPending - } - _ = m.client.UpdateTodoStatus(context.Background(), t.ID, nextStatus) - return m.refreshTodos() - } - - case key.Matches(msg, todo.Keys.Delete): - if len(m.todos) > 0 { - t := m.todos[m.todoCursor] - _ = m.client.RemoveTodo(context.Background(), t.ID) - if m.todoCursor >= len(m.todos)-1 && m.todoCursor > 0 { - m.todoCursor-- - } - return m.refreshTodos() - } - - case key.Matches(msg, todo.Keys.Add): - // 切换到聊天模式,让用户通过 /todo add 命令行新增,或者这里可以简单处理 - m.AddMessage("assistant", todo.MsgPromptAdd) - m.ui.Mode = state.ModeChat - m.refreshViewport() - return *m, nil - } - - return *m, nil -} - -func isAPIKeyRecoveryCommand(cmd string) bool { - switch cmd { - case "/apikey", "/provider", "/help", "/switch", "/pwd", "/workspace", "/y", "/n", "/exit", "/quit", "/q": - return true - default: - return false - } -} - -func containsModel(models []string, target string) bool { - for _, model := range models { - if model == target { - return true - } - } - return false -} - -func formatTypeStats(byType map[string]int) string { - if len(byType) == 0 { - return "none" - } - ordered := []string{ - services.TypeUserPreference, - services.TypeProjectRule, - services.TypeCodeFact, - services.TypeFixRecipe, - services.TypeSessionMemory, - } - parts := make([]string, 0, len(byType)) - for _, key := range ordered { - if count := byType[key]; count > 0 { - parts = append(parts, fmt.Sprintf("%s=%d", key, count)) - } - } - if len(parts) == 0 { - return "none" - } - return strings.Join(parts, ", ") -} - -func (m *Model) buildMessages() []services.Message { - mu := m.mutex() - mu.Lock() - defer mu.Unlock() - result := make([]services.Message, 0, len(m.chat.Messages)) - // 工具结果会被注入成 system 上下文,但只保留最近几条, - // 否则连续工具链很容易把真正的对话历史挤出上下文窗口。 - keepToolContextIndex := recentToolContextIndexes(m.chat.Messages, maxToolContextMessages) - - // 按照消息的原始时间顺序进行迭代 - for idx, msg := range m.chat.Messages { - if msg.Role == "system" && isResumeSummaryMessage(msg.Content) { - continue - } - if msg.Role == "system" && isTransientToolStatusMessage(msg.Content) { - continue - } - if msg.Role == "system" && isToolContextMessage(msg.Content) { - if _, ok := keepToolContextIndex[idx]; !ok { - continue - } - } - // 跳过空的 assistant 消息 - if msg.Role == "assistant" && strings.TrimSpace(msg.Content) == "" { - continue - } - // 将非空消息按其原始角色和内容添加到结果中 - result = append(result, services.Message{ - Role: msg.Role, - Content: msg.Content, - }) - } - - return result -} - -func (m *Model) streamResponse(messages []services.Message) tea.Cmd { - stream, err := m.client.Chat(context.Background(), messages, m.chat.ActiveModel) - if err != nil { - return func() tea.Msg { return StreamErrorMsg{Err: err} } - } - - m.streamChan = stream - return func() tea.Msg { - chunk, ok := <-stream - if !ok { - return StreamDoneMsg{} - } - return StreamChunkMsg{Content: chunk} - } -} - -func (m *Model) streamResponseFromChannel() tea.Cmd { - if m.streamChan == nil { - return nil - } - return func() tea.Msg { - chunk, ok := <-m.streamChan - if !ok { - return StreamDoneMsg{} - } - return StreamChunkMsg{Content: chunk} - } -} - -func (m *Model) sendCodeToAI(code string) tea.Cmd { - prompt := fmt.Sprintf("Please explain the following code:\n```\n%s\n```", code) - m.AddMessage("user", prompt) - m.AddMessage("assistant", "") - m.TrimHistory(m.chat.HistoryTurns) - m.chat.Generating = true - m.ui.AutoScroll = true - m.refreshViewport() - - messages := m.buildMessages() - return m.streamResponse(messages) -} - -func isTransientToolStatusMessage(content string) bool { - return strings.HasPrefix(strings.TrimSpace(content), toolStatusPrefix) -} - -func isToolContextMessage(content string) bool { - return strings.HasPrefix(strings.TrimSpace(content), toolContextPrefix) -} - -func recentToolContextIndexes(messages []state.Message, keep int) map[int]struct{} { - result := map[int]struct{}{} - if keep <= 0 || len(messages) == 0 { - return result - } - for i := len(messages) - 1; i >= 0 && len(result) < keep; i-- { - msg := messages[i] - if msg.Role == "system" && isToolContextMessage(msg.Content) { - result[i] = struct{}{} - } - } - return result -} - -func formatToolStatusMessage(toolName string, params map[string]interface{}) string { - detail := "" - if filePath, ok := params["filePath"].(string); ok && strings.TrimSpace(filePath) != "" { - detail = " file=" + strings.TrimSpace(filePath) - } else if path, ok := params["path"].(string); ok && strings.TrimSpace(path) != "" { - detail = " path=" + strings.TrimSpace(path) - } else if workdir, ok := params["workdir"].(string); ok && strings.TrimSpace(workdir) != "" { - detail = " workdir=" + strings.TrimSpace(workdir) - } - return fmt.Sprintf("%s tool=%s%s", toolStatusPrefix, strings.TrimSpace(toolName), detail) -} - -func isSecurityAskResult(result *services.ToolResult) (string, string, bool) { - if result == nil || result.Success || result.Metadata == nil { - return "", "", false - } - action, _ := result.Metadata["securityAction"].(string) - if strings.TrimSpace(strings.ToLower(action)) != "ask" { - return "", "", false - } - toolType, _ := result.Metadata["securityToolType"].(string) - target, _ := result.Metadata["securityTarget"].(string) - if strings.TrimSpace(toolType) == "" || strings.TrimSpace(target) == "" { - return "", "", false - } - return strings.TrimSpace(toolType), strings.TrimSpace(target), true -} - -func formatPendingApprovalMessage(pending *state.PendingApproval) string { - if pending == nil { - return "Security approval is required. Use /y to allow once or /n to reject." - } - toolName := strings.TrimSpace(pending.Call.Tool) - if toolName == "" { - toolName = "unknown" - } - return fmt.Sprintf("Security approval required for %s.\nTarget: %s\nUse /y to allow once, or /n to reject.", toolName, pending.Target) -} - -func formatToolContextMessage(result *services.ToolResult) string { - if result == nil { - return toolContextPrefix + "\n" + "tool=unknown\n" + "success=false\n" + "error:\nTool returned empty result" - } - - // 这里故意使用稳定的纯文本 key/value 结构,而不是直接把 ToolResult 原样塞回模型: - // 一方面更容易截断超长输出,另一方面也能减少不同工具返回格式带来的歧义。 - builder := strings.Builder{} - builder.WriteString(toolContextPrefix) - builder.WriteString("\n") - builder.WriteString(fmt.Sprintf("tool=%s\n", strings.TrimSpace(result.ToolName))) - builder.WriteString(fmt.Sprintf("success=%t\n", result.Success)) - - if len(result.Metadata) > 0 { - if encoded, err := json.Marshal(result.Metadata); err == nil { - builder.WriteString("metadata=") - builder.WriteString(string(encoded)) - builder.WriteString("\n") - } - } - - if result.Success { - output := strings.TrimSpace(result.Output) - if output != "" { - builder.WriteString("output:\n") - builder.WriteString(truncateForContext(output, maxToolContextOutputSize)) - } - } else { - errText := strings.TrimSpace(result.Error) - if errText == "" { - errText = strings.TrimSpace(result.Output) - } - if errText != "" { - builder.WriteString("error:\n") - builder.WriteString(truncateForContext(errText, maxToolContextOutputSize)) - } - } - - return builder.String() -} - -func formatToolErrorContext(err error) string { - errText := "Unknown error" - if err != nil { - errText = err.Error() - } - return toolContextPrefix + "\n" + "tool=unknown\n" + "success=false\n" + "error:\n" + truncateForContext(errText, maxToolContextOutputSize) -} - -func truncateForContext(text string, maxLen int) string { - trimmed := strings.TrimSpace(text) - if maxLen <= 0 || len(trimmed) <= maxLen { - return trimmed - } - suffix := fmt.Sprintf("\n... (truncated, total=%d chars)", len(trimmed)) - keep := maxLen - len(suffix) - if keep < 0 { - keep = 0 - } - return trimmed[:keep] + suffix -} - -func runCodeCmd(code string) tea.Cmd { - return func() tea.Msg { - ext, runner := detectLanguage(code) - if ext == "" { - return StreamErrorMsg{Err: fmt.Errorf("could not detect the code language")} - } - - tmpFile, err := os.CreateTemp("", "neocode-*."+ext) - if err != nil { - return StreamErrorMsg{Err: fmt.Errorf("failed to create a temporary file: %w", err)} - } - defer os.Remove(tmpFile.Name()) - - if _, err := tmpFile.WriteString(code); err != nil { - return StreamErrorMsg{Err: fmt.Errorf("failed to write the temporary file: %w", err)} - } - tmpFile.Close() - - var cmd *exec.Cmd - if runner != "" { - cmd = exec.Command(runner, tmpFile.Name()) - } else { - cmd = exec.Command("go", "run", tmpFile.Name()) - } - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Stdin = os.Stdin - - if err := cmd.Run(); err != nil { - return StreamErrorMsg{Err: err} - } - - return StreamDoneMsg{} - } -} - -func detectLanguage(code string) (string, string) { - code = strings.TrimSpace(code) - - if strings.HasPrefix(code, "#!/bin/bash") || strings.HasPrefix(code, "#!/bin/sh") { - return "sh", "bash" - } - if strings.HasPrefix(code, "package main") || strings.Contains(code, "func main()") { - return "go", "" - } - if strings.HasPrefix(code, "def ") || strings.HasPrefix(code, "class ") { - return "py", "python" - } - if strings.HasPrefix(code, "fn ") || strings.HasPrefix(code, "impl ") { - return "rs", "rustc" - } - if strings.HasPrefix(code, "console.log") || strings.Contains(code, "=>") { - return "js", "node" - } - - return "", "" -} - -func (m *Model) calculateInputHeight() int { - lines := strings.Count(m.textarea.Value(), "\n") + 1 - if lines < 3 { - return 3 - } - if lines > 8 { - return 8 - } - return lines -} - -func (m *Model) syncLayout() { - if m.ui.Width <= 0 || m.ui.Height <= 0 { - return - } - inputWidth := m.ui.Width - if inputWidth < 20 { - inputWidth = 20 - } - m.textarea.SetWidth(inputWidth) - m.textarea.SetHeight(m.calculateInputHeight()) - m.textarea.Prompt = "> " - - statusHeight := 1 - inputHeight := m.textarea.Height() + 2 - helpHeight := 0 - if m.ui.Mode == state.ModeHelp { - helpHeight = minInt(20, m.ui.Height-statusHeight-3) - } - contentHeight := m.ui.Height - statusHeight - inputHeight - helpHeight - if contentHeight < 3 { - contentHeight = 3 - } - m.viewport.Width = m.ui.Width - m.viewport.Height = contentHeight -} - -func (m *Model) refreshViewport() { - m.syncLayout() - content := m.renderChatContent() - m.viewport.SetContent(content) - if m.ui.AutoScroll || m.viewport.AtBottom() { - m.viewport.GotoBottom() - m.ui.AutoScroll = true - } -} diff --git a/internal/tui/core/update_test.go b/internal/tui/core/update_test.go deleted file mode 100644 index d9b0adae..00000000 --- a/internal/tui/core/update_test.go +++ /dev/null @@ -1,1495 +0,0 @@ -package core - -import ( - "context" - "errors" - "strings" - "testing" - - "go-llm-demo/configs" - "go-llm-demo/internal/tui/services" - "go-llm-demo/internal/tui/state" - - tea "github.com/charmbracelet/bubbletea" -) - -type fakeChatClient struct { - chatChunks []string - chatErr error - lastMessages []services.Message - lastModel string - memoryStats *services.MemoryStats - nilMemoryStats bool - memoryErr error - clearMemoryErr error - clearSessionErr error - defaultModelName string - todos []services.Todo -} - -func (f *fakeChatClient) Chat(_ context.Context, messages []services.Message, model string) (<-chan string, error) { - f.lastMessages = append([]services.Message(nil), messages...) - f.lastModel = model - if f.chatErr != nil { - return nil, f.chatErr - } - - ch := make(chan string, len(f.chatChunks)) - for _, chunk := range f.chatChunks { - ch <- chunk - } - close(ch) - return ch, nil -} - -func (f *fakeChatClient) GetMemoryStats(context.Context) (*services.MemoryStats, error) { - if f.memoryErr != nil { - return nil, f.memoryErr - } - if f.nilMemoryStats { - return nil, nil - } - if f.memoryStats != nil { - statsCopy := *f.memoryStats - return &statsCopy, nil - } - return &services.MemoryStats{}, nil -} - -func (f *fakeChatClient) ClearMemory(context.Context) error { - return f.clearMemoryErr -} - -func (f *fakeChatClient) ClearSessionMemory(context.Context) error { - return f.clearSessionErr -} - -func (f *fakeChatClient) DefaultModel() string { - if f.defaultModelName != "" { - return f.defaultModelName - } - return "test-model" -} - -func (f *fakeChatClient) GetTodoList(context.Context) ([]services.Todo, error) { - return f.todos, nil -} - -func (f *fakeChatClient) AddTodo(_ context.Context, content string, priority services.TodoPriority) (*services.Todo, error) { - t := &services.Todo{ID: "test", Content: content, Priority: priority, Status: services.TodoPending} - f.todos = append(f.todos, *t) - return t, nil -} - -func (f *fakeChatClient) UpdateTodoStatus(_ context.Context, id string, status services.TodoStatus) error { - for i, t := range f.todos { - if t.ID == id { - f.todos[i].Status = status - return nil - } - } - return nil -} - -func (f *fakeChatClient) RemoveTodo(_ context.Context, id string) error { - for i, t := range f.todos { - if t.ID == id { - f.todos = append(f.todos[:i], f.todos[i+1:]...) - return nil - } - } - return nil -} - -func newTestModel(t *testing.T, client *fakeChatClient) *Model { - t.Helper() - - restoreCoreGlobals(t) - cfg := configs.DefaultAppConfig() - configs.GlobalAppConfig = cfg - - m := NewModel(client, "persona", 4, "config.yaml", "") - m.ui.Width = 80 - m.ui.Height = 24 - m.syncLayout() - return &m -} - -func restoreCoreGlobals(t *testing.T) { - t.Helper() - - origValidateChatAPIKey := validateChatAPIKey - origWriteAppConfig := writeAppConfig - origGetWorkspaceRoot := getWorkspaceRoot - origExecuteToolCall := executeToolCall - origGlobalConfig := configs.GlobalAppConfig - - t.Cleanup(func() { - validateChatAPIKey = origValidateChatAPIKey - writeAppConfig = origWriteAppConfig - getWorkspaceRoot = origGetWorkspaceRoot - executeToolCall = origExecuteToolCall - configs.GlobalAppConfig = origGlobalConfig - }) -} - -func lastMessageContent(t *testing.T, m Model) string { - t.Helper() - if len(m.chat.Messages) == 0 { - t.Fatal("expected at least one message") - } - return m.chat.Messages[len(m.chat.Messages)-1].Content -} - -func assertLastMessageContains(t *testing.T, m Model, want string) { - t.Helper() - if !strings.Contains(lastMessageContent(t, m), want) { - t.Fatalf("expected last message to contain %q, got %q", want, lastMessageContent(t, m)) - } -} - -func TestHandleSubmitEmptyInputNoOp(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.textarea.SetValue(" ") - - updated, cmd := m.handleSubmit() - got := updated.(Model) - - if cmd != nil { - t.Fatal("expected no command for empty input") - } - if len(got.chat.Messages) != 0 { - t.Fatalf("expected no messages, got %d", len(got.chat.Messages)) - } -} - -func TestHandleSubmitFromHelpModeReturnsToChat(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.ui.Mode = state.ModeHelp - m.textarea.SetValue("help") - - updated, cmd := m.handleSubmit() - got := updated.(Model) - - if cmd != nil { - t.Fatal("expected no command when leaving help mode") - } - if got.ui.Mode != state.ModeChat { - t.Fatalf("expected chat mode, got %v", got.ui.Mode) - } - if len(got.chat.Messages) != 0 { - t.Fatalf("expected no messages while leaving help mode, got %d", len(got.chat.Messages)) - } -} - -func TestHandleSubmitRequiresReadyAPIKey(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.APIKeyReady = false - m.textarea.SetValue("hello") - - updated, cmd := m.handleSubmit() - got := updated.(Model) - - if cmd != nil { - t.Fatal("expected no command when API key is not ready") - } - if len(got.chat.Messages) != 1 { - t.Fatalf("expected one assistant warning, got %d messages", len(got.chat.Messages)) - } - if got.chat.Messages[0].Role != "assistant" { - t.Fatalf("expected assistant warning, got %+v", got.chat.Messages[0]) - } - if !strings.Contains(got.chat.Messages[0].Content, "API Key") { - t.Fatalf("expected API key warning, got %q", got.chat.Messages[0].Content) - } -} - -func TestHandleSubmitStartsStreamingConversation(t *testing.T) { - client := &fakeChatClient{chatChunks: []string{"hello back"}} - m := newTestModel(t, client) - m.chat.APIKeyReady = true - m.textarea.SetValue("hello") - - updated, cmd := m.handleSubmit() - got := updated.(Model) - - if cmd == nil { - t.Fatal("expected streaming command") - } - if !got.chat.Generating { - t.Fatal("expected generating=true") - } - if len(got.chat.Messages) != 2 { - t.Fatalf("expected user and assistant placeholder, got %d messages", len(got.chat.Messages)) - } - if got.chat.Messages[0].Role != "user" || got.chat.Messages[0].Content != "hello" { - t.Fatalf("unexpected user message: %+v", got.chat.Messages[0]) - } - if got.chat.Messages[1].Role != "assistant" || got.chat.Messages[1].Content != "" { - t.Fatalf("expected assistant placeholder, got %+v", got.chat.Messages[1]) - } - if len(got.chat.CommandHistory) != 1 || got.chat.CommandHistory[0] != "hello" { - t.Fatalf("expected command history to record input, got %+v", got.chat.CommandHistory) - } - - msg := cmd() - chunk, ok := msg.(StreamChunkMsg) - if !ok { - t.Fatalf("expected StreamChunkMsg, got %T", msg) - } - if chunk.Content != "hello back" { - t.Fatalf("expected first stream chunk, got %q", chunk.Content) - } - if len(client.lastMessages) != 1 || client.lastMessages[0].Role != "user" || client.lastMessages[0].Content != "hello" { - t.Fatalf("expected streamed context to contain only the user message, got %+v", client.lastMessages) - } -} - -func TestHandleCommandRejectsNonRecoveryCommandWithoutAPIKey(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.APIKeyReady = false - - updated, cmd := m.handleCommand("/memory") - got := updated.(Model) - - if cmd != nil { - t.Fatal("expected no command") - } - if len(got.chat.Messages) != 1 { - t.Fatalf("expected one warning message, got %d", len(got.chat.Messages)) - } - if !strings.Contains(got.chat.Messages[0].Content, "API Key") { - t.Fatalf("expected API key guidance, got %q", got.chat.Messages[0].Content) - } -} - -func TestHandleCommandAPIKeyRequiresArgument(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - - updated, _ := m.handleCommand("/apikey") - got := updated.(Model) - assertLastMessageContains(t, got, "/apikey ") -} - -func TestHandleCommandAPIKeyRequiresLoadedConfig(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - configs.GlobalAppConfig = nil - - updated, _ := m.handleCommand("/apikey TEST_ENV") - got := updated.(Model) - assertLastMessageContains(t, got, "configuration") -} - -func TestHandleCommandAPIKeyEnvStillMissing(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - cfg := configs.DefaultAppConfig() - configs.GlobalAppConfig = cfg - - updated, _ := m.handleCommand("/apikey MISSING_ENV") - got := updated.(Model) - - if got.chat.APIKeyReady { - t.Fatal("expected API key to remain not ready") - } - if cfg.AI.APIKey != "MISSING_ENV" { - t.Fatalf("expected env name to switch, got %q", cfg.AI.APIKey) - } - assertLastMessageContains(t, got, "MISSING_ENV") -} - -func TestHandleCommandAPIKeyInvalidKey(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - cfg := configs.DefaultAppConfig() - configs.GlobalAppConfig = cfg - t.Setenv("BAD_ENV", "secret") - - validateChatAPIKey = func(context.Context, *configs.AppConfiguration) error { - return services.ErrInvalidAPIKey - } - - updated, _ := m.handleCommand("/apikey BAD_ENV") - got := updated.(Model) - - if got.chat.APIKeyReady { - t.Fatal("expected invalid key to mark API key as not ready") - } - assertLastMessageContains(t, got, "BAD_ENV") -} - -func TestHandleCommandAPIKeyGenericValidationError(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - cfg := configs.DefaultAppConfig() - configs.GlobalAppConfig = cfg - t.Setenv("GENERIC_ENV", "secret") - - validateChatAPIKey = func(context.Context, *configs.AppConfiguration) error { - return errors.New("validation failed") - } - - updated, _ := m.handleCommand("/apikey GENERIC_ENV") - got := updated.(Model) - - if got.chat.APIKeyReady { - t.Fatal("expected generic validation failure to mark API key as not ready") - } - assertLastMessageContains(t, got, "validation failed") -} - -func TestHandleCommandAPIKeySuccessWritesConfig(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - cfg := configs.DefaultAppConfig() - cfg.AI.APIKey = "ORIGINAL_ENV" - configs.GlobalAppConfig = cfg - t.Setenv("TEST_API_KEY_ENV", "secret") - - var writePath string - validateChatAPIKey = func(context.Context, *configs.AppConfiguration) error { return nil } - writeAppConfig = func(path string, cfg *configs.AppConfiguration) error { - writePath = path - if cfg.AI.APIKey != "TEST_API_KEY_ENV" { - t.Fatalf("expected config to be updated before write, got %q", cfg.AI.APIKey) - } - return nil - } - - updated, cmd := m.handleCommand("/apikey TEST_API_KEY_ENV") - got := updated.(Model) - - if cmd != nil { - t.Fatal("expected no command") - } - if !got.chat.APIKeyReady { - t.Fatal("expected API key to be ready after validation") - } - if cfg.AI.APIKey != "TEST_API_KEY_ENV" { - t.Fatalf("expected config env name to change, got %q", cfg.AI.APIKey) - } - if writePath != "config.yaml" { - t.Fatalf("expected config write path config.yaml, got %q", writePath) - } - if !strings.Contains(lastMessageContent(t, got), "TEST_API_KEY_ENV") { - t.Fatalf("expected success message to mention env name, got %q", lastMessageContent(t, got)) - } -} - -func TestHandleCommandAPIKeyWriteFailureRestoresPreviousEnvName(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - cfg := configs.DefaultAppConfig() - cfg.AI.APIKey = "PREVIOUS_ENV" - configs.GlobalAppConfig = cfg - t.Setenv("PREVIOUS_ENV", "old-secret") - t.Setenv("NEW_ENV", "new-secret") - - validateChatAPIKey = func(context.Context, *configs.AppConfiguration) error { return nil } - writeAppConfig = func(string, *configs.AppConfiguration) error { return errors.New("write failed") } - - updated, _ := m.handleCommand("/apikey NEW_ENV") - got := updated.(Model) - - if cfg.AI.APIKey != "PREVIOUS_ENV" { - t.Fatalf("expected previous env name to be restored, got %q", cfg.AI.APIKey) - } - if !got.chat.APIKeyReady { - t.Fatal("expected API key readiness to match restored environment variable") - } - if !strings.Contains(lastMessageContent(t, got), "write failed") { - t.Fatalf("expected write failure message, got %q", lastMessageContent(t, got)) - } -} - -func TestHandleCommandProviderWithoutRuntimeKeyMarksAPIKeyNotReady(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - cfg := configs.DefaultAppConfig() - cfg.AI.Provider = "openll" - cfg.AI.APIKey = "MISSING_ENV" - configs.GlobalAppConfig = cfg - - writeAppConfig = func(string, *configs.AppConfiguration) error { return nil } - - updated, _ := m.handleCommand("/provider openai") - got := updated.(Model) - - if got.chat.APIKeyReady { - t.Fatal("expected API key to become not ready when provider env var is missing") - } - if cfg.AI.Provider != "openai" { - t.Fatalf("expected provider to switch, got %q", cfg.AI.Provider) - } - if got.chat.ActiveModel == "" { - t.Fatal("expected provider switch to reset active model") - } - if !strings.Contains(lastMessageContent(t, got), "openai") { - t.Fatalf("expected provider switch message, got %q", lastMessageContent(t, got)) - } -} - -func TestHandleCommandProviderRequiresArgument(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - - updated, _ := m.handleCommand("/provider") - got := updated.(Model) - assertLastMessageContains(t, got, "/provider ") -} - -func TestHandleCommandProviderRejectsUnknownProvider(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - configs.GlobalAppConfig = configs.DefaultAppConfig() - - updated, _ := m.handleCommand("/provider unknown") - got := updated.(Model) - assertLastMessageContains(t, got, "unknown") -} - -func TestHandleCommandProviderRequiresLoadedConfig(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - configs.GlobalAppConfig = nil - - updated, _ := m.handleCommand("/provider openai") - got := updated.(Model) - assertLastMessageContains(t, got, "configuration") -} - -func TestHandleCommandProviderWriteFailure(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - cfg := configs.DefaultAppConfig() - configs.GlobalAppConfig = cfg - writeAppConfig = func(string, *configs.AppConfiguration) error { return errors.New("write failed") } - - updated, _ := m.handleCommand("/provider openai") - got := updated.(Model) - assertLastMessageContains(t, got, "write failed") -} - -func TestHandleCommandProviderValidationFailure(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - cfg := configs.DefaultAppConfig() - cfg.AI.APIKey = "READY_ENV" - configs.GlobalAppConfig = cfg - t.Setenv("READY_ENV", "secret") - - writeAppConfig = func(string, *configs.AppConfiguration) error { return nil } - validateChatAPIKey = func(context.Context, *configs.AppConfiguration) error { - return errors.New("validation failed") - } - - updated, _ := m.handleCommand("/provider openai") - got := updated.(Model) - - if got.chat.APIKeyReady { - t.Fatal("expected validation failure to mark API key as not ready") - } - assertLastMessageContains(t, got, "validation failed") -} - -func TestHandleCommandProviderSuccess(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - cfg := configs.DefaultAppConfig() - cfg.AI.APIKey = "READY_ENV" - configs.GlobalAppConfig = cfg - t.Setenv("READY_ENV", "secret") - - writeAppConfig = func(string, *configs.AppConfiguration) error { return nil } - validateChatAPIKey = func(context.Context, *configs.AppConfiguration) error { return nil } - - updated, _ := m.handleCommand("/provider openai") - got := updated.(Model) - - if !got.chat.APIKeyReady { - t.Fatal("expected successful provider switch to keep API key ready") - } - if got.chat.ActiveModel == "" { - t.Fatal("expected active model to be set") - } - assertLastMessageContains(t, got, "openai") -} - -func TestHandleCommandSwitchModelValidationSuccess(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - cfg := configs.DefaultAppConfig() - cfg.AI.APIKey = "READY_ENV" - configs.GlobalAppConfig = cfg - t.Setenv("READY_ENV", "secret") - - validateChatAPIKey = func(context.Context, *configs.AppConfiguration) error { return nil } - writeAppConfig = func(string, *configs.AppConfiguration) error { return nil } - - updated, _ := m.handleCommand("/switch gpt-5.4-mini") - got := updated.(Model) - - if !got.chat.APIKeyReady { - t.Fatal("expected API key to stay ready") - } - if got.chat.ActiveModel != "gpt-5.4-mini" { - t.Fatalf("expected active model to switch, got %q", got.chat.ActiveModel) - } - if cfg.AI.Model != "gpt-5.4-mini" { - t.Fatalf("expected config model to switch, got %q", cfg.AI.Model) - } -} - -func TestHandleCommandSwitchRequiresArgument(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - - updated, _ := m.handleCommand("/switch") - got := updated.(Model) - assertLastMessageContains(t, got, "/switch ") -} - -func TestHandleCommandSwitchRequiresLoadedConfig(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - configs.GlobalAppConfig = nil - - updated, _ := m.handleCommand("/switch gpt-5.4") - got := updated.(Model) - assertLastMessageContains(t, got, "configuration") -} - -func TestHandleCommandSwitchWriteFailure(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - cfg := configs.DefaultAppConfig() - configs.GlobalAppConfig = cfg - writeAppConfig = func(string, *configs.AppConfiguration) error { return errors.New("write failed") } - - updated, _ := m.handleCommand("/switch gpt-5.4") - got := updated.(Model) - assertLastMessageContains(t, got, "write failed") -} - -func TestHandleCommandSwitchMissingRuntimeKey(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - cfg := configs.DefaultAppConfig() - cfg.AI.APIKey = "MISSING_ENV" - configs.GlobalAppConfig = cfg - writeAppConfig = func(string, *configs.AppConfiguration) error { return nil } - - updated, _ := m.handleCommand("/switch gpt-5.4") - got := updated.(Model) - - if got.chat.APIKeyReady { - t.Fatal("expected API key to be not ready when runtime key is missing") - } - assertLastMessageContains(t, got, "MISSING_ENV") -} - -func TestHandleCommandSwitchValidationFailure(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - cfg := configs.DefaultAppConfig() - cfg.AI.APIKey = "READY_ENV" - configs.GlobalAppConfig = cfg - t.Setenv("READY_ENV", "secret") - - writeAppConfig = func(string, *configs.AppConfiguration) error { return nil } - validateChatAPIKey = func(context.Context, *configs.AppConfiguration) error { return errors.New("validation failed") } - - updated, _ := m.handleCommand("/switch gpt-5.4") - got := updated.(Model) - - if got.chat.APIKeyReady { - t.Fatal("expected validation failure to mark API key not ready") - } - assertLastMessageContains(t, got, "validation failed") -} - -func TestHandleCommandWorkspaceFallsBackToGlobalRoot(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.WorkspaceRoot = "" - getWorkspaceRoot = func() string { return `D:/neo-code/workspace` } - - updated, _ := m.handleCommand("/workspace") - got := updated.(Model) - - if !strings.Contains(lastMessageContent(t, got), `D:/neo-code/workspace`) { - t.Fatalf("expected workspace fallback path, got %q", lastMessageContent(t, got)) - } -} - -func TestHandleCommandWorkspaceRejectsExtraArgs(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - - updated, _ := m.handleCommand("/pwd extra") - got := updated.(Model) - assertLastMessageContains(t, got, "/pwd") -} - -func TestHandleCommandMemoryFailure(t *testing.T) { - client := &fakeChatClient{memoryErr: errors.New("stats failed")} - m := newTestModel(t, client) - m.chat.APIKeyReady = true - - updated, _ := m.handleCommand("/memory") - got := updated.(Model) - assertLastMessageContains(t, got, "stats failed") -} - -func TestHandleCommandMemorySuccess(t *testing.T) { - client := &fakeChatClient{memoryStats: &services.MemoryStats{ - PersistentItems: 1, - SessionItems: 2, - TotalItems: 3, - TopK: 4, - MinScore: 1.5, - Path: "memory.json", - ByType: map[string]int{ - services.TypeUserPreference: 1, - }, - }} - m := newTestModel(t, client) - m.chat.APIKeyReady = true - - updated, _ := m.handleCommand("/memory") - got := updated.(Model) - assertLastMessageContains(t, got, "memory.json") -} - -func TestHandleCommandClearMemoryRequiresConfirm(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.APIKeyReady = true - - updated, _ := m.handleCommand("/clear-memory") - got := updated.(Model) - assertLastMessageContains(t, got, "confirm") -} - -func TestHandleCommandClearMemoryFailure(t *testing.T) { - client := &fakeChatClient{clearMemoryErr: errors.New("clear failed")} - m := newTestModel(t, client) - m.chat.APIKeyReady = true - - updated, _ := m.handleCommand("/clear-memory confirm") - got := updated.(Model) - assertLastMessageContains(t, got, "clear failed") -} - -func TestHandleCommandClearMemorySuccessRefreshesStats(t *testing.T) { - client := &fakeChatClient{memoryStats: &services.MemoryStats{TotalItems: 9}} - m := newTestModel(t, client) - m.chat.APIKeyReady = true - - updated, _ := m.handleCommand("/clear-memory confirm") - got := updated.(Model) - - if got.chat.MemoryStats.TotalItems != 9 { - t.Fatalf("expected stats refresh, got %+v", got.chat.MemoryStats) - } -} - -func TestHandleCommandClearContextFailure(t *testing.T) { - client := &fakeChatClient{clearSessionErr: errors.New("clear session failed")} - m := newTestModel(t, client) - m.chat.APIKeyReady = true - - updated, _ := m.handleCommand("/clear-context") - got := updated.(Model) - assertLastMessageContains(t, got, "clear session failed") -} - -func TestHandleCommandRunReturnsBatchCommand(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.APIKeyReady = true - - _, cmd := m.handleCommand("/run package main") - if cmd == nil { - t.Fatal("expected batch command") - } -} - -func TestHandleCommandRunWithoutArgsIsNoOp(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.APIKeyReady = true - - updated, cmd := m.handleCommand("/run") - got := updated.(Model) - if cmd != nil { - t.Fatal("expected no command") - } - if len(got.chat.Messages) != 0 { - t.Fatalf("expected no messages, got %d", len(got.chat.Messages)) - } -} - -func TestHandleCommandExplainStartsStreaming(t *testing.T) { - client := &fakeChatClient{chatChunks: []string{"explained"}} - m := newTestModel(t, client) - m.chat.APIKeyReady = true - - updated, cmd := m.handleCommand("/explain package main") - got := updated.(Model) - if cmd == nil { - t.Fatal("expected stream command") - } - if !got.chat.Generating { - t.Fatal("expected generating=true") - } - if len(got.chat.Messages) != 2 { - t.Fatalf("expected user and assistant messages, got %d", len(got.chat.Messages)) - } - if !strings.Contains(got.chat.Messages[0].Content, "package main") { - t.Fatalf("expected explain prompt to include code, got %q", got.chat.Messages[0].Content) - } - msg := cmd() - if chunk, ok := msg.(StreamChunkMsg); !ok || chunk.Content != "explained" { - t.Fatalf("expected explain stream chunk, got %#v", msg) - } -} - -func TestHandleCommandExplainWithoutArgsIsNoOp(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.APIKeyReady = true - - updated, cmd := m.handleCommand("/explain") - got := updated.(Model) - if cmd != nil { - t.Fatal("expected no command") - } - if len(got.chat.Messages) != 0 { - t.Fatalf("expected no messages, got %d", len(got.chat.Messages)) - } -} - -func TestHandleCommandUnknownCommand(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.APIKeyReady = true - - updated, _ := m.handleCommand("/unknown") - got := updated.(Model) - assertLastMessageContains(t, got, "/unknown") -} - -func TestStreamChunkMsgAppendsContentAndSchedulesNextChunk(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.Generating = true - m.chat.Messages = []state.Message{{Role: "assistant", Content: ""}} - - ch := make(chan string, 1) - ch <- "second" - close(ch) - m.streamChan = ch - - updated, cmd := m.Update(StreamChunkMsg{Content: "first"}) - got := updated.(Model) - - if got.chat.Messages[0].Content != "first" { - t.Fatalf("expected first chunk to append, got %q", got.chat.Messages[0].Content) - } - if cmd == nil { - t.Fatal("expected follow-up command") - } - msg := cmd() - chunk, ok := msg.(StreamChunkMsg) - if !ok { - t.Fatalf("expected StreamChunkMsg, got %T", msg) - } - if chunk.Content != "second" { - t.Fatalf("expected second chunk, got %q", chunk.Content) - } -} - -func TestWindowSizeMsgUpdatesLayout(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - - updated, cmd := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) - got := updated.(Model) - - if cmd != nil { - t.Fatal("expected no command") - } - if got.ui.Width != 100 || got.ui.Height != 40 { - t.Fatalf("expected updated size, got %dx%d", got.ui.Width, got.ui.Height) - } -} - -func TestMouseMsgUpdatesViewport(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.viewport.SetContent("line1\nline2\nline3\nline4") - - updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseWheelDown}) - _ = updated.(Model) -} - -func TestMouseClickCopiesCodeBlock(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.Messages = []state.Message{{Role: "assistant", Content: "```go\nfmt.Println(1)\n```"}} - m.refreshViewport() - - var copied string - m.copyToClipboard = func(text string) error { - copied = text - return nil - } - - updated, cmd := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: 1, Y: 2}) - got := updated.(Model) - - if cmd != nil { - t.Fatal("expected no follow-up command") - } - if copied != "fmt.Println(1)" { - t.Fatalf("expected code to be copied, got %q", copied) - } - if !strings.Contains(got.ui.CopyStatus, "Copied go code block") { - t.Fatalf("expected copy status, got %q", got.ui.CopyStatus) - } -} - -func TestMouseClickCopyFailureShowsEnglishStatus(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.Messages = []state.Message{{Role: "assistant", Content: "```go\nfmt.Println(1)\n```"}} - m.refreshViewport() - - m.copyToClipboard = func(string) error { - return errors.New("clipboard unavailable") - } - - updated, cmd := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: 1, Y: 2}) - got := updated.(Model) - - if cmd != nil { - t.Fatal("expected no follow-up command") - } - if !strings.Contains(got.ui.CopyStatus, "Copy failed: clipboard unavailable") { - t.Fatalf("expected english copy failure status, got %q", got.ui.CopyStatus) - } -} - -func TestMouseClickOutsideCopyRegionDoesNotCopy(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.Messages = []state.Message{{Role: "assistant", Content: "```go\nfmt.Println(1)\n```"}} - m.refreshViewport() - - called := false - m.copyToClipboard = func(string) error { - called = true - return nil - } - - updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: 20, Y: 2}) - got := updated.(Model) - - if called { - t.Fatal("expected copy not to trigger") - } - if got.ui.CopyStatus != "" { - t.Fatalf("expected copy status to remain empty, got %q", got.ui.CopyStatus) - } -} - -func TestStreamChunkMsgNoOpWhenNotGenerating(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.Generating = false - m.chat.Messages = []state.Message{{Role: "assistant", Content: "start"}} - - updated, _ := m.Update(StreamChunkMsg{Content: "ignored"}) - got := updated.(Model) - - if got.chat.Messages[0].Content != "start" { - t.Fatalf("expected content unchanged, got %q", got.chat.Messages[0].Content) - } -} - -func TestStreamDoneMsgCompletesWithoutToolCall(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.Generating = true - ch := make(chan string) - close(ch) - m.streamChan = ch - m.chat.Messages = []state.Message{{Role: "assistant", Content: "done", Streaming: true}} - - updated, cmd := m.Update(StreamDoneMsg{}) - got := updated.(Model) - - if cmd != nil { - t.Fatal("expected no command") - } - if got.chat.Generating { - t.Fatal("expected generation to stop") - } - if got.streamChan != nil { - t.Fatal("expected stream channel to be cleared") - } - if got.chat.Messages[0].Streaming { - t.Fatal("expected last message streaming flag to clear") - } -} - -func TestStreamDoneMsgDoesNotReexecuteWhenToolAlreadyExecuting(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.Generating = true - m.chat.ToolExecuting = true - m.chat.Messages = []state.Message{{Role: "assistant", Content: `{"tool":"read","params":{"path":"README.md"}}`, Streaming: true}} - - called := false - executeToolCall = func(services.ToolCall) *services.ToolResult { - called = true - return nil - } - - updated, cmd := m.Update(StreamDoneMsg{}) - got := updated.(Model) - if cmd != nil { - t.Fatal("expected no command") - } - if !got.chat.ToolExecuting { - t.Fatal("expected tool executing flag to remain true") - } - if called { - t.Fatal("expected no duplicate tool execution") - } -} - -func TestShowHideHelpRefreshMemoryAndExitMsgs(t *testing.T) { - client := &fakeChatClient{memoryStats: &services.MemoryStats{TotalItems: 7}} - m := newTestModel(t, client) - - updated, _ := m.Update(ShowHelpMsg{}) - got := updated.(Model) - if got.ui.Mode != state.ModeHelp { - t.Fatalf("expected help mode, got %v", got.ui.Mode) - } - - updated, _ = got.Update(HideHelpMsg{}) - got = updated.(Model) - if got.ui.Mode != state.ModeChat { - t.Fatalf("expected chat mode, got %v", got.ui.Mode) - } - - updated, _ = got.Update(RefreshMemoryMsg{}) - got = updated.(Model) - if got.chat.MemoryStats.TotalItems != 7 { - t.Fatalf("expected refreshed stats, got %+v", got.chat.MemoryStats) - } - - _, cmd := got.Update(ExitMsg{}) - if cmd == nil { - t.Fatal("expected quit command") - } -} - -func TestRefreshMemoryMsgIgnoresClientError(t *testing.T) { - client := &fakeChatClient{memoryErr: errors.New("stats failed")} - m := newTestModel(t, client) - m.chat.MemoryStats.TotalItems = 5 - - updated, _ := m.Update(RefreshMemoryMsg{}) - got := updated.(Model) - if got.chat.MemoryStats.TotalItems != 5 { - t.Fatalf("expected previous stats to be preserved, got %+v", got.chat.MemoryStats) - } -} - -func TestStreamDoneMsgExecutesToolCallFromAssistantJSON(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.Generating = true - m.chat.Messages = []state.Message{{Role: "assistant", Content: `{"tool":"read","params":{"path":"README.md"}}`, Streaming: true}} - - expected := &services.ToolResult{ToolName: "read", Success: true, Output: "ok"} - executeToolCall = func(call services.ToolCall) *services.ToolResult { - if call.Tool != "read" { - t.Fatalf("expected read tool, got %q", call.Tool) - } - if got, _ := call.Params["path"].(string); got != "README.md" { - t.Fatalf("expected normalized path param, got %+v", call.Params) - } - return expected - } - - updated, cmd := m.Update(StreamDoneMsg{}) - got := updated.(Model) - - if !got.chat.ToolExecuting { - t.Fatal("expected tool execution flag to be set") - } - if len(got.chat.Messages) != 2 { - t.Fatalf("expected tool status message to be appended, got %d messages", len(got.chat.Messages)) - } - if !strings.HasPrefix(got.chat.Messages[1].Content, toolStatusPrefix) { - t.Fatalf("expected transient tool status, got %q", got.chat.Messages[1].Content) - } - if cmd == nil { - t.Fatal("expected tool execution command") - } - msg := cmd() - resultMsg, ok := msg.(ToolResultMsg) - if !ok { - t.Fatalf("expected ToolResultMsg, got %T", msg) - } - if resultMsg.Result != expected { - t.Fatalf("expected tool result to round-trip, got %+v", resultMsg.Result) - } -} - -func TestStreamDoneMsgReturnsToolErrorWhenToolResultIsNil(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.Generating = true - m.chat.Messages = []state.Message{{Role: "assistant", Content: `{"tool":"read","params":{"path":"README.md"}}`, Streaming: true}} - - executeToolCall = func(services.ToolCall) *services.ToolResult { return nil } - - _, cmd := m.Update(StreamDoneMsg{}) - if cmd == nil { - t.Fatal("expected tool execution command") - } - msg := cmd() - if _, ok := msg.(ToolErrorMsg); !ok { - t.Fatalf("expected ToolErrorMsg, got %T", msg) - } -} - -func TestToolResultMsgAddsContextAndRestartsStreaming(t *testing.T) { - client := &fakeChatClient{chatChunks: []string{"tool follow-up"}} - m := newTestModel(t, client) - m.chat.Messages = []state.Message{{Role: "user", Content: "hello"}} - m.chat.ToolExecuting = true - - result := &services.ToolResult{ToolName: "read", Success: true, Output: "README"} - updated, cmd := m.Update(ToolResultMsg{Result: result}) - got := updated.(Model) - - if got.chat.ToolExecuting { - t.Fatal("expected tool execution flag to be cleared") - } - if !got.chat.Generating { - t.Fatal("expected follow-up generation to start") - } - if len(got.chat.Messages) != 3 { - t.Fatalf("expected tool context and placeholder messages, got %d", len(got.chat.Messages)) - } - if !strings.HasPrefix(got.chat.Messages[1].Content, toolContextPrefix) { - t.Fatalf("expected tool context message, got %q", got.chat.Messages[1].Content) - } - if got.chat.Messages[2].Role != "assistant" || got.chat.Messages[2].Content != "" { - t.Fatalf("expected assistant placeholder, got %+v", got.chat.Messages[2]) - } - if cmd == nil { - t.Fatal("expected streaming command") - } - msg := cmd() - chunk, ok := msg.(StreamChunkMsg) - if !ok { - t.Fatalf("expected StreamChunkMsg, got %T", msg) - } - if chunk.Content != "tool follow-up" { - t.Fatalf("expected tool follow-up chunk, got %q", chunk.Content) - } -} - -func TestToolErrorMsgAddsErrorContextAndRestartsStreaming(t *testing.T) { - client := &fakeChatClient{chatChunks: []string{"error recovery"}} - m := newTestModel(t, client) - m.chat.ToolExecuting = true - - updated, cmd := m.Update(ToolErrorMsg{Err: errors.New("tool failed")}) - got := updated.(Model) - - if got.chat.ToolExecuting { - t.Fatal("expected tool execution flag to be cleared") - } - if !got.chat.Generating { - t.Fatal("expected generation restart after tool error") - } - if len(got.chat.Messages) != 2 { - t.Fatalf("expected tool error context and placeholder, got %d messages", len(got.chat.Messages)) - } - if !strings.Contains(got.chat.Messages[0].Content, "tool failed") { - t.Fatalf("expected tool error context, got %q", got.chat.Messages[0].Content) - } - if cmd == nil { - t.Fatal("expected follow-up stream command") - } - msg := cmd() - if _, ok := msg.(StreamChunkMsg); !ok { - t.Fatalf("expected StreamChunkMsg, got %T", msg) - } -} - -func TestBuildMessagesSkipsEmptyAssistantPlaceholder(t *testing.T) { - m := Model{ - chat: state.ChatState{Messages: []state.Message{ - {Role: "system", Content: "persona"}, - {Role: "user", Content: "hello"}, - {Role: "assistant", Content: ""}, - }}, - } - - got := m.buildMessages() - if len(got) != 2 { - t.Fatalf("expected 2 messages, got %d", len(got)) - } - if got[0].Role != "system" || got[1].Role != "user" { - t.Fatalf("unexpected message order: %+v", got) - } - if got[1].Content != "hello" { - t.Fatalf("expected user message to be preserved, got %+v", got[1]) - } -} - -func TestFormatTypeStats(t *testing.T) { - if got := formatTypeStats(nil); got == "" { - t.Fatal("expected non-empty placeholder") - } - got := formatTypeStats(map[string]int{ - services.TypeUserPreference: 2, - services.TypeCodeFact: 1, - }) - if !strings.Contains(got, services.TypeUserPreference+"=2") || !strings.Contains(got, services.TypeCodeFact+"=1") { - t.Fatalf("unexpected formatted stats %q", got) - } -} - -func TestRecentToolContextIndexes(t *testing.T) { - messages := []state.Message{ - {Role: "system", Content: "[TOOL_CONTEXT]\na"}, - {Role: "assistant", Content: "x"}, - {Role: "system", Content: "[TOOL_CONTEXT]\nb"}, - } - got := recentToolContextIndexes(messages, 1) - if len(got) != 1 { - t.Fatalf("expected one index, got %+v", got) - } - if _, ok := got[2]; !ok { - t.Fatalf("expected newest index to be kept, got %+v", got) - } -} - -func TestFormatToolStatusMessage(t *testing.T) { - got := formatToolStatusMessage("read", map[string]interface{}{"filePath": "README.md"}) - if !strings.Contains(got, "tool=read") || !strings.Contains(got, "README.md") { - t.Fatalf("unexpected tool status %q", got) - } -} - -func TestFormatToolContextMessage(t *testing.T) { - got := formatToolContextMessage(&services.ToolResult{ - ToolName: "read", - Success: true, - Output: "hello", - Metadata: map[string]interface{}{"k": "v"}, - }) - if !strings.Contains(got, "tool=read") || !strings.Contains(got, "metadata=") || !strings.Contains(got, "output:") { - t.Fatalf("unexpected tool context %q", got) - } - - got = formatToolContextMessage(&services.ToolResult{ToolName: "read", Success: false, Error: "boom"}) - if !strings.Contains(got, "error:") || !strings.Contains(got, "boom") { - t.Fatalf("unexpected error context %q", got) - } -} - -func TestFormatToolErrorContext(t *testing.T) { - got := formatToolErrorContext(errors.New("boom")) - if !strings.Contains(got, "boom") { - t.Fatalf("unexpected tool error context %q", got) - } -} - -func TestTruncateForContext(t *testing.T) { - if got := truncateForContext(" hi ", 10); got != "hi" { - t.Fatalf("expected trimmed content, got %q", got) - } - got := truncateForContext(strings.Repeat("a", 20), 10) - if !strings.Contains(got, "truncated") { - t.Fatalf("expected truncation marker, got %q", got) - } -} - -func TestDetectLanguage(t *testing.T) { - tests := []struct { - code string - ext string - run string - }{ - {"#!/bin/bash\necho hi", "sh", "bash"}, - {"package main\nfunc main(){}", "go", ""}, - {"def hi():\n pass", "py", "python"}, - {"fn main() {}", "rs", "rustc"}, - {"console.log('x')", "js", "node"}, - {"unknown", "", ""}, - } - for _, tt := range tests { - ext, run := detectLanguage(tt.code) - if ext != tt.ext || run != tt.run { - t.Fatalf("detectLanguage(%q) = (%q,%q), want (%q,%q)", tt.code, ext, run, tt.ext, tt.run) - } - } -} - -func TestCalculateInputHeight(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - - m.textarea.SetValue("one") - if got := m.calculateInputHeight(); got != 3 { - t.Fatalf("expected minimum height 3, got %d", got) - } - m.textarea.SetValue(strings.Repeat("a\n", 10)) - if got := m.calculateInputHeight(); got != 8 { - t.Fatalf("expected capped height 8, got %d", got) - } -} - -func TestStreamResponseReturnsErrorMsg(t *testing.T) { - client := &fakeChatClient{chatErr: errors.New("chat failed")} - m := newTestModel(t, client) - - cmd := m.streamResponse([]services.Message{{Role: "user", Content: "hi"}}) - if cmd == nil { - t.Fatal("expected command") - } - msg := cmd() - if _, ok := msg.(StreamErrorMsg); !ok { - t.Fatalf("expected StreamErrorMsg, got %T", msg) - } -} - -func TestStreamResponseAndStreamResponseFromChannelDone(t *testing.T) { - client := &fakeChatClient{chatChunks: nil} - m := newTestModel(t, client) - - cmd := m.streamResponse([]services.Message{{Role: "user", Content: "hi"}}) - if cmd == nil { - t.Fatal("expected command") - } - msg := cmd() - if _, ok := msg.(StreamDoneMsg); !ok { - t.Fatalf("expected StreamDoneMsg, got %T", msg) - } - - m.streamChan = nil - if cmd := m.streamResponseFromChannel(); cmd != nil { - t.Fatal("expected nil command when stream channel is nil") - } -} - -func TestStreamErrorReplacesTrailingPlaceholder(t *testing.T) { - m := Model{ - chat: state.ChatState{ - HistoryTurns: 6, - Messages: []state.Message{ - {Role: "user", Content: "hello"}, - {Role: "assistant", Content: ""}, - }, - }, - } - - updated, _ := m.Update(StreamErrorMsg{Err: errors.New("boom")}) - got := updated.(Model) - if len(got.chat.Messages) != 2 { - t.Fatalf("expected placeholder replacement without extra message, got %d messages", len(got.chat.Messages)) - } - if !strings.Contains(got.chat.Messages[1].Content, "boom") { - t.Fatalf("expected trailing placeholder to become error, got %q", got.chat.Messages[1].Content) - } -} - -func TestClearContextDoesNotReinjectStalePersonaMessage(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.APIKeyReady = true - m.chat.Messages = []state.Message{ - {Role: "system", Content: "stale persona"}, - {Role: "user", Content: "hello"}, - } - - updated, _ := m.handleCommand("/clear-context") - got := updated.(Model) - if len(got.chat.Messages) != 1 { - t.Fatalf("expected only confirmation message after clear-context, got %d messages", len(got.chat.Messages)) - } - if got.chat.Messages[0].Role != "assistant" { - t.Fatalf("expected confirmation assistant message, got %+v", got.chat.Messages[0]) - } -} - -func TestBuildMessagesSkipsTransientToolStatusMessage(t *testing.T) { - m := Model{ - chat: state.ChatState{Messages: []state.Message{ - {Role: "user", Content: "hello"}, - {Role: "system", Content: "[TOOL_STATUS] tool=read file=README.md"}, - {Role: "assistant", Content: "ok"}, - }}, - } - - got := m.buildMessages() - if len(got) != 2 { - t.Fatalf("expected 2 messages after filtering tool status, got %d", len(got)) - } - for _, msg := range got { - if msg.Role == "system" && strings.HasPrefix(msg.Content, "[TOOL_STATUS]") { - t.Fatalf("transient tool status should not be included in model context: %+v", msg) - } - } -} - -func TestBuildMessagesKeepsOnlyRecentToolContextMessages(t *testing.T) { - m := Model{} - m.chat.Messages = append(m.chat.Messages, state.Message{Role: "user", Content: "step 1"}) - for i := 1; i <= 5; i++ { - m.chat.Messages = append(m.chat.Messages, state.Message{Role: "system", Content: "[TOOL_CONTEXT]\ntool=read\nsuccess=true\noutput:\nchunk " + string(rune('0'+i))}) - } - m.chat.Messages = append(m.chat.Messages, state.Message{Role: "assistant", Content: "done"}) - - got := m.buildMessages() - toolCtxCount := 0 - for _, msg := range got { - if msg.Role == "system" && strings.HasPrefix(msg.Content, "[TOOL_CONTEXT]") { - toolCtxCount++ - } - } - if toolCtxCount != maxToolContextMessages { - t.Fatalf("expected %d tool context messages, got %d", maxToolContextMessages, toolCtxCount) - } - - joined := "" - for _, msg := range got { - joined += msg.Content + "\n" - } - if strings.Contains(joined, "chunk 1") || strings.Contains(joined, "chunk 2") { - t.Fatalf("old tool context should be evicted, got context: %s", joined) - } - if !strings.Contains(joined, "chunk 3") || !strings.Contains(joined, "chunk 4") || !strings.Contains(joined, "chunk 5") { - t.Fatalf("newest tool context messages should be kept, got context: %s", joined) - } -} - -func TestWorkspaceCommandShowsWorkspaceRoot(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.APIKeyReady = true - m.chat.WorkspaceRoot = `F:/Qiniu/test1` - - updated, _ := m.handleCommand("/pwd") - got := updated.(Model) - if len(got.chat.Messages) != 1 { - t.Fatalf("expected exactly 1 message, got %d", len(got.chat.Messages)) - } - if got.chat.Messages[0].Role != "assistant" { - t.Fatalf("expected assistant message, got %+v", got.chat.Messages[0]) - } - if !strings.Contains(got.chat.Messages[0].Content, `F:/Qiniu/test1`) { - t.Fatalf("expected workspace path in response, got %q", got.chat.Messages[0].Content) - } -} - -func TestApproveCommandWhileToolExecutingKeepsPendingApproval(t *testing.T) { - m := Model{ - chat: state.ChatState{ - ToolExecuting: true, - PendingApproval: &state.PendingApproval{ - Call: services.ToolCall{ - Tool: "bash", - Params: map[string]interface{}{ - "command": "echo hello", - }, - }, - ToolType: "Bash", - Target: "echo hello", - }, - }, - } - - updated, cmd := m.handleCommand("/y") - if cmd != nil { - t.Fatal("expected no tool execution command while another tool is running") - } - - got := updated.(Model) - if got.chat.PendingApproval == nil { - t.Fatal("expected pending approval to be preserved") - } - if got.chat.PendingApproval.Call.Tool != "bash" { - t.Fatalf("expected pending tool to stay intact, got %+v", got.chat.PendingApproval.Call) - } - if len(got.chat.Messages) != 1 { - t.Fatalf("expected a single assistant warning, got %d", len(got.chat.Messages)) - } - if !strings.Contains(got.chat.Messages[0].Content, "/y") { - t.Fatalf("expected warning message to mention /y, got %q", got.chat.Messages[0].Content) - } -} - -func TestToolResultMsgSecurityAskStoresPendingApproval(t *testing.T) { - client := &fakeChatClient{} - m := newTestModel(t, client) - m.chat.ToolExecuting = true - - result := &services.ToolResult{ - ToolName: "bash", - Success: false, - Metadata: map[string]interface{}{ - "securityAction": "ask", - "securityToolType": "Bash", - "securityTarget": "echo hello", - }, - } - - updated, cmd := m.Update(ToolResultMsg{ - Result: result, - Call: services.ToolCall{ - Tool: "bash", - Params: map[string]interface{}{ - "command": "echo hello", - }, - }, - }) - if cmd != nil { - t.Fatal("expected no follow-up command while waiting for approval") - } - - got := updated.(Model) - if got.chat.ToolExecuting { - t.Fatal("expected tool executing flag to be cleared") - } - if got.chat.PendingApproval == nil { - t.Fatal("expected pending approval to be recorded") - } - if got.chat.PendingApproval.Target != "echo hello" { - t.Fatalf("unexpected pending approval target %q", got.chat.PendingApproval.Target) - } - if len(got.chat.Messages) != 1 { - t.Fatalf("expected one approval prompt message, got %d", len(got.chat.Messages)) - } - if !strings.Contains(got.chat.Messages[0].Content, "/y") { - t.Fatalf("expected approval prompt to mention /y, got %q", got.chat.Messages[0].Content) - } -} diff --git a/internal/tui/core/view.go b/internal/tui/core/view.go deleted file mode 100644 index c7f96ceb..00000000 --- a/internal/tui/core/view.go +++ /dev/null @@ -1,121 +0,0 @@ -package core - -import ( - "strings" - - "go-llm-demo/internal/tui/components" - "go-llm-demo/internal/tui/state" - - "github.com/charmbracelet/lipgloss" -) - -func (m Model) View() string { - if m.ui.Width < 20 || m.ui.Height < 6 { - return "Window too small" - } - - statusHeight := 1 - helpHeight := 0 - if m.ui.Mode == state.ModeHelp { - helpHeight = minInt(20, m.ui.Height-statusHeight-3) - } - - inputContent := m.renderInputArea() - inputHeight := countLines(inputContent) - if inputHeight < 4 { - inputHeight = 4 - } - - contentHeight := m.ui.Height - statusHeight - inputHeight - helpHeight - if contentHeight < 3 { - contentHeight = 3 - } - - statusBar := lipgloss.NewStyle(). - Height(statusHeight). - Width(m.ui.Width). - Render(components.StatusBar{ - Model: m.chat.ActiveModel, - MemoryCnt: m.chat.MemoryStats.TotalItems, - Generating: m.chat.Generating, - Width: m.ui.Width, - }.Render()) - - viewportView := m.viewport - viewportView.SetContent(m.renderChatContent()) - chatArea := lipgloss.NewStyle(). - Width(m.ui.Width). - Height(contentHeight). - Render(viewportView.View()) - - inputArea := lipgloss.NewStyle(). - Width(m.ui.Width). - Render(inputContent) - - if m.ui.Mode == state.ModeHelp { - help := lipgloss.NewStyle(). - Width(m.ui.Width). - Height(helpHeight). - Render(components.RenderHelp(m.ui.Width)) - return lipgloss.JoinVertical(lipgloss.Left, statusBar, chatArea, help, inputArea) - } - - return lipgloss.JoinVertical(lipgloss.Left, statusBar, chatArea, inputArea) -} - -func countLines(s string) int { - if s == "" { - return 0 - } - return strings.Count(s, "\n") + 1 -} - -func (m Model) renderInputArea() string { - return components.InputBox{ - Body: m.textarea.View(), - Generating: m.chat.Generating, - Status: m.ui.CopyStatus, - }.Render() -} - -func (m *Model) renderChatContent() string { - if m.ui.Mode == state.ModeTodo { - m.chatLayout = components.RenderedChatLayout{} - return components.TodoList{ - Todos: m.todos, - Cursor: m.todoCursor, - Width: m.viewport.Width, - Focused: true, - }.Render() - } - layout := components.MessageList{Messages: m.toComponentMessages(), Width: m.viewport.Width}.RenderLayout() - m.chatLayout = layout - return layout.Content -} - -func (m Model) toComponentMessages() []components.Message { - messages := make([]components.Message, len(m.chat.Messages)) - for i, msg := range m.chat.Messages { - messages[i] = components.Message{ - Role: msg.Role, - Content: displayMessageContent(msg.Role, msg.Content), - Timestamp: msg.Timestamp, - Streaming: msg.Streaming, - } - } - return messages -} - -func displayMessageContent(role, content string) string { - if role == "system" && isResumeSummaryMessage(content) { - return strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(content), resumeSummaryPrefix)) - } - return content -} - -func minInt(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/internal/tui/core/view_test.go b/internal/tui/core/view_test.go deleted file mode 100644 index 9311439e..00000000 --- a/internal/tui/core/view_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package core - -import ( - "strings" - "testing" - "time" - - "go-llm-demo/internal/tui/state" -) - -func TestCountLines(t *testing.T) { - tests := []struct { - name string - in string - want int - }{ - {name: "empty", in: "", want: 0}, - {name: "single", in: "hello", want: 1}, - {name: "multi", in: "a\nb\nc", want: 3}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := countLines(tt.in); got != tt.want { - t.Fatalf("expected %d, got %d", tt.want, got) - } - }) - } -} - -func TestToComponentMessagesPreservesFields(t *testing.T) { - ts := time.Unix(123, 0) - m := Model{ - chat: state.ChatState{Messages: []state.Message{{ - Role: "assistant", - Content: "hello", - Timestamp: ts, - Streaming: true, - }}}, - } - - got := m.toComponentMessages() - if len(got) != 1 { - t.Fatalf("expected 1 message, got %d", len(got)) - } - if got[0].Role != "assistant" || got[0].Content != "hello" || !got[0].Timestamp.Equal(ts) || !got[0].Streaming { - t.Fatalf("unexpected converted message: %+v", got[0]) - } -} - -func TestViewShowsSmallWindowMessage(t *testing.T) { - m := Model{} - m.ui.Width = 10 - m.ui.Height = 5 - - if got := m.View(); got != "Window too small" { - t.Fatalf("expected small window warning, got %q", got) - } -} - -func TestViewRendersHelpPanelInHelpMode(t *testing.T) { - m := NewModel(&fakeChatClient{}, "persona", 6, "config.yaml", "workspace") - m.ui.Width = 80 - m.ui.Height = 30 - m.ui.Mode = state.ModeHelp - - rendered := m.View() - if !strings.Contains(rendered, "NeoCode Help") { - t.Fatalf("expected help panel in view, got %q", rendered) - } - if !strings.Contains(rendered, "/help") { - t.Fatalf("expected help commands in view, got %q", rendered) - } -} diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go new file mode 100644 index 00000000..afe04047 --- /dev/null +++ b/internal/tui/keymap.go @@ -0,0 +1,94 @@ +package tui + +import "github.com/charmbracelet/bubbles/key" + +type keyMap struct { + Send key.Binding + NewSession key.Binding + NextPanel key.Binding + PrevPanel key.Binding + FocusInput key.Binding + OpenSession key.Binding + ToggleHelp key.Binding + Quit key.Binding + ScrollUp key.Binding + ScrollDown key.Binding + PageUp key.Binding + PageDown key.Binding + Top key.Binding + Bottom key.Binding +} + +func newKeyMap() keyMap { + return keyMap{ + Send: key.NewBinding( + key.WithKeys("enter", "ctrl+s"), + key.WithHelp("enter", "send"), + ), + NewSession: key.NewBinding( + key.WithKeys("ctrl+n"), + key.WithHelp("ctrl+n", "new"), + ), + NextPanel: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next panel"), + ), + PrevPanel: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "prev panel"), + ), + FocusInput: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "focus input"), + ), + OpenSession: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "open session"), + ), + ToggleHelp: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + Quit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + ScrollUp: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("up/k", "up"), + ), + ScrollDown: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("down/j", "down"), + ), + PageUp: key.NewBinding( + key.WithKeys("pgup", "b"), + key.WithHelp("pgup", "page up"), + ), + PageDown: key.NewBinding( + key.WithKeys("pgdown", "f"), + key.WithHelp("pgdn", "page down"), + ), + Top: key.NewBinding( + key.WithKeys("g", "home"), + key.WithHelp("g", "top"), + ), + Bottom: key.NewBinding( + key.WithKeys("G", "end"), + key.WithHelp("G", "bottom"), + ), + } +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Send, k.NewSession, k.NextPanel, 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}, + } +} diff --git a/internal/tui/services/api_client.go b/internal/tui/services/api_client.go deleted file mode 100644 index 11362b5a..00000000 --- a/internal/tui/services/api_client.go +++ /dev/null @@ -1,239 +0,0 @@ -package services - -import ( - "context" - "strings" - - "go-llm-demo/configs" - "go-llm-demo/internal/server/domain" - "go-llm-demo/internal/server/infra/provider" - "go-llm-demo/internal/server/infra/repository" - "go-llm-demo/internal/server/infra/tools" - "go-llm-demo/internal/server/service" -) - -type Message = domain.Message -type Todo = domain.Todo -type TodoStatus = domain.TodoStatus -type TodoPriority = domain.TodoPriority - -const ( - TodoPending = domain.TodoPending - TodoInProgress = domain.TodoInProgress - TodoCompleted = domain.TodoCompleted - - TodoPriorityHigh = domain.TodoPriorityHigh - TodoPriorityMedium = domain.TodoPriorityMedium - TodoPriorityLow = domain.TodoPriorityLow -) - -var ( - ParseTodoPriority = domain.ParseTodoPriority -) - -// ChatClient 定义 TUI 侧依赖的最小聊天与记忆接口。 -type ChatClient interface { - Chat(ctx context.Context, messages []Message, model string) (<-chan string, error) - GetMemoryStats(ctx context.Context) (*MemoryStats, error) - ClearMemory(ctx context.Context) error - ClearSessionMemory(ctx context.Context) error - GetTodoList(ctx context.Context) ([]Todo, error) - AddTodo(ctx context.Context, content string, priority TodoPriority) (*Todo, error) - UpdateTodoStatus(ctx context.Context, id string, status TodoStatus) error - RemoveTodo(ctx context.Context, id string) error - DefaultModel() string -} - -// WorkingSessionSummaryProvider 为支持恢复摘要的客户端扩展接口。 -type WorkingSessionSummaryProvider interface { - GetWorkingSessionSummary(ctx context.Context) (string, error) -} - -// MemoryStats 是 TUI 展示 memory 面板所需的聚合统计信息。 -type MemoryStats struct { - PersistentItems int - SessionItems int - TotalItems int - TopK int - MinScore float64 - Path string - ByType map[string]int -} - -type localChatClient struct { - roleSvc domain.RoleService - memorySvc domain.MemoryService - workingSvc domain.WorkingMemoryService - todoSvc domain.TodoService - config *configs.AppConfiguration -} - -// NewLocalChatClient 将本地服务组装为 TUI 使用的聊天客户端。 -func NewLocalChatClient() (ChatClient, error) { - cfg := configs.GlobalAppConfig - if cfg == nil { - return nil, context.Canceled - } - - storePath := strings.TrimSpace(cfg.Memory.StoragePath) - if storePath == "" { - storePath = "./data/memory_rules.json" - } - maxItems := cfg.Memory.MaxItems - if maxItems <= 0 { - maxItems = 1000 - } - workspaceRoot := tools.GetWorkspaceRoot() - persistentRepo := repository.NewFileMemoryStore(storePath, maxItems) - sessionRepo := repository.NewSessionMemoryStore(maxItems) - workingStatePath := "" - if cfg.History.PersistSessionState { - workingStatePath = BuildWorkspaceStatePath(cfg.History.WorkspaceStateDir, workspaceRoot) - } - workingRepo := repository.NewWorkingMemoryStore(workingStatePath) - memorySvc := service.NewMemoryService( - persistentRepo, - sessionRepo, - cfg.Memory.TopK, - cfg.Memory.MinMatchScore, - cfg.Memory.MaxPromptChars, - storePath, - cfg.Memory.PersistTypes, - ) - workingSvc := service.NewWorkingMemoryService(workingRepo, cfg.History.ShortTermTurns, workspaceRoot) - - roleRepo := repository.NewFileRoleStore("./data/roles.json") - roleSvc := service.NewRoleService(roleRepo, strings.TrimSpace(cfg.Persona.FilePath)) - - todoRepo := repository.NewInMemoryTodoRepository() - todoSvc := service.NewTodoService(todoRepo) - tools.GlobalRegistry.Register(tools.NewTodoTool(todoSvc)) - - // 当前仍以内进程方式组装服务,后续替换为真实 transport 时可继续复用该接口。 - return &localChatClient{ - roleSvc: roleSvc, - memorySvc: memorySvc, - workingSvc: workingSvc, - todoSvc: todoSvc, - config: cfg, - }, nil -} - -// Chat 通过本地聊天服务发送消息。 -func (c *localChatClient) Chat(ctx context.Context, messages []Message, model string) (<-chan string, error) { - chatProvider, err := provider.NewChatProvider(model) - if err != nil { - return nil, err - } - chatSvc := service.NewChatService(c.memorySvc, c.workingSvc, c.todoSvc, c.roleSvc, chatProvider) - return chatSvc.Send(ctx, &domain.ChatRequest{Messages: messages, Model: model}) -} - -// GetMemoryStats 返回 TUI 所需的当前记忆统计信息。 -func (c *localChatClient) GetMemoryStats(ctx context.Context) (*MemoryStats, error) { - stats, err := c.memorySvc.GetStats(ctx) - if err != nil { - return nil, err - } - return &MemoryStats{ - PersistentItems: stats.PersistentItems, - SessionItems: stats.SessionItems, - TotalItems: stats.TotalItems, - TopK: stats.TopK, - MinScore: stats.MinScore, - Path: stats.Path, - ByType: stats.ByType, - }, nil -} - -// ClearMemory 通过本地记忆服务清空长期记忆。 -func (c *localChatClient) ClearMemory(ctx context.Context) error { - return c.memorySvc.Clear(ctx) -} - -// ClearSessionMemory 清空会话记忆和工作记忆状态。 -func (c *localChatClient) ClearSessionMemory(ctx context.Context) error { - if err := c.memorySvc.ClearSession(ctx); err != nil { - return err - } - if c.workingSvc != nil { - return c.workingSvc.Clear(ctx) - } - return nil -} - -// GetTodoList 返回当前任务清单。 -func (c *localChatClient) GetTodoList(ctx context.Context) ([]domain.Todo, error) { - if c.todoSvc == nil { - return nil, nil - } - return c.todoSvc.ListTodos(ctx) -} - -// AddTodo 添加一个新任务。 -func (c *localChatClient) AddTodo(ctx context.Context, content string, priority domain.TodoPriority) (*domain.Todo, error) { - if c.todoSvc == nil { - return nil, nil - } - return c.todoSvc.AddTodo(ctx, content, priority) -} - -// UpdateTodoStatus 更新任务状态。 -func (c *localChatClient) UpdateTodoStatus(ctx context.Context, id string, status domain.TodoStatus) error { - if c.todoSvc == nil { - return nil - } - return c.todoSvc.UpdateTodoStatus(ctx, id, status) -} - -// RemoveTodo 移除特定任务。 -func (c *localChatClient) RemoveTodo(ctx context.Context, id string) error { - if c.todoSvc == nil { - return nil - } - return c.todoSvc.RemoveTodo(ctx, id) -} - -// DefaultModel 返回 TUI 使用的默认模型。 -func (c *localChatClient) DefaultModel() string { - return provider.DefaultModelForConfig(c.config) -} - -// GetWorkingSessionSummary 返回当前工作区上次保存的会话摘要。 -func (c *localChatClient) GetWorkingSessionSummary(ctx context.Context) (string, error) { - if c.workingSvc == nil || c.config == nil || !c.config.History.ResumeLastSession { - return "", nil - } - state, err := c.workingSvc.Get(ctx) - if err != nil || state == nil { - return "", err - } - return formatWorkingSessionSummary(state), nil -} - -func formatWorkingSessionSummary(state *domain.WorkingMemoryState) string { - if state == nil { - return "" - } - lines := make([]string, 0, 6) - if strings.TrimSpace(state.CurrentTask) != "" { - lines = append(lines, "已恢复上次工作现场:") - lines = append(lines, "- 当前目标: "+domain.SummarizeText(state.CurrentTask, 120)) - } - if strings.TrimSpace(state.LastCompletedAction) != "" { - lines = append(lines, "- 上次完成: "+domain.SummarizeText(state.LastCompletedAction, 120)) - } - if strings.TrimSpace(state.CurrentInProgress) != "" { - lines = append(lines, "- 当前进行中: "+domain.SummarizeText(state.CurrentInProgress, 120)) - } - if strings.TrimSpace(state.NextStep) != "" { - lines = append(lines, "- 下一步: "+domain.SummarizeText(state.NextStep, 120)) - } - if len(state.RecentFiles) > 0 { - lines = append(lines, "- 最近文件: "+strings.Join(state.RecentFiles, ", ")) - } - if len(lines) == 0 { - return "" - } - return strings.Join(lines, "\n") -} diff --git a/internal/tui/services/runtime_services.go b/internal/tui/services/runtime_services.go deleted file mode 100644 index cef89191..00000000 --- a/internal/tui/services/runtime_services.go +++ /dev/null @@ -1,78 +0,0 @@ -package services - -import ( - "context" - - "go-llm-demo/configs" - "go-llm-demo/internal/server/domain" - serverprovider "go-llm-demo/internal/server/infra/provider" - serverrepo "go-llm-demo/internal/server/infra/repository" - servertools "go-llm-demo/internal/server/infra/tools" - serverservice "go-llm-demo/internal/server/service" -) - -type ToolCall = domain.ToolCall -type ToolResult = servertools.ToolResult - -const ( - TypeUserPreference = domain.TypeUserPreference - TypeProjectRule = domain.TypeProjectRule - TypeCodeFact = domain.TypeCodeFact - TypeFixRecipe = domain.TypeFixRecipe - TypeSessionMemory = domain.TypeSessionMemory -) - -var ( - ErrInvalidAPIKey = serverprovider.ErrInvalidAPIKey - ErrAPIKeyValidationSoft = serverprovider.ErrAPIKeyValidationSoft -) - -func ResolveWorkspaceRoot(workspaceFlag string) (string, error) { - return servertools.ResolveWorkspaceRoot(workspaceFlag) -} - -func SetWorkspaceRoot(root string) error { - return servertools.SetWorkspaceRoot(root) -} - -func GetWorkspaceRoot() string { - return servertools.GetWorkspaceRoot() -} - -func NormalizeToolParams(params map[string]interface{}) map[string]interface{} { - return servertools.NormalizeParams(params) -} - -func ExecuteToolCall(call ToolCall) *ToolResult { - return servertools.GlobalRegistry.Execute(call) -} - -func ApproveSecurityAsk(toolType, target string) { - servertools.ApproveSecurityAsk(toolType, target) -} - -func InitializeSecurity(configDir string) error { - securityRepo := serverrepo.NewSecurityConfigRepository() - securitySvc := serverservice.NewSecurityService(securityRepo) - if err := securitySvc.Initialize(configDir); err != nil { - return err - } - servertools.SetSecurityChecker(securitySvc) - return nil -} - -func ValidateChatAPIKey(ctx context.Context, cfg *configs.AppConfiguration) error { - return serverprovider.ValidateChatAPIKey(ctx, cfg) -} - -func NormalizeProviderName(name string) (string, bool) { - return serverprovider.NormalizeProviderName(name) -} - -func SupportedProviders() []string { - return serverprovider.SupportedProviders() -} - -func DefaultModelForProvider(name string) string { - return serverprovider.DefaultModelForProvider(name) -} diff --git a/internal/tui/services/runtime_services_test.go b/internal/tui/services/runtime_services_test.go deleted file mode 100644 index 15622053..00000000 --- a/internal/tui/services/runtime_services_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package services - -import ( - "context" - "strings" - "testing" - - servertools "go-llm-demo/internal/server/infra/tools" -) - -func TestResolveWorkspaceRootUsesEnvOverride(t *testing.T) { - dir := t.TempDir() - t.Setenv(servertools.WorkspaceEnvVar, dir) - - got, err := ResolveWorkspaceRoot("") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got != dir { - t.Fatalf("expected env workspace root %q, got %q", dir, got) - } -} - -func TestSetAndGetWorkspaceRoot(t *testing.T) { - origRoot := GetWorkspaceRoot() - t.Cleanup(func() { - _ = SetWorkspaceRoot(origRoot) - }) - - dir := t.TempDir() - if err := SetWorkspaceRoot(dir); err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got := GetWorkspaceRoot(); got != dir { - t.Fatalf("expected workspace root %q, got %q", dir, got) - } -} - -func TestNormalizeToolParamsRecursivelyConvertsSnakeCase(t *testing.T) { - got := NormalizeToolParams(map[string]interface{}{ - "file_path": "README.md", - "nested_map": map[string]interface{}{ - "line_number": 12, - }, - }) - - if got["filePath"] != "README.md" { - t.Fatalf("expected filePath key, got %+v", got) - } - nested, ok := got["nestedMap"].(map[string]interface{}) - if !ok { - t.Fatalf("expected nestedMap, got %+v", got["nestedMap"]) - } - if nested["lineNumber"] != 12 { - t.Fatalf("expected nested camelCase key, got %+v", nested) - } -} - -func TestExecuteToolCallReturnsUnknownToolError(t *testing.T) { - result := ExecuteToolCall(ToolCall{Tool: "unknown-tool", Params: map[string]interface{}{}}) - if result == nil { - t.Fatal("expected tool result") - } - if result.Success { - t.Fatalf("expected failure for unknown tool, got %+v", result) - } - if result.ToolName != "unknown-tool" { - t.Fatalf("expected tool name to round-trip, got %q", result.ToolName) - } -} - -func TestValidateChatAPIKeyRejectsNilConfig(t *testing.T) { - err := ValidateChatAPIKey(context.Background(), nil) - if err == nil || !strings.Contains(strings.ToLower(err.Error()), "config") { - t.Fatalf("expected nil-config error, got %v", err) - } -} - -func TestNormalizeProviderNameSupportedProvidersAndDefaultModel(t *testing.T) { - name, ok := NormalizeProviderName("openai") - if !ok || name != "openai" { - t.Fatalf("expected normalized openai provider, got %q ok=%v", name, ok) - } - if _, ok := NormalizeProviderName("unknown-provider"); ok { - t.Fatal("expected unknown provider to be rejected") - } - - providers := SupportedProviders() - if len(providers) == 0 { - t.Fatal("expected supported providers") - } - foundOpenAI := false - for _, provider := range providers { - if provider == "openai" { - foundOpenAI = true - break - } - } - if !foundOpenAI { - t.Fatalf("expected openai in supported providers, got %+v", providers) - } - - if model := DefaultModelForProvider("openai"); strings.TrimSpace(model) == "" { - t.Fatal("expected default model for openai") - } -} diff --git a/internal/tui/services/workspace_state_path.go b/internal/tui/services/workspace_state_path.go deleted file mode 100644 index c465594b..00000000 --- a/internal/tui/services/workspace_state_path.go +++ /dev/null @@ -1,20 +0,0 @@ -package services - -import ( - "crypto/sha1" - "encoding/hex" - "path/filepath" - "strings" -) - -// BuildWorkspaceStatePath 返回当前工作区的会话状态文件路径。 -func BuildWorkspaceStatePath(baseDir, workspaceRoot string) string { - baseDir = strings.TrimSpace(baseDir) - workspaceRoot = strings.TrimSpace(workspaceRoot) - if baseDir == "" || workspaceRoot == "" { - return "" - } - - hash := sha1.Sum([]byte(strings.ToLower(workspaceRoot))) - return filepath.Join(baseDir, hex.EncodeToString(hash[:]), "session_state.json") -} diff --git a/internal/tui/services/workspace_state_path_test.go b/internal/tui/services/workspace_state_path_test.go deleted file mode 100644 index 93c97a58..00000000 --- a/internal/tui/services/workspace_state_path_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package services - -import "testing" - -func TestBuildWorkspaceStatePathUsesStableHash(t *testing.T) { - baseDir := "./data/workspaces" - root := "D:/neo-code" - - first := BuildWorkspaceStatePath(baseDir, root) - second := BuildWorkspaceStatePath(baseDir, root) - if first == "" || first != second { - t.Fatalf("expected stable workspace state path, got %q and %q", first, second) - } -} diff --git a/internal/tui/state.go b/internal/tui/state.go new file mode 100644 index 00000000..bfa1a6e9 --- /dev/null +++ b/internal/tui/state.go @@ -0,0 +1,126 @@ +package tui + +import ( + "fmt" + "io" + "strings" + "time" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + agentruntime "github.com/dust/neo-code/internal/runtime" +) + +type panel int + +const ( + panelSessions panel = iota + panelTranscript + panelActivity + panelInput +) + +type UIState struct { + Sessions []agentruntime.SessionSummary + ActiveSessionID string + ActiveSessionTitle string + InputText string + IsAgentRunning bool + StreamingReply bool + CurrentTool string + ExecutionError string + StatusText string + CurrentProvider string + CurrentModel string + CurrentWorkdir string + ShowHelp bool + ShowModelPicker bool + Focus panel +} + +type activityEntry struct { + Time time.Time + Kind string + Title string + Detail string + IsError bool +} + +type sessionItem struct { + Summary agentruntime.SessionSummary + Active bool +} + +func (s sessionItem) FilterValue() string { + return strings.ToLower(s.Summary.Title) +} + +type modelItem struct { + name string + description string +} + +func (m modelItem) Title() string { + return m.name +} + +func (m modelItem) Description() string { + return m.description +} + +func (m modelItem) FilterValue() string { + return strings.ToLower(m.name + " " + m.description) +} + +type sessionDelegate struct { + styles styles +} + +func (d sessionDelegate) Height() int { + return 3 +} + +func (d sessionDelegate) Spacing() int { + return 1 +} + +func (d sessionDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { + return nil +} + +func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + session, ok := item.(sessionItem) + if !ok { + return + } + + width := max(18, m.Width()-2) + title := trimRunes(session.Summary.Title, max(8, width-10)) + meta := session.Summary.UpdatedAt.Format("01-02 15:04") + + prefix := "o" + if session.Active { + prefix = "*" + } + if index == m.Index() { + prefix = ">" + } + + content := lipgloss.JoinVertical( + lipgloss.Left, + fmt.Sprintf("%s %s", prefix, title), + d.styles.sessionMeta.Render(" "+meta), + ) + + style := d.styles.sessionRow + if session.Active { + style = d.styles.sessionRowActive + } + if index == m.Index() { + style = d.styles.sessionRowFocused + } + + fmt.Fprint(w, style.Width(width).Render(content)) +} diff --git a/internal/tui/state/chat_state.go b/internal/tui/state/chat_state.go deleted file mode 100644 index 213f5f5d..00000000 --- a/internal/tui/state/chat_state.go +++ /dev/null @@ -1,35 +0,0 @@ -package state - -import ( - "time" - - "go-llm-demo/internal/tui/services" -) - -type Message struct { - Role string - Content string - Timestamp time.Time - Streaming bool -} - -type PendingApproval struct { - Call services.ToolCall - ToolType string - Target string -} - -type ChatState struct { - Messages []Message - HistoryTurns int - Generating bool - ActiveModel string - MemoryStats services.MemoryStats - CommandHistory []string - CmdHistIndex int - WorkspaceRoot string - ToolExecuting bool - PendingApproval *PendingApproval - APIKeyReady bool - ConfigPath string -} diff --git a/internal/tui/state/ui_state.go b/internal/tui/state/ui_state.go deleted file mode 100644 index 9694c890..00000000 --- a/internal/tui/state/ui_state.go +++ /dev/null @@ -1,20 +0,0 @@ -package state - -type Mode int - -const ( - ModeChat Mode = iota - ModeCodeInput - ModeHelp - ModeMemory - ModeTodo -) - -type UIState struct { - Width int - Height int - Mode Mode - Focused string - AutoScroll bool - CopyStatus string -} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 00000000..ccbd99e1 --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,285 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +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" +) + +type styles struct { + doc lipgloss.Style + headerBrand lipgloss.Style + headerSub lipgloss.Style + headerMeta lipgloss.Style + headerSpacer lipgloss.Style + panel lipgloss.Style + panelFocused lipgloss.Style + panelTitle lipgloss.Style + panelSubtitle lipgloss.Style + panelBody lipgloss.Style + empty lipgloss.Style + sessionRow lipgloss.Style + sessionRowActive lipgloss.Style + sessionRowFocused lipgloss.Style + sessionMeta lipgloss.Style + streamTitle lipgloss.Style + streamMeta lipgloss.Style + streamContent lipgloss.Style + messageUserTag lipgloss.Style + messageAgentTag lipgloss.Style + messageToolTag lipgloss.Style + messageBody lipgloss.Style + messageUserBody lipgloss.Style + messageToolBody lipgloss.Style + inlineNotice lipgloss.Style + inlineError lipgloss.Style + inlineSystem lipgloss.Style + codeBlock lipgloss.Style + codeText lipgloss.Style + commandMenu lipgloss.Style + commandMenuTitle lipgloss.Style + commandUsage lipgloss.Style + commandUsageMatch lipgloss.Style + commandDesc lipgloss.Style + inputMeta lipgloss.Style + inputLine lipgloss.Style + footer lipgloss.Style + badgeUser lipgloss.Style + badgeAgent lipgloss.Style + badgeSuccess lipgloss.Style + badgeWarning lipgloss.Style + badgeError lipgloss.Style + badgeMuted lipgloss.Style +} + +func newStyles() styles { + panel := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(colorBorder)). + Background(lipgloss.Color(colorPanel)). + Padding(0, 1) + + return styles{ + doc: lipgloss.NewStyle(). + Padding(1, 2). + Background(lipgloss.Color(colorBg)). + Foreground(lipgloss.Color(colorText)), + headerBrand: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorInk)). + Background(lipgloss.Color(colorPrimary)). + Padding(0, 1), + headerSub: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorSubtle)), + headerMeta: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorSubtle)), + headerSpacer: lipgloss.NewStyle(). + Width(2), + panel: panel, + panelFocused: panel.Copy(). + BorderForeground(lipgloss.Color(colorPrimary)), + panelTitle: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorText)), + panelSubtitle: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorSubtle)), + panelBody: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorText)), + empty: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorSubtle)). + Padding(1, 0), + sessionRow: lipgloss.NewStyle(). + Padding(0, 1). + Foreground(lipgloss.Color(colorText)), + sessionRowActive: lipgloss.NewStyle(). + Padding(0, 1). + Foreground(lipgloss.Color(colorText)). + Background(lipgloss.Color(colorCode)), + sessionRowFocused: lipgloss.NewStyle(). + Padding(0, 1). + Foreground(lipgloss.Color(colorInk)). + Background(lipgloss.Color(colorPrimary)). + Bold(true), + sessionMeta: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorSubtle)), + streamTitle: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorText)), + streamMeta: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorSubtle)), + streamContent: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorText)), + messageUserTag: tagStyle(colorUser, colorInk), + messageAgentTag: tagStyle(colorPrimary, colorInk), + messageToolTag: tagStyle(colorSuccess, colorInk), + messageBody: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorText)). + PaddingLeft(1), + messageUserBody: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorText)). + PaddingLeft(1), + messageToolBody: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorSuccess)). + PaddingLeft(1), + inlineNotice: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorSubtle)). + Italic(true), + inlineError: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorError)). + Bold(true), + inlineSystem: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorSubtle)), + codeBlock: lipgloss.NewStyle(). + MarginLeft(1). + Padding(0, 1). + Background(lipgloss.Color(colorCode)). + BorderLeft(true). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(colorBorder)), + codeText: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#BAC2DE")), + commandMenu: lipgloss.NewStyle(). + Background(lipgloss.Color(colorCode)). + Padding(1, 1), + commandMenuTitle: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorPrimary)), + commandUsage: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorText)), + commandUsageMatch: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorPrimary)), + commandDesc: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorSubtle)), + inputMeta: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorSubtle)), + inputLine: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorText)), + footer: lipgloss.NewStyle(). + PaddingTop(1). + Foreground(lipgloss.Color(colorSubtle)), + badgeUser: badge(colorUser, colorInk), + badgeAgent: badge(colorPrimary, colorInk), + badgeSuccess: badge(colorSuccess, colorInk), + badgeWarning: badge(colorWarning, colorInk), + badgeError: badge(colorError, colorInk), + badgeMuted: badge(colorBorder, colorText), + } +} + +func tagStyle(bg string, fg string) lipgloss.Style { + return lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(fg)). + Background(lipgloss.Color(bg)). + Padding(0, 1) +} + +func badge(bg string, fg string) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(fg)). + Background(lipgloss.Color(bg)). + Padding(0, 1) +} + +func wrapPlain(text string, width int) string { + if width <= 0 { + return text + } + + lines := strings.Split(text, "\n") + out := make([]string, 0, len(lines)) + for _, line := range lines { + runes := []rune(line) + if len(runes) == 0 { + out = append(out, "") + continue + } + for len(runes) > width { + out = append(out, string(runes[:width])) + runes = runes[width:] + } + out = append(out, string(runes)) + } + return strings.Join(out, "\n") +} + +func trimRunes(text string, limit int) string { + runes := []rune(text) + if len(runes) <= limit || limit < 4 { + return text + } + return string(runes[:limit-3]) + "..." +} + +func trimMiddle(text string, limit int) string { + runes := []rune(text) + if len(runes) <= limit || limit < 7 { + return text + } + left := (limit - 3) / 2 + right := limit - 3 - left + return string(runes[:left]) + "..." + string(runes[len(runes)-right:]) +} + +func fallback(value string, fallbackValue string) string { + if strings.TrimSpace(value) == "" { + return fallbackValue + } + return value +} + +func preview(text string, width int, lines int) string { + rawLines := strings.Split(strings.TrimSpace(text), "\n") + out := make([]string, 0, lines) + for _, line := range rawLines { + if strings.TrimSpace(line) == "" { + continue + } + out = append(out, wrapPlain(line, width)) + if len(out) >= lines { + break + } + } + if len(out) == 0 { + return "(empty)" + } + joined := strings.Join(out, "\n") + runes := []rune(joined) + if len(runes) > width*lines { + return string(runes[:width*lines-3]) + "..." + } + return joined +} + +func clamp(value int, minValue int, maxValue int) int { + if value < minValue { + return minValue + } + if value > maxValue { + return maxValue + } + return value +} + +func max(a int, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/tui/todo/config.go b/internal/tui/todo/config.go deleted file mode 100644 index f53ad769..00000000 --- a/internal/tui/todo/config.go +++ /dev/null @@ -1,97 +0,0 @@ -package todo - -import ( - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/lipgloss" -) - -// Todo 模块相关常量与配置 -const ( - // API 路径(由于目前是本地服务,这里作为逻辑标识) - APIPathList = "/api/todo/list" - APIPathAdd = "/api/todo/add" - APIPathUpdate = "/api/todo/update" - APIPathRemove = "/api/todo/remove" - - // UI 分页大小 - PageSize = 10 - - // 状态文本 - StatusPending = "Pending" - StatusInProgress = "In Progress" - StatusCompleted = "Completed" - - // 优先级文本 - PriorityHigh = "High" - PriorityMedium = "Medium" - PriorityLow = "Low" - - // 状态图标 - IconPending = "[ ]" - IconInProgress = "[-]" - IconCompleted = "[x]" - IconCursor = "> " - IconNoCursor = " " -) - -// UI 颜色配置 -var ( - ColorPending = lipgloss.Color("#E5C07B") // 黄色 - ColorInProgress = lipgloss.Color("#61AFEF") // 蓝色 - ColorCompleted = lipgloss.Color("#98C379") // 绿色 - ColorPriorityHigh = lipgloss.Color("#E06C75") // 红色 - ColorSelection = lipgloss.Color("#C678DD") // 紫色 - ColorDim = lipgloss.Color("#5C6370") // 灰色 - ColorTitle = lipgloss.Color("#61AFEF") // 蓝色 -) - -// 文本配置 -const ( - TitleText = "--- Todo List ---" - EmptyText = "There are no todos yet. Use /todo add [priority] or press 'a' in todo mode to add one." - HelpFooterText = "↑/↓: move Enter: toggle status x: delete a: return and add Esc: back to chat" - - // 交互提示消息 - MsgUsageAdd = "Usage: /todo add [priority(high/medium/low)]" - MsgAddSuccess = "Added todo: %s" - MsgAddFailed = "Failed to add todo: %v" - MsgUnknownSubCmd = "Unknown todo subcommand: %s" - MsgPromptAdd = "Use /todo add [priority] to add a new task." -) - -// 按键绑定 -type KeyMap struct { - Up key.Binding - Down key.Binding - Add key.Binding - Done key.Binding - Delete key.Binding - Back key.Binding -} - -var Keys = KeyMap{ - Up: key.NewBinding( - key.WithKeys("up", "k"), - key.WithHelp("↑/k", "up"), - ), - Down: key.NewBinding( - key.WithKeys("down", "j"), - key.WithHelp("↓/j", "down"), - ), - Add: key.NewBinding( - key.WithKeys("a", "n"), - key.WithHelp("a/n", "add"), - ), - Done: key.NewBinding( - key.WithKeys("enter", " "), - key.WithHelp("enter/space", "toggle"), - ), - Delete: key.NewBinding( - key.WithKeys("x", "delete"), - key.WithHelp("x/del", "delete"), - ), - Back: key.NewBinding( - key.WithKeys("esc", "q"), - key.WithHelp("esc/q", "back"), - ), -} diff --git a/internal/tui/todo/config_test.go b/internal/tui/todo/config_test.go deleted file mode 100644 index 91c1b40a..00000000 --- a/internal/tui/todo/config_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package todo - -import "testing" - -func TestTodoConfigBasics(t *testing.T) { - if PageSize <= 0 { - t.Fatalf("expected PageSize > 0, got %d", PageSize) - } - if APIPathList == "" || APIPathAdd == "" || APIPathUpdate == "" || APIPathRemove == "" { - t.Fatalf("expected API paths to be non-empty") - } - if TitleText == "" || EmptyText == "" || HelpFooterText == "" { - t.Fatalf("expected UI text to be non-empty") - } - if IconPending == "" || IconInProgress == "" || IconCompleted == "" { - t.Fatalf("expected status icons to be non-empty") - } - - if len(Keys.Up.Keys()) == 0 || len(Keys.Down.Keys()) == 0 || len(Keys.Add.Keys()) == 0 || len(Keys.Done.Keys()) == 0 || len(Keys.Delete.Keys()) == 0 || len(Keys.Back.Keys()) == 0 { - t.Fatalf("expected key bindings to have at least one key") - } -} diff --git a/internal/tui/update.go b/internal/tui/update.go new file mode 100644 index 00000000..3a4e0c51 --- /dev/null +++ b/internal/tui/update.go @@ -0,0 +1,494 @@ +package tui + +import ( + "context" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + + "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" +) + +type RuntimeMsg struct{ Event agentruntime.RuntimeEvent } +type RuntimeClosedMsg struct{} +type runFinishedMsg struct{ err error } +type localCommandResultMsg struct { + notice string + err error +} + +func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + var spinCmd tea.Cmd + a.spinner, spinCmd = a.spinner.Update(msg) + if a.state.IsAgentRunning { + cmds = append(cmds, spinCmd) + } + + switch typed := msg.(type) { + case tea.WindowSizeMsg: + a.width = typed.Width + a.height = typed.Height + a.resizeComponents() + return a, tea.Batch(cmds...) + case RuntimeMsg: + a.handleRuntimeEvent(typed.Event) + _ = a.refreshSessions() + a.syncActiveSessionTitle() + a.rebuildTranscript() + cmds = append(cmds, ListenForRuntimeEvent(a.runtime.Events())) + return a, tea.Batch(cmds...) + case RuntimeClosedMsg: + a.state.IsAgentRunning = false + if strings.TrimSpace(a.state.StatusText) == "" { + a.state.StatusText = statusRuntimeClosed + } + a.rebuildTranscript() + return a, tea.Batch(cmds...) + case runFinishedMsg: + if typed.err != nil { + 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()) + } + _ = a.refreshSessions() + a.syncActiveSessionTitle() + a.rebuildTranscript() + return a, tea.Batch(cmds...) + case localCommandResultMsg: + if typed.err != nil { + a.state.ExecutionError = typed.err.Error() + a.state.StatusText = typed.err.Error() + a.appendInlineMessage(roleError, typed.err.Error()) + } else { + a.state.ExecutionError = "" + a.state.StatusText = typed.notice + cfg := a.configManager.Get() + a.syncConfigState(cfg) + a.selectCurrentModel(cfg.CurrentModel) + a.appendInlineMessage(roleSystem, typed.notice) + } + a.rebuildTranscript() + return a, tea.Batch(cmds...) + case tea.KeyMsg: + if key.Matches(typed, a.keys.Quit) { + return a, tea.Quit + } + if key.Matches(typed, a.keys.ToggleHelp) { + a.state.ShowHelp = !a.state.ShowHelp + a.help.ShowAll = a.state.ShowHelp + a.resizeComponents() + return a, tea.Batch(cmds...) + } + if a.state.ShowModelPicker { + return a.updateModelPicker(typed) + } + if key.Matches(typed, a.keys.NextPanel) { + a.focusNext() + return a, tea.Batch(cmds...) + } + if key.Matches(typed, a.keys.PrevPanel) { + a.focusPrev() + return a, tea.Batch(cmds...) + } + if key.Matches(typed, a.keys.FocusInput) { + a.focus = panelInput + a.applyFocus() + return a, tea.Batch(cmds...) + } + if key.Matches(typed, a.keys.NewSession) && !a.state.IsAgentRunning { + a.state.ActiveSessionID = "" + a.state.ActiveSessionTitle = draftSessionTitle + a.activeMessages = nil + a.state.StatusText = statusDraft + a.state.ExecutionError = "" + a.state.CurrentTool = "" + a.input.Reset() + a.state.InputText = "" + a.focus = panelInput + a.applyFocus() + a.rebuildTranscript() + return a, tea.Batch(cmds...) + } + + switch a.focus { + case panelSessions: + if key.Matches(typed, a.keys.OpenSession) && !a.isFilteringSessions() { + if err := a.activateSelectedSession(); err != nil { + a.state.StatusText = err.Error() + a.state.ExecutionError = err.Error() + a.appendInlineMessage(roleError, err.Error()) + } + a.focus = panelInput + a.applyFocus() + a.rebuildTranscript() + return a, tea.Batch(cmds...) + } + var cmd tea.Cmd + a.sessions, cmd = a.sessions.Update(msg) + cmds = append(cmds, cmd) + return a, tea.Batch(cmds...) + case panelTranscript: + a.handleViewportKeys(&a.transcript, typed) + return a, tea.Batch(cmds...) + case panelInput: + return a.updateInputPanel(msg, typed, cmds) + } + } + + return a, tea.Batch(cmds...) +} + +func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (tea.Model, tea.Cmd) { + if key.Matches(typed, a.keys.Send) { + input := strings.TrimSpace(a.input.Value()) + if input == "" || a.state.IsAgentRunning { + return a, tea.Batch(cmds...) + } + + a.input.Reset() + a.state.InputText = "" + + switch strings.ToLower(input) { + case slashCommandModelPicker: + a.openModelPicker() + return a, tea.Batch(cmds...) + } + + if strings.HasPrefix(input, slashPrefix) { + a.state.StatusText = statusApplyingCommand + cmds = append(cmds, runLocalCommand(a.configManager, input)) + return a, tea.Batch(cmds...) + } + + a.state.IsAgentRunning = true + a.state.StreamingReply = false + a.state.ExecutionError = "" + a.state.StatusText = statusThinking + a.state.CurrentTool = "" + a.activeMessages = append(a.activeMessages, provider.Message{Role: roleUser, Content: input}) + a.rebuildTranscript() + cmds = append(cmds, runAgent(a.runtime, a.state.ActiveSessionID, input)) + return a, tea.Batch(cmds...) + } + + var cmd tea.Cmd + a.input, cmd = a.input.Update(msg) + a.state.InputText = a.input.Value() + a.resizeComponents() + cmds = append(cmds, cmd) + return a, tea.Batch(cmds...) +} + +func (a App) updateModelPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, a.keys.FocusInput): + a.closeModelPicker() + return a, nil + case msg.String() == "enter": + item, ok := a.modelPicker.SelectedItem().(modelItem) + a.closeModelPicker() + if !ok { + return a, nil + } + return a, runModelSelection(a.configManager, item.name) + } + + var cmd tea.Cmd + a.modelPicker, cmd = a.modelPicker.Update(msg) + return a, cmd +} + +func (a *App) refreshSessions() error { + sessions, err := a.runtime.ListSessions(context.Background()) + if err != nil { + return err + } + + a.state.Sessions = sessions + + var selectedID string + if item, ok := a.sessions.SelectedItem().(sessionItem); ok { + selectedID = item.Summary.ID + } + + items := make([]list.Item, 0, len(sessions)) + cursor := 0 + for i, summary := range sessions { + items = append(items, sessionItem{Summary: summary, Active: summary.ID == a.state.ActiveSessionID}) + if summary.ID == selectedID || summary.ID == a.state.ActiveSessionID { + cursor = i + } + } + + a.sessions.SetItems(items) + if len(items) > 0 { + a.sessions.Select(cursor) + } + + return nil +} + +func (a *App) refreshMessages() error { + if strings.TrimSpace(a.state.ActiveSessionID) == "" { + a.activeMessages = nil + return nil + } + + session, err := a.runtime.LoadSession(context.Background(), a.state.ActiveSessionID) + if err != nil { + return err + } + + a.activeMessages = session.Messages + a.state.ActiveSessionTitle = session.Title + return nil +} + +func (a *App) activateSelectedSession() error { + item, ok := a.sessions.SelectedItem().(sessionItem) + if !ok { + return nil + } + + a.state.ActiveSessionID = item.Summary.ID + a.state.ActiveSessionTitle = item.Summary.Title + a.state.ExecutionError = "" + a.state.CurrentTool = "" + + if err := a.refreshSessions(); err != nil { + return err + } + + return a.refreshMessages() +} + +func (a *App) syncActiveSessionTitle() { + if strings.TrimSpace(a.state.ActiveSessionID) == "" { + if strings.TrimSpace(a.state.ActiveSessionTitle) == "" { + a.state.ActiveSessionTitle = draftSessionTitle + } + return + } + + for _, item := range a.state.Sessions { + if item.ID == a.state.ActiveSessionID { + a.state.ActiveSessionTitle = item.Title + return + } + } +} + +func (a *App) syncConfigState(cfg config.Config) { + a.state.CurrentProvider = cfg.SelectedProvider + a.state.CurrentModel = cfg.CurrentModel + a.state.CurrentWorkdir = cfg.Workdir +} + +func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) { + if a.state.ActiveSessionID == "" { + a.state.ActiveSessionID = event.SessionID + } + + switch event.Type { + case agentruntime.EventUserMessage: + a.state.StatusText = statusThinking + a.state.StreamingReply = false + a.state.CurrentTool = "" + a.state.ExecutionError = "" + case agentruntime.EventToolStart: + a.state.StatusText = statusRunningTool + a.state.StreamingReply = false + if payload, ok := event.Payload.(provider.ToolCall); ok { + a.state.CurrentTool = payload.Name + a.appendInlineMessage(roleEvent, "Running tool: "+payload.Name+"...") + } + case agentruntime.EventToolResult: + a.state.StreamingReply = false + a.state.CurrentTool = "" + if payload, ok := event.Payload.(tools.ToolResult); ok { + if payload.IsError { + a.state.ExecutionError = payload.Content + a.state.StatusText = statusToolError + a.appendInlineMessage(roleError, preview(payload.Content, 88, 4)) + } else if strings.TrimSpace(a.state.ExecutionError) == "" { + a.state.StatusText = statusToolFinished + a.appendInlineMessage(roleEvent, "Completed tool: "+payload.Name) + } + } + case agentruntime.EventAgentChunk: + if payload, ok := event.Payload.(string); ok { + a.appendAssistantChunk(payload) + } + case agentruntime.EventAgentDone: + a.state.IsAgentRunning = false + a.state.StreamingReply = false + a.state.CurrentTool = "" + if strings.TrimSpace(a.state.ExecutionError) == "" { + a.state.StatusText = statusReady + } + 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.EventError: + a.state.StatusText = statusError + a.state.IsAgentRunning = false + a.state.StreamingReply = false + a.state.CurrentTool = "" + if payload, ok := event.Payload.(string); ok { + a.state.ExecutionError = payload + a.state.StatusText = payload + a.appendInlineMessage(roleError, payload) + } + } +} + +func (a *App) appendAssistantChunk(chunk string) { + if chunk == "" { + return + } + + if !a.state.StreamingReply || len(a.activeMessages) == 0 || a.activeMessages[len(a.activeMessages)-1].Role != roleAssistant { + a.activeMessages = append(a.activeMessages, provider.Message{Role: roleAssistant, Content: chunk}) + a.state.StreamingReply = true + return + } + + a.activeMessages[len(a.activeMessages)-1].Content += chunk +} + +func (a *App) appendInlineMessage(role string, message string) { + content := strings.TrimSpace(message) + if content == "" { + return + } + + a.activeMessages = append(a.activeMessages, provider.Message{Role: role, Content: content}) +} + +func (a *App) lastAssistantMatches(content string) bool { + if len(a.activeMessages) == 0 { + return false + } + + last := a.activeMessages[len(a.activeMessages)-1] + return last.Role == roleAssistant && strings.TrimSpace(last.Content) == strings.TrimSpace(content) +} + +func (a *App) handleViewportKeys(vp *viewport.Model, msg tea.KeyMsg) { + switch { + case key.Matches(msg, a.keys.ScrollUp): + vp.LineUp(2) + case key.Matches(msg, a.keys.ScrollDown): + vp.LineDown(2) + case key.Matches(msg, a.keys.PageUp): + vp.HalfViewUp() + case key.Matches(msg, a.keys.PageDown): + vp.HalfViewDown() + case key.Matches(msg, a.keys.Top): + vp.GotoTop() + case key.Matches(msg, a.keys.Bottom): + vp.GotoBottom() + } +} + +func (a *App) focusNext() { + order := []panel{panelSessions, panelTranscript, panelInput} + current := 0 + for i, item := range order { + if item == a.focus { + current = i + break + } + } + + a.focus = order[(current+1)%len(order)] + a.applyFocus() +} + +func (a *App) focusPrev() { + order := []panel{panelSessions, panelTranscript, panelInput} + current := 0 + for i, item := range order { + if item == a.focus { + current = i + break + } + } + + if current == 0 { + a.focus = order[len(order)-1] + } else { + a.focus = order[current-1] + } + + a.applyFocus() +} + +func (a *App) applyFocus() { + a.state.Focus = a.focus + if a.focus == panelInput && !a.state.ShowModelPicker { + a.input.Focus() + return + } + a.input.Blur() +} + +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)) + 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) + a.modelPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) + a.rebuildTranscript() +} + +func (a *App) rebuildTranscript() { + width := max(24, a.transcript.Width) + if len(a.activeMessages) == 0 { + a.transcript.SetContent(a.styles.empty.Width(width).Render(emptyConversationText)) + a.transcript.GotoTop() + return + } + + atBottom := a.transcript.AtBottom() + blocks := make([]string, 0, len(a.activeMessages)) + for _, message := range a.activeMessages { + blocks = append(blocks, a.renderMessageBlock(message, width)) + } + + a.transcript.SetContent(strings.Join(blocks, "\n\n")) + if atBottom || a.state.IsAgentRunning { + a.transcript.GotoBottom() + } +} + +func ListenForRuntimeEvent(sub <-chan agentruntime.RuntimeEvent) tea.Cmd { + return func() tea.Msg { + event, ok := <-sub + if !ok { + return RuntimeClosedMsg{} + } + return RuntimeMsg{Event: event} + } +} + +func runAgent(runtime agentruntime.Runtime, sessionID string, content string) tea.Cmd { + return func() tea.Msg { + err := runtime.Run(context.Background(), agentruntime.UserInput{SessionID: sessionID, Content: content}) + return runFinishedMsg{err: err} + } +} diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go new file mode 100644 index 00000000..74596fe1 --- /dev/null +++ b/internal/tui/update_test.go @@ -0,0 +1,857 @@ +package tui + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + + "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" +) + +type stubRuntime struct { + runInputs []agentruntime.UserInput + events chan agentruntime.RuntimeEvent + sessions []agentruntime.SessionSummary + loads map[string]agentruntime.Session + runErr error + listErr error + loadErr error +} + +func newStubRuntime() *stubRuntime { + return &stubRuntime{ + events: make(chan agentruntime.RuntimeEvent, 16), + loads: map[string]agentruntime.Session{}, + } +} + +func (r *stubRuntime) Run(ctx context.Context, input agentruntime.UserInput) error { + r.runInputs = append(r.runInputs, input) + return r.runErr +} + +func (r *stubRuntime) Events() <-chan agentruntime.RuntimeEvent { + return r.events +} + +func (r *stubRuntime) ListSessions(ctx context.Context) ([]agentruntime.SessionSummary, error) { + if r.listErr != nil { + return nil, r.listErr + } + return append([]agentruntime.SessionSummary(nil), r.sessions...), nil +} + +func (r *stubRuntime) LoadSession(ctx context.Context, id string) (agentruntime.Session, error) { + if r.loadErr != nil { + return agentruntime.Session{}, r.loadErr + } + if session, ok := r.loads[id]; ok { + return session, nil + } + return agentruntime.Session{}, nil +} + +func TestAppUpdateComposerCommands(t *testing.T) { + tests := []struct { + name string + input string + 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", + 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 app.state.IsAgentRunning { + t.Fatalf("expected agent to stay idle") + } + }, + }, + { + name: "model command opens picker and does not start runtime", + input: "/model", + 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.ShowModelPicker { + t.Fatalf("expected model picker to open") + } + if app.state.StatusText != statusChooseModel { + t.Fatalf("expected status %q, got %q", statusChooseModel, app.state.StatusText) + } + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + + app, err := New(nil, manager, runtime) + if err != nil { + t.Fatalf("New() error = %v", err) + } + app.input.SetValue(tt.input) + app.state.InputText = tt.input + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + + for _, msg := range collectTeaMessages(cmd) { + model, followCmd := app.Update(msg) + app = model.(App) + _ = collectTeaMessages(followCmd) + } + + tt.assert(t, runtime, manager, app) + }) + } +} + +func TestAppUpdateModelPickerAndRuntimeMessages(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, app *App, runtime *stubRuntime) + msg tea.Msg + assert func(t *testing.T, runtime *stubRuntime, app App, msgs []tea.Msg) + }{ + { + name: "escape closes model picker", + setup: func(t *testing.T, app *App, runtime *stubRuntime) { + app.state.ShowModelPicker = true + 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 { + t.Fatalf("expected model picker to close") + } + if app.state.Focus != panelInput { + t.Fatalf("expected focus to return to input") + } + }, + }, + { + name: "runtime chunk appends assistant draft", + setup: func(t *testing.T, app *App, runtime *stubRuntime) { + app.state.ActiveSessionID = "session-1" + }, + msg: RuntimeMsg{Event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventAgentChunk, + SessionID: "session-1", + Payload: "hello", + }}, + assert: func(t *testing.T, runtime *stubRuntime, app App, msgs []tea.Msg) { + t.Helper() + if len(app.activeMessages) == 0 { + t.Fatalf("expected assistant draft message") + } + last := app.activeMessages[len(app.activeMessages)-1] + if last.Role != roleAssistant || last.Content != "hello" { + t.Fatalf("unexpected last assistant draft: %+v", last) + } + }, + }, + { + name: "runtime done appends final assistant and clears running state", + setup: func(t *testing.T, app *App, runtime *stubRuntime) { + app.state.IsAgentRunning = true + app.state.ActiveSessionID = "session-2" + }, + msg: RuntimeMsg{Event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventAgentDone, + SessionID: "session-2", + Payload: provider.Message{ + Role: roleAssistant, + Content: "final", + }, + }}, + 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.StatusText != statusReady { + t.Fatalf("expected status ready, got %q", app.state.StatusText) + } + last := app.activeMessages[len(app.activeMessages)-1] + if last.Content != "final" { + t.Fatalf("expected final assistant message, got %+v", last) + } + }, + }, + { + name: "runtime tool result error is surfaced", + setup: func(t *testing.T, app *App, runtime *stubRuntime) { + app.state.ActiveSessionID = "session-3" + }, + msg: RuntimeMsg{Event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventToolResult, + SessionID: "session-3", + Payload: tools.ToolResult{ + Name: "filesystem_edit", + Content: "boom", + IsError: true, + }, + }}, + assert: func(t *testing.T, runtime *stubRuntime, app App, msgs []tea.Msg) { + t.Helper() + if app.state.ExecutionError != "boom" { + t.Fatalf("expected execution error boom, got %q", app.state.ExecutionError) + } + if app.state.StatusText != statusToolError { + t.Fatalf("expected status tool error, got %q", app.state.StatusText) + } + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if tt.setup != nil { + tt.setup(t, &app, runtime) + } + + model, cmd := app.Update(tt.msg) + app = model.(App) + tt.assert(t, runtime, app, collectTeaMessages(cmd)) + }) + } +} + +func TestAppHelpersAndRenderingSmoke(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + now := agentruntime.Session{ + ID: "session-1", + Title: "Existing Session", + Messages: []provider.Message{ + {Role: roleUser, Content: "hi"}, + {Role: roleAssistant, Content: "hello"}, + }, + } + runtime.sessions = []agentruntime.SessionSummary{ + {ID: now.ID, Title: now.Title, UpdatedAt: now.UpdatedAt}, + } + runtime.loads[now.ID] = now + + app, err := New(nil, manager, runtime) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if app.Init() == nil { + t.Fatalf("expected init command") + } + + app.openModelPicker() + app.closeModelPicker() + app.selectCurrentModel(config.DefaultOpenAIModel) + app.appendAssistantChunk("hello") + app.appendAssistantChunk(" world") + if !app.lastAssistantMatches("hello world") { + t.Fatalf("expected assistant draft to match") + } + app.appendInlineMessage(roleSystem, "notice") + + app.focusNext() + app.focusPrev() + app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyDown}) + app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyUp}) + + if err := app.refreshSessions(); err != nil { + t.Fatalf("refreshSessions() error = %v", err) + } + if err := app.activateSelectedSession(); err != nil { + t.Fatalf("activateSelectedSession() error = %v", err) + } + app.syncActiveSessionTitle() + app.syncConfigState(manager.Get()) + app.rebuildTranscript() + + if app.View() == "" { + t.Fatalf("expected non-empty View()") + } + if app.renderHeader() == "" || app.renderBody(app.computeLayout()) == "" { + t.Fatalf("expected non-empty render output") + } + app.state.ShowModelPicker = true + if app.renderModelPicker(48, 12) == "" || app.renderWaterfall(80, 20) == "" { + t.Fatalf("expected model picker rendering") + } + app.state.ShowModelPicker = false + if app.renderCommandMenu(80) == "" { + app.input.SetValue("/") + app.state.InputText = "/" + if app.renderCommandMenu(80) == "" { + t.Fatalf("expected slash command menu when input starts with slash") + } + } + if app.renderPrompt(80) == "" || app.renderHelp(80) == "" { + t.Fatalf("expected prompt and help output") + } + if app.focusLabel() == "" || app.statusBadge("ready") == "" { + t.Fatalf("expected status helpers to render") + } + app.focus = panelSessions + if app.focusLabel() != focusLabelSessions { + t.Fatalf("expected session focus label") + } + app.focus = panelTranscript + if app.focusLabel() != focusLabelTranscript { + t.Fatalf("expected transcript focus label") + } + app.focus = panelInput + if app.statusBadge("error: boom") == "" || app.statusBadge("running now") == "" { + t.Fatalf("expected status badge variants") + } + if app.renderMessageBlock(provider.Message{Role: roleError, Content: "boom"}, 80) == "" { + t.Fatalf("expected error message block") + } + if app.renderMessageBlock(provider.Message{ + Role: roleAssistant, + ToolCalls: []provider.ToolCall{ + {Name: "filesystem_edit"}, + }, + }, 80) == "" { + t.Fatalf("expected tool call message block") + } + if app.renderMessageContent("```go\nfmt.Println(\"x\")\n```", 80, app.styles.messageBody) == "" { + t.Fatalf("expected code block rendering") + } + if app.computeLayout().contentWidth == 0 { + t.Fatalf("expected computed layout") + } + app.width = 90 + app.height = 26 + compact := app.computeLayout() + if !compact.stacked { + t.Fatalf("expected compact layout to stack") + } + app.sessions.SetFilterState(list.Filtering) + if !app.isFilteringSessions() { + t.Fatalf("expected filtering state") + } +} + +func TestTUIStandaloneHelpers(t *testing.T) { + t.Parallel() + + if len(newKeyMap().ShortHelp()) == 0 || len(newKeyMap().FullHelp()) == 0 { + t.Fatalf("expected key help bindings") + } + + if wrapPlain("abcdef", 3) == "" || trimRunes("abcdef", 4) == "" || trimMiddle("abcdefgh", 5) == "" { + t.Fatalf("expected string helpers to return content") + } + if fallback("", "x") != "x" { + t.Fatalf("expected fallback to use replacement") + } + if preview("line1\nline2\nline3", 8, 2) == "" { + t.Fatalf("expected preview output") + } + if clamp(10, 0, 5) != 5 || max(2, 3) != 3 { + t.Fatalf("expected numeric helpers to work") + } + + sItem := sessionItem{Summary: agentruntime.SessionSummary{Title: "My Session"}} + if sItem.FilterValue() != "my session" { + t.Fatalf("unexpected session item filter value") + } + + mItem := modelItem{name: "gpt-5.4", description: "Frontier"} + if mItem.Title() == "" || mItem.Description() == "" || mItem.FilterValue() == "" { + t.Fatalf("expected model item helpers to return values") + } + + delegate := sessionDelegate{styles: newStyles()} + if delegate.Height() == 0 || delegate.Spacing() == 0 { + t.Fatalf("expected delegate sizing") + } + if delegate.Update(nil, nil) != nil { + t.Fatalf("expected delegate update to return nil") + } + var buf bytes.Buffer + model := newModelPicker() + sessionList := []list.Item{sItem} + listModel := list.New(sessionList, delegate, 30, 10) + delegate.Render(&buf, listModel, 0, sItem) + if buf.Len() == 0 { + t.Fatalf("expected delegate render output") + } + + eventCh := make(chan agentruntime.RuntimeEvent, 1) + eventCh <- agentruntime.RuntimeEvent{Type: agentruntime.EventAgentChunk, Payload: "x"} + if msg := ListenForRuntimeEvent(eventCh)(); msg == nil { + t.Fatalf("expected runtime event message") + } + close(eventCh) + if _, ok := ListenForRuntimeEvent(eventCh)().(RuntimeClosedMsg); !ok { + t.Fatalf("expected runtime closed message") + } + + runtime := newStubRuntime() + runMsg := runAgent(runtime, "session-x", "hello")() + if _, ok := runMsg.(runFinishedMsg); !ok { + t.Fatalf("expected runFinishedMsg") + } + if len(runtime.runInputs) != 1 || runtime.runInputs[0].Content != "hello" { + t.Fatalf("expected runtime run input to be captured") + } + + manager := newTestConfigManager(t) + msg := runModelSelection(manager, "gpt-5.4")() + if result, ok := msg.(localCommandResultMsg); !ok || result.err != nil { + t.Fatalf("expected successful localCommandResultMsg, got %+v", msg) + } + + _ = model +} + +func TestAppUpdateAdditionalTransitions(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) + msg tea.Msg + assert func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) + }{ + { + name: "window resize updates dimensions", + msg: tea.WindowSizeMsg{Width: 100, Height: 32}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if app.width != 100 || app.height != 32 { + t.Fatalf("expected updated dimensions, got %dx%d", app.width, app.height) + } + }, + }, + { + name: "runtime closed stops agent", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.state.IsAgentRunning = true + app.state.StatusText = "" + }, + msg: RuntimeClosedMsg{}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if app.state.IsAgentRunning || app.state.StatusText != statusRuntimeClosed { + t.Fatalf("expected runtime closed state, got %+v", app.state) + } + }, + }, + { + name: "run finished error is surfaced", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.state.IsAgentRunning = true + }, + msg: runFinishedMsg{err: context.DeadlineExceeded}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if app.state.IsAgentRunning || app.state.ExecutionError == "" { + t.Fatalf("expected execution error to be set") + } + }, + }, + { + name: "local command 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() + if app.state.StatusText != "[System] ok" { + t.Fatalf("expected success notice, got %q", app.state.StatusText) + } + }, + }, + { + name: "local command 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() + if app.state.ExecutionError == "" || app.state.StatusText == "" { + t.Fatalf("expected local command error state") + } + }, + }, + { + name: "toggle help flips state", + msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if !app.state.ShowHelp || !app.help.ShowAll { + t.Fatalf("expected help to be visible") + } + }, + }, + { + name: "next panel moves focus", + msg: tea.KeyMsg{Type: tea.KeyTab}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if app.focus != panelSessions { + t.Fatalf("expected focus to move to sessions, got %v", app.focus) + } + }, + }, + { + name: "previous panel moves focus backward", + msg: tea.KeyMsg{Type: tea.KeyShiftTab}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if app.focus != panelTranscript { + t.Fatalf("expected focus to move backward, got %v", app.focus) + } + }, + }, + { + name: "new session clears active draft", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.state.ActiveSessionID = "existing" + app.state.ActiveSessionTitle = "Existing" + app.activeMessages = []provider.Message{{Role: roleUser, Content: "hello"}} + }, + msg: tea.KeyMsg{Type: tea.KeyCtrlN}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if app.state.ActiveSessionID != "" || len(app.activeMessages) != 0 || app.state.StatusText != statusDraft { + t.Fatalf("expected new draft state, got %+v", app.state) + } + }, + }, + { + name: "session enter activates selected session", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + runtime.sessions = []agentruntime.SessionSummary{{ID: "s1", Title: "One"}} + runtime.loads["s1"] = agentruntime.Session{ + ID: "s1", + Title: "One", + Messages: []provider.Message{{Role: roleAssistant, Content: "loaded"}}, + } + if err := app.refreshSessions(); err != nil { + t.Fatalf("refresh sessions: %v", err) + } + app.focus = panelSessions + app.applyFocus() + }, + msg: tea.KeyMsg{Type: tea.KeyEnter}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if app.state.ActiveSessionID != "s1" || len(app.activeMessages) != 1 { + t.Fatalf("expected selected session to load, got %+v / %+v", app.state, app.activeMessages) + } + }, + }, + { + name: "transcript focus handles scroll keys", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.focus = panelTranscript + app.transcript.SetContent(strings.Repeat("line\n", 80)) + app.transcript.Height = 5 + app.transcript.GotoBottom() + }, + msg: tea.KeyMsg{Type: tea.KeyUp}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if app.transcript.YOffset < 0 { + t.Fatalf("expected non-negative offset") + } + }, + }, + { + name: "input typing updates composer text", + msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if app.state.InputText != "h" { + t.Fatalf("expected input text to update, got %q", app.state.InputText) + } + }, + }, + { + name: "plain input send starts runtime", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.input.SetValue("inspect repo") + app.state.InputText = "inspect repo" + }, + msg: tea.KeyMsg{Type: tea.KeyEnter}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if !app.state.IsAgentRunning { + t.Fatalf("expected agent to start running") + } + if len(app.activeMessages) == 0 || app.activeMessages[len(app.activeMessages)-1].Role != roleUser { + t.Fatalf("expected user message appended") + } + if len(runtime.runInputs) != 1 || runtime.runInputs[0].Content != "inspect repo" { + t.Fatalf("expected runtime command to execute once, got %+v", runtime.runInputs) + } + finished := false + for _, msg := range msgs { + if _, ok := msg.(runFinishedMsg); ok { + finished = true + } + } + if !finished { + t.Fatalf("expected runFinishedMsg from command") + } + }, + }, + { + name: "quit returns quit command", + msg: tea.KeyMsg{Type: tea.KeyCtrlC}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + foundQuit := false + for _, msg := range msgs { + if _, ok := msg.(tea.QuitMsg); ok { + foundQuit = true + } + } + if !foundQuit { + t.Fatalf("expected quit message") + } + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if tt.setup != nil { + tt.setup(t, &app, runtime, manager) + } + + model, cmd := app.Update(tt.msg) + app = model.(App) + tt.assert(t, app, runtime, manager, collectTeaMessages(cmd)) + }) + } +} + +func TestAppUpdateModelPickerEnterAppliesSelection(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.openModelPicker() + if len(app.modelPicker.Items()) < 2 { + t.Fatalf("expected model picker catalog") + } + selected := app.modelPicker.Items()[1].(modelItem).name + app.modelPicker.Select(1) + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if app.state.ShowModelPicker { + 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.CurrentModel != selected { + t.Fatalf("expected current model %q, got %q", selected, cfg.CurrentModel) + } +} + +func TestAppHandleRuntimeEventAdditionalBranches(t *testing.T) { + tests := []struct { + name string + event agentruntime.RuntimeEvent + setup func(*App) + assert func(t *testing.T, app App) + }{ + { + name: "user message resets execution state", + setup: func(app *App) { + app.state.ExecutionError = "old" + app.state.CurrentTool = "bash" + }, + event: agentruntime.RuntimeEvent{Type: agentruntime.EventUserMessage, SessionID: "s1"}, + assert: func(t *testing.T, app App) { + t.Helper() + if app.state.ExecutionError != "" || app.state.CurrentTool != "" || app.state.StatusText != statusThinking { + t.Fatalf("unexpected user message state: %+v", app.state) + } + }, + }, + { + name: "tool start stores current tool", + event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventToolStart, + SessionID: "s1", + Payload: provider.ToolCall{ + Name: "filesystem_edit", + }, + }, + assert: func(t *testing.T, app App) { + t.Helper() + if app.state.CurrentTool != "filesystem_edit" || app.state.StatusText != statusRunningTool { + t.Fatalf("unexpected tool start state: %+v", app.state) + } + }, + }, + { + name: "tool success appends completion event", + setup: func(app *App) { + app.state.CurrentTool = "filesystem_edit" + }, + event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventToolResult, + SessionID: "s1", + Payload: tools.ToolResult{ + Name: "filesystem_edit", + }, + }, + assert: func(t *testing.T, app App) { + t.Helper() + if app.state.CurrentTool != "" || app.state.StatusText != statusToolFinished { + t.Fatalf("unexpected tool success state: %+v", app.state) + } + }, + }, + { + name: "error event appends inline error", + event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventError, + SessionID: "s1", + Payload: "boom", + }, + assert: func(t *testing.T, app App) { + t.Helper() + if app.state.ExecutionError != "boom" || app.state.IsAgentRunning { + t.Fatalf("unexpected error state: %+v", app.state) + } + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if tt.setup != nil { + tt.setup(&app) + } + app.handleRuntimeEvent(tt.event) + tt.assert(t, app) + }) + } +} + +func TestAppRefreshErrorPaths(t *testing.T) { + t.Run("refresh sessions returns runtime error", func(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + runtime.listErr = context.DeadlineExceeded + + app, err := New(nil, manager, runtime) + if err == nil || !strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { + t.Fatalf("expected list session error during New, got %v", err) + } + _ = app + }) + + t.Run("refresh messages returns load error", func(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + runtime.loadErr = context.Canceled + + app, err := New(nil, manager, runtime) + if err != nil { + t.Fatalf("New() error = %v", err) + } + app.state.ActiveSessionID = "broken" + + err = app.refreshMessages() + if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) { + t.Fatalf("expected load session error, got %v", err) + } + }) +} + +func newTestConfigManager(t *testing.T) *config.Manager { + t.Helper() + manager := config.NewManager(config.NewLoader(t.TempDir())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("load config: %v", err) + } + return manager +} + +func collectTeaMessages(cmd tea.Cmd) []tea.Msg { + if cmd == nil { + return nil + } + msgCh := make(chan tea.Msg, 1) + go func() { + msgCh <- cmd() + }() + + var msg tea.Msg + select { + case msg = <-msgCh: + case <-time.After(25 * time.Millisecond): + return nil + } + if msg == nil { + return nil + } + switch typed := msg.(type) { + case tea.BatchMsg: + var out []tea.Msg + for _, child := range typed { + out = append(out, collectTeaMessages(child)...) + } + return out + default: + return []tea.Msg{typed} + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 00000000..d7490ba9 --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,314 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/lipgloss" + + "github.com/dust/neo-code/internal/provider" +) + +type layout struct { + stacked bool + contentWidth int + contentHeight int + sidebarWidth int + sidebarHeight int + rightWidth int + rightHeight 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.") + } + + lay := a.computeLayout() + header := a.renderHeader() + body := a.renderBody(lay) + helpView := a.renderHelp(lay.contentWidth) + return a.styles.doc.Render(lipgloss.JoinVertical(lipgloss.Left, header, body, helpView)) +} + +func (a App) renderHeader() string { + status := a.state.StatusText + if a.state.IsAgentRunning { + status = a.spinner.View() + " " + fallback(status, statusRunning) + } + + 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.statusBadge(status), + ) + + return lipgloss.JoinVertical( + lipgloss.Left, + brand, + lipgloss.JoinHorizontal( + lipgloss.Top, + a.styles.headerMeta.Render("Workdir "+trimMiddle(a.state.CurrentWorkdir, max(28, a.width/3))), + a.styles.headerSpacer.Render(""), + meta, + ), + ) +} + +func (a App) renderBody(lay layout) string { + sidebar := a.renderSidebar(lay.sidebarWidth, lay.sidebarHeight) + stream := a.renderWaterfall(lay.rightWidth, lay.rightHeight) + if lay.stacked { + return lipgloss.JoinVertical(lipgloss.Left, sidebar, stream) + } + return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, stream) +} + +func (a App) renderSidebar(width int, height int) string { + return a.renderPanel(sidebarTitle, sidebarSubtitle, a.sessions.View(), width, height, a.focus == panelSessions) +} + +func (a App) renderWaterfall(width int, height int) string { + if a.state.ShowModelPicker { + return lipgloss.Place( + width, + height, + lipgloss.Center, + lipgloss.Center, + a.renderModelPicker(clamp(width-10, 36, 56), clamp(height-6, 10, 14)), + ) + } + + 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} + 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...)) +} + +func (a App) renderModelPicker(width int, height int) string { + 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) +} + +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 "" + } + + 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 + return a.styles.footer.Width(width).Render(a.help.View(a.keys)) +} + +func (a App) renderPanel(title string, subtitle string, body string, width int, height int, focused bool) string { + style := a.styles.panel + if focused { + style = a.styles.panelFocused + } + + 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)) +} + +func (a App) renderMessageBlock(message provider.Message, width int) string { + switch message.Role { + case roleEvent: + return a.styles.inlineNotice.Width(width).Render(" > " + wrapPlain(message.Content, max(16, width-6))) + case roleError: + return a.styles.inlineError.Width(width).Render(" ! " + wrapPlain(message.Content, max(16, width-6))) + case roleSystem: + 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))) + tag := messageTagAgent + tagStyle := a.styles.messageAgentTag + bodyStyle := a.styles.messageBody + + switch message.Role { + case roleUser: + tag = messageTagUser + tagStyle = a.styles.messageUserTag + bodyStyle = a.styles.messageUserBody + case roleTool: + tag = messageTagTool + tagStyle = a.styles.messageToolTag + bodyStyle = a.styles.messageToolBody + } + + content := strings.TrimSpace(message.Content) + if content == "" && len(message.ToolCalls) > 0 { + names := make([]string, 0, len(message.ToolCalls)) + for _, call := range message.ToolCalls { + names = append(names, call.Name) + } + content = "Tool calls: " + strings.Join(names, ", ") + } + if content == "" { + content = emptyMessageText + } + + return lipgloss.JoinVertical( + lipgloss.Left, + tagStyle.Render(tag), + a.renderMessageContent(content, maxMessageWidth-2, bodyStyle), + ) +} + +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))) + } + + blocks := make([]string, 0, len(parts)) + for i, part := range parts { + if i%2 == 0 { + trimmed := strings.Trim(part, "\n") + if trimmed == "" { + continue + } + blocks = append(blocks, bodyStyle.Width(width).Render(wrapPlain(trimmed, max(16, width-2)))) + continue + } + + code := strings.Trim(part, "\n") + lines := strings.Split(code, "\n") + if len(lines) > 1 && !strings.Contains(lines[0], " ") && !strings.Contains(lines[0], "\t") { + code = strings.Join(lines[1:], "\n") + } + blocks = append(blocks, a.styles.codeBlock.Width(width).Render(a.styles.codeText.Width(max(10, width-4)).Render(code))) + } + + if len(blocks) == 0 { + return bodyStyle.Width(width).Render(emptyMessageText) + } + + return lipgloss.JoinVertical(lipgloss.Left, blocks...) +} + +func (a App) statusBadge(text string) string { + lower := strings.ToLower(text) + switch { + case strings.Contains(lower, "error") || strings.Contains(lower, "failed"): + return a.styles.badgeError.Render(text) + case a.state.IsAgentRunning || strings.Contains(lower, "running") || strings.Contains(lower, "thinking"): + return a.styles.badgeWarning.Render(text) + default: + return a.styles.badgeSuccess.Render(text) + } +} + +func (a App) focusLabel() string { + switch a.focus { + case panelSessions: + return focusLabelSessions + case panelTranscript: + return focusLabelTranscript + default: + return focusLabelComposer + } +} + +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) + lay := layout{contentWidth: contentWidth, contentHeight: contentHeight} + if contentWidth < 110 { + lay.stacked = true + lay.sidebarWidth = contentWidth + lay.sidebarHeight = clamp(contentHeight/3, 9, 13) + lay.rightWidth = contentWidth + lay.rightHeight = max(10, contentHeight-lay.sidebarHeight) + return lay + } + + lay.sidebarWidth = 30 + lay.sidebarHeight = contentHeight + lay.rightWidth = contentWidth - lay.sidebarWidth + lay.rightHeight = contentHeight + return lay +} + +func (a App) isFilteringSessions() bool { + return a.sessions.FilterState() != list.Unfiltered +} diff --git a/pkg/gitkeep b/pkg/gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/scripts/gen_proto.ps1 b/scripts/gen_proto.ps1 deleted file mode 100644 index 7faf9085..00000000 --- a/scripts/gen_proto.ps1 +++ /dev/null @@ -1,34 +0,0 @@ -# NeoCode API 契约自动化生成脚本 -# 确保你已经安装了 protoc 工具以及 protoc-gen-go 插件 - -# 获取当前脚本所在目录 -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$RootDir = Resolve-Path (Join-Path $ScriptDir "..") -$ProtoDir = Join-Path $RootDir "api/proto" -$ProtoFile = Join-Path $ProtoDir "chat.proto" - -Write-Host "--- 开始生成 API 契约代码 ---" -ForegroundColor Cyan - -# 检查 protoc 是否存在 -if (-not (Get-Command "protoc" -ErrorAction SilentlyContinue)) { - Write-Host "失败:未在系统中找到 protoc 编译器。" -ForegroundColor Red - Write-Host "请参考文档安装:https://grpc.io/docs/protoc-installation/" -ForegroundColor Gray - exit 1 -} - -# 执行 protoc 命令 -# --proto_path 指定搜索 .proto 文件的目录 -# --go_out 控制生成结构体代码 (.pb.go) 的根目录 -# --go_opt=module=go-llm-demo 告诉工具按照 go.mod 中的模块路径进行相对输出 -protoc --proto_path="$ProtoDir" ` - --go_out="$RootDir" ` - --go_opt=module=go-llm-demo ` - "$ProtoFile" - -if ($LASTEXITCODE -eq 0) { - Write-Host "成功:代码已生成至 api/proto/chat.pb.go" -ForegroundColor Green - Write-Host "提示:现在组员可以单独在测试代码中引入 'go-llm-demo/api/proto' 进行开发。" -ForegroundColor Gray - Write-Host "注意:请勿手动修改生成的 .pb.go 文件!" -ForegroundColor Yellow -} else { - Write-Host "失败:生成过程中出现错误,请检查 .proto 文件语法或插件是否安装。" -ForegroundColor Red -} diff --git a/scripts/gitkeep b/scripts/gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/test/contract_test.go b/test/contract_test.go deleted file mode 100644 index 2de182e0..00000000 --- a/test/contract_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package test - -import ( - "testing" - - "go-llm-demo/api/proto" // 引入我们的“新契约” - "go-llm-demo/internal/server/domain" // 引入“旧后端模型” -) - -// TestFrontendBackendCompatibility 这个测试模拟了: -// 如果前端按照契约发请求,后端能不能正常处理。 -func TestFrontendBackendCompatibility(t *testing.T) { - // 1. 【模拟前端逻辑】 - // 前端开发者按照 chat.proto 契约,捏造了一个请求 - frontendReq := &proto.ChatRequest{ - Model: "qwen-max", - Messages: []*proto.Message{ - {Role: "user", Content: "你好,请自我介绍"}, - }, - } - - t.Logf("前端发出了契约请求,模型为: %s", frontendReq.Model) - - // 2. 【模拟“转换层”逻辑】 - // 这是一个临时的适配逻辑,用来验证契约字段是否能覆盖后端需求 - // 如果我们在 .proto 里漏掉了某个关键字段,这一步就会发现“没法转” - var backendMessages []domain.Message - for _, m := range frontendReq.Messages { - backendMessages = append(backendMessages, domain.Message{ - Role: m.Role, - Content: m.Content, - }) - } - - backendReq := &domain.ChatRequest{ - Model: frontendReq.Model, - Messages: backendMessages, - } - - // 3. 【验证后端兼容性】 - // 我们检查转换后的对象是否符合后端 domain 的要求 - if backendReq.Model != "qwen-max" { - t.Errorf("数据在转换过程中丢失!预期模型 qwen-max,实际得到 %s", backendReq.Model) - } - - if len(backendReq.Messages) != 1 || backendReq.Messages[0].Content != "你好,请自我介绍" { - t.Error("对话内容转换失败") - } - - // 4. 【模拟后端回传契约】 - // 后端处理完后,需要把结果装进契约定义的响应里 - // 如果契约定义的字段不够(比如没有 is_finished),前端就没法渲染 - mockReply := "我是 NeoCode 助手" - response := &proto.ChatResponse{ - Content: mockReply, - IsFinished: true, - Status: &proto.Status{ - Code: 0, - Message: "OK", - }, - } - - if response.Status.Code != 0 { - t.Error("响应契约状态码异常") - } - - t.Log("验证成功:当前的 api/proto 契约能够完美覆盖现有的前后端数据传输需求!") -} - -// 这个测试证明了: -// 1. 前端可以只看 .proto 就开始写代码。 -// 2. 后端可以只看转换后的对象就开始写代码。 -// 3. 契约层(api/proto)作为标准是完全可行的。 diff --git a/test/gitkeep b/test/gitkeep deleted file mode 100644 index e69de29b..00000000