Changeset 0.22.8 (#150)

This commit is contained in:
2026-03-04 16:06:12 +00:00
parent 389e47b0f9
commit 7e26a2a261
114 changed files with 3700 additions and 7572 deletions

View File

@@ -84,7 +84,7 @@ func NewChannelHandler() *ChannelHandler {
var channelDeleteHook func(channelID string)
// SetChannelDeleteHook registers a callback invoked after channel deletion.
// Used to clean up attachment files on the storage backend.
// Used to clean up channel files on the storage backend.
func SetChannelDeleteHook(fn func(channelID string)) {
channelDeleteHook = fn
}
@@ -543,7 +543,7 @@ func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
return
}
// Clean up storage files (CASCADE already removed PG attachment rows)
// Clean up storage files (CASCADE already removed PG file rows)
if channelDeleteHook != nil {
go channelDeleteHook(channelID)
}

View File

@@ -41,7 +41,7 @@ type completionRequest struct {
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stream *bool `json:"stream,omitempty"`
AttachmentIDs []string `json:"attachment_ids,omitempty"` // staged attachment UUIDs to include in request
FileIDs []string `json:"file_ids,omitempty"` // staged file UUIDs to include in request
DisabledTools []string `json:"disabled_tools,omitempty"` // tool names to exclude from this request
}
@@ -50,7 +50,7 @@ type CompletionHandler struct {
vault *crypto.KeyResolver
stores store.Stores
hub *events.Hub // WebSocket hub for browser tool bridge
objStore storage.ObjectStore // file storage for attachment content (nil = disabled)
objStore storage.ObjectStore // file storage for uploaded/generated content (nil = disabled)
embedder *knowledge.Embedder // for memory semantic recall (v0.18.0)
health HealthRecorder // provider health tracking (v0.22.0, nil = disabled)
healthStore HealthStatusQuerier // health status queries for routing (v0.22.2, nil = disabled)
@@ -341,14 +341,14 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
// Resolve capabilities early — needed for vision gating below
caps := h.getModelCapabilities(c, model, configID)
// Build user message — multimodal if attachments are present
// Build user message — multimodal if files are present
userMsg := providers.Message{
Role: "user",
Content: req.Content,
}
if len(req.AttachmentIDs) > 0 && h.objStore != nil {
parts, augContent, _, err := h.buildMultimodalParts(c, channelID, req.Content, req.AttachmentIDs, caps)
if len(req.FileIDs) > 0 && h.objStore != nil {
parts, augContent, _, err := h.buildMultimodalParts(c, channelID, req.Content, req.FileIDs, caps)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -370,11 +370,11 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
log.Printf("Failed to persist user message: %v", err)
}
// Link attachments to the persisted message
if msgID != "" && len(req.AttachmentIDs) > 0 {
for _, attID := range req.AttachmentIDs {
if err := h.stores.Attachments.SetMessageID(c.Request.Context(), attID, msgID); err != nil {
log.Printf("Failed to link attachment %s to message %s: %v", attID, msgID, err)
// Link files to the persisted message
if msgID != "" && len(req.FileIDs) > 0 {
for _, fID := range req.FileIDs {
if err := h.stores.Files.SetMessageID(c.Request.Context(), fID, msgID); err != nil {
log.Printf("Failed to link file %s to message %s: %v", fID, msgID, err)
}
}
}
@@ -865,13 +865,13 @@ func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfi
}
// ── Multimodal Assembly ─────────────────────
// Builds content parts from attachments for the user message.
// Builds content parts from files for the user message.
//
// Returns:
// - parts: ContentParts array (non-nil only when images are present)
// - augContent: enriched text content with document context (for doc-only case)
// - validIDs: attachment IDs that were successfully processed
// - error: if any attachment is invalid or vision is needed but missing
// - validIDs: file IDs that were successfully processed
// - error: if any file is invalid or vision is needed but missing
//
// Rules:
// - Images → base64 data URI (requires vision capability)
@@ -882,7 +882,7 @@ func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfi
func (h *CompletionHandler) buildMultimodalParts(
c *gin.Context,
channelID, textContent string,
attachmentIDs []string,
fileIDs []string,
caps models.ModelCapabilities,
) ([]providers.ContentPart, string, []string, error) {
@@ -893,39 +893,39 @@ func (h *CompletionHandler) buildMultimodalParts(
var validIDs []string
hasImage := false
for _, attID := range attachmentIDs {
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
for _, fileID := range fileIDs {
att, err := h.stores.Files.GetByID(c.Request.Context(), fileID)
if err != nil {
return nil, "", nil, fmt.Errorf("attachment %s not found", attID)
return nil, "", nil, fmt.Errorf("file %s not found", fileID)
}
// Security: verify attachment belongs to this channel
// Security: verify file belongs to this channel
if att.ChannelID != channelID {
return nil, "", nil, fmt.Errorf("attachment %s does not belong to this channel", attID)
return nil, "", nil, fmt.Errorf("file %s does not belong to this channel", fileID)
}
if isImageContentType(att.ContentType) {
// Vision gating: reject images if model lacks vision
if !caps.Vision {
return nil, "", nil, fmt.Errorf("model does not support image input; remove image attachments or choose a vision-capable model")
return nil, "", nil, fmt.Errorf("model does not support image input; remove image files or choose a vision-capable model")
}
// Read image from storage, base64 encode, build data URI
reader, _, _, err := h.objStore.Get(c.Request.Context(), att.StorageKey)
if err != nil {
log.Printf("Failed to read attachment %s from storage: %v", attID, err)
log.Printf("Failed to read file %s from storage: %v", fileID, err)
parts = append(parts, providers.ContentPart{
Type: "text",
Text: fmt.Sprintf("[Image: %s — failed to read from storage]", att.Filename),
})
validIDs = append(validIDs, attID)
validIDs = append(validIDs, fileID)
continue
}
data, err := io.ReadAll(reader)
reader.Close()
if err != nil {
log.Printf("Failed to read attachment %s bytes: %v", attID, err)
validIDs = append(validIDs, attID)
log.Printf("Failed to read file %s bytes: %v", fileID, err)
validIDs = append(validIDs, fileID)
continue
}
@@ -964,7 +964,7 @@ func (h *CompletionHandler) buildMultimodalParts(
docTexts = append(docTexts, placeholder)
}
validIDs = append(validIDs, attID)
validIDs = append(validIDs, fileID)
}
// If images present → use ContentParts (multimodal array)

View File

@@ -24,7 +24,7 @@ import (
const (
defaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
defaultMaxUploadSize = 50 * 1024 * 1024 // 50 MB total per request (future: multi-file)
defaultMaxAttachmentsPerMsg = 5 // future: multi-file per message
defaultMaxFilesPerMsg = 5 // future: multi-file per message
defaultOrphanMaxAge = 24 * time.Hour
)
@@ -51,20 +51,20 @@ var allowedMIMETypes = map[string]bool{
// ── Handler ────────────────────────────────
type AttachmentHandler struct {
type FileHandler struct {
stores store.Stores
objStore storage.ObjectStore
extQueue *extraction.Queue // nil if extraction disabled
}
func NewAttachmentHandler(stores store.Stores, objStore storage.ObjectStore, extQueue *extraction.Queue) *AttachmentHandler {
return &AttachmentHandler{stores: stores, objStore: objStore, extQueue: extQueue}
func NewFileHandler(stores store.Stores, objStore storage.ObjectStore, extQueue *extraction.Queue) *FileHandler {
return &FileHandler{stores: stores, objStore: objStore, extQueue: extQueue}
}
// ── Upload ─────────────────────────────────
// POST /api/v1/channels/:id/attachments
// Multipart form: file field "file", returns attachment metadata.
func (h *AttachmentHandler) Upload(c *gin.Context) {
// POST /api/v1/channels/:id/files
// Multipart form: file field "file", returns file metadata.
func (h *FileHandler) Upload(c *gin.Context) {
if h.objStore == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
@@ -128,14 +128,16 @@ func (h *AttachmentHandler) Upload(c *gin.Context) {
return
}
// Build storage key: attachments/{channel_id}/{attachment_id}_{filename}
// Build storage key: files are stored under files/{channel_id}/{file_id}_{filename}
// We generate the ID first via a temp UUID, then use it in the key.
att := &models.Attachment{
att := &models.File{
ChannelID: channelID,
UserID: userID,
Origin: models.FileOriginUserUpload,
Filename: sanitizeFilename(header.Filename),
ContentType: contentType,
SizeBytes: header.Size,
DisplayHint: displayHintFor(contentType),
Metadata: models.JSONMap{
"extraction_status": "pending",
},
@@ -144,30 +146,30 @@ func (h *AttachmentHandler) Upload(c *gin.Context) {
// Create PG row first to get the UUID
// storage_key is set after we have the ID
att.StorageKey = "placeholder"
if err := h.stores.Attachments.Create(c.Request.Context(), att); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create attachment record"})
if err := h.stores.Files.Create(c.Request.Context(), att); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create file record"})
return
}
// Now build the real storage key and update
att.StorageKey = fmt.Sprintf("attachments/%s/%s_%s", channelID, att.ID, att.Filename)
att.StorageKey = fmt.Sprintf("files/%s/%s_%s", channelID, att.ID, att.Filename)
// Write to object store
if err := h.objStore.Put(c.Request.Context(), att.StorageKey, file, header.Size, contentType); err != nil {
// Rollback PG row on storage failure
h.stores.Attachments.Delete(c.Request.Context(), att.ID)
h.stores.Files.Delete(c.Request.Context(), att.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
return
}
// Update storage_key in PG
database.DB.ExecContext(c.Request.Context(),
database.Q(`UPDATE attachments SET storage_key = $1 WHERE id = $2`),
database.Q(`UPDATE files SET storage_key = $1 WHERE id = $2`),
att.StorageKey, att.ID)
// For images, mark extraction as not needed (complete immediately)
if strings.HasPrefix(contentType, "image/") {
h.stores.Attachments.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
h.stores.Files.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
"extraction_status": "complete",
})
att.Metadata["extraction_status"] = "complete"
@@ -178,8 +180,8 @@ func (h *AttachmentHandler) Upload(c *gin.Context) {
if _, err := file.Seek(0, io.SeekStart); err == nil {
if textBytes, err := io.ReadAll(file); err == nil {
text := string(textBytes)
h.stores.Attachments.SetExtractedText(c.Request.Context(), att.ID, text)
h.stores.Attachments.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
h.stores.Files.SetExtractedText(c.Request.Context(), att.ID, text)
h.stores.Files.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
"extraction_status": "complete",
})
att.Metadata["extraction_status"] = "complete"
@@ -199,9 +201,9 @@ func (h *AttachmentHandler) Upload(c *gin.Context) {
}
// ── Download ───────────────────────────────
// GET /api/v1/attachments/:id/download
// GET /api/v1/files/:id/download
// Streams file content with auth check via channel membership.
func (h *AttachmentHandler) Download(c *gin.Context) {
func (h *FileHandler) Download(c *gin.Context) {
if h.objStore == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
@@ -210,9 +212,9 @@ func (h *AttachmentHandler) Download(c *gin.Context) {
userID := getUserID(c)
attID := c.Param("id")
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
att, err := h.stores.Files.GetByID(c.Request.Context(), attID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"})
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
@@ -236,14 +238,14 @@ func (h *AttachmentHandler) Download(c *gin.Context) {
}
// ── Get Metadata ───────────────────────────
// GET /api/v1/attachments/:id
func (h *AttachmentHandler) GetMetadata(c *gin.Context) {
// GET /api/v1/files/:id
func (h *FileHandler) GetMetadata(c *gin.Context) {
userID := getUserID(c)
attID := c.Param("id")
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
att, err := h.stores.Files.GetByID(c.Request.Context(), attID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"})
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
@@ -254,9 +256,9 @@ func (h *AttachmentHandler) GetMetadata(c *gin.Context) {
c.JSON(http.StatusOK, att)
}
// ── List Channel Attachments ───────────────
// GET /api/v1/channels/:id/attachments
func (h *AttachmentHandler) ListByChannel(c *gin.Context) {
// ── List Channel Files ───────────────
// GET /api/v1/channels/:id/files
func (h *FileHandler) ListByChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
@@ -264,27 +266,27 @@ func (h *AttachmentHandler) ListByChannel(c *gin.Context) {
return
}
attachments, err := h.stores.Attachments.GetByChannel(c.Request.Context(), channelID)
files, err := h.stores.Files.GetByChannel(c.Request.Context(), channelID, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list attachments"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return
}
if attachments == nil {
attachments = []models.Attachment{}
if files == nil {
files = []models.File{}
}
c.JSON(http.StatusOK, gin.H{"attachments": attachments})
c.JSON(http.StatusOK, gin.H{"files": files})
}
// ── Delete ─────────────────────────────────
// DELETE /api/v1/attachments/:id
func (h *AttachmentHandler) DeleteAttachment(c *gin.Context) {
// DELETE /api/v1/files/:id
func (h *FileHandler) Delete(c *gin.Context) {
userID := getUserID(c)
attID := c.Param("id")
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
att, err := h.stores.Files.GetByID(c.Request.Context(), attID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"})
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
@@ -293,9 +295,9 @@ func (h *AttachmentHandler) DeleteAttachment(c *gin.Context) {
}
// Delete from PG (returns the row for storage cleanup)
deleted, err := h.stores.Attachments.Delete(c.Request.Context(), attID)
deleted, err := h.stores.Files.Delete(c.Request.Context(), attID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete attachment"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete file"})
return
}
@@ -312,13 +314,13 @@ func (h *AttachmentHandler) DeleteAttachment(c *gin.Context) {
}()
}
c.JSON(http.StatusOK, gin.H{"message": "attachment deleted"})
c.JSON(http.StatusOK, gin.H{"message": "file deleted"})
}
// ── Admin: Orphan Cleanup ──────────────────
// POST /admin/storage/cleanup
func (h *AttachmentHandler) CleanupOrphans(c *gin.Context) {
orphans, err := h.stores.Attachments.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
func (h *FileHandler) CleanupOrphans(c *gin.Context) {
orphans, err := h.stores.Files.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list orphans"})
return
@@ -328,7 +330,7 @@ func (h *AttachmentHandler) CleanupOrphans(c *gin.Context) {
var freedBytes int64
for _, att := range orphans {
if _, err := h.stores.Attachments.Delete(c.Request.Context(), att.ID); err != nil {
if _, err := h.stores.Files.Delete(c.Request.Context(), att.ID); err != nil {
log.Printf("orphan cleanup: failed to delete %s from PG: %v", att.ID, err)
continue
}
@@ -349,8 +351,8 @@ func (h *AttachmentHandler) CleanupOrphans(c *gin.Context) {
// ── Admin: Orphan Count ────────────────────
// GET /admin/storage/orphans (for the admin panel card)
func (h *AttachmentHandler) OrphanCount(c *gin.Context) {
orphans, err := h.stores.Attachments.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
func (h *FileHandler) OrphanCount(c *gin.Context) {
orphans, err := h.stores.Files.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count orphans"})
return
@@ -369,7 +371,7 @@ func (h *AttachmentHandler) OrphanCount(c *gin.Context) {
// ── Admin: Extraction Queue Status ─────────
// GET /admin/storage/extraction
func (h *AttachmentHandler) ExtractionStatus(c *gin.Context) {
func (h *FileHandler) ExtractionStatus(c *gin.Context) {
if h.extQueue == nil {
c.JSON(http.StatusOK, gin.H{
"enabled": false,
@@ -403,12 +405,12 @@ func (h *AttachmentHandler) ExtractionStatus(c *gin.Context) {
// ── Channel Delete Hook ────────────────────
// Called by ChannelHandler.DeleteChannel to clean up storage.
func (h *AttachmentHandler) CleanupChannelStorage(channelID string) {
func (h *FileHandler) CleanupChannelStorage(channelID string) {
if h.objStore == nil {
return
}
// CASCADE already deleted PG rows. Clean up filesystem.
prefix := fmt.Sprintf("attachments/%s", channelID)
prefix := fmt.Sprintf("files/%s", channelID)
if err := h.objStore.DeletePrefix(context.Background(), prefix); err != nil {
log.Printf("storage cleanup for channel %s failed: %v", channelID, err)
}
@@ -418,7 +420,7 @@ func (h *AttachmentHandler) CleanupChannelStorage(channelID string) {
// verifyChannelAccess checks that the requesting user owns the channel.
// Future RBAC (v0.20.0): replace with rbac.Can(userID, channelID, permission).
func (h *AttachmentHandler) verifyChannelAccess(c *gin.Context, channelID, userID string) bool {
func (h *FileHandler) verifyChannelAccess(c *gin.Context, channelID, userID string) bool {
var ownerID string
err := database.DB.QueryRowContext(c.Request.Context(),
database.Q(`SELECT user_id FROM channels WHERE id = $1`), channelID).Scan(&ownerID)
@@ -461,7 +463,7 @@ func sanitizeFilename(name string) string {
// UploadToProject handles project-scoped file uploads.
// POST /api/v1/projects/:id/files
func (h *AttachmentHandler) UploadToProject(c *gin.Context) {
func (h *FileHandler) UploadToProject(c *gin.Context) {
if h.objStore == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
@@ -516,17 +518,19 @@ func (h *AttachmentHandler) UploadToProject(c *gin.Context) {
return
}
att := models.Attachment{
att := models.File{
ChannelID: "", // no channel association
UserID: userID,
ProjectID: &projectID,
Origin: models.FileOriginUserUpload,
Filename: header.Filename,
ContentType: contentType,
SizeBytes: header.Size,
StorageKey: storageKey,
DisplayHint: displayHintFor(contentType),
}
if err := h.stores.Attachments.Create(c.Request.Context(), &att); err != nil {
if err := h.stores.Files.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
@@ -543,7 +547,7 @@ func (h *AttachmentHandler) UploadToProject(c *gin.Context) {
// ListByProject returns all files uploaded to a project.
// GET /api/v1/projects/:id/files
func (h *AttachmentHandler) ListByProject(c *gin.Context) {
func (h *FileHandler) ListByProject(c *gin.Context) {
userID := getUserID(c)
projectID := c.Param("id")
@@ -562,7 +566,7 @@ func (h *AttachmentHandler) ListByProject(c *gin.Context) {
return
}
files, err := h.stores.Attachments.GetByProject(c.Request.Context(), projectID)
files, err := h.stores.Files.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"})
@@ -589,3 +593,23 @@ var extToMIME = map[string]string{
".csv": "text/csv",
".svg": "image/svg+xml",
}
// displayHintFor returns the appropriate display hint for a content type.
func displayHintFor(contentType string) string {
switch {
case strings.HasPrefix(contentType, "image/"):
return models.FileHintInline
case strings.HasPrefix(contentType, "video/"):
return models.FileHintInline
case strings.HasPrefix(contentType, "audio/"):
return models.FileHintInline
case contentType == "application/pdf":
return models.FileHintThumbnail
case strings.HasPrefix(contentType, "text/"):
return models.FileHintInline
case contentType == "application/json":
return models.FileHintInline
default:
return models.FileHintDownload
}
}

View File

@@ -238,13 +238,13 @@ func setupHarness(t *testing.T) *testHarness {
protected.GET("/knowledge-bases-discoverable", kbH.ListDiscoverableKBs) // v0.17.0
protected.PUT("/knowledge-bases/:id/discoverable", kbH.SetDiscoverable) // v0.17.0
// Attachments (nil storage = upload returns 503, but metadata works)
attachH := NewAttachmentHandler(stores, nil, nil)
protected.POST("/channels/:id/attachments", attachH.Upload)
protected.GET("/channels/:id/attachments", attachH.ListByChannel)
protected.GET("/attachments/:id", attachH.GetMetadata)
protected.GET("/attachments/:id/download", attachH.Download)
protected.DELETE("/attachments/:id", attachH.DeleteAttachment)
// Files (nil storage = upload returns 503, but metadata works)
fileH := NewFileHandler(stores, nil, nil)
protected.POST("/channels/:id/files", fileH.Upload)
protected.GET("/channels/:id/files", fileH.ListByChannel)
protected.GET("/files/:id", fileH.GetMetadata)
protected.GET("/files/:id/download", fileH.Download)
protected.DELETE("/files/:id", fileH.Delete)
// Completions
completions := NewCompletionHandler(nil, stores, nil, nil, nil)

View File

@@ -10,7 +10,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// PersonaHandler handles persona (formerly preset) endpoints.
// PersonaHandler handles persona endpoints.
type PersonaHandler struct {
stores store.Stores
}