Changeset 0.8.0 (#39)
This commit is contained in:
96
FORKING_IMPL.md
Normal file
96
FORKING_IMPL.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Message Editing & Forking — Implementation
|
||||||
|
|
||||||
|
**Version:** 0.7.x (Phase 2 per ARCHITECTURE.md §8)
|
||||||
|
**Depends on:** parent_id (006), channel_cursors (008) — both deployed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status: Complete (backend + frontend)
|
||||||
|
|
||||||
|
### Backend (already existed)
|
||||||
|
- [x] Migration 013 (`deleted_at`, `sibling_index`)
|
||||||
|
- [x] `tree.go` — `getActivePath`, `getActiveLeaf`, `getPathToLeaf`, `getSiblings`,
|
||||||
|
`findLeafFromMessage`, `nextSiblingIndex`, `updateCursor`
|
||||||
|
- [x] `completion.go` — `loadConversation` walks active path via recursive CTE,
|
||||||
|
`persistMessage` is cursor-aware with sibling_index, returns new message ID
|
||||||
|
- [x] `messages.go` — `GetActivePath`, `EditMessage`, `Regenerate` (streaming + sibling),
|
||||||
|
`UpdateCursor`, `ListSiblings`
|
||||||
|
- [x] Routes wired in `main.go`
|
||||||
|
|
||||||
|
### Frontend (new work)
|
||||||
|
- [x] `api.js` — `getActivePath`, `editMessage`, `streamRegenerate`, `updateCursor`, `listSiblings`
|
||||||
|
- [x] `app.js` — tree-aware message flow:
|
||||||
|
- `selectChat` uses `/path` endpoint
|
||||||
|
- `sendMessage` reloads path after streaming for server-authoritative IDs
|
||||||
|
- `regenerateMessage(id)` creates sibling via message-specific endpoint
|
||||||
|
- `editMessage(id)` → inline edit → `submitEdit` → sibling + completion
|
||||||
|
- `switchSibling(id, direction)` navigates branches via cursor update
|
||||||
|
- `reloadActivePath()` refreshes from server after every mutation
|
||||||
|
- Legacy `regenerate()` button delegates to `regenerateMessage(lastAssistantId)`
|
||||||
|
- [x] `ui.js`:
|
||||||
|
- `_messageHTML` renders ‹ 1/3 › branch indicators at fork points
|
||||||
|
- Edit button on user messages, Regen button on assistant messages
|
||||||
|
- `showEditInline` — textarea with Ctrl+Enter submit, Escape cancel
|
||||||
|
- [x] `styles.css` — `.branch-nav`, `.branch-arrow`, `.branch-pos`,
|
||||||
|
`.msg-edit-input`, `.msg-edit-actions`, `.msg-edit-cancel`, `.msg-edit-submit`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| `GET` | `/channels/:id/path` | Active path with sibling metadata |
|
||||||
|
| `POST` | `/channels/:id/messages/:msgId/edit` | Create sibling user message |
|
||||||
|
| `POST` | `/channels/:id/messages/:msgId/regenerate` | Create sibling assistant response (SSE) |
|
||||||
|
| `PUT` | `/channels/:id/cursor` | Switch active branch |
|
||||||
|
| `GET` | `/channels/:id/messages/:msgId/siblings` | List siblings at a fork point |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
1. **`sibling_index` column** — explicit integer, not derived from `created_at`. Tree is self-describing.
|
||||||
|
2. **New `/path` endpoint** — `ListMessages` retained for admin/debug. Frontend uses `/path` exclusively.
|
||||||
|
3. **Two-step edit** — API: edit creates sibling, completion is separate. Frontend chains them.
|
||||||
|
4. **Uniform ‹ › arrows** — same UI regardless of how fork was created (edit, regen, explicit).
|
||||||
|
5. **Message-specific regenerate** — replaces old channel-level stub. Bottom-bar button delegates.
|
||||||
|
6. **Branch switch → leaf** — clicking mid-tree sibling walks to deepest descendant via `findLeafFromMessage`.
|
||||||
|
7. **Path reload after every mutation** — `reloadActivePath()` called post-send, edit, regen, abort.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Flows
|
||||||
|
|
||||||
|
### Edit a message
|
||||||
|
1. Hover user message → click **Edit**
|
||||||
|
2. Textarea replaces message text (Ctrl+Enter to submit, Escape to cancel)
|
||||||
|
3. Submit → `POST /messages/:id/edit` (creates sibling) → `POST /chat/completions` (new response)
|
||||||
|
4. Branch indicator appears: ‹ 1/2 ›
|
||||||
|
5. Old branch preserved, navigable via arrows
|
||||||
|
|
||||||
|
### Regenerate a response
|
||||||
|
1. Hover assistant message → click **Regen** (or bottom-bar ↻ button)
|
||||||
|
2. `POST /messages/:id/regenerate` streams new response as sibling
|
||||||
|
3. Branch indicator appears: ‹ 1/2 ›
|
||||||
|
4. Original response preserved, navigable via arrows
|
||||||
|
|
||||||
|
### Navigate branches
|
||||||
|
1. Click ‹ or › on any fork point
|
||||||
|
2. `GET /messages/:id/siblings` → pick adjacent → `PUT /cursor` with sibling ID
|
||||||
|
3. Backend walks to leaf, returns new path
|
||||||
|
4. Entire conversation below fork point updates to show selected branch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Existing linear conversations load and render correctly (backward compat)
|
||||||
|
- [ ] New messages in linear conversation work as before
|
||||||
|
- [ ] Regen creates sibling, shows ‹ 1/2 ›, original preserved
|
||||||
|
- [ ] Multiple regens show ‹ 1/3 ›, all navigable
|
||||||
|
- [ ] Edit creates sibling user message + triggers new completion
|
||||||
|
- [ ] Branch arrows navigate correctly (← older, → newer)
|
||||||
|
- [ ] Context sent to LLM is active path only (no cross-branch pollution)
|
||||||
|
- [ ] Cursor persists across page reload (reopening chat shows last-viewed branch)
|
||||||
|
- [ ] Mobile: edit textarea usable, branch arrows tappable
|
||||||
28
server/database/migrations/013_message_forking.sql
Normal file
28
server/database/migrations/013_message_forking.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- ==========================================
|
||||||
|
-- Migration 013: Message Forking Support
|
||||||
|
-- ==========================================
|
||||||
|
-- Adds soft delete and sibling ordering to support
|
||||||
|
-- conversation forking (edit, regenerate, branch).
|
||||||
|
-- See ARCHITECTURE.md §8 for the full tree model.
|
||||||
|
-- ==========================================
|
||||||
|
|
||||||
|
-- Soft delete: pruned branches keep their structure for undo
|
||||||
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Sibling ordering: explicit position among children of same parent
|
||||||
|
-- First child = 0, second = 1, etc. Set at insert time.
|
||||||
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS sibling_index INTEGER DEFAULT 0;
|
||||||
|
|
||||||
|
-- Partial index: fast lookup of live messages only
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_alive
|
||||||
|
ON messages(channel_id, created_at)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- Children of a parent (for sibling queries)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_parent_alive
|
||||||
|
ON messages(parent_id, sibling_index)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- Backfill sibling_index for existing messages.
|
||||||
|
-- In linear conversations every message is the sole child of its parent,
|
||||||
|
-- so all get sibling_index = 0 (already the default). No update needed.
|
||||||
@@ -128,20 +128,13 @@ func TestCreateMessageValidRoles(t *testing.T) {
|
|||||||
t.Skip("requires database connection for ownership check")
|
t.Skip("requires database connection for ownership check")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Regenerate Stub ─────────────────────────
|
// ── Regenerate ───────────────────────────────
|
||||||
|
|
||||||
func TestRegenerateReturns501(t *testing.T) {
|
func TestRegenerateRequiresDB(t *testing.T) {
|
||||||
h := NewMessageHandler()
|
// Regenerate is now a full handler (creates sibling assistant message
|
||||||
|
// via tree-aware streaming). It calls userOwnsChannel → database.DB,
|
||||||
w := httptest.NewRecorder()
|
// so it cannot run without a live database connection.
|
||||||
c, _ := gin.CreateTestContext(w)
|
t.Skip("requires database connection for ownership check")
|
||||||
c.Request = httptest.NewRequest("POST", "/api/v1/channels/x/regenerate", nil)
|
|
||||||
|
|
||||||
h.Regenerate(c)
|
|
||||||
|
|
||||||
if w.Code != http.StatusNotImplemented {
|
|
||||||
t.Errorf("Expected 501, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pagination Helpers ──────────────────────
|
// ── Pagination Helpers ──────────────────────
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load conversation history
|
// Load conversation history
|
||||||
messages, err := h.loadConversation(channelID, presetSystemPrompt)
|
messages, err := h.loadConversation(channelID, userID, presetSystemPrompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
|
||||||
return
|
return
|
||||||
@@ -126,7 +126,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Persist user message
|
// Persist user message
|
||||||
if err := h.persistMessage(channelID, "user", req.Content, "", 0, 0); err != nil {
|
if _, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil); err != nil {
|
||||||
log.Printf("Failed to persist user message: %v", err)
|
log.Printf("Failed to persist user message: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,9 +160,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if stream {
|
if stream {
|
||||||
h.streamCompletion(c, provider, providerCfg, provReq, channelID, model)
|
h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model)
|
||||||
} else {
|
} else {
|
||||||
h.syncCompletion(c, provider, providerCfg, provReq, channelID, model)
|
h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ func (h *CompletionHandler) streamCompletion(
|
|||||||
provider providers.Provider,
|
provider providers.Provider,
|
||||||
cfg providers.ProviderConfig,
|
cfg providers.ProviderConfig,
|
||||||
req providers.CompletionRequest,
|
req providers.CompletionRequest,
|
||||||
channelID, model string,
|
channelID, userID, model string,
|
||||||
) {
|
) {
|
||||||
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req)
|
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -228,7 +228,7 @@ func (h *CompletionHandler) streamCompletion(
|
|||||||
|
|
||||||
// Persist assistant response
|
// Persist assistant response
|
||||||
if fullContent != "" {
|
if fullContent != "" {
|
||||||
if err := h.persistMessage(channelID, "assistant", fullContent, model, 0, 0); err != nil {
|
if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil {
|
||||||
log.Printf("Failed to persist assistant message: %v", err)
|
log.Printf("Failed to persist assistant message: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,7 +241,7 @@ func (h *CompletionHandler) syncCompletion(
|
|||||||
provider providers.Provider,
|
provider providers.Provider,
|
||||||
cfg providers.ProviderConfig,
|
cfg providers.ProviderConfig,
|
||||||
req providers.CompletionRequest,
|
req providers.CompletionRequest,
|
||||||
channelID, model string,
|
channelID, userID, model string,
|
||||||
) {
|
) {
|
||||||
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
|
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -250,7 +250,7 @@ func (h *CompletionHandler) syncCompletion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist assistant response
|
// Persist assistant response
|
||||||
if err := h.persistMessage(channelID, "assistant", resp.Content, model, resp.InputTokens, resp.OutputTokens); err != nil {
|
if _, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model, resp.InputTokens, resp.OutputTokens, nil); err != nil {
|
||||||
log.Printf("Failed to persist assistant message: %v", err)
|
log.Printf("Failed to persist assistant message: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +394,10 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
|||||||
|
|
||||||
// ── Conversation Loader ─────────────────────
|
// ── Conversation Loader ─────────────────────
|
||||||
|
|
||||||
func (h *CompletionHandler) loadConversation(channelID string, presetSystemPrompt string) ([]providers.Message, error) {
|
// loadConversation builds the message history for the LLM by walking the
|
||||||
|
// active path through the message tree. Only the current branch is sent —
|
||||||
|
// sibling branches are never included in context.
|
||||||
|
func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemPrompt string) ([]providers.Message, error) {
|
||||||
// Load system prompt from channel
|
// Load system prompt from channel
|
||||||
var systemPrompt *string
|
var systemPrompt *string
|
||||||
_ = database.DB.QueryRow(
|
_ = database.DB.QueryRow(
|
||||||
@@ -416,23 +419,20 @@ func (h *CompletionHandler) loadConversation(channelID string, presetSystemPromp
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load message history (oldest first)
|
// Walk the active path (root → leaf) instead of loading all messages
|
||||||
rows, err := database.DB.Query(`
|
path, err := getActivePath(channelID, userID)
|
||||||
SELECT role, content FROM messages
|
|
||||||
WHERE channel_id = $1
|
|
||||||
ORDER BY created_at ASC
|
|
||||||
`, channelID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
for rows.Next() {
|
for _, m := range path {
|
||||||
var msg providers.Message
|
if m.Role == "system" {
|
||||||
if err := rows.Scan(&msg.Role, &msg.Content); err != nil {
|
continue // system prompts handled above
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
messages = append(messages, msg)
|
messages = append(messages, providers.Message{
|
||||||
|
Role: m.Role,
|
||||||
|
Content: m.Content,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages, nil
|
return messages, nil
|
||||||
@@ -440,42 +440,59 @@ func (h *CompletionHandler) loadConversation(channelID string, presetSystemPromp
|
|||||||
|
|
||||||
// ── Message Persistence ─────────────────────
|
// ── Message Persistence ─────────────────────
|
||||||
|
|
||||||
func (h *CompletionHandler) persistMessage(channelID, role, content, model string, inputTokens, outputTokens int) error {
|
// persistMessage inserts a message into the tree, updates the cursor, and
|
||||||
|
// returns the new message's ID.
|
||||||
|
//
|
||||||
|
// Parent resolution:
|
||||||
|
// - parentOverride (explicit parent for edit/regen operations)
|
||||||
|
// - cursor's active_leaf_id (normal conversation flow)
|
||||||
|
// - latest message by created_at (fallback for legacy/missing cursor)
|
||||||
|
func (h *CompletionHandler) persistMessage(channelID, userID, role, content, model string, inputTokens, outputTokens int, parentOverride *string) (string, error) {
|
||||||
var tokensUsed *int
|
var tokensUsed *int
|
||||||
if inputTokens > 0 || outputTokens > 0 {
|
if inputTokens > 0 || outputTokens > 0 {
|
||||||
total := inputTokens + outputTokens
|
total := inputTokens + outputTokens
|
||||||
tokensUsed = &total
|
tokensUsed = &total
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find current leaf for parent_id
|
// Determine parent
|
||||||
var parentID *string
|
var parentID *string
|
||||||
_ = database.DB.QueryRow(
|
if parentOverride != nil {
|
||||||
`SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1`,
|
parentID = parentOverride
|
||||||
channelID,
|
} else {
|
||||||
).Scan(&parentID)
|
parentID, _ = getActiveLeaf(channelID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute sibling_index
|
||||||
|
siblingIdx := nextSiblingIndex(channelID, parentID)
|
||||||
|
|
||||||
|
// Determine participant
|
||||||
participantType := "user"
|
participantType := "user"
|
||||||
participantID := channelID // will be overridden below
|
participantID := userID
|
||||||
if role == "assistant" {
|
if role == "assistant" {
|
||||||
participantType = "model"
|
participantType = "model"
|
||||||
participantID = model
|
participantID = model
|
||||||
} else {
|
|
||||||
// Get channel owner as participant_id for user messages
|
|
||||||
var ownerID string
|
|
||||||
if err := database.DB.QueryRow(`SELECT user_id FROM channels WHERE id = $1`, channelID).Scan(&ownerID); err == nil {
|
|
||||||
participantID = ownerID
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := database.DB.Exec(`
|
// Insert with RETURNING id
|
||||||
INSERT INTO messages (channel_id, role, content, model, tokens_used, parent_id, participant_type, participant_id)
|
var newID string
|
||||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8)
|
err := database.DB.QueryRow(`
|
||||||
`, channelID, role, content, model, tokensUsed, parentID, participantType, participantID)
|
INSERT INTO messages (channel_id, role, content, model, tokens_used,
|
||||||
|
parent_id, participant_type, participant_id, sibling_index)
|
||||||
|
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9)
|
||||||
|
RETURNING id
|
||||||
|
`, channelID, role, content, model, tokensUsed,
|
||||||
|
parentID, participantType, participantID, siblingIdx,
|
||||||
|
).Scan(&newID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cursor to point at new message
|
||||||
|
if userID != "" {
|
||||||
|
_ = updateCursor(channelID, userID, newID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Touch channel updated_at
|
// Touch channel updated_at
|
||||||
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
|
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
|
||||||
return nil
|
return newID, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,17 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Request / Response types ────────────────
|
// ── Request / Response types ────────────────
|
||||||
@@ -19,14 +24,32 @@ type createMessageRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type messageResponse struct {
|
type messageResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ChannelID string `json:"channel_id"`
|
ChannelID string `json:"channel_id"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Model *string `json:"model"`
|
Model *string `json:"model"`
|
||||||
TokensUsed *int `json:"tokens_used"`
|
TokensUsed *int `json:"tokens_used"`
|
||||||
ParentID *string `json:"parent_id,omitempty"`
|
ParentID *string `json:"parent_id,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
SiblingCount int `json:"sibling_count"`
|
||||||
|
SiblingIndex int `json:"sibling_index"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type editRequest struct {
|
||||||
|
Content string `json:"content" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type regenerateRequest struct {
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
PresetID string `json:"preset_id,omitempty"`
|
||||||
|
APIConfigID string `json:"api_config_id,omitempty"`
|
||||||
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cursorRequest struct {
|
||||||
|
ActiveLeafID string `json:"active_leaf_id" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MessageHandler holds dependencies for message endpoints.
|
// MessageHandler holds dependencies for message endpoints.
|
||||||
@@ -37,7 +60,11 @@ func NewMessageHandler() *MessageHandler {
|
|||||||
return &MessageHandler{}
|
return &MessageHandler{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── List Messages ───────────────────────────
|
// ── 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) {
|
func (h *MessageHandler) ListMessages(c *gin.Context) {
|
||||||
page, perPage, offset := parsePagination(c)
|
page, perPage, offset := parsePagination(c)
|
||||||
@@ -45,15 +72,13 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
|||||||
userID := getUserID(c)
|
userID := getUserID(c)
|
||||||
channelID := c.Param("id")
|
channelID := c.Param("id")
|
||||||
|
|
||||||
// Verify ownership
|
|
||||||
if !userOwnsChannel(c, channelID, userID) {
|
if !userOwnsChannel(c, channelID, userID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count total messages
|
|
||||||
var total int
|
var total int
|
||||||
err := database.DB.QueryRow(
|
err := database.DB.QueryRow(
|
||||||
`SELECT COUNT(*) FROM messages WHERE channel_id = $1`,
|
`SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL`,
|
||||||
channelID,
|
channelID,
|
||||||
).Scan(&total)
|
).Scan(&total)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -61,11 +86,11 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch messages (oldest first for conversation display)
|
|
||||||
rows, err := database.DB.Query(`
|
rows, err := database.DB.Query(`
|
||||||
SELECT id, channel_id, role, content, model, tokens_used, parent_id, created_at
|
SELECT id, channel_id, role, content, model, tokens_used, parent_id,
|
||||||
|
sibling_index, created_at
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE channel_id = $1
|
WHERE channel_id = $1 AND deleted_at IS NULL
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
`, channelID, perPage, offset)
|
`, channelID, perPage, offset)
|
||||||
@@ -80,12 +105,14 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
|||||||
var msg messageResponse
|
var msg messageResponse
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
||||||
&msg.Model, &msg.TokensUsed, &msg.ParentID, &msg.CreatedAt,
|
&msg.Model, &msg.TokensUsed, &msg.ParentID,
|
||||||
|
&msg.SiblingIndex, &msg.CreatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan message"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan message"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
msg.SiblingCount = getSiblingCount(channelID, msg.ParentID)
|
||||||
messages = append(messages, msg)
|
messages = append(messages, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +125,32 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Create Message ──────────────────────────
|
// ── 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) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := getActivePath(channelID, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("GetActivePath error: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load path"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"path": path})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create Message (manual) ─────────────────
|
||||||
|
// POST /channels/:id/messages
|
||||||
|
|
||||||
func (h *MessageHandler) CreateMessage(c *gin.Context) {
|
func (h *MessageHandler) CreateMessage(c *gin.Context) {
|
||||||
var req createMessageRequest
|
var req createMessageRequest
|
||||||
@@ -110,64 +162,457 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) {
|
|||||||
userID := getUserID(c)
|
userID := getUserID(c)
|
||||||
channelID := c.Param("id")
|
channelID := c.Param("id")
|
||||||
|
|
||||||
// Verify ownership
|
|
||||||
if !userOwnsChannel(c, channelID, userID) {
|
if !userOwnsChannel(c, channelID, userID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the current leaf message to set as parent
|
// Use cursor for parent, compute sibling_index
|
||||||
var parentID *string
|
parentID, _ := getActiveLeaf(channelID, userID)
|
||||||
_ = database.DB.QueryRow(
|
siblingIdx := nextSiblingIndex(channelID, parentID)
|
||||||
`SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1`,
|
|
||||||
channelID,
|
participantType := "user"
|
||||||
).Scan(&parentID)
|
participantID := userID
|
||||||
|
if req.Role == "assistant" {
|
||||||
|
participantType = "model"
|
||||||
|
if req.Model != "" {
|
||||||
|
participantID = req.Model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var msg messageResponse
|
var msg messageResponse
|
||||||
err := database.DB.QueryRow(`
|
err := database.DB.QueryRow(`
|
||||||
INSERT INTO messages (channel_id, role, content, model, parent_id, participant_type, participant_id)
|
INSERT INTO messages (channel_id, role, content, model, parent_id,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
participant_type, participant_id, sibling_index)
|
||||||
RETURNING id, channel_id, role, content, model, tokens_used, parent_id, created_at
|
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,
|
`, channelID, req.Role, req.Content, req.Model, parentID,
|
||||||
func() string {
|
participantType, participantID, siblingIdx,
|
||||||
if req.Role == "assistant" {
|
|
||||||
return "model"
|
|
||||||
}
|
|
||||||
return "user"
|
|
||||||
}(),
|
|
||||||
func() string {
|
|
||||||
if req.Role == "assistant" && req.Model != "" {
|
|
||||||
return req.Model
|
|
||||||
}
|
|
||||||
return userID
|
|
||||||
}(),
|
|
||||||
).Scan(
|
).Scan(
|
||||||
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
&msg.ID, &msg.ChannelID, &msg.Role, &msg.Content,
|
||||||
&msg.Model, &msg.TokensUsed, &msg.ParentID, &msg.CreatedAt,
|
&msg.Model, &msg.TokensUsed, &msg.ParentID,
|
||||||
|
&msg.SiblingIndex, &msg.CreatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
msg.SiblingCount = getSiblingCount(channelID, msg.ParentID)
|
||||||
|
|
||||||
// Touch the channel's updated_at so it sorts to top of list
|
_ = updateCursor(channelID, userID, msg.ID)
|
||||||
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
|
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, msg)
|
c.JSON(http.StatusCreated, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Regenerate (stub for #8 Chat Engine) ────
|
// ── 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) Regenerate(c *gin.Context) {
|
func (h *MessageHandler) EditMessage(c *gin.Context) {
|
||||||
c.JSON(http.StatusNotImplemented, gin.H{
|
var req editRequest
|
||||||
"error": "regenerate requires Chat Engine (ticket #8)",
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := getUserID(c)
|
||||||
|
channelID := c.Param("id")
|
||||||
|
messageID := c.Param("msgId")
|
||||||
|
|
||||||
|
if !userOwnsChannel(c, channelID, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load target — must exist, belong to channel, be a user message
|
||||||
|
var targetParentID *string
|
||||||
|
var targetRole string
|
||||||
|
err := database.DB.QueryRow(`
|
||||||
|
SELECT parent_id, role FROM messages
|
||||||
|
WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL
|
||||||
|
`, messageID, channelID).Scan(&targetParentID, &targetRole)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load message"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if targetRole != "user" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "can only edit user messages"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sibling: same parent_id as the target
|
||||||
|
siblingIdx := nextSiblingIndex(channelID, targetParentID)
|
||||||
|
|
||||||
|
var msg messageResponse
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
if 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(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stream (stub for #8 Chat Engine) ────────
|
// ── 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) Stream(c *gin.Context) {
|
func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||||
c.JSON(http.StatusNotImplemented, gin.H{
|
var req regenerateRequest
|
||||||
"error": "streaming requires Chat Engine (ticket #8)",
|
_ = c.ShouldBindJSON(&req) // body is optional
|
||||||
|
|
||||||
|
userID := getUserID(c)
|
||||||
|
channelID := c.Param("id")
|
||||||
|
messageID := c.Param("msgId")
|
||||||
|
|
||||||
|
if !userOwnsChannel(c, channelID, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load target message
|
||||||
|
var targetParentID *string
|
||||||
|
var targetRole string
|
||||||
|
err := database.DB.QueryRow(`
|
||||||
|
SELECT parent_id, role FROM messages
|
||||||
|
WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL
|
||||||
|
`, messageID, channelID).Scan(&targetParentID, &targetRole)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load message"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if targetRole != "assistant" && targetRole != "user" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "can only regenerate user or assistant messages"})
|
||||||
|
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)
|
||||||
|
var contextPath []PathMessage
|
||||||
|
var newParentID *string
|
||||||
|
|
||||||
|
if targetRole == "assistant" {
|
||||||
|
if targetParentID == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot regenerate root message"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contextPath, err = getPathToLeaf(channelID, *targetParentID)
|
||||||
|
newParentID = targetParentID
|
||||||
|
} else {
|
||||||
|
// user message — respond to it
|
||||||
|
contextPath, err = getPathToLeaf(channelID, messageID)
|
||||||
|
newParentID = &messageID
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build context"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolve model + provider ──
|
||||||
|
|
||||||
|
comp := NewCompletionHandler()
|
||||||
|
|
||||||
|
var presetSystemPrompt string
|
||||||
|
model := req.Model
|
||||||
|
apiConfigID := req.APIConfigID
|
||||||
|
temperature := req.Temperature
|
||||||
|
maxTokens := req.MaxTokens
|
||||||
|
|
||||||
|
if req.PresetID != "" {
|
||||||
|
preset := ResolvePreset(req.PresetID, userID)
|
||||||
|
if preset == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "preset not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if model == "" {
|
||||||
|
model = preset.BaseModelID
|
||||||
|
}
|
||||||
|
if apiConfigID == "" && preset.APIConfigID != nil {
|
||||||
|
apiConfigID = *preset.APIConfigID
|
||||||
|
}
|
||||||
|
if temperature == nil && preset.Temperature != nil {
|
||||||
|
temperature = preset.Temperature
|
||||||
|
}
|
||||||
|
if maxTokens == 0 && preset.MaxTokens != nil {
|
||||||
|
maxTokens = *preset.MaxTokens
|
||||||
|
}
|
||||||
|
if preset.SystemPrompt != "" {
|
||||||
|
presetSystemPrompt = preset.SystemPrompt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: channel's stored model
|
||||||
|
if model == "" {
|
||||||
|
var channelModel *string
|
||||||
|
_ = database.DB.QueryRow(`SELECT model FROM channels WHERE id = $1`, channelID).Scan(&channelModel)
|
||||||
|
if channelModel != nil {
|
||||||
|
model = *channelModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
providerCfg, providerID, model, configID, err := comp.resolveConfig(userID, channelID, completionRequest{
|
||||||
|
Model: model,
|
||||||
|
APIConfigID: apiConfigID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, err := providers.Get(providerID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build LLM message array
|
||||||
|
llmMessages := make([]providers.Message, 0, len(contextPath)+1)
|
||||||
|
|
||||||
|
if presetSystemPrompt != "" {
|
||||||
|
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: presetSystemPrompt})
|
||||||
|
} else {
|
||||||
|
var systemPrompt *string
|
||||||
|
_ = database.DB.QueryRow(`SELECT system_prompt FROM channels WHERE id = $1`, channelID).Scan(&systemPrompt)
|
||||||
|
if systemPrompt != nil && *systemPrompt != "" {
|
||||||
|
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: *systemPrompt})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range contextPath {
|
||||||
|
if m.Role == "system" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
llmMessages = append(llmMessages, providers.Message{Role: m.Role, Content: m.Content})
|
||||||
|
}
|
||||||
|
|
||||||
|
provReq := providers.CompletionRequest{
|
||||||
|
Model: model,
|
||||||
|
Messages: llmMessages,
|
||||||
|
}
|
||||||
|
|
||||||
|
caps := comp.getModelCapabilities(model, configID)
|
||||||
|
if maxTokens > 0 {
|
||||||
|
provReq.MaxTokens = maxTokens
|
||||||
|
} else {
|
||||||
|
provReq.MaxTokens = providers.ResolveMaxOutput(model, caps)
|
||||||
|
}
|
||||||
|
if temperature != nil {
|
||||||
|
provReq.Temperature = temperature
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stream the response ──
|
||||||
|
|
||||||
|
ch, err := provider.StreamCompletion(c.Request.Context(), providerCfg, provReq)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Header("X-Accel-Buffering", "no")
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
|
||||||
|
var fullContent string
|
||||||
|
flusher, _ := c.Writer.(http.Flusher)
|
||||||
|
|
||||||
|
for event := range ch {
|
||||||
|
if event.Error != nil {
|
||||||
|
fmt.Fprintf(c.Writer, "data: {\"error\":\"%s\"}\n\n", event.Error.Error())
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.Delta != "" {
|
||||||
|
fullContent += event.Delta
|
||||||
|
fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{\"content\":%q},\"finish_reason\":null}],\"model\":%q}\n\n",
|
||||||
|
event.Delta, model)
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.Done {
|
||||||
|
finishReason := event.FinishReason
|
||||||
|
if finishReason == "" {
|
||||||
|
finishReason = "stop"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{},\"finish_reason\":%q}],\"model\":%q}\n\n",
|
||||||
|
finishReason, model)
|
||||||
|
io.WriteString(c.Writer, "data: [DONE]\n\n")
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist as child or sibling depending on target role
|
||||||
|
if fullContent != "" {
|
||||||
|
siblingIdx := nextSiblingIndex(channelID, newParentID)
|
||||||
|
|
||||||
|
var newID string
|
||||||
|
err := database.DB.QueryRow(`
|
||||||
|
INSERT INTO messages (channel_id, role, content, model,
|
||||||
|
parent_id, participant_type, participant_id, sibling_index)
|
||||||
|
VALUES ($1, 'assistant', $2, $3, $4, 'model', $5, $6)
|
||||||
|
RETURNING id
|
||||||
|
`, channelID, fullContent, model, newParentID, model, siblingIdx).Scan(&newID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to persist regenerated message: %v", err)
|
||||||
|
} else {
|
||||||
|
_ = updateCursor(channelID, userID, newID)
|
||||||
|
|
||||||
|
// Send message metadata in a final SSE event so frontend can update
|
||||||
|
msgJSON, _ := json.Marshal(gin.H{
|
||||||
|
"id": newID,
|
||||||
|
"parent_id": newParentID,
|
||||||
|
"sibling_index": siblingIdx,
|
||||||
|
"sibling_count": getSiblingCount(channelID, newParentID),
|
||||||
|
})
|
||||||
|
fmt.Fprintf(c.Writer, "data: {\"switchboard_meta\":%s}\n\n", msgJSON)
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = database.DB.Exec(`UPDATE channels SET updated_at = NOW() WHERE id = $1`, channelID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := getUserID(c)
|
||||||
|
channelID := c.Param("id")
|
||||||
|
|
||||||
|
if !userOwnsChannel(c, channelID, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the target message belongs to this channel
|
||||||
|
var msgChannelID string
|
||||||
|
err := database.DB.QueryRow(`
|
||||||
|
SELECT channel_id FROM messages
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
|
`, req.ActiveLeafID).Scan(&msgChannelID)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to find leaf"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := updateCursor(channelID, userID, leafID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update cursor"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the new active path
|
||||||
|
path, err := getPathToLeaf(channelID, leafID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load path"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"path": path, "active_leaf_id": leafID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
siblings, currentIdx, err := getSiblings(messageID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"siblings": siblings,
|
||||||
|
"current_index": currentIdx,
|
||||||
|
"total": len(siblings),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
296
server/handlers/tree.go
Normal file
296
server/handlers/tree.go
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Tree Types ──────────────────────────────
|
||||||
|
|
||||||
|
// PathMessage represents a single message in an active path, enriched
|
||||||
|
// with sibling metadata for the frontend branch indicator.
|
||||||
|
type PathMessage struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ParentID *string `json:"parent_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Model *string `json:"model"`
|
||||||
|
TokensUsed *int `json:"tokens_used,omitempty"`
|
||||||
|
SiblingCount int `json:"sibling_count"`
|
||||||
|
SiblingIndex int `json:"sibling_index"`
|
||||||
|
ParticipantType string `json:"participant_type,omitempty"`
|
||||||
|
ParticipantID string `json:"participant_id,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiblingInfo is a lightweight entry for the siblings listing endpoint.
|
||||||
|
type SiblingInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Model *string `json:"model,omitempty"`
|
||||||
|
SiblingIndex int `json:"sibling_index"`
|
||||||
|
Preview string `json:"preview"` // first ~80 chars of content
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get Active Leaf ─────────────────────────
|
||||||
|
|
||||||
|
// getActiveLeaf returns the cursor's active_leaf_id for a user in a channel.
|
||||||
|
// Falls back to the chronologically latest live message if no cursor exists.
|
||||||
|
func getActiveLeaf(channelID, userID string) (*string, error) {
|
||||||
|
var leafID *string
|
||||||
|
|
||||||
|
// Try cursor first
|
||||||
|
err := database.DB.QueryRow(`
|
||||||
|
SELECT active_leaf_id FROM channel_cursors
|
||||||
|
WHERE channel_id = $1 AND user_id = $2
|
||||||
|
`, channelID, userID).Scan(&leafID)
|
||||||
|
|
||||||
|
if err == nil && leafID != nil {
|
||||||
|
// Verify the leaf still exists and isn't deleted
|
||||||
|
var exists bool
|
||||||
|
database.DB.QueryRow(`
|
||||||
|
SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND deleted_at IS NULL)
|
||||||
|
`, *leafID).Scan(&exists)
|
||||||
|
if exists {
|
||||||
|
return leafID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: latest live message in channel
|
||||||
|
var fallbackID string
|
||||||
|
err = database.DB.QueryRow(`
|
||||||
|
SELECT id FROM messages
|
||||||
|
WHERE channel_id = $1 AND deleted_at IS NULL
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
`, channelID).Scan(&fallbackID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil // empty channel
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &fallbackID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get Active Path ─────────────────────────
|
||||||
|
|
||||||
|
// getActivePath walks from the active leaf up to the root via parent_id,
|
||||||
|
// returning messages in root-first order. This is what gets sent to the LLM.
|
||||||
|
func getActivePath(channelID, userID string) ([]PathMessage, error) {
|
||||||
|
leafID, err := getActiveLeaf(channelID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if leafID == nil {
|
||||||
|
return []PathMessage{}, nil // empty channel
|
||||||
|
}
|
||||||
|
|
||||||
|
return getPathToLeaf(channelID, *leafID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPathToLeaf walks from a specific leaf up to root, returning root-first.
|
||||||
|
func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
|
||||||
|
rows, err := database.DB.Query(`
|
||||||
|
WITH RECURSIVE path AS (
|
||||||
|
-- Anchor: start at the leaf
|
||||||
|
SELECT id, parent_id, role, content, model, tokens_used,
|
||||||
|
participant_type, participant_id, sibling_index, created_at,
|
||||||
|
0 AS depth
|
||||||
|
FROM messages
|
||||||
|
WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- Walk up via parent_id
|
||||||
|
SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used,
|
||||||
|
m.participant_type, m.participant_id, m.sibling_index, m.created_at,
|
||||||
|
p.depth + 1
|
||||||
|
FROM messages m
|
||||||
|
JOIN path p ON m.id = p.parent_id
|
||||||
|
WHERE m.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
SELECT id, parent_id, role, content, model, tokens_used,
|
||||||
|
participant_type, participant_id, sibling_index, created_at
|
||||||
|
FROM path
|
||||||
|
ORDER BY depth DESC
|
||||||
|
`, leafID, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getPathToLeaf: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var path []PathMessage
|
||||||
|
for rows.Next() {
|
||||||
|
var m PathMessage
|
||||||
|
var participantType, participantID sql.NullString
|
||||||
|
if err := rows.Scan(
|
||||||
|
&m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
|
||||||
|
&participantType, &participantID, &m.SiblingIndex, &m.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("getPathToLeaf scan: %w", err)
|
||||||
|
}
|
||||||
|
if participantType.Valid {
|
||||||
|
m.ParticipantType = participantType.String
|
||||||
|
}
|
||||||
|
if participantID.Valid {
|
||||||
|
m.ParticipantID = participantID.String
|
||||||
|
}
|
||||||
|
path = append(path, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich with sibling counts
|
||||||
|
for i := range path {
|
||||||
|
path[i].SiblingCount = getSiblingCount(channelID, path[i].ParentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sibling Queries ─────────────────────────
|
||||||
|
|
||||||
|
// getSiblingCount returns how many live siblings share the same parent_id.
|
||||||
|
// Root messages (parent_id IS NULL) count all roots in the same channel.
|
||||||
|
func getSiblingCount(channelID string, parentID *string) int {
|
||||||
|
var count int
|
||||||
|
if parentID == nil {
|
||||||
|
database.DB.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM messages
|
||||||
|
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
|
||||||
|
`, channelID).Scan(&count)
|
||||||
|
} else {
|
||||||
|
database.DB.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM messages
|
||||||
|
WHERE parent_id = $1 AND deleted_at IS NULL
|
||||||
|
`, *parentID).Scan(&count)
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
count = 1
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSiblings returns all live siblings of a given message (including itself),
|
||||||
|
// ordered by sibling_index.
|
||||||
|
func getSiblings(messageID string) ([]SiblingInfo, int, error) {
|
||||||
|
// First get the parent_id of the target message
|
||||||
|
var parentID *string
|
||||||
|
var channelID string
|
||||||
|
err := database.DB.QueryRow(`
|
||||||
|
SELECT parent_id, channel_id FROM messages
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
|
`, messageID).Scan(&parentID, &channelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("message not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows *sql.Rows
|
||||||
|
if parentID == nil {
|
||||||
|
// Root messages: siblings are other roots in the same channel
|
||||||
|
rows, err = database.DB.Query(`
|
||||||
|
SELECT id, role, model, sibling_index, LEFT(content, 80), created_at
|
||||||
|
FROM messages
|
||||||
|
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
|
||||||
|
ORDER BY sibling_index, created_at
|
||||||
|
`, channelID)
|
||||||
|
} else {
|
||||||
|
rows, err = database.DB.Query(`
|
||||||
|
SELECT id, role, model, sibling_index, LEFT(content, 80), created_at
|
||||||
|
FROM messages
|
||||||
|
WHERE parent_id = $1 AND deleted_at IS NULL
|
||||||
|
ORDER BY sibling_index, created_at
|
||||||
|
`, *parentID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var siblings []SiblingInfo
|
||||||
|
currentIdx := 0
|
||||||
|
for i := 0; rows.Next(); i++ {
|
||||||
|
var s SiblingInfo
|
||||||
|
if err := rows.Scan(&s.ID, &s.Role, &s.Model, &s.SiblingIndex, &s.Preview, &s.CreatedAt); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if s.ID == messageID {
|
||||||
|
currentIdx = i
|
||||||
|
}
|
||||||
|
siblings = append(siblings, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return siblings, currentIdx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Find Leaf ───────────────────────────────
|
||||||
|
|
||||||
|
// findLeafFromMessage walks down from a message to find the deepest descendant.
|
||||||
|
// Follows the first child (lowest sibling_index) at each level.
|
||||||
|
// Used when switching branches: clicking a mid-tree sibling navigates to its leaf.
|
||||||
|
func findLeafFromMessage(messageID string) (string, error) {
|
||||||
|
var leafID string
|
||||||
|
err := database.DB.QueryRow(`
|
||||||
|
WITH RECURSIVE descendants AS (
|
||||||
|
SELECT id, 0 AS depth
|
||||||
|
FROM messages
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT child.id, d.depth + 1
|
||||||
|
FROM messages child
|
||||||
|
JOIN descendants d ON child.parent_id = d.id
|
||||||
|
WHERE child.deleted_at IS NULL
|
||||||
|
AND child.sibling_index = (
|
||||||
|
SELECT MIN(sibling_index) FROM messages
|
||||||
|
WHERE parent_id = d.id AND deleted_at IS NULL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
SELECT id FROM descendants
|
||||||
|
ORDER BY depth DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, messageID).Scan(&leafID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return messageID, nil // if anything fails, just use the message itself
|
||||||
|
}
|
||||||
|
return leafID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Next Sibling Index ──────────────────────
|
||||||
|
|
||||||
|
// nextSiblingIndex returns the next sibling_index for a new child of parentID.
|
||||||
|
// For root messages (parentID is nil), counts existing roots in the channel.
|
||||||
|
func nextSiblingIndex(channelID string, parentID *string) int {
|
||||||
|
var maxIdx sql.NullInt64
|
||||||
|
if parentID == nil {
|
||||||
|
database.DB.QueryRow(`
|
||||||
|
SELECT MAX(sibling_index) FROM messages
|
||||||
|
WHERE channel_id = $1 AND parent_id IS NULL AND deleted_at IS NULL
|
||||||
|
`, channelID).Scan(&maxIdx)
|
||||||
|
} else {
|
||||||
|
database.DB.QueryRow(`
|
||||||
|
SELECT MAX(sibling_index) FROM messages
|
||||||
|
WHERE parent_id = $1 AND deleted_at IS NULL
|
||||||
|
`, *parentID).Scan(&maxIdx)
|
||||||
|
}
|
||||||
|
if !maxIdx.Valid {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int(maxIdx.Int64) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update Cursor ───────────────────────────
|
||||||
|
|
||||||
|
// updateCursor upserts the channel_cursors row for a user.
|
||||||
|
func updateCursor(channelID, userID, messageID string) error {
|
||||||
|
_, err := database.DB.Exec(`
|
||||||
|
INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (channel_id, user_id) DO UPDATE SET
|
||||||
|
active_leaf_id = $3, updated_at = NOW()
|
||||||
|
`, channelID, userID, messageID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -104,10 +104,16 @@ func main() {
|
|||||||
protected.GET("/channels/:id/messages", msgs.ListMessages)
|
protected.GET("/channels/:id/messages", msgs.ListMessages)
|
||||||
protected.POST("/channels/:id/messages", msgs.CreateMessage)
|
protected.POST("/channels/:id/messages", msgs.CreateMessage)
|
||||||
|
|
||||||
// ── Chat Engine (#8) ────────────────
|
// Message tree (forking)
|
||||||
|
protected.GET("/channels/:id/path", msgs.GetActivePath)
|
||||||
|
protected.PUT("/channels/:id/cursor", msgs.UpdateCursor)
|
||||||
|
protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage)
|
||||||
|
protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate)
|
||||||
|
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
|
||||||
|
|
||||||
|
// Chat Completions
|
||||||
comp := handlers.NewCompletionHandler()
|
comp := handlers.NewCompletionHandler()
|
||||||
protected.POST("/chat/completions", comp.Complete)
|
protected.POST("/chat/completions", comp.Complete)
|
||||||
protected.POST("/channels/:id/regenerate", msgs.Regenerate) // TODO: wire to completion handler
|
|
||||||
|
|
||||||
// API Configs
|
// API Configs
|
||||||
apiCfg := handlers.NewAPIConfigHandler()
|
apiCfg := handlers.NewAPIConfigHandler()
|
||||||
|
|||||||
@@ -428,6 +428,79 @@ a:hover { text-decoration: underline; }
|
|||||||
}
|
}
|
||||||
.msg-action-btn:hover { background: var(--bg-hover); color: var(--text); }
|
.msg-action-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
|
||||||
|
/* Branch navigation indicator */
|
||||||
|
.branch-nav {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.branch-arrow {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-2);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 5px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 18px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
.branch-arrow:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.branch-arrow:disabled { opacity: 0.25; cursor: default; }
|
||||||
|
.branch-pos {
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 28px;
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline message editing */
|
||||||
|
.msg-edit-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: var(--msg-font, 14px);
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 60px;
|
||||||
|
max-height: 400px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.msg-edit-input:focus { border-color: var(--accent-hover); box-shadow: 0 0 0 2px var(--accent-dim); }
|
||||||
|
.msg-edit-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.msg-edit-cancel, .msg-edit-submit {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.msg-edit-cancel {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.msg-edit-cancel:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.msg-edit-submit {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.msg-edit-submit:hover { background: var(--accent-hover); }
|
||||||
|
|
||||||
.msg-text { font-size: var(--msg-font, 14px); line-height: 1.7; }
|
.msg-text { font-size: var(--msg-font, 14px); line-height: 1.7; }
|
||||||
|
|
||||||
/* ── Markdown Content ────────────────────── */
|
/* ── Markdown Content ────────────────────── */
|
||||||
@@ -755,13 +828,14 @@ button { font-family: var(--font); cursor: pointer; }
|
|||||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||||
display: none; align-items: center; justify-content: center;
|
display: none; align-items: center; justify-content: center;
|
||||||
z-index: 1000; backdrop-filter: blur(2px);
|
z-index: 1000; backdrop-filter: blur(2px);
|
||||||
|
padding: 2rem; /* keeps modal away from viewport edges at any zoom */
|
||||||
}
|
}
|
||||||
.modal-overlay.active { display: flex; }
|
.modal-overlay.active { display: flex; }
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: var(--bg-surface); border: 1px solid var(--border);
|
background: var(--bg-surface); border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg); width: 90%; max-width: 520px;
|
border-radius: var(--radius-lg); width: 90%; max-width: 520px;
|
||||||
max-height: 85vh; display: flex; flex-direction: column;
|
max-height: 100%; display: flex; flex-direction: column;
|
||||||
animation: modal-in 0.15s ease;
|
animation: modal-in 0.15s ease;
|
||||||
}
|
}
|
||||||
.modal-wide { max-width: 700px; }
|
.modal-wide { max-width: 700px; }
|
||||||
@@ -788,13 +862,44 @@ button { font-family: var(--font); cursor: pointer; }
|
|||||||
.settings-section:last-child { border-bottom: none; margin-bottom: 0; }
|
.settings-section:last-child { border-bottom: none; margin-bottom: 0; }
|
||||||
.settings-section h3 { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.06em; }
|
.settings-section h3 { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||||
|
|
||||||
|
/* Modal tab bars — shared horizontal scroll with arrow navigation */
|
||||||
|
.modal-tabs-wrap {
|
||||||
|
position: relative; flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--border); background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
.modal-tabs {
|
||||||
|
display: flex; padding: 0 16px;
|
||||||
|
border-bottom: 1px solid var(--border); background: var(--bg-raised);
|
||||||
|
overflow-x: auto; overflow-y: hidden;
|
||||||
|
scrollbar-width: none; -ms-overflow-style: none;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.modal-tabs::-webkit-scrollbar { display: none; }
|
||||||
|
/* When wrapped, the wrapper owns the border */
|
||||||
|
.modal-tabs-wrap .modal-tabs { border-bottom: none; background: none; }
|
||||||
|
|
||||||
|
.tab-arrow {
|
||||||
|
position: absolute; top: 0; bottom: 1px; /* 1px for border */ width: 28px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--bg-raised); border: none; color: var(--text-2);
|
||||||
|
cursor: pointer; font-size: 16px; z-index: 2; opacity: 0;
|
||||||
|
pointer-events: none; transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.tab-arrow.visible { opacity: 1; pointer-events: auto; }
|
||||||
|
.tab-arrow:hover { color: var(--accent); }
|
||||||
|
.tab-arrow-left { left: 0; padding-left: 2px; box-shadow: 4px 0 8px -2px rgba(0,0,0,0.15); }
|
||||||
|
.tab-arrow-right { right: 0; padding-right: 2px; box-shadow: -4px 0 8px -2px rgba(0,0,0,0.15); }
|
||||||
|
|
||||||
/* Settings tabs */
|
/* Settings tabs */
|
||||||
.settings-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 16px; background: var(--bg-raised); gap: 0; }
|
.settings-tabs { gap: 0; }
|
||||||
.settings-tabs .settings-tab {
|
.settings-tabs .settings-tab {
|
||||||
padding: 10px 14px; background: none; border: none; border-radius: 0;
|
padding: 10px 14px; background: none; border: none; border-radius: 0;
|
||||||
color: var(--text-3); cursor: pointer; font-size: 13px; font-family: var(--font);
|
color: var(--text-3); cursor: pointer; font-size: 13px; font-family: var(--font);
|
||||||
border-bottom: 2px solid transparent; transition: color var(--transition);
|
border-bottom: 2px solid transparent; transition: color var(--transition);
|
||||||
outline: none; -webkit-appearance: none; appearance: none;
|
outline: none; -webkit-appearance: none; appearance: none;
|
||||||
|
white-space: nowrap; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.settings-tabs .settings-tab:hover { color: var(--text-2); }
|
.settings-tabs .settings-tab:hover { color: var(--text-2); }
|
||||||
.settings-tabs .settings-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
.settings-tabs .settings-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
@@ -817,10 +922,11 @@ button { font-family: var(--font); cursor: pointer; }
|
|||||||
.badge-global { font-size: 10px; color: var(--text-3); background: var(--bg-raised); padding: 2px 8px; border-radius: 4px; }
|
.badge-global { font-size: 10px; color: var(--text-3); background: var(--bg-raised); padding: 2px 8px; border-radius: 4px; }
|
||||||
|
|
||||||
/* Admin tabs */
|
/* Admin tabs */
|
||||||
.admin-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 16px; background: var(--bg-raised); }
|
.admin-tabs { }
|
||||||
.admin-tab {
|
.admin-tab {
|
||||||
padding: 10px 14px; background: none; border: none; color: var(--text-3);
|
padding: 10px 14px; background: none; border: none; color: var(--text-3);
|
||||||
cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent;
|
cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent;
|
||||||
|
white-space: nowrap; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
.admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
|
|
||||||
@@ -937,9 +1043,9 @@ button { font-family: var(--font); cursor: pointer; }
|
|||||||
|
|
||||||
/* ── Debug Modal ─────────────────────────── */
|
/* ── Debug Modal ─────────────────────────── */
|
||||||
|
|
||||||
.debug-modal { max-width: 900px; height: 80vh; overflow: hidden; }
|
.debug-modal { max-width: 900px; height: 100%; overflow: hidden; }
|
||||||
.debug-tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 16px; background: var(--bg-raised); }
|
.debug-tabs { }
|
||||||
.debug-tab { padding: 8px 12px; background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 12px; border-bottom: 2px solid transparent; }
|
.debug-tab { padding: 8px 12px; background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 12px; border-bottom: 2px solid transparent; white-space: nowrap; flex-shrink: 0; }
|
||||||
.debug-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
.debug-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
.debug-modal-body { flex: 1; overflow: hidden; padding: 0 !important; }
|
.debug-modal-body { flex: 1; overflow: hidden; padding: 0 !important; }
|
||||||
.debug-tab-content { height: 100%; display: flex; flex-direction: column; }
|
.debug-tab-content { height: 100%; display: flex; flex-direction: column; }
|
||||||
@@ -980,4 +1086,8 @@ button { font-family: var(--font); cursor: pointer; }
|
|||||||
.input-area { padding: 0 0.5rem 0.5rem; }
|
.input-area { padding: 0 0.5rem 0.5rem; }
|
||||||
.input-wrap textarea { font-size: 16px; } /* prevent iOS zoom */
|
.input-wrap textarea { font-size: 16px; } /* prevent iOS zoom */
|
||||||
.mobile-menu-btn { display: flex; }
|
.mobile-menu-btn { display: flex; }
|
||||||
|
.modal-overlay { padding: 0.75rem; }
|
||||||
|
.modal { width: 100%; }
|
||||||
|
.tab-arrow { width: 36px; font-size: 20px; } /* larger touch target */
|
||||||
|
.modal-tabs { padding: 0 12px; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,9 +118,6 @@
|
|||||||
<div class="input-wrap">
|
<div class="input-wrap">
|
||||||
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
|
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
|
||||||
<div class="input-actions">
|
<div class="input-actions">
|
||||||
<button class="action-btn" id="regenerateBtn" style="display:none" title="Regenerate">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="action-btn stop-btn" id="stopBtn" title="Stop">
|
<button class="action-btn stop-btn" id="stopBtn" title="Stop">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -201,7 +198,7 @@
|
|||||||
<div class="modal-overlay" id="settingsModal">
|
<div class="modal-overlay" id="settingsModal">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="modal-header"><h2>Settings</h2><button class="modal-close" id="settingsCloseBtn">✕</button></div>
|
<div class="modal-header"><h2>Settings</h2><button class="modal-close" id="settingsCloseBtn">✕</button></div>
|
||||||
<div class="settings-tabs">
|
<div class="modal-tabs settings-tabs">
|
||||||
<button class="settings-tab active" data-stab="general" onclick="UI.switchSettingsTab('general')">General</button>
|
<button class="settings-tab active" data-stab="general" onclick="UI.switchSettingsTab('general')">General</button>
|
||||||
<button class="settings-tab" data-stab="appearance" onclick="UI.switchSettingsTab('appearance')">Appearance</button>
|
<button class="settings-tab" data-stab="appearance" onclick="UI.switchSettingsTab('appearance')">Appearance</button>
|
||||||
<button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')">Providers</button>
|
<button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')">Providers</button>
|
||||||
@@ -283,7 +280,7 @@
|
|||||||
<div class="modal-overlay" id="adminModal">
|
<div class="modal-overlay" id="adminModal">
|
||||||
<div class="modal modal-wide">
|
<div class="modal modal-wide">
|
||||||
<div class="modal-header"><h2>Admin Panel</h2><button class="modal-close" id="adminCloseBtn">✕</button></div>
|
<div class="modal-header"><h2>Admin Panel</h2><button class="modal-close" id="adminCloseBtn">✕</button></div>
|
||||||
<div class="admin-tabs">
|
<div class="modal-tabs admin-tabs">
|
||||||
<button class="admin-tab active" data-tab="users">Users</button>
|
<button class="admin-tab active" data-tab="users">Users</button>
|
||||||
<button class="admin-tab" data-tab="providers">Providers</button>
|
<button class="admin-tab" data-tab="providers">Providers</button>
|
||||||
<button class="admin-tab" data-tab="models">Models</button>
|
<button class="admin-tab" data-tab="models">Models</button>
|
||||||
@@ -408,7 +405,7 @@
|
|||||||
<div class="modal-overlay" id="debugModal">
|
<div class="modal-overlay" id="debugModal">
|
||||||
<div class="modal debug-modal">
|
<div class="modal debug-modal">
|
||||||
<div class="modal-header"><h2>Debug Log</h2><button class="modal-close" onclick="closeDebugModal()">✕</button></div>
|
<div class="modal-header"><h2>Debug Log</h2><button class="modal-close" onclick="closeDebugModal()">✕</button></div>
|
||||||
<div class="debug-tabs">
|
<div class="modal-tabs debug-tabs">
|
||||||
<button class="debug-tab active" data-tab="console" onclick="switchDebugTab('console')">Console (<span id="debugConsoleCount">0</span>)</button>
|
<button class="debug-tab active" data-tab="console" onclick="switchDebugTab('console')">Console (<span id="debugConsoleCount">0</span>)</button>
|
||||||
<button class="debug-tab" data-tab="network" onclick="switchDebugTab('network')">Network (<span id="debugNetworkCount">0</span>)</button>
|
<button class="debug-tab" data-tab="network" onclick="switchDebugTab('network')">Network (<span id="debugNetworkCount">0</span>)</button>
|
||||||
<button class="debug-tab" data-tab="state" onclick="switchDebugTab('state')">State</button>
|
<button class="debug-tab" data-tab="state" onclick="switchDebugTab('state')">State</button>
|
||||||
|
|||||||
@@ -122,6 +122,55 @@ const API = {
|
|||||||
return this._get(`/api/v1/channels/${channelId}/messages?page=${page}&per_page=${perPage}`);
|
return this._get(`/api/v1/channels/${channelId}/messages?page=${page}&per_page=${perPage}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Message Tree (forking) ───────────────
|
||||||
|
|
||||||
|
getActivePath(channelId) {
|
||||||
|
return this._get(`/api/v1/channels/${channelId}/path`);
|
||||||
|
},
|
||||||
|
|
||||||
|
editMessage(channelId, messageId, content) {
|
||||||
|
return this._post(`/api/v1/channels/${channelId}/messages/${messageId}/edit`, { content });
|
||||||
|
},
|
||||||
|
|
||||||
|
async streamRegenerate(channelId, messageId, signal, model, presetId, apiConfigId) {
|
||||||
|
const body = {};
|
||||||
|
if (model) body.model = model;
|
||||||
|
if (presetId) body.preset_id = presetId;
|
||||||
|
if (apiConfigId) body.api_config_id = apiConfigId;
|
||||||
|
|
||||||
|
let resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this._authHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status === 401 && this.refreshToken) {
|
||||||
|
if (await this.refresh()) {
|
||||||
|
resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this._authHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCursor(channelId, activeLeafId) {
|
||||||
|
return this._put(`/api/v1/channels/${channelId}/cursor`, { active_leaf_id: activeLeafId });
|
||||||
|
},
|
||||||
|
|
||||||
|
listSiblings(channelId, messageId) {
|
||||||
|
return this._get(`/api/v1/channels/${channelId}/messages/${messageId}/siblings`);
|
||||||
|
},
|
||||||
|
|
||||||
// ── Completions ──────────────────────────
|
// ── Completions ──────────────────────────
|
||||||
|
|
||||||
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId) {
|
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId) {
|
||||||
|
|||||||
302
src/js/app.js
302
src/js/app.js
@@ -242,9 +242,17 @@ 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.listMessages(chatId);
|
const resp = await API.getActivePath(chatId);
|
||||||
chat.messages = (resp.data || []).map(m => ({
|
chat.messages = (resp.path || []).map(m => ({
|
||||||
role: m.role, content: m.content, model: m.model || '', timestamp: m.created_at
|
id: m.id,
|
||||||
|
parent_id: m.parent_id || null,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
model: m.model || '',
|
||||||
|
modelName: '',
|
||||||
|
timestamp: m.created_at,
|
||||||
|
siblingCount: m.sibling_count || 1,
|
||||||
|
siblingIndex: m.sibling_index || 0,
|
||||||
}));
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load messages:', e.message);
|
console.error('Failed to load messages:', e.message);
|
||||||
@@ -309,7 +317,8 @@ async function sendMessage() {
|
|||||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
|
|
||||||
chat.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString() });
|
// Optimistic: show user message immediately
|
||||||
|
chat.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 });
|
||||||
UI.renderMessages(chat.messages);
|
UI.renderMessages(chat.messages);
|
||||||
|
|
||||||
App.isGenerating = true;
|
App.isGenerating = true;
|
||||||
@@ -318,14 +327,15 @@ async function sendMessage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId);
|
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId);
|
||||||
const assistantContent = await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
||||||
chat.messages.push({ role: 'assistant', content: assistantContent, model, modelName: modelInfo?.name || model, timestamp: new Date().toISOString() });
|
|
||||||
chat.messageCount = chat.messages.length;
|
// Reload active path from server to get proper IDs and sibling metadata
|
||||||
UI.renderMessages(chat.messages);
|
await reloadActivePath();
|
||||||
UI.showRegenerate(true);
|
UI.showRegenerate(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.name === 'AbortError') {
|
if (e.name === 'AbortError') {
|
||||||
UI.toast('Generation stopped', 'warning');
|
UI.toast('Generation stopped', 'warning');
|
||||||
|
await reloadActivePath();
|
||||||
} else {
|
} else {
|
||||||
console.error('Completion error:', e);
|
console.error('Completion error:', e);
|
||||||
const msg = e.message || '';
|
const msg = e.message || '';
|
||||||
@@ -348,34 +358,63 @@ async function sendMessage() {
|
|||||||
|
|
||||||
function stopGeneration() { if (App.abortController) App.abortController.abort(); }
|
function stopGeneration() { if (App.abortController) App.abortController.abort(); }
|
||||||
|
|
||||||
async function regenerate() {
|
// ── Reload Active Path from Server ──────────
|
||||||
if (App.isGenerating || !App.currentChatId) return;
|
|
||||||
|
async function reloadActivePath() {
|
||||||
|
if (!App.currentChatId) return;
|
||||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||||
if (!chat || chat.messages.length === 0) return;
|
if (!chat) return;
|
||||||
|
|
||||||
while (chat.messages.length && chat.messages[chat.messages.length - 1].role === 'assistant') chat.messages.pop();
|
try {
|
||||||
if (chat.messages.length === 0) return;
|
const resp = await API.getActivePath(App.currentChatId);
|
||||||
|
chat.messages = (resp.path || []).map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
parent_id: m.parent_id || null,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
model: m.model || '',
|
||||||
|
modelName: '',
|
||||||
|
timestamp: m.created_at,
|
||||||
|
siblingCount: m.sibling_count || 1,
|
||||||
|
siblingIndex: m.sibling_index || 0,
|
||||||
|
}));
|
||||||
|
chat.messageCount = chat.messages.length;
|
||||||
|
UI.renderMessages(chat.messages);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to reload path:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
UI.renderMessages(chat.messages);
|
// ── Regenerate (tree-aware: creates sibling) ─
|
||||||
const lastUser = chat.messages[chat.messages.length - 1];
|
|
||||||
if (lastUser.role !== 'user') return;
|
async function regenerateMessage(messageId) {
|
||||||
|
if (App.isGenerating || !App.currentChatId) return;
|
||||||
|
|
||||||
const selectedId = UI.getModelValue();
|
const selectedId = UI.getModelValue();
|
||||||
const modelInfo = App.models.find(m => m.id === selectedId);
|
const modelInfo = App.models.find(m => m.id === selectedId);
|
||||||
const model = modelInfo?.baseModelId || selectedId;
|
const model = modelInfo?.baseModelId || selectedId;
|
||||||
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||||||
|
|
||||||
App.isGenerating = true;
|
App.isGenerating = true;
|
||||||
App.abortController = new AbortController();
|
App.abortController = new AbortController();
|
||||||
UI.setGenerating(true);
|
UI.setGenerating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal, modelInfo?.configId, presetId);
|
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||||
const content = await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
const resp = await API.streamRegenerate(
|
||||||
chat.messages.push({ role: 'assistant', content, model, modelName: modelInfo?.name || model, timestamp: new Date().toISOString() });
|
App.currentChatId, messageId, App.abortController.signal,
|
||||||
UI.renderMessages(chat.messages);
|
model, presetId, modelInfo?.configId
|
||||||
|
);
|
||||||
|
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
|
||||||
|
|
||||||
|
// Reload from server to get proper tree state
|
||||||
|
await reloadActivePath();
|
||||||
UI.showRegenerate(true);
|
UI.showRegenerate(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.name !== 'AbortError') {
|
if (e.name === 'AbortError') {
|
||||||
|
UI.toast('Generation stopped', 'warning');
|
||||||
|
await reloadActivePath();
|
||||||
|
} else {
|
||||||
const msg = e.message || '';
|
const msg = e.message || '';
|
||||||
if (msg.includes('provider error') && msg.includes('401')) {
|
if (msg.includes('provider error') && msg.includes('401')) {
|
||||||
UI.toast('Provider API key rejected — check Settings', 'error');
|
UI.toast('Provider API key rejected — check Settings', 'error');
|
||||||
@@ -388,6 +427,212 @@ async function regenerate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy: bottom-bar regenerate button targets the last assistant message
|
||||||
|
async function regenerate() {
|
||||||
|
if (App.isGenerating || !App.currentChatId) return;
|
||||||
|
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||||
|
if (!chat || chat.messages.length === 0) return;
|
||||||
|
|
||||||
|
// Find the last assistant message with an ID
|
||||||
|
for (let i = chat.messages.length - 1; i >= 0; i--) {
|
||||||
|
if (chat.messages[i].role === 'assistant' && chat.messages[i].id) {
|
||||||
|
return regenerateMessage(chat.messages[i].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UI.toast('No assistant message to regenerate', 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit Message (creates sibling, triggers completion) ──
|
||||||
|
|
||||||
|
async function editMessage(messageId) {
|
||||||
|
if (App.isGenerating || !App.currentChatId) return;
|
||||||
|
|
||||||
|
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||||
|
if (!chat) return;
|
||||||
|
|
||||||
|
const msg = chat.messages.find(m => m.id === messageId);
|
||||||
|
if (!msg || msg.role !== 'user') return;
|
||||||
|
|
||||||
|
// Show inline edit UI
|
||||||
|
UI.showEditInline(messageId, msg.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEdit(messageId, newContent) {
|
||||||
|
if (App.isGenerating || !App.currentChatId) return;
|
||||||
|
if (!newContent.trim()) return;
|
||||||
|
|
||||||
|
const selectedId = UI.getModelValue();
|
||||||
|
const modelInfo = App.models.find(m => m.id === selectedId);
|
||||||
|
const model = modelInfo?.baseModelId || selectedId;
|
||||||
|
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||||||
|
|
||||||
|
App.isGenerating = true;
|
||||||
|
App.abortController = new AbortController();
|
||||||
|
UI.setGenerating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||||
|
|
||||||
|
// Step 1: Create sibling user message via edit endpoint
|
||||||
|
const editResp = await API.editMessage(App.currentChatId, messageId, newContent.trim());
|
||||||
|
|
||||||
|
// Step 2: Reload path to show the new edited message
|
||||||
|
await reloadActivePath();
|
||||||
|
|
||||||
|
// Step 3: Generate assistant response as child of the new edit.
|
||||||
|
// Uses regenerate endpoint (which handles user messages by creating
|
||||||
|
// a child response, not a sibling). This avoids the duplicate user
|
||||||
|
// message that streamCompletion would create.
|
||||||
|
const resp = await API.streamRegenerate(
|
||||||
|
App.currentChatId, editResp.id, App.abortController.signal,
|
||||||
|
model, presetId, modelInfo?.configId
|
||||||
|
);
|
||||||
|
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
|
||||||
|
|
||||||
|
// Step 4: Final reload to get the complete tree state
|
||||||
|
await reloadActivePath();
|
||||||
|
UI.showRegenerate(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') {
|
||||||
|
UI.toast('Generation stopped', 'warning');
|
||||||
|
await reloadActivePath();
|
||||||
|
} else {
|
||||||
|
console.error('Edit error:', e);
|
||||||
|
UI.toast(e.message || 'Edit failed', 'error');
|
||||||
|
await reloadActivePath();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
App.isGenerating = false;
|
||||||
|
App.abortController = null;
|
||||||
|
UI.setGenerating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
// Re-render to remove the edit form
|
||||||
|
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||||
|
if (chat) UI.renderMessages(chat.messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Switch Branch (sibling navigation) ──────
|
||||||
|
|
||||||
|
async function switchSibling(messageId, direction) {
|
||||||
|
if (App.isGenerating || !App.currentChatId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get siblings for this message
|
||||||
|
const data = await API.listSiblings(App.currentChatId, messageId);
|
||||||
|
const siblings = data.siblings || [];
|
||||||
|
const currentIdx = data.current_index ?? 0;
|
||||||
|
|
||||||
|
const targetIdx = currentIdx + direction;
|
||||||
|
if (targetIdx < 0 || targetIdx >= siblings.length) return;
|
||||||
|
|
||||||
|
const targetSibling = siblings[targetIdx];
|
||||||
|
|
||||||
|
// Update cursor to the target sibling (backend walks to leaf)
|
||||||
|
const resp = await API.updateCursor(App.currentChatId, targetSibling.id);
|
||||||
|
|
||||||
|
// Update local state with the new path
|
||||||
|
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||||
|
if (chat && resp.path) {
|
||||||
|
chat.messages = resp.path.map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
parent_id: m.parent_id || null,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
model: m.model || '',
|
||||||
|
modelName: '',
|
||||||
|
timestamp: m.created_at,
|
||||||
|
siblingCount: m.sibling_count || 1,
|
||||||
|
siblingIndex: m.sibling_index || 0,
|
||||||
|
}));
|
||||||
|
chat.messageCount = chat.messages.length;
|
||||||
|
UI.renderMessages(chat.messages);
|
||||||
|
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Branch switch failed:', e.message);
|
||||||
|
UI.toast('Failed to switch branch', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modal Helpers ───────────────────────────
|
||||||
|
|
||||||
|
function openModal(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.add('active');
|
||||||
|
// Defer overflow check — browser needs a frame to lay out the modal
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.querySelectorAll('.modal-tabs').forEach(checkTabsOverflow);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle .is-scrollable on tab bars that overflow horizontally
|
||||||
|
// and inject arrow navigation buttons when needed.
|
||||||
|
function checkTabsOverflow(tabs) {
|
||||||
|
if (!tabs) return;
|
||||||
|
const scrollable = tabs.scrollWidth > tabs.clientWidth + 1;
|
||||||
|
|
||||||
|
if (!scrollable) {
|
||||||
|
// Remove wrapper if overflow went away (e.g. zoom decreased)
|
||||||
|
const wrap = tabs.parentElement;
|
||||||
|
if (wrap && wrap.classList.contains('modal-tabs-wrap')) {
|
||||||
|
wrap.parentElement.insertBefore(tabs, wrap);
|
||||||
|
wrap.remove();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap if not already wrapped
|
||||||
|
if (!tabs.parentElement.classList.contains('modal-tabs-wrap')) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'modal-tabs-wrap';
|
||||||
|
tabs.parentElement.insertBefore(wrap, tabs);
|
||||||
|
wrap.appendChild(tabs);
|
||||||
|
|
||||||
|
// Inject arrows
|
||||||
|
const left = document.createElement('button');
|
||||||
|
left.className = 'tab-arrow tab-arrow-left';
|
||||||
|
left.innerHTML = '‹';
|
||||||
|
left.addEventListener('click', () => { tabs.scrollLeft -= 120; });
|
||||||
|
|
||||||
|
const right = document.createElement('button');
|
||||||
|
right.className = 'tab-arrow tab-arrow-right';
|
||||||
|
right.innerHTML = '›';
|
||||||
|
right.addEventListener('click', () => { tabs.scrollLeft += 120; });
|
||||||
|
|
||||||
|
wrap.appendChild(left);
|
||||||
|
wrap.appendChild(right);
|
||||||
|
|
||||||
|
// Update arrow visibility on scroll
|
||||||
|
tabs.addEventListener('scroll', () => updateTabArrows(tabs), { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTabArrows(tabs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTabArrows(tabs) {
|
||||||
|
const wrap = tabs.parentElement;
|
||||||
|
if (!wrap || !wrap.classList.contains('modal-tabs-wrap')) return;
|
||||||
|
|
||||||
|
const left = wrap.querySelector('.tab-arrow-left');
|
||||||
|
const right = wrap.querySelector('.tab-arrow-right');
|
||||||
|
if (!left || !right) return;
|
||||||
|
|
||||||
|
const atStart = tabs.scrollLeft <= 2;
|
||||||
|
const atEnd = tabs.scrollLeft + tabs.clientWidth >= tabs.scrollWidth - 2;
|
||||||
|
|
||||||
|
left.classList.toggle('visible', !atStart);
|
||||||
|
right.classList.toggle('visible', !atEnd);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Auth Flow ────────────────────────────────
|
// ── Auth Flow ────────────────────────────────
|
||||||
|
|
||||||
function showSplash(health) {
|
function showSplash(health) {
|
||||||
@@ -511,7 +756,6 @@ function initListeners() {
|
|||||||
// Chat actions
|
// Chat actions
|
||||||
document.getElementById('sendBtn').addEventListener('click', sendMessage);
|
document.getElementById('sendBtn').addEventListener('click', sendMessage);
|
||||||
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
|
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
|
||||||
document.getElementById('regenerateBtn').addEventListener('click', regenerate);
|
|
||||||
|
|
||||||
// Model selector (custom dropdown)
|
// Model selector (custom dropdown)
|
||||||
UI.initModelDropdown();
|
UI.initModelDropdown();
|
||||||
@@ -660,13 +904,23 @@ function initListeners() {
|
|||||||
// Close modals on overlay click
|
// Close modals on overlay click
|
||||||
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||||||
overlay.addEventListener('click', (e) => {
|
overlay.addEventListener('click', (e) => {
|
||||||
if (e.target === overlay) overlay.classList.remove('active');
|
if (e.target === overlay) closeModal(overlay.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape' && App.isGenerating) stopGeneration();
|
if (e.key === 'Escape') {
|
||||||
|
if (App.isGenerating) { stopGeneration(); return; }
|
||||||
|
// Close topmost open modal
|
||||||
|
const open = [...document.querySelectorAll('.modal-overlay.active')];
|
||||||
|
if (open.length) closeModal(open[open.length - 1].id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recheck tab overflow on resize / zoom changes
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(checkTabsOverflow);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -500,11 +500,11 @@ const DebugLog = {
|
|||||||
|
|
||||||
function openDebugModal() {
|
function openDebugModal() {
|
||||||
DebugLog.render();
|
DebugLog.render();
|
||||||
document.getElementById('debugModal').classList.add('active');
|
openModal('debugModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDebugModal() {
|
function closeDebugModal() {
|
||||||
document.getElementById('debugModal').classList.remove('active');
|
closeModal('debugModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchDebugTab(tab) {
|
function switchDebugTab(tab) {
|
||||||
|
|||||||
81
src/js/ui.js
81
src/js/ui.js
@@ -110,15 +110,37 @@ const UI = {
|
|||||||
assistantLabel = m?.name || msg.model;
|
assistantLabel = m?.name || msg.model;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Branch indicator: show ‹ 2/3 › when siblings exist
|
||||||
|
const hasSiblings = (msg.siblingCount || 1) > 1;
|
||||||
|
const sibPos = (msg.siblingIndex || 0) + 1; // display as 1-indexed
|
||||||
|
const sibTotal = msg.siblingCount || 1;
|
||||||
|
const branchNav = hasSiblings ? `
|
||||||
|
<div class="branch-nav">
|
||||||
|
<button class="branch-arrow" onclick="switchSibling('${msg.id}', -1)"
|
||||||
|
${sibPos <= 1 ? 'disabled' : ''} title="Previous version">‹</button>
|
||||||
|
<span class="branch-pos">${sibPos}/${sibTotal}</span>
|
||||||
|
<button class="branch-arrow" onclick="switchSibling('${msg.id}', 1)"
|
||||||
|
${sibPos >= sibTotal ? 'disabled' : ''} title="Next version">›</button>
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
const msgId = msg.id ? esc(msg.id) : '';
|
||||||
|
const editBtn = isUser && msgId ? `<button class="msg-action-btn" onclick="editMessage('${msgId}')" title="Edit">Edit</button>` : '';
|
||||||
|
const regenBtn = !isUser && msgId ? `<button class="msg-action-btn" onclick="regenerateMessage('${msgId}')" title="Regenerate">Regen</button>` : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="message ${msg.role}">
|
<div class="message ${msg.role}" data-msg-id="${msgId}">
|
||||||
<div class="msg-inner">
|
<div class="msg-inner">
|
||||||
<div class="msg-avatar">${isUser ? '👤' : '🤖'}</div>
|
<div class="msg-avatar">${isUser ? '👤' : '🤖'}</div>
|
||||||
<div class="msg-body">
|
<div class="msg-body">
|
||||||
<div class="msg-head">
|
<div class="msg-head">
|
||||||
<span class="msg-role">${isUser ? 'You' : esc(assistantLabel)}</span>
|
<span class="msg-role">${isUser ? 'You' : esc(assistantLabel)}</span>
|
||||||
|
${branchNav}
|
||||||
${time ? `<span class="msg-time">${time}</span>` : ''}
|
${time ? `<span class="msg-time">${time}</span>` : ''}
|
||||||
<div class="msg-actions">
|
<div class="msg-actions">
|
||||||
|
${editBtn}
|
||||||
|
${regenBtn}
|
||||||
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
|
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,6 +159,48 @@ const UI = {
|
|||||||
</div>`;
|
</div>`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
showEditInline(messageId, currentContent) {
|
||||||
|
const msgEl = document.querySelector(`.message[data-msg-id="${messageId}"]`);
|
||||||
|
if (!msgEl) return;
|
||||||
|
|
||||||
|
const textEl = msgEl.querySelector('.msg-text');
|
||||||
|
if (!textEl) return;
|
||||||
|
|
||||||
|
// Replace message text with an edit form
|
||||||
|
const escaped = currentContent.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
textEl.innerHTML = `
|
||||||
|
<textarea class="msg-edit-input" id="editInput_${messageId}">${escaped}</textarea>
|
||||||
|
<div class="msg-edit-actions">
|
||||||
|
<button class="msg-edit-cancel" onclick="cancelEdit()">Cancel</button>
|
||||||
|
<button class="msg-edit-submit" onclick="submitEdit('${messageId}', document.getElementById('editInput_${messageId}').value)">Submit & Regenerate</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// Focus and select all
|
||||||
|
const textarea = document.getElementById(`editInput_${messageId}`);
|
||||||
|
if (textarea) {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.selectionStart = textarea.value.length;
|
||||||
|
// Auto-resize
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
textarea.style.height = textarea.scrollHeight + 'px';
|
||||||
|
textarea.addEventListener('input', () => {
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
textarea.style.height = textarea.scrollHeight + 'px';
|
||||||
|
});
|
||||||
|
// Ctrl+Enter to submit
|
||||||
|
textarea.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitEdit(messageId, textarea.value);
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ── Streaming ────────────────────────────
|
// ── Streaming ────────────────────────────
|
||||||
|
|
||||||
async streamResponse(response, messages, modelName) {
|
async streamResponse(response, messages, modelName) {
|
||||||
@@ -376,7 +440,8 @@ const UI = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
showRegenerate(show) {
|
showRegenerate(show) {
|
||||||
document.getElementById('regenerateBtn').style.display = show ? 'flex' : 'none';
|
// Bottom-bar regenerate button removed in v0.7.1.
|
||||||
|
// Regen is now per-message via inline buttons.
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Toast ────────────────────────────────
|
// ── Toast ────────────────────────────────
|
||||||
@@ -414,9 +479,9 @@ const UI = {
|
|||||||
|
|
||||||
UI.loadProfileIntoSettings();
|
UI.loadProfileIntoSettings();
|
||||||
UI.switchSettingsTab('general');
|
UI.switchSettingsTab('general');
|
||||||
document.getElementById('settingsModal').classList.add('active');
|
openModal('settingsModal');
|
||||||
},
|
},
|
||||||
closeSettings() { document.getElementById('settingsModal').classList.remove('active'); },
|
closeSettings() { closeModal('settingsModal'); },
|
||||||
|
|
||||||
switchSettingsTab(tab) {
|
switchSettingsTab(tab) {
|
||||||
document.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.stab === tab));
|
document.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.stab === tab));
|
||||||
@@ -473,6 +538,10 @@ const UI = {
|
|||||||
if (splash) splash.style.zoom = z;
|
if (splash) splash.style.zoom = z;
|
||||||
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
|
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
|
||||||
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
|
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
|
||||||
|
// Recheck tab overflow after layout settles with new zoom
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(checkTabsOverflow);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async checkUserProvidersAllowed() {
|
async checkUserProvidersAllowed() {
|
||||||
@@ -527,8 +596,8 @@ const UI = {
|
|||||||
|
|
||||||
// ── Admin Modal ──────────────────────────
|
// ── Admin Modal ──────────────────────────
|
||||||
|
|
||||||
openAdmin() { document.getElementById('adminModal').classList.add('active'); UI.switchAdminTab('users'); },
|
openAdmin() { openModal('adminModal'); UI.switchAdminTab('users'); },
|
||||||
closeAdmin() { document.getElementById('adminModal').classList.remove('active'); },
|
closeAdmin() { closeModal('adminModal'); },
|
||||||
|
|
||||||
async switchAdminTab(tab) {
|
async switchAdminTab(tab) {
|
||||||
document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
||||||
|
|||||||
Reference in New Issue
Block a user