feat: Web UI 实时流式输出 + 自动打开浏览器 + 完成提示

- 新增 Web UI 实时输出(SSE)
  - 随机端口分配,避免端口冲突
  - 单面板显示,按后端类型分色(Codex 绿/Gemini 紫/Claude 橙)
  - 短生命周期,随任务结束而关闭
- 新增自动打开浏览器功能(跨平台:macOS/Linux/Windows)
- 新增完成提示(✓ 完成 + 浏览器通知)
- 新增内容类型显示
  - 💭 思考过程(reasoning - 灰色斜体)
  - 💻 命令执行(command - 黄色代码块)
  - 📝 最终回复(message - 白色)
- 修复 Codex turn.completed 事件捕获
- 扩展 stderr 噪音过滤到所有后端
- 新增 /ccg:enhance 命令(Prompt 增强)

修改文件:
- codeagent-wrapper/server.go: Web UI 核心实现
- codeagent-wrapper/executor.go: WebServer 集成
- codeagent-wrapper/parser.go: turn.completed 支持 + 内容类型
- codeagent-wrapper/filter.go: 全后端噪音过滤
- templates/commands/enhance.md: 新增 Prompt 增强命令
- README.md: 更新功能列表(15 个命令)
- package.json: v1.7.3

版本: v1.7.3
This commit is contained in:
li committed 2026-01-09 04:27:10 +08:00
1 parent 11a5e14cd6
commit 322a4d329e
9 files changed
+637 -33

No files matched your search

+40 -10
View File
@@ -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:
+6 -2
View File
@@ -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
+6 -6
View File
@@ -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 "))
+3
View File
@@ -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
}
+49 -8
View File
@@ -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 != "" {
+488
View File
@@ -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(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s - Live Output</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0d1117;
color: #c9d1d9;
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
background: #161b22;
padding: 12px 20px;
border-bottom: 1px solid #30363d;
display: flex;
align-items: center;
gap: 12px;
}
.panel-icon {
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 11px;
background: %s;
color: #fff;
}
.title { font-size: 18px; font-weight: 600; color: %s; }
.live-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
color: #3fb950;
font-size: 12px;
margin-left: auto;
}
.live-dot {
width: 8px;
height: 8px;
border-radius: 50%%;
background: #3fb950;
animation: blink 1s infinite;
}
@keyframes blink {
0%%, 100%% { opacity: 1; }
50%% { opacity: 0.3; }
}
.output-area {
flex: 1;
background: #0d1117;
padding: 16px 20px;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 13px;
white-space: pre-wrap;
word-break: break-word;
overflow-y: auto;
line-height: 1.6;
}
.cursor {
display: inline-block;
width: 8px;
height: 16px;
background: #3fb950;
animation: cursor-blink 1s infinite;
vertical-align: text-bottom;
}
@keyframes cursor-blink {
0%%, 50%% { opacity: 1; }
51%%, 100%% { opacity: 0; }
}
.done-indicator {
color: #8b949e;
font-style: italic;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #30363d;
}
</style>
</head>
<body>
<header>
<div class="panel-icon">%s</div>
<div class="title">%s</div>
<div class="live-indicator" id="liveIndicator">
<span class="live-dot"></span> LIVE
</div>
</header>
<div class="output-area" id="output"></div>
<script>
const output = document.getElementById('output');
const liveIndicator = document.getElementById('liveIndicator');
let connected = false;
async function connectToStream() {
try {
const res = await fetch('/api/sessions');
const sessions = await res.json();
if (sessions.length === 0) {
setTimeout(connectToStream, 500);
return;
}
const session = sessions[0];
if (connected) return;
connected = true;
// Display user task/prompt first
if (session.task) {
const taskEl = document.createElement('div');
taskEl.style.cssText = 'color: #58a6ff; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #30363d;';
taskEl.innerHTML = '<strong>📋 Task:</strong><br>' + session.task.replace(/\n/g, '<br>');
output.appendChild(taskEl);
}
const es = new EventSource('/api/stream/' + session.id);
es.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.content) {
const cursor = output.querySelector('.cursor');
if (cursor) cursor.remove();
// Create content element with appropriate styling
const contentEl = document.createElement('span');
const contentType = data.content_type || 'message';
switch (contentType) {
case 'reasoning':
contentEl.style.cssText = 'color: #8b949e; font-style: italic;';
contentEl.textContent = '💭 ' + data.content;
break;
case 'command':
contentEl.style.cssText = 'color: #fbbf24; background: #1e1e1e; padding: 8px; margin: 8px 0; display: block; border-left: 3px solid #d97706; font-family: monospace;';
contentEl.textContent = data.content;
break;
case 'message':
default:
contentEl.style.cssText = 'color: #c9d1d9;';
contentEl.textContent = data.content;
break;
}
output.appendChild(contentEl);
const cursorEl = document.createElement('span');
cursorEl.className = 'cursor';
output.appendChild(cursorEl);
// Auto-scroll to bottom
requestAnimationFrame(() => {
output.scrollTop = output.scrollHeight;
});
}
if (data.done) {
const cursor = output.querySelector('.cursor');
if (cursor) cursor.remove();
liveIndicator.style.display = 'none';
const doneEl = document.createElement('div');
doneEl.className = 'done-indicator';
doneEl.textContent = '✓ 完成';
output.appendChild(doneEl);
// Auto-scroll to bottom
requestAnimationFrame(() => {
output.scrollTop = output.scrollHeight;
});
es.close();
// Browser notification
if (Notification.permission === 'granted') {
new Notification('任务完成', { body: '代码生成已完成' });
}
}
};
es.onerror = () => {
liveIndicator.style.display = 'none';
};
} catch (e) {
setTimeout(connectToStream, 500);
}
}
connectToStream();
</script>
</body>
</html>`, 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
}
}
}