Changeset 0.22.8 (#150)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user