diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 8cbf11c..6b26c99 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -220,14 +220,7 @@ func runMessageChannel(ctx context.Context, cfg config.Config, engine *agent.Orc return engine.HandleMessage(ctx, msg.ChatID, msg.UserID, msg.Text) }, func(ctx context.Context, msg webui.IncomingMessage, callback webui.StreamEventCallback) (string, error) { - return engine.HandleMessageStream(ctx, msg.ChatID, msg.UserID, msg.Text, func(event agent.StreamEvent) error { - return callback(webui.StreamEvent{ - Type: webui.StreamEventType(event.Type), - Content: event.Content, - Step: event.Step, - ToolName: event.ToolName, - }) - }) + return engine.HandleMessageStream(ctx, msg.ChatID, msg.UserID, msg.Text, buildWebUIStreamForwarder(callback)) }, func(ctx context.Context, chatID, userID string, files []llm.InputFile) ([]string, error) { return engine.UploadAndCacheFiles(ctx, chatID, userID, files) @@ -237,3 +230,20 @@ func runMessageChannel(ctx context.Context, cfg config.Config, engine *agent.Orc return fmt.Errorf("unsupported message channel: %s", cfg.MessageChannel) } } + +func buildWebUIStreamForwarder(callback webui.StreamEventCallback) agent.StreamEventCallback { + return func(event agent.StreamEvent) error { + if callback == nil { + return nil + } + switch event.Type { + case agent.StreamEventTypeFinal, agent.StreamEventTypeError: + return callback(webui.StreamEvent{ + Type: webui.StreamEventType(event.Type), + Content: event.Content, + }) + default: + return nil + } + } +} diff --git a/cmd/bot/main_test.go b/cmd/bot/main_test.go new file mode 100644 index 0000000..a649ded --- /dev/null +++ b/cmd/bot/main_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "testing" + + "laodingbot/internal/agent" + "laodingbot/internal/transport/webui" +) + +func TestBuildWebUIStreamForwarderFiltersTraceEvents(t *testing.T) { + var got []webui.StreamEvent + forwarder := buildWebUIStreamForwarder(func(event webui.StreamEvent) error { + got = append(got, event) + return nil + }) + + events := []agent.StreamEvent{ + {Type: agent.StreamEventTypeThought, Content: "thinking", Step: 1}, + {Type: agent.StreamEventTypeToolCall, Content: "pwd", Step: 1, ToolName: "shell"}, + {Type: agent.StreamEventTypeToolResult, Content: "C:/Project", Step: 1, ToolName: "shell"}, + {Type: agent.StreamEventTypeFinal, Content: "done", Step: 2}, + {Type: agent.StreamEventTypeError, Content: "boom", Step: 3}, + } + + for _, event := range events { + if err := forwarder(event); err != nil { + t.Fatalf("forwarder returned error: %v", err) + } + } + + if len(got) != 2 { + t.Fatalf("expected 2 forwarded events, got %d", len(got)) + } + if got[0].Type != webui.StreamEventTypeFinal || got[0].Content != "done" { + t.Fatalf("unexpected final event: %+v", got[0]) + } + if got[0].Step != 0 || got[0].ToolName != "" { + t.Fatalf("expected final event without trace fields, got %+v", got[0]) + } + if got[1].Type != webui.StreamEventTypeError || got[1].Content != "boom" { + t.Fatalf("unexpected error event: %+v", got[1]) + } +} diff --git a/doc/WebUI_Stream_API_前端对接说明.md b/doc/WebUI_Stream_API_前端对接说明.md index 9a852e6..c039b76 100644 --- a/doc/WebUI_Stream_API_前端对接说明.md +++ b/doc/WebUI_Stream_API_前端对接说明.md @@ -57,12 +57,14 @@ data: {"type":"final","content":"当前目录是 C:/Project/MyProject","step":2} 事件类型: -- `thought`: 模型思考过程 -- `tool_call`: 工具调用请求 -- `tool_result`: 工具返回结果 - `final`: 最终回答 - `error`: 错误信息 +说明: + +- 当前 WebUI 默认只向前端返回 `final` 和 `error`。 +- 中间推理轨迹(如 `thought`、`tool_call`、`tool_result` 以及对应的 `step`)会写入服务端 `debug` 日志,不再直接返回给用户界面。 + ## 4. 连接生命周期 - 正常结束: 收到 `type=final` 后结束渲染,连接可由浏览器自然关闭。 @@ -71,10 +73,8 @@ data: {"type":"final","content":"当前目录是 C:/Project/MyProject","step":2} ## 5. 前端渲染建议 -推荐将一次请求的事件按 `step` 分组后渲染,典型展示区块: +推荐前端仅处理两类结果: -- 思考区: 逐条显示 `thought` -- 工具区: 成对显示 `tool_call` 与 `tool_result` - 答案区: 显示最后一个 `final` - 错误区: 显示 `error` @@ -88,7 +88,7 @@ data: {"type":"final","content":"当前目录是 C:/Project/MyProject","step":2} ## 6. TypeScript 对接示例 (fetch + ReadableStream) ```ts -type StreamEventType = 'thought' | 'tool_call' | 'tool_result' | 'final' | 'error'; +type StreamEventType = 'final' | 'error'; interface StreamEvent { type: StreamEventType; @@ -165,6 +165,7 @@ export async function streamChat( - step?: number - tool_name?: string 5) 收到 final 视为本轮完成;收到 error 视为失败 +6) 不要再假设前端会收到 thought/tool_call/tool_result;这些内部轨迹已改为服务端 debug 日志 你的改造要求: 1) 保留现有 UI 风格和组件结构,不做无关重构 diff --git a/internal/agent/orchestrator.go b/internal/agent/orchestrator.go index 322c280..34bce08 100644 --- a/internal/agent/orchestrator.go +++ b/internal/agent/orchestrator.go @@ -358,16 +358,17 @@ func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, routedSkills [ "", "===== ReAct 思考指引 =====", "你采用 ReAct(Reasoning + Acting)模式进行任务处理。", - "1. 思考优先:在做出任何行动之前,先在回复中阐述你的推理过程(Thought)。", + "1. 思考优先:在做出任何行动之前,先完成内部推理,但不要把 Thought、trace、step 暴露给用户。", "2. 工具调用:如果需要获取信息或执行操作,使用提供的工具函数(function calling)进行调用。", "3. 观察反馈:检查工具返回的结果,据此决定下一步行动。", - "4. 最终回答:当你有足够信息时,直接给出面向用户的最终文本回复,不要调用工具。", + "4. 最终回答:当你有足够信息时,只输出面向用户的最终文本回复,不要附带推理轨迹,不要调用工具。", "", "注意事项:", "- 每次要么调用工具,要么给出最终回答,不要两者都做。", "- 如果工具调用失败,根据错误信息(Traceback)调整策略后重试或给出替代方案。", "- 涉及文件、目录、命令时,优先调用工具获取真实结果,不要猜测。", - "- 你的思考过程(Thought)应写在回复内容中,帮助追踪推理逻辑。", + "- 如果本轮需要调用工具,可以在 assistant content 中写简短内部推理,供系统记录日志;这些内容不会直接展示给用户。", + "- 最终用户可见内容中禁止出现 Thought、Trace、Step、Observation、Action、ActionInput 等字段或标题。", "", "===== 运行环境 =====", runtimeDoc, @@ -556,7 +557,7 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp const maxSteps = 20 for step := 1; step <= maxSteps; step++ { if o.log != nil { - o.log.Infof("%s react step=%d start messages_count=%d", traceLogPrefix, step, len(messages)) + o.log.Debugf("%s react step=%d start messages_count=%d", traceLogPrefix, step, len(messages)) } // 调用 LLM(传入完整 messages + tools 定义) @@ -566,7 +567,7 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp } if o.log != nil { - o.log.Infof("%s react step=%d content_len=%d tool_calls=%d", + o.log.Debugf("%s react step=%d content_len=%d tool_calls=%d", traceLogPrefix, step, len(completion.Content), len(completion.ToolCalls)) if completion.Content != "" { o.log.Debugf("%s react step=%d thought=%q", traceLogPrefix, step, completion.Content) @@ -575,12 +576,12 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp // ========== 无 tool_calls → 最终回答 ========== if len(completion.ToolCalls) == 0 { - finalText := strings.TrimSpace(completion.Content) + finalText := sanitizeUserFacingAnswer(completion.Content) if finalText == "" { finalText = "已完成处理。" } if o.log != nil { - o.log.Infof("%s react final at step=%d answer_len=%d", traceLogPrefix, step, len(finalText)) + o.log.Debugf("%s react final at step=%d answer_len=%d", traceLogPrefix, step, len(finalText)) } return finalText, nil } @@ -614,7 +615,7 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp } if o.log != nil { - o.log.Infof("%s react step=%d tool_call tool=%s input_len=%d", traceLogPrefix, step, toolName, len(toolInput)) + o.log.Debugf("%s react step=%d tool_call tool=%s input_len=%d", traceLogPrefix, step, toolName, len(toolInput)) o.log.Debugf("%s react step=%d tool=%s input=%q", traceLogPrefix, step, toolName, toolInput) } @@ -633,7 +634,7 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp } if o.log != nil { - o.log.Infof("%s react step=%d tool=%s observation_len=%d", traceLogPrefix, step, toolName, len(obs)) + o.log.Debugf("%s react step=%d tool=%s observation_len=%d", traceLogPrefix, step, toolName, len(obs)) o.log.Debugf("%s react step=%d tool=%s observation=%q", traceLogPrefix, step, toolName, truncateForLog(obs, 500)) } @@ -711,7 +712,7 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID const maxSteps = 20 for step := 1; step <= maxSteps; step++ { if o.log != nil { - o.log.Infof("%s react stream step=%d start messages_count=%d", traceLogPrefix, step, len(messages)) + o.log.Debugf("%s react stream step=%d start messages_count=%d", traceLogPrefix, step, len(messages)) } // 调用 LLM @@ -721,7 +722,7 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID } if o.log != nil { - o.log.Infof("%s react stream step=%d content_len=%d tool_calls=%d", + o.log.Debugf("%s react stream step=%d content_len=%d tool_calls=%d", traceLogPrefix, step, len(completion.Content), len(completion.ToolCalls)) if completion.Content != "" { o.log.Debugf("%s react stream step=%d thought=%q", traceLogPrefix, step, completion.Content) @@ -741,12 +742,12 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID // ========== 无 tool_calls → 最终回答 ========== if len(completion.ToolCalls) == 0 { - finalText := strings.TrimSpace(completion.Content) + finalText := sanitizeUserFacingAnswer(completion.Content) if finalText == "" { finalText = "已完成处理。" } if o.log != nil { - o.log.Infof("%s react stream final at step=%d answer_len=%d", traceLogPrefix, step, len(finalText)) + o.log.Debugf("%s react stream final at step=%d answer_len=%d", traceLogPrefix, step, len(finalText)) } // 推送最终答案事件 if err := callback(StreamEvent{ @@ -808,7 +809,7 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID } if o.log != nil { - o.log.Infof("%s react stream step=%d tool_call tool=%s input_len=%d", traceLogPrefix, step, toolName, len(toolInput)) + o.log.Debugf("%s react stream step=%d tool_call tool=%s input_len=%d", traceLogPrefix, step, toolName, len(toolInput)) o.log.Debugf("%s react stream step=%d tool=%s input=%q", traceLogPrefix, step, toolName, toolInput) } @@ -827,7 +828,7 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID } if o.log != nil { - o.log.Infof("%s react stream step=%d tool=%s observation_len=%d", traceLogPrefix, step, toolName, len(obs)) + o.log.Debugf("%s react stream step=%d tool=%s observation_len=%d", traceLogPrefix, step, toolName, len(obs)) o.log.Debugf("%s react stream step=%d tool=%s observation=%q", traceLogPrefix, step, toolName, truncateForLog(obs, 500)) } @@ -872,7 +873,7 @@ func (o *Orchestrator) runLegacyReAct(ctx context.Context, chatID, userID, compr for step := 1; step <= maxSteps; step++ { if o.log != nil { - o.log.Infof("%s legacy react step=%d start", traceLogPrefix, step) + o.log.Debugf("%s legacy react step=%d start", traceLogPrefix, step) } messages := buildReActMessages(systemPrompt, compressedContext, userInput, scratchpad) @@ -890,10 +891,10 @@ func (o *Orchestrator) runLegacyReAct(ctx context.Context, chatID, userID, compr if decision.IsFinalAnswer { finalText := "" if decision.FinalAnswer != nil { - finalText = strings.TrimSpace(*decision.FinalAnswer) + finalText = sanitizeUserFacingAnswer(*decision.FinalAnswer) } if finalText == "" { - finalText = strings.TrimSpace(decision.Thought) + finalText = sanitizeUserFacingAnswer(decision.Thought) } if finalText == "" { finalText = "已完成处理。" @@ -1518,3 +1519,52 @@ func truncateForLog(s string, maxLen int) string { } return s[:maxLen] + "...(truncated)" } + +func sanitizeUserFacingAnswer(raw string) string { + raw = strings.ReplaceAll(raw, "\r\n", "\n") + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + + markers := []string{"Final Answer:", "Final Answer:", "最终回答:", "最终回答:", "最终答案:", "最终答案:", "Answer:", "Answer:"} + for _, marker := range markers { + idx := strings.LastIndex(raw, marker) + if idx >= 0 { + candidate := strings.TrimSpace(raw[idx+len(marker):]) + if candidate != "" { + return candidate + } + } + } + + lines := strings.Split(raw, "\n") + cleaned := make([]string, 0, len(lines)) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + if len(cleaned) > 0 && cleaned[len(cleaned)-1] != "" { + cleaned = append(cleaned, "") + } + continue + } + lower := strings.ToLower(trimmed) + if strings.HasPrefix(lower, "thought:") || strings.HasPrefix(lower, "trace:") || strings.HasPrefix(lower, "observation:") || + strings.HasPrefix(lower, "action:") || strings.HasPrefix(lower, "actioninput:") || strings.HasPrefix(lower, "action input:") || + strings.HasPrefix(lower, "step ") || strings.HasPrefix(trimmed, "思考:") || strings.HasPrefix(trimmed, "思考:") || + strings.HasPrefix(trimmed, "推理:") || strings.HasPrefix(trimmed, "推理:") || strings.HasPrefix(trimmed, "观察:") || + strings.HasPrefix(trimmed, "观察:") || strings.HasPrefix(trimmed, "行动:") || strings.HasPrefix(trimmed, "行动:") || + strings.HasPrefix(trimmed, "步骤 ") { + continue + } + cleaned = append(cleaned, trimmed) + } + + for len(cleaned) > 0 && cleaned[len(cleaned)-1] == "" { + cleaned = cleaned[:len(cleaned)-1] + } + if len(cleaned) == 0 { + return "" + } + return strings.TrimSpace(strings.Join(cleaned, "\n")) +} diff --git a/internal/agent/orchestrator_skill_selection_test.go b/internal/agent/orchestrator_skill_selection_test.go index 6024dfa..88f2574 100644 --- a/internal/agent/orchestrator_skill_selection_test.go +++ b/internal/agent/orchestrator_skill_selection_test.go @@ -93,3 +93,32 @@ func TestMatchSkillsByNameEmpty(t *testing.T) { t.Fatalf("expected 0 matches, got %d", len(matched)) } } + +func TestSanitizeUserFacingAnswerExtractsFinalAnswer(t *testing.T) { + raw := "Thought: 先分析用户问题\nObservation: 已经有足够信息\nFinal Answer: 这是给用户的结果" + got := sanitizeUserFacingAnswer(raw) + if got != "这是给用户的结果" { + t.Fatalf("expected final answer only, got %q", got) + } +} + +func TestSanitizeUserFacingAnswerDropsTraceLines(t *testing.T) { + raw := strings.Join([]string{ + "Step 1 Thought: 检查上下文", + "Action: shell", + "Observation: ok", + "请执行以下变更。", + }, "\n") + got := sanitizeUserFacingAnswer(raw) + if got != "请执行以下变更。" { + t.Fatalf("expected user-facing text only, got %q", got) + } +} + +func TestSanitizeUserFacingAnswerKeepsNormalAnswer(t *testing.T) { + raw := "1. 先打开配置文件\n2. 修改端口后重启服务" + got := sanitizeUserFacingAnswer(raw) + if got != raw { + t.Fatalf("expected answer unchanged, got %q", got) + } +} diff --git a/internal/llm/client.go b/internal/llm/client.go index 4942bdb..729218b 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -81,9 +81,10 @@ type InputFile struct { } type OpenAICompatibleClient struct { - client openai.Client - model string - log *logger.Logger + client openai.Client + model string + disableThinkingParam bool + log *logger.Logger } func NewOpenAICompatibleClient(cfg config.LLMConfig, log *logger.Logger) *OpenAICompatibleClient { @@ -95,9 +96,10 @@ func NewOpenAICompatibleClient(cfg config.LLMConfig, log *logger.Logger) *OpenAI opts = append(opts, option.WithBaseURL(cfg.BaseURL)) } return &OpenAICompatibleClient{ - client: openai.NewClient(opts...), - model: cfg.Model, - log: log, + client: openai.NewClient(opts...), + model: cfg.Model, + disableThinkingParam: shouldDisableThinkingParam(cfg.BaseURL), + log: log, } } @@ -138,7 +140,7 @@ func (c *OpenAICompatibleClient) GenerateWithTools(ctx context.Context, messages } } - resp, err := c.client.Chat.Completions.New(ctx, params) + resp, err := c.client.Chat.Completions.New(ctx, params, c.chatCompletionRequestOptions()...) if err != nil { return nil, fmt.Errorf("llm tool-call request failed: %w", err) } @@ -180,7 +182,7 @@ func (c *OpenAICompatibleClient) generateWithMessagesInternal(ctx context.Contex Messages: sdkMessages, } - resp, err := c.client.Chat.Completions.New(ctx, params) + resp, err := c.client.Chat.Completions.New(ctx, params, c.chatCompletionRequestOptions()...) if err != nil { if c.log != nil { c.log.Errorf("llm request failed err=%v", err) @@ -392,3 +394,18 @@ func appendIfMissing(items []string, value string) []string { } return append(items, value) } + +func (c *OpenAICompatibleClient) chatCompletionRequestOptions() []option.RequestOption { + if !c.disableThinkingParam { + return nil + } + return []option.RequestOption{option.WithJSONSet("enable_thinking", false)} +} + +func shouldDisableThinkingParam(baseURL string) bool { + baseURL = strings.ToLower(strings.TrimSpace(baseURL)) + if baseURL == "" { + return false + } + return strings.Contains(baseURL, "dashscope.aliyuncs.com") +} diff --git a/internal/llm/client_test.go b/internal/llm/client_test.go new file mode 100644 index 0000000..fa52147 --- /dev/null +++ b/internal/llm/client_test.go @@ -0,0 +1,24 @@ +package llm + +import "testing" + +func TestShouldDisableThinkingParam(t *testing.T) { + if !shouldDisableThinkingParam("https://dashscope.aliyuncs.com/compatible-mode/v1") { + t.Fatal("expected DashScope base URL to require enable_thinking=false") + } + if shouldDisableThinkingParam("https://api.openai.com/v1") { + t.Fatal("expected standard OpenAI base URL not to require enable_thinking=false") + } +} + +func TestChatCompletionRequestOptions(t *testing.T) { + client := &OpenAICompatibleClient{disableThinkingParam: true} + if got := len(client.chatCompletionRequestOptions()); got != 1 { + t.Fatalf("expected 1 request option when disableThinkingParam=true, got %d", got) + } + + client.disableThinkingParam = false + if got := len(client.chatCompletionRequestOptions()); got != 0 { + t.Fatalf("expected 0 request options when disableThinkingParam=false, got %d", got) + } +} diff --git a/skills/safe_pi_planning/skill.md b/skills/safe_pi_planning/skill.md index 6f44df0..1839251 100644 --- a/skills/safe_pi_planning/skill.md +++ b/skills/safe_pi_planning/skill.md @@ -140,13 +140,15 @@ description: 扮演 SAFe 铁三角(PM、架构师、RTE),将宏观 Epic **Observation**: 获取渲染后的 Markdown 架构蓝图,包含愿景、特性清单、Enabler 表、NFRs、依赖关系、执行顺序和质量门禁检查清单。 -将此蓝图**完整展示给用户**,征求反馈。 +**⚠️ 关键要求**:工具返回的内容(Observation)不会直接展示给用户。你**必须**将 `publish_pi_plan` 返回的蓝图 Markdown **全文**复制到你的最终回复中,让用户可以看到完整的规划内容。**严禁**仅用一句"蓝图已生成"代替正文输出。 + +输出蓝图后,征求用户反馈。如果用户没有异议,直接继续执行阶段 3。 --- -### 阶段 3:任务下发到 Gitea(用户确认后) +### 阶段 3:任务下发到 Gitea -当用户确认规划方案后,将 Feature 和 Enabler **逐一拆解为 User Story**,通过 `create_gitea_ticket` 在 Gitea 创建工单。 +蓝图展示给用户后,将 Feature 和 Enabler **逐一拆解为 User Story**,通过 `create_gitea_ticket` 在 Gitea 创建工单。如果用户明确表示需要调整,先根据反馈修订蓝图后再创建工单。 #### 拆解原则 @@ -215,13 +217,20 @@ Thought: Action: publish_pi_plan Action Input: {"pi_vision": "...", "features": [...], ...} + +Observation: (工具返回完整 Markdown 蓝图) + +Thought: 蓝图已生成。由于 Observation 不会直接展示给用户,我必须在 Final Answer 中包含蓝图全文内容。同时继续执行阶段 3,创建 Gitea 工单。 + +Final Answer: +(此处粘贴 publish_pi_plan 返回的完整蓝图 Markdown 全文) ``` ## 5. 输出规范 最终交付给用户的内容必须包含: -1. **PI 蓝图**:`publish_pi_plan` 生成的完整 Markdown 报告 +1. **PI 蓝图**:`publish_pi_plan` 返回的完整 Markdown 报告(必须全文输出,不可省略或仅用一句话概括) 2. **Gitea 工单汇总**(如已执行阶段 3): - 工单编号与链接列表 - 按执行顺序排列