Changeset 0.28.0.5 (#177)

This commit is contained in:
2026-03-12 15:47:22 +00:00
parent 52bd36ba48
commit 8f20e5fa60
13 changed files with 225 additions and 107 deletions

View File

@@ -46,7 +46,7 @@ func (h *ChannelModelHandler) List(c *gin.Context) {
if roster == nil {
roster = []models.ChannelModel{}
}
c.JSON(http.StatusOK, roster)
c.JSON(http.StatusOK, gin.H{"models": roster})
}
// ── Add ──────────────────────────────────────
@@ -109,6 +109,7 @@ func (h *ChannelModelHandler) Add(c *gin.Context) {
// Return the updated roster
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if roster == nil { roster = []models.ChannelModel{} }
c.JSON(http.StatusCreated, gin.H{"models": roster})
}
@@ -175,6 +176,7 @@ func (h *ChannelModelHandler) Update(c *gin.Context) {
}
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if roster == nil { roster = []models.ChannelModel{} }
c.JSON(http.StatusOK, gin.H{"models": roster})
}
@@ -219,6 +221,7 @@ func (h *ChannelModelHandler) Delete(c *gin.Context) {
}
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if roster == nil { roster = []models.ChannelModel{} }
c.JSON(http.StatusOK, gin.H{"models": roster})
}

View File

