diff --git a/docs/ICD/channels.md b/docs/ICD/channels.md index 2e48295..da787ff 100644 --- a/docs/ICD/channels.md +++ b/docs/ICD/channels.md @@ -14,21 +14,33 @@ multi-model workflows. GET /channels?page=1&per_page=50 ``` +Optional query filters: `type`, `types` (comma-separated), `folder`, +`folder_id`, `search`, `project_id` (UUID or `"none"`), `archived`. + Returns pagination envelope. Each channel: ```json { "id": "uuid", + "user_id": "uuid", "title": "My Chat", "type": "direct|dm|group|channel|workflow|service", + "ai_mode": "auto|mention_only|off", + "topic": "string|null", "description": "", "model": "claude-sonnet-4-20250514", "provider_config_id": "uuid|null", "system_prompt": "", - "project_id": "uuid|null", + "is_archived": false, + "is_pinned": false, "folder": "string|null", + "folder_id": "uuid|null", + "project_id": "uuid|null", + "workspace_id": "uuid|null", "tags": ["tag1", "tag2"], - "participant_count": 1, + "settings": {}, + "message_count": 0, + "unread_count": 0, "created_at": "...", "updated_at": "..." } @@ -60,7 +72,9 @@ POST /channels "provider_config_id": "uuid", "system_prompt": "", "folder": null, - "tags": [] + "folder_id": null, + "tags": [], + "participants": ["user-uuid"] } ``` @@ -69,13 +83,20 @@ participant with `role: "owner"`. If `model` and `provider_config_id` are provided, the channel model roster (`channel_models`) is auto-populated with an initial entry. +`participants` is only used for `dm` type — array of exactly one +other user UUID. The handler enforces DM dedup: if a DM already +exists between the two users, the existing channel is returned +(HTTP 200, not 201). + **Get channel:** ``` GET /channels/:id ``` -Returns single channel object. +Returns single channel object. Accessible by the channel owner +**or** any user who is a participant in the channel (via +`channel_participants`). **Update channel:** @@ -84,7 +105,10 @@ PUT /channels/:id ``` Accepts partial updates — only fields present in the body are changed. -Same field set as create. +Same field set as create, plus `is_archived`, `is_pinned`, `settings` +(JSONB merge), `workspace_id`, `ai_mode`, `topic`, `folder_id`. + +**Auth:** Owner only (keyed on `user_id` column). **Delete channel:** @@ -92,7 +116,10 @@ Same field set as create. DELETE /channels/:id ``` -Cascades: deletes messages, attachments, channel_models, channel_kbs. +Cascades: deletes messages, files, channel_models, channel_kbs, +channel_participants. Storage cleanup runs asynchronously. + +**Auth:** Owner only. ### Message Tree @@ -202,12 +229,12 @@ PUT /channels/:id/cursor ```json { - "message_id": "uuid", - "child_id": "uuid" + "active_leaf_id": "uuid" } ``` -Sets which child is active at the `message_id` branch point. +Sets which leaf message is active. The server walks the tree from +this leaf to the root and returns the full path. Subsequent `GET /channels/:id/path` will follow the new branch. **List siblings** — all children of a message's parent: @@ -270,38 +297,39 @@ credentials are loaded and used for the actual API call. **SSE Stream Format:** -The response is `Content-Type: text/event-stream`. Events: +The response is `Content-Type: text/event-stream`. Content and +reasoning deltas use OpenAI-compatible envelope format. Tool events +use named SSE events. ``` -data: {"content":"Hello"} +data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":null}],"model":"claude-sonnet-4-20250514"} -data: {"content":" world"} +data: {"choices":[{"delta":{"content":" world"},"finish_reason":null}],"model":"claude-sonnet-4-20250514"} -data: {"reasoning":"Let me think..."} +data: {"choices":[{"delta":{"reasoning_content":"Let me think..."},"finish_reason":null}],"model":"claude-sonnet-4-20250514"} event: tool_use -data: {"id":"call_1","name":"web_search","input":{"query":"..."}} +data: [{"id":"call_1","name":"web_search","input":{"query":"..."}}] event: tool_result -data: {"id":"call_1","content":"Search results..."} +data: [{"id":"call_1","content":"Search results..."}] -data: {"content":"Based on the search..."} +data: {"choices":[{"delta":{"content":"Based on the search..."},"finish_reason":null}],"model":"claude-sonnet-4-20250514"} -event: finish -data: {"message_id":"uuid","model":"claude-sonnet-4-20250514","usage":{"input_tokens":150,"output_tokens":42}} +data: {"choices":[{"delta":{},"finish_reason":"stop"}],"model":"claude-sonnet-4-20250514"} data: [DONE] ``` | Event | `event:` field | Payload | |-------|---------------|---------| -| Content delta | _(unnamed)_ | `{ "content": "text" }` | -| Reasoning delta | _(unnamed)_ | `{ "reasoning": "text" }` | -| Tool invocation | `tool_use` | `{ "id", "name", "input" }` | -| Tool result | `tool_result` | `{ "id", "content" }` | -| Stream complete | `finish` | `{ "message_id", "model", "usage" }` | +| Content delta | _(unnamed)_ | OpenAI envelope: `choices[0].delta.content` | +| Reasoning delta | _(unnamed)_ | OpenAI envelope: `choices[0].delta.reasoning_content` | +| Tool invocation | `tool_use` | `[{ "id", "name", "input" }]` | +| Tool result | `tool_result` | `[{ "id", "content" }]` | +| Stream complete | _(unnamed)_ | OpenAI envelope: `choices[0].finish_reason` = `"stop"` or `"budget_exceeded"` | | End sentinel | _(unnamed)_ | `[DONE]` (literal string) | -| Error mid-stream | `error` | `{ "error": "message" }` | +| Error mid-stream | _(unnamed)_ | `{ "error": "message" }` | **Tool cycle:** Tool use and tool result events can repeat up to `max_tool_iterations` (configured server-side). The handler executes @@ -311,7 +339,8 @@ tools, feeds results back to the model, and continues streaming. ``` X-Switchboard-Provider: providerID/configID -X-Switchboard-Route: policy-name (when routing policy is active) +X-Switchboard-Route: policy-name (when routing policy is active) +X-Switchboard-Fallback: depth (when fallback chain was triggered) ``` **List available tools:** @@ -401,25 +430,10 @@ Content-Type: multipart/form-data Field: `file`. Sets `origin: "user_upload"`. Returns the file object. -**Create (tool output):** - -``` -POST /files -``` - -```json -{ - "channel_id": "uuid", - "message_id": "uuid", - "filename": "generated_image.png", - "content_type": "image/png", - "display_hint": "inline", - "metadata": { "tool_name": "image_generation" } -} -``` - -Body: multipart `file` field or base64 `data` field. -Sets `origin: "tool_output"`. Returns file object. +**Create (tool output):** Tool-generated files are created internally +by the tool execution loop. There is no public REST endpoint for +tool output creation — the completion handler writes files directly +via the store layer. **List by channel:** @@ -432,17 +446,19 @@ Returns `{ "files": [...] }`. Filterable by `origin`, `content_type`. **List by message:** ``` -GET /messages/:id/files → { "files": [...] } +GET /messages/:id/files ``` -How the frontend discovers generated artifacts to render inline. +Returns `{ "files": [...] }`. How the frontend discovers generated +artifacts to render inline. **List by user (file manager):** ``` -GET /files?page=1&per_page=50 → { "files": [...], "total": N } +GET /files?page=1&per_page=50 ``` +Returns `{ "files": [...], "total": N, "page": N, "per_page": N }`. All files owned by the authenticated user, paginated. **Get metadata:** @@ -478,7 +494,7 @@ GET /files/:id/download Returns the file with appropriate `Content-Type` and `Content-Disposition` headers. -**Thumbnail:** +**Thumbnail** _(planned — v0.28.0+):_ ``` GET /files/:id/thumbnail @@ -614,35 +630,40 @@ No request body needed. Returns `{ "ok": true }`. **Bulk query:** ``` -GET /presence?users=alice,bob,charlie +GET /presence?users=uuid1,uuid2,uuid3 ``` -Returns online status for listed usernames. Threshold: online if -last heartbeat < 90 seconds ago. +Returns online/offline status for listed user UUIDs. Threshold: +online if last heartbeat < 90 seconds ago. ```json { - "users": { - "alice": { "status": "online", "last_seen": "..." }, - "bob": { "status": "offline", "last_seen": "..." } + "presence": { + "uuid1": "online", + "uuid2": "offline" } } ``` +Values are flat status strings. Capped at 100 user IDs per request. + Subsequent updates delivered via WebSocket `presence.changed` events. -**Typing indicators** are sent via WebSocket (see websocket.md). The -typing event payload includes `participant_id` and `participant_type`. +**Typing indicators** are sent via `POST /channels/:id/typing` +(authenticated, broadcasts `typing.user` WebSocket event to other +user participants) and via WebSocket (see websocket.md). The typing +event payload includes `channel_id`, `user_id`, and `display_name`. ### Chat Folders -User-scoped grouping for chats. Folders have no semantic weight — they -are named drawers. +User-scoped grouping for chats. Folders have no semantic weight — +they are named drawers. The schema supports nesting via `parent_id` +(DnD nesting not yet implemented in the UI). ``` -GET /folders → list user's folders -POST /folders ← { "name": "Research" } -PUT /folders/:id ← { "name": "Renamed" } +GET /folders → { "folders": [...] } +POST /folders ← { "name": "Research", "sort_order": 0 } +PUT /folders/:id ← { "name": "Renamed", "sort_order": 1 } DELETE /folders/:id → chats become unfiled ``` @@ -651,7 +672,6 @@ DELETE /folders/:id → chats become unfiled ```json { "id": "uuid", - "user_id": "uuid", "name": "Research", "parent_id": "uuid|null", "sort_order": 0, @@ -661,7 +681,9 @@ DELETE /folders/:id → chats become unfiled ``` Moving a chat into/out of a folder is done via `PUT /channels/:id` -with `{ "folder_id": "uuid" }` or `{ "folder_id": null }`. +with `{ "folder_id": "uuid" }` or `{ "folder_id": "" }` to unbind. + +The channel list also supports `?folder_id=uuid` as a query filter. **Auth:** Authenticated (user-scoped). @@ -673,8 +695,13 @@ Additional channel fields (set via `PUT /channels/:id`): |-------|------|---------|-------------| | `ai_mode` | string | `auto` | `auto`, `mention_only`, or `off` | | `topic` | string | null | Short description shown in header | -| `allow_anonymous` | boolean | false | Session participants can join (workflow channels) | -| `kb_auto_inject` | boolean | false | Top-K KB chunks auto-prepend to context | + +Fields managed internally (not settable via channel update): + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `allow_anonymous` | boolean | false | Session participants can join. Set by workflow instance handlers on channel creation. | +| `kb_auto_inject` | boolean | false | Top-K KB chunks auto-prepend to context _(v0.28.0 — schema exists, not yet wired)_. | `ai_mode` behavioral matrix: @@ -702,8 +729,10 @@ calculation. GET /users/search?q=alice ``` -Returns `{ "data": [{ "id", "username", "handle", "display_name", "avatar_url" }] }`. -Used for DM creation and participant picker. Matches on handle. +Returns `{ "users": [{ "id", "username", "display_name", "handle" }] }`. +Matches on username, display_name, and handle (case-insensitive +substring). Used for DM creation and participant picker. Excludes the +calling user. Max 20 results. **Auth:** Authenticated. diff --git a/server/handlers/channel_models.go b/server/handlers/channel_models.go index e9ccb67..30be966 100644 --- a/server/handlers/channel_models.go +++ b/server/handlers/channel_models.go @@ -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}) } diff --git a/server/handlers/channels.go b/server/handlers/channels.go index 0861929..d82756f 100644 --- a/server/handlers/channels.go +++ b/server/handlers/channels.go @@ -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)) } diff --git a/server/handlers/completion.go b/server/handlers/completion.go index 601db03..f3f1db0 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -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 ── diff --git a/server/handlers/files.go b/server/handlers/files.go index c74ad3d..ce2a389 100644 --- a/server/handlers/files.go +++ b/server/handlers/files.go @@ -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) { diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go index 9419602..c3ab0d2 100644 --- a/server/handlers/integration_test.go +++ b/server/handlers/integration_test.go @@ -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)) } diff --git a/server/handlers/messages.go b/server/handlers/messages.go index 6fd1319..2815f21 100644 --- a/server/handlers/messages.go +++ b/server/handlers/messages.go @@ -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 ─────────────────────────── diff --git a/server/main.go b/server/main.go index acd824a..3e4c0c2 100644 --- a/server/main.go +++ b/server/main.go @@ -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() diff --git a/server/store/postgres/channel.go b/server/store/postgres/channel.go index ac686b3..0b9e183 100644 --- a/server/store/postgres/channel.go +++ b/server/store/postgres/channel.go @@ -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 } diff --git a/server/store/sqlite/channel.go b/server/store/sqlite/channel.go index 9089607..1993ca3 100644 --- a/server/store/sqlite/channel.go +++ b/server/store/sqlite/channel.go @@ -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 } diff --git a/src/js/channel-models.js b/src/js/channel-models.js index e6fbf78..e847a76 100644 --- a/src/js/channel-models.js +++ b/src/js/channel-models.js @@ -35,7 +35,8 @@ const ChannelModels = { } try { const roster = await API.listChannelModels(channelId); - this._roster = Array.isArray(roster) ? roster : []; + const models = roster.models || roster; + this._roster = Array.isArray(models) ? models : []; } catch (e) { console.debug('Channel models not loaded:', e.message); this._roster = []; diff --git a/src/js/chat.js b/src/js/chat.js index cf3e7ea..432dedd 100644 --- a/src/js/chat.js +++ b/src/js/chat.js @@ -150,7 +150,7 @@ async function summarizeAndContinue() { const resp = await API.getActivePath(channelId); const chat = App.chats.find(c => c.id === channelId); if (chat && resp) { - chat.messages = (resp.path || []).map(m => ({ + chat.messages = (resp.messages || []).map(m => ({ id: m.id, parent_id: m.parent_id || null, role: m.role, @@ -244,7 +244,7 @@ async function selectChat(chatId) { if (chat.messages.length === 0 && chat.messageCount > 0) { try { const resp = await API.getActivePath(chatId); - chat.messages = (resp.path || []).map(m => ({ + chat.messages = (resp.messages || []).map(m => ({ id: m.id, parent_id: m.parent_id || null, role: m.role, @@ -767,7 +767,7 @@ async function reloadActivePath() { try { const resp = await API.getActivePath(App.activeId); - chat.messages = (resp.path || []).map(m => ({ + chat.messages = (resp.messages || []).map(m => ({ id: m.id, parent_id: m.parent_id || null, role: m.role, @@ -958,8 +958,8 @@ async function switchSibling(messageId, direction) { // Update local state with the new path const chat = App.getActiveChat(); - if (chat && resp.path) { - chat.messages = resp.path.map(m => ({ + if (chat && resp.messages) { + chat.messages = resp.messages.map(m => ({ id: m.id, parent_id: m.parent_id || null, role: m.role, diff --git a/src/js/tools-toggle.js b/src/js/tools-toggle.js index 6a57589..00d70bd 100644 --- a/src/js/tools-toggle.js +++ b/src/js/tools-toggle.js @@ -33,7 +33,7 @@ const ToolsToggle = (() => { _loadDisabled(); try { const resp = await API.getTools(); - _tools = resp.data || []; + _tools = resp.tools || []; _buildCategories(); _render(); _show(); @@ -52,7 +52,7 @@ const ToolsToggle = (() => { async function refresh() { try { const resp = await API.getTools(); - _tools = resp.data || []; + _tools = resp.tools || []; _buildCategories(); _render(); } catch (e) { /* silent */ }