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

@@ -29,6 +29,7 @@ type Config struct {
LLM LLMConfig
Security SecurityConfig
WebSearch WebSearchConfig
Gitea GiteaConfig
SQLitePath string
}
@@ -52,11 +53,11 @@ type WebUIConfig struct {
}
type LLMConfig struct {
BaseURL string
APIKey string
Model string
FileModel string
FilePromptMode string
BaseURL string
APIKey string
Model string
FileModel string
RouterModel string // 轻量路由模型,用于技能意图路由;为空则仅用关键词匹配
}
type SecurityConfig struct {
@@ -70,6 +71,13 @@ type WebSearchConfig struct {
APIKey string
}
type GiteaConfig struct {
BaseURL string // Gitea 实例地址
Token string // Personal Access Token
Owner string // 仓库所有者
Repo string // 仓库名称
}
func Load() (Config, error) {
agentWorkspaceDir := resolveAgentWorkspaceDir()
if err := preloadEnvFiles(); err != nil {
@@ -106,11 +114,11 @@ func Load() (Config, error) {
MaxUploadBytes: int64(intFromEnv("WEBUI_MAX_UPLOAD_MB", 20)) * 1024 * 1024,
},
LLM: LLMConfig{
BaseURL: strings.TrimRight(defaultIfEmpty(os.Getenv("LLM_BASE_URL"), "https://api.openai.com/v1"), "/"),
APIKey: strings.TrimSpace(os.Getenv("LLM_API_KEY")),
Model: defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini"),
FileModel: defaultIfEmpty(os.Getenv("LLM_FILE_MODEL"), defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini")),
FilePromptMode: normalizeFilePromptMode(defaultIfEmpty(os.Getenv("LLM_FILE_PROMPT_MODE"), "user_content_file_parts")),
BaseURL: strings.TrimRight(defaultIfEmpty(os.Getenv("LLM_BASE_URL"), "https://api.openai.com/v1"), "/"),
APIKey: strings.TrimSpace(os.Getenv("LLM_API_KEY")),
Model: defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini"),
FileModel: defaultIfEmpty(os.Getenv("LLM_FILE_MODEL"), defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini")),
RouterModel: strings.TrimSpace(os.Getenv("LLM_ROUTER_MODEL")),
},
SQLitePath: defaultIfEmpty(os.Getenv("SQLITE_PATH"), filepath.Join(defaultDataDir, "laodingbot.db")),
WebSearch: WebSearchConfig{
@@ -122,6 +130,12 @@ func Load() (Config, error) {
AllowedCommands: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_COMMANDS"), "pwd,ls,cat,echo,grep,find,head,tail,go")),
WorkDir: defaultIfEmpty(os.Getenv("WORK_DIR"), defaultWorkSubdir),
},
Gitea: GiteaConfig{
BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("GITEA_BASE_URL")), "/"),
Token: strings.TrimSpace(os.Getenv("GITEA_TOKEN")),
Owner: strings.TrimSpace(os.Getenv("GITEA_OWNER")),
Repo: strings.TrimSpace(os.Getenv("GITEA_REPO")),
},
}
cfg.MessageChannel = strings.ToLower(strings.TrimSpace(cfg.MessageChannel))
@@ -178,9 +192,6 @@ func Load() (Config, error) {
if cfg.LLM.APIKey == "" {
return Config{}, fmt.Errorf("LLM_API_KEY is required")
}
if cfg.LLM.FilePromptMode != "user_content_file_parts" && cfg.LLM.FilePromptMode != "system_fileid_uri" {
return Config{}, fmt.Errorf("LLM_FILE_PROMPT_MODE must be one of: user_content_file_parts, system_fileid_uri")
}
cfg.SoulPath = resolvePathInWorkspace(cfg.SoulPath, agentWorkspaceDir)
cfg.SkillsDir = resolvePathInWorkspace(cfg.SkillsDir, agentWorkspaceDir)
@@ -417,14 +428,3 @@ func splitCSV(raw string) []string {
}
return out
}
func normalizeFilePromptMode(v string) string {
v = strings.ToLower(strings.TrimSpace(v))
if v == "" {
return "user_content_file_parts"
}
if v == "system_fileid" || v == "system_fileid_url" || v == "system_fileid_uri" {
return "system_fileid_uri"
}
return v
}