From 8effeee0634f26773f5cb683b0012623ba67c665 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Mon, 4 May 2026 19:37:14 +0800 Subject: [PATCH] feat(app): wire chat module + LocalFS storage + LLM registry into app.New --- config.example.yaml | 28 +++++++++++++++++++ internal/app/app.go | 58 +++++++++++++++++++++++++++++++++++++++ internal/config/config.go | 47 +++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+) diff --git a/config.example.yaml b/config.example.yaml index 5daa0d8..a16e502 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -44,3 +44,31 @@ workspace: # git CLI 配置(默认走 PATH 里的 git) git: binary: "git" + +# Chat 模块(多模态对话 + LLM endpoints) +chat: + attachment: + # 单文件上限(MB) + max_file_size_mb: 20 + # 单条消息中所有附件总和上限(MB) + max_message_size_mb: 50 + # 允许的 MIME 类型白名单。"text/*" 通配任意 text/ 子类型。 + mime_whitelist: + - image/png + - image/jpeg + - image/webp + - image/gif + - application/pdf + - text/* + stream: + # SSE ring buffer 在流结束后保留多久,方便前端断线重连。 + ring_buffer_retention_seconds: 300 + history: + # token 预算的安全余量百分比(5.0 表示在 (context_window - max_output) 上再保留 5%) + safety_margin_pct: 5.0 + +# 附件存储驱动(当前仅支持 localfs)。生产建议挂载到独立卷。 +storage: + driver: "localfs" + localfs: + base_path: "./data/uploads" diff --git a/internal/app/app.go b/internal/app/app.go index df918ca..9b15988 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -30,12 +30,15 @@ import ( "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/audit" + "github.com/yan1h/agent-coding-workflow/internal/chat" "github.com/yan1h/agent-coding-workflow/internal/config" "github.com/yan1h/agent-coding-workflow/internal/infra/crypto" "github.com/yan1h/agent-coding-workflow/internal/infra/db" "github.com/yan1h/agent-coding-workflow/internal/infra/git" + "github.com/yan1h/agent-coding-workflow/internal/infra/llm" "github.com/yan1h/agent-coding-workflow/internal/infra/logger" "github.com/yan1h/agent-coding-workflow/internal/infra/notify" + "github.com/yan1h/agent-coding-workflow/internal/infra/storage" "github.com/yan1h/agent-coding-workflow/internal/project" httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" @@ -173,6 +176,61 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { gopSvc := workspace.NewGitOpsService(wsRepo, auditRec, gitr, pa, credLoader) workspace.NewHandler(wsSvc, wtSvc, gopSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) + // ===== chat 模块装配 ===== + // 依赖图: + // pgx.Pool ─ chat.Repository ─┐ + // ├─ EndpointLoader ─ llm.Registry ─┐ + // crypto.Encryptor ───────────┘ │ + // │ + // storage.LocalFS ─ Storage ──┐ │ + // ├─ MessageService ─ Handler ──────┤ + // notify.Dispatcher ─ StreamerHub ─ ConversationService ─ Hub ───┘ + // + // 启动期把卡死的 pending 消息复位为 error,避免行永远卡住。 + chatRepo := chat.NewRepository(pool) + if err := chatRepo.MarkPendingAsErrorOnStartup(ctx); err != nil { + log.Warn("mark chat pending messages as error on startup", "err", err.Error()) + } + + endpointLoader := chat.NewEndpointLoader(chatRepo, enc) + llmRegistry := llm.NewRegistry(endpointLoader) + + var attachStorage storage.Storage + switch cfg.Storage.Driver { + case "localfs", "": + s, err := storage.NewLocalFS(cfg.Storage.LocalFS.BasePath) + if err != nil { + return nil, fmt.Errorf("storage localfs: %w", err) + } + attachStorage = s + default: + return nil, fmt.Errorf("unsupported storage driver %q", cfg.Storage.Driver) + } + + // safety_margin_pct 配置为百分比(5.0 表示 5%),构造器期望小数(0.05),故 /100。 + safetyFraction := cfg.Chat.History.SafetyMarginPct / 100 + retention := time.Duration(cfg.Chat.Stream.RingBufferRetentionSeconds) * time.Second + + chatEpSvc := chat.NewEndpointService(chatRepo, enc, llmRegistry, auditRec, log) + chatTplSvc := chat.NewTemplateService(chatRepo, auditRec, log) + chatConvSvc := chat.NewConversationService(chatRepo, llmRegistry, auditRec, log) + chatHub := chat.NewStreamerHub(chatRepo, llmRegistry, notifyDispatcher, auditRec, + chatConvSvc, log, retention, safetyFraction) + chatMsgSvc := chat.NewMessageService(chatRepo, attachStorage, chatHub, auditRec, log, + chat.MessageServiceConfig{ + MimeWhitelist: cfg.Chat.Attachment.MimeWhitelist, + MaxFileBytes: cfg.Chat.Attachment.MaxFileSizeMB << 20, + MaxMsgBytes: cfg.Chat.Attachment.MaxMessageSizeMB << 20, + SafetyPct: safetyFraction, + }) + chatUsageSvc := chat.NewUsageService(chatRepo) + chatUploader := chat.NewUploader(chatRepo, attachStorage, + cfg.Chat.Attachment.MimeWhitelist, + cfg.Chat.Attachment.MaxFileSizeMB<<20, + ) + chat.NewHandler(chatConvSvc, chatMsgSvc, chatTplSvc, chatEpSvc, chatUsageSvc, + chatUploader, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) + if cfg.Bootstrap.AdminEmail != "" && cfg.Bootstrap.AdminPassword != "" { if u, err := userSvc.Bootstrap(ctx, cfg.Bootstrap.AdminEmail, cfg.Bootstrap.AdminPassword); err != nil { log.Warn("bootstrap admin failed", "err", err.Error()) diff --git a/internal/config/config.go b/internal/config/config.go index 942219d..a29d51d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,6 +37,8 @@ type Config struct { Bootstrap BootstrapConfig `mapstructure:"bootstrap"` Workspace Workspace `mapstructure:"workspace"` Git Git `mapstructure:"git"` + Chat Chat `mapstructure:"chat"` + Storage Storage `mapstructure:"storage"` } // HTTPConfig 描述 HTTP 服务监听参数。 @@ -70,6 +72,41 @@ type Git struct { Binary string `mapstructure:"binary"` } +// Chat 配置 chat 模块的附件、流式、历史与定时清理策略。 +type Chat struct { + Attachment ChatAttachment `mapstructure:"attachment"` + Stream ChatStream `mapstructure:"stream"` + History ChatHistory `mapstructure:"history"` +} + +// ChatAttachment 控制单文件 / 单消息上限与允许的 MIME 白名单。 +type ChatAttachment struct { + MaxFileSizeMB int64 `mapstructure:"max_file_size_mb"` + MaxMessageSizeMB int64 `mapstructure:"max_message_size_mb"` + MimeWhitelist []string `mapstructure:"mime_whitelist"` +} + +// ChatStream 控制 SSE ring buffer 保留时长与取消宽限期。 +type ChatStream struct { + RingBufferRetentionSeconds int `mapstructure:"ring_buffer_retention_seconds"` +} + +// ChatHistory 控制 token 预算的安全余量百分比(如 5 表示保留 5% 余量)。 +type ChatHistory struct { + SafetyMarginPct float64 `mapstructure:"safety_margin_pct"` +} + +// Storage 选择附件落盘驱动与对应参数。 +type Storage struct { + Driver string `mapstructure:"driver"` // 当前仅支持 "localfs" + LocalFS StorageLocalFS `mapstructure:"localfs"` +} + +// StorageLocalFS 是 driver=localfs 时的根目录。 +type StorageLocalFS struct { + BasePath string `mapstructure:"base_path"` +} + // Load 从(按优先级递增)默认值 → 配置文件(如有)→ 环境变量 加载配置。 // configFile 可为空字符串,仅用环境变量。 func Load(configFile string) (*Config, error) { @@ -85,6 +122,16 @@ func Load(configFile string) (*Config, error) { v.SetDefault("workspace.push_timeout", "5m") v.SetDefault("workspace.ops_timeout", "60s") v.SetDefault("git.binary", "git") + v.SetDefault("chat.attachment.max_file_size_mb", 20) + v.SetDefault("chat.attachment.max_message_size_mb", 50) + v.SetDefault("chat.attachment.mime_whitelist", []string{ + "image/png", "image/jpeg", "image/webp", "image/gif", + "application/pdf", "text/*", + }) + v.SetDefault("chat.stream.ring_buffer_retention_seconds", 300) + v.SetDefault("chat.history.safety_margin_pct", 5.0) + v.SetDefault("storage.driver", "localfs") + v.SetDefault("storage.localfs.base_path", "./data/uploads") if configFile != "" { v.SetConfigFile(configFile)