Changeset 0.29.0 (#195)
This commit is contained in:
@@ -12,12 +12,13 @@ import (
|
||||
|
||||
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/storage"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/treepath"
|
||||
)
|
||||
|
||||
// ── Request / Response types ────────────────
|
||||
@@ -50,12 +51,12 @@ type editRequest struct {
|
||||
}
|
||||
|
||||
type regenerateRequest struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
PersonaID string `json:"persona_id,omitempty"`
|
||||
ProviderConfigID string `json:"provider_config_id,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
DisabledTools []string `json:"disabled_tools,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
PersonaID string `json:"persona_id,omitempty"`
|
||||
ProviderConfigID string `json:"provider_config_id,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
DisabledTools []string `json:"disabled_tools,omitempty"`
|
||||
}
|
||||
|
||||
type cursorRequest struct {
|
||||
@@ -77,9 +78,6 @@ func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *even
|
||||
|
||||
// ── List Messages (flat, all branches) ──────
|
||||
// GET /channels/:id/messages
|
||||
//
|
||||
// Returns all live messages across all branches (useful for admin/debug).
|
||||
// Frontend should prefer /channels/:id/path for rendering.
|
||||
|
||||
func (h *MessageHandler) ListMessages(c *gin.Context) {
|
||||
page, perPage, offset := parsePagination(c)
|
||||
@@ -93,62 +91,37 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"})
|
||||
return
|
||||
}
|
||||
} else if !userOwnsChannel(c, channelID, userID) {
|
||||
} else if !userCanAccessChannel(c, h.stores, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
var total int
|
||||
err := database.DB.QueryRow(
|
||||
database.Q(`SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL`),
|
||||
channelID,
|
||||
).Scan(&total)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count messages"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := database.DB.Query(database.Q(`
|
||||
SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id,
|
||||
m.sibling_index, m.participant_type, m.participant_id,
|
||||
CASE WHEN m.participant_type = 'user' THEN COALESCE(u.display_name, u.username)
|
||||
WHEN m.participant_type = 'persona' THEN p.name
|
||||
ELSE NULL END AS sender_name,
|
||||
CASE WHEN m.participant_type = 'user' THEN u.avatar_url
|
||||
WHEN m.participant_type = 'persona' THEN p.avatar
|
||||
ELSE NULL END AS sender_avatar,
|
||||
m.created_at
|
||||
FROM messages m
|
||||
LEFT JOIN users u ON m.participant_type = 'user' AND m.participant_id = u.id::text
|
||||
LEFT JOIN personas p ON m.participant_type = 'persona' AND m.participant_id = p.id::text
|
||||
WHERE m.channel_id = $1 AND m.deleted_at IS NULL
|
||||
ORDER BY m.created_at ASC
|
||||
LIMIT $2 OFFSET $3
|
||||
`), channelID, perPage, offset)
|
||||
ctx := c.Request.Context()
|
||||
msgs, total, err := h.stores.Messages.ListWithSenderInfo(ctx, channelID, perPage, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list messages"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
messages := make([]messageResponse, 0)
|
||||
for rows.Next() {
|
||||
var msg messageResponse
|
||||
err := rows.Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
||||
&msg.Model, &msg.TokensUsed, &msg.ParentID,
|
||||
&msg.SiblingIndex, &msg.ParticipantType, &msg.ParticipantID,
|
||||
&msg.SenderName, &msg.SenderAvatar,
|
||||
&msg.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan message"})
|
||||
return
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
messages := make([]messageResponse, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
messages = append(messages, messageResponse{
|
||||
ID: m.ID,
|
||||
ChannelID: m.ChannelID,
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
Model: m.Model,
|
||||
TokensUsed: m.TokensUsed,
|
||||
ParentID: m.ParentID,
|
||||
SiblingIndex: m.SiblingIndex,
|
||||
ParticipantType: m.ParticipantType,
|
||||
ParticipantID: m.ParticipantID,
|
||||
SenderName: m.SenderName,
|
||||
SenderAvatar: m.SenderAvatar,
|
||||
CreatedAt: m.CreatedAt,
|
||||
})
|
||||
}
|
||||
rows.Close() // release connection before sibling queries
|
||||
|
||||
// Enrich with sibling counts (requires DB access, must happen after rows are closed)
|
||||
// Enrich with sibling counts
|
||||
for i := range messages {
|
||||
messages[i].SiblingCount = getSiblingCount(channelID, messages[i].ParentID)
|
||||
}
|
||||
@@ -164,15 +137,12 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
||||
|
||||
// ── Active Path ─────────────────────────────
|
||||
// GET /channels/:id/path
|
||||
//
|
||||
// Returns the active branch from root → leaf with sibling metadata
|
||||
// at each node. This is what the frontend renders.
|
||||
|
||||
func (h *MessageHandler) GetActivePath(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
channelID := c.Param("id")
|
||||
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
if !userCanAccessChannel(c, h.stores, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -205,12 +175,11 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"})
|
||||
return
|
||||
}
|
||||
} else if !userOwnsChannel(c, channelID, userID) {
|
||||
} else if !userCanAccessChannel(c, h.stores, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use cursor for parent, compute sibling_index
|
||||
// Sessions use the same cursor logic (empty userID = channel-level latest)
|
||||
effectiveUserID := userID
|
||||
if isSessionAuth(c) {
|
||||
effectiveUserID = c.GetString("session_id")
|
||||
@@ -231,63 +200,43 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
var msg messageResponse
|
||||
if database.IsSQLite() {
|
||||
newID := store.NewID()
|
||||
_, err := database.DB.Exec(`
|
||||
INSERT INTO messages (id, channel_id, role, content, model, parent_id,
|
||||
participant_type, participant_id, sibling_index)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, newID, channelID, req.Role, req.Content, req.Model, parentID,
|
||||
participantType, participantID, siblingIdx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
|
||||
return
|
||||
}
|
||||
err = database.DB.QueryRow(`
|
||||
SELECT id, channel_id, role, content, model, tokens_used, parent_id,
|
||||
sibling_index, created_at
|
||||
FROM messages WHERE id = ?`, newID).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
||||
&msg.Model, &msg.TokensUsed, &msg.ParentID,
|
||||
&msg.SiblingIndex, &msg.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read created message"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, model, parent_id,
|
||||
participant_type, participant_id, sibling_index)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, channel_id, role, content, model, tokens_used, parent_id,
|
||||
sibling_index, created_at
|
||||
`, channelID, req.Role, req.Content, req.Model, parentID,
|
||||
participantType, participantID, siblingIdx,
|
||||
).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
||||
&msg.Model, &msg.TokensUsed, &msg.ParentID,
|
||||
&msg.SiblingIndex, &msg.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
|
||||
return
|
||||
}
|
||||
msg := &models.Message{
|
||||
ChannelID: channelID,
|
||||
Role: req.Role,
|
||||
Content: req.Content,
|
||||
Model: req.Model,
|
||||
ParentID: parentID,
|
||||
SiblingIndex: siblingIdx,
|
||||
ParticipantType: participantType,
|
||||
ParticipantID: participantID,
|
||||
ToolCalls: models.JSONMap{},
|
||||
Metadata: models.JSONMap{},
|
||||
}
|
||||
msg.SiblingCount = getSiblingCount(channelID, msg.ParentID)
|
||||
|
||||
_ = updateCursor(channelID, userID, msg.ID)
|
||||
_, _ = database.DB.Exec(database.Q(`UPDATE channels SET updated_at = NOW() WHERE id = $1`), channelID)
|
||||
if err := h.stores.Messages.CreateWithCursor(c.Request.Context(), msg, userID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, msg)
|
||||
resp := messageResponse{
|
||||
ID: msg.ID,
|
||||
ChannelID: msg.ChannelID,
|
||||
Role: msg.Role,
|
||||
Content: msg.Content,
|
||||
SiblingIndex: msg.SiblingIndex,
|
||||
CreatedAt: msg.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
if msg.Model != "" {
|
||||
resp.Model = &msg.Model
|
||||
}
|
||||
resp.ParentID = msg.ParentID
|
||||
resp.SiblingCount = getSiblingCount(channelID, msg.ParentID)
|
||||
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// ── Edit Message (create sibling) ───────────
|
||||
// POST /channels/:id/messages/:msgId/edit
|
||||
//
|
||||
// Creates a new user message as a sibling of the target (same parent_id).
|
||||
// Does NOT auto-trigger completion — the frontend sends a separate request.
|
||||
|
||||
func (h *MessageHandler) EditMessage(c *gin.Context) {
|
||||
var req editRequest
|
||||
@@ -300,18 +249,14 @@ func (h *MessageHandler) EditMessage(c *gin.Context) {
|
||||
channelID := c.Param("id")
|
||||
messageID := c.Param("msgId")
|
||||
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
if !userCanAccessChannel(c, h.stores, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Load target — must exist, belong to channel, be a user message
|
||||
var targetParentID *string
|
||||
var targetRole string
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT parent_id, role FROM messages
|
||||
WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL
|
||||
`), messageID, channelID).Scan(&targetParentID, &targetRole)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Load target — must exist, belong to channel, be a user message
|
||||
targetParentID, targetRole, err := h.stores.Messages.GetParentAndRole(ctx, messageID, channelID)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||
return
|
||||
@@ -328,65 +273,39 @@ func (h *MessageHandler) EditMessage(c *gin.Context) {
|
||||
// Create sibling: same parent_id as the target
|
||||
siblingIdx := nextSiblingIndex(channelID, targetParentID)
|
||||
|
||||
var msg messageResponse
|
||||
if database.IsSQLite() {
|
||||
newID := store.NewID()
|
||||
_, err = database.DB.Exec(`
|
||||
INSERT INTO messages (id, channel_id, role, content, parent_id,
|
||||
participant_type, participant_id, sibling_index)
|
||||
VALUES (?, ?, 'user', ?, ?, 'user', ?, ?)
|
||||
`, newID, channelID, req.Content, targetParentID, userID, siblingIdx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create edit"})
|
||||
return
|
||||
}
|
||||
err = database.DB.QueryRow(`
|
||||
SELECT id, channel_id, role, content, model, tokens_used, parent_id,
|
||||
sibling_index, created_at
|
||||
FROM messages WHERE id = ?`, newID).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
||||
&msg.Model, &msg.TokensUsed, &msg.ParentID,
|
||||
&msg.SiblingIndex, &msg.CreatedAt,
|
||||
)
|
||||
} else {
|
||||
err = database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, parent_id,
|
||||
participant_type, participant_id, sibling_index)
|
||||
VALUES ($1, 'user', $2, $3, 'user', $4, $5)
|
||||
RETURNING id, channel_id, role, content, model, tokens_used, parent_id,
|
||||
sibling_index, created_at
|
||||
`, channelID, req.Content, targetParentID, userID, siblingIdx,
|
||||
).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
||||
&msg.Model, &msg.TokensUsed, &msg.ParentID,
|
||||
&msg.SiblingIndex, &msg.CreatedAt,
|
||||
)
|
||||
msg := &models.Message{
|
||||
ChannelID: channelID,
|
||||
Role: "user",
|
||||
Content: req.Content,
|
||||
ParentID: targetParentID,
|
||||
SiblingIndex: siblingIdx,
|
||||
ParticipantType: "user",
|
||||
ParticipantID: userID,
|
||||
ToolCalls: models.JSONMap{},
|
||||
Metadata: models.JSONMap{},
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
if err := h.stores.Messages.CreateWithCursor(ctx, msg, userID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create edit"})
|
||||
return
|
||||
}
|
||||
msg.SiblingCount = getSiblingCount(channelID, msg.ParentID)
|
||||
|
||||
// Cursor now points to the new sibling (it's a leaf — no children yet)
|
||||
_ = updateCursor(channelID, userID, msg.ID)
|
||||
_, _ = database.DB.Exec(database.Q(`UPDATE channels SET updated_at = NOW() WHERE id = $1`), channelID)
|
||||
resp := messageResponse{
|
||||
ID: msg.ID,
|
||||
ChannelID: msg.ChannelID,
|
||||
Role: msg.Role,
|
||||
Content: msg.Content,
|
||||
ParentID: msg.ParentID,
|
||||
SiblingIndex: msg.SiblingIndex,
|
||||
SiblingCount: getSiblingCount(channelID, msg.ParentID),
|
||||
CreatedAt: msg.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, msg)
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// ── Regenerate / Complete ──────────────────────
|
||||
// POST /channels/:id/messages/:msgId/regenerate
|
||||
//
|
||||
// Two modes depending on target role:
|
||||
// - assistant message → creates a NEW assistant sibling (same parent_id).
|
||||
// Context is root → target's parent. ("Give me a different response.")
|
||||
// - user message → creates a child assistant response.
|
||||
// Context is root → target. ("Respond to this message.")
|
||||
//
|
||||
// The second mode is used after editing: the edit endpoint creates a
|
||||
// sibling user message, then the frontend calls regenerate on it to
|
||||
// get an assistant response without duplicating the user message.
|
||||
|
||||
func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
var req regenerateRequest
|
||||
@@ -396,18 +315,14 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
channelID := c.Param("id")
|
||||
messageID := c.Param("msgId")
|
||||
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
if !userCanAccessChannel(c, h.stores, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Load target message
|
||||
var targetParentID *string
|
||||
var targetRole string
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT parent_id, role FROM messages
|
||||
WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL
|
||||
`), messageID, channelID).Scan(&targetParentID, &targetRole)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Load target message
|
||||
targetParentID, targetRole, err := h.stores.Messages.GetParentAndRole(ctx, messageID, channelID)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||
return
|
||||
@@ -421,13 +336,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine context path and new message's parent based on target role:
|
||||
//
|
||||
// assistant target: context = root → target's parent (exclude target)
|
||||
// new parent = target's parent (sibling of target)
|
||||
//
|
||||
// user target: context = root → target (include target)
|
||||
// new parent = target itself (child of target)
|
||||
// Determine context path and new message's parent based on target role
|
||||
var contextPath []PathMessage
|
||||
var newParentID *string
|
||||
|
||||
@@ -486,15 +395,14 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
|
||||
// Fallback: channel's stored model
|
||||
if model == "" {
|
||||
var channelModel *string
|
||||
_ = database.DB.QueryRow(database.Q(`SELECT model FROM channels WHERE id = $1`), channelID).Scan(&channelModel)
|
||||
channelModel, _ := h.stores.Channels.GetDefaultModel(ctx, channelID)
|
||||
if channelModel != nil {
|
||||
model = *channelModel
|
||||
}
|
||||
}
|
||||
|
||||
providerCfg, providerID, model, configID, providerScope, err := comp.resolveConfig(userID, channelID, completionRequest{
|
||||
Model: model,
|
||||
Model: model,
|
||||
ProviderConfigID: providerConfigID,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -514,8 +422,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
if personaSystemPrompt != "" {
|
||||
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: personaSystemPrompt})
|
||||
} else {
|
||||
var systemPrompt *string
|
||||
_ = database.DB.QueryRow(database.Q(`SELECT system_prompt FROM channels WHERE id = $1`), channelID).Scan(&systemPrompt)
|
||||
systemPrompt, _ := h.stores.Channels.GetSystemPrompt(ctx, channelID)
|
||||
if systemPrompt != nil && *systemPrompt != "" {
|
||||
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: *systemPrompt})
|
||||
}
|
||||
@@ -544,14 +451,10 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Attach tool definitions (same as normal completion)
|
||||
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
|
||||
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(ctx, channelID)
|
||||
|
||||
// Query channel metadata for tool context and execution context
|
||||
var chType string
|
||||
var chTeamID *string
|
||||
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1
|
||||
`), channelID).Scan(&chType, &chTeamID)
|
||||
// Query channel metadata for tool context
|
||||
chType, chTeamID, _ := h.stores.Channels.GetTypeAndTeamID(ctx, channelID)
|
||||
msgTeamID := ""
|
||||
if chTeamID != nil {
|
||||
msgTeamID = *chTeamID
|
||||
@@ -559,7 +462,6 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
|
||||
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
|
||||
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
|
||||
// v0.25.0: Build ToolContext with channel metadata
|
||||
tctx := tools.ToolContext{
|
||||
ChannelType: chType,
|
||||
WorkspaceID: workspaceID,
|
||||
@@ -567,12 +469,11 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
PersonaID: personaID,
|
||||
IsVisitor: isSessionAuth(c),
|
||||
}
|
||||
provReq.Tools = comp.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, tctx, personaID)
|
||||
provReq.Tools = comp.buildToolDefs(ctx, userID, hasBrowserTools, req.DisabledTools, tctx, personaID)
|
||||
}
|
||||
|
||||
// ── Stream the response (shared loop handles tools, reasoning, SSE) ──
|
||||
// ── Stream the response ──
|
||||
|
||||
// Apply provider-specific request hooks (v0.22.1)
|
||||
if hooks := providers.GetHooks(providerID); hooks != nil {
|
||||
hooks.PreRequest(providerCfg, &provReq)
|
||||
}
|
||||
@@ -583,47 +484,34 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
if result.Content != "" {
|
||||
siblingIdx := nextSiblingIndex(channelID, newParentID)
|
||||
|
||||
// Match persistMessage pattern: nil interface{} → SQL NULL,
|
||||
// non-nil string → valid JSONB. A nil json.RawMessage ([]byte)
|
||||
// is sent by pq as empty bytes, not NULL, causing "invalid input
|
||||
// syntax for type json".
|
||||
var tcVal interface{}
|
||||
var toolCalls models.JSONMap
|
||||
if len(result.ToolActivity) > 0 {
|
||||
b, _ := json.Marshal(result.ToolActivity)
|
||||
tcVal = string(b)
|
||||
_ = json.Unmarshal(b, &toolCalls)
|
||||
}
|
||||
if toolCalls == nil {
|
||||
toolCalls = models.JSONMap{}
|
||||
}
|
||||
|
||||
var newID string
|
||||
if database.IsSQLite() {
|
||||
newID = store.NewID()
|
||||
_, err := database.DB.Exec(`
|
||||
INSERT INTO messages (id, channel_id, role, content, model, tool_calls,
|
||||
parent_id, participant_type, participant_id, sibling_index)
|
||||
VALUES (?, ?, 'assistant', ?, ?, ?, ?, 'model', ?, ?)
|
||||
`, newID, channelID, result.Content, model, tcVal, newParentID, model, siblingIdx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to persist regenerated message: %v", err)
|
||||
newID = ""
|
||||
}
|
||||
msg := &models.Message{
|
||||
ChannelID: channelID,
|
||||
Role: "assistant",
|
||||
Content: result.Content,
|
||||
Model: model,
|
||||
ToolCalls: toolCalls,
|
||||
Metadata: models.JSONMap{},
|
||||
ParentID: newParentID,
|
||||
SiblingIndex: siblingIdx,
|
||||
ParticipantType: "model",
|
||||
ParticipantID: model,
|
||||
}
|
||||
|
||||
if err := h.stores.Messages.CreateWithCursor(ctx, msg, userID); err != nil {
|
||||
log.Printf("Failed to persist regenerated message: %v", err)
|
||||
} else {
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, model, tool_calls,
|
||||
parent_id, participant_type, participant_id, sibling_index)
|
||||
VALUES ($1, 'assistant', $2, $3, $4, $5, 'model', $6, $7)
|
||||
RETURNING id
|
||||
`, channelID, result.Content, model, tcVal, newParentID, model, siblingIdx).Scan(&newID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to persist regenerated message: %v", err)
|
||||
newID = ""
|
||||
}
|
||||
}
|
||||
|
||||
if newID != "" {
|
||||
_ = updateCursor(channelID, userID, newID)
|
||||
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
msgJSON, _ := json.Marshal(gin.H{
|
||||
"id": newID,
|
||||
"id": msg.ID,
|
||||
"parent_id": newParentID,
|
||||
"sibling_index": siblingIdx,
|
||||
"sibling_count": getSiblingCount(channelID, newParentID),
|
||||
@@ -633,8 +521,6 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = database.DB.Exec(database.Q(`UPDATE channels SET updated_at = NOW() WHERE id = $1`), channelID)
|
||||
}
|
||||
|
||||
// Log usage for regeneration
|
||||
@@ -645,9 +531,6 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
|
||||
// ── Switch Branch (update cursor) ───────────
|
||||
// PUT /channels/:id/cursor
|
||||
//
|
||||
// Navigates to a different branch. If the target message has children,
|
||||
// follows the first-child chain to find the leaf.
|
||||
|
||||
func (h *MessageHandler) UpdateCursor(c *gin.Context) {
|
||||
var req cursorRequest
|
||||
@@ -659,17 +542,15 @@ func (h *MessageHandler) UpdateCursor(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
channelID := c.Param("id")
|
||||
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
if !userCanAccessChannel(c, h.stores, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the target message belongs to this channel
|
||||
var msgChannelID string
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
SELECT channel_id FROM messages
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
`), req.ActiveLeafID).Scan(&msgChannelID)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Verify the target message belongs to this channel and is not deleted.
|
||||
// GetParentAndRole does: WHERE id=$1 AND channel_id=$2 AND deleted_at IS NULL
|
||||
_, _, err := h.stores.Messages.GetParentAndRole(ctx, req.ActiveLeafID, channelID)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||
return
|
||||
@@ -678,10 +559,6 @@ func (h *MessageHandler) UpdateCursor(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify message"})
|
||||
return
|
||||
}
|
||||
if msgChannelID != channelID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "message does not belong to this channel"})
|
||||
return
|
||||
}
|
||||
|
||||
// Walk down to the leaf from the target
|
||||
leafID, err := findLeafFromMessage(req.ActiveLeafID)
|
||||
@@ -707,16 +584,13 @@ func (h *MessageHandler) UpdateCursor(c *gin.Context) {
|
||||
|
||||
// ── List Siblings ───────────────────────────
|
||||
// GET /channels/:id/messages/:msgId/siblings
|
||||
//
|
||||
// Returns all siblings of a message (messages sharing the same parent_id).
|
||||
// Used by the branch navigator to populate the arrow navigation.
|
||||
|
||||
func (h *MessageHandler) ListSiblings(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
channelID := c.Param("id")
|
||||
messageID := c.Param("msgId")
|
||||
|
||||
if !userOwnsChannel(c, channelID, userID) {
|
||||
if !userCanAccessChannel(c, h.stores, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -733,36 +607,34 @@ func (h *MessageHandler) ListSiblings(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Ownership Check ─────────────────────────
|
||||
// ── Ownership / Access Check ─────────────────
|
||||
|
||||
func userOwnsChannel(c *gin.Context, channelID, userID string) bool {
|
||||
var ownerID string
|
||||
err := database.DB.QueryRow(
|
||||
database.Q(`SELECT user_id FROM channels WHERE id = $1`), channelID,
|
||||
).Scan(&ownerID)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
return false
|
||||
}
|
||||
// userCanAccessChannel verifies channel ownership or participation,
|
||||
// writing an error response if denied. Returns true if access is allowed.
|
||||
func userCanAccessChannel(c *gin.Context, stores store.Stores, channelID, userID string) bool {
|
||||
ok, err := stores.Channels.UserCanAccess(c.Request.Context(), channelID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel ownership"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel access"})
|
||||
return false
|
||||
}
|
||||
if ownerID == userID {
|
||||
return true
|
||||
if !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
|
||||
return false
|
||||
}
|
||||
|
||||
// v0.23.2: Also allow if user is a participant (multi-user DMs, channels)
|
||||
var exists bool
|
||||
_ = database.DB.QueryRow(
|
||||
database.Q(`SELECT EXISTS(SELECT 1 FROM channel_participants WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2)`),
|
||||
channelID, userID,
|
||||
).Scan(&exists)
|
||||
if exists {
|
||||
return true
|
||||
}
|
||||
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
// userOwnsChannel is the legacy wrapper used by other handlers (completion.go).
|
||||
// Delegates to userCanAccessChannel using the treepath global stores.
|
||||
func userOwnsChannel(c *gin.Context, channelID, userID string) bool {
|
||||
ok, err := treepath.Stores.Channels.UserCanAccess(c.Request.Context(), channelID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel access"})
|
||||
return false
|
||||
}
|
||||
if !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user