Changeset 0.28.0.5 (#177)
This commit is contained in:
@@ -14,21 +14,33 @@ multi-model workflows.
|
|||||||
GET /channels?page=1&per_page=50
|
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:
|
Returns pagination envelope. Each channel:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
|
"user_id": "uuid",
|
||||||
"title": "My Chat",
|
"title": "My Chat",
|
||||||
"type": "direct|dm|group|channel|workflow|service",
|
"type": "direct|dm|group|channel|workflow|service",
|
||||||
|
"ai_mode": "auto|mention_only|off",
|
||||||
|
"topic": "string|null",
|
||||||
"description": "",
|
"description": "",
|
||||||
"model": "claude-sonnet-4-20250514",
|
"model": "claude-sonnet-4-20250514",
|
||||||
"provider_config_id": "uuid|null",
|
"provider_config_id": "uuid|null",
|
||||||
"system_prompt": "",
|
"system_prompt": "",
|
||||||
"project_id": "uuid|null",
|
"is_archived": false,
|
||||||
|
"is_pinned": false,
|
||||||
"folder": "string|null",
|
"folder": "string|null",
|
||||||
|
"folder_id": "uuid|null",
|
||||||
|
"project_id": "uuid|null",
|
||||||
|
"workspace_id": "uuid|null",
|
||||||
"tags": ["tag1", "tag2"],
|
"tags": ["tag1", "tag2"],
|
||||||
"participant_count": 1,
|
"settings": {},
|
||||||
|
"message_count": 0,
|
||||||
|
"unread_count": 0,
|
||||||
"created_at": "...",
|
"created_at": "...",
|
||||||
"updated_at": "..."
|
"updated_at": "..."
|
||||||
}
|
}
|
||||||
@@ -60,7 +72,9 @@ POST /channels
|
|||||||
"provider_config_id": "uuid",
|
"provider_config_id": "uuid",
|
||||||
"system_prompt": "",
|
"system_prompt": "",
|
||||||
"folder": null,
|
"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
|
are provided, the channel model roster (`channel_models`) is
|
||||||
auto-populated with an initial entry.
|
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 channel:**
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /channels/:id
|
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:**
|
**Update channel:**
|
||||||
|
|
||||||
@@ -84,7 +105,10 @@ PUT /channels/:id
|
|||||||
```
|
```
|
||||||
|
|
||||||
Accepts partial updates — only fields present in the body are changed.
|
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:**
|
**Delete channel:**
|
||||||
|
|
||||||
@@ -92,7 +116,10 @@ Same field set as create.
|
|||||||
DELETE /channels/:id
|
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
|
### Message Tree
|
||||||
|
|
||||||
@@ -202,12 +229,12 @@ PUT /channels/:id/cursor
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"message_id": "uuid",
|
"active_leaf_id": "uuid"
|
||||||
"child_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.
|
Subsequent `GET /channels/:id/path` will follow the new branch.
|
||||||
|
|
||||||
**List siblings** — all children of a message's parent:
|
**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:**
|
**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
|
event: tool_use
|
||||||
data: {"id":"call_1","name":"web_search","input":{"query":"..."}}
|
data: [{"id":"call_1","name":"web_search","input":{"query":"..."}}]
|
||||||
|
|
||||||
event: tool_result
|
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: {"choices":[{"delta":{},"finish_reason":"stop"}],"model":"claude-sonnet-4-20250514"}
|
||||||
data: {"message_id":"uuid","model":"claude-sonnet-4-20250514","usage":{"input_tokens":150,"output_tokens":42}}
|
|
||||||
|
|
||||||
data: [DONE]
|
data: [DONE]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Event | `event:` field | Payload |
|
| Event | `event:` field | Payload |
|
||||||
|-------|---------------|---------|
|
|-------|---------------|---------|
|
||||||
| Content delta | _(unnamed)_ | `{ "content": "text" }` |
|
| Content delta | _(unnamed)_ | OpenAI envelope: `choices[0].delta.content` |
|
||||||
| Reasoning delta | _(unnamed)_ | `{ "reasoning": "text" }` |
|
| Reasoning delta | _(unnamed)_ | OpenAI envelope: `choices[0].delta.reasoning_content` |
|
||||||
| Tool invocation | `tool_use` | `{ "id", "name", "input" }` |
|
| Tool invocation | `tool_use` | `[{ "id", "name", "input" }]` |
|
||||||
| Tool result | `tool_result` | `{ "id", "content" }` |
|
| Tool result | `tool_result` | `[{ "id", "content" }]` |
|
||||||
| Stream complete | `finish` | `{ "message_id", "model", "usage" }` |
|
| Stream complete | _(unnamed)_ | OpenAI envelope: `choices[0].finish_reason` = `"stop"` or `"budget_exceeded"` |
|
||||||
| End sentinel | _(unnamed)_ | `[DONE]` (literal string) |
|
| 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
|
**Tool cycle:** Tool use and tool result events can repeat up to
|
||||||
`max_tool_iterations` (configured server-side). The handler executes
|
`max_tool_iterations` (configured server-side). The handler executes
|
||||||
@@ -312,6 +340,7 @@ tools, feeds results back to the model, and continues streaming.
|
|||||||
```
|
```
|
||||||
X-Switchboard-Provider: providerID/configID
|
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:**
|
**List available tools:**
|
||||||
@@ -401,25 +430,10 @@ Content-Type: multipart/form-data
|
|||||||
|
|
||||||
Field: `file`. Sets `origin: "user_upload"`. Returns the file object.
|
Field: `file`. Sets `origin: "user_upload"`. Returns the file object.
|
||||||
|
|
||||||
**Create (tool output):**
|
**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
|
||||||
POST /files
|
via the store layer.
|
||||||
```
|
|
||||||
|
|
||||||
```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.
|
|
||||||
|
|
||||||
**List by channel:**
|
**List by channel:**
|
||||||
|
|
||||||
@@ -432,17 +446,19 @@ Returns `{ "files": [...] }`. Filterable by `origin`, `content_type`.
|
|||||||
**List by message:**
|
**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):**
|
**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.
|
All files owned by the authenticated user, paginated.
|
||||||
|
|
||||||
**Get metadata:**
|
**Get metadata:**
|
||||||
@@ -478,7 +494,7 @@ GET /files/:id/download
|
|||||||
Returns the file with appropriate `Content-Type` and
|
Returns the file with appropriate `Content-Type` and
|
||||||
`Content-Disposition` headers.
|
`Content-Disposition` headers.
|
||||||
|
|
||||||
**Thumbnail:**
|
**Thumbnail** _(planned — v0.28.0+):_
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /files/:id/thumbnail
|
GET /files/:id/thumbnail
|
||||||
@@ -614,35 +630,40 @@ No request body needed. Returns `{ "ok": true }`.
|
|||||||
**Bulk query:**
|
**Bulk query:**
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /presence?users=alice,bob,charlie
|
GET /presence?users=uuid1,uuid2,uuid3
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns online status for listed usernames. Threshold: online if
|
Returns online/offline status for listed user UUIDs. Threshold:
|
||||||
last heartbeat < 90 seconds ago.
|
online if last heartbeat < 90 seconds ago.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"users": {
|
"presence": {
|
||||||
"alice": { "status": "online", "last_seen": "..." },
|
"uuid1": "online",
|
||||||
"bob": { "status": "offline", "last_seen": "..." }
|
"uuid2": "offline"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Values are flat status strings. Capped at 100 user IDs per request.
|
||||||
|
|
||||||
Subsequent updates delivered via WebSocket `presence.changed` events.
|
Subsequent updates delivered via WebSocket `presence.changed` events.
|
||||||
|
|
||||||
**Typing indicators** are sent via WebSocket (see websocket.md). The
|
**Typing indicators** are sent via `POST /channels/:id/typing`
|
||||||
typing event payload includes `participant_id` and `participant_type`.
|
(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
|
### Chat Folders
|
||||||
|
|
||||||
User-scoped grouping for chats. Folders have no semantic weight — they
|
User-scoped grouping for chats. Folders have no semantic weight —
|
||||||
are named drawers.
|
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
|
GET /folders → { "folders": [...] }
|
||||||
POST /folders ← { "name": "Research" }
|
POST /folders ← { "name": "Research", "sort_order": 0 }
|
||||||
PUT /folders/:id ← { "name": "Renamed" }
|
PUT /folders/:id ← { "name": "Renamed", "sort_order": 1 }
|
||||||
DELETE /folders/:id → chats become unfiled
|
DELETE /folders/:id → chats become unfiled
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -651,7 +672,6 @@ DELETE /folders/:id → chats become unfiled
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
"user_id": "uuid",
|
|
||||||
"name": "Research",
|
"name": "Research",
|
||||||
"parent_id": "uuid|null",
|
"parent_id": "uuid|null",
|
||||||
"sort_order": 0,
|
"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`
|
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).
|
**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` |
|
| `ai_mode` | string | `auto` | `auto`, `mention_only`, or `off` |
|
||||||
| `topic` | string | null | Short description shown in header |
|
| `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:
|
`ai_mode` behavioral matrix:
|
||||||
|
|
||||||
@@ -702,8 +729,10 @@ calculation.
|
|||||||
GET /users/search?q=alice
|
GET /users/search?q=alice
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns `{ "data": [{ "id", "username", "handle", "display_name", "avatar_url" }] }`.
|
Returns `{ "users": [{ "id", "username", "display_name", "handle" }] }`.
|
||||||
Used for DM creation and participant picker. Matches on 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.
|
**Auth:** Authenticated.
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func (h *ChannelModelHandler) List(c *gin.Context) {
|
|||||||
if roster == nil {
|
if roster == nil {
|
||||||
roster = []models.ChannelModel{}
|
roster = []models.ChannelModel{}
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, roster)
|
c.JSON(http.StatusOK, gin.H{"models": roster})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Add ──────────────────────────────────────
|
// ── Add ──────────────────────────────────────
|
||||||
@@ -109,6 +109,7 @@ func (h *ChannelModelHandler) Add(c *gin.Context) {
|
|||||||
|
|
||||||
// Return the updated roster
|
// Return the updated roster
|
||||||
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
|
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
|
||||||
|
if roster == nil { roster = []models.ChannelModel{} }
|
||||||
c.JSON(http.StatusCreated, gin.H{"models": roster})
|
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)
|
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
|
||||||
|
if roster == nil { roster = []models.ChannelModel{} }
|
||||||
c.JSON(http.StatusOK, gin.H{"models": roster})
|
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)
|
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
|
||||||
|
if roster == nil { roster = []models.ChannelModel{} }
|
||||||
c.JSON(http.StatusOK, gin.H{"models": roster})
|
c.JSON(http.StatusOK, gin.H{"models": roster})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type createChannelRequest struct {
|
|||||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||||
ProviderConfigID *string `json:"provider_config_id,omitempty"`
|
ProviderConfigID *string `json:"provider_config_id,omitempty"`
|
||||||
Folder string `json:"folder,omitempty"`
|
Folder string `json:"folder,omitempty"`
|
||||||
|
FolderID *string `json:"folder_id,omitempty"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
// v0.23.2: DM creation
|
// v0.23.2: DM creation
|
||||||
Participants []string `json:"participants,omitempty"` // user IDs for DM (exactly 1 other user)
|
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"`
|
IsArchived *bool `json:"is_archived,omitempty"`
|
||||||
IsPinned *bool `json:"is_pinned,omitempty"`
|
IsPinned *bool `json:"is_pinned,omitempty"`
|
||||||
Folder *string `json:"folder,omitempty"`
|
Folder *string `json:"folder,omitempty"`
|
||||||
|
FolderID *string `json:"folder_id,omitempty"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings
|
Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings
|
||||||
WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5)
|
WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5)
|
||||||
@@ -61,6 +63,7 @@ type channelResponse struct {
|
|||||||
IsArchived bool `json:"is_archived"`
|
IsArchived bool `json:"is_archived"`
|
||||||
IsPinned bool `json:"is_pinned"`
|
IsPinned bool `json:"is_pinned"`
|
||||||
Folder *string `json:"folder"`
|
Folder *string `json:"folder"`
|
||||||
|
FolderID *string `json:"folder_id,omitempty"`
|
||||||
ProjectID *string `json:"project_id,omitempty"`
|
ProjectID *string `json:"project_id,omitempty"`
|
||||||
WorkspaceID *string `json:"workspace_id,omitempty"`
|
WorkspaceID *string `json:"workspace_id,omitempty"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
@@ -162,6 +165,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
|||||||
// Optional filters
|
// Optional filters
|
||||||
archived := c.DefaultQuery("archived", "false")
|
archived := c.DefaultQuery("archived", "false")
|
||||||
folder := c.Query("folder")
|
folder := c.Query("folder")
|
||||||
|
folderID := c.Query("folder_id") // UUID — preferred over folder (text)
|
||||||
channelType := c.DefaultQuery("type", "") // empty = all types
|
channelType := c.DefaultQuery("type", "") // empty = all types
|
||||||
// v0.23.1: multi-value type filter. Supports both ?types=dm&types=channel
|
// v0.23.1: multi-value type filter. Supports both ?types=dm&types=channel
|
||||||
// and comma-joined ?types=dm,channel
|
// and comma-joined ?types=dm,channel
|
||||||
@@ -202,6 +206,11 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
|||||||
countArgs = append(countArgs, folder)
|
countArgs = append(countArgs, folder)
|
||||||
argN++
|
argN++
|
||||||
}
|
}
|
||||||
|
if folderID != "" {
|
||||||
|
countQuery += ` AND c.folder_id = $` + strconv.Itoa(argN)
|
||||||
|
countArgs = append(countArgs, folderID)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
if search != "" {
|
if search != "" {
|
||||||
countQuery += ` AND c.title ILIKE $` + strconv.Itoa(argN)
|
countQuery += ` AND c.title ILIKE $` + strconv.Itoa(argN)
|
||||||
countArgs = append(countArgs, "%"+search+"%")
|
countArgs = append(countArgs, "%"+search+"%")
|
||||||
@@ -226,7 +235,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
|||||||
query := `
|
query := `
|
||||||
SELECT c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic,
|
SELECT c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic,
|
||||||
c.description, c.model, c.provider_config_id,
|
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,
|
c.tags, c.settings,
|
||||||
COALESCE(mc.cnt, 0) AS message_count,
|
COALESCE(mc.cnt, 0) AS message_count,
|
||||||
c.created_at, c.updated_at
|
c.created_at, c.updated_at
|
||||||
@@ -259,6 +268,11 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
|||||||
args = append(args, folder)
|
args = append(args, folder)
|
||||||
argN++
|
argN++
|
||||||
}
|
}
|
||||||
|
if folderID != "" {
|
||||||
|
query += ` AND c.folder_id = $` + strconv.Itoa(argN)
|
||||||
|
args = append(args, folderID)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
if search != "" {
|
if search != "" {
|
||||||
query += ` AND c.title ILIKE $` + strconv.Itoa(argN)
|
query += ` AND c.title ILIKE $` + strconv.Itoa(argN)
|
||||||
args = append(args, "%"+search+"%")
|
args = append(args, "%"+search+"%")
|
||||||
@@ -289,7 +303,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
|||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic,
|
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic,
|
||||||
&ch.Description, &ch.Model, &ch.ProviderConfigID,
|
&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),
|
scanTags(&tags), scanJSON(&ch.Settings),
|
||||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -380,14 +394,16 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
|||||||
var ch channelResponse
|
var ch channelResponse
|
||||||
var tags []string
|
var tags []string
|
||||||
err := database.DB.QueryRow(database.Q(`
|
err := database.DB.QueryRow(database.Q(`
|
||||||
SELECT id, user_id, title, type, description, model, provider_config_id,
|
SELECT id, user_id, title, type, ai_mode, topic,
|
||||||
system_prompt, is_archived, is_pinned, folder, project_id, workspace_id,
|
description, model, provider_config_id,
|
||||||
|
system_prompt, is_archived, is_pinned, folder, folder_id, project_id, workspace_id,
|
||||||
tags, settings,
|
tags, settings,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM channels WHERE id = $1
|
FROM channels WHERE id = $1
|
||||||
`), existingID).Scan(
|
`), existingID).Scan(
|
||||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
|
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic,
|
||||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
&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,
|
scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -410,10 +426,10 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
|||||||
id := store.NewID()
|
id := store.NewID()
|
||||||
_, err := database.DB.Exec(`
|
_, err := database.DB.Exec(`
|
||||||
INSERT INTO channels (id, user_id, title, type, description, model,
|
INSERT INTO channels (id, user_id, title, type, description, model,
|
||||||
system_prompt, provider_config_id, folder, tags, ai_mode)
|
system_prompt, provider_config_id, folder, folder_id, tags, ai_mode)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
id, userID, req.Title, channelType, req.Description, req.Model,
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
|
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
|
// Read back the row
|
||||||
err = database.DB.QueryRow(`
|
err = database.DB.QueryRow(`
|
||||||
SELECT id, user_id, title, type, description, model, provider_config_id,
|
SELECT id, user_id, title, type, ai_mode, topic,
|
||||||
system_prompt, is_archived, is_pinned, folder, project_id, workspace_id,
|
description, model, provider_config_id,
|
||||||
|
system_prompt, is_archived, is_pinned, folder, folder_id, project_id, workspace_id,
|
||||||
tags, settings,
|
tags, settings,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM channels WHERE id = ?`, id).Scan(
|
FROM channels WHERE id = ?`, id).Scan(
|
||||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
|
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic,
|
||||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
&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,
|
scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -436,15 +454,17 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
err := database.DB.QueryRow(`
|
err := database.DB.QueryRow(`
|
||||||
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags, ai_mode)
|
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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
|
RETURNING id, user_id, title, type, ai_mode, topic,
|
||||||
is_archived, is_pinned, folder, project_id, workspace_id, tags, settings, created_at, updated_at
|
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,
|
`, 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(
|
).Scan(
|
||||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
|
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic,
|
||||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
&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,
|
pq.Array(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -525,8 +545,9 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
|
|||||||
var ch channelResponse
|
var ch channelResponse
|
||||||
var tags []string
|
var tags []string
|
||||||
err := database.DB.QueryRow(database.Q(`
|
err := database.DB.QueryRow(database.Q(`
|
||||||
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
SELECT c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic,
|
||||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, c.workspace_id,
|
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,
|
c.tags, c.settings,
|
||||||
COALESCE(mc.cnt, 0) AS message_count,
|
COALESCE(mc.cnt, 0) AS message_count,
|
||||||
c.created_at, c.updated_at
|
c.created_at, c.updated_at
|
||||||
@@ -534,10 +555,13 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
|
|||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
|
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
|
||||||
) mc ON mc.channel_id = c.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(
|
`), channelID, userID).Scan(
|
||||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
|
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic,
|
||||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
&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),
|
scanTags(&tags), scanJSON(&ch.Settings),
|
||||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -621,6 +645,13 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
|
|||||||
if req.Folder != nil {
|
if req.Folder != nil {
|
||||||
addClause("folder", *req.Folder)
|
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 {
|
if req.Tags != nil {
|
||||||
addClause("tags", writeTagsArg(req.Tags))
|
addClause("tags", writeTagsArg(req.Tags))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ──
|
// ── Streaming Completion (SSE) with Tool Loop ──
|
||||||
|
|||||||
@@ -278,6 +278,48 @@ func (h *FileHandler) ListByChannel(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"files": files})
|
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 ─────────────────────────────────
|
||||||
// DELETE /api/v1/files/:id
|
// DELETE /api/v1/files/:id
|
||||||
func (h *FileHandler) Delete(c *gin.Context) {
|
func (h *FileHandler) Delete(c *gin.Context) {
|
||||||
|
|||||||
@@ -3361,7 +3361,7 @@ func TestIntegration_Messages_TreePath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
var pathEnv map[string]interface{}
|
var pathEnv map[string]interface{}
|
||||||
decode(w, &pathEnv)
|
decode(w, &pathEnv)
|
||||||
pathResp := pathEnv["path"].([]interface{})
|
pathResp := pathEnv["messages"].([]interface{})
|
||||||
if len(pathResp) != 3 {
|
if len(pathResp) != 3 {
|
||||||
t.Fatalf("expected 3 messages in path, got %d", len(pathResp))
|
t.Fatalf("expected 3 messages in path, got %d", len(pathResp))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ func (h *MessageHandler) GetActivePath(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"path": path})
|
c.JSON(http.StatusOK, gin.H{"messages": path})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Create Message (manual) ─────────────────
|
// ── Create Message (manual) ─────────────────
|
||||||
@@ -702,7 +702,7 @@ func (h *MessageHandler) UpdateCursor(c *gin.Context) {
|
|||||||
return
|
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 ───────────────────────────
|
// ── List Siblings ───────────────────────────
|
||||||
|
|||||||
@@ -818,9 +818,11 @@ func main() {
|
|||||||
fileH := handlers.NewFileHandler(stores, objStore, extQueue)
|
fileH := handlers.NewFileHandler(stores, objStore, extQueue)
|
||||||
protected.POST("/channels/:id/files", fileH.Upload)
|
protected.POST("/channels/:id/files", fileH.Upload)
|
||||||
protected.GET("/channels/:id/files", fileH.ListByChannel)
|
protected.GET("/channels/:id/files", fileH.ListByChannel)
|
||||||
|
protected.GET("/files", fileH.ListByUser)
|
||||||
protected.GET("/files/:id", fileH.GetMetadata)
|
protected.GET("/files/:id", fileH.GetMetadata)
|
||||||
protected.GET("/files/:id/download", fileH.Download)
|
protected.GET("/files/:id/download", fileH.Download)
|
||||||
protected.DELETE("/files/:id", fileH.Delete)
|
protected.DELETE("/files/:id", fileH.Delete)
|
||||||
|
protected.GET("/messages/:id/files", fileH.ListByMessage)
|
||||||
|
|
||||||
// Export (v0.22.4) — pandoc-based markdown → PDF/DOCX conversion
|
// Export (v0.22.4) — pandoc-based markdown → PDF/DOCX conversion
|
||||||
exportH := handlers.NewExportHandler()
|
exportH := handlers.NewExportHandler()
|
||||||
|
|||||||
@@ -187,13 +187,18 @@ func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) er
|
|||||||
if cm.PersonaID != nil && *cm.PersonaID != "" {
|
if cm.PersonaID != nil && *cm.PersonaID != "" {
|
||||||
return s.SetPersonaModel(ctx, cm)
|
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
|
// Raw model upsert (no persona) — matches idx_channel_models_raw partial index
|
||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
INSERT INTO channel_models (channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default)
|
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)
|
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
|
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`,
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -193,13 +193,18 @@ func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) er
|
|||||||
if cm.PersonaID != nil && *cm.PersonaID != "" {
|
if cm.PersonaID != nil && *cm.PersonaID != "" {
|
||||||
return s.SetPersonaModel(ctx, cm)
|
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
|
// Raw model upsert (no persona) — matches idx_channel_models_raw partial index
|
||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default)
|
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT (channel_id, model_id, provider_config_id) WHERE persona_id IS NULL DO UPDATE SET
|
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`,
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ const ChannelModels = {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const roster = await API.listChannelModels(channelId);
|
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) {
|
} catch (e) {
|
||||||
console.debug('Channel models not loaded:', e.message);
|
console.debug('Channel models not loaded:', e.message);
|
||||||
this._roster = [];
|
this._roster = [];
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ async function summarizeAndContinue() {
|
|||||||
const resp = await API.getActivePath(channelId);
|
const resp = await API.getActivePath(channelId);
|
||||||
const chat = App.chats.find(c => c.id === channelId);
|
const chat = App.chats.find(c => c.id === channelId);
|
||||||
if (chat && resp) {
|
if (chat && resp) {
|
||||||
chat.messages = (resp.path || []).map(m => ({
|
chat.messages = (resp.messages || []).map(m => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
parent_id: m.parent_id || null,
|
parent_id: m.parent_id || null,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
@@ -244,7 +244,7 @@ async function selectChat(chatId) {
|
|||||||
if (chat.messages.length === 0 && chat.messageCount > 0) {
|
if (chat.messages.length === 0 && chat.messageCount > 0) {
|
||||||
try {
|
try {
|
||||||
const resp = await API.getActivePath(chatId);
|
const resp = await API.getActivePath(chatId);
|
||||||
chat.messages = (resp.path || []).map(m => ({
|
chat.messages = (resp.messages || []).map(m => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
parent_id: m.parent_id || null,
|
parent_id: m.parent_id || null,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
@@ -767,7 +767,7 @@ async function reloadActivePath() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await API.getActivePath(App.activeId);
|
const resp = await API.getActivePath(App.activeId);
|
||||||
chat.messages = (resp.path || []).map(m => ({
|
chat.messages = (resp.messages || []).map(m => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
parent_id: m.parent_id || null,
|
parent_id: m.parent_id || null,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
@@ -958,8 +958,8 @@ async function switchSibling(messageId, direction) {
|
|||||||
|
|
||||||
// Update local state with the new path
|
// Update local state with the new path
|
||||||
const chat = App.getActiveChat();
|
const chat = App.getActiveChat();
|
||||||
if (chat && resp.path) {
|
if (chat && resp.messages) {
|
||||||
chat.messages = resp.path.map(m => ({
|
chat.messages = resp.messages.map(m => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
parent_id: m.parent_id || null,
|
parent_id: m.parent_id || null,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const ToolsToggle = (() => {
|
|||||||
_loadDisabled();
|
_loadDisabled();
|
||||||
try {
|
try {
|
||||||
const resp = await API.getTools();
|
const resp = await API.getTools();
|
||||||
_tools = resp.data || [];
|
_tools = resp.tools || [];
|
||||||
_buildCategories();
|
_buildCategories();
|
||||||
_render();
|
_render();
|
||||||
_show();
|
_show();
|
||||||
@@ -52,7 +52,7 @@ const ToolsToggle = (() => {
|
|||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
const resp = await API.getTools();
|
const resp = await API.getTools();
|
||||||
_tools = resp.data || [];
|
_tools = resp.tools || [];
|
||||||
_buildCategories();
|
_buildCategories();
|
||||||
_render();
|
_render();
|
||||||
} catch (e) { /* silent */ }
|
} catch (e) { /* silent */ }
|
||||||
|
|||||||
Reference in New Issue
Block a user