Changeset 0.22.4 (#146)
This commit is contained in:
@@ -457,6 +457,121 @@ func sanitizeFilename(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// ── Project Files (v0.22.4) ────────────────
|
||||
|
||||
// UploadToProject handles project-scoped file uploads.
|
||||
// POST /api/v1/projects/:id/files
|
||||
func (h *AttachmentHandler) UploadToProject(c *gin.Context) {
|
||||
if h.objStore == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
projectID := c.Param("id")
|
||||
|
||||
// Verify project access (user owns or is team member)
|
||||
var exists bool
|
||||
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM projects
|
||||
WHERE id = $1 AND (owner_id = $2 OR team_id IN (
|
||||
SELECT team_id FROM team_members WHERE user_id = $2 AND is_active = true
|
||||
))
|
||||
)
|
||||
`), projectID, userID).Scan(&exists)
|
||||
if err != nil || !exists {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if header.Size > int64(defaultMaxFileSize) {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file exceeds size limit"})
|
||||
return
|
||||
}
|
||||
|
||||
// Detect content type
|
||||
buf := make([]byte, 512)
|
||||
n, _ := file.Read(buf)
|
||||
contentType := http.DetectContentType(buf[:n])
|
||||
file.Seek(0, io.SeekStart)
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if better, ok := extToMIME[ext]; ok && contentType == "application/octet-stream" {
|
||||
contentType = better
|
||||
}
|
||||
|
||||
// Store file
|
||||
storageKey := fmt.Sprintf("projects/%s/%s/%s", projectID, store.NewID()[:8], sanitizeFilename(header.Filename))
|
||||
if err := h.objStore.Put(c.Request.Context(), storageKey, file, header.Size, contentType); err != nil {
|
||||
log.Printf("error: project file upload: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
|
||||
return
|
||||
}
|
||||
|
||||
att := models.Attachment{
|
||||
ChannelID: "", // no channel association
|
||||
UserID: userID,
|
||||
ProjectID: &projectID,
|
||||
Filename: header.Filename,
|
||||
ContentType: contentType,
|
||||
SizeBytes: header.Size,
|
||||
StorageKey: storageKey,
|
||||
}
|
||||
|
||||
if err := h.stores.Attachments.Create(c.Request.Context(), &att); err != nil {
|
||||
log.Printf("error: project file create: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file metadata"})
|
||||
return
|
||||
}
|
||||
|
||||
if h.extQueue != nil {
|
||||
if err := h.extQueue.Enqueue(att.ID, att.StorageKey, contentType, att.Filename); err != nil {
|
||||
log.Printf("warning: project file extraction enqueue failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, att)
|
||||
}
|
||||
|
||||
// ListByProject returns all files uploaded to a project.
|
||||
// GET /api/v1/projects/:id/files
|
||||
func (h *AttachmentHandler) ListByProject(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
projectID := c.Param("id")
|
||||
|
||||
// Verify project access
|
||||
var exists bool
|
||||
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM projects
|
||||
WHERE id = $1 AND (owner_id = $2 OR team_id IN (
|
||||
SELECT team_id FROM team_members WHERE user_id = $2 AND is_active = true
|
||||
))
|
||||
)
|
||||
`), projectID, userID).Scan(&exists)
|
||||
if err != nil || !exists {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
files, err := h.stores.Attachments.GetByProject(c.Request.Context(), projectID)
|
||||
if err != nil {
|
||||
log.Printf("error: list project files: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"files": files, "count": len(files)})
|
||||
}
|
||||
|
||||
// extToMIME maps file extensions to MIME types for cases where
|
||||
// http.DetectContentType returns application/octet-stream.
|
||||
var extToMIME = map[string]string{
|
||||
|
||||
@@ -64,6 +64,9 @@ type HealthRecorder interface {
|
||||
RecordSuccess(providerConfigID string, latencyMs int)
|
||||
RecordError(providerConfigID string, latencyMs int, errMsg string)
|
||||
RecordTimeout(providerConfigID string, latencyMs int, errMsg string)
|
||||
RecordRateLimit(providerConfigID string, latencyMs int, errMsg string) // v0.22.4
|
||||
RecordToolSuccess(toolName string, latencyMs int) // v0.22.4
|
||||
RecordToolError(toolName string, latencyMs int, errMsg string) // v0.22.4
|
||||
}
|
||||
|
||||
// HealthStatusQuerier retrieves current provider health for routing decisions.
|
||||
@@ -1365,6 +1368,8 @@ func (h *CompletionHandler) recordHealth(configID string, start time.Time, err e
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "context deadline exceeded") || strings.Contains(errMsg, "timeout") {
|
||||
h.health.RecordTimeout(configID, latencyMs, errMsg)
|
||||
} else if strings.Contains(errMsg, "HTTP 429") || strings.Contains(errMsg, "rate limit") || strings.Contains(errMsg, "Too Many Requests") {
|
||||
h.health.RecordRateLimit(configID, latencyMs, errMsg)
|
||||
} else {
|
||||
h.health.RecordError(configID, latencyMs, errMsg)
|
||||
}
|
||||
|
||||
110
server/handlers/export.go
Normal file
110
server/handlers/export.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ExportHandler converts markdown content to PDF or DOCX via pandoc.
|
||||
type ExportHandler struct{}
|
||||
|
||||
func NewExportHandler() *ExportHandler { return &ExportHandler{} }
|
||||
|
||||
type exportRequest struct {
|
||||
Content string `json:"content" binding:"required"`
|
||||
Format string `json:"format" binding:"required"` // "pdf" or "docx"
|
||||
Filename string `json:"filename"` // optional base name
|
||||
}
|
||||
|
||||
// Convert handles POST /api/v1/export.
|
||||
// Converts markdown content to the requested format and returns the file.
|
||||
func (h *ExportHandler) Convert(c *gin.Context) {
|
||||
var req exportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate format
|
||||
switch req.Format {
|
||||
case "pdf", "docx":
|
||||
// ok
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "format must be pdf or docx"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check pandoc availability
|
||||
if _, err := exec.LookPath("pandoc"); err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "pandoc is not installed on the server"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create temp dir for conversion
|
||||
tmpDir, err := os.MkdirTemp("", "export-*")
|
||||
if err != nil {
|
||||
log.Printf("export: failed to create temp dir: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "export failed"})
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Write markdown to temp file
|
||||
inputPath := filepath.Join(tmpDir, "input.md")
|
||||
if err := os.WriteFile(inputPath, []byte(req.Content), 0644); err != nil {
|
||||
log.Printf("export: failed to write input: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "export failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine output filename
|
||||
baseName := "document"
|
||||
if req.Filename != "" {
|
||||
baseName = strings.TrimSuffix(req.Filename, filepath.Ext(req.Filename))
|
||||
}
|
||||
outputFile := baseName + "." + req.Format
|
||||
outputPath := filepath.Join(tmpDir, outputFile)
|
||||
|
||||
// Build pandoc command
|
||||
args := []string{inputPath, "-o", outputPath, "--standalone"}
|
||||
if req.Format == "pdf" {
|
||||
// Try to use a lightweight PDF engine
|
||||
for _, engine := range []string{"weasyprint", "wkhtmltopdf", "pdflatex"} {
|
||||
if _, err := exec.LookPath(engine); err == nil {
|
||||
args = append(args, "--pdf-engine="+engine)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(c.Request.Context(), "pandoc", args...)
|
||||
cmd.Dir = tmpDir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Printf("export: pandoc failed: %v\n%s", err, string(output))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "pandoc conversion failed",
|
||||
"details": string(output),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the file
|
||||
contentType := "application/octet-stream"
|
||||
switch req.Format {
|
||||
case "pdf":
|
||||
contentType = "application/pdf"
|
||||
case "docx":
|
||||
contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
}
|
||||
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", outputFile))
|
||||
c.File(outputPath)
|
||||
c.Header("Content-Type", contentType)
|
||||
}
|
||||
@@ -79,6 +79,9 @@ func (h *HealthAdminHandler) GetAllProviderHealth(c *gin.Context) {
|
||||
Status: health.DeriveStatus(w.ErrorRate(), w.RequestCount),
|
||||
RequestCount: w.RequestCount,
|
||||
ErrorRate: w.ErrorRate(),
|
||||
ErrorCount: w.ErrorCount,
|
||||
RateLimitCount: w.RateLimitCount,
|
||||
TimeoutCount: w.TimeoutCount,
|
||||
AvgLatencyMs: w.AvgLatencyMs(),
|
||||
MaxLatencyMs: w.MaxLatencyMs,
|
||||
LastError: w.LastError,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -35,7 +36,11 @@ func recordHealthFn(hr HealthRecorder, configID string, start time.Time, err err
|
||||
latencyMs := int(time.Since(start).Milliseconds())
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
hr.RecordError(configID, latencyMs, errMsg)
|
||||
if strings.Contains(errMsg, "HTTP 429") || strings.Contains(errMsg, "rate limit") || strings.Contains(errMsg, "Too Many Requests") {
|
||||
hr.RecordRateLimit(configID, latencyMs, errMsg)
|
||||
} else {
|
||||
hr.RecordError(configID, latencyMs, errMsg)
|
||||
}
|
||||
} else {
|
||||
hr.RecordSuccess(configID, latencyMs)
|
||||
}
|
||||
@@ -205,9 +210,19 @@ func streamWithToolLoop(
|
||||
var toolResult tools.ToolResult
|
||||
|
||||
// Check if tool is registered locally (server-side)
|
||||
toolStart := time.Now()
|
||||
if tools.Get(call.Name) != nil {
|
||||
log.Printf("🔧 Executing tool (server): %s (call %s)", call.Name, call.ID)
|
||||
toolResult = tools.ExecuteCall(c.Request.Context(), execCtx, call)
|
||||
// Record tool health (v0.22.4)
|
||||
if health != nil {
|
||||
toolLatency := int(time.Since(toolStart).Milliseconds())
|
||||
if toolResult.IsError {
|
||||
health.RecordToolError(call.Name, toolLatency, toolResult.Content)
|
||||
} else {
|
||||
health.RecordToolSuccess(call.Name, toolLatency)
|
||||
}
|
||||
}
|
||||
} else if hub != nil && hub.IsConnected(userID) {
|
||||
// Route to browser via WebSocket bridge
|
||||
log.Printf("🔧 Executing tool (browser): %s (call %s)", call.Name, call.ID)
|
||||
@@ -418,8 +433,18 @@ func streamModelResponse(
|
||||
}
|
||||
|
||||
var toolResult tools.ToolResult
|
||||
toolStart := time.Now()
|
||||
if tools.Get(call.Name) != nil {
|
||||
toolResult = tools.ExecuteCall(c.Request.Context(), execCtx, call)
|
||||
// Record tool health (v0.22.4)
|
||||
if health != nil {
|
||||
toolLatency := int(time.Since(toolStart).Milliseconds())
|
||||
if toolResult.IsError {
|
||||
health.RecordToolError(call.Name, toolLatency, toolResult.Content)
|
||||
} else {
|
||||
health.RecordToolSuccess(call.Name, toolLatency)
|
||||
}
|
||||
}
|
||||
} else if hub != nil && hub.IsConnected(userID) {
|
||||
toolResult = executeBrowserTool(hub, userID, call)
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user