diff --git a/cmd/server/main.go b/cmd/server/main.go index ecbfc2b5..18290079 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -49,15 +49,18 @@ func main() { 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, roleSvc, chatProvider) - fmt.Printf("Server initialized with services: %+v\n", chatGateway) - fmt.Println("Note: This is a placeholder. Actual server implementation goes here.") + chatGateway := service.NewChatService(memorySvc, workingSvc, todoSvc, roleSvc, chatProvider) + fmt.Printf("服务器已初始化并加载服务: %+v\n", chatGateway) + fmt.Println("注意:这是一个占位符。实际的服务器实现将在此处进行.") } func initializeSecurity(configDir string) error { diff --git a/configs/app_config.go b/configs/app_config.go index 54715712..045eef33 100644 --- a/configs/app_config.go +++ b/configs/app_config.go @@ -33,7 +33,9 @@ type AppConfiguration struct { } `yaml:"memory"` History struct { - ShortTermTurns int `yaml:"short_term_turns"` + ShortTermTurns int `yaml:"short_term_turns"` + MaxToolContextMessages int `yaml:"max_tool_context_messages"` + MaxToolContextOutputSize int `yaml:"max_tool_context_output_size"` } `yaml:"history"` Persona struct { @@ -58,6 +60,8 @@ func DefaultAppConfig() *AppConfiguration { 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.Persona.FilePath = DefaultPersonaFilePath return cfg } @@ -112,7 +116,7 @@ func EnsureConfigFile(filePath string) (*AppConfiguration, bool, error) { // WriteAppConfig 将应用配置写入磁盘。 func WriteAppConfig(filePath string, cfg *AppConfiguration) error { if cfg == nil { - return fmt.Errorf("配置信息为空") + return fmt.Errorf("应用配置不能为空") } cfgCopy := *cfg cfgCopy.AI.APIKey = strings.TrimSpace(cfgCopy.AI.APIKey) @@ -134,36 +138,42 @@ func (c *AppConfiguration) Validate() error { // ValidateBase 检查不包含密钥的基础配置是否合法。 func (c *AppConfiguration) ValidateBase() error { if c == nil { - return fmt.Errorf("app config is nil") + return fmt.Errorf("应用配置不能为空") } providerName := normalizeProviderName(c.AI.Provider) if providerName == "" { - return fmt.Errorf("invalid config: ai.provider is required") + return fmt.Errorf("配置无效:需要 ai.provider") } if !isSupportedProvider(providerName) { - return fmt.Errorf("invalid config: unsupported ai.provider %q", strings.TrimSpace(c.AI.Provider)) + return fmt.Errorf("配置无效:不支持的 ai.provider %q", strings.TrimSpace(c.AI.Provider)) } c.AI.Provider = providerName if strings.TrimSpace(c.AI.Model) == "" { - return fmt.Errorf("invalid config: ai.model is required") + return fmt.Errorf("配置无效:需要 ai.model") } if c.Memory.TopK <= 0 { - return fmt.Errorf("invalid config: memory.top_k must be greater than 0") + return fmt.Errorf("配置无效:memory.top_k 必须大于 0") } if c.Memory.MinMatchScore < 0 { - return fmt.Errorf("invalid config: memory.min_match_score must not be negative") + return fmt.Errorf("配置无效:memory.min_match_score 不能为负数") } if c.Memory.MaxPromptChars <= 0 { - return fmt.Errorf("invalid config: memory.max_prompt_chars must be greater than 0") + return fmt.Errorf("配置无效:memory.max_prompt_chars 必须大于 0") } if c.Memory.MaxItems <= 0 { - return fmt.Errorf("invalid config: memory.max_items must be greater than 0") + return fmt.Errorf("配置无效:memory.max_items 必须大于 0") } if strings.TrimSpace(c.Memory.StoragePath) == "" { - return fmt.Errorf("invalid config: memory.storage_path is required") + return fmt.Errorf("配置无效:需要 memory.storage_path") } if c.History.ShortTermTurns <= 0 { - return fmt.Errorf("invalid config: history.short_term_turns must be greater than 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") } return nil } @@ -175,7 +185,7 @@ func (c *AppConfiguration) ValidateRuntime() error { } envVarName := c.APIKeyEnvVarName() if c.RuntimeAPIKey() == "" { - return fmt.Errorf("invalid runtime: %s environment variable is required", envVarName) + return fmt.Errorf("运行时无效:需要 %s 环境变量", envVarName) } return nil } diff --git a/configs/app_config_test.go b/configs/app_config_test.go index 2ea13389..0adddbaa 100644 --- a/configs/app_config_test.go +++ b/configs/app_config_test.go @@ -76,7 +76,7 @@ func TestAppConfigurationValidateBaseRejectsUnsupportedProvider(t *testing.T) { cfg.AI.Provider = "unknown" err := cfg.ValidateBase() - if err == nil || !strings.Contains(err.Error(), "unsupported ai.provider") { + if err == nil || !strings.Contains(err.Error(), "不支持的 ai.provider") { t.Fatalf("expected unsupported provider error, got: %v", err) } } @@ -191,6 +191,8 @@ func validConfig() *AppConfiguration { 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.Persona.FilePath = DefaultPersonaFilePath return cfg } diff --git a/configs/persona.txt b/configs/persona.txt index 51b9d54a..cfd90216 100644 --- a/configs/persona.txt +++ b/configs/persona.txt @@ -15,6 +15,15 @@ - 涉及路径操作时,默认限定在当前工作区内,不对工作区外路径进行读写。 你可以调用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); diff --git a/go.mod b/go.mod index 3bc33011..6b42c65e 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,18 @@ module go-llm-demo go 1.26.1 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 ) require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect - github.com/charmbracelet/bubbles v1.0.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect @@ -29,8 +32,5 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // 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 - google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 003ee1ab..5d3134dc 100644 --- a/go.sum +++ b/go.sum @@ -1,50 +1,43 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 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= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 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/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 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= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +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/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= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -53,24 +46,21 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/server/domain/todo.go b/internal/server/domain/todo.go new file mode 100644 index 00000000..5ef37f5c --- /dev/null +++ b/internal/server/domain/todo.go @@ -0,0 +1,94 @@ +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 new file mode 100644 index 00000000..75bb7a6d --- /dev/null +++ b/internal/server/domain/todo_test.go @@ -0,0 +1,88 @@ +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 index fa4c7d6a..a94e34b2 100644 --- a/internal/server/domain/tool.go +++ b/internal/server/domain/tool.go @@ -1,6 +1,9 @@ package domain -import "encoding/json" +import ( + "encoding/json" + "strings" +) // ToolCall 表示工具调用请求。 type ToolCall struct { @@ -8,6 +11,32 @@ type ToolCall struct { 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 diff --git a/internal/server/infra/provider/chat_provider.go b/internal/server/infra/provider/chat_provider.go index edfc3058..868e74b0 100644 --- a/internal/server/infra/provider/chat_provider.go +++ b/internal/server/infra/provider/chat_provider.go @@ -24,8 +24,8 @@ const ( ) var ( - ErrInvalidAPIKey = errors.New("invalid api key") - ErrAPIKeyValidationSoft = errors.New("api key validation uncertain") + ErrInvalidAPIKey = errors.New("无效的 API Key") + ErrAPIKeyValidationSoft = errors.New("API Key 校验结果不确定") ) type ChatCompletionProvider struct { @@ -82,7 +82,7 @@ func NewChatProvider(model string) (domain.ChatProvider, error) { // ValidateChatAPIKey 按当前提供方配置校验运行时 API Key。 func ValidateChatAPIKey(ctx context.Context, cfg *configs.AppConfiguration) error { if cfg == nil { - return fmt.Errorf("config is nil") + return fmt.Errorf("config cannot be nil") } providerName := providerNameFromConfig(cfg) @@ -263,7 +263,7 @@ func isRetryableError(err error) bool { func validateChatAPIKey(ctx context.Context, cfg *configs.AppConfiguration) error { if cfg == nil { - return fmt.Errorf("configs is nil") + return fmt.Errorf("配置不能为空") } modelName := strings.TrimSpace(cfg.AI.Model) diff --git a/internal/server/infra/repository/memory_repository.go b/internal/server/infra/repository/memory_repository.go index fb2bc065..4513b275 100644 --- a/internal/server/infra/repository/memory_repository.go +++ b/internal/server/infra/repository/memory_repository.go @@ -103,7 +103,7 @@ func (s *FileMemoryStore) readAllLocked() ([]domain.MemoryItem, error) { func (s *FileMemoryStore) writeAllLocked(items []domain.MemoryItem) error { if strings.TrimSpace(s.path) == "" { - return fmt.Errorf("persistent memory path is empty") + return fmt.Errorf("长期记忆路径为空") } if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { return err diff --git a/internal/server/infra/repository/role_repository.go b/internal/server/infra/repository/role_repository.go index 63285006..ab8669a6 100644 --- a/internal/server/infra/repository/role_repository.go +++ b/internal/server/infra/repository/role_repository.go @@ -38,7 +38,7 @@ func (s *FileRoleStore) GetByID(ctx context.Context, id string) (*domain.Role, e } } - return nil, errors.New("role not found") + return nil, errors.New("角色不存在") } // GetByName 返回指定名称对应的角色。 @@ -57,7 +57,7 @@ func (s *FileRoleStore) GetByName(ctx context.Context, name string) (*domain.Rol } } - return nil, errors.New("role not found") + return nil, errors.New("角色不存在") } // List 返回所有已存储的角色。 @@ -122,7 +122,7 @@ func (s *FileRoleStore) Delete(ctx context.Context, id string) error { } if len(newRoles) == len(roles) { - return errors.New("role not found") + return errors.New("角色不存在") } return s.writeAllLocked(newRoles) diff --git a/internal/server/infra/repository/todo_repository.go b/internal/server/infra/repository/todo_repository.go new file mode 100644 index 00000000..01a64548 --- /dev/null +++ b/internal/server/infra/repository/todo_repository.go @@ -0,0 +1,75 @@ +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 new file mode 100644 index 00000000..9868da0c --- /dev/null +++ b/internal/server/infra/repository/todo_repository_test.go @@ -0,0 +1,130 @@ +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/tools/bash.go b/internal/server/infra/tools/bash.go index cfc0641e..ad589cc8 100644 --- a/internal/server/infra/tools/bash.go +++ b/internal/server/infra/tools/bash.go @@ -15,12 +15,12 @@ type BashTool struct{} func (b *BashTool) Definition() ToolDefinition { return ToolDefinition{ Name: "bash", - Description: "在工作区内执行 bash 命令。支持可选 workdir 和 timeout,默认 120000ms。", + 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: "要执行的 bash 命令。"}, - {Name: "workdir", Type: "string", Description: "工作区内命令执行目录,默认工作区根目录。"}, - {Name: "timeout", Type: "integer", Description: "命令超时时间,单位毫秒,默认 120000。"}, - {Name: "description", Type: "string", Description: "对命令目的的简短说明,便于日志和审计。"}, + {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."}, }, } } @@ -40,7 +40,7 @@ func (b *BashTool) Run(params map[string]interface{}) *ToolResult { return errRes } if timeoutMs < 1 { - return &ToolResult{ToolName: b.Definition().Name, Success: false, Error: "timeout 必须 >= 1"} + return &ToolResult{ToolName: b.Definition().Name, Success: false, Error: "timeout must be >= 1"} } workdir, errRes := optionalString(params, "workdir", ".") if errRes != nil { @@ -61,11 +61,11 @@ func (b *BashTool) Run(params map[string]interface{}) *ToolResult { var shellArgs []string switch runtime.GOOS { case "linux", "darwin": - // Linux/macOS: 使用 bash + // Linux/macOS: use bash shell = "bash" shellArgs = []string{"-lc", command} case "windows": - // Windows: 使用 PowerShell + // Windows: use PowerShell shell = "powershell" shellArgs = []string{"-Command", command} default: @@ -73,7 +73,7 @@ func (b *BashTool) Run(params map[string]interface{}) *ToolResult { shellArgs = []string{"-lc", command} } - // 使用动态选择的 shell 和参数创建命令 + // 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 @@ -85,11 +85,11 @@ func (b *BashTool) Run(params map[string]interface{}) *ToolResult { if err != nil { if ctx.Err() != nil { result.Success = false - result.Error = fmt.Sprintf("命令在 %dms 后超时", timeoutMs) + result.Error = fmt.Sprintf("command timed out after %dms", timeoutMs) return result } result.Success = false - result.Error = fmt.Sprintf("命令执行失败: %v", err) + result.Error = fmt.Sprintf("command execution failed: %v", err) if stderrBuf.Len() > 0 { result.Error += ": " + stderrBuf.String() } diff --git a/internal/server/infra/tools/bash_security_test.go b/internal/server/infra/tools/bash_security_test.go index 21703a3e..0bdbd1df 100644 --- a/internal/server/infra/tools/bash_security_test.go +++ b/internal/server/infra/tools/bash_security_test.go @@ -30,7 +30,7 @@ func TestBashTool_Run_DeniedBySecurity(t *testing.T) { if result.Success { t.Fatal("expected bash execution to be denied") } - if !strings.Contains(result.Error, "安全策略拒绝执行") { + if !strings.Contains(result.Error, "Security policy denied execution") { t.Fatalf("unexpected error: %s", result.Error) } if result.Metadata["securityAction"] != string(domain.ActionDeny) { @@ -52,7 +52,7 @@ func TestBashTool_Run_AskBySecurity(t *testing.T) { if result.Success { t.Fatal("expected bash execution to require confirmation") } - if !strings.Contains(result.Error, "需要用户确认") { + if !strings.Contains(result.Error, "requires user confirmation") { t.Fatalf("unexpected error: %s", result.Error) } if result.Metadata["securityAction"] != string(domain.ActionAsk) { diff --git a/internal/server/infra/tools/edit.go b/internal/server/infra/tools/edit.go index 73e975a1..432ce942 100644 --- a/internal/server/infra/tools/edit.go +++ b/internal/server/infra/tools/edit.go @@ -12,12 +12,12 @@ type EditTool struct{} func (e *EditTool) Definition() ToolDefinition { return ToolDefinition{ Name: "edit", - Description: "在工作区内对文件执行精确字符串替换。默认只替换首次命中,可通过 replaceAll 控制。", + 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: "工作区内要修改的文件路径。"}, - {Name: "oldString", Type: "string", Required: true, Description: "要替换的原始文本,必须与文件内容完全匹配。"}, - {Name: "newString", Type: "string", Required: true, Description: "替换后的新文本,必须不同于 oldString。"}, - {Name: "replaceAll", Type: "boolean", Description: "是否替换所有命中,默认 false。"}, + {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."}, }, } } @@ -44,7 +44,7 @@ func (e *EditTool) Run(params map[string]interface{}) *ToolResult { return errRes } if oldString == newString { - return &ToolResult{ToolName: e.Definition().Name, Success: false, Error: "newString 必须不同于 oldString"} + return &ToolResult{ToolName: e.Definition().Name, Success: false, Error: "newString must be different from oldString"} } replaceAll, errRes := optionalBool(params, "replaceAll", false) if errRes != nil { @@ -54,11 +54,11 @@ func (e *EditTool) Run(params map[string]interface{}) *ToolResult { content, err := os.ReadFile(filePath) if err != nil { - return &ToolResult{ToolName: e.Definition().Name, Success: false, Error: fmt.Sprintf("读取文件失败: %v", err)} + 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("未在文件中找到要替换的字符串: %q", 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) @@ -68,7 +68,7 @@ func (e *EditTool) Run(params map[string]interface{}) *ToolResult { 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("写入文件失败: %v", err)} + 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("成功替换 %d 处匹配项", replacements), Metadata: map[string]interface{}{"filePath": filePath, "oldString": oldString, "newString": newString, "replaceAll": replaceAll, "replacements": replacements, "bytesWritten": len(newContent)}} + 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 index dd3d7535..751642f5 100644 --- a/internal/server/infra/tools/grep.go +++ b/internal/server/infra/tools/grep.go @@ -14,11 +14,11 @@ type GrepTool struct{} func (g *GrepTool) Definition() ToolDefinition { return ToolDefinition{ Name: "grep", - Description: "在工作区内递归搜索文件内容并返回所有命中的文件、行号和行文本。", + 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: "要搜索的正则表达式。"}, - {Name: "path", Type: "string", Description: "工作区内的搜索根目录,默认当前工作区。"}, - {Name: "include", Type: "string", Description: "可选的文件名 glob 过滤,例如 '*.go'。"}, + {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'."}, }, } } @@ -31,7 +31,7 @@ func (g *GrepTool) Run(params map[string]interface{}) *ToolResult { } regex, err := regexp.Compile(pattern) if err != nil { - return &ToolResult{ToolName: g.Definition().Name, Success: false, Error: fmt.Sprintf("无效的正则表达式模式: %v", err)} + return &ToolResult{ToolName: g.Definition().Name, Success: false, Error: fmt.Sprintf("invalid regex pattern: %v", err)} } searchPath, errRes := optionalString(params, "path", ".") if errRes != nil { @@ -84,7 +84,7 @@ func (g *GrepTool) Run(params map[string]interface{}) *ToolResult { return &ToolResult{ToolName: g.Definition().Name, Success: false, Error: walkErr.Error()} } if matchCount == 0 { - return &ToolResult{ToolName: g.Definition().Name, Success: true, Output: "未找到匹配项。", Metadata: map[string]interface{}{"pattern": pattern, "path": searchPath, "include": includePattern, "matches": 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 index 61edea27..2bb7e787 100644 --- a/internal/server/infra/tools/helpers.go +++ b/internal/server/infra/tools/helpers.go @@ -173,6 +173,7 @@ func resolveWorkspacePath(path string) (string, error) { if err != nil { return "", err } + // 通过相对路径判断是否越过工作区边界,比单纯字符串前缀判断更稳妥。 rel, err := filepath.Rel(root, candidate) if err != nil { return "", fmt.Errorf("路径超出工作区: %s", path) @@ -232,6 +233,7 @@ func AtomicWrite(filePath string, content []byte) error { return nil } +// NormalizeParams 递归将工具参数键名统一转换为 camelCase。 func NormalizeParams(params map[string]interface{}) map[string]interface{} { if params == nil { return map[string]interface{}{} diff --git a/internal/server/infra/tools/list.go b/internal/server/infra/tools/list.go index 3cf89809..f4497a6d 100644 --- a/internal/server/infra/tools/list.go +++ b/internal/server/infra/tools/list.go @@ -11,8 +11,8 @@ type ListTool struct{} func (l *ListTool) Definition() ToolDefinition { return ToolDefinition{ Name: "list", - Description: "列出工作区内目录内容。每行一个条目,子目录后缀为 '/'.", - Parameters: []ToolParamSpec{{Name: "path", Type: "string", Description: "工作区内待列出的目录,默认当前工作区根目录。"}}, + 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."}}, } } @@ -30,12 +30,12 @@ func (l *ListTool) Run(params map[string]interface{}) *ToolResult { file, err := os.Open(path) if err != nil { - return &ToolResult{ToolName: l.Definition().Name, Success: false, Error: fmt.Sprintf("打开目录失败: %v", err)} + 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("读取目录失败: %v", err)} + return &ToolResult{ToolName: l.Definition().Name, Success: false, Error: fmt.Sprintf("failed to read directory: %v", err)} } output := "" for _, entry := range entries { diff --git a/internal/server/infra/tools/read.go b/internal/server/infra/tools/read.go index 5efe1411..7e8e6b6e 100644 --- a/internal/server/infra/tools/read.go +++ b/internal/server/infra/tools/read.go @@ -12,16 +12,16 @@ type ReadTool struct{} func (r *ReadTool) Definition() ToolDefinition { return ToolDefinition{ Name: "read", - Description: "读取工作区内的文件或目录。读取文件时支持 offset/limit 分页,读取目录时返回目录项列表。", + 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: "工作区内的目标文件或目录路径。支持相对路径。"}, - {Name: "offset", Type: "integer", Description: "读取文件时的起始行号,1 开始,默认 1。"}, - {Name: "limit", Type: "integer", Description: "读取文件时的最大返回行数,默认 2000。"}, + {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 执行读取工具。 +// Run executes the read tool. func (r *ReadTool) Run(params map[string]interface{}) *ToolResult { filePath, errRes := requiredString(params, "filePath") if errRes != nil { @@ -45,27 +45,27 @@ func (r *ReadTool) Run(params map[string]interface{}) *ToolResult { return errRes } if offset < 1 { - return &ToolResult{ToolName: r.Definition().Name, Success: false, Error: "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 必须 >= 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("打开文件失败: %v", err)} + 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("获取文件状态失败: %v", err)} + 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("读取目录失败: %v", err)} + return &ToolResult{ToolName: r.Definition().Name, Success: false, Error: fmt.Sprintf("failed to read directory: %v", err)} } output := "" for _, entry := range entries { @@ -89,7 +89,7 @@ func (r *ReadTool) Run(params map[string]interface{}) *ToolResult { currentLine++ } if err := scanner.Err(); err != nil { - return &ToolResult{ToolName: r.Definition().Name, Success: false, Error: fmt.Sprintf("读取文件错误: %v", err)} + return &ToolResult{ToolName: r.Definition().Name, Success: false, Error: fmt.Sprintf("error reading file: %v", err)} } output := "" diff --git a/internal/server/infra/tools/security.go b/internal/server/infra/tools/security.go index 82df86b9..1f1a014d 100644 --- a/internal/server/infra/tools/security.go +++ b/internal/server/infra/tools/security.go @@ -94,7 +94,7 @@ func guardToolExecution(toolType, target, toolName string) *ToolResult { return &ToolResult{ ToolName: toolName, Success: false, - Error: fmt.Sprintf("安全策略拒绝执行 %s: %s", toolType, target), + Error: fmt.Sprintf("Security policy denied execution of %s: %s", toolType, target), Metadata: metadata, } case domain.ActionAsk: @@ -104,19 +104,10 @@ func guardToolExecution(toolType, target, toolName string) *ToolResult { return &ToolResult{ ToolName: toolName, Success: false, - Error: fmt.Sprintf("命中安全策略,执行 %s 前需要用户确认: %s", toolType, target), + Error: fmt.Sprintf("Execution of %s on %s requires user confirmation (Action: Ask).", toolType, target), Metadata: metadata, } default: - metadata["securityAction"] = string(domain.ActionAsk) - if consumeSecurityAskApproval(toolType, target) { - return nil - } - return &ToolResult{ - ToolName: toolName, - Success: false, - Error: fmt.Sprintf("安全策略返回未知动作(%s),已按需确认处理: %s", action, target), - Metadata: metadata, - } + return nil } } diff --git a/internal/server/infra/tools/todo.go b/internal/server/infra/tools/todo.go new file mode 100644 index 00000000..1dd316be --- /dev/null +++ b/internal/server/infra/tools/todo.go @@ -0,0 +1,136 @@ +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 new file mode 100644 index 00000000..e87ad8b8 --- /dev/null +++ b/internal/server/infra/tools/todo_test.go @@ -0,0 +1,195 @@ +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 index fe89ed26..0f00e69e 100644 --- a/internal/server/infra/tools/tool.go +++ b/internal/server/infra/tools/tool.go @@ -1,7 +1,6 @@ package tools import ( - "encoding/json" "fmt" "sort" @@ -21,6 +20,7 @@ type ToolRegistry struct { tools map[string]Tool } +// NewToolRegistry 创建并注册默认工具集合。 func NewToolRegistry() *ToolRegistry { r := &ToolRegistry{tools: make(map[string]Tool)} r.Register(&ReadTool{}) @@ -69,6 +69,7 @@ func (r *ToolRegistry) Execute(call domain.ToolCall) *ToolResult { 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 { @@ -80,11 +81,7 @@ func (r *ToolRegistry) Execute(call domain.ToolCall) *ToolResult { if result.Metadata == nil { result.Metadata = map[string]interface{}{} } + // 为后续日志、UI 展示和上下文回灌补齐最基础的工具元信息。 result.Metadata["tool"] = call.Tool return result } - -// JsonMarshalIndent 用于缩进JSON编码。 -func JsonMarshalIndent(v interface{}, prefix, indent string) ([]byte, error) { - return json.MarshalIndent(v, prefix, indent) -} diff --git a/internal/server/infra/tools/write.go b/internal/server/infra/tools/write.go index d49bf067..65338953 100644 --- a/internal/server/infra/tools/write.go +++ b/internal/server/infra/tools/write.go @@ -9,10 +9,10 @@ type WriteTool struct{} func (w *WriteTool) Definition() ToolDefinition { return ToolDefinition{ Name: "write", - Description: "在工作区内写入整个文件内容。若父目录不存在则自动创建。", + 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: "工作区内目标文件路径。"}, - {Name: "content", Type: "string", Required: true, Description: "将完整写入文件的新内容。"}, + {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."}, }, } } @@ -35,7 +35,7 @@ func (w *WriteTool) Run(params map[string]interface{}) *ToolResult { } if err := AtomicWrite(filePath, []byte(content)); err != nil { - return &ToolResult{ToolName: w.Definition().Name, Success: false, Error: fmt.Sprintf("写入文件失败: %v", err)} + 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("成功写入 %s", filePath), Metadata: map[string]interface{}{"filePath": filePath, "bytesWritten": len(content)}} + 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 index cd8dcd15..02e3f98a 100644 --- a/internal/server/service/chat_service.go +++ b/internal/server/service/chat_service.go @@ -11,15 +11,17 @@ import ( 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, roleSvc domain.RoleService, chatProvider domain.ChatProvider) domain.ChatGateway { +// 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, } @@ -55,27 +57,23 @@ func (s *chatServiceImpl) Send(ctx context.Context, req *domain.ChatRequest) (<- return nil, err } } + todoContext := "" + if s.todoSvc != nil { + todos, _ := s.todoSvc.ListTodos(ctx) + todoContext = buildTodoContext(todos) + } + + blocks := []string{workingContext, todoContext} if userInput != "" { - memoryContext, err := s.memorySvc.BuildContext(ctx, userInput) - if err != nil { - return nil, err - } - combinedContext := joinContextBlocks(workingContext, memoryContext) - if combinedContext != "" { - // 如果前面已经注入了 role prompt,则把工作记忆和长期记忆继续拼到同一条 system 消息中, - // 避免生成多条 system 消息打乱提示优先级。 - if rolePrompt != "" && len(messages) > 0 && messages[0].Role == "system" { - messages[0].Content = rolePrompt + "\n\n" + combinedContext - } else { - messages = append([]domain.Message{{Role: "system", Content: combinedContext}}, messages...) - } - } - } else if workingContext != "" { - if rolePrompt != "" && len(messages) > 0 && messages[0].Role == "system" { - messages[0].Content = rolePrompt + "\n\n" + workingContext - } else { - messages = append([]domain.Message{{Role: "system", Content: workingContext}}, messages...) + 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) @@ -121,6 +119,26 @@ func (s *chatServiceImpl) latestUserInput(messages []domain.Message) string { 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 { diff --git a/internal/server/service/security_service.go b/internal/server/service/security_service.go index c1765188..ed65f8c6 100644 --- a/internal/server/service/security_service.go +++ b/internal/server/service/security_service.go @@ -97,7 +97,7 @@ func ruleMatches(rule domain.Rule, toolType string, target string) bool { case "Bash": pattern = rule.Command actionBit = rule.Exec - case "WebFetch": + case "Webfetch": pattern = rule.Domain actionBit = rule.Network default: diff --git a/internal/server/service/security_service_test.go b/internal/server/service/security_service_test.go index 701eeaed..447d4038 100644 --- a/internal/server/service/security_service_test.go +++ b/internal/server/service/security_service_test.go @@ -74,32 +74,32 @@ func TestSecurityService_Check(t *testing.T) { want domain.Action // 我们期望拦截器给出的裁定 (deny/allow/ask) }{ // --- 黑名单拦截测试 --- - {"完全拦截-禁止读取Git", "Read", ".git/config", domain.ActionDeny}, - {"完全拦截-禁止写入Git", "Write", ".git/HEAD", domain.ActionDeny}, - {"命令拦截-禁止删库", "Bash", "rm -rf /", domain.ActionDeny}, + {"完全拦截-禁止读取Git", string(domain.ToolRead), ".git/config", domain.ActionDeny}, + {"完全拦截-禁止写入Git", string(domain.ToolWrite), ".git/HEAD", domain.ActionDeny}, + {"命令拦截-禁止删库", string(domain.ToolBash), "rm -rf /", domain.ActionDeny}, // --- 路径规范化/绕过攻击测试 (安全核心) --- - {"路径绕过-尝试跨出目录绕过黑名单", "Read", "src/../.git/config", domain.ActionDeny}, - {"路径绕过-冗余斜杠绕过", "Read", ".git///config", domain.ActionDeny}, - {"路径绕过-相对路径前缀", "Read", "./.git/config", 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", "Read", "config/.env", domain.ActionDeny}, - {"穿透-黑名单未定义Write-落入兜底", "Write", "config/.env", domain.ActionAsk}, + {"拦截-黑名单明确禁止Read", string(domain.ToolRead), "config/.env", domain.ActionDeny}, + {"穿透-黑名单未定义Write-落入兜底", string(domain.ToolWrite), "config/.env", domain.ActionAsk}, // --- 白名单与黄名单匹配深度测试 --- - {"白名单-允许阅读源码", "Read", "src/main.go", domain.ActionAllow}, - {"白名单-深层目录匹配", "Read", "src/internal/pkg/util.go", domain.ActionAllow}, - {"黄名单-修改代码需确认", "Write", "src/main.go", domain.ActionAsk}, - {"黄名单-命令匹配", "Bash", "go build main.go", domain.ActionAsk}, - {"白名单-精确命令匹配", "Bash", "go version", domain.ActionAllow}, + {"白名单-允许阅读源码", 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}, // --- 网络与兜底策略 --- - {"网络-白名单域名子域", "WebFetch", "api.google.com", domain.ActionAllow}, - {"网络-不在名单内域名", "WebFetch", "hacker.com", domain.ActionAsk}, + {"网络-白名单域名子域", string(domain.ToolWebFetch), "api.google.com", domain.ActionAllow}, + {"网络-不在名单内域名", string(domain.ToolWebFetch), "hacker.com", domain.ActionAsk}, {"兜底-完全未知工具", "SelfDestruct", "now", domain.ActionAsk}, - {"空目标字符串", "Read", "", domain.ActionAsk}, - {"大小写敏感性测试", "Read", ".GIT/config", domain.ActionAsk}, //依据实现,目前是敏感的 + {"空目标字符串", string(domain.ToolRead), "", domain.ActionAsk}, + {"大小写敏感性测试", string(domain.ToolRead), ".GIT/config", domain.ActionAsk}, //依据实现,目前是敏感的 } // 补充白名单网络规则以支持上面的测试 diff --git a/internal/server/service/todo_service.go b/internal/server/service/todo_service.go new file mode 100644 index 00000000..4d557681 --- /dev/null +++ b/internal/server/service/todo_service.go @@ -0,0 +1,55 @@ +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 new file mode 100644 index 00000000..43e253df --- /dev/null +++ b/internal/server/service/todo_service_test.go @@ -0,0 +1,145 @@ +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/tui/components/help.go b/internal/tui/components/help.go index c5fc1159..6d4e7193 100644 --- a/internal/tui/components/help.go +++ b/internal/tui/components/help.go @@ -26,6 +26,7 @@ func RenderHelp(width int) string { {"/apikey ", "切换 API Key 变量名"}, {"/provider ", "切换模型提供商"}, {"/switch ", "切换模型"}, + {"/todo [add|list]", "管理待办清单"}, {"/run ", "执行代码"}, {"/explain ", "解释代码"}, {"/memory", "显示记忆统计"}, diff --git a/internal/tui/components/todo_list.go b/internal/tui/components/todo_list.go new file mode 100644 index 00000000..15c035f6 --- /dev/null +++ b/internal/tui/components/todo_list.go @@ -0,0 +1,81 @@ +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 new file mode 100644 index 00000000..fe1e0602 --- /dev/null +++ b/internal/tui/components/todo_list_test.go @@ -0,0 +1,47 @@ +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 index 88d54e60..146cf1c4 100644 --- a/internal/tui/core/model.go +++ b/internal/tui/core/model.go @@ -28,6 +28,10 @@ type Model struct { viewport viewport.Model mu *sync.Mutex + + // Todo 相关状态 + todos []services.Todo + todoCursor int } // NewModel 创建 TUI 状态模型。 diff --git a/internal/tui/core/msg.go b/internal/tui/core/msg.go index 2377d69c..67ef7211 100644 --- a/internal/tui/core/msg.go +++ b/internal/tui/core/msg.go @@ -46,3 +46,11 @@ 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 index 83aecc5f..6a425105 100644 --- a/internal/tui/core/update.go +++ b/internal/tui/core/update.go @@ -12,7 +12,9 @@ import ( "go-llm-demo/configs" "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" ) @@ -214,10 +216,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if msg.Type == tea.KeyEsc && m.ui.Mode == state.ModeHelp { - m.ui.Mode = state.ModeChat - m.refreshViewport() - return *m, nil + 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 { @@ -542,6 +550,38 @@ func (m *Model) handleCommand(input string) (tea.Model, tea.Cmd) { m.chat.MemoryStats = *stats } m.AddMessage("assistant", "已清空本地长期记忆") + 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("清空会话记忆失败: %v", err)) @@ -575,6 +615,73 @@ func (m *Model) handleCommand(input string) (tea.Model, tea.Cmd) { 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": @@ -754,7 +861,7 @@ func formatPendingApprovalMessage(pending *state.PendingApproval) string { func formatToolContextMessage(result *services.ToolResult) string { if result == nil { - return toolContextPrefix + "\n" + "tool=unknown\n" + "success=false\n" + "error:\n工具返回为空" + return toolContextPrefix + "\n" + "tool=unknown\n" + "success=false\n" + "error:\nTool returned empty result" } // 这里故意使用稳定的纯文本 key/value 结构,而不是直接把 ToolResult 原样塞回模型: @@ -794,7 +901,7 @@ func formatToolContextMessage(result *services.ToolResult) string { } func formatToolErrorContext(err error) string { - errText := "未知错误" + errText := "Unknown error" if err != nil { errText = err.Error() } diff --git a/internal/tui/core/update_test.go b/internal/tui/core/update_test.go index 6ed11b68..5f732796 100644 --- a/internal/tui/core/update_test.go +++ b/internal/tui/core/update_test.go @@ -24,6 +24,7 @@ type fakeChatClient struct { clearMemoryErr error clearSessionErr error defaultModelName string + todos []services.Todo } func (f *fakeChatClient) Chat(_ context.Context, messages []services.Message, model string) (<-chan string, error) { @@ -70,6 +71,36 @@ func (f *fakeChatClient) DefaultModel() string { 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() diff --git a/internal/tui/core/view.go b/internal/tui/core/view.go index 35269b01..db6cc047 100644 --- a/internal/tui/core/view.go +++ b/internal/tui/core/view.go @@ -78,6 +78,14 @@ func (m Model) renderInputArea() string { } func (m Model) renderChatContent() string { + if m.ui.Mode == state.ModeTodo { + return components.TodoList{ + Todos: m.todos, + Cursor: m.todoCursor, + Width: m.viewport.Width, + Focused: true, + }.Render() + } return components.MessageList{Messages: m.toComponentMessages(), Width: m.viewport.Width}.Render() } diff --git a/internal/tui/services/api_client.go b/internal/tui/services/api_client.go index 7bca50b3..5816bd82 100644 --- a/internal/tui/services/api_client.go +++ b/internal/tui/services/api_client.go @@ -13,15 +13,38 @@ import ( ) 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 } +// MemoryStats 是 TUI 展示 memory 面板所需的聚合统计信息。 type MemoryStats struct { PersistentItems int SessionItems int @@ -36,6 +59,7 @@ type localChatClient struct { roleSvc domain.RoleService memorySvc domain.MemoryService workingSvc domain.WorkingMemoryService + todoSvc domain.TodoService config *configs.AppConfiguration } @@ -71,7 +95,18 @@ func NewLocalChatClient() (ChatClient, error) { roleRepo := repository.NewFileRoleStore("./data/roles.json") roleSvc := service.NewRoleService(roleRepo, strings.TrimSpace(cfg.Persona.FilePath)) - return &localChatClient{roleSvc: roleSvc, memorySvc: memorySvc, workingSvc: workingSvc, config: cfg}, nil + 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 通过本地聊天服务发送消息。 @@ -80,7 +115,7 @@ func (c *localChatClient) Chat(ctx context.Context, messages []Message, model st if err != nil { return nil, err } - chatSvc := service.NewChatService(c.memorySvc, c.workingSvc, c.roleSvc, chatProvider) + chatSvc := service.NewChatService(c.memorySvc, c.workingSvc, c.todoSvc, c.roleSvc, chatProvider) return chatSvc.Send(ctx, &domain.ChatRequest{Messages: messages, Model: model}) } @@ -117,6 +152,38 @@ func (c *localChatClient) ClearSessionMemory(ctx context.Context) error { 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) diff --git a/internal/tui/state/ui_state.go b/internal/tui/state/ui_state.go index 4958a434..6ad21406 100644 --- a/internal/tui/state/ui_state.go +++ b/internal/tui/state/ui_state.go @@ -7,6 +7,7 @@ const ( ModeCodeInput ModeHelp ModeMemory + ModeTodo ) type UIState struct { diff --git a/internal/tui/todo/config.go b/internal/tui/todo/config.go new file mode 100644 index 00000000..e08de9f1 --- /dev/null +++ b/internal/tui/todo/config.go @@ -0,0 +1,97 @@ +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 = "待办" + StatusInProgress = "进行中" + StatusCompleted = "已完成" + + // 优先级文本 + PriorityHigh = "高" + PriorityMedium = "中" + PriorityLow = "低" + + // 状态图标 + 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 = "--- 待办清单 ---" + EmptyText = "当前没有任何待办事项。使用 /todo add <内容> [优先级] 或在待办模式下按 'a' 新增。" + HelpFooterText = "↑/↓: 浏览 Enter: 切换状态 x: 删除 a: 返回并新增 Esc: 返回聊天" + + // 交互提示消息 + MsgUsageAdd = "用法: /todo add <内容> [优先级(high/medium/low)]" + MsgAddSuccess = "已添加待办: %s" + MsgAddFailed = "添加待办失败: %v" + MsgUnknownSubCmd = "未知待办子命令: %s" + MsgPromptAdd = "请使用 /todo add <内容> [优先级] 来添加新任务。" +) + +// 按键绑定 +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", "上移"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "下移"), + ), + Add: key.NewBinding( + key.WithKeys("a", "n"), + key.WithHelp("a/n", "新增"), + ), + Done: key.NewBinding( + key.WithKeys("enter", " "), + key.WithHelp("enter/space", "切换状态"), + ), + Delete: key.NewBinding( + key.WithKeys("x", "delete"), + key.WithHelp("x/del", "删除"), + ), + Back: key.NewBinding( + key.WithKeys("esc", "q"), + key.WithHelp("esc/q", "返回"), + ), +} diff --git a/internal/tui/todo/config_test.go b/internal/tui/todo/config_test.go new file mode 100644 index 00000000..91c1b40a --- /dev/null +++ b/internal/tui/todo/config_test.go @@ -0,0 +1,22 @@ +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") + } +}