Refactored orchestrator for staged file handling, added structured prompt support, adjusted Feishu file handling
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -18,6 +19,20 @@ type Client interface {
|
||||
Generate(ctx context.Context, systemPrompt, userPrompt string) (string, error)
|
||||
}
|
||||
|
||||
type FileChatClient interface {
|
||||
GenerateWithFiles(ctx context.Context, systemPrompt, userPrompt string, fileIDs []string) (string, error)
|
||||
}
|
||||
|
||||
type FileUploader interface {
|
||||
UploadFile(ctx context.Context, file InputFile, purpose string) (string, error)
|
||||
}
|
||||
|
||||
type InputFile struct {
|
||||
FileName string
|
||||
MimeType string
|
||||
Content []byte
|
||||
}
|
||||
|
||||
type OpenAICompatibleClient struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
@@ -43,27 +58,64 @@ type chatRequest struct {
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Content any `json:"content"`
|
||||
}
|
||||
|
||||
type chatContentPart struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
FileID string `json:"file_id,omitempty"`
|
||||
}
|
||||
|
||||
type chatResponse struct {
|
||||
Choices []struct {
|
||||
Message chatMessage `json:"message"`
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type fileUploadResponse struct {
|
||||
ID string `json:"id"`
|
||||
Bytes int64 `json:"bytes,omitempty"`
|
||||
CreatedAt int64 `json:"created_at,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Object string `json:"object,omitempty"`
|
||||
Purpose string `json:"purpose,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Status any `json:"status,omitempty"`
|
||||
StatusDetails any `json:"status_details,omitempty"`
|
||||
Data *struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data,omitempty"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *OpenAICompatibleClient) Generate(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
|
||||
return c.generateInternal(ctx, systemPrompt, userPrompt, nil)
|
||||
}
|
||||
|
||||
func (c *OpenAICompatibleClient) GenerateWithFiles(ctx context.Context, systemPrompt, userPrompt string, fileIDs []string) (string, error) {
|
||||
return c.generateInternal(ctx, systemPrompt, userPrompt, fileIDs)
|
||||
}
|
||||
|
||||
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", c.model, len(systemPrompt), len(userPrompt))
|
||||
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))
|
||||
}
|
||||
userContent := buildUserContent(userPrompt, fileIDs)
|
||||
body := chatRequest{
|
||||
Model: c.model,
|
||||
Messages: []chatMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
{Role: "user", Content: userContent},
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(body)
|
||||
@@ -132,3 +184,144 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, systemPrompt, use
|
||||
|
||||
return out.Choices[0].Message.Content, nil
|
||||
}
|
||||
|
||||
func buildUserContent(userPrompt string, fileIDs []string) any {
|
||||
trimmedPrompt := strings.TrimSpace(userPrompt)
|
||||
if len(fileIDs) == 0 {
|
||||
return userPrompt
|
||||
}
|
||||
|
||||
parts := make([]chatContentPart, 0, len(fileIDs)+1)
|
||||
if trimmedPrompt != "" {
|
||||
parts = append(parts, chatContentPart{Type: "text", Text: userPrompt})
|
||||
}
|
||||
for _, id := range fileIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, chatContentPart{Type: "file", FileID: id})
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return userPrompt
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func (c *OpenAICompatibleClient) UploadFile(ctx context.Context, file InputFile, purpose string) (string, error) {
|
||||
if strings.TrimSpace(file.FileName) == "" {
|
||||
return "", fmt.Errorf("empty file name")
|
||||
}
|
||||
if len(file.Content) == 0 {
|
||||
return "", fmt.Errorf("empty file content")
|
||||
}
|
||||
purpose = strings.TrimSpace(purpose)
|
||||
purposes := []string{}
|
||||
if purpose != "" {
|
||||
purposes = append(purposes, purpose)
|
||||
}
|
||||
// Provider compatibility fallback order.
|
||||
purposes = appendIfMissing(purposes, "file-extract")
|
||||
purposes = appendIfMissing(purposes, "batch")
|
||||
|
||||
var lastErr error
|
||||
for _, p := range purposes {
|
||||
fileID, err := c.uploadFileOnce(ctx, file, p)
|
||||
if err == nil {
|
||||
return fileID, nil
|
||||
}
|
||||
lastErr = err
|
||||
if c.log != nil {
|
||||
c.log.Warnf("llm file upload failed purpose=%s err=%v", p, err)
|
||||
}
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("llm file upload failed: no purpose tried")
|
||||
}
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
func (c *OpenAICompatibleClient) uploadFileOnce(ctx context.Context, file InputFile, purpose string) (string, error) {
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
if err := writer.WriteField("purpose", purpose); err != nil {
|
||||
return "", err
|
||||
}
|
||||
part, err := writer.CreateFormFile("file", file.FileName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := part.Write(file.Content); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
url := strings.TrimRight(c.baseURL, "/") + "/files"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var out fileUploadResponse
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", fmt.Errorf("llm file upload response decode failed: %w body=%s", err, clipForError(raw))
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if strings.TrimSpace(out.Message) != "" {
|
||||
return "", fmt.Errorf("llm file upload error: %s", out.Message)
|
||||
}
|
||||
if out.Error != nil && out.Error.Message != "" {
|
||||
return "", fmt.Errorf("llm file upload error: %s", out.Error.Message)
|
||||
}
|
||||
return "", fmt.Errorf("llm file upload status: %d body=%s", resp.StatusCode, clipForError(raw))
|
||||
}
|
||||
fileID := strings.TrimSpace(out.ID)
|
||||
if fileID == "" && out.Data != nil {
|
||||
fileID = strings.TrimSpace(out.Data.ID)
|
||||
}
|
||||
if fileID == "" {
|
||||
return "", fmt.Errorf("llm file upload returned empty file id body=%s", clipForError(raw))
|
||||
}
|
||||
if c.log != nil {
|
||||
c.log.Infof("llm file uploaded name=%s size=%d file_id=%s purpose=%s status=%v", file.FileName, len(file.Content), fileID, purpose, out.Status)
|
||||
}
|
||||
return fileID, nil
|
||||
}
|
||||
|
||||
func clipForError(raw []byte) string {
|
||||
s := strings.TrimSpace(string(raw))
|
||||
const max = 400
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "...(truncated)"
|
||||
}
|
||||
|
||||
func appendIfMissing(items []string, value string) []string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return items
|
||||
}
|
||||
for _, it := range items {
|
||||
if strings.EqualFold(strings.TrimSpace(it), value) {
|
||||
return items
|
||||
}
|
||||
}
|
||||
return append(items, value)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user