feat: support file-aware model switch and update tech docs

This commit is contained in:
2026-03-09 17:38:13 +08:00
parent 52b8dbb835
commit bd41f48971
4 changed files with 245 additions and 137 deletions

View File

@@ -46,9 +46,11 @@ type FeishuConfig struct {
}
type LLMConfig struct {
BaseURL string
APIKey string
Model string
BaseURL string
APIKey string
Model string
FileModel string
FilePromptMode string
}
type SecurityConfig struct {
@@ -94,9 +96,11 @@ func Load() (Config, error) {
EventPath: defaultIfEmpty(os.Getenv("FEISHU_EVENT_PATH"), "/feishu/events"),
},
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"),
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")),
},
SQLitePath: defaultIfEmpty(os.Getenv("SQLITE_PATH"), filepath.Join(defaultDataDir, "laodingbot.db")),
WebSearch: WebSearchConfig{
@@ -155,6 +159,9 @@ 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)
@@ -391,3 +398,14 @@ 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
}

View File

@@ -34,20 +34,24 @@ type InputFile struct {
}
type OpenAICompatibleClient struct {
baseURL string
apiKey string
model string
http *http.Client
log *logger.Logger
baseURL string
apiKey string
model string
fileModel string
filePromptMode string
http *http.Client
log *logger.Logger
}
func NewOpenAICompatibleClient(cfg config.LLMConfig, log *logger.Logger) *OpenAICompatibleClient {
return &OpenAICompatibleClient{
baseURL: cfg.BaseURL,
apiKey: cfg.APIKey,
model: cfg.Model,
http: &http.Client{Timeout: 60 * time.Second},
log: log,
baseURL: cfg.BaseURL,
apiKey: cfg.APIKey,
model: cfg.Model,
fileModel: cfg.FileModel,
filePromptMode: cfg.FilePromptMode,
http: &http.Client{Timeout: 60 * time.Second},
log: log,
}
}
@@ -107,16 +111,20 @@ func (c *OpenAICompatibleClient) GenerateWithFiles(ctx context.Context, systemPr
}
func (c *OpenAICompatibleClient) generateInternal(ctx context.Context, systemPrompt, userPrompt string, fileIDs []string) (string, error) {
if c.log != nil {
c.log.Debugf("llm request start model=%s system_len=%d user_len=%d file_count=%d", c.model, len(systemPrompt), len(userPrompt), len(fileIDs))
model := c.model
ids := nonEmptyIDs(fileIDs)
if len(ids) > 0 {
if strings.TrimSpace(c.fileModel) != "" {
model = c.fileModel
}
}
userContent := buildUserContent(userPrompt, fileIDs)
if c.log != nil {
c.log.Debugf("llm request start model=%s system_len=%d user_len=%d file_count=%d file_prompt_mode=%s", model, len(systemPrompt), len(userPrompt), len(ids), c.normalizedFilePromptMode())
}
messages := buildMessages(systemPrompt, userPrompt, ids, c.normalizedFilePromptMode())
body := chatRequest{
Model: c.model,
Messages: []chatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userContent},
},
Model: model,
Messages: messages,
}
b, err := json.Marshal(body)
if err != nil {
@@ -179,12 +187,32 @@ func (c *OpenAICompatibleClient) generateInternal(ctx context.Context, systemPro
return "", fmt.Errorf("llm returned empty choices")
}
if c.log != nil {
c.log.Infof("llm response success model=%s output_len=%d", c.model, len(out.Choices[0].Message.Content))
c.log.Infof("llm response success model=%s output_len=%d", model, len(out.Choices[0].Message.Content))
}
return out.Choices[0].Message.Content, nil
}
func buildMessages(systemPrompt, userPrompt string, fileIDs []string, mode string) []chatMessage {
mode = strings.ToLower(strings.TrimSpace(mode))
if mode == "system_fileid_uri" {
msgs := []chatMessage{{Role: "system", Content: systemPrompt}}
for _, id := range fileIDs {
if strings.TrimSpace(id) == "" {
continue
}
msgs = append(msgs, chatMessage{Role: "system", Content: "fileid://" + strings.TrimSpace(id)})
}
msgs = append(msgs, chatMessage{Role: "user", Content: userPrompt})
return msgs
}
userContent := buildUserContent(userPrompt, fileIDs)
return []chatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userContent},
}
}
func buildUserContent(userPrompt string, fileIDs []string) any {
trimmedPrompt := strings.TrimSpace(userPrompt)
if len(fileIDs) == 0 {
@@ -325,3 +353,31 @@ func appendIfMissing(items []string, value string) []string {
}
return append(items, value)
}
func nonEmptyIDs(ids []string) []string {
if len(ids) == 0 {
return nil
}
out := make([]string, 0, len(ids))
seen := map[string]struct{}{}
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func (c *OpenAICompatibleClient) normalizedFilePromptMode() string {
mode := strings.ToLower(strings.TrimSpace(c.filePromptMode))
if mode == "system_fileid" || mode == "system_fileid_url" || mode == "system_fileid_uri" {
return "system_fileid_uri"
}
return "user_content_file_parts"
}