From d30956576fc2bdc10ab8767bc969f00eff0b949b Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Tue, 24 Mar 2026 15:46:12 +0800 Subject: [PATCH 1/9] =?UTF-8?q?:=E5=A2=9E=E5=8A=A0todo=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=8C=E7=8E=B0=E5=9C=A8AI=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E9=80=9A=E8=BF=87todo=E5=B7=A5=E5=85=B7=E8=A7=84=E5=88=92?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E7=9A=84=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- configs/app_config.go | 36 +++-- configs/app_config_test.go | 2 +- configs/persona.txt | 10 +- internal/server/domain/todo.go | 43 ++++++ .../server/infra/provider/chat_provider.go | 8 +- .../infra/repository/memory_repository.go | 2 +- .../infra/repository/role_repository.go | 6 +- .../infra/repository/todo_repository.go | 75 ++++++++++ internal/server/infra/tools/helpers.go | 2 + internal/server/infra/tools/todo.go | 128 ++++++++++++++++++ internal/server/infra/tools/tool.go | 9 +- internal/server/service/chat_service.go | 41 ++++-- internal/server/service/security_service.go | 4 + internal/server/service/todo_service.go | 48 +++++++ internal/server/service/todo_service_test.go | 101 ++++++++++++++ internal/tui/core/model.go | 60 +++++--- internal/tui/core/msg.go | 4 + internal/tui/core/update.go | 96 +++++++++---- internal/tui/core/view.go | 60 +++++++- internal/tui/infra/api_client.go | 27 +++- 20 files changed, 674 insertions(+), 88 deletions(-) create mode 100644 internal/server/domain/todo.go create mode 100644 internal/server/infra/repository/todo_repository.go create mode 100644 internal/server/infra/tools/todo.go create mode 100644 internal/server/service/todo_service.go create mode 100644 internal/server/service/todo_service_test.go 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..02b09847 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) } } diff --git a/configs/persona.txt b/configs/persona.txt index bd28d82b..95c0eb3d 100644 --- a/configs/persona.txt +++ b/configs/persona.txt @@ -6,7 +6,15 @@ - 对不确定的内容明确说明假设 - 默认使用中文回答 -你可以调用edit,grep,list,read,write工具,规范和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/internal/server/domain/todo.go b/internal/server/domain/todo.go new file mode 100644 index 00000000..10770704 --- /dev/null +++ b/internal/server/domain/todo.go @@ -0,0 +1,43 @@ +package domain + +import "context" + +// TodoStatus 表示任务状态 +type TodoStatus string + +const ( + TodoPending TodoStatus = "pending" + TodoInProgress TodoStatus = "in_progress" + TodoCompleted TodoStatus = "completed" +) + +// Todo 表示任务清单中的一项 +type Todo struct { + ID string `json:"id"` + Content string `json:"content"` + Status TodoStatus `json:"status"` + Priority string `json:"priority"` // high, medium, low +} + +// TodoService 定义任务清单服务接口 +type TodoService interface { + // AddTodo 添加一个新任务 + AddTodo(ctx context.Context, content string, priority string) (*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/infra/provider/chat_provider.go b/internal/server/infra/provider/chat_provider.go index edfc3058..fe52a2e0 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("配置不能为空") } 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/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/todo.go b/internal/server/infra/tools/todo.go new file mode 100644 index 00000000..73aa5152 --- /dev/null +++ b/internal/server/infra/tools/todo.go @@ -0,0 +1,128 @@ +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: "管理显式任务清单。可以添加任务、更新任务状态(pending, in_progress, completed)、列出所有任务或移除任务。", + Parameters: []ToolParamSpec{ + {Name: "action", Type: "string", Required: true, Description: "操作类型: add, update, list, remove, clear"}, + {Name: "content", Type: "string", Description: "任务内容(仅用于 add 操作)"}, + {Name: "priority", Type: "string", Description: "优先级: high, medium, low(仅用于 add 操作,默认为 medium)"}, + {Name: "id", Type: "string", Description: "任务 ID(用于 update 和 remove 操作)"}, + {Name: "status", Type: "string", Description: "新状态: pending, in_progress, completed(仅用于 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() + + switch strings.ToLower(action) { + case "add": + content, errRes := requiredString(params, "content") + if errRes != nil { + errRes.ToolName = t.Definition().Name + return errRes + } + priority := optionalStringDefault(params, "priority", "medium") + 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("已添加任务: %s (%s)", todo.ID, todo.Content)} + + case "update": + 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 := domain.TodoStatus(strings.ToLower(statusStr)) + if status != domain.TodoPending && status != domain.TodoInProgress && status != domain.TodoCompleted { + return &ToolResult{ToolName: t.Definition().Name, Success: false, Error: "无效的状态值"} + } + 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("任务 %s 状态已更新为 %s", id, status)} + + case "list": + 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: "当前任务清单为空"} + } + var sb strings.Builder + sb.WriteString("当前任务清单:\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 (优先级: %s)\n", statusIcon, todo.ID, todo.Content, todo.Priority)) + } + return &ToolResult{ToolName: t.Definition().Name, Success: true, Output: sb.String()} + + case "remove": + 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("已移除任务 %s", id)} + + case "clear": + 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: "任务清单已清空"} + + default: + return &ToolResult{ToolName: t.Definition().Name, Success: false, Error: fmt.Sprintf("不支持的操作: %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/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/service/chat_service.go b/internal/server/service/chat_service.go index cd8dcd15..0ba50070 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,26 +57,31 @@ 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) + } + if userInput != "" { memoryContext, err := s.memorySvc.BuildContext(ctx, userInput) if err != nil { return nil, err } - combinedContext := joinContextBlocks(workingContext, memoryContext) + combinedContext := joinContextBlocks(workingContext, todoContext, 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 != "" { + } else if workingContext != "" || todoContext != "" { + combinedContext := joinContextBlocks(workingContext, todoContext) if rolePrompt != "" && len(messages) > 0 && messages[0].Role == "system" { - messages[0].Content = rolePrompt + "\n\n" + workingContext + messages[0].Content = rolePrompt + "\n\n" + combinedContext } else { - messages = append([]domain.Message{{Role: "system", Content: workingContext}}, messages...) + messages = append([]domain.Message{{Role: "system", Content: combinedContext}}, messages...) } } @@ -121,6 +128,24 @@ 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 { + status := "pending" + if todo.Status == domain.TodoInProgress { + status = "in_progress" + } else if todo.Status == domain.TodoCompleted { + status = "completed" + } + sb.WriteString(fmt.Sprintf("- %s: %s (status: %s, priority: %s)\n", todo.ID, todo.Content, status, todo.Priority)) + } + return sb.String() +} + 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 76fb1247..a5f7f33f 100644 --- a/internal/server/service/security_service.go +++ b/internal/server/service/security_service.go @@ -10,6 +10,7 @@ import ( "github.com/bmatcuk/doublestar/v4" ) +// SecurityService 根据工具类型和目标对象给出允许、拒绝或询问的决策。 type SecurityService interface { Check(toolType string, target string) domain.Action } @@ -22,6 +23,7 @@ type securityServiceImpl struct { yellowList *domain.Config } +// NewSecurityService 创建基于规则配置仓库的安全检查服务。 func NewSecurityService(configRepo domain.SecurityConfigRepository) SecurityService { return &securityServiceImpl{ configRepo: configRepo, @@ -113,6 +115,7 @@ func ruleMatches(rule domain.Rule, toolType string, target string) bool { return false } + // Bash 规则匹配命令字符串;其余规则统一走 doublestar 路径/域名匹配。 if toolType == "Bash" { return matchCommand(pattern, target) } @@ -126,6 +129,7 @@ func ruleMatches(rule domain.Rule, toolType string, target string) bool { } func matchCommand(pattern, command string) bool { + // 命令规则沿用类似 glob 的写法,再转换为正则以便复用配置表达力。 rePattern := regexp.QuoteMeta(pattern) rePattern = strings.ReplaceAll(rePattern, `\*\*`, `.*`) rePattern = strings.ReplaceAll(rePattern, `\*`, `.*`) diff --git a/internal/server/service/todo_service.go b/internal/server/service/todo_service.go new file mode 100644 index 00000000..36f258e0 --- /dev/null +++ b/internal/server/service/todo_service.go @@ -0,0 +1,48 @@ +package service + +import ( + "context" + "go-llm-demo/internal/server/domain" + "sort" +) + +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 string) (*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 todos[i].ID < todos[j].ID + }) + return todos, nil +} + +func (s *todoServiceImpl) ClearTodos(ctx context.Context) error { + return s.repo.Clear(ctx) +} + +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..92a68600 --- /dev/null +++ b/internal/server/service/todo_service_test.go @@ -0,0 +1,101 @@ +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(), "测试任务1", "high") + if err != nil { + t.Fatalf("添加任务失败: %v", err) + } + + if todo.ID == "" { + t.Fatal("任务 ID 不应为空") + } + if todo.Content != "测试任务1" { + t.Errorf("期望内容为 '测试任务1', 得到 '%s'", todo.Content) + } + if todo.Status != domain.TodoPending { + t.Errorf("期望状态为 'pending', 得到 '%s'", todo.Status) + } +} + +func TestTodoService_ListTodos(t *testing.T) { + service, _ := setupTodoService() + _, _ = service.AddTodo(context.Background(), "任务1", "high") + _, _ = service.AddTodo(context.Background(), "任务2", "low") + + todos, err := service.ListTodos(context.Background()) + if err != nil { + t.Fatalf("列出任务失败: %v", err) + } + + if len(todos) != 2 { + t.Fatalf("期望有 2 个任务, 得到 %d", len(todos)) + } + if todos[0].Content != "任务1" || todos[1].Content != "任务2" { + t.Error("任务排序或内容不正确") + } +} + +func TestTodoService_UpdateTodoStatus(t *testing.T) { + service, _ := setupTodoService() + todo, _ := service.AddTodo(context.Background(), "待办任务", "medium") + + err := service.UpdateTodoStatus(context.Background(), todo.ID, domain.TodoCompleted) + if err != nil { + t.Fatalf("更新状态失败: %v", err) + } + + todos, _ := service.ListTodos(context.Background()) + if todos[0].Status != domain.TodoCompleted { + t.Errorf("期望状态为 'completed', 得到 '%s'", todos[0].Status) + } +} + +func TestTodoService_RemoveTodo(t *testing.T) { + service, _ := setupTodoService() + todo1, _ := service.AddTodo(context.Background(), "任务1", "high") + _, _ = service.AddTodo(context.Background(), "任务2", "low") + + err := service.RemoveTodo(context.Background(), todo1.ID) + if err != nil { + t.Fatalf("移除任务失败: %v", err) + } + + todos, _ := service.ListTodos(context.Background()) + if len(todos) != 1 { + t.Fatalf("期望剩余 1 个任务, 得到 %d", len(todos)) + } + if todos[0].Content != "任务2" { + t.Error("移除的任务不正确") + } +} + +func TestTodoService_ClearTodos(t *testing.T) { + service, _ := setupTodoService() + _, _ = service.AddTodo(context.Background(), "任务1", "high") + _, _ = service.AddTodo(context.Background(), "任务2", "low") + + err := service.ClearTodos(context.Background()) + if err != nil { + t.Fatalf("清空任务失败: %v", err) + } + + todos, _ := service.ListTodos(context.Background()) + if len(todos) != 0 { + t.Fatalf("期望任务清单为空, 得到 %d 个任务", len(todos)) + } +} diff --git a/internal/tui/core/model.go b/internal/tui/core/model.go index b0ce2951..1e56fdc0 100644 --- a/internal/tui/core/model.go +++ b/internal/tui/core/model.go @@ -6,6 +6,7 @@ import ( "time" "go-llm-demo/configs" + "go-llm-demo/internal/server/domain" "go-llm-demo/internal/tui/infra" "github.com/charmbracelet/bubbles/cursor" @@ -15,6 +16,7 @@ import ( "github.com/charmbracelet/lipgloss" ) +// Mode 表示 TUI 当前所处的主交互模式。 type Mode int const ( @@ -24,19 +26,23 @@ const ( ModeMemory ) +// Model 保存 Bubble Tea 运行所需的全部界面状态。 type Model struct { width int height int mode Mode focused string - messages []Message - historyTurns int + messages []Message + historyTurns int + maxToolContextMessages int + maxToolContextOutputSize int generating bool activeModel string memoryStats infra.MemoryStats + todos []domain.Todo commandHistory []string cmdHistIndex int @@ -58,6 +64,7 @@ type Model struct { mu *sync.Mutex } +// Message 是 TUI 内部使用的聊天消息表示。 type Message struct { Role string Content string @@ -72,10 +79,22 @@ func NewModel(client infra.ChatClient, persona string, historyTurns int, configP if stats == nil { stats = &infra.MemoryStats{} } + + maxToolContextMessages := 3 + maxToolContextOutputSize := 4000 + if configs.GlobalAppConfig != nil { + if historyTurns <= 0 { + historyTurns = configs.GlobalAppConfig.History.ShortTermTurns + } + maxToolContextMessages = configs.GlobalAppConfig.History.MaxToolContextMessages + maxToolContextOutputSize = configs.GlobalAppConfig.History.MaxToolContextOutputSize + } + if historyTurns <= 0 { historyTurns = 6 } + // 输入框和视口在初始化阶段统一配置,后续 Update/View 只处理状态变化。 input := textarea.New() focusedStyle, blurredStyle := textarea.DefaultStyles() focusedStyle.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("#61AFEF")) @@ -99,23 +118,25 @@ func NewModel(client infra.ChatClient, persona string, historyTurns int, configP vp.SetContent("") return Model{ - mode: ModeChat, - focused: "input", - messages: make([]Message, 0), - historyTurns: historyTurns, - activeModel: client.DefaultModel(), - memoryStats: *stats, - commandHistory: make([]string, 0), - cmdHistIndex: -1, - client: client, - persona: persona, - workspaceRoot: workspaceRoot, - apiKeyReady: configs.RuntimeAPIKey() != "", - configPath: configPath, - textarea: input, - viewport: vp, - autoScroll: true, - mu: &sync.Mutex{}, + mode: ModeChat, + focused: "input", + messages: make([]Message, 0), + historyTurns: historyTurns, + maxToolContextMessages: maxToolContextMessages, + maxToolContextOutputSize: maxToolContextOutputSize, + activeModel: client.DefaultModel(), + memoryStats: *stats, + commandHistory: make([]string, 0), + cmdHistIndex: -1, + client: client, + persona: persona, + workspaceRoot: workspaceRoot, + apiKeyReady: configs.RuntimeAPIKey() != "", + configPath: configPath, + textarea: input, + viewport: vp, + autoScroll: true, + mu: &sync.Mutex{}, } } @@ -182,6 +203,7 @@ func (m *Model) TrimHistory(maxTurns int) { return } + // 系统消息承载 persona、工具上下文等结构化信息,不参与普通轮次裁剪。 var system []Message var others []Message diff --git a/internal/tui/core/msg.go b/internal/tui/core/msg.go index e4a9c560..d3b1dbee 100644 --- a/internal/tui/core/msg.go +++ b/internal/tui/core/msg.go @@ -67,6 +67,10 @@ type RefreshMemoryMsg struct{} func (RefreshMemoryMsg) isMsg() {} +type RefreshTodoMsg struct{} + +func (RefreshTodoMsg) isMsg() {} + type streamNextChunk struct { stream <-chan string } diff --git a/internal/tui/core/update.go b/internal/tui/core/update.go index cc6e05ab..59dc963e 100644 --- a/internal/tui/core/update.go +++ b/internal/tui/core/update.go @@ -19,10 +19,8 @@ import ( ) const ( - toolStatusPrefix = "[TOOL_STATUS]" - toolContextPrefix = "[TOOL_CONTEXT]" - maxToolContextOutputSize = 4000 - maxToolContextMessages = 3 + toolStatusPrefix = "[TOOL_STATUS]" + toolContextPrefix = "[TOOL_CONTEXT]" ) // Update 处理 Bubble Tea 事件并驱动聊天状态更新。 @@ -73,8 +71,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } mu.Unlock() - // 当前工具协议约定:模型如果想调用工具,需要把最后一条 assistant 消息完整输出为 - // {"tool":"...","params":{...}} 结构。这里在流结束后统一解析,避免半截 JSON 被误触发。 + // 检查最后一条AI消息是否为JSON格式的工具调用 if shouldCheckToolCall { var jsonData map[string]interface{} if err := json.Unmarshal([]byte(lastContent), &jsonData); err == nil { @@ -156,6 +153,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.refreshViewport() return m, nil + case RefreshTodoMsg: + todos, err := m.client.GetTodoList(context.Background()) + if err == nil { + m.todos = todos + } + m.refreshViewport() + return m, nil + case ExitMsg: return m, tea.Quit @@ -165,14 +170,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.toolExecuting = false mu.Unlock() // 将结构化工具上下文添加为系统消息,然后重新获取AI响应 - m.AddMessage("system", formatToolContextMessage(msg.Result)) + m.AddMessage("system", m.formatToolContextMessage(msg.Result)) m.AddMessage("assistant", "") m.generating = true m.refreshViewport() // 构建包含工具结果的消息并重新请求AI messages := m.buildMessages() - return m, m.streamResponse(messages) + return m, tea.Batch( + m.streamResponse(messages), + func() tea.Msg { return RefreshTodoMsg{} }, + ) case ToolErrorMsg: mu := m.mutex() @@ -180,14 +188,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.toolExecuting = false mu.Unlock() // 将工具执行错误添加为结构化系统上下文 - m.AddMessage("system", formatToolErrorContext(msg.Err)) + m.AddMessage("system", m.formatToolErrorContext(msg.Err)) m.AddMessage("assistant", "") m.generating = true m.refreshViewport() // 构建包含错误信息的消息并重新请求AI messages := m.buildMessages() - return m, m.streamResponse(messages) + return m, tea.Batch( + m.streamResponse(messages), + func() tea.Msg { return RefreshTodoMsg{} }, + ) } return m, cmd @@ -531,9 +542,7 @@ func (m *Model) buildMessages() []infra.Message { mu.Lock() defer mu.Unlock() result := make([]infra.Message, 0, len(m.messages)) - // 工具结果会被注入成 system 上下文,但只保留最近几条, - // 否则连续工具链很容易把真正的对话历史挤出上下文窗口。 - keepToolContextIndex := recentToolContextIndexes(m.messages, maxToolContextMessages) + keepToolContextIndex := recentToolContextIndexes(m.messages, m.maxToolContextMessages) // 按照消息的原始时间顺序进行迭代 for idx, msg := range m.messages { @@ -541,9 +550,17 @@ func (m *Model) buildMessages() []infra.Message { continue } if msg.Role == "system" && isToolContextMessage(msg.Content) { + content := msg.Content if _, ok := keepToolContextIndex[idx]; !ok { - continue + // 对于较旧的工具执行结果,只保留状态头信息,隐藏其具体输出以节省 Token。 + // 这样 Agent 仍然知道它已经执行过该工具及其结果。 + content = stripToolOutput(content) } + result = append(result, infra.Message{ + Role: msg.Role, + Content: content, + }) + continue } // 跳过空的 assistant 消息 if msg.Role == "assistant" && strings.TrimSpace(msg.Content) == "" { @@ -559,6 +576,23 @@ func (m *Model) buildMessages() []infra.Message { return result } +// stripToolOutput 移除工具上下文消息中的详细输出,仅保留工具名和执行状态。 +func stripToolOutput(content string) string { + lines := strings.Split(content, "\n") + var result []string + for _, line := range lines { + // 保留标识头、工具名、成功状态和元数据,丢弃具体的输出/错误正文。 + if strings.HasPrefix(line, toolContextPrefix) || + strings.HasPrefix(line, "tool=") || + strings.HasPrefix(line, "success=") || + strings.HasPrefix(line, "metadata=") { + result = append(result, line) + } + } + result = append(result, "output: (该历史输出已由系统自动压缩以节省 Token 窗口)") + return strings.Join(result, "\n") +} + func (m *Model) streamResponse(messages []infra.Message) tea.Cmd { stream, err := m.client.Chat(context.Background(), messages, m.activeModel) if err != nil { @@ -635,13 +669,11 @@ func formatToolStatusMessage(toolName string, params map[string]interface{}) str return fmt.Sprintf("%s tool=%s%s", toolStatusPrefix, strings.TrimSpace(toolName), detail) } -func formatToolContextMessage(result *tools.ToolResult) string { +func (m *Model) formatToolContextMessage(result *tools.ToolResult) string { if result == nil { return toolContextPrefix + "\n" + "tool=unknown\n" + "success=false\n" + "error:\n工具返回为空" } - // 这里故意使用稳定的纯文本 key/value 结构,而不是直接把 ToolResult 原样塞回模型: - // 一方面更容易截断超长输出,另一方面也能减少不同工具返回格式带来的歧义。 builder := strings.Builder{} builder.WriteString(toolContextPrefix) builder.WriteString("\n") @@ -660,7 +692,7 @@ func formatToolContextMessage(result *tools.ToolResult) string { output := strings.TrimSpace(result.Output) if output != "" { builder.WriteString("output:\n") - builder.WriteString(truncateForContext(output, maxToolContextOutputSize)) + builder.WriteString(truncateForContext(output, m.maxToolContextOutputSize)) } } else { errText := strings.TrimSpace(result.Error) @@ -669,19 +701,19 @@ func formatToolContextMessage(result *tools.ToolResult) string { } if errText != "" { builder.WriteString("error:\n") - builder.WriteString(truncateForContext(errText, maxToolContextOutputSize)) + builder.WriteString(truncateForContext(errText, m.maxToolContextOutputSize)) } } return builder.String() } -func formatToolErrorContext(err error) string { +func (m *Model) formatToolErrorContext(err error) string { errText := "未知错误" if err != nil { errText = err.Error() } - return toolContextPrefix + "\n" + "tool=unknown\n" + "success=false\n" + "error:\n" + truncateForContext(errText, maxToolContextOutputSize) + return toolContextPrefix + "\n" + "tool=unknown\n" + "success=false\n" + "error:\n" + truncateForContext(errText, m.maxToolContextOutputSize) } func truncateForContext(text string, maxLen int) string { @@ -689,12 +721,24 @@ func truncateForContext(text string, maxLen int) string { if maxLen <= 0 || len(trimmed) <= maxLen { return trimmed } - suffix := fmt.Sprintf("\n... (truncated, total=%d chars)", len(trimmed)) - keep := maxLen - len(suffix) - if keep < 0 { - keep = 0 + + // 采用“头尾保留”策略,中间用省略号代替。 + // 这在读取代码或查看长日志时非常有用(可以看到开头和结尾的报错)。 + totalLen := len(trimmed) + suffix := fmt.Sprintf("\n... (已截断 %d 字符,总计 %d)\n", totalLen-maxLen, totalLen) + + // 保留头部的 60% 和尾部的 40% 空间 + headLen := int(float64(maxLen-len(suffix)) * 0.6) + tailLen := (maxLen - len(suffix)) - headLen + + if headLen < 0 { + headLen = 0 + } + if tailLen < 0 { + tailLen = 0 } - return trimmed[:keep] + suffix + + return trimmed[:headLen] + suffix + trimmed[totalLen-tailLen:] } func runCodeCmd(code string) tea.Cmd { diff --git a/internal/tui/core/view.go b/internal/tui/core/view.go index 60d2a206..8fcddca0 100644 --- a/internal/tui/core/view.go +++ b/internal/tui/core/view.go @@ -3,6 +3,7 @@ package core import ( "strings" + "go-llm-demo/internal/server/domain" "go-llm-demo/internal/tui/components" "github.com/charmbracelet/lipgloss" @@ -14,9 +15,13 @@ func (m Model) View() string { } statusHeight := 1 + todoHeight := 0 + if len(m.todos) > 0 { + todoHeight = minInt(len(m.todos)+2, 8) + } helpHeight := 0 if m.mode == ModeHelp { - helpHeight = minInt(20, m.height-statusHeight-3) + helpHeight = minInt(20, m.height-statusHeight-todoHeight-3) } inputContent := m.renderInputArea() @@ -25,7 +30,7 @@ func (m Model) View() string { inputHeight = 4 } - contentHeight := m.height - statusHeight - inputHeight - helpHeight + contentHeight := m.height - statusHeight - inputHeight - helpHeight - todoHeight if contentHeight < 3 { contentHeight = 3 } @@ -51,15 +56,62 @@ func (m Model) View() string { Width(m.width). Render(inputContent) + var todoArea string + if todoHeight > 0 { + todoArea = lipgloss.NewStyle(). + Width(m.width). + Height(todoHeight). + Border(lipgloss.NormalBorder(), true, false, false, false). + BorderForeground(lipgloss.Color("#3E4452")). + Render(m.renderTodoArea()) + } + if m.mode == ModeHelp { help := lipgloss.NewStyle(). Width(m.width). Height(helpHeight). Render(RenderHelp(m.width)) - return lipgloss.JoinVertical(lipgloss.Left, statusBar, chatArea, help, inputArea) + return lipgloss.JoinVertical(lipgloss.Left, statusBar, chatArea, todoArea, help, inputArea) + } + + return lipgloss.JoinVertical(lipgloss.Left, statusBar, chatArea, todoArea, inputArea) +} + +func (m Model) renderTodoArea() string { + if len(m.todos) == 0 { + return "" + } + + titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B")).Bold(true) + todoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#ABB2BF")) + doneStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#5C6370")).Strikethrough(true) + inProgressStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#61AFEF")) + + var b strings.Builder + b.WriteString(titleStyle.Render("任务清单:")) + b.WriteString("\n") + + // 仅显示最近的 6 个任务 + displayTodos := m.todos + if len(displayTodos) > 6 { + displayTodos = displayTodos[len(displayTodos)-6:] + } + + for _, todo := range displayTodos { + icon := "[ ] " + style := todoStyle + if todo.Status == domain.TodoInProgress { + icon = "[/] " + style = inProgressStyle + } else if todo.Status == domain.TodoCompleted { + icon = "[x] " + style = doneStyle + } + b.WriteString(style.Render(icon + todo.Content)) + b.WriteString("\n") } - return lipgloss.JoinVertical(lipgloss.Left, statusBar, chatArea, inputArea) + return b.String() } func countLines(s string) int { diff --git a/internal/tui/infra/api_client.go b/internal/tui/infra/api_client.go index 263e2a8b..fcdfbef4 100644 --- a/internal/tui/infra/api_client.go +++ b/internal/tui/infra/api_client.go @@ -14,14 +14,17 @@ import ( type Message = domain.Message +// 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) ([]domain.Todo, error) DefaultModel() string } +// MemoryStats 是 TUI 展示 memory 面板所需的聚合统计信息。 type MemoryStats struct { PersistentItems int SessionItems int @@ -36,6 +39,7 @@ type localChatClient struct { roleSvc domain.RoleService memorySvc domain.MemoryService workingSvc domain.WorkingMemoryService + todoSvc domain.TodoService config *configs.AppConfiguration } @@ -71,7 +75,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 +95,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 +132,14 @@ 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) +} + // DefaultModel 返回 TUI 使用的默认模型。 func (c *localChatClient) DefaultModel() string { return provider.DefaultModelForConfig(c.config) From 88121026c02a542ef63090e4f4733e0fd3b1f141 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Tue, 24 Mar 2026 19:12:58 +0800 Subject: [PATCH 2/9] =?UTF-8?q?:=E4=BF=AE=E5=A4=8Dtodo=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E6=9C=AA=E6=AD=A3=E7=A1=AE=E6=B3=A8=E5=85=A5=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/main.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 5671098a..b40ffbf8 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -44,13 +44,16 @@ 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("注意:这是一个占位符。实际的服务器实现将在此处进行.") } From 76a9d9229e1fe1090dfacdde3de4e3c702464d99 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Tue, 24 Mar 2026 19:42:08 +0800 Subject: [PATCH 3/9] =?UTF-8?q?:=E4=BF=AE=E5=A4=8D=E6=96=B9=E6=B3=95?= =?UTF-8?q?=E7=BC=BA=E5=B0=91=E5=AF=BC=E8=87=B4=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/main.go | 9 ++++++--- internal/tui/core/update_test.go | 9 +++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 5671098a..b40ffbf8 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -44,13 +44,16 @@ 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("注意:这是一个占位符。实际的服务器实现将在此处进行.") } diff --git a/internal/tui/core/update_test.go b/internal/tui/core/update_test.go index d4375402..282f5efd 100644 --- a/internal/tui/core/update_test.go +++ b/internal/tui/core/update_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "go-llm-demo/internal/server/domain" "go-llm-demo/internal/tui/infra" ) @@ -31,6 +32,10 @@ func (fakeChatClient) DefaultModel() string { return "test-model" } +func (fakeChatClient) GetTodoList(context.Context) ([]domain.Todo, error) { + return nil, nil +} + func TestBuildMessagesSkipsEmptyAssistantPlaceholder(t *testing.T) { m := Model{ messages: []Message{ @@ -127,8 +132,8 @@ func TestBuildMessagesKeepsOnlyRecentToolContextMessages(t *testing.T) { toolCtxCount++ } } - if toolCtxCount != maxToolContextMessages { - t.Fatalf("expected %d tool context messages, got %d", maxToolContextMessages, toolCtxCount) + if toolCtxCount != 3 { + t.Fatalf("expected 3 tool context messages, got %d", toolCtxCount) } joined := "" From 343aa0f937ef807e04aa5173e4d1d12ca963b740 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Tue, 24 Mar 2026 20:15:16 +0800 Subject: [PATCH 4/9] =?UTF-8?q?:=E4=BF=AE=E5=A4=8D=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=B1=BB=E7=9A=84=E5=8F=82=E6=95=B0=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- configs/app_config_test.go | 2 ++ internal/tui/core/update_test.go | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/configs/app_config_test.go b/configs/app_config_test.go index 02b09847..0adddbaa 100644 --- a/configs/app_config_test.go +++ b/configs/app_config_test.go @@ -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/internal/tui/core/update_test.go b/internal/tui/core/update_test.go index 282f5efd..41220736 100644 --- a/internal/tui/core/update_test.go +++ b/internal/tui/core/update_test.go @@ -118,7 +118,7 @@ func TestBuildMessagesSkipsTransientToolStatusMessage(t *testing.T) { } func TestBuildMessagesKeepsOnlyRecentToolContextMessages(t *testing.T) { - m := Model{} + m := Model{maxToolContextMessages: 3} m.messages = append(m.messages, Message{Role: "user", Content: "step 1"}) for i := 1; i <= 5; i++ { m.messages = append(m.messages, Message{Role: "system", Content: "[TOOL_CONTEXT]\ntool=read\nsuccess=true\noutput:\nchunk " + string(rune('0'+i))}) @@ -132,8 +132,8 @@ func TestBuildMessagesKeepsOnlyRecentToolContextMessages(t *testing.T) { toolCtxCount++ } } - if toolCtxCount != 3 { - t.Fatalf("expected 3 tool context messages, got %d", toolCtxCount) + if toolCtxCount != 5 { + t.Fatalf("expected 5 tool context messages, got %d", toolCtxCount) } joined := "" From 7bac8abf1bb7c3f0164ddebd4b0910b0511bee3b Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 25 Mar 2026 13:59:58 +0800 Subject: [PATCH 5/9] =?UTF-8?q?:=E4=BF=AE=E5=A4=8DTUI=20core=20?= =?UTF-8?q?=E5=B1=82=E7=9B=B4=E6=8E=A5=E5=BC=95=E7=94=A8domain=E5=92=8Ctod?= =?UTF-8?q?o=E6=A8=A1=E5=9D=97=E5=A4=A7=E9=87=8F=E7=A1=AC=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 10 +- go.sum | 26 ++-- internal/server/domain/todo.go | 63 +++++++++- internal/server/domain/tool.go | 31 ++++- internal/server/infra/tools/todo.go | 60 +++++---- internal/server/service/chat_service.go | 8 +- .../server/service/security_service_test.go | 34 ++--- internal/server/service/todo_service.go | 11 +- internal/server/service/todo_service_test.go | 16 +-- internal/tui/components/help.go | 1 + internal/tui/components/todo_list.go | 81 ++++++++++++ internal/tui/core/model.go | 4 + internal/tui/core/msg.go | 8 ++ internal/tui/core/update.go | 119 +++++++++++++++++- internal/tui/core/update_test.go | 31 +++++ internal/tui/core/view.go | 8 ++ internal/tui/services/api_client.go | 46 ++++++- internal/tui/state/ui_state.go | 1 + internal/tui/todo/config.go | 90 +++++++++++++ 19 files changed, 551 insertions(+), 97 deletions(-) create mode 100644 internal/tui/components/todo_list.go create mode 100644 internal/tui/todo/config.go 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 index 10770704..5ef37f5c 100644 --- a/internal/server/domain/todo.go +++ b/internal/server/domain/todo.go @@ -1,6 +1,9 @@ package domain -import "context" +import ( + "context" + "strings" +) // TodoStatus 表示任务状态 type TodoStatus string @@ -11,18 +14,66 @@ const ( 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 string `json:"priority"` // high, medium, low + 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 string) (*Todo, error) + AddTodo(ctx context.Context, content string, priority TodoPriority) (*Todo, error) // UpdateTodoStatus 更新任务状态 UpdateTodoStatus(ctx context.Context, id string, status TodoStatus) error // ListTodos 获取所有任务 diff --git a/internal/server/domain/tool.go b/internal/server/domain/tool.go index fa4c7d6a..73d53b23 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/tools/todo.go b/internal/server/infra/tools/todo.go index 73aa5152..1dd316be 100644 --- a/internal/server/infra/tools/todo.go +++ b/internal/server/infra/tools/todo.go @@ -19,13 +19,13 @@ func NewTodoTool(todoSvc domain.TodoService) *TodoTool { func (t *TodoTool) Definition() ToolDefinition { return ToolDefinition{ Name: "todo", - Description: "管理显式任务清单。可以添加任务、更新任务状态(pending, in_progress, completed)、列出所有任务或移除任务。", + 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: "操作类型: add, update, list, remove, clear"}, - {Name: "content", Type: "string", Description: "任务内容(仅用于 add 操作)"}, - {Name: "priority", Type: "string", Description: "优先级: high, medium, low(仅用于 add 操作,默认为 medium)"}, - {Name: "id", Type: "string", Description: "任务 ID(用于 update 和 remove 操作)"}, - {Name: "status", Type: "string", Description: "新状态: pending, in_progress, completed(仅用于 update 操作)"}, + {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."}, }, } } @@ -39,21 +39,31 @@ func (t *TodoTool) Run(params map[string]interface{}) *ToolResult { ctx := context.Background() - switch strings.ToLower(action) { - case "add": + 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 } - priority := optionalStringDefault(params, "priority", "medium") + 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("已添加任务: %s (%s)", todo.ID, todo.Content)} + return &ToolResult{ToolName: t.Definition().Name, Success: true, Output: fmt.Sprintf("Added task: %s (%s)", todo.ID, todo.Content)} - case "update": + case domain.TodoActionUpdate: id, errRes := requiredString(params, "id") if errRes != nil { errRes.ToolName = t.Definition().Name @@ -64,26 +74,26 @@ func (t *TodoTool) Run(params map[string]interface{}) *ToolResult { errRes.ToolName = t.Definition().Name return errRes } - status := domain.TodoStatus(strings.ToLower(statusStr)) - if status != domain.TodoPending && status != domain.TodoInProgress && status != domain.TodoCompleted { - return &ToolResult{ToolName: t.Definition().Name, Success: false, Error: "无效的状态值"} + 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("任务 %s 状态已更新为 %s", id, status)} + return &ToolResult{ToolName: t.Definition().Name, Success: true, Output: fmt.Sprintf("Task %s status updated to %s", id, status)} - case "list": + 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: "当前任务清单为空"} + return &ToolResult{ToolName: t.Definition().Name, Success: true, Output: "Todo list is empty"} } var sb strings.Builder - sb.WriteString("当前任务清单:\n") + sb.WriteString("Todo list:\n") for _, todo := range todos { statusIcon := "[ ]" if todo.Status == domain.TodoInProgress { @@ -91,11 +101,11 @@ func (t *TodoTool) Run(params map[string]interface{}) *ToolResult { } else if todo.Status == domain.TodoCompleted { statusIcon = "[x]" } - sb.WriteString(fmt.Sprintf("%s %s: %s (优先级: %s)\n", statusIcon, todo.ID, todo.Content, todo.Priority)) + 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 "remove": + case domain.TodoActionRemove: id, errRes := requiredString(params, "id") if errRes != nil { errRes.ToolName = t.Definition().Name @@ -105,18 +115,16 @@ func (t *TodoTool) Run(params map[string]interface{}) *ToolResult { 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("已移除任务 %s", id)} + return &ToolResult{ToolName: t.Definition().Name, Success: true, Output: fmt.Sprintf("Removed task %s", id)} - case "clear": + 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: "任务清单已清空"} - - default: - return &ToolResult{ToolName: t.Definition().Name, Success: false, Error: fmt.Sprintf("不支持的操作: %s", action)} + 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 { diff --git a/internal/server/service/chat_service.go b/internal/server/service/chat_service.go index 0ba50070..f5610f8e 100644 --- a/internal/server/service/chat_service.go +++ b/internal/server/service/chat_service.go @@ -135,13 +135,7 @@ func buildTodoContext(todos []domain.Todo) string { var sb strings.Builder sb.WriteString("[TODO_LIST]\n") for _, todo := range todos { - status := "pending" - if todo.Status == domain.TodoInProgress { - status = "in_progress" - } else if todo.Status == domain.TodoCompleted { - status = "completed" - } - sb.WriteString(fmt.Sprintf("- %s: %s (status: %s, priority: %s)\n", todo.ID, todo.Content, status, todo.Priority)) + sb.WriteString(fmt.Sprintf("- %s: %s (status: %s, priority: %s)\n", todo.ID, todo.Content, todo.Status, todo.Priority)) } return sb.String() } 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 index 36f258e0..4d557681 100644 --- a/internal/server/service/todo_service.go +++ b/internal/server/service/todo_service.go @@ -4,6 +4,8 @@ import ( "context" "go-llm-demo/internal/server/domain" "sort" + "strconv" + "strings" ) type todoServiceImpl struct { @@ -14,7 +16,7 @@ func NewTodoService(repo domain.TodoRepository) domain.TodoService { return &todoServiceImpl{repo: repo} } -func (s *todoServiceImpl) AddTodo(ctx context.Context, content string, priority string) (*domain.Todo, error) { +func (s *todoServiceImpl) AddTodo(ctx context.Context, content string, priority domain.TodoPriority) (*domain.Todo, error) { todo := domain.Todo{ Content: content, Status: domain.TodoPending, @@ -34,7 +36,7 @@ func (s *todoServiceImpl) ListTodos(ctx context.Context) ([]domain.Todo, error) } // 按 ID 排序以保证输出稳定性 sort.Slice(todos, func(i, j int) bool { - return todos[i].ID < todos[j].ID + return todoIDNum(todos[i].ID) < todoIDNum(todos[j].ID) }) return todos, nil } @@ -43,6 +45,11 @@ 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 index 92a68600..859562d8 100644 --- a/internal/server/service/todo_service_test.go +++ b/internal/server/service/todo_service_test.go @@ -16,7 +16,7 @@ func setupTodoService() (domain.TodoService, *repository.InMemoryTodoRepository) func TestTodoService_AddTodo(t *testing.T) { service, _ := setupTodoService() - todo, err := service.AddTodo(context.Background(), "测试任务1", "high") + todo, err := service.AddTodo(context.Background(), "测试任务1", domain.TodoPriorityHigh) if err != nil { t.Fatalf("添加任务失败: %v", err) } @@ -34,8 +34,8 @@ func TestTodoService_AddTodo(t *testing.T) { func TestTodoService_ListTodos(t *testing.T) { service, _ := setupTodoService() - _, _ = service.AddTodo(context.Background(), "任务1", "high") - _, _ = service.AddTodo(context.Background(), "任务2", "low") + _, _ = service.AddTodo(context.Background(), "任务1", domain.TodoPriorityHigh) + _, _ = service.AddTodo(context.Background(), "任务2", domain.TodoPriorityLow) todos, err := service.ListTodos(context.Background()) if err != nil { @@ -52,7 +52,7 @@ func TestTodoService_ListTodos(t *testing.T) { func TestTodoService_UpdateTodoStatus(t *testing.T) { service, _ := setupTodoService() - todo, _ := service.AddTodo(context.Background(), "待办任务", "medium") + todo, _ := service.AddTodo(context.Background(), "待办任务", domain.TodoPriorityMedium) err := service.UpdateTodoStatus(context.Background(), todo.ID, domain.TodoCompleted) if err != nil { @@ -67,8 +67,8 @@ func TestTodoService_UpdateTodoStatus(t *testing.T) { func TestTodoService_RemoveTodo(t *testing.T) { service, _ := setupTodoService() - todo1, _ := service.AddTodo(context.Background(), "任务1", "high") - _, _ = service.AddTodo(context.Background(), "任务2", "low") + todo1, _ := service.AddTodo(context.Background(), "任务1", domain.TodoPriorityHigh) + _, _ = service.AddTodo(context.Background(), "任务2", domain.TodoPriorityLow) err := service.RemoveTodo(context.Background(), todo1.ID) if err != nil { @@ -86,8 +86,8 @@ func TestTodoService_RemoveTodo(t *testing.T) { func TestTodoService_ClearTodos(t *testing.T) { service, _ := setupTodoService() - _, _ = service.AddTodo(context.Background(), "任务1", "high") - _, _ = service.AddTodo(context.Background(), "任务2", "low") + _, _ = service.AddTodo(context.Background(), "任务1", domain.TodoPriorityHigh) + _, _ = service.AddTodo(context.Background(), "任务2", domain.TodoPriorityLow) err := service.ClearTodos(context.Background()) if err != nil { 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..2e57496e --- /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 := " " + itemStyle := lipgloss.NewStyle() + if i == tl.Cursor && tl.Focused { + cursor = "> " + itemStyle = itemStyle.Background(todo.ColorSelection).Foreground(lipgloss.Color("#FFFFFF")) + } + + statusIcon := "[ ]" + statusStyle := lipgloss.NewStyle().Foreground(todo.ColorPending) + switch t.Status { + case services.TodoInProgress: + statusIcon = "[-]" + statusStyle = lipgloss.NewStyle().Foreground(todo.ColorInProgress) + case services.TodoCompleted: + statusIcon = "[x]" + 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/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 b9eceb61..5816bd82 100644 --- a/internal/tui/services/api_client.go +++ b/internal/tui/services/api_client.go @@ -13,6 +13,23 @@ 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 { @@ -20,7 +37,10 @@ type ChatClient interface { GetMemoryStats(ctx context.Context) (*MemoryStats, error) ClearMemory(ctx context.Context) error ClearSessionMemory(ctx context.Context) error - GetTodoList(ctx context.Context) ([]domain.Todo, 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 } @@ -140,6 +160,30 @@ func (c *localChatClient) GetTodoList(ctx context.Context) ([]domain.Todo, error 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..8102b0d6 --- /dev/null +++ b/internal/tui/todo/config.go @@ -0,0 +1,90 @@ +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 = "低" +) + +// 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", "返回"), + ), +} From 4e2293ef7fd4b621fe42ae92fae7892ae556ecb7 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 25 Mar 2026 14:21:48 +0800 Subject: [PATCH 6/9] =?UTF-8?q?:=E4=BF=AE=E5=A4=8D=E5=9B=A0security?= =?UTF-8?q?=5Fservice=E4=B8=AD=E2=80=9CWebFetch=E2=80=9D=E5=92=8Ctool?= =?UTF-8?q?=E4=B8=AD=E2=80=9CWebfetch=E2=80=9D=E4=B8=8D=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E6=B5=8B=E8=AF=95=E9=94=99=E8=AF=AF=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/domain/tool.go | 16 ++++++++-------- internal/server/service/security_service.go | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/server/domain/tool.go b/internal/server/domain/tool.go index 73d53b23..a94e34b2 100644 --- a/internal/server/domain/tool.go +++ b/internal/server/domain/tool.go @@ -14,14 +14,14 @@ type ToolCall struct { 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" + 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) { 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: From 8c155b26d7592bbec031900376a11697f79bfa9a Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 25 Mar 2026 14:25:57 +0800 Subject: [PATCH 7/9] =?UTF-8?q?:=E4=BF=AE=E5=A4=8D=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E8=BF=94=E5=9B=9E=E8=AF=AD=E7=A7=8D=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=AF=BC=E8=87=B4=E6=8A=A5=E9=94=99=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/infra/provider/chat_provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/server/infra/provider/chat_provider.go b/internal/server/infra/provider/chat_provider.go index fe52a2e0..868e74b0 100644 --- a/internal/server/infra/provider/chat_provider.go +++ b/internal/server/infra/provider/chat_provider.go @@ -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("配置不能为空") + return fmt.Errorf("config cannot be nil") } providerName := providerNameFromConfig(cfg) From cc8cf8820f6c6cb73e457b4a297ee6669ebb8281 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 25 Mar 2026 14:47:40 +0800 Subject: [PATCH 8/9] =?UTF-8?q?