@@ -25,6 +25,7 @@ type createChannelRequest struct {
SystemPrompt string `json:"system_prompt,omitempty"`
ProviderConfigID *string `json:"provider_config_id,omitempty"`
Folder string `json:"folder,omitempty"`
FolderID *string `json:"folder_id,omitempty"`
Tags []string `json:"tags,omitempty"`
// v0.23.2: DM creation
Participants []string `json:"participants,omitempty"` // user IDs for DM (exactly 1 other user)
@@ -40,6 +41,7 @@ type updateChannelRequest struct {
IsArchived *bool `json:"is_archived,omitempty"`
IsPinned *bool `json:"is_pinned,omitempty"`
Folder *string `json:"folder,omitempty"`
FolderID *string `json:"folder_id,omitempty"`
Tags []string `json:"tags,omitempty"`
Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings
WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5)
@@ -61,6 +63,7 @@ type channelResponse struct {
IsArchived bool `json:"is_archived"`
IsPinned bool `json:"is_pinned"`
Folder *string `json:"folder"`
FolderID *string `json:"folder_id,omitempty"`
ProjectID *string `json:"project_id,omitempty"`
WorkspaceID *string `json:"workspace_id,omitempty"`
Tags []string `json:"tags"`
@@ -162,6 +165,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
// Optional filters
archived := c.DefaultQuery("archived", "false")
folder := c.Query("folder")
folderID := c.Query("folder_id") // UUID — preferred over folder (text)
channelType := c.DefaultQuery("type", "") // empty = all types
// v0.23.1: multi-value type filter. Supports both ?types=dm&types=channel
// and comma-joined ?types=dm,channel
@@ -202,6 +206,11 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
countArgs = append(countArgs, folder)
argN++
}
if folderID != "" {
countQuery += ` AND c.folder_id = $` + strconv.Itoa(argN)
countArgs = append(countArgs, folderID)
argN++
}
if search != "" {
countQuery += ` AND c.title ILIKE $` + strconv.Itoa(argN)
countArgs = append(countArgs, "%"+search+"%")
@@ -226,7 +235,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
query := `
SELECT c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic,
c.description, c.model, c.provider_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, c.workspace_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.folder_id, c.project_id, c.workspace_id,
c.tags, c.settings,
COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at
@@ -259,6 +268,11 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
args = append(args, folder)
argN++
}
if folderID != "" {
query += ` AND c.folder_id = $` + strconv.Itoa(argN)
args = append(args, folderID)
argN++
}
if search != "" {
query += ` AND c.title ILIKE $` + strconv.Itoa(argN)
args = append(args, "%"+search+"%")
@@ -289,7 +303,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
err := rows.Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic,
&ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.FolderID, &ch.ProjectID, &ch.WorkspaceID,
scanTags(&tags), scanJSON(&ch.Settings),
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
)
@@ -380,14 +394,16 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
var ch channelResponse
var tags []string
err := database.DB.QueryRow(database.Q(`
SELECT id, user_id, title, type, description, model, provider_config_id,
system_prompt, is_archived, is_pinned, folder, project_id, workspace_id,
SELECT id, user_id, title, type, ai_mode, topic,
description, model, provider_config_id,
system_prompt, is_archived, is_pinned, folder, folder_id, project_id, workspace_id,
tags, settings,
created_at, updated_at
FROM channels WHERE id = $1
`), existingID).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic,
&ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.FolderID, &ch.ProjectID, &ch.WorkspaceID,
scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
)
if err == nil {
@@ -410,10 +426,10 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
id := store.NewID()
_, err := database.DB.Exec(`
INSERT INTO channels (id, user_id, title, type, description, model,
system_prompt, provider_config_id, folder, tags, ai_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
system_prompt, provider_config_id, folder, folder_id, tags, ai_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, userID, req.Title, channelType, req.Description, req.Model,
req.SystemPrompt, req.ProviderConfigID, req.Folder, writeTagsArg(req.Tags), aiMode,
req.SystemPrompt, req.ProviderConfigID, req.Folder, req.FolderID, writeTagsArg(req.Tags), aiMode,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
@@ -421,13 +437,15 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
}
// Read back the row
err = database.DB.QueryRow(`
SELECT id, user_id, title, type, description, model, provider_config_id,
system_prompt, is_archived, is_pinned, folder, project_id, workspace_id,
SELECT id, user_id, title, type, ai_mode, topic,
description, model, provider_config_id,
system_prompt, is_archived, is_pinned, folder, folder_id, project_id, workspace_id,
tags, settings,
created_at, updated_at
FROM channels WHERE id = ?`, id).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic,
&ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.FolderID, &ch.ProjectID, &ch.WorkspaceID,
scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
)
if err != nil {
@@ -436,15 +454,17 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
}
} else {
err := database.DB.QueryRow(`
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags, ai_mode)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
is_archived, is_pinned, folder, project_id, workspace_id, tags, settings, created_at, updated_at
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, folder_id, tags, ai_mode)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, user_id, title, type, ai_mode, topic,
description, model, provider_config_id, system_prompt,
is_archived, is_pinned, folder, folder_id, project_id, workspace_id, tags, settings, created_at, updated_at
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.ProviderConfigID,
req.Folder, pq.Array(req.Tags), aiMode,
req.Folder, req.FolderID, pq.Array(req.Tags), aiMode,
).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic,
&ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.FolderID, &ch.ProjectID, &ch.WorkspaceID,
pq.Array(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
)
if err != nil {
@@ -525,8 +545,9 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
var ch channelResponse
var tags []string
err := database.DB.QueryRow(database.Q(`
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, c.workspace_id,
SELECT c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic,
c.description, c.model, c.provider_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.folder_id, c.project_id, c.workspace_id,
c.tags, c.settings,
COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at
@@ -534,10 +555,13 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
LEFT JOIN (
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
) mc ON mc.channel_id = c.id
WHERE c.id = $1 AND c.user_id = $2
WHERE c.id = $1 AND (c.user_id = $2 OR c.id IN (
SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $2
))
`), channelID, userID).Scan(
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic,
&ch.Description, &ch.Model, &ch.ProviderConfigID,
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.FolderID, &ch.ProjectID, &ch.WorkspaceID,
scanTags(&tags), scanJSON(&ch.Settings),
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
)
@@ -621,6 +645,13 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
if req.Folder != nil {
addClause("folder", *req.Folder)
}
if req.FolderID != nil {
if *req.FolderID == "" {
addClause("folder_id", nil) // unbind from folder
} else {
addClause("folder_id", *req.FolderID)
}
}
if req.Tags != nil {
addClause("tags", writeTagsArg(req.Tags))
}

View File

@@ -924,7 +924,7 @@ func (h *CompletionHandler) ListTools(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{"data": result})
c.JSON(http.StatusOK, gin.H{"tools": result})
}
// ── Streaming Completion (SSE) with Tool Loop ──

View File

@@ -278,6 +278,48 @@ func (h *FileHandler) ListByChannel(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"files": files})
}
// ── List by Message ───────────────────────
// GET /api/v1/messages/:id/files
// Returns files attached to a specific message (inline artifacts, tool output).
func (h *FileHandler) ListByMessage(c *gin.Context) {
messageID := c.Param("id")
files, err := h.stores.Files.GetByMessage(c.Request.Context(), messageID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return
}
if files == nil {
files = []models.File{}
}
c.JSON(http.StatusOK, gin.H{"files": files})
}
// ── List by User (File Manager) ───────────
// GET /api/v1/files?page=1&per_page=50
// Paginated list of all files owned by the authenticated user.
func (h *FileHandler) ListByUser(c *gin.Context) {
userID := getUserID(c)
page, perPage, _ := parsePagination(c)
files, total, err := h.stores.Files.GetByUser(c.Request.Context(), userID, page, perPage)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return
}
if files == nil {
files = []models.File{}
}
c.JSON(http.StatusOK, gin.H{
"files": files,
"total": total,
"page": page,
"per_page": perPage,
})
}
// ── Delete ─────────────────────────────────
// DELETE /api/v1/files/:id
func (h *FileHandler) Delete(c *gin.Context) {

View File

@@ -3361,7 +3361,7 @@ func TestIntegration_Messages_TreePath(t *testing.T) {
}
var pathEnv map[string]interface{}
decode(w, &pathEnv)
pathResp := pathEnv["path"].([]interface{})
pathResp := pathEnv["messages"].([]interface{})
if len(pathResp) != 3 {
t.Fatalf("expected 3 messages in path, got %d", len(pathResp))
}

View File

@@ -183,7 +183,7 @@ func (h *MessageHandler) GetActivePath(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"path": path})
c.JSON(http.StatusOK, gin.H{"messages": path})
}
// ── Create Message (manual) ─────────────────
@@ -702,7 +702,7 @@ func (h *MessageHandler) UpdateCursor(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"path": path, "active_leaf_id": leafID})
c.JSON(http.StatusOK, gin.H{"messages": path, "active_leaf_id": leafID})
}
// ── List Siblings ───────────────────────────

View File

@@ -818,9 +818,11 @@ func main() {
fileH := handlers.NewFileHandler(stores, objStore, extQueue)
protected.POST("/channels/:id/files", fileH.Upload)
protected.GET("/channels/:id/files", fileH.ListByChannel)
protected.GET("/files", fileH.ListByUser)
protected.GET("/files/:id", fileH.GetMetadata)
protected.GET("/files/:id/download", fileH.Download)
protected.DELETE("/files/:id", fileH.Delete)
protected.GET("/messages/:id/files", fileH.ListByMessage)
// Export (v0.22.4) — pandoc-based markdown → PDF/DOCX conversion
exportH := handlers.NewExportHandler()

View File

@@ -187,13 +187,18 @@ func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) er
if cm.PersonaID != nil && *cm.PersonaID != "" {
return s.SetPersonaModel(ctx, cm)
}
// Nullable provider_config_id: empty string → SQL NULL (UUID FK)
var provCfgID interface{} = cm.ProviderConfigID
if cm.ProviderConfigID == "" {
provCfgID = nil
}
// Raw model upsert (no persona) — matches idx_channel_models_raw partial index
_, err := DB.ExecContext(ctx, `
INSERT INTO channel_models (channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (channel_id, model_id, provider_config_id) WHERE persona_id IS NULL DO UPDATE SET
display_name = $4, system_prompt = $5, settings = $6, is_default = $7`,
cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
cm.ChannelID, cm.ModelID, provCfgID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
return err
}

View File

@@ -193,13 +193,18 @@ func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) er
if cm.PersonaID != nil && *cm.PersonaID != "" {
return s.SetPersonaModel(ctx, cm)
}
// Nullable provider_config_id: empty string → SQL NULL (UUID FK)
var provCfgID interface{} = cm.ProviderConfigID
if cm.ProviderConfigID == "" {
provCfgID = nil
}
// Raw model upsert (no persona) — matches idx_channel_models_raw partial index
_, err := DB.ExecContext(ctx, `
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (channel_id, model_id, provider_config_id) WHERE persona_id IS NULL DO UPDATE SET
display_name = excluded.display_name, system_prompt = excluded.system_prompt, settings = excluded.settings, is_default = excluded.is_default`,
store.NewID(), cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
store.NewID(), cm.ChannelID, cm.ModelID, provCfgID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
return err
}