diff --git a/README.md b/README.md index 6d74a0a..559f9b9 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ - ✅ **多模型并行** - Codex ∥ Gemini 同时分析,交叉验证减少错误 - ✅ **零写入权限** - 外部模型只返回 Patch,Claude 保持代码主权 - ✅ **Token 优化** - ROLE_FILE 动态注入,专家提示词零消耗 -- ✅ **一键安装** - npx 运行,自动安装全部 14 个命令 +- ✅ **Web UI 实时输出** - 自动打开浏览器,流式显示思考过程、命令执行、生成结果 +- ✅ **一键安装** - npx 运行,自动安装全部 15 个命令 --- @@ -57,7 +58,7 @@ npx ccg-workflow **安装流程**: 1. 选择是否配置 ace-tool MCP(可跳过) 2. 确认安装 -3. 自动安装全部 14 个命令 +3. 自动安装全部 15 个命令 4. 配置 PATH(如需要) 就这么简单!无需选择语言、模型、命令预设。 @@ -69,7 +70,7 @@ npx ccg-workflow | 前端模型 | **Gemini** | | 后端模型 | **Codex** | | 协作模式 | **smart** | -| 命令数量 | **14 个**(全部安装) | +| 命令数量 | **15 个**(全部安装) | ### 第一个命令 @@ -99,7 +100,7 @@ npx ccg-workflow /ccg:backend # 纯后端任务(Codex 主导,更快) ``` -### 完整命令列表(14 个) +### 完整命令列表(15 个) #### 开发工作流 @@ -109,6 +110,7 @@ npx ccg-workflow | `/ccg:frontend` | 前端专项(快速模式) | Gemini | | `/ccg:backend` | 后端专项(快速模式) | Codex | | `/ccg:feat` | 智能功能开发 | 规划 → 实施 | +| `/ccg:enhance` | Prompt 增强(ace-tool) | MCP | | `/ccg:analyze` | 技术分析(仅分析不改代码) | Codex ∥ Gemini | | `/ccg:debug` | 问题诊断 + 修复 | Codex ∥ Gemini | | `/ccg:optimize` | 性能优化 | Codex ∥ Gemini | @@ -166,7 +168,7 @@ npx ccg-workflow ``` ~/.claude/ -├── commands/ccg/ # 14 个斜杠命令 +├── commands/ccg/ # 15 个斜杠命令 ├── agents/ccg/ # 4 个子智能体 ├── skills/ # 暂无 ├── bin/ @@ -350,7 +352,7 @@ Copyright (c) 2025 fengshao1227
-**版本**: v1.7.0 | **最后更新**: 2026-01-08 +**版本**: v1.7.3 | **最后更新**: 2026-01-09 Made with ❤️ by the CCG Community diff --git a/codeagent-wrapper/executor.go b/codeagent-wrapper/executor.go index 8062f8b..906bb70 100644 --- a/codeagent-wrapper/executor.go +++ b/codeagent-wrapper/executor.go @@ -2,6 +2,8 @@ package main import ( "context" + "crypto/rand" + "encoding/hex" "errors" "fmt" "io" @@ -832,6 +834,23 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe codexArgs = argsBuilder(cfg, targetArg) } + // Start WebServer for this task (single-panel, random port, short-lived) + var webSessionID string + if globalWebServer == nil { + globalWebServer = NewWebServer(cfg.Backend) + if err := globalWebServer.Start(); err != nil { + logWarn(fmt.Sprintf("Failed to start web server: %v", err)) + } + } + + // Generate a unique session ID for WebServer tracking + if globalWebServer != nil { + randBytes := make([]byte, 4) + rand.Read(randBytes) + webSessionID = fmt.Sprintf("%s-%d-%s", cfg.Backend, time.Now().UnixMilli(), hex.EncodeToString(randBytes)) + globalWebServer.StartSession(webSessionID, cfg.Backend, taskSpec.Task) + } + prefixMsg := func(msg string) string { if taskSpec.ID == "" { return msg @@ -935,16 +954,12 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe stderrWriters = append(stderrWriters, stderrLogger) } - // For gemini backend, filter noisy stderr output + // Filter noisy stderr output for all backends var stderrFilter *filteringWriter if !silent { - stderrOut := io.Writer(os.Stderr) - if cfg.Backend == "gemini" { - stderrFilter = newFilteringWriter(os.Stderr, geminiNoisePatterns) - stderrOut = stderrFilter - defer stderrFilter.Flush() - } - stderrWriters = append([]io.Writer{stderrOut}, stderrWriters...) + stderrFilter = newFilteringWriter(os.Stderr, noisePatterns) + defer stderrFilter.Flush() + stderrWriters = append([]io.Writer{stderrFilter}, stderrWriters...) } if len(stderrWriters) == 1 { cmd.SetStderr(stderrWriters[0]) @@ -982,8 +997,19 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe messageSeen := make(chan struct{}, 1) completeSeen := make(chan struct{}, 1) parseCh := make(chan parseResult, 1) + + // Create onContent callback for streaming to WebServer + var onContentCallback func(content, contentType string) + if globalWebServer != nil && webSessionID != "" { + sessionID := webSessionID + backendName := cfg.Backend + onContentCallback = func(content, contentType string) { + globalWebServer.SendContentWithType(sessionID, backendName, content, contentType) + } + } + go func() { - msg, tid := parseJSONStreamInternal(stdoutReader, logWarnFn, logInfoFn, func() { + msg, tid := parseJSONStreamInternalWithContent(stdoutReader, logWarnFn, logInfoFn, func() { select { case messageSeen <- struct{}{}: default: @@ -993,7 +1019,11 @@ func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe case completeSeen <- struct{}{}: default: } - }) + // Notify WebServer that session is complete + if globalWebServer != nil && webSessionID != "" { + globalWebServer.EndSession(webSessionID, cfg.Backend) + } + }, onContentCallback) select { case completeSeen <- struct{}{}: default: diff --git a/codeagent-wrapper/filter.go b/codeagent-wrapper/filter.go index ab5c522..7f065b0 100644 --- a/codeagent-wrapper/filter.go +++ b/codeagent-wrapper/filter.go @@ -6,8 +6,9 @@ import ( "strings" ) -// geminiNoisePatterns contains stderr patterns to filter for gemini backend -var geminiNoisePatterns = []string{ +// noisePatterns contains stderr patterns to filter for all backends +var noisePatterns = []string{ + // Gemini specific "[STARTUP]", "Session cleanup disabled", "Warning:", @@ -16,6 +17,9 @@ var geminiNoisePatterns = []string{ "Loaded cached credentials", "Loading extension:", "YOLO mode is enabled", + // Common noise + "[WARN] Skipping unreadable directory", + "supports tool updates. Listening for changes", } // filteringWriter wraps an io.Writer and filters out lines matching patterns diff --git a/codeagent-wrapper/filter_test.go b/codeagent-wrapper/filter_test.go index 12042f8..0881ff8 100644 --- a/codeagent-wrapper/filter_test.go +++ b/codeagent-wrapper/filter_test.go @@ -14,31 +14,31 @@ func TestFilteringWriter(t *testing.T) { }{ { name: "filter STARTUP lines", - patterns: geminiNoisePatterns, + patterns: noisePatterns, input: "[STARTUP] Recording metric\nHello World\n[STARTUP] Another line\n", want: "Hello World\n", }, { name: "filter Warning lines", - patterns: geminiNoisePatterns, + patterns: noisePatterns, input: "Warning: something bad\nActual output\n", want: "Actual output\n", }, { name: "filter multiple patterns", - patterns: geminiNoisePatterns, + patterns: noisePatterns, input: "YOLO mode is enabled\nSession cleanup disabled\nReal content\nLoading extension: foo\n", want: "Real content\n", }, { name: "no filtering needed", - patterns: geminiNoisePatterns, + patterns: noisePatterns, input: "Line 1\nLine 2\nLine 3\n", want: "Line 1\nLine 2\nLine 3\n", }, { name: "empty input", - patterns: geminiNoisePatterns, + patterns: noisePatterns, input: "", want: "", }, @@ -60,7 +60,7 @@ func TestFilteringWriter(t *testing.T) { func TestFilteringWriterPartialLines(t *testing.T) { var buf bytes.Buffer - fw := newFilteringWriter(&buf, geminiNoisePatterns) + fw := newFilteringWriter(&buf, noisePatterns) // Write partial line fw.Write([]byte("Hello ")) diff --git a/codeagent-wrapper/main.go b/codeagent-wrapper/main.go index 5ad2107..53e3da3 100644 --- a/codeagent-wrapper/main.go +++ b/codeagent-wrapper/main.go @@ -56,6 +56,9 @@ var ( var forceKillDelay atomic.Int32 +// globalWebServer is the SSE web server for live streaming output +var globalWebServer *WebServer + func init() { forceKillDelay.Store(5) // seconds - default value } diff --git a/codeagent-wrapper/parser.go b/codeagent-wrapper/parser.go index 7f6e11a..02b30f1 100644 --- a/codeagent-wrapper/parser.go +++ b/codeagent-wrapper/parser.go @@ -96,6 +96,10 @@ type ItemContent struct { } func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(string), onMessage func(), onComplete func()) (message, threadID string) { + return parseJSONStreamInternalWithContent(r, warnFn, infoFn, onMessage, onComplete, nil) +} + +func parseJSONStreamInternalWithContent(r io.Reader, warnFn func(string), infoFn func(string), onMessage func(), onComplete func(), onContent func(content, contentType string)) (message, threadID string) { reader := bufio.NewReaderSize(r, jsonLineReaderSize) if warnFn == nil { @@ -159,7 +163,7 @@ func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(strin } // Detect backend type by field presence - isCodex := event.ThreadID != "" + isCodex := event.ThreadID != "" || event.Type == "turn.completed" || event.Type == "turn.started" if !isCodex && len(event.Item) > 0 { var itemHeader struct { Type string `json:"type"` @@ -193,11 +197,11 @@ func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(strin threadID = event.ThreadID infoFn(fmt.Sprintf("thread.started event thread_id=%s", threadID)) - case "thread.completed": + case "thread.completed", "turn.completed": if event.ThreadID != "" && threadID == "" { threadID = event.ThreadID } - infoFn(fmt.Sprintf("thread.completed event thread_id=%s", event.ThreadID)) + infoFn(fmt.Sprintf("%s event thread_id=%s", event.Type, event.ThreadID)) notifyComplete() case "item.completed": @@ -211,20 +215,49 @@ func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(strin } } - if itemType == "agent_message" && len(event.Item) > 0 { - // Lazy parse: only parse item content when needed + switch itemType { + case "agent_message", "reasoning": + // Parse text content var item ItemContent if err := json.Unmarshal(event.Item, &item); err == nil { normalized := normalizeText(item.Text) infoFn(fmt.Sprintf("item.completed event item_type=%s message_len=%d", itemType, len(normalized))) if normalized != "" { - codexMessage = normalized - notifyMessage() + if itemType == "agent_message" { + codexMessage = normalized + notifyMessage() + } + // Send content (Codex outputs complete blocks, not streaming deltas) + if onContent != nil { + onContent(normalized, itemType) + } } } else { warnFn(fmt.Sprintf("Failed to parse item content: %s", err.Error())) } - } else { + + case "command_execution": + // Parse command execution + var cmdItem struct { + Command string `json:"command"` + AggregatedOutput string `json:"aggregated_output"` + ExitCode *int `json:"exit_code"` + Status string `json:"status"` + } + if err := json.Unmarshal(event.Item, &cmdItem); err == nil { + // Format command execution info + var content strings.Builder + content.WriteString(fmt.Sprintf("$ %s\n", cmdItem.Command)) + if cmdItem.AggregatedOutput != "" { + content.WriteString(cmdItem.AggregatedOutput) + } + infoFn(fmt.Sprintf("item.completed event item_type=command_execution cmd=%s exit=%v", cmdItem.Command, cmdItem.ExitCode)) + if onContent != nil && content.Len() > 0 { + onContent(content.String(), "command") + } + } + + default: infoFn(fmt.Sprintf("item.completed event item_type=%s", itemType)) } } @@ -242,6 +275,10 @@ func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(strin if event.Result != "" { claudeMessage = event.Result notifyMessage() + // Stream content to callback + if onContent != nil { + onContent(event.Result, "message") + } } if event.Type == "result" { @@ -258,6 +295,10 @@ func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(strin if event.Content != "" { geminiBuffer.WriteString(event.Content) + // Stream content to callback + if onContent != nil { + onContent(event.Content, "message") + } } if event.Status != "" { diff --git a/codeagent-wrapper/server.go b/codeagent-wrapper/server.go new file mode 100644 index 0000000..7666adf --- /dev/null +++ b/codeagent-wrapper/server.go @@ -0,0 +1,488 @@ +package main + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "runtime" + "sync" + "time" +) + +// WebServer manages SSE connections for real-time output streaming +type WebServer struct { + mu sync.RWMutex + clients map[string][]chan ContentEvent + sessions map[string]*SessionState + server *http.Server + port int + backend string // Current backend name for single-panel display +} + +// SessionState tracks a running session +type SessionState struct { + ID string `json:"id"` + Backend string `json:"backend"` + Task string `json:"task"` + StartTime time.Time `json:"start_time"` + Content string `json:"content"` + Done bool `json:"done"` +} + +// ContentEvent is sent to SSE clients +type ContentEvent struct { + SessionID string `json:"session_id"` + Backend string `json:"backend"` + Content string `json:"content,omitempty"` + ContentType string `json:"content_type,omitempty"` // "reasoning", "command", "message" + Done bool `json:"done,omitempty"` +} + +// NewWebServer creates a new web server +func NewWebServer(backend string) *WebServer { + return &WebServer{ + clients: make(map[string][]chan ContentEvent), + sessions: make(map[string]*SessionState), + backend: backend, + } +} + +// Start starts the web server on a random available port +func (ws *WebServer) Start() error { + mux := http.NewServeMux() + + // Serve the main page + mux.HandleFunc("/", ws.handleIndex) + + // API endpoints + mux.HandleFunc("/api/sessions", ws.handleSessions) + mux.HandleFunc("/api/stream/", ws.handleStream) + + // Listen on port 0 to get a random available port + listener, err := net.Listen("tcp", ":0") + if err != nil { + return err + } + + ws.port = listener.Addr().(*net.TCPAddr).Port + + ws.server = &http.Server{ + Handler: mux, + } + + url := fmt.Sprintf("http://localhost:%d", ws.port) + fmt.Fprintf(os.Stderr, " Web UI: %s\n", url) + + go func() { + if err := ws.server.Serve(listener); err != nil && err != http.ErrServerClosed { + logWarn(fmt.Sprintf("Web server error: %v", err)) + } + }() + + // Auto-open browser + go openBrowser(url) + + return nil +} + +// openBrowser opens the specified URL in the default browser +func openBrowser(url string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + default: + return + } + _ = cmd.Start() +} + +// StartSession starts tracking a new session +func (ws *WebServer) StartSession(id, backend, task string) { + ws.mu.Lock() + defer ws.mu.Unlock() + + ws.sessions[id] = &SessionState{ + ID: id, + Backend: backend, + Task: task, + StartTime: time.Now(), + } +} + +// SendContent sends content to all subscribers of a session +func (ws *WebServer) SendContent(sessionID, backend, content string) { + ws.SendContentWithType(sessionID, backend, content, "message") +} + +// SendContentWithType sends content with a specific type to all subscribers +func (ws *WebServer) SendContentWithType(sessionID, backend, content, contentType string) { + ws.mu.Lock() + defer ws.mu.Unlock() + + // Update session state + if session, ok := ws.sessions[sessionID]; ok { + session.Content += content + } + + // Send to all subscribers + event := ContentEvent{ + SessionID: sessionID, + Backend: backend, + Content: content, + ContentType: contentType, + } + + for _, ch := range ws.clients[sessionID] { + select { + case ch <- event: + default: + // Skip if channel is full + } + } +} + +// EndSession marks a session as complete +func (ws *WebServer) EndSession(sessionID, backend string) { + ws.mu.Lock() + defer ws.mu.Unlock() + + if session, ok := ws.sessions[sessionID]; ok { + session.Done = true + } + + event := ContentEvent{ + SessionID: sessionID, + Backend: backend, + Done: true, + } + + for _, ch := range ws.clients[sessionID] { + select { + case ch <- event: + default: + } + } +} + +// handleIndex serves the main HTML page +func (ws *WebServer) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + // Generate single-panel HTML based on backend + html := ws.generateIndexHTML() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(html)) +} + +// generateIndexHTML creates a single-panel HTML page for the current backend +func (ws *WebServer) generateIndexHTML() string { + backend := ws.backend + if backend == "" { + backend = "Agent" + } + + // Determine colors based on backend + iconBg := "#238636" + titleColor := "#3fb950" + iconText := "AGT" + + switch backend { + case "codex": + iconBg = "#238636" + titleColor = "#3fb950" + iconText = "CDX" + case "gemini": + iconBg = "#8957e5" + titleColor = "#a371f7" + iconText = "GEM" + case "claude": + iconBg = "#d97706" + titleColor = "#fbbf24" + iconText = "CLD" + } + + return fmt.Sprintf(` + + + + + %s - Live Output + + + +
+
%s
+
%s
+
+ LIVE +
+
+
+ + +`, backend, iconBg, titleColor, iconText, backend) +} + +// handleSessions returns all active sessions +func (ws *WebServer) handleSessions(w http.ResponseWriter, r *http.Request) { + ws.mu.RLock() + sessions := make([]*SessionState, 0, len(ws.sessions)) + for _, s := range ws.sessions { + sessions = append(sessions, s) + } + ws.mu.RUnlock() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(sessions) +} + +// handleStream handles SSE connections for a session +func (ws *WebServer) handleStream(w http.ResponseWriter, r *http.Request) { + sessionID := r.URL.Path[len("/api/stream/"):] + if sessionID == "" { + http.Error(w, "Session ID required", http.StatusBadRequest) + return + } + + // Set SSE headers + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "SSE not supported", http.StatusInternalServerError) + return + } + + // Create channel for this client + ch := make(chan ContentEvent, 100) + + // Register client + ws.mu.Lock() + ws.clients[sessionID] = append(ws.clients[sessionID], ch) + // Only send done state if session is already complete (no historical content) + if session, ok := ws.sessions[sessionID]; ok && session.Done { + ch <- ContentEvent{ + SessionID: sessionID, + Backend: session.Backend, + Done: true, + } + } + ws.mu.Unlock() + + // Cleanup on disconnect + defer func() { + ws.mu.Lock() + clients := ws.clients[sessionID] + for i, c := range clients { + if c == ch { + ws.clients[sessionID] = append(clients[:i], clients[i+1:]...) + break + } + } + ws.mu.Unlock() + close(ch) + }() + + // Stream events + for { + select { + case event := <-ch: + data, _ := json.Marshal(event) + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + + if event.Done { + return + } + case <-r.Context().Done(): + return + } + } +} diff --git a/package.json b/package.json index be5a48c..c0f06fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccg-workflow", - "version": "1.7.2", + "version": "1.7.3", "description": "Claude-Codex-Gemini 多模型协作系统 - 智能路由多模型开发工作流", "type": "module", "packageManager": "pnpm@10.17.1", diff --git a/templates/commands/enhance.md b/templates/commands/enhance.md new file mode 100644 index 0000000..613c577 --- /dev/null +++ b/templates/commands/enhance.md @@ -0,0 +1,36 @@ +--- +description: 使用 ace-tool enhance_prompt 优化 Prompt,展示原始与增强版本供确认 +--- + +## Usage +`/ccg:enhance ` + +## Context +- Original prompt: $ARGUMENTS + +## Your Role +You are the **Prompt Enhancer** that optimizes user prompts for better AI task execution. + +## Process + +### Step 1: 调用 Prompt 增强工具 + +调用 `mcp__ace-tool__enhance_prompt` 工具: +- `prompt`: 用户原始输入 +- `conversation_history`: 最近 5-10 轮对话历史 +- `project_root_path`: 当前项目根目录(可选) + +### Step 2: 处理响应 + +根据工具返回的结果: +- **增强后的 prompt**: 展示增强版本,询问用户是否使用 +- **`__END_CONVERSATION__`**: 停止对话,不执行任何任务 +- **工具调用失败**: 直接使用原始 prompt + +### Step 3: 执行任务 + +根据用户选择执行相应操作。 + +## Notes +- 支持自动语言检测(中文输入 → 中文输出) +- 也可通过在消息末尾添加 `-enhance` 或 `-Enhancer` 触发