feat: implement streaming chat, skill routing, and SAFe PI planning tools

- Add /api/chat/stream endpoint with Server-Sent Events (SSE) for real-time message streaming
  * Implement StreamEvent types (thought, tool_call, tool_result, final, error)
  * Add StreamEventCallback mechanism for event propagation
  * Create StreamChatHandler in webui/bot with proper HTTP headers and flushing

- Implement LLM-based skill router for intelligent capability selection
  * Add optional routerLLM client for semantic routing
  * Implement routeSkillsWithLLM() to match user intent to available skills
  * Add matchSkillsByName() for fuzzy skill matching
  * Update buildUnifiedSystemPrompt() to use routed skills

- Add streaming support to ReAct pipeline
  * Implement runUnifiedReActStream() for streaming thought/action/observation
  * Emit StreamEvent at each ReAct step
  * Support callback error handling in streaming mode

- Integrate three new DevOps tools
  * tools/filedoc: Extract document content from file_id via OpenAI
  * tools/giteaticket: Create Gitea issues from PI plan items with SAFe metadata
  * tools/piplan: Publish PI planning blueprints with dependency tracking

- Add SAFe PI Planning skill
  * Implement PM/SA/RTE (iron triangle) workflow
  * Support for Feature, Enabler, and Dependency definition
  * Automatic task decomposition and Gitea integration

- Create frontend integration documentation
  * Complete SSE protocol specification
  * TypeScript fetch + ReadableStream example
  * LLM-ready refactoring template for other projects

- Simplify file handling
  * Remove legacy file context structures and dual-mode processing
  * Consolidate file operations into UploadAndCacheFiles()
  * Remove FilePromptMode configuration and related complexity

- Update configuration
  * Add Router model support (LLM_ROUTER_MODEL)
  * Add Gitea configuration (BaseURL, Token, Owner, Repo)
  * WebSearch and additional tool infrastructure

Tests: All 22 test packages passing, 8/8 webui tests including 3 new stream tests
This commit is contained in:
2026-03-11 17:58:19 +08:00
parent 0e1a800646
commit 8dc5354fa4
17 changed files with 3086 additions and 565 deletions

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
@@ -51,66 +52,6 @@ func TestHandleChatSuccess(t *testing.T) {
}
}
func TestHandleChatWithFileIDs(t *testing.T) {
b := newTestBot(t, 1024*1024)
b.chatHandler = func(_ context.Context, msg IncomingMessage) (string, error) {
if msg.ChatID != "s1" || msg.UserID != "u1" || msg.Text != "hello" {
t.Fatalf("unexpected message: %+v", msg)
}
if len(msg.FileIDs) != 2 || msg.FileIDs[0] != "file_a" || msg.FileIDs[1] != "file_b" {
t.Fatalf("unexpected file ids: %+v", msg.FileIDs)
}
return "ok", nil
}
body := strings.NewReader(`{"text":"hello","session_id":"s1","user_id":"u1","file_ids":["file_a","file_b"]}`)
req := httptest.NewRequest(http.MethodPost, "/api/chat", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
b.handleChat(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
}
func TestHandleChatWithFileIDsAliases(t *testing.T) {
tests := []struct {
name string
body string
}{
{name: "camel array", body: `{"text":"hello","sessionId":"s1","userId":"u1","fileIds":["file_a","file_b"]}`},
{name: "flat array", body: `{"text":"hello","session_id":"s1","user_id":"u1","fileids":["file_a","file_b"]}`},
{name: "single key", body: `{"text":"hello","session_id":"s1","user_id":"u1","file_id":"file_a"}`},
{name: "csv string", body: `{"text":"hello","session_id":"s1","user_id":"u1","file_ids":"file_a, file_b"}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := newTestBot(t, 1024*1024)
b.chatHandler = func(_ context.Context, msg IncomingMessage) (string, error) {
if msg.ChatID != "s1" || msg.UserID != "u1" || msg.Text != "hello" {
t.Fatalf("unexpected message: %+v", msg)
}
if len(msg.FileIDs) == 0 {
t.Fatalf("expected file ids from alias payload, got empty")
}
return "ok", nil
}
body := strings.NewReader(tt.body)
req := httptest.NewRequest(http.MethodPost, "/api/chat", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
b.handleChat(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
})
}
}
func TestHandleChatMissingText(t *testing.T) {
b := newTestBot(t, 1024*1024)
b.chatHandler = func(_ context.Context, _ IncomingMessage) (string, error) { return "", nil }
@@ -215,3 +156,149 @@ func TestHandleUploadMissingFile(t *testing.T) {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestHandleChatStreamSuccess(t *testing.T) {
b := newTestBot(t, 1024*1024)
b.streamChatHandler = func(_ context.Context, msg IncomingMessage, cb StreamEventCallback) (string, error) {
if msg.ChatID != "s1" || msg.UserID != "u1" || msg.Text != "hello" {
t.Fatalf("unexpected message: %+v", msg)
}
if err := cb(StreamEvent{Type: StreamEventTypeThought, Content: "thinking", Step: 1}); err != nil {
return "", err
}
if err := cb(StreamEvent{Type: StreamEventTypeToolCall, Content: "{\"input\":\"pwd\"}", Step: 1, ToolName: "shell"}); err != nil {
return "", err
}
if err := cb(StreamEvent{Type: StreamEventTypeToolResult, Content: "C:/Project", Step: 1, ToolName: "shell"}); err != nil {
return "", err
}
if err := cb(StreamEvent{Type: StreamEventTypeFinal, Content: "done", Step: 2}); err != nil {
return "", err
}
return "done", nil
}
body := strings.NewReader(`{"text":"hello","session_id":"s1","user_id":"u1"}`)
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
b.handleChatStream(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
if got := w.Header().Get("Content-Type"); got != "text/event-stream" {
t.Fatalf("expected text/event-stream, got %q", got)
}
var events []StreamEvent
chunks := strings.Split(strings.TrimSpace(w.Body.String()), "\n\n")
for _, chunk := range chunks {
line := strings.TrimSpace(chunk)
if line == "" {
continue
}
if !strings.HasPrefix(line, "data: ") {
t.Fatalf("invalid sse line: %q", line)
}
payload := strings.TrimPrefix(line, "data: ")
var ev StreamEvent
if err := json.Unmarshal([]byte(payload), &ev); err != nil {
t.Fatalf("unmarshal stream event failed: %v payload=%s", err, payload)
}
events = append(events, ev)
}
if len(events) != 4 {
t.Fatalf("expected 4 events, got %d", len(events))
}
if events[0].Type != StreamEventTypeThought {
t.Fatalf("event[0] type mismatch: %s", events[0].Type)
}
if events[1].Type != StreamEventTypeToolCall || events[1].ToolName != "shell" {
t.Fatalf("event[1] mismatch: %+v", events[1])
}
if events[2].Type != StreamEventTypeToolResult || events[2].ToolName != "shell" {
t.Fatalf("event[2] mismatch: %+v", events[2])
}
if events[3].Type != StreamEventTypeFinal || events[3].Content != "done" {
t.Fatalf("event[3] mismatch: %+v", events[3])
}
}
func TestHandleChatStreamHandlerError(t *testing.T) {
b := newTestBot(t, 1024*1024)
b.streamChatHandler = func(_ context.Context, _ IncomingMessage, _ StreamEventCallback) (string, error) {
return "", errors.New("boom")
}
body := strings.NewReader(`{"text":"hello"}`)
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
b.handleChatStream(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
respBody := w.Body.String()
if !strings.Contains(respBody, `"type":"error"`) {
t.Fatalf("expected error event in stream, body=%q", respBody)
}
if !strings.Contains(respBody, "stream error: boom") {
t.Fatalf("expected error detail in stream, body=%q", respBody)
}
}
func TestHandleChatStreamValidation(t *testing.T) {
t.Run("method not allowed", func(t *testing.T) {
b := newTestBot(t, 1024*1024)
req := httptest.NewRequest(http.MethodGet, "/api/chat/stream", nil)
w := httptest.NewRecorder()
b.handleChatStream(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected 405, got %d", w.Code)
}
})
t.Run("content type must be json", func(t *testing.T) {
b := newTestBot(t, 1024*1024)
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", strings.NewReader(`{"text":"hello"}`))
w := httptest.NewRecorder()
b.handleChatStream(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
})
t.Run("handler not ready", func(t *testing.T) {
b := newTestBot(t, 1024*1024)
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", strings.NewReader(`{"text":"hello"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
b.handleChatStream(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", w.Code)
}
})
t.Run("text required", func(t *testing.T) {
b := newTestBot(t, 1024*1024)
b.streamChatHandler = func(_ context.Context, _ IncomingMessage, _ StreamEventCallback) (string, error) {
return "", nil
}
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", strings.NewReader(`{"text":" "}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
b.handleChatStream(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
})
}