Changeset 0.22.4 (#146)

This commit is contained in:
2026-03-02 22:16:08 +00:00
parent 9940fb5831
commit 3953dcf364
25 changed files with 2254 additions and 145 deletions

View File

@@ -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{

View File

@@ -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
View 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)
}

View File

@@ -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,

View File

@@ -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 {