Files
LaodingBot/internal/config/config.go
Ding, Shuo 8dc5354fa4 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
2026-03-11 17:58:19 +08:00

431 lines
13 KiB
Go

package config
import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
type Config struct {
MessageChannel string
LogLevel string
SoulPath string
SkillsDir string
ReactMaxSteps int
ToolCallTimeoutSec int
ToolOutputMaxChars int
EnableCapabilityGap bool
AutoSkillDir string
GapDraftTriggerCount int
GapClusterLookbackHours int
Telegram TelegramConfig
Feishu FeishuConfig
WebUI WebUIConfig
LLM LLMConfig
Security SecurityConfig
WebSearch WebSearchConfig
Gitea GiteaConfig
SQLitePath string
}
type TelegramConfig struct {
Token string
PollTimeoutSeconds int
}
type FeishuConfig struct {
AppID string
AppSecret string
VerifyToken string
ListenAddr string
EventPath string
}
type WebUIConfig struct {
ListenAddr string
MaxUploadBytes int64
}
type LLMConfig struct {
BaseURL string
APIKey string
Model string
FileModel string
RouterModel string // 轻量路由模型,用于技能意图路由;为空则仅用关键词匹配
}
type SecurityConfig struct {
AllowedDirs []string
AllowedCommands []string
WorkDir string
}
type WebSearchConfig struct {
Engine string // "duckduckgo" or "brave"
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 {
return Config{}, err
}
defaultWorkSubdir := filepath.Join(agentWorkspaceDir, "workspace")
defaultDataDir := filepath.Join(agentWorkspaceDir, "data")
cfg := Config{
MessageChannel: defaultIfEmpty(os.Getenv("MESSAGE_CHANNEL"), "telegram"),
LogLevel: defaultIfEmpty(os.Getenv("LOG_LEVEL"), "info"),
SoulPath: defaultIfEmpty(os.Getenv("SOUL_PATH"), filepath.Join(agentWorkspaceDir, "bot_context", "soul.md")),
SkillsDir: defaultIfEmpty(os.Getenv("SKILLS_DIR"), filepath.Join(agentWorkspaceDir, "skills")),
ReactMaxSteps: intFromEnv("REACT_MAX_STEPS", 0),
ToolCallTimeoutSec: intFromEnv("TOOL_CALL_TIMEOUT_SEC", 15),
ToolOutputMaxChars: intFromEnv("TOOL_OUTPUT_MAX_CHARS", 4000),
EnableCapabilityGap: boolFromEnv("ENABLE_CAPABILITY_GAP", true),
AutoSkillDir: defaultIfEmpty(os.Getenv("AUTO_SKILL_DIR"), filepath.Join(agentWorkspaceDir, "skills")),
GapDraftTriggerCount: intFromEnv("GAP_DRAFT_TRIGGER_COUNT", 3),
GapClusterLookbackHours: intFromEnv("GAP_CLUSTER_LOOKBACK_HOURS", 168),
Telegram: TelegramConfig{
Token: strings.TrimSpace(os.Getenv("TELEGRAM_BOT_TOKEN")),
PollTimeoutSeconds: intFromEnv("TELEGRAM_POLL_TIMEOUT_SECONDS", 30),
},
Feishu: FeishuConfig{
AppID: strings.TrimSpace(os.Getenv("FEISHU_APP_ID")),
AppSecret: strings.TrimSpace(os.Getenv("FEISHU_APP_SECRET")),
VerifyToken: strings.TrimSpace(os.Getenv("FEISHU_VERIFY_TOKEN")),
ListenAddr: defaultIfEmpty(os.Getenv("FEISHU_LISTEN_ADDR"), ":8080"),
EventPath: defaultIfEmpty(os.Getenv("FEISHU_EVENT_PATH"), "/feishu/events"),
},
WebUI: WebUIConfig{
ListenAddr: defaultIfEmpty(os.Getenv("WEBUI_LISTEN_ADDR"), ":8090"),
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")),
RouterModel: strings.TrimSpace(os.Getenv("LLM_ROUTER_MODEL")),
},
SQLitePath: defaultIfEmpty(os.Getenv("SQLITE_PATH"), filepath.Join(defaultDataDir, "laodingbot.db")),
WebSearch: WebSearchConfig{
Engine: defaultIfEmpty(os.Getenv("WEB_SEARCH_ENGINE"), "duckduckgo"),
APIKey: strings.TrimSpace(os.Getenv("WEB_SEARCH_API_KEY")),
},
Security: SecurityConfig{
AllowedDirs: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_DIRS"), strings.Join([]string{agentWorkspaceDir, defaultDataDir, defaultWorkSubdir}, ","))),
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))
cfg.LogLevel = strings.ToLower(strings.TrimSpace(cfg.LogLevel))
if cfg.MessageChannel != "telegram" && cfg.MessageChannel != "feishu" && cfg.MessageChannel != "webui" {
return Config{}, fmt.Errorf("MESSAGE_CHANNEL must be telegram, feishu, or webui")
}
if cfg.LogLevel != "debug" && cfg.LogLevel != "info" && cfg.LogLevel != "warn" && cfg.LogLevel != "error" {
return Config{}, fmt.Errorf("LOG_LEVEL must be debug, info, warn, or error")
}
if cfg.ReactMaxSteps < 1 || cfg.ReactMaxSteps > 8 {
return Config{}, fmt.Errorf("REACT_MAX_STEPS must be between 1 and 8")
}
if cfg.ToolCallTimeoutSec < 1 || cfg.ToolCallTimeoutSec > 300 {
return Config{}, fmt.Errorf("TOOL_CALL_TIMEOUT_SEC must be between 1 and 300")
}
if cfg.ToolOutputMaxChars < 256 || cfg.ToolOutputMaxChars > 200000 {
return Config{}, fmt.Errorf("TOOL_OUTPUT_MAX_CHARS must be between 256 and 200000")
}
if cfg.GapDraftTriggerCount < 1 || cfg.GapDraftTriggerCount > 100 {
return Config{}, fmt.Errorf("GAP_DRAFT_TRIGGER_COUNT must be between 1 and 100")
}
if cfg.GapClusterLookbackHours < 1 || cfg.GapClusterLookbackHours > 24*365 {
return Config{}, fmt.Errorf("GAP_CLUSTER_LOOKBACK_HOURS must be between 1 and 8760")
}
if cfg.WebUI.MaxUploadBytes < 1024 || cfg.WebUI.MaxUploadBytes > 200*1024*1024 {
return Config{}, fmt.Errorf("WEBUI_MAX_UPLOAD_MB must be between 1 and 200")
}
if cfg.MessageChannel == "telegram" {
if cfg.Telegram.Token == "" {
return Config{}, fmt.Errorf("TELEGRAM_BOT_TOKEN is required when MESSAGE_CHANNEL=telegram")
}
if cfg.Feishu.AppID != "" || cfg.Feishu.AppSecret != "" {
return Config{}, fmt.Errorf("feishu config must be empty when MESSAGE_CHANNEL=telegram")
}
}
if cfg.MessageChannel == "feishu" {
if cfg.Feishu.AppID == "" || cfg.Feishu.AppSecret == "" {
return Config{}, fmt.Errorf("FEISHU_APP_ID and FEISHU_APP_SECRET are required when MESSAGE_CHANNEL=feishu")
}
if cfg.Telegram.Token != "" {
return Config{}, fmt.Errorf("TELEGRAM_BOT_TOKEN must be empty when MESSAGE_CHANNEL=feishu")
}
}
if cfg.MessageChannel == "webui" {
if strings.TrimSpace(cfg.WebUI.ListenAddr) == "" {
return Config{}, fmt.Errorf("WEBUI_LISTEN_ADDR is required when MESSAGE_CHANNEL=webui")
}
}
if cfg.LLM.APIKey == "" {
return Config{}, fmt.Errorf("LLM_API_KEY is required")
}
cfg.SoulPath = resolvePathInWorkspace(cfg.SoulPath, agentWorkspaceDir)
cfg.SkillsDir = resolvePathInWorkspace(cfg.SkillsDir, agentWorkspaceDir)
cfg.AutoSkillDir = resolvePathInWorkspace(cfg.AutoSkillDir, agentWorkspaceDir)
cfg.SQLitePath = resolvePathInWorkspace(cfg.SQLitePath, agentWorkspaceDir)
cfg.Security.WorkDir = resolvePathInWorkspace(cfg.Security.WorkDir, agentWorkspaceDir)
cfg.Security.AllowedDirs = resolveDirsInWorkspace(cfg.Security.AllowedDirs, agentWorkspaceDir)
cfg.Security.AllowedDirs = ensureAllowedDirs(cfg.Security.AllowedDirs,
filepath.Clean(agentWorkspaceDir),
filepath.Join(agentWorkspaceDir, "skills"),
filepath.Join(agentWorkspaceDir, "data"),
filepath.Join(agentWorkspaceDir, "workspace"),
)
cfg.Security.AllowedCommands = ensureAllowedCommands(cfg.Security.AllowedCommands, "go", "curl", "curl.exe")
return cfg, nil
}
func preloadEnvFiles() error {
explicit := strings.TrimSpace(os.Getenv("CONFIG_ENV_FILE"))
if explicit != "" {
if err := tryLoadEnvFile(explicit, true); err != nil {
return fmt.Errorf("load env file %s failed: %w", explicit, err)
}
}
workspaceDir := resolveAgentWorkspaceDir()
workspaceEnv := filepath.Join(workspaceDir, "configs", "env")
workspaceDotEnv := filepath.Join(workspaceDir, ".env")
if err := tryLoadEnvFile(workspaceEnv, true); err != nil {
return fmt.Errorf("load env file %s failed: %w", workspaceEnv, err)
}
if err := tryLoadEnvFile(workspaceDotEnv, true); err != nil {
return fmt.Errorf("load env file %s failed: %w", workspaceDotEnv, err)
}
if err := tryLoadEnvFile("configs/env", false); err != nil {
return fmt.Errorf("load env file %s failed: %w", "configs/env", err)
}
if err := tryLoadEnvFile(".env", false); err != nil {
return fmt.Errorf("load env file %s failed: %w", ".env", err)
}
return nil
}
func tryLoadEnvFile(path string, override bool) error {
err := loadEnvFile(path, override)
if err != nil && errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
func resolveAgentWorkspaceDir() string {
raw := strings.TrimSpace(os.Getenv("AGENT_WORKSPACE_DIR"))
if raw == "" {
raw = filepath.Join(".", "workspace", "agent_runtime")
}
abs, err := filepath.Abs(raw)
if err != nil {
return raw
}
return abs
}
func resolvePathInWorkspace(path, workspaceDir string) string {
path = strings.TrimSpace(path)
if path == "" {
return path
}
if filepath.IsAbs(path) {
return filepath.Clean(path)
}
return filepath.Clean(filepath.Join(workspaceDir, path))
}
func resolveDirsInWorkspace(dirs []string, workspaceDir string) []string {
out := make([]string, 0, len(dirs))
for _, d := range dirs {
resolved := resolvePathInWorkspace(d, workspaceDir)
if strings.TrimSpace(resolved) != "" {
out = append(out, resolved)
}
}
if len(out) == 0 {
out = append(out, filepath.Clean(workspaceDir))
}
return out
}
func ensureAllowedDirs(existing []string, dirs ...string) []string {
set := map[string]struct{}{}
out := make([]string, 0, len(existing)+len(dirs))
for _, d := range existing {
clean := filepath.Clean(strings.TrimSpace(d))
if clean == "" {
continue
}
if _, ok := set[clean]; ok {
continue
}
set[clean] = struct{}{}
out = append(out, clean)
}
for _, d := range dirs {
clean := filepath.Clean(strings.TrimSpace(d))
if clean == "" {
continue
}
if _, ok := set[clean]; ok {
continue
}
set[clean] = struct{}{}
out = append(out, clean)
}
return out
}
func ensureAllowedCommands(existing []string, commands ...string) []string {
set := map[string]struct{}{}
out := make([]string, 0, len(existing)+len(commands))
for _, c := range existing {
cmd := strings.ToLower(strings.TrimSpace(c))
if cmd == "" {
continue
}
if _, ok := set[cmd]; ok {
continue
}
set[cmd] = struct{}{}
out = append(out, cmd)
}
for _, c := range commands {
cmd := strings.ToLower(strings.TrimSpace(c))
if cmd == "" {
continue
}
if _, ok := set[cmd]; ok {
continue
}
set[cmd] = struct{}{}
out = append(out, cmd)
}
return out
}
func loadEnvFile(path string, override bool) error {
absPath, err := filepath.Abs(path)
if err != nil {
absPath = path
}
f, err := os.Open(absPath)
if err != nil {
return err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
line = strings.TrimPrefix(line, "export ")
idx := strings.Index(line, "=")
if idx <= 0 {
continue
}
key := strings.TrimSpace(line[:idx])
if key == "" {
continue
}
val := strings.TrimSpace(line[idx+1:])
if len(val) >= 2 {
if (val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'') {
val = val[1 : len(val)-1]
}
}
if !override {
if _, exists := os.LookupEnv(key); exists {
continue
}
}
if err := os.Setenv(key, val); err != nil {
return err
}
}
return scanner.Err()
}
func defaultIfEmpty(v, d string) string {
v = strings.TrimSpace(v)
if v == "" {
return d
}
return v
}
func intFromEnv(name string, d int) int {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return d
}
v, err := strconv.Atoi(raw)
if err != nil {
return d
}
return v
}
func boolFromEnv(name string, d bool) bool {
raw := strings.ToLower(strings.TrimSpace(os.Getenv(name)))
if raw == "" {
return d
}
if raw == "1" || raw == "true" || raw == "yes" || raw == "on" {
return true
}
if raw == "0" || raw == "false" || raw == "no" || raw == "off" {
return false
}
return d
}
func splitCSV(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
v := strings.TrimSpace(p)
if v != "" {
out = append(out, v)
}
}
return out
}