From 440172aae8563db76b4132c5ce8ad249d358562a Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 11 Jun 2026 13:04:59 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ai):=20=E8=AE=B0=E5=BD=95=20Cl?= =?UTF-8?q?aude=20CLI=20=E4=B8=8A=E6=B8=B8=E8=AF=B7=E6=B1=82=E5=85=A5?= =?UTF-8?q?=E5=8F=82=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一 Claude CLI 聊天请求写入 AI 上游请求日志 - 记录脱敏后的 prompt、参数、模型和工具名称 - 补充 CLI 上游日志解析与脱敏测试 --- ...olExecutor.aiUpstreamLogInspection.test.ts | 40 +++++++ internal/ai/provider/claude_cli.go | 105 +++++++++++++++--- internal/ai/provider/claude_cli_test.go | 54 +++++++++ internal/ai/provider/request_log.go | 14 ++- 4 files changed, 197 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/ai/aiLocalToolExecutor.aiUpstreamLogInspection.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.aiUpstreamLogInspection.test.ts index e4eeb37..680f7b2 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.aiUpstreamLogInspection.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.aiUpstreamLogInspection.test.ts @@ -89,4 +89,44 @@ describe('aiLocalToolExecutor inspect_ai_upstream_logs', () => { expect(result.content).toContain('请先发送一次 AI 消息'); expect(result.content).toContain('扩大 lineLimit'); }); + + it('summarizes CLI upstream requests that complete without an HTTP status code', async () => { + const readAppLogTail = vi.fn().mockResolvedValue({ + success: true, + data: { + logPath: 'C:/Users/demo/.GoNavi/Logs/gonavi.log', + keyword: 'ClaudeCLI', + requestedLineLimit: 160, + lines: [ + '2026/06/11 12:20:00.000000 [INFO] AI 上游请求开始:requestId=claudecli-123 provider=ClaudeCLI method=CLI endpoint=https://proxy.example.com/api/anthropic body={"command":"claude","args":["-p","[prompt logged separately]"],"prompt":"hello","has_api_key":true}', + '2026/06/11 12:20:01.000000 [INFO] AI 上游请求完成:requestId=claudecli-123 provider=ClaudeCLI endpoint=https://proxy.example.com/api/anthropic duration=981ms', + ], + }, + }); + + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_ai_upstream_logs', { + provider: 'ClaudeCLI', + includeBody: true, + }), + connections: [], + mcpTools: [], + toolContextMap: new Map(), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + readAppLogTail, + }, + }); + + expect(result.success).toBe(true); + expect(readAppLogTail).toHaveBeenCalledWith(160, 'ClaudeCLI'); + expect(result.content).toContain('"requestId":"claudecli-123"'); + expect(result.content).toContain('"provider":"ClaudeCLI"'); + expect(result.content).toContain('"method":"CLI"'); + expect(result.content).toContain('"state":"completed"'); + expect(result.content).toContain('"duration":"981ms"'); + expect(result.content).toContain('"hasBody":true'); + expect(result.content).not.toContain('"status":0'); + }); }); diff --git a/internal/ai/provider/claude_cli.go b/internal/ai/provider/claude_cli.go index f7fc00b..5ead8d0 100644 --- a/internal/ai/provider/claude_cli.go +++ b/internal/ai/provider/claude_cli.go @@ -14,6 +14,7 @@ import ( "time" ai "GoNavi-Wails/internal/ai" + "GoNavi-Wails/internal/logger" ) var claudeLookPath = exec.LookPath @@ -66,15 +67,29 @@ func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C return nil, err } + requestLog := logAIUpstreamRequestStart( + p.Name(), + "CLI", + claudeCLIEndpointForLog(p.config), + buildClaudeCLIRequestLogBody("json", args, prompt, p.config, req), + ) + var requestErr error + defer func() { + logAIUpstreamRequestFinish(requestLog, 0, requestErr) + }() + output, err := cmd.Output() if err != nil { if isClaudeCLITimeout(ctx, err) { - return nil, fmt.Errorf("claude CLI 执行超时(%s),当前 Base URL 或 API Key 可能没有返回有效响应", claudeCLIRequestTimeout) + requestErr = fmt.Errorf("claude CLI 执行超时(%s),当前 Base URL 或 API Key 可能没有返回有效响应", claudeCLIRequestTimeout) + return nil, requestErr } if exitErr, ok := err.(*exec.ExitError); ok { - return nil, fmt.Errorf("claude CLI 执行失败: %s", string(exitErr.Stderr)) + requestErr = fmt.Errorf("claude CLI 执行失败: %s", string(exitErr.Stderr)) + return nil, requestErr } - return nil, fmt.Errorf("claude CLI 执行失败: %w", err) + requestErr = fmt.Errorf("claude CLI 执行失败: %w", err) + return nil, requestErr } // 解析 JSON 输出 @@ -84,7 +99,8 @@ func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C return &ai.ChatResponse{Content: strings.TrimSpace(string(output))}, nil } if errMsg, hasError := extractClaudeCLIEventError(result); hasError { - return nil, fmt.Errorf("claude CLI 返回错误: %s", errMsg) + requestErr = fmt.Errorf("claude CLI 返回错误: %s", errMsg) + return nil, requestErr } return &ai.ChatResponse{Content: result.Result}, nil @@ -105,19 +121,29 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, args = append(args, "--model", p.config.Model) } - fmt.Printf("[ClaudeCLI DEBUG] Running: claude %v\n", args) - cmd := claudeCommandContext(ctx, "claude", args...) if err := p.setEnv(cmd); err != nil { return err } + requestLog := logAIUpstreamRequestStart( + p.Name(), + "CLI", + claudeCLIEndpointForLog(p.config), + buildClaudeCLIRequestLogBody("stream-json", args, prompt, p.config, req), + ) + var requestErr error + defer func() { + logAIUpstreamRequestFinish(requestLog, 0, requestErr) + }() + // 关闭 stdin,防止 claude CLI 等待输入 cmd.Stdin = nil stdout, err := cmd.StdoutPipe() if err != nil { - return fmt.Errorf("创建 stdout 管道失败: %w", err) + requestErr = fmt.Errorf("创建 stdout 管道失败: %w", err) + return requestErr } // 捕获 stderr @@ -125,10 +151,13 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, cmd.Stderr = &stderrBuf if err := cmd.Start(); err != nil { - return fmt.Errorf("启动 claude CLI 失败: %w", err) + requestErr = fmt.Errorf("启动 claude CLI 失败: %w", err) + return requestErr } - fmt.Printf("[ClaudeCLI DEBUG] Process started, PID: %d\n", cmd.Process.Pid) + if cmd.Process != nil { + logger.Infof("ClaudeCLI 请求进程已启动:requestId=%s pid=%d", requestLog.id, cmd.Process.Pid) + } // 前端已有 loading 动画,无需在 content 中注入"正在思考" @@ -142,11 +171,9 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, continue } - fmt.Printf("[ClaudeCLI DEBUG] Line: %s\n", line[:min(len(line), 200)]) - var event cliStreamEvent if err := json.Unmarshal([]byte(line), &event); err != nil { - fmt.Printf("[ClaudeCLI DEBUG] Non-JSON line: %s\n", line) + logger.Warnf("ClaudeCLI 忽略非 JSON 输出:requestId=%s line=%s", requestLog.id, RedactAIUpstreamLogText(line)) continue } @@ -155,6 +182,7 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, if isClaudeCLISystemRetryEvent(event) { if errMsg, hasError := extractClaudeCLISystemRetryError(event); hasError { callback(ai.StreamChunk{Error: errMsg, Done: true}) + requestErr = fmt.Errorf("claude CLI 鉴权失败: %s", errMsg) if cmd.Process != nil { _ = cmd.Process.Kill() } @@ -165,6 +193,7 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, case "assistant": if errMsg, hasError := extractClaudeCLIEventError(event); hasError { callback(ai.StreamChunk{Error: errMsg, Done: true}) + requestErr = fmt.Errorf("claude CLI 返回错误: %s", errMsg) _ = cmd.Wait() return nil } @@ -188,6 +217,7 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, case "result": if errMsg, hasError := extractClaudeCLIEventError(event); hasError { callback(ai.StreamChunk{Error: errMsg, Done: true}) + requestErr = fmt.Errorf("claude CLI 返回错误: %s", errMsg) _ = cmd.Wait() return nil } @@ -198,6 +228,7 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, case "error": errMsg, _ := extractClaudeCLIEventError(event) callback(ai.StreamChunk{Error: errMsg, Done: true}) + requestErr = fmt.Errorf("claude CLI 返回错误: %s", errMsg) _ = cmd.Wait() return nil } @@ -205,11 +236,11 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, waitErr := cmd.Wait() stderrStr := strings.TrimSpace(stderrBuf.String()) - fmt.Printf("[ClaudeCLI DEBUG] Process exited. stderr: %s\n", stderrStr) if isClaudeCLITimeout(ctx, waitErr) { + requestErr = fmt.Errorf("claude CLI 执行超时(%s),当前 Base URL 或 API Key 可能没有返回有效响应", claudeCLIRequestTimeout) callback(ai.StreamChunk{ - Error: fmt.Sprintf("claude CLI 执行超时(%s),当前 Base URL 或 API Key 可能没有返回有效响应", claudeCLIRequestTimeout), + Error: requestErr.Error(), Done: true, }) return nil @@ -220,6 +251,7 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, if stderrStr != "" { errMsg = fmt.Sprintf("claude CLI 异常退出: %s", stderrStr) } + requestErr = fmt.Errorf("%s", errMsg) callback(ai.StreamChunk{Error: errMsg, Done: true}) return nil } @@ -242,6 +274,51 @@ func isClaudeCLITimeout(ctx context.Context, err error) bool { return errors.Is(ctx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) } +func claudeCLIEndpointForLog(config ai.ProviderConfig) string { + baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/") + if baseURL != "" { + return sanitizeAIUpstreamURL(baseURL) + } + return "claude://cli" +} + +func buildClaudeCLIRequestLogBody(outputFormat string, args []string, prompt string, config ai.ProviderConfig, req ai.ChatRequest) map[string]any { + return map[string]any{ + "command": "claude", + "args": claudeCLIArgsForLog(args), + "prompt": prompt, + "output_format": outputFormat, + "model": strings.TrimSpace(config.Model), + "base_url": claudeCLIEndpointForLog(config), + "has_api_key": strings.TrimSpace(config.APIKey) != "", + "message_count": len(req.Messages), + "tool_count": len(req.Tools), + "tool_names": claudeCLIToolNamesForLog(req.Tools), + } +} + +func claudeCLIArgsForLog(args []string) []string { + result := append([]string(nil), args...) + for i := 0; i < len(result)-1; i++ { + if result[i] == "-p" { + result[i+1] = "[prompt logged separately]" + i++ + } + } + return result +} + +func claudeCLIToolNamesForLog(tools []ai.Tool) []string { + names := make([]string, 0, len(tools)) + for _, tool := range tools { + name := strings.TrimSpace(tool.Function.Name) + if name != "" { + names = append(names, name) + } + } + return names +} + // setEnv 设置 Claude CLI 的环境变量 func (p *ClaudeCLIProvider) setEnv(cmd *exec.Cmd) error { env, err := buildClaudeCLIEnv(p.config, cmd.Environ(), runtime.GOOS, claudeLookPath, fileExists) diff --git a/internal/ai/provider/claude_cli_test.go b/internal/ai/provider/claude_cli_test.go index 8929841..0e4dd25 100644 --- a/internal/ai/provider/claude_cli_test.go +++ b/internal/ai/provider/claude_cli_test.go @@ -38,6 +38,60 @@ func TestBuildClaudeCLIEnv_IncludesAnthropicProxyEnv(t *testing.T) { } } +func TestBuildClaudeCLIRequestLogBodyRedactsSecretsAndKeepsRequestShape(t *testing.T) { + prompt := "请分析订单表。临时凭证 Bearer abcdefghijklmnopqrstuvwxyz,另一个 key 是 sk-live-abcdefghijklmnopqrstuvwxyz" + args := []string{"-p", prompt, "--output-format", "stream-json", "--model", "claude-sonnet"} + originalPromptArg := args[1] + + body := buildClaudeCLIRequestLogBody("stream-json", args, prompt, ai.ProviderConfig{ + BaseURL: "https://proxy.example.com/api/anthropic?key=proxy-secret&alt=sse", + APIKey: "sk-config-secret-1234567890", + Model: "claude-sonnet", + }, ai.ChatRequest{ + Messages: []ai.Message{{Role: "user", Content: prompt}}, + Tools: []ai.Tool{{ + Type: "function", + Function: ai.ToolFunction{ + Name: "inspect_ai_upstream_logs", + Description: "读取 AI 上游请求日志", + }, + }}, + }) + + got := formatAIUpstreamRequestLogBody(body) + + for _, want := range []string{ + `"command":"claude"`, + `"output_format":"stream-json"`, + `"model":"claude-sonnet"`, + `"has_api_key":true`, + `"message_count":1`, + `"tool_count":1`, + `"[prompt logged separately]"`, + `inspect_ai_upstream_logs`, + `key=%5BREDACTED%5D`, + `[REDACTED]`, + } { + if !strings.Contains(got, want) { + t.Fatalf("expected Claude CLI request log body to contain %q, got %s", want, got) + } + } + + for _, leaked := range []string{ + "proxy-secret", + "sk-config-secret", + "Bearer abcdefghijklmnopqrstuvwxyz", + "sk-live-abcdefghijklmnopqrstuvwxyz", + } { + if strings.Contains(got, leaked) { + t.Fatalf("Claude CLI request log body leaked %q: %s", leaked, got) + } + } + if args[1] != originalPromptArg { + t.Fatalf("expected original args to remain unchanged, got %q", args[1]) + } +} + func TestBuildClaudeCLIEnv_UsesDetectedGitBashOnWindows(t *testing.T) { env, err := buildClaudeCLIEnv(ai.ProviderConfig{}, []string{"ProgramFiles=C:\\Program Files"}, "windows", func(name string) (string, error) { switch name { diff --git a/internal/ai/provider/request_log.go b/internal/ai/provider/request_log.go index 2a6de9b..dc4c3b1 100644 --- a/internal/ai/provider/request_log.go +++ b/internal/ai/provider/request_log.go @@ -75,12 +75,22 @@ func logAIUpstreamRequestFinish(handle aiUpstreamRequestLogHandle, statusCode in ) return } + if statusCode > 0 { + logger.Infof( + "AI 上游请求完成:requestId=%s provider=%s endpoint=%s status=%d duration=%s", + handle.id, + handle.provider, + handle.endpoint, + statusCode, + duration, + ) + return + } logger.Infof( - "AI 上游请求完成:requestId=%s provider=%s endpoint=%s status=%d duration=%s", + "AI 上游请求完成:requestId=%s provider=%s endpoint=%s duration=%s", handle.id, handle.provider, handle.endpoint, - statusCode, duration, ) }