Changeset 0.23.2 (#155)
This commit is contained in:
399
docs/TODO-0.23.2.md
Normal file
399
docs/TODO-0.23.2.md
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
# TODO — v0.23.2
|
||||||
|
|
||||||
|
Working checklist. Covers remaining v0.23.1 multi-user scope plus
|
||||||
|
deferred v0.22.8 surface integration and the channel persistence bug.
|
||||||
|
ROADMAP and CHANGELOG updated when work is complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Already Shipped (in 0.23.0 / 0.23.1 changesets)
|
||||||
|
|
||||||
|
Backend foundation is in place. These are done and should not be
|
||||||
|
re-implemented.
|
||||||
|
|
||||||
|
- [x] Migration 016: `ai_mode`, `topic` on channels; `user_presence`
|
||||||
|
table; channel type constraint extended to `dm`, `channel`
|
||||||
|
- [x] `ai_mode` guard in `completion.go` — `off` returns 403,
|
||||||
|
`mention_only` without @mention returns `{status: delivered}`
|
||||||
|
- [x] `resolveMention()` resolves users (steps 3–4: exact + prefix
|
||||||
|
on `users.username`, self-mention blocked)
|
||||||
|
- [x] User @mention in completion handler — skips AI, attempts
|
||||||
|
`NotifyUserMention` via interface cast (hub method not yet
|
||||||
|
implemented — silently no-ops)
|
||||||
|
- [x] Presence endpoints: `POST /presence/heartbeat` (upsert),
|
||||||
|
`GET /presence?users=...` (90s threshold query)
|
||||||
|
- [x] Folders: full CRUD handler (`handlers/folders.go`), wired routes
|
||||||
|
- [x] `CreateChannel` accepts `type` field (default `direct`)
|
||||||
|
- [x] `ListChannels` supports `?types=dm,channel` multi-filter
|
||||||
|
- [x] Three-section sidebar in `chat.html` (Projects / Channels / Chats)
|
||||||
|
- [x] `renderChannelsSection()` in `ui-core.js` (# / person icons,
|
||||||
|
unread badges, online dots, active state)
|
||||||
|
- [x] `selectChannel()`, `newChannelOrDM()`, `loadChannels()` in
|
||||||
|
`projects-ui.js`
|
||||||
|
- [x] Folder UI: create, rename, delete, context menu, drag chats
|
||||||
|
- [x] Chat list filters out `type=channel` and `type=dm`
|
||||||
|
- [x] API methods: `createChannel(title,model,sp,type)`,
|
||||||
|
`listSidebarChannels()`, `presenceHeartbeat()`
|
||||||
|
- [x] `persona_groups` + `persona_group_members` tables (migration 004)
|
||||||
|
- [x] `PersonaGroup` / `PersonaGroupMember` model structs
|
||||||
|
- [x] Channel participants: CRUD handler, auto-created on channel create
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
- [x] **Channel persistence through refresh.** `loadChannels()` read
|
||||||
|
`resp.channels` but `ListChannels` returns a `paginatedResponse`
|
||||||
|
with `data` key. Channels created in-session appeared (pushed to
|
||||||
|
`App.channels` client-side) but vanished on reload because the
|
||||||
|
API response was always parsed as empty.
|
||||||
|
Fix: `resp.channels` → `resp.data` in `projects-ui.js`.
|
||||||
|
|
||||||
|
- [x] **Folder drag-and-drop completely broken.** Three compounding
|
||||||
|
issues:
|
||||||
|
(a) `loadChats()` never mapped `c.folder` → `folderId` — all
|
||||||
|
chats rendered as unfiled regardless of DB state.
|
||||||
|
(b) Folder groups rendered "Drop chats here" but had zero drag
|
||||||
|
event handlers (`ondragover`/`ondrop` missing).
|
||||||
|
(c) No drop zone existed for unfiling a chat or removing from
|
||||||
|
a project — the Chats section body had no drag handlers.
|
||||||
|
Fixes: added `folderId: c.folder || null` to `loadChats()` map;
|
||||||
|
added `ondragover`/`ondragleave`/`ondrop` to `.sb-folder-group`
|
||||||
|
divs in `renderChatList()`; added `onChatSectionDrop` handler to
|
||||||
|
`#sbBodyChats` (handles both unfile-from-folder and remove-from-
|
||||||
|
project); added `onFolderDrop` and `moveChatToFolder` functions;
|
||||||
|
added "Move to folder" / "Remove from folder" to chat context
|
||||||
|
menu; added drag-over CSS for folders and section body.
|
||||||
|
|
||||||
|
- [x] **DM creation 404 on `/api/v1/users`.** Route didn't exist.
|
||||||
|
Added `GET /api/v1/users/search?q=` endpoint returning id,
|
||||||
|
username, display_name for approved users (max 20, excludes
|
||||||
|
caller). Registered on `protected` group (any authed user).
|
||||||
|
Rewrote DM creation UI from text input to lazy search modal —
|
||||||
|
shows all users on open, filters as you type with 200ms debounce,
|
||||||
|
click to select and create DM.
|
||||||
|
|
||||||
|
- [x] **No unfiled drop zone when folders exist.** When folders were
|
||||||
|
present, folder groups consumed all space in `#sbBodyChats`,
|
||||||
|
leaving no bare target to drag a chat out of a folder. Added
|
||||||
|
`.sb-unfiled-zone` wrapper around unfiled chats with its own
|
||||||
|
drag handlers. Shows "Drop here to unfile" hint when zone is
|
||||||
|
empty. `onUnfiledDrop` handles both unfile-from-folder and
|
||||||
|
remove-from-project.
|
||||||
|
|
||||||
|
- [x] **No channel CRUD.** Channel sidebar items had no context menu
|
||||||
|
or management affordances. Added: right-click context menu with
|
||||||
|
Rename / Set topic (channels only) / Delete. Inline ✕ delete
|
||||||
|
button on hover (same pattern as chat items). `renameChannel()`
|
||||||
|
uses `_showCreationDialog` with pre-filled value. `deleteChannel()`
|
||||||
|
with confirm dialog removes from `App.channels` + `App.chats`,
|
||||||
|
clears active if deleted channel was selected.
|
||||||
|
|
||||||
|
- [x] **Drag-over highlight persists.** `onChatDragEnd` only cleared
|
||||||
|
`.project-group.drag-over` — missed folders, unfiled zone, and
|
||||||
|
section body. Fixed to `document.querySelectorAll('.drag-over')`
|
||||||
|
so all drop targets are cleaned up when a drag ends anywhere.
|
||||||
|
|
||||||
|
- [x] **Channel ⋯ menu inconsistency.** Channels used ✕ delete button
|
||||||
|
+ right-click only, while folders used ⋯ hover button. Replaced
|
||||||
|
channel ✕ with `.sb-ch-menu` ⋯ button matching the folder
|
||||||
|
pattern (hidden, shown on hover, triggers context menu on click).
|
||||||
|
Both channels and folders now have ⋯ hover + right-click.
|
||||||
|
|
||||||
|
- [x] **Folder delete silently spills chats.** `deleteFolder()` always
|
||||||
|
unfiled contained chats with no user input. Replaced with a
|
||||||
|
three-button modal when the folder has chats: "Keep chats"
|
||||||
|
(unfile and preserve), "Delete chats too" (hard-delete all
|
||||||
|
contained chats), or Cancel. Empty folders get a simple confirm.
|
||||||
|
Delete-all path iterates chats, calls `API.deleteChannel()` for
|
||||||
|
each, clears active state if needed, then removes the folder.
|
||||||
|
|
||||||
|
- [x] **Folder ⋯ button shifts collapse arrow.** The ⋯ button used
|
||||||
|
`display:none/block` which caused reflow on hover, shifting the
|
||||||
|
arrow left. Fixed: swapped DOM order (⋯ before arrow), changed
|
||||||
|
to `visibility:hidden/visible` so space is always reserved.
|
||||||
|
Applied same fix to channel ⋯ button. Arrow gets fixed width
|
||||||
|
with `flex-shrink:0`.
|
||||||
|
|
||||||
|
- [x] **Channels STILL not surviving refresh (round 2).** The unread
|
||||||
|
count subquery in `ListChannels` referenced
|
||||||
|
`cp.last_read_message_id` — a column added by migration 017.
|
||||||
|
If 017 hasn't been applied (fresh DB only has through 016), the
|
||||||
|
query fails and returns zero channels. Additionally, the `$1`
|
||||||
|
parameter was reused in Postgres (subquery + WHERE) which would
|
||||||
|
break SQLite's positional `?` binding.
|
||||||
|
Fix: rewrote unread subquery to use `last_read_at` (exists since
|
||||||
|
migration 005). Split `$1` reuse into `$1` (subquery) and `$2`
|
||||||
|
(WHERE), with `args` providing `userID` twice. `MarkRead` also
|
||||||
|
fixed to use `last_read_at` as primary, `last_read_message_id`
|
||||||
|
as best-effort (silently fails if 017 not applied).
|
||||||
|
|
||||||
|
- [x] **Settings Models section empty.** Template `models` section
|
||||||
|
fell through to the generic `settingsDynamic` div, but
|
||||||
|
`loadUserModels()` wrote to `#userModelList` which didn't exist.
|
||||||
|
Added dedicated template sections for `models` and `teams`.
|
||||||
|
|
||||||
|
- [x] **CI: TestIntegration_Messages_CRUD 500.** `ListMessages` JOIN
|
||||||
|
used `u.avatar` but the column is `avatar_url` in the users
|
||||||
|
table. Query failed on SQLite (Postgres would fail too). Same
|
||||||
|
bug in `treepath/path.go` `resolveSenderInfo()`.
|
||||||
|
Fix: `u.avatar` → `u.avatar_url` in both locations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Decisions (resolved)
|
||||||
|
|
||||||
|
**Unified active conversation.** `App.currentChatId` and
|
||||||
|
`App.currentChannelId` merge into `App.activeConversation = { id, type }`
|
||||||
|
where type is `direct|dm|group|channel`. The send path, model bar,
|
||||||
|
input state, streaming, session restore, and URL sync all key off
|
||||||
|
`activeConversation.id`. The type discriminator drives ai_mode checks,
|
||||||
|
context banner visibility, and participant rendering. Refactor touches
|
||||||
|
`selectChat()`, `selectChannel()`, `sendMessage()`, session restore in
|
||||||
|
`app.js`, and active-highlight in sidebar renderers.
|
||||||
|
|
||||||
|
**DM deduplication.** One channel per participant pair. `CreateChannel`
|
||||||
|
for `type=dm` checks for an existing DM with the same two participants
|
||||||
|
before creating. Display name is the other party's name (from the
|
||||||
|
caller's perspective). For future 3+ human DMs: comma-join up to 2
|
||||||
|
names + "and N others".
|
||||||
|
|
||||||
|
**No LLMs in DMs.** DMs are human-to-human by definition. `ai_mode`
|
||||||
|
is `mention_only` — you can @mention a persona for a one-off response,
|
||||||
|
but personas are not participants. If you want persistent AI in a
|
||||||
|
conversation with another human, that's a Group Chat (`type=group`).
|
||||||
|
The taxonomy stays clean.
|
||||||
|
|
||||||
|
**Post-creation participant mutation.** Users and personas can be added
|
||||||
|
or removed from any channel/group after creation. Participant CRUD
|
||||||
|
endpoints already exist. Constraints: DMs cannot drop below 2 human
|
||||||
|
participants; groups cannot drop their last persona (becomes a DM at
|
||||||
|
that point — block with error, not silent type coercion).
|
||||||
|
|
||||||
|
**Channel deletion vs archival.** Governed by `channel_retention.mode`:
|
||||||
|
- `flexible` (default): delete or archive freely, user's choice.
|
||||||
|
- `retain`: archive only. Delete button becomes "Archive" everywhere.
|
||||||
|
`DELETE /channels/:id` returns 403 for non-admins. Admins purge
|
||||||
|
from the admin panel, subject to retention age floor.
|
||||||
|
|
||||||
|
See "Retention Policy" section below for the full lifecycle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1 · Unified Active Conversation
|
||||||
|
|
||||||
|
Prerequisite refactor. Do this first — everything else builds on it.
|
||||||
|
|
||||||
|
- [x] **`App.activeConversation` object.** Replace `App.currentChatId`
|
||||||
|
and `App.currentChannelId` with `App.activeConversation = { id,
|
||||||
|
type }` (null when nothing selected). Update `app-state.js`.
|
||||||
|
Added `activeId` getter, `activeType` getter, `setActive(id,type)`
|
||||||
|
method, and `getActiveChat()` helper.
|
||||||
|
- [x] **`selectChat()` migration.** Sets
|
||||||
|
`App.activeConversation = { id: chatId, type: chat.type }`.
|
||||||
|
Session restore reads/writes `cs-active-conversation` (JSON).
|
||||||
|
- [x] **`selectChannel()` migration.** Sets
|
||||||
|
`App.activeConversation = { id, type: ch.type }`. Same session
|
||||||
|
storage key. Now also ensures conversation object in `App.chats`
|
||||||
|
for message caching and maps messages into standard shape.
|
||||||
|
- [x] **Send path.** `sendMessage()` reads `App.activeId`
|
||||||
|
for the channel ID. No behavior change — the backend already
|
||||||
|
handles all channel types on the same completion endpoint.
|
||||||
|
- [x] **Model bar, input state, streaming.** All `currentChatId`
|
||||||
|
references → `App.activeId`. All `App.chats.find(...)` for
|
||||||
|
active chat → `App.getActiveChat()`. 12 files updated.
|
||||||
|
- [x] **Sidebar active highlight.** Both `renderChatList()` and
|
||||||
|
`renderChannelsSection()` check `App.activeId` for the
|
||||||
|
active class. Selecting either clears the other.
|
||||||
|
- [x] **URL sync.** `history.replaceState` uses
|
||||||
|
`/chat/${activeConversation.id}` regardless of type. The Go
|
||||||
|
route handler already loads any channel by ID.
|
||||||
|
- [x] **Session restore.** Reads `cs-active-conversation` JSON,
|
||||||
|
checks both `App.chats` and `App.channels` arrays, calls
|
||||||
|
`selectChat()` or `selectChannel()` as appropriate.
|
||||||
|
- [x] **`newChannelOrDM()` integration.** Now pushes to both
|
||||||
|
`App.channels` (sidebar) and `App.chats` (message cache),
|
||||||
|
and sets the new channel as active.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2 · Channel + DM Plumbing
|
||||||
|
|
||||||
|
- [x] **NotifyUserMention on events hub.** Replaced broken gin context
|
||||||
|
interface cast with direct `h.hub.SendToUser()`. Delivers
|
||||||
|
`user.mentioned` event with channel_id, from_user, content
|
||||||
|
preview. Added `user.mentioned` to event route table as
|
||||||
|
`DirToClient`. Added `truncateContent()` helper.
|
||||||
|
- [x] **DM creation flow.** Sidebar "+" shows choice dialog (New Channel
|
||||||
|
/ New DM). DM flow uses `_showCreationDialog` with username input,
|
||||||
|
looks up user by username, creates `type=dm` channel with
|
||||||
|
`participants: [targetId]`. Sets `ai_mode=mention_only` by default.
|
||||||
|
- [x] **DM dedup guard.** `CreateChannel` for `type=dm` queries
|
||||||
|
`channel_participants` for existing DM between the two users.
|
||||||
|
Returns existing channel (200) instead of creating duplicate (201).
|
||||||
|
Self-DM blocked with 400.
|
||||||
|
- [x] **Channel context banner.** `#channelContextBanner` div between
|
||||||
|
chat header and messages. `UI.updateContextBanner()` shows/hides
|
||||||
|
based on `App.activeConversation.type`. DM shows partner name +
|
||||||
|
@mention hint. Group shows leader/all routing hint. Channel shows
|
||||||
|
ai_mode + topic. CSS in `chat.css`.
|
||||||
|
- [x] **ai_mode and topic in API response.** Added `AiMode` and `Topic`
|
||||||
|
fields to `channelResponse`. `ListChannels` SELECT and Scan
|
||||||
|
updated. `CreateChannel` INSERTs `ai_mode` (defaults to
|
||||||
|
`mention_only` for DMs, `auto` otherwise).
|
||||||
|
- [x] **DM participant auto-creation.** `CreateChannel` adds all
|
||||||
|
`req.Participants` as member participants alongside the creator
|
||||||
|
(owner). Works for both Postgres and SQLite.
|
||||||
|
- [x] **Unread counts.** Add `last_read_message_id` and `last_read_at`
|
||||||
|
columns to `channel_participants`. Backend computes unread count
|
||||||
|
per channel per user. `listSidebarChannels` response includes
|
||||||
|
`unread_count`. Mark-read on select.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3 · @mention UX for Users
|
||||||
|
|
||||||
|
- [x] **Autocomplete includes users.** `channel-models.js` `onInput()`
|
||||||
|
builds candidates from `App.models` only. Add `App.users` list
|
||||||
|
(fetched once on startup from `GET /api/v1/users` or lightweight
|
||||||
|
endpoint). Merge into candidates with a person icon and different
|
||||||
|
accent color. Resolution order in autocomplete: personas first,
|
||||||
|
then users, then models (mirrors backend `resolveMention`).
|
||||||
|
- [x] **User mention pill styling.** `@username` pills render with a
|
||||||
|
different background color (e.g. blue/teal vs persona accent)
|
||||||
|
and person icon to distinguish human from AI mentions.
|
||||||
|
- [x] **Notification on mention.** `user.mentioned` WS event handler
|
||||||
|
in `chat.js` increments unread badge on channel sidebar item
|
||||||
|
and shows a toast with content preview.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4 · Presence
|
||||||
|
|
||||||
|
- [x] **Client heartbeat.** 30s `setInterval` calling
|
||||||
|
`API.presenceHeartbeat()`. Start on app init, pause on
|
||||||
|
`visibilitychange` hidden, resume on visible.
|
||||||
|
- [x] **Presence on channel load.** When rendering the Channels section,
|
||||||
|
collect DM partner IDs and call `GET /api/v1/presence?users=...`
|
||||||
|
to populate `App.presence`. Wire into `renderChannelsSection()`
|
||||||
|
online dot.
|
||||||
|
- [x] **Presence WebSocket events.** Hub broadcasts
|
||||||
|
`presence.changed { userId, status }` to sessions in shared
|
||||||
|
channels. Frontend updates `App.presence` and re-renders the
|
||||||
|
relevant sidebar item (not full re-render).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5 · Persona Groups + Group Chat
|
||||||
|
|
||||||
|
- [x] **Persona group CRUD endpoints.** Wire `persona_groups` and
|
||||||
|
`persona_group_members` to handlers. Routes:
|
||||||
|
`GET/POST /api/v1/persona-groups`,
|
||||||
|
`GET/PUT/DELETE /api/v1/persona-groups/:id`,
|
||||||
|
`POST/DELETE /api/v1/persona-groups/:id/members`.
|
||||||
|
`is_leader` flag on members.
|
||||||
|
- [x] **"New Group Chat" flow.** UI in sidebar "+" or separate button.
|
||||||
|
Option A: pick from saved persona group template.
|
||||||
|
Option B: ad-hoc persona picker (checkboxes).
|
||||||
|
Creates `type=group` channel, adds persona participants, sets
|
||||||
|
leader via `is_leader`. Humans and personas can be added
|
||||||
|
post-creation via participant CRUD.
|
||||||
|
- [x] **Group leader default response.** In `completion.go`, when
|
||||||
|
channel `type=group` and no @mention in content, resolve the
|
||||||
|
leader persona from `persona_group_members WHERE is_leader`
|
||||||
|
(via `channel_participants` → group membership). Use leader's
|
||||||
|
model/config/system prompt for the completion.
|
||||||
|
- [x] **`@all` fan-out.** When `@all` is detected in message content,
|
||||||
|
route to every persona participant. Reuse existing
|
||||||
|
`multiModelStream` path or sequential chain. Depth-1 only
|
||||||
|
(responses from @all do not trigger further chains).
|
||||||
|
- [x] **Participant mutation guards.** DMs: block removal if it would
|
||||||
|
drop below 2 human participants. Groups: block removal of last
|
||||||
|
persona (return 400 with clear error message, not silent type
|
||||||
|
coercion).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6 · Message Attribution
|
||||||
|
|
||||||
|
- [x] **Human sender display.** Messages from other human participants
|
||||||
|
show their avatar + display name instead of "You". Requires
|
||||||
|
the message response to include `sender_name`, `sender_avatar`
|
||||||
|
(or `participant_type=user` + `participant_id` that the frontend
|
||||||
|
resolves from a user cache).
|
||||||
|
- [x] **Persona sender display.** Already works for streaming via
|
||||||
|
avatar resolution. Verify it works for loaded history in
|
||||||
|
channel context (not just direct chats).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7 · Channel Lifecycle
|
||||||
|
|
||||||
|
- [x] **Archive action.** Context menu on channel/DM sidebar items:
|
||||||
|
"Archive". Sets `is_archived = true, archived_at = NOW()` via
|
||||||
|
`PUT /channels/:id`. Archived channels move to a collapsed
|
||||||
|
"Archived" sub-section at the bottom of the Channels list
|
||||||
|
(or hidden entirely with a "Show archived" toggle).
|
||||||
|
- [x] **Delete action (gated).** When `channel_retention.mode` is
|
||||||
|
`flexible` (default): context menu shows "Delete". Hard-deletes
|
||||||
|
the channel and all messages. When `retain`: context menu shows
|
||||||
|
"Archive" only. No delete option for non-admins.
|
||||||
|
- [x] **Admin purge.** Admin panel gets a "Channels" section or row
|
||||||
|
in an existing section showing archived channels with age.
|
||||||
|
"Purge" button hard-deletes. When `purge_after_days` is set,
|
||||||
|
purge is blocked for channels archived less than N days ago.
|
||||||
|
- [x] **`channel_retention` config keys.** Add to `global_config`:
|
||||||
|
`channel_retention.mode` (`flexible`|`retain`, default `flexible`),
|
||||||
|
`channel_retention.purge_after_days` (int|null, default null),
|
||||||
|
`channel_retention.archive_after_days` (int|null, default null).
|
||||||
|
Admin settings UI: "Retention" section under System.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8 · Surface Integration (deferred v0.22.8)
|
||||||
|
|
||||||
|
These were on the v0.22.8 checklist and haven't been wired yet.
|
||||||
|
|
||||||
|
- [x] **ChatPane.primary bridge.** `ChatPane.create()` in `startApp()`
|
||||||
|
from server-rendered mount points. `UI.renderMessages` delegates
|
||||||
|
to `ChatPane.primary.renderMessages`. Currently `ChatPane.primary`
|
||||||
|
is defined but never assigned.
|
||||||
|
- [x] **Theme save/load cycle.** Wire `Theme` (from
|
||||||
|
`ui-primitives-additions.js`) into settings appearance save/load.
|
||||||
|
Persist preference to user settings API. CM6 theme sync on
|
||||||
|
change.
|
||||||
|
- [x] **Admin hybrid section loaders.** Complete all JS-loaded admin
|
||||||
|
sections (some still show empty panels).
|
||||||
|
- [x] **Settings surface completeness.** Models visibility toggles,
|
||||||
|
User Personas CRUD, BYOK provider CRUD, Teams tab content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Forward: Retention Policy Automation (post-0.23.2)
|
||||||
|
|
||||||
|
Not in scope for 0.23.2 but documents the planned direction.
|
||||||
|
|
||||||
|
**Lifecycle:** Active → Archived → Purged. Each transition is either
|
||||||
|
user-initiated or policy-driven.
|
||||||
|
|
||||||
|
**Auto-archive scanner.** Background job (same pattern as compaction
|
||||||
|
scanner). When `channel_retention.archive_after_days` is set, queries
|
||||||
|
`channels WHERE is_archived = false AND updated_at < NOW() - interval`
|
||||||
|
and sets `is_archived = true`. Runs on a configurable schedule (hourly
|
||||||
|
default). Workflow channels auto-archive when their final stage
|
||||||
|
completes — the scanner catches anything the workflow engine misses.
|
||||||
|
|
||||||
|
**Auto-purge scanner.** When `channel_retention.purge_after_days` is
|
||||||
|
set, queries `channels WHERE is_archived = true AND archived_at <
|
||||||
|
NOW() - interval` and hard-deletes in batches. This is also the floor
|
||||||
|
for manual admin purge — admins cannot purge channels archived less
|
||||||
|
than N days ago.
|
||||||
|
|
||||||
|
**Audit trail.** Every archive and purge action (manual or automatic)
|
||||||
|
gets an audit log entry: `action = "channel.archived"` or
|
||||||
|
`action = "channel.purged"` with actor (user ID or "system"), channel
|
||||||
|
metadata snapshot, and timestamp.
|
||||||
|
|
||||||
|
Slots naturally into v0.24.x alongside RBAC (retention policy is
|
||||||
|
inherently an admin/compliance concern) or as a standalone 0.23.x
|
||||||
|
minor if it lands before auth work starts.
|
||||||
10
server/database/migrations/017_unread.sql
Normal file
10
server/database/migrations/017_unread.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- ==========================================
|
||||||
|
-- Chat Switchboard — 017 Unread Counts
|
||||||
|
-- ==========================================
|
||||||
|
-- Adds last_read_message_id to channel_participants for
|
||||||
|
-- per-user unread count computation.
|
||||||
|
-- ICD §3 (Channels & Conversations) — v0.23.2
|
||||||
|
-- ==========================================
|
||||||
|
|
||||||
|
ALTER TABLE channel_participants
|
||||||
|
ADD COLUMN IF NOT EXISTS last_read_message_id UUID;
|
||||||
3
server/database/migrations/sqlite/017_unread.sql
Normal file
3
server/database/migrations/sqlite/017_unread.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- Chat Switchboard — 017 Unread Counts (SQLite)
|
||||||
|
|
||||||
|
ALTER TABLE channel_participants ADD COLUMN last_read_message_id TEXT;
|
||||||
@@ -45,6 +45,9 @@ var routeTable = map[string]Direction{
|
|||||||
// User/presence
|
// User/presence
|
||||||
"user.presence": DirToClient,
|
"user.presence": DirToClient,
|
||||||
"user.status": DirToClient,
|
"user.status": DirToClient,
|
||||||
|
"user.mentioned": DirToClient, // v0.23.2: targeted @mention notification
|
||||||
|
"typing.user": DirToClient, // v0.23.2: human typing in DM/channel
|
||||||
|
"message.created": DirToClient, // v0.23.2: chained/DM message delivery
|
||||||
|
|
||||||
// System
|
// System
|
||||||
"system.notify": DirToClient,
|
"system.notify": DirToClient,
|
||||||
|
|||||||
@@ -333,6 +333,14 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
|
|||||||
hasAdminPrompt = true
|
hasAdminPrompt = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Channel retention mode (v0.23.2 — flexible or retain)
|
||||||
|
retentionMode := "flexible"
|
||||||
|
if retCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "channel_retention"); err == nil {
|
||||||
|
if mode, ok := retCfg["mode"].(string); ok && mode != "" {
|
||||||
|
retentionMode = mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"banner": banner,
|
"banner": banner,
|
||||||
"branding": branding,
|
"branding": branding,
|
||||||
@@ -340,9 +348,10 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
|
|||||||
"storage_configured": storageConfigured,
|
"storage_configured": storageConfigured,
|
||||||
"paste_to_file_chars": pasteChars,
|
"paste_to_file_chars": pasteChars,
|
||||||
"policies": gin.H{
|
"policies": gin.H{
|
||||||
"allow_registration": policies["allow_registration"],
|
"allow_registration": policies["allow_registration"],
|
||||||
"allow_user_byok": policies["allow_user_byok"],
|
"allow_user_byok": policies["allow_user_byok"],
|
||||||
"allow_user_personas": policies["allow_user_personas"],
|
"allow_user_personas": policies["allow_user_personas"],
|
||||||
|
"channel_retention_mode": retentionMode,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -724,3 +733,88 @@ func (h *AdminHandler) auditLog(c *gin.Context, action, resourceType, resourceID
|
|||||||
log.Printf("audit log error: %v", err)
|
log.Printf("audit log error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Archived Channels (v0.23.2) ──────────────
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListArchivedChannels(c *gin.Context) {
|
||||||
|
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
|
||||||
|
SELECT c.id, c.title, c.type, c.user_id,
|
||||||
|
COALESCE(u.username, '') AS owner_name,
|
||||||
|
c.updated_at,
|
||||||
|
(SELECT COUNT(*) FROM messages WHERE channel_id = c.id) AS message_count
|
||||||
|
FROM channels c
|
||||||
|
LEFT JOIN users u ON u.id = c.user_id
|
||||||
|
WHERE c.is_archived = true
|
||||||
|
ORDER BY c.updated_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type archivedChannel struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
OwnerName string `json:"owner_name"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
MessageCount int `json:"message_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
channels := []archivedChannel{}
|
||||||
|
for rows.Next() {
|
||||||
|
var ch archivedChannel
|
||||||
|
var userID string
|
||||||
|
if rows.Scan(&ch.ID, &ch.Title, &ch.Type, &userID, &ch.OwnerName, &ch.UpdatedAt, &ch.MessageCount) == nil {
|
||||||
|
channels = append(channels, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retention config
|
||||||
|
retentionMode := "flexible"
|
||||||
|
if retCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "channel_retention"); err == nil {
|
||||||
|
if mode, ok := retCfg["mode"].(string); ok {
|
||||||
|
retentionMode = mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"channels": channels,
|
||||||
|
"retention_mode": retentionMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) PurgeChannel(c *gin.Context) {
|
||||||
|
channelID := c.Param("id")
|
||||||
|
|
||||||
|
// Verify the channel is actually archived
|
||||||
|
var isArchived bool
|
||||||
|
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||||
|
SELECT is_archived FROM channels WHERE id = $1
|
||||||
|
`), channelID).Scan(&isArchived)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isArchived {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "channel must be archived before purging"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard delete (CASCADE removes messages, participants, models)
|
||||||
|
_, err = database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||||
|
DELETE FROM channels WHERE id = $1
|
||||||
|
`), channelID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "purge failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
uid := getUserID(c)
|
||||||
|
h.auditLog(c, uid, "channel.purged", "channel", channelID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,13 +19,16 @@ import (
|
|||||||
|
|
||||||
type createChannelRequest struct {
|
type createChannelRequest struct {
|
||||||
Title string `json:"title" binding:"required,max=500"`
|
Title string `json:"title" binding:"required,max=500"`
|
||||||
Type string `json:"type,omitempty"` // direct (default), group, workflow
|
Type string `json:"type,omitempty"` // direct (default), dm, group, channel, workflow
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||||
ProviderConfigID *string `json:"provider_config_id,omitempty"`
|
ProviderConfigID *string `json:"provider_config_id,omitempty"`
|
||||||
Folder string `json:"folder,omitempty"`
|
Folder string `json:"folder,omitempty"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
// v0.23.2: DM creation
|
||||||
|
Participants []string `json:"participants,omitempty"` // user IDs for DM (exactly 1 other user)
|
||||||
|
AiMode string `json:"ai_mode,omitempty"` // auto (default), mention_only, off
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateChannelRequest struct {
|
type updateChannelRequest struct {
|
||||||
@@ -40,6 +43,8 @@ type updateChannelRequest struct {
|
|||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings
|
Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings
|
||||||
WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5)
|
WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5)
|
||||||
|
AiMode *string `json:"ai_mode,omitempty"` // v0.23.2: auto, mention_only, off
|
||||||
|
Topic *string `json:"topic,omitempty"` // v0.23.2: channel topic
|
||||||
}
|
}
|
||||||
|
|
||||||
type channelResponse struct {
|
type channelResponse struct {
|
||||||
@@ -47,6 +52,8 @@ type channelResponse struct {
|
|||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
AiMode string `json:"ai_mode,omitempty"`
|
||||||
|
Topic *string `json:"topic,omitempty"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Model *string `json:"model"`
|
Model *string `json:"model"`
|
||||||
ProviderConfigID *string `json:"provider_config_id"`
|
ProviderConfigID *string `json:"provider_config_id"`
|
||||||
@@ -59,6 +66,7 @@ type channelResponse struct {
|
|||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Settings json.RawMessage `json:"settings,omitempty"`
|
Settings json.RawMessage `json:"settings,omitempty"`
|
||||||
MessageCount int `json:"message_count"`
|
MessageCount int `json:"message_count"`
|
||||||
|
UnreadCount int `json:"unread_count,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -140,16 +148,24 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
|||||||
archived := c.DefaultQuery("archived", "false")
|
archived := c.DefaultQuery("archived", "false")
|
||||||
folder := c.Query("folder")
|
folder := c.Query("folder")
|
||||||
channelType := c.DefaultQuery("type", "") // empty = all types
|
channelType := c.DefaultQuery("type", "") // empty = all types
|
||||||
channelTypes := c.QueryArray("types") // v0.23.1: multi-value, e.g. ?types=dm&types=channel
|
// v0.23.1: multi-value type filter. Supports both ?types=dm&types=channel
|
||||||
// Comma-joined convenience: ?types=dm,channel
|
// and comma-joined ?types=dm,channel
|
||||||
if len(channelTypes) == 0 && c.Query("types") != "" {
|
var channelTypes []string
|
||||||
channelTypes = strings.Split(c.Query("types"), ",")
|
if raw := c.Query("types"); raw != "" {
|
||||||
|
for _, t := range strings.Split(raw, ",") {
|
||||||
|
t = strings.TrimSpace(t)
|
||||||
|
if t != "" {
|
||||||
|
channelTypes = append(channelTypes, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
search := strings.TrimSpace(c.Query("search"))
|
search := strings.TrimSpace(c.Query("search"))
|
||||||
projectFilter := c.Query("project_id") // "uuid" or "none"
|
projectFilter := c.Query("project_id") // "uuid" or "none"
|
||||||
|
|
||||||
// Count total
|
// Count total — include channels owned by user OR where user is a participant
|
||||||
countQuery := `SELECT COUNT(*) FROM channels WHERE user_id = $1 AND is_archived = $2`
|
countQuery := `SELECT COUNT(*) FROM channels c WHERE (c.user_id = $1 OR c.id IN (
|
||||||
|
SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $1
|
||||||
|
)) AND c.is_archived = $2`
|
||||||
countArgs := []interface{}{userID, archived == "true"}
|
countArgs := []interface{}{userID, archived == "true"}
|
||||||
argN := 3
|
argN := 3
|
||||||
|
|
||||||
@@ -160,26 +176,26 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
|||||||
countArgs = append(countArgs, strings.TrimSpace(t))
|
countArgs = append(countArgs, strings.TrimSpace(t))
|
||||||
argN++
|
argN++
|
||||||
}
|
}
|
||||||
countQuery += " AND type IN (" + strings.Join(placeholders, ",") + ")"
|
countQuery += " AND c.type IN (" + strings.Join(placeholders, ",") + ")"
|
||||||
} else if channelType != "" {
|
} else if channelType != "" {
|
||||||
countQuery += ` AND type = $` + strconv.Itoa(argN)
|
countQuery += ` AND c.type = $` + strconv.Itoa(argN)
|
||||||
countArgs = append(countArgs, channelType)
|
countArgs = append(countArgs, channelType)
|
||||||
argN++
|
argN++
|
||||||
}
|
}
|
||||||
if folder != "" {
|
if folder != "" {
|
||||||
countQuery += ` AND folder = $` + strconv.Itoa(argN)
|
countQuery += ` AND c.folder = $` + strconv.Itoa(argN)
|
||||||
countArgs = append(countArgs, folder)
|
countArgs = append(countArgs, folder)
|
||||||
argN++
|
argN++
|
||||||
}
|
}
|
||||||
if search != "" {
|
if search != "" {
|
||||||
countQuery += ` AND title ILIKE $` + strconv.Itoa(argN)
|
countQuery += ` AND c.title ILIKE $` + strconv.Itoa(argN)
|
||||||
countArgs = append(countArgs, "%"+search+"%")
|
countArgs = append(countArgs, "%"+search+"%")
|
||||||
argN++
|
argN++
|
||||||
}
|
}
|
||||||
if projectFilter == "none" {
|
if projectFilter == "none" {
|
||||||
countQuery += ` AND project_id IS NULL`
|
countQuery += ` AND c.project_id IS NULL`
|
||||||
} else if projectFilter != "" {
|
} else if projectFilter != "" {
|
||||||
countQuery += ` AND project_id = $` + strconv.Itoa(argN)
|
countQuery += ` AND c.project_id = $` + strconv.Itoa(argN)
|
||||||
countArgs = append(countArgs, projectFilter)
|
countArgs = append(countArgs, projectFilter)
|
||||||
argN++
|
argN++
|
||||||
}
|
}
|
||||||
@@ -191,8 +207,10 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch channels with message count
|
// Fetch channels with message count
|
||||||
|
// Include channels owned by user OR where user is a participant (multi-user)
|
||||||
query := `
|
query := `
|
||||||
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
SELECT c.id, c.user_id, c.title, c.type, c.ai_mode, c.topic,
|
||||||
|
c.description, c.model, c.provider_config_id,
|
||||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, c.workspace_id,
|
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.project_id, c.workspace_id,
|
||||||
c.tags, c.settings,
|
c.tags, c.settings,
|
||||||
COALESCE(mc.cnt, 0) AS message_count,
|
COALESCE(mc.cnt, 0) AS message_count,
|
||||||
@@ -201,7 +219,9 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
|||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
|
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
|
||||||
) mc ON mc.channel_id = c.id
|
) mc ON mc.channel_id = c.id
|
||||||
WHERE c.user_id = $1 AND c.is_archived = $2`
|
WHERE (c.user_id = $1 OR c.id IN (
|
||||||
|
SELECT channel_id FROM channel_participants WHERE participant_type = 'user' AND participant_id = $1
|
||||||
|
)) AND c.is_archived = $2`
|
||||||
|
|
||||||
args := []interface{}{userID, archived == "true"}
|
args := []interface{}{userID, archived == "true"}
|
||||||
argN = 3
|
argN = 3
|
||||||
@@ -252,7 +272,8 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
|||||||
var ch channelResponse
|
var ch channelResponse
|
||||||
var tags []string
|
var tags []string
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
|
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.AiMode, &ch.Topic,
|
||||||
|
&ch.Description, &ch.Model, &ch.ProviderConfigID,
|
||||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
||||||
scanTags(&tags), scanJSON(&ch.Settings),
|
scanTags(&tags), scanJSON(&ch.Settings),
|
||||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
@@ -267,6 +288,18 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
|||||||
ch.Tags = tags
|
ch.Tags = tags
|
||||||
channels = append(channels, ch)
|
channels = append(channels, ch)
|
||||||
}
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
// v0.23.2: Compute unread counts per channel (safe post-processing)
|
||||||
|
for i := range channels {
|
||||||
|
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||||
|
SELECT COUNT(*) FROM messages m
|
||||||
|
JOIN channel_participants cp ON cp.channel_id = m.channel_id
|
||||||
|
WHERE cp.channel_id = $1
|
||||||
|
AND cp.participant_type = 'user' AND cp.participant_id = $2
|
||||||
|
AND m.created_at > cp.last_read_at
|
||||||
|
`), channels[i].ID, userID).Scan(&channels[i].UnreadCount)
|
||||||
|
}
|
||||||
|
|
||||||
SafeJSON(c, http.StatusOK, paginatedResponse{
|
SafeJSON(c, http.StatusOK, paginatedResponse{
|
||||||
Data: channels,
|
Data: channels,
|
||||||
@@ -298,6 +331,62 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
|||||||
channelType = "direct"
|
channelType = "direct"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default ai_mode: mention_only for DMs, auto for everything else
|
||||||
|
aiMode := req.AiMode
|
||||||
|
if aiMode == "" {
|
||||||
|
if channelType == "dm" {
|
||||||
|
aiMode = "mention_only"
|
||||||
|
} else {
|
||||||
|
aiMode = "auto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DM dedup: check for existing DM between these two users ──
|
||||||
|
if channelType == "dm" && len(req.Participants) == 1 {
|
||||||
|
otherUserID := req.Participants[0]
|
||||||
|
if otherUserID == userID {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot create DM with yourself"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for an existing DM channel where both users are participants
|
||||||
|
var existingID string
|
||||||
|
_ = database.DB.QueryRow(database.Q(`
|
||||||
|
SELECT cp1.channel_id FROM channel_participants cp1
|
||||||
|
JOIN channel_participants cp2 ON cp1.channel_id = cp2.channel_id
|
||||||
|
JOIN channels c ON c.id = cp1.channel_id
|
||||||
|
WHERE c.type = 'dm'
|
||||||
|
AND cp1.participant_type = 'user' AND cp1.participant_id = $1
|
||||||
|
AND cp2.participant_type = 'user' AND cp2.participant_id = $2
|
||||||
|
LIMIT 1
|
||||||
|
`), userID, otherUserID).Scan(&existingID)
|
||||||
|
if existingID != "" {
|
||||||
|
// Return existing channel instead of creating duplicate
|
||||||
|
var ch channelResponse
|
||||||
|
var tags []string
|
||||||
|
err := database.DB.QueryRow(database.Q(`
|
||||||
|
SELECT id, user_id, title, type, description, model, provider_config_id,
|
||||||
|
system_prompt, is_archived, is_pinned, folder, project_id, workspace_id,
|
||||||
|
tags, settings,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM channels WHERE id = $1
|
||||||
|
`), existingID).Scan(
|
||||||
|
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
|
||||||
|
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
||||||
|
scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
if tags == nil {
|
||||||
|
tags = []string{}
|
||||||
|
}
|
||||||
|
ch.Tags = tags
|
||||||
|
SafeJSON(c, http.StatusOK, ch) // 200, not 201 — existing resource
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If read fails, fall through and create new (shouldn't happen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// INSERT and retrieve the new row
|
// INSERT and retrieve the new row
|
||||||
var ch channelResponse
|
var ch channelResponse
|
||||||
var tags []string
|
var tags []string
|
||||||
@@ -306,10 +395,10 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
|||||||
id := store.NewID()
|
id := store.NewID()
|
||||||
_, err := database.DB.Exec(`
|
_, err := database.DB.Exec(`
|
||||||
INSERT INTO channels (id, user_id, title, type, description, model,
|
INSERT INTO channels (id, user_id, title, type, description, model,
|
||||||
system_prompt, provider_config_id, folder, tags)
|
system_prompt, provider_config_id, folder, tags, ai_mode)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
id, userID, req.Title, channelType, req.Description, req.Model,
|
id, userID, req.Title, channelType, req.Description, req.Model,
|
||||||
req.SystemPrompt, req.ProviderConfigID, req.Folder, writeTagsArg(req.Tags),
|
req.SystemPrompt, req.ProviderConfigID, req.Folder, writeTagsArg(req.Tags), aiMode,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
|
||||||
@@ -332,12 +421,12 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
err := database.DB.QueryRow(`
|
err := database.DB.QueryRow(`
|
||||||
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags)
|
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags, ai_mode)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
|
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
|
||||||
is_archived, is_pinned, folder, project_id, workspace_id, tags, settings, created_at, updated_at
|
is_archived, is_pinned, folder, project_id, workspace_id, tags, settings, created_at, updated_at
|
||||||
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.ProviderConfigID,
|
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.ProviderConfigID,
|
||||||
req.Folder, pq.Array(req.Tags),
|
req.Folder, pq.Array(req.Tags), aiMode,
|
||||||
).Scan(
|
).Scan(
|
||||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
|
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
|
||||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
||||||
@@ -370,6 +459,28 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
|||||||
`, ch.ID, userID)
|
`, ch.ID, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.23.2: Add DM partner as member participant
|
||||||
|
if channelType == "dm" && len(req.Participants) > 0 {
|
||||||
|
for _, pid := range req.Participants {
|
||||||
|
if pid == userID {
|
||||||
|
continue // skip self (already added as owner)
|
||||||
|
}
|
||||||
|
if database.IsSQLite() {
|
||||||
|
_, _ = database.DB.Exec(`
|
||||||
|
INSERT INTO channel_participants (id, channel_id, participant_type, participant_id, role)
|
||||||
|
VALUES (?, ?, 'user', ?, 'member')
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, store.NewID(), ch.ID, pid)
|
||||||
|
} else {
|
||||||
|
_, _ = database.DB.Exec(`
|
||||||
|
INSERT INTO channel_participants (channel_id, participant_type, participant_id, role)
|
||||||
|
VALUES ($1, 'user', $2, 'member')
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, ch.ID, pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-create channel_model if model specified
|
// Auto-create channel_model if model specified
|
||||||
if req.Model != "" {
|
if req.Model != "" {
|
||||||
if database.IsSQLite() {
|
if database.IsSQLite() {
|
||||||
@@ -519,6 +630,17 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
|
|||||||
addClause("workspace_id", *req.WorkspaceID)
|
addClause("workspace_id", *req.WorkspaceID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if req.AiMode != nil {
|
||||||
|
mode := *req.AiMode
|
||||||
|
if mode != "auto" && mode != "mention_only" && mode != "off" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ai_mode must be auto, mention_only, or off"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addClause("ai_mode", mode)
|
||||||
|
}
|
||||||
|
if req.Topic != nil {
|
||||||
|
addClause("topic", *req.Topic)
|
||||||
|
}
|
||||||
|
|
||||||
if len(setClauses) == 0 {
|
if len(setClauses) == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||||
@@ -571,3 +693,38 @@ func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "channel deleted"})
|
c.JSON(http.StatusOK, gin.H{"message": "channel deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mark Read (v0.23.2) ───────────────────────
|
||||||
|
|
||||||
|
func (h *ChannelHandler) MarkRead(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
channelID := c.Param("id")
|
||||||
|
|
||||||
|
// Update the read cursor timestamp (last_read_at exists since migration 005)
|
||||||
|
_, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||||
|
UPDATE channel_participants
|
||||||
|
SET last_read_at = NOW()
|
||||||
|
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2
|
||||||
|
`), channelID, userID)
|
||||||
|
if err != nil {
|
||||||
|
// Participant row may not exist for legacy direct chats — that's OK
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort: also set last_read_message_id if the column exists (migration 017)
|
||||||
|
var latestMsgID *string
|
||||||
|
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||||
|
SELECT id FROM messages WHERE channel_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||||
|
`), channelID).Scan(&latestMsgID)
|
||||||
|
if latestMsgID != nil {
|
||||||
|
// This may fail if 017 hasn't been applied — that's fine, last_read_at is primary
|
||||||
|
_, _ = database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||||
|
UPDATE channel_participants
|
||||||
|
SET last_read_message_id = $1
|
||||||
|
WHERE channel_id = $2 AND participant_type = 'user' AND participant_id = $3
|
||||||
|
`), *latestMsgID, channelID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|||||||
@@ -224,8 +224,43 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if aiMode == "mention_only" && extractFirstMention(req.Content) == "" {
|
if aiMode == "mention_only" && extractFirstMention(req.Content) == "" {
|
||||||
// Deliver message without triggering completion
|
// Persist the user message before returning
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "delivered", "ai_skipped": true})
|
msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil, "", "")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[mention_only] Failed to persist user message: %v", err)
|
||||||
|
}
|
||||||
|
// Broadcast via WS to ALL user participants (not just sender)
|
||||||
|
if h.hub != nil && msgID != "" {
|
||||||
|
payload, _ := json.Marshal(map[string]any{
|
||||||
|
"id": msgID,
|
||||||
|
"channel_id": channelID,
|
||||||
|
"role": "user",
|
||||||
|
"content": req.Content,
|
||||||
|
"user_id": userID,
|
||||||
|
})
|
||||||
|
evt := events.Event{
|
||||||
|
Label: "message.created",
|
||||||
|
Payload: payload,
|
||||||
|
Ts: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
// Send to sender
|
||||||
|
h.hub.SendToUser(userID, evt)
|
||||||
|
// Send to other user participants in the channel
|
||||||
|
pRows, pErr := database.DB.QueryContext(c.Request.Context(), database.Q(`
|
||||||
|
SELECT participant_id FROM channel_participants
|
||||||
|
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id != $2
|
||||||
|
`), channelID, userID)
|
||||||
|
if pErr == nil {
|
||||||
|
defer pRows.Close()
|
||||||
|
for pRows.Next() {
|
||||||
|
var pid string
|
||||||
|
if pRows.Scan(&pid) == nil {
|
||||||
|
h.hub.SendToUser(pid, evt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "delivered", "ai_skipped": true, "message_id": msgID})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,6 +269,40 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
var personaSystemPrompt string
|
var personaSystemPrompt string
|
||||||
var personaID string // tracks active persona for KB scoping
|
var personaID string // tracks active persona for KB scoping
|
||||||
var personaThinkingBudget *int // persona-level thinking budget for hook injection (v0.22.1)
|
var personaThinkingBudget *int // persona-level thinking budget for hook injection (v0.22.1)
|
||||||
|
|
||||||
|
// v0.23.2: Group leader default — when type=group and no @mention, use the leader persona
|
||||||
|
if req.PersonaID == "" && extractFirstMention(req.Content) == "" {
|
||||||
|
var channelType string
|
||||||
|
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||||
|
SELECT COALESCE(type, 'direct') FROM channels WHERE id = $1
|
||||||
|
`), channelID).Scan(&channelType)
|
||||||
|
if channelType == "group" {
|
||||||
|
// Find the leader persona via channel_participants → persona_group_members
|
||||||
|
var leaderPersonaID string
|
||||||
|
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||||
|
SELECT cp.participant_id
|
||||||
|
FROM channel_participants cp
|
||||||
|
JOIN persona_group_members pgm ON pgm.persona_id = cp.participant_id
|
||||||
|
WHERE cp.channel_id = $1
|
||||||
|
AND cp.participant_type = 'persona'
|
||||||
|
AND pgm.is_leader = true
|
||||||
|
LIMIT 1
|
||||||
|
`), channelID).Scan(&leaderPersonaID)
|
||||||
|
// Fallback: first persona participant if no leader flag set
|
||||||
|
if leaderPersonaID == "" {
|
||||||
|
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||||
|
SELECT participant_id FROM channel_participants
|
||||||
|
WHERE channel_id = $1 AND participant_type = 'persona'
|
||||||
|
ORDER BY created_at LIMIT 1
|
||||||
|
`), channelID).Scan(&leaderPersonaID)
|
||||||
|
}
|
||||||
|
if leaderPersonaID != "" {
|
||||||
|
req.PersonaID = leaderPersonaID
|
||||||
|
log.Printf("[group] channel %s: leader persona %s", channelID[:min(8, len(channelID))], leaderPersonaID[:min(8, len(leaderPersonaID))])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if req.PersonaID != "" {
|
if req.PersonaID != "" {
|
||||||
persona := ResolvePersona(h.stores, req.PersonaID, userID)
|
persona := ResolvePersona(h.stores, req.PersonaID, userID)
|
||||||
if persona == nil {
|
if persona == nil {
|
||||||
@@ -413,14 +482,60 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
// - AI mention → override model/persona for this turn (v0.23.0)
|
// - AI mention → override model/persona for this turn (v0.23.0)
|
||||||
mentionModel, mentionConfig, mentionPersona, mentionedUserID := h.resolveMention(c.Request.Context(), userID, req.Content)
|
mentionModel, mentionConfig, mentionPersona, mentionedUserID := h.resolveMention(c.Request.Context(), userID, req.Content)
|
||||||
|
|
||||||
if mentionedUserID != "" {
|
// v0.23.2: @all fan-out — route to every persona participant (depth-1 only)
|
||||||
// Human @mention — message already persisted; notify, skip AI.
|
if strings.Contains(strings.ToLower(req.Content), "@all") {
|
||||||
if hub, ok := c.Get("events_hub"); ok {
|
// Get all persona participants for this channel
|
||||||
if h2, ok2 := hub.(interface {
|
allRows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
|
||||||
NotifyUserMention(toUser, channel, fromUser string)
|
SELECT participant_id FROM channel_participants
|
||||||
}); ok2 {
|
WHERE channel_id = $1 AND participant_type = 'persona'
|
||||||
h2.NotifyUserMention(mentionedUserID, channelID, userID)
|
ORDER BY created_at
|
||||||
|
`), channelID)
|
||||||
|
if err == nil {
|
||||||
|
defer allRows.Close()
|
||||||
|
var allPersonaIDs []string
|
||||||
|
for allRows.Next() {
|
||||||
|
var pid string
|
||||||
|
if allRows.Scan(&pid) == nil {
|
||||||
|
allPersonaIDs = append(allPersonaIDs, pid)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if len(allPersonaIDs) > 0 {
|
||||||
|
// Use the first persona for the streamed response
|
||||||
|
first := ResolvePersona(h.stores, allPersonaIDs[0], userID)
|
||||||
|
if first != nil {
|
||||||
|
mentionModel = first.BaseModelID
|
||||||
|
if first.ProviderConfigID != nil {
|
||||||
|
mentionConfig = *first.ProviderConfigID
|
||||||
|
}
|
||||||
|
mentionPersona = first
|
||||||
|
}
|
||||||
|
// Chain remaining personas via goroutine after the stream completes
|
||||||
|
if len(allPersonaIDs) > 1 && h.hub != nil {
|
||||||
|
remaining := allPersonaIDs[1:]
|
||||||
|
go func() {
|
||||||
|
for _, pid := range remaining {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
h.chainToPersona(channelID, userID, pid, req.Content, 1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mentionedUserID != "" {
|
||||||
|
// Human @mention — message already persisted; notify recipient, skip AI.
|
||||||
|
if h.hub != nil {
|
||||||
|
payload, _ := json.Marshal(map[string]any{
|
||||||
|
"channel_id": channelID,
|
||||||
|
"from_user": userID,
|
||||||
|
"content": truncateContent(req.Content, 120),
|
||||||
|
})
|
||||||
|
h.hub.SendToUser(mentionedUserID, events.Event{
|
||||||
|
Label: "user.mentioned",
|
||||||
|
Payload: payload,
|
||||||
|
Ts: time.Now().UnixMilli(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "delivered", "mentioned_user": mentionedUserID})
|
c.JSON(http.StatusOK, gin.H{"status": "delivered", "mentioned_user": mentionedUserID})
|
||||||
return
|
return
|
||||||
@@ -1230,7 +1345,7 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
|
|||||||
var mentionedUserID string
|
var mentionedUserID string
|
||||||
err = database.DB.QueryRowContext(ctx, database.Q(`
|
err = database.DB.QueryRowContext(ctx, database.Q(`
|
||||||
SELECT id FROM users
|
SELECT id FROM users
|
||||||
WHERE LOWER(username) = $1 AND id != $2 AND is_approved = true
|
WHERE LOWER(username) = $1 AND id != $2 AND is_active = true
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`), normalized, userID).Scan(&mentionedUserID)
|
`), normalized, userID).Scan(&mentionedUserID)
|
||||||
if err == nil && mentionedUserID != "" {
|
if err == nil && mentionedUserID != "" {
|
||||||
@@ -1242,12 +1357,12 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
|
|||||||
var userCount int
|
var userCount int
|
||||||
database.DB.QueryRowContext(ctx, database.Q(`
|
database.DB.QueryRowContext(ctx, database.Q(`
|
||||||
SELECT COUNT(*) FROM users
|
SELECT COUNT(*) FROM users
|
||||||
WHERE LOWER(username) LIKE $1 AND id != $2 AND is_approved = true
|
WHERE LOWER(username) LIKE $1 AND id != $2 AND is_active = true
|
||||||
`), normalized+"%", userID).Scan(&userCount)
|
`), normalized+"%", userID).Scan(&userCount)
|
||||||
if userCount == 1 {
|
if userCount == 1 {
|
||||||
database.DB.QueryRowContext(ctx, database.Q(`
|
database.DB.QueryRowContext(ctx, database.Q(`
|
||||||
SELECT id FROM users
|
SELECT id FROM users
|
||||||
WHERE LOWER(username) LIKE $1 AND id != $2 AND is_approved = true
|
WHERE LOWER(username) LIKE $1 AND id != $2 AND is_active = true
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`), normalized+"%", userID).Scan(&mentionedUserID)
|
`), normalized+"%", userID).Scan(&mentionedUserID)
|
||||||
if mentionedUserID != "" {
|
if mentionedUserID != "" {
|
||||||
@@ -1820,3 +1935,12 @@ func (h *CompletionHandler) recordHealth(configID string, start time.Time, err e
|
|||||||
h.health.RecordSuccess(configID, latencyMs)
|
h.health.RecordSuccess(configID, latencyMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// truncateContent returns content truncated to maxLen runes with ellipsis.
|
||||||
|
func truncateContent(s string, maxLen int) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:maxLen]) + "…"
|
||||||
|
}
|
||||||
|
|||||||
@@ -203,6 +203,141 @@ func (h *CompletionHandler) chainIfMentioned(
|
|||||||
h.chainIfMentioned(channelID, userID, mentionPersona.ID, resp.Content, depth+1)
|
h.chainIfMentioned(channelID, userID, mentionPersona.ID, resp.Content, depth+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// chainToPersona triggers a completion for a specific persona by ID.
|
||||||
|
// Used by @all fan-out. Does NOT recurse (depth-1 only).
|
||||||
|
func (h *CompletionHandler) chainToPersona(channelID, userID, personaID, triggerContent string, depth int) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[chain-all] PANIC: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
persona := ResolvePersona(h.stores, personaID, userID)
|
||||||
|
if persona == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if h.hub != nil {
|
||||||
|
h.emitTyping(userID, channelID, persona.ID, persona.Name, true)
|
||||||
|
defer h.emitTyping(userID, channelID, persona.ID, persona.Name, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
configID := ""
|
||||||
|
if persona.ProviderConfigID != nil {
|
||||||
|
configID = *persona.ProviderConfigID
|
||||||
|
}
|
||||||
|
chainReq := completionRequest{
|
||||||
|
ChannelID: channelID,
|
||||||
|
Model: persona.BaseModelID,
|
||||||
|
ProviderConfigID: configID,
|
||||||
|
}
|
||||||
|
providerCfg, providerID, model, resolvedConfigID, _, err := h.resolveConfig(userID, channelID, chainReq)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[chain-all] Config failed for %s: %v", persona.Name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configID = resolvedConfigID
|
||||||
|
|
||||||
|
provider, err := providers.Get(providerID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
messages, err := h.loadConversation(channelID, userID, persona.SystemPrompt, persona.ID, model)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
boundary := fmt.Sprintf(
|
||||||
|
"[You are now %s. Previous assistant messages may be from different AI personas — ignore their style. Respond only as yourself per your system prompt.]",
|
||||||
|
persona.Name,
|
||||||
|
)
|
||||||
|
messages = append(messages, providers.Message{Role: "system", Content: boundary})
|
||||||
|
|
||||||
|
caps := capspkg.ResolveIntrinsic(model, nil, nil)
|
||||||
|
provReq := providers.CompletionRequest{
|
||||||
|
Model: model,
|
||||||
|
Messages: messages,
|
||||||
|
MaxTokens: capspkg.ResolveMaxOutput(model, caps),
|
||||||
|
}
|
||||||
|
if persona.Temperature != nil {
|
||||||
|
provReq.Temperature = persona.Temperature
|
||||||
|
}
|
||||||
|
if persona.ThinkingBudget != nil && *persona.ThinkingBudget > 0 {
|
||||||
|
if providerCfg.Settings == nil {
|
||||||
|
providerCfg.Settings = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
providerCfg.Settings["extended_thinking"] = true
|
||||||
|
providerCfg.Settings["thinking_budget"] = *persona.ThinkingBudget
|
||||||
|
}
|
||||||
|
|
||||||
|
if hooks := providers.GetHooks(providerID); hooks != nil {
|
||||||
|
hooks.PreRequest(providerCfg, &provReq)
|
||||||
|
}
|
||||||
|
|
||||||
|
callStart := time.Now()
|
||||||
|
resp, err := provider.ChatCompletion(ctx, providerCfg, provReq)
|
||||||
|
latencyMs := int(time.Since(callStart).Milliseconds())
|
||||||
|
if err != nil {
|
||||||
|
if h.health != nil {
|
||||||
|
h.health.RecordError(configID, latencyMs, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.health != nil {
|
||||||
|
h.health.RecordSuccess(configID, latencyMs)
|
||||||
|
}
|
||||||
|
if resp.Content == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msgID, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model,
|
||||||
|
resp.InputTokens, resp.OutputTokens, nil, nil, configID, persona.ID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.hub != nil {
|
||||||
|
payload, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"id": msgID,
|
||||||
|
"channel_id": channelID,
|
||||||
|
"role": "assistant",
|
||||||
|
"content": resp.Content,
|
||||||
|
"model": model,
|
||||||
|
"participant_type": "persona",
|
||||||
|
"participant_id": persona.ID,
|
||||||
|
"display_name": persona.Name,
|
||||||
|
"avatar": persona.Avatar,
|
||||||
|
"tokens_used": resp.InputTokens + resp.OutputTokens,
|
||||||
|
"chain_depth": depth,
|
||||||
|
})
|
||||||
|
h.hub.SendToUser(userID, events.Event{
|
||||||
|
Label: "message.created",
|
||||||
|
Payload: payload,
|
||||||
|
Ts: time.Now().UnixMilli(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.stores.Usage != nil {
|
||||||
|
entry := &models.UsageEntry{
|
||||||
|
ChannelID: &channelID,
|
||||||
|
UserID: userID,
|
||||||
|
ProviderConfigID: &configID,
|
||||||
|
ProviderScope: "global",
|
||||||
|
ModelID: model,
|
||||||
|
PromptTokens: resp.InputTokens,
|
||||||
|
CompletionTokens: resp.OutputTokens,
|
||||||
|
}
|
||||||
|
_ = h.stores.Usage.Log(ctx, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[chain-all] ✅ %s responded (%d tokens) in channel %s",
|
||||||
|
persona.Name, resp.InputTokens+resp.OutputTokens, channelID[:min(8, len(channelID))])
|
||||||
|
}
|
||||||
|
|
||||||
// emitTyping sends a typing indicator via WebSocket.
|
// emitTyping sends a typing indicator via WebSocket.
|
||||||
func (h *CompletionHandler) emitTyping(userID, channelID, participantID, displayName string, start bool) {
|
func (h *CompletionHandler) emitTyping(userID, channelID, participantID, displayName string, start bool) {
|
||||||
eventType := "typing.start"
|
eventType := "typing.start"
|
||||||
|
|||||||
@@ -29,16 +29,20 @@ 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"`
|
||||||
SiblingCount int `json:"sibling_count"`
|
SiblingCount int `json:"sibling_count"`
|
||||||
SiblingIndex int `json:"sibling_index"`
|
SiblingIndex int `json:"sibling_index"`
|
||||||
CreatedAt string `json:"created_at"`
|
ParticipantType *string `json:"participant_type,omitempty"`
|
||||||
|
ParticipantID *string `json:"participant_id,omitempty"`
|
||||||
|
SenderName *string `json:"sender_name,omitempty"`
|
||||||
|
SenderAvatar *string `json:"sender_avatar,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type editRequest struct {
|
type editRequest struct {
|
||||||
@@ -98,11 +102,20 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows, err := database.DB.Query(database.Q(`
|
rows, err := database.DB.Query(database.Q(`
|
||||||
SELECT id, channel_id, role, content, model, tokens_used, parent_id,
|
SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.parent_id,
|
||||||
sibling_index, created_at
|
m.sibling_index, m.participant_type, m.participant_id,
|
||||||
FROM messages
|
CASE WHEN m.participant_type = 'user' THEN COALESCE(u.display_name, u.username)
|
||||||
WHERE channel_id = $1 AND deleted_at IS NULL
|
WHEN m.participant_type = 'persona' THEN p.name
|
||||||
ORDER BY created_at ASC
|
ELSE NULL END AS sender_name,
|
||||||
|
CASE WHEN m.participant_type = 'user' THEN u.avatar_url
|
||||||
|
WHEN m.participant_type = 'persona' THEN p.avatar
|
||||||
|
ELSE NULL END AS sender_avatar,
|
||||||
|
m.created_at
|
||||||
|
FROM messages m
|
||||||
|
LEFT JOIN users u ON m.participant_type = 'user' AND m.participant_id = u.id::text
|
||||||
|
LEFT JOIN personas p ON m.participant_type = 'persona' AND m.participant_id = p.id::text
|
||||||
|
WHERE m.channel_id = $1 AND m.deleted_at IS NULL
|
||||||
|
ORDER BY m.created_at ASC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
`), channelID, perPage, offset)
|
`), channelID, perPage, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -117,7 +130,9 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
|||||||
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.Model, &msg.TokensUsed, &msg.ParentID,
|
||||||
&msg.SiblingIndex, &msg.CreatedAt,
|
&msg.SiblingIndex, &msg.ParticipantType, &msg.ParticipantID,
|
||||||
|
&msg.SenderName, &msg.SenderAvatar,
|
||||||
|
&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"})
|
||||||
@@ -693,9 +708,20 @@ func userOwnsChannel(c *gin.Context, channelID, userID string) bool {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel ownership"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel ownership"})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if ownerID != userID {
|
if ownerID == userID {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
|
return true
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
|
// v0.23.2: Also allow if user is a participant (multi-user DMs, channels)
|
||||||
|
var exists bool
|
||||||
|
_ = database.DB.QueryRow(
|
||||||
|
database.Q(`SELECT EXISTS(SELECT 1 FROM channel_participants WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2)`),
|
||||||
|
channelID, userID,
|
||||||
|
).Scan(&exists)
|
||||||
|
if exists {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
)
|
)
|
||||||
@@ -258,6 +259,37 @@ func (h *ParticipantHandler) Remove(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.23.2: DM guard — cannot drop below 2 human participants
|
||||||
|
var channelType string
|
||||||
|
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||||
|
SELECT COALESCE(type, 'direct') FROM channels WHERE id = $1
|
||||||
|
`), channelID).Scan(&channelType)
|
||||||
|
|
||||||
|
if channelType == "dm" && p.ParticipantType == "user" {
|
||||||
|
var userCount int
|
||||||
|
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||||
|
SELECT COUNT(*) FROM channel_participants
|
||||||
|
WHERE channel_id = $1 AND participant_type = 'user'
|
||||||
|
`), channelID).Scan(&userCount)
|
||||||
|
if userCount <= 2 {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "DMs require at least 2 participants"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.23.2: Group guard — cannot remove the last persona
|
||||||
|
if channelType == "group" && p.ParticipantType == "persona" {
|
||||||
|
var personaCount int
|
||||||
|
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||||
|
SELECT COUNT(*) FROM channel_participants
|
||||||
|
WHERE channel_id = $1 AND participant_type = 'persona'
|
||||||
|
`), channelID).Scan(&personaCount)
|
||||||
|
if personaCount <= 1 {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "group chats require at least 1 persona"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If removing a persona, also remove its auto-created model roster entry
|
// If removing a persona, also remove its auto-created model roster entry
|
||||||
if p.ParticipantType == "persona" {
|
if p.ParticipantType == "persona" {
|
||||||
_ = h.stores.Channels.DeleteModelByPersona(c.Request.Context(), channelID, p.ParticipantID)
|
_ = h.stores.Channels.DeleteModelByPersona(c.Request.Context(), channelID, p.ParticipantID)
|
||||||
|
|||||||
311
server/handlers/persona_groups.go
Normal file
311
server/handlers/persona_groups.go
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
// persona_groups.go — Persona group (roster template) CRUD (v0.23.2)
|
||||||
|
//
|
||||||
|
// Persona groups are saved collections of personas used as templates
|
||||||
|
// for creating group chats. Each member has an is_leader flag.
|
||||||
|
//
|
||||||
|
// Routes:
|
||||||
|
// GET /api/v1/persona-groups
|
||||||
|
// POST /api/v1/persona-groups
|
||||||
|
// GET /api/v1/persona-groups/:id
|
||||||
|
// PUT /api/v1/persona-groups/:id
|
||||||
|
// DELETE /api/v1/persona-groups/:id
|
||||||
|
// POST /api/v1/persona-groups/:id/members
|
||||||
|
// DELETE /api/v1/persona-groups/:id/members/:memberId
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PersonaGroupHandler struct{}
|
||||||
|
|
||||||
|
func NewPersonaGroupHandler() *PersonaGroupHandler { return &PersonaGroupHandler{} }
|
||||||
|
|
||||||
|
// ── List ────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *PersonaGroupHandler) List(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
|
||||||
|
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
|
||||||
|
SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at
|
||||||
|
FROM persona_groups
|
||||||
|
WHERE owner_id = $1
|
||||||
|
ORDER BY name
|
||||||
|
`), userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list groups"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
groups := []models.PersonaGroup{}
|
||||||
|
for rows.Next() {
|
||||||
|
var g models.PersonaGroup
|
||||||
|
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
|
||||||
|
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groups = append(groups, g)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load members for each group
|
||||||
|
for i := range groups {
|
||||||
|
groups[i].Members = h.loadMembers(c, groups[i].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"groups": groups})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get ─────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *PersonaGroupHandler) Get(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
var g models.PersonaGroup
|
||||||
|
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||||
|
SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at
|
||||||
|
FROM persona_groups WHERE id = $1 AND owner_id = $2
|
||||||
|
`), id, userID).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
|
||||||
|
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get group"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Members = h.loadMembers(c, g.ID)
|
||||||
|
c.JSON(http.StatusOK, g)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create ──────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *PersonaGroupHandler) Create(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name" binding:"required,min=1,max=100"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var g models.PersonaGroup
|
||||||
|
if database.IsSQLite() {
|
||||||
|
id := store.NewID()
|
||||||
|
_, err := database.DB.ExecContext(c.Request.Context(), `
|
||||||
|
INSERT INTO persona_groups (id, name, description, owner_id, scope)
|
||||||
|
VALUES (?, ?, ?, ?, 'personal')
|
||||||
|
`, id, strings.TrimSpace(req.Name), req.Description, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
database.DB.QueryRowContext(c.Request.Context(), `
|
||||||
|
SELECT id, name, description, owner_id, scope, team_id, created_at, updated_at
|
||||||
|
FROM persona_groups WHERE id = ?
|
||||||
|
`, id).Scan(&g.ID, &g.Name, &g.Description, &g.OwnerID,
|
||||||
|
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt)
|
||||||
|
} else {
|
||||||
|
err := database.DB.QueryRowContext(c.Request.Context(), `
|
||||||
|
INSERT INTO persona_groups (name, description, owner_id, scope)
|
||||||
|
VALUES ($1, $2, $3, 'personal')
|
||||||
|
RETURNING id, name, description, owner_id, scope, team_id, created_at, updated_at
|
||||||
|
`, strings.TrimSpace(req.Name), req.Description, userID).Scan(
|
||||||
|
&g.ID, &g.Name, &g.Description, &g.OwnerID,
|
||||||
|
&g.Scope, &g.TeamID, &g.CreatedAt, &g.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Members = []models.PersonaGroupMember{}
|
||||||
|
c.JSON(http.StatusCreated, g)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update ──────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *PersonaGroupHandler) Update(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
var ownerID string
|
||||||
|
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||||
|
SELECT owner_id FROM persona_groups WHERE id = $1
|
||||||
|
`), id).Scan(&ownerID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ownerID != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "not your group"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name != nil {
|
||||||
|
database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||||
|
UPDATE persona_groups SET name = $1, updated_at = NOW() WHERE id = $2
|
||||||
|
`), strings.TrimSpace(*req.Name), id)
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||||
|
UPDATE persona_groups SET description = $1, updated_at = NOW() WHERE id = $2
|
||||||
|
`), *req.Description, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ──────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *PersonaGroupHandler) Delete(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
result, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||||
|
DELETE FROM persona_groups WHERE id = $1 AND owner_id = $2
|
||||||
|
`), id, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, _ := result.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add Member ──────────────────────────────────
|
||||||
|
|
||||||
|
func (h *PersonaGroupHandler) AddMember(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
groupID := c.Param("id")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
PersonaID string `json:"persona_id" binding:"required"`
|
||||||
|
IsLeader bool `json:"is_leader"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
var ownerID string
|
||||||
|
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||||
|
SELECT owner_id FROM persona_groups WHERE id = $1
|
||||||
|
`), groupID).Scan(&ownerID)
|
||||||
|
if err != nil || ownerID != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "not your group"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If setting as leader, clear existing leader
|
||||||
|
if req.IsLeader {
|
||||||
|
database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||||
|
UPDATE persona_group_members SET is_leader = false WHERE group_id = $1
|
||||||
|
`), groupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if database.IsSQLite() {
|
||||||
|
id := store.NewID()
|
||||||
|
_, err = database.DB.ExecContext(c.Request.Context(), `
|
||||||
|
INSERT INTO persona_group_members (id, group_id, persona_id, is_leader)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = excluded.is_leader
|
||||||
|
`, id, groupID, req.PersonaID, req.IsLeader)
|
||||||
|
} else {
|
||||||
|
_, err = database.DB.ExecContext(c.Request.Context(), `
|
||||||
|
INSERT INTO persona_group_members (group_id, persona_id, is_leader)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (group_id, persona_id) DO UPDATE SET is_leader = EXCLUDED.is_leader
|
||||||
|
`, groupID, req.PersonaID, req.IsLeader)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Remove Member ───────────────────────────────
|
||||||
|
|
||||||
|
func (h *PersonaGroupHandler) RemoveMember(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
groupID := c.Param("id")
|
||||||
|
memberID := c.Param("memberId")
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
var ownerID string
|
||||||
|
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||||
|
SELECT owner_id FROM persona_groups WHERE id = $1
|
||||||
|
`), groupID).Scan(&ownerID)
|
||||||
|
if err != nil || ownerID != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "not your group"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||||
|
DELETE FROM persona_group_members WHERE id = $1 AND group_id = $2
|
||||||
|
`), memberID, groupID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *PersonaGroupHandler) loadMembers(c *gin.Context, groupID string) []models.PersonaGroupMember {
|
||||||
|
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
|
||||||
|
SELECT pgm.id, pgm.group_id, pgm.persona_id, pgm.is_leader, pgm.sort_order,
|
||||||
|
COALESCE(p.name, '') AS persona_name,
|
||||||
|
COALESCE(p.handle, '') AS persona_handle,
|
||||||
|
COALESCE(p.avatar, '') AS persona_avatar
|
||||||
|
FROM persona_group_members pgm
|
||||||
|
LEFT JOIN personas p ON p.id = pgm.persona_id
|
||||||
|
WHERE pgm.group_id = $1
|
||||||
|
ORDER BY pgm.is_leader DESC, pgm.sort_order, pgm.id
|
||||||
|
`), groupID)
|
||||||
|
if err != nil {
|
||||||
|
return []models.PersonaGroupMember{}
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
members := []models.PersonaGroupMember{}
|
||||||
|
for rows.Next() {
|
||||||
|
var m models.PersonaGroupMember
|
||||||
|
if err := rows.Scan(&m.ID, &m.GroupID, &m.PersonaID, &m.IsLeader, &m.SortOrder,
|
||||||
|
&m.PersonaName, &m.PersonaHandle, &m.PersonaAvatar); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
members = append(members, m)
|
||||||
|
}
|
||||||
|
return members
|
||||||
|
}
|
||||||
@@ -67,3 +67,50 @@ func PresenceQuery(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"presence": result})
|
c.JSON(http.StatusOK, gin.H{"presence": result})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchUsers returns a lightweight list of approved users matching a query.
|
||||||
|
// GET /api/v1/users/search?q=alice — matches against username and display_name.
|
||||||
|
// Returns at most 20 results. Excludes the calling user.
|
||||||
|
func SearchUsers(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
q := strings.TrimSpace(c.Query("q"))
|
||||||
|
|
||||||
|
query := database.Q(`
|
||||||
|
SELECT id, username, COALESCE(display_name, '') AS display_name
|
||||||
|
FROM users
|
||||||
|
WHERE is_active = true AND id != $1
|
||||||
|
`)
|
||||||
|
args := []interface{}{userID}
|
||||||
|
|
||||||
|
if q != "" {
|
||||||
|
query += database.Q(` AND (LOWER(username) LIKE $2 OR LOWER(display_name) LIKE $3)`)
|
||||||
|
pattern := "%" + strings.ToLower(q) + "%"
|
||||||
|
args = append(args, pattern, pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY username LIMIT 20`
|
||||||
|
|
||||||
|
rows, err := database.DB.QueryContext(c.Request.Context(), query, args...)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type userResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
results := []userResult{}
|
||||||
|
for rows.Next() {
|
||||||
|
var u userResult
|
||||||
|
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"users": results})
|
||||||
|
}
|
||||||
|
|||||||
@@ -376,6 +376,46 @@ func main() {
|
|||||||
protected.GET("/channels/:id", channels.GetChannel)
|
protected.GET("/channels/:id", channels.GetChannel)
|
||||||
protected.PUT("/channels/:id", channels.UpdateChannel)
|
protected.PUT("/channels/:id", channels.UpdateChannel)
|
||||||
protected.DELETE("/channels/:id", channels.DeleteChannel)
|
protected.DELETE("/channels/:id", channels.DeleteChannel)
|
||||||
|
protected.POST("/channels/:id/mark-read", channels.MarkRead)
|
||||||
|
|
||||||
|
// Typing indicator broadcast (v0.23.2)
|
||||||
|
protected.POST("/channels/:id/typing", func(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
channelID := c.Param("id")
|
||||||
|
// Resolve display name
|
||||||
|
var displayName string
|
||||||
|
_ = database.DB.QueryRow(database.Q(`
|
||||||
|
SELECT COALESCE(display_name, username) FROM users WHERE id = $1
|
||||||
|
`), userID).Scan(&displayName)
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = userID[:8]
|
||||||
|
}
|
||||||
|
// Broadcast to other user participants
|
||||||
|
pRows, err := database.DB.Query(database.Q(`
|
||||||
|
SELECT participant_id FROM channel_participants
|
||||||
|
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id != $2
|
||||||
|
`), channelID, userID)
|
||||||
|
if err == nil {
|
||||||
|
defer pRows.Close()
|
||||||
|
payload, _ := json.Marshal(map[string]any{
|
||||||
|
"channel_id": channelID,
|
||||||
|
"user_id": userID,
|
||||||
|
"display_name": displayName,
|
||||||
|
})
|
||||||
|
evt := events.Event{
|
||||||
|
Label: "typing.user",
|
||||||
|
Payload: payload,
|
||||||
|
Ts: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
for pRows.Next() {
|
||||||
|
var pid string
|
||||||
|
if pRows.Scan(&pid) == nil {
|
||||||
|
hub.SendToUser(pid, evt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"ok": true})
|
||||||
|
})
|
||||||
|
|
||||||
// Chat Folders (v0.23.1)
|
// Chat Folders (v0.23.1)
|
||||||
folders := handlers.NewFolderHandler()
|
folders := handlers.NewFolderHandler()
|
||||||
@@ -388,6 +428,19 @@ func main() {
|
|||||||
protected.POST("/presence/heartbeat", handlers.PresenceHeartbeat)
|
protected.POST("/presence/heartbeat", handlers.PresenceHeartbeat)
|
||||||
protected.GET("/presence", handlers.PresenceQuery)
|
protected.GET("/presence", handlers.PresenceQuery)
|
||||||
|
|
||||||
|
// User search (v0.23.2 — DM user picker)
|
||||||
|
protected.GET("/users/search", handlers.SearchUsers)
|
||||||
|
|
||||||
|
// Persona groups (v0.23.2 — roster templates for group chats)
|
||||||
|
pgH := handlers.NewPersonaGroupHandler()
|
||||||
|
protected.GET("/persona-groups", pgH.List)
|
||||||
|
protected.POST("/persona-groups", pgH.Create)
|
||||||
|
protected.GET("/persona-groups/:id", pgH.Get)
|
||||||
|
protected.PUT("/persona-groups/:id", pgH.Update)
|
||||||
|
protected.DELETE("/persona-groups/:id", pgH.Delete)
|
||||||
|
protected.POST("/persona-groups/:id/members", pgH.AddMember)
|
||||||
|
protected.DELETE("/persona-groups/:id/members/:memberId", pgH.RemoveMember)
|
||||||
|
|
||||||
// Channel models (v0.20.0 — multi-model @mention routing)
|
// Channel models (v0.20.0 — multi-model @mention routing)
|
||||||
chModelH := handlers.NewChannelModelHandler(stores)
|
chModelH := handlers.NewChannelModelHandler(stores)
|
||||||
protected.GET("/channels/:id/models", chModelH.List)
|
protected.GET("/channels/:id/models", chModelH.List)
|
||||||
@@ -802,6 +855,10 @@ func main() {
|
|||||||
admin.POST("/storage/cleanup", fileAdm.CleanupOrphans)
|
admin.POST("/storage/cleanup", fileAdm.CleanupOrphans)
|
||||||
admin.GET("/storage/extraction", fileAdm.ExtractionStatus)
|
admin.GET("/storage/extraction", fileAdm.ExtractionStatus)
|
||||||
|
|
||||||
|
// Archived channels (admin — v0.23.2)
|
||||||
|
admin.GET("/channels/archived", adm.ListArchivedChannels)
|
||||||
|
admin.DELETE("/channels/:id/purge", adm.PurgeChannel)
|
||||||
|
|
||||||
// Extensions (admin)
|
// Extensions (admin)
|
||||||
extAdm := handlers.NewExtensionHandler(stores)
|
extAdm := handlers.NewExtensionHandler(stores)
|
||||||
admin.GET("/extensions", extAdm.AdminListExtensions)
|
admin.GET("/extensions", extAdm.AdminListExtensions)
|
||||||
|
|||||||
@@ -160,7 +160,10 @@ window.addEventListener('unhandledrejection', function(e) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sb-section-body" id="sbBodyChats">
|
<div class="sb-section-body" id="sbBodyChats"
|
||||||
|
ondragover="event.preventDefault();event.dataTransfer.dropEffect='move';this.classList.add('drag-over')"
|
||||||
|
ondragleave="this.classList.remove('drag-over')"
|
||||||
|
ondrop="onChatSectionDrop(event)">
|
||||||
{{/* Populated by UI.renderChatList() */}}
|
{{/* Populated by UI.renderChatList() */}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,6 +267,9 @@ window.addEventListener('unhandledrejection', function(e) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{/* v0.23.2: Channel context banner — shown for DMs and named channels */}}
|
||||||
|
<div id="channelContextBanner" class="channel-context-banner" style="display:none;"></div>
|
||||||
|
|
||||||
{{/* Messages */}}
|
{{/* Messages */}}
|
||||||
<div id="chatMessages" class="msg-container">
|
<div id="chatMessages" class="msg-container">
|
||||||
{{/* Populated by UI.renderMessages() */}}
|
{{/* Populated by UI.renderMessages() */}}
|
||||||
|
|||||||
@@ -140,6 +140,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="userAddPersonaForm" style="display:none;"></div>
|
<div id="userAddPersonaForm" style="display:none;"></div>
|
||||||
<div id="userPersonaList"><div class="settings-placeholder">Loading personas…</div></div>
|
<div id="userPersonaList"><div class="settings-placeholder">Loading personas…</div></div>
|
||||||
|
{{else if eq .Section "models"}}
|
||||||
|
<div id="userModelList"><div class="settings-placeholder">Loading models…</div></div>
|
||||||
|
{{else if eq .Section "teams"}}
|
||||||
|
<div id="settingsDynamic"><div class="settings-placeholder">Loading teams…</div></div>
|
||||||
{{else}}<div class="settings-placeholder" id="settingsDynamic">Loading…</div>
|
{{else}}<div class="settings-placeholder" id="settingsDynamic">Loading…</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ type PathMessage struct {
|
|||||||
SiblingIndex int `json:"sibling_index"`
|
SiblingIndex int `json:"sibling_index"`
|
||||||
ParticipantType string `json:"participant_type,omitempty"`
|
ParticipantType string `json:"participant_type,omitempty"`
|
||||||
ParticipantID string `json:"participant_id,omitempty"`
|
ParticipantID string `json:"participant_id,omitempty"`
|
||||||
|
SenderName *string `json:"sender_name,omitempty"`
|
||||||
|
SenderAvatar *string `json:"sender_avatar,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,5 +161,79 @@ func GetPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
|
|||||||
path[i].SiblingCount = GetSiblingCount(channelID, path[i].ParentID)
|
path[i].SiblingCount = GetSiblingCount(channelID, path[i].ParentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.23.2: Resolve sender names and avatars
|
||||||
|
resolveSenderInfo(path)
|
||||||
|
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveSenderInfo batch-resolves sender_name and sender_avatar for path messages.
|
||||||
|
func resolveSenderInfo(path []PathMessage) {
|
||||||
|
// Collect unique participant IDs by type
|
||||||
|
userIDs := map[string]bool{}
|
||||||
|
personaIDs := map[string]bool{}
|
||||||
|
for _, m := range path {
|
||||||
|
if m.ParticipantID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch m.ParticipantType {
|
||||||
|
case "user":
|
||||||
|
userIDs[m.ParticipantID] = true
|
||||||
|
case "persona":
|
||||||
|
personaIDs[m.ParticipantID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve users
|
||||||
|
userNames := map[string]string{}
|
||||||
|
userAvatars := map[string]string{}
|
||||||
|
for uid := range userIDs {
|
||||||
|
var name, avatar sql.NullString
|
||||||
|
_ = database.DB.QueryRow(database.Q(`
|
||||||
|
SELECT COALESCE(display_name, username), avatar_url FROM users WHERE id = $1
|
||||||
|
`), uid).Scan(&name, &avatar)
|
||||||
|
if name.Valid {
|
||||||
|
userNames[uid] = name.String
|
||||||
|
}
|
||||||
|
if avatar.Valid {
|
||||||
|
userAvatars[uid] = avatar.String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve personas
|
||||||
|
personaNames := map[string]string{}
|
||||||
|
personaAvatars := map[string]string{}
|
||||||
|
for pid := range personaIDs {
|
||||||
|
var name, avatar sql.NullString
|
||||||
|
_ = database.DB.QueryRow(database.Q(`
|
||||||
|
SELECT name, avatar FROM personas WHERE id = $1
|
||||||
|
`), pid).Scan(&name, &avatar)
|
||||||
|
if name.Valid {
|
||||||
|
personaNames[pid] = name.String
|
||||||
|
}
|
||||||
|
if avatar.Valid {
|
||||||
|
personaAvatars[pid] = avatar.String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply to path
|
||||||
|
for i := range path {
|
||||||
|
pid := path[i].ParticipantID
|
||||||
|
switch path[i].ParticipantType {
|
||||||
|
case "user":
|
||||||
|
if n, ok := userNames[pid]; ok {
|
||||||
|
path[i].SenderName = &n
|
||||||
|
}
|
||||||
|
if a, ok := userAvatars[pid]; ok && a != "" {
|
||||||
|
path[i].SenderAvatar = &a
|
||||||
|
}
|
||||||
|
case "persona":
|
||||||
|
if n, ok := personaNames[pid]; ok {
|
||||||
|
path[i].SenderName = &n
|
||||||
|
}
|
||||||
|
if a, ok := personaAvatars[pid]; ok && a != "" {
|
||||||
|
path[i].SenderAvatar = &a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -191,6 +191,11 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.92em;
|
font-size: 0.92em;
|
||||||
}
|
}
|
||||||
|
/* v0.23.2: User mention pill — teal to distinguish from AI mentions */
|
||||||
|
.mention-pill.mention-user {
|
||||||
|
color: #2dd4bf;
|
||||||
|
background: rgba(45,212,191,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Model Attribution (multi-model messages) ─ */
|
/* ── Model Attribution (multi-model messages) ─ */
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,48 @@
|
|||||||
display: inline-flex; align-items: center; gap: 4px;
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v0.23.2: Channel context banner */
|
||||||
|
.channel-context-banner {
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.channel-context-banner .ctx-mode {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.channel-context-banner .ctx-mode-btn {
|
||||||
|
cursor: pointer; border: 1px solid var(--border);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.channel-context-banner .ctx-mode-btn:hover {
|
||||||
|
background: var(--bg-hover); color: var(--text);
|
||||||
|
}
|
||||||
|
.channel-context-banner .ctx-topic {
|
||||||
|
color: var(--text-2); font-size: 12px;
|
||||||
|
}
|
||||||
|
.channel-context-banner .ctx-hint {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.ctx-participants-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none; border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
color: var(--text-2); font-size: 11px; padding: 2px 8px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.ctx-participants-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.hover-bg:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
/* Message container (scrollable) */
|
/* Message container (scrollable) */
|
||||||
.msg-container {
|
.msg-container {
|
||||||
flex: 1; overflow-y: auto; scroll-behavior: smooth;
|
flex: 1; overflow-y: auto; scroll-behavior: smooth;
|
||||||
@@ -171,6 +213,16 @@
|
|||||||
.message.user .msg-avatar { background: var(--accent-dim); }
|
.message.user .msg-avatar { background: var(--accent-dim); }
|
||||||
.message.assistant .msg-avatar { background: var(--bg-raised); }
|
.message.assistant .msg-avatar { background: var(--bg-raised); }
|
||||||
|
|
||||||
|
/* Other human's messages — left-aligned like assistant, teal accent */
|
||||||
|
.message.other-user .msg-body {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px 16px 16px 4px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.message.other-user .msg-role { color: #2dd4bf; }
|
||||||
|
.message.other-user .msg-avatar { background: rgba(45,212,191,0.12); color: #2dd4bf; }
|
||||||
|
|
||||||
.msg-body { flex: 1; min-width: 0; }
|
.msg-body { flex: 1; min-width: 0; }
|
||||||
.msg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
.msg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||||
.msg-role { font-size: 13px; font-weight: 600; }
|
.msg-role { font-size: 13px; font-weight: 600; }
|
||||||
|
|||||||
@@ -705,6 +705,14 @@
|
|||||||
.sb-channel-item.active { background: var(--bg-raised); color: var(--text); }
|
.sb-channel-item.active { background: var(--bg-raised); color: var(--text); }
|
||||||
.sidebar.collapsed .sb-channel-item { justify-content: center; padding: 5px 0; margin: 0 6px; }
|
.sidebar.collapsed .sb-channel-item { justify-content: center; padding: 5px 0; margin: 0 6px; }
|
||||||
|
|
||||||
|
.sb-ch-menu {
|
||||||
|
visibility: hidden; background: none; border: none; color: var(--text-3); cursor: pointer;
|
||||||
|
font-size: 14px; padding: 0 4px; line-height: 1; border-radius: 3px; margin-left: auto;
|
||||||
|
}
|
||||||
|
.sb-ch-menu:hover { color: var(--text); background: var(--bg-hover); }
|
||||||
|
.sb-channel-item:hover .sb-ch-menu { visibility: visible; }
|
||||||
|
.sidebar.collapsed .sb-ch-menu { display: none; }
|
||||||
|
|
||||||
.sb-ch-icon { color: var(--text-3); flex-shrink: 0; display: flex; }
|
.sb-ch-icon { color: var(--text-3); flex-shrink: 0; display: flex; }
|
||||||
.sb-ch-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.sb-ch-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.sidebar.collapsed .sb-ch-name { display: none; }
|
.sidebar.collapsed .sb-ch-name { display: none; }
|
||||||
@@ -731,14 +739,15 @@
|
|||||||
/* ── Chats section folder items ── */
|
/* ── Chats section folder items ── */
|
||||||
|
|
||||||
.sb-folder-group { margin-bottom: 1px; }
|
.sb-folder-group { margin-bottom: 1px; }
|
||||||
|
.sb-folder-group.drag-over .sb-folder-header { background: var(--accent-dim); }
|
||||||
.sb-folder-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.sb-folder-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.sb-folder-menu {
|
.sb-folder-menu {
|
||||||
display: none; background: none; border: none; cursor: pointer;
|
visibility: hidden; background: none; border: none; cursor: pointer;
|
||||||
color: var(--text-3); font-size: 14px; padding: 0 2px; line-height: 1;
|
color: var(--text-3); font-size: 14px; padding: 0 2px; line-height: 1;
|
||||||
flex-shrink: 0; border-radius: 3px;
|
flex-shrink: 0; border-radius: 3px;
|
||||||
}
|
}
|
||||||
.sb-folder-menu:hover { color: var(--text); background: var(--bg-hover); }
|
.sb-folder-menu:hover { color: var(--text); background: var(--bg-hover); }
|
||||||
.sb-folder-header:hover .sb-folder-menu { display: block; }
|
.sb-folder-header:hover .sb-folder-menu { visibility: visible; }
|
||||||
.sb-folder-empty { padding: 4px 14px 6px 28px; font-size: 11px; color: var(--text-3); font-style: italic; }
|
.sb-folder-empty { padding: 4px 14px 6px 28px; font-size: 11px; color: var(--text-3); font-style: italic; }
|
||||||
|
|
||||||
.sb-folder-header {
|
.sb-folder-header {
|
||||||
@@ -756,7 +765,13 @@
|
|||||||
}
|
}
|
||||||
.sb-folder-header:hover { background: var(--bg-hover); color: var(--text-2); }
|
.sb-folder-header:hover { background: var(--bg-hover); color: var(--text-2); }
|
||||||
|
|
||||||
.sb-folder-arrow { margin-left: auto; font-size: 9px; }
|
.sb-folder-arrow { font-size: 9px; flex-shrink: 0; width: 12px; text-align: center; }
|
||||||
|
|
||||||
|
/* v0.23.2: Drop zone for chats section */
|
||||||
|
#sbBodyChats.drag-over { outline: 2px dashed var(--accent); outline-offset: -2px; border-radius: 4px; }
|
||||||
|
.sb-unfiled-zone { min-height: 30px; border-radius: 4px; margin-top: 2px; }
|
||||||
|
.sb-unfiled-zone.drag-over { outline: 2px dashed var(--accent); outline-offset: -2px; }
|
||||||
|
.sb-unfiled-hint { padding: 6px 14px; font-size: 11px; color: var(--text-3); font-style: italic; }
|
||||||
|
|
||||||
/* Indented chat items inside folders */
|
/* Indented chat items inside folders */
|
||||||
.chat-item.indented { padding-left: 28px; }
|
.chat-item.indented { padding-left: 28px; }
|
||||||
|
|||||||
@@ -220,6 +220,13 @@
|
|||||||
SCAFFOLDING.stats =
|
SCAFFOLDING.stats =
|
||||||
'<div id="adminStats" class="stat-cards-grid"><div class="loading">Loading...</div></div>';
|
'<div id="adminStats" class="stat-cards-grid"><div class="loading">Loading...</div></div>';
|
||||||
|
|
||||||
|
SCAFFOLDING.channels =
|
||||||
|
'<div class="stat-cards-grid" style="margin-bottom:16px">' +
|
||||||
|
'<div class="stat-card"><div class="stat-value" id="adminArchivedCount">—</div><div class="stat-label">Archived</div></div>' +
|
||||||
|
'<div class="stat-card"><div class="stat-value" id="adminRetentionMode">—</div><div class="stat-label">Retention Mode</div></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div id="adminArchivedList" class="admin-list"><div class="loading">Loading...</div></div>';
|
||||||
|
|
||||||
|
|
||||||
// === Add button actions ==============================================
|
// === Add button actions ==============================================
|
||||||
|
|
||||||
@@ -272,7 +279,7 @@
|
|||||||
users:'people', teams:'people', groups:'people',
|
users:'people', teams:'people', groups:'people',
|
||||||
providers:'ai', models:'ai', personas:'ai', roles:'ai', knowledgeBases:'ai', memory:'ai',
|
providers:'ai', models:'ai', personas:'ai', roles:'ai', knowledgeBases:'ai', memory:'ai',
|
||||||
health:'routing', routing:'routing', capabilities:'routing',
|
health:'routing', routing:'routing', capabilities:'routing',
|
||||||
settings:'system', storage:'system', extensions:'system',
|
settings:'system', storage:'system', extensions:'system', channels:'system',
|
||||||
usage:'monitoring', audit:'monitoring', stats:'monitoring',
|
usage:'monitoring', audit:'monitoring', stats:'monitoring',
|
||||||
};
|
};
|
||||||
var cat = catMap[section] || 'people';
|
var cat = catMap[section] || 'people';
|
||||||
@@ -280,7 +287,7 @@
|
|||||||
people: [{id:'users',l:'Users'},{id:'teams',l:'Teams'},{id:'groups',l:'Groups'}],
|
people: [{id:'users',l:'Users'},{id:'teams',l:'Teams'},{id:'groups',l:'Groups'}],
|
||||||
ai: [{id:'providers',l:'Providers'},{id:'models',l:'Models'},{id:'personas',l:'Personas'},{id:'roles',l:'Roles'},{id:'knowledgeBases',l:'Knowledge'},{id:'memory',l:'Memory'}],
|
ai: [{id:'providers',l:'Providers'},{id:'models',l:'Models'},{id:'personas',l:'Personas'},{id:'roles',l:'Roles'},{id:'knowledgeBases',l:'Knowledge'},{id:'memory',l:'Memory'}],
|
||||||
routing: [{id:'health',l:'Health'},{id:'routing',l:'Routing'},{id:'capabilities',l:'Capabilities'}],
|
routing: [{id:'health',l:'Health'},{id:'routing',l:'Routing'},{id:'capabilities',l:'Capabilities'}],
|
||||||
system: [{id:'settings',l:'Settings'},{id:'storage',l:'Storage'},{id:'extensions',l:'Extensions'}],
|
system: [{id:'settings',l:'Settings'},{id:'storage',l:'Storage'},{id:'extensions',l:'Extensions'},{id:'channels',l:'Channels'}],
|
||||||
monitoring: [{id:'usage',l:'Usage'},{id:'audit',l:'Audit'},{id:'stats',l:'Stats'}],
|
monitoring: [{id:'usage',l:'Usage'},{id:'audit',l:'Audit'},{id:'stats',l:'Stats'}],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ const API = {
|
|||||||
updateChannel(id, updates) { return this._put(`/api/v1/channels/${id}`, updates); },
|
updateChannel(id, updates) { return this._put(`/api/v1/channels/${id}`, updates); },
|
||||||
generateTitle(id) { return this._post(`/api/v1/channels/${id}/generate-title`, {}); },
|
generateTitle(id) { return this._post(`/api/v1/channels/${id}/generate-title`, {}); },
|
||||||
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
|
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
|
||||||
|
markRead(id) { return this._post(`/api/v1/channels/${id}/mark-read`, {}); },
|
||||||
|
|
||||||
// Channel models (v0.20.0 — multi-model @mention routing)
|
// Channel models (v0.20.0 — multi-model @mention routing)
|
||||||
listChannelModels(channelId) { return this._get(`/api/v1/channels/${channelId}/models`); },
|
listChannelModels(channelId) { return this._get(`/api/v1/channels/${channelId}/models`); },
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
const App = {
|
const App = {
|
||||||
chats: [],
|
chats: [],
|
||||||
currentChatId: null,
|
|
||||||
models: [],
|
models: [],
|
||||||
hiddenModels: new Set(),
|
hiddenModels: new Set(),
|
||||||
isGenerating: false,
|
isGenerating: false,
|
||||||
@@ -21,12 +20,32 @@ const App = {
|
|||||||
collapsedProjects: {},
|
collapsedProjects: {},
|
||||||
activeProjectId: null,
|
activeProjectId: null,
|
||||||
showArchivedProjects: false,
|
showArchivedProjects: false,
|
||||||
|
// v0.23.2: Unified active conversation
|
||||||
|
activeConversation: null, // { id, type } where type = direct|dm|group|channel
|
||||||
// v0.23.1: Multi-user
|
// v0.23.1: Multi-user
|
||||||
channels: [], // DMs + named channels
|
channels: [], // DMs + named channels (sidebar display data)
|
||||||
currentChannelId: null,
|
|
||||||
folders: [], // chat folders (user-scoped)
|
folders: [], // chat folders (user-scoped)
|
||||||
collapsedFolders: {},
|
collapsedFolders: {},
|
||||||
presence: {}, // { userId: 'online' | 'offline' }
|
presence: {}, // { userId: 'online' | 'offline' }
|
||||||
|
users: [], // v0.23.2: [{id, username, displayName}] for @mention autocomplete
|
||||||
|
|
||||||
|
/** Get active conversation ID (shorthand). */
|
||||||
|
get activeId() { return this.activeConversation?.id || null; },
|
||||||
|
|
||||||
|
/** Get active conversation type. */
|
||||||
|
get activeType() { return this.activeConversation?.type || null; },
|
||||||
|
|
||||||
|
/** Set the active conversation. Clears if id is null. */
|
||||||
|
setActive(id, type) {
|
||||||
|
this.activeConversation = id ? { id, type: type || 'direct' } : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Find the active conversation's chat object (with cached messages). */
|
||||||
|
getActiveChat() {
|
||||||
|
const id = this.activeConversation?.id;
|
||||||
|
if (!id) return null;
|
||||||
|
return this.chats.find(c => c.id === id) || null;
|
||||||
|
},
|
||||||
|
|
||||||
findModel(id) {
|
findModel(id) {
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ async function startApp() {
|
|||||||
UI.updateCapabilityBadges();
|
UI.updateCapabilityBadges();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.23.2: Load user list for @mention autocomplete
|
||||||
|
await loadUsers();
|
||||||
|
|
||||||
// Guard: if token was invalidated during startup (401 → refresh fail → clearTokens),
|
// Guard: if token was invalidated during startup (401 → refresh fail → clearTokens),
|
||||||
// kick back to login instead of rendering a broken UI.
|
// kick back to login instead of rendering a broken UI.
|
||||||
if (!API.isAuthed) {
|
if (!API.isAuthed) {
|
||||||
@@ -103,6 +106,16 @@ async function startApp() {
|
|||||||
|
|
||||||
await initBanners();
|
await initBanners();
|
||||||
initFiles();
|
initFiles();
|
||||||
|
|
||||||
|
// v0.23.2: Create primary ChatPane instance from server-rendered mount points
|
||||||
|
if (typeof ChatPane !== 'undefined') {
|
||||||
|
ChatPane.primary = ChatPane.create({
|
||||||
|
id: 'main',
|
||||||
|
messagesEl: document.getElementById('chatMessages'),
|
||||||
|
inputEl: document.getElementById('chatInputBar'),
|
||||||
|
standalone: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (typeof ToolsToggle !== 'undefined') ToolsToggle.init();
|
if (typeof ToolsToggle !== 'undefined') ToolsToggle.init();
|
||||||
if (typeof REPL !== 'undefined') REPL.init(); // v0.21.3: admin-gated REPL tab
|
if (typeof REPL !== 'undefined') REPL.init(); // v0.21.3: admin-gated REPL tab
|
||||||
if (typeof KnowledgeUI !== 'undefined') {
|
if (typeof KnowledgeUI !== 'undefined') {
|
||||||
@@ -115,16 +128,25 @@ async function startApp() {
|
|||||||
UI.restoreSidebarSections();
|
UI.restoreSidebarSections();
|
||||||
UI.updateModelSelector();
|
UI.updateModelSelector();
|
||||||
|
|
||||||
// Restore last-active chat from sessionStorage
|
// Restore last-active conversation from sessionStorage
|
||||||
try {
|
try {
|
||||||
const savedChat = sessionStorage.getItem('cs-active-chat');
|
const saved = sessionStorage.getItem('cs-active-conversation');
|
||||||
if (savedChat && App.chats.some(c => c.id === savedChat)) {
|
if (saved) {
|
||||||
selectChat(savedChat);
|
const ac = JSON.parse(saved);
|
||||||
|
if (ac?.id) {
|
||||||
|
const inChats = App.chats.some(c => c.id === ac.id);
|
||||||
|
const inChannels = (App.channels || []).some(c => c.id === ac.id);
|
||||||
|
if (inChats) {
|
||||||
|
selectChat(ac.id);
|
||||||
|
} else if (inChannels) {
|
||||||
|
selectChannel(ac.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
// If no chat was restored, show the welcome/empty state
|
// If no conversation was restored, show the welcome/empty state
|
||||||
if (!App.currentChatId) {
|
if (!App.activeId) {
|
||||||
UI.showEmptyState();
|
UI.showEmptyState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +256,7 @@ async function handleLogout() {
|
|||||||
Events.clear();
|
Events.clear();
|
||||||
await API.logout();
|
await API.logout();
|
||||||
App.chats = [];
|
App.chats = [];
|
||||||
App.currentChatId = null;
|
App.setActive(null);
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -206,22 +206,41 @@ const ChannelModels = {
|
|||||||
|
|
||||||
// Build match list from ALL enabled models + personas (not just roster)
|
// Build match list from ALL enabled models + personas (not just roster)
|
||||||
const allModels = (App.models || []).filter(m => !m.hidden);
|
const allModels = (App.models || []).filter(m => !m.hidden);
|
||||||
const matches = allModels.filter(m => {
|
const modelMatches = allModels.filter(m => {
|
||||||
// Match against persona handle
|
|
||||||
const handle = (m.personaHandle || '').toLowerCase().replace(/-/g, ' ');
|
const handle = (m.personaHandle || '').toLowerCase().replace(/-/g, ' ');
|
||||||
// Match against model ID
|
|
||||||
const modelId = (m.baseModelId || m.id || '').toLowerCase().replace(/-/g, ' ');
|
const modelId = (m.baseModelId || m.id || '').toLowerCase().replace(/-/g, ' ');
|
||||||
// Match against display name
|
|
||||||
const name = (m.name || '').toLowerCase().replace(/-/g, ' ');
|
const name = (m.name || '').toLowerCase().replace(/-/g, ' ');
|
||||||
const firstName = name.split(' ')[0] || '';
|
const firstName = name.split(' ')[0] || '';
|
||||||
|
|
||||||
if (partial.length === 0) return true; // show all on bare @
|
if (partial.length === 0) return true;
|
||||||
return handle.startsWith(partial)
|
return handle.startsWith(partial)
|
||||||
|| modelId.startsWith(partial)
|
|| modelId.startsWith(partial)
|
||||||
|| name.startsWith(partial)
|
|| name.startsWith(partial)
|
||||||
|| firstName.startsWith(partial);
|
|| firstName.startsWith(partial);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v0.23.2: Include users in autocomplete (after personas, before models)
|
||||||
|
const userMatches = (App.users || []).filter(u => {
|
||||||
|
const uname = u.username.toLowerCase();
|
||||||
|
const dname = (u.displayName || '').toLowerCase();
|
||||||
|
if (partial.length === 0) return true;
|
||||||
|
return uname.startsWith(partial) || dname.startsWith(partial);
|
||||||
|
}).map(u => ({
|
||||||
|
id: u.username,
|
||||||
|
name: u.displayName || u.username,
|
||||||
|
baseModelId: u.username,
|
||||||
|
isPersona: false,
|
||||||
|
isUser: true,
|
||||||
|
personaHandle: null,
|
||||||
|
personaAvatar: null,
|
||||||
|
provider: '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Resolution order: personas first, then users, then models
|
||||||
|
const personas = modelMatches.filter(m => m.isPersona);
|
||||||
|
const models = modelMatches.filter(m => !m.isPersona);
|
||||||
|
const matches = [...personas, ...userMatches, ...models];
|
||||||
|
|
||||||
if (matches.length === 0) { this.hideAutocomplete(); return; }
|
if (matches.length === 0) { this.hideAutocomplete(); return; }
|
||||||
// Cap at 10 to keep dropdown manageable
|
// Cap at 10 to keep dropdown manageable
|
||||||
this.showAutocomplete(inputEl, atIdx, cursorPos, matches.slice(0, 10));
|
this.showAutocomplete(inputEl, atIdx, cursorPos, matches.slice(0, 10));
|
||||||
@@ -253,13 +272,17 @@ const ChannelModels = {
|
|||||||
? `<img src="${esc(m.personaAvatar)}" class="mention-ac-avatar" alt="">`
|
? `<img src="${esc(m.personaAvatar)}" class="mention-ac-avatar" alt="">`
|
||||||
: m.isPersona
|
: m.isPersona
|
||||||
? `<span class="mention-ac-avatar mention-ac-avatar-fallback">⚡</span>`
|
? `<span class="mention-ac-avatar mention-ac-avatar-fallback">⚡</span>`
|
||||||
: `<span class="mention-ac-avatar mention-ac-avatar-fallback">🤖</span>`;
|
: m.isUser
|
||||||
|
? `<span class="mention-ac-avatar mention-ac-avatar-fallback mention-ac-user">👤</span>`
|
||||||
|
: `<span class="mention-ac-avatar mention-ac-avatar-fallback">🤖</span>`;
|
||||||
|
|
||||||
const handleText = m.isPersona && m.personaHandle
|
const handleText = m.isPersona && m.personaHandle
|
||||||
? `@${esc(m.personaHandle)}`
|
? `@${esc(m.personaHandle)}`
|
||||||
: `@${esc(m.baseModelId || m.id)}`;
|
: m.isUser
|
||||||
|
? `@${esc(m.baseModelId)}`
|
||||||
|
: `@${esc(m.baseModelId || m.id)}`;
|
||||||
|
|
||||||
const providerHint = m.provider ? esc(m.provider) : '';
|
const providerHint = m.isUser ? 'user' : (m.provider ? esc(m.provider) : '');
|
||||||
|
|
||||||
item.innerHTML = `${avatar}<div class="mention-ac-info"><span class="mention-ac-name">${esc(m.name || m.id)}</span><span class="mention-ac-handle">${handleText}</span></div><span class="mention-ac-model">${providerHint}</span>`;
|
item.innerHTML = `${avatar}<div class="mention-ac-info"><span class="mention-ac-name">${esc(m.name || m.id)}</span><span class="mention-ac-handle">${handleText}</span></div><span class="mention-ac-model">${providerHint}</span>`;
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
|
|||||||
204
src/js/chat.js
204
src/js/chat.js
@@ -83,6 +83,8 @@ const ChatInput = {
|
|||||||
updateInputTokens();
|
updateInputTokens();
|
||||||
// @mention autocomplete (v0.20.0)
|
// @mention autocomplete (v0.20.0)
|
||||||
if (typeof ChannelModels !== 'undefined') ChannelModels.onInput(ChatInput);
|
if (typeof ChannelModels !== 'undefined') ChannelModels.onInput(ChatInput);
|
||||||
|
// v0.23.2: Typing indicator for multi-user channels
|
||||||
|
_emitTyping();
|
||||||
},
|
},
|
||||||
maxHeight: 200,
|
maxHeight: 200,
|
||||||
});
|
});
|
||||||
@@ -108,15 +110,30 @@ const ChatInput = {
|
|||||||
updateInputTokens();
|
updateInputTokens();
|
||||||
// @mention autocomplete (v0.20.0)
|
// @mention autocomplete (v0.20.0)
|
||||||
if (typeof ChannelModels !== 'undefined') ChannelModels.onInput(this);
|
if (typeof ChannelModels !== 'undefined') ChannelModels.onInput(this);
|
||||||
|
// v0.23.2: Typing indicator
|
||||||
|
_emitTyping();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// v0.23.2: Debounced typing indicator for multi-user channels
|
||||||
|
let _typingTimer = null;
|
||||||
|
function _emitTyping() {
|
||||||
|
if (!App.activeId) return;
|
||||||
|
// Only emit for multi-user conversation types
|
||||||
|
const type = App.activeConversation?.type;
|
||||||
|
if (type !== 'dm' && type !== 'channel' && type !== 'group') return;
|
||||||
|
// Debounce: emit at most once every 3s
|
||||||
|
if (_typingTimer) return;
|
||||||
|
_typingTimer = setTimeout(() => { _typingTimer = null; }, 3000);
|
||||||
|
API._post(`/api/v1/channels/${App.activeId}/typing`, {}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Summarize & Continue ──────────────────
|
// ── Summarize & Continue ──────────────────
|
||||||
|
|
||||||
async function summarizeAndContinue() {
|
async function summarizeAndContinue() {
|
||||||
const channelId = App.currentChatId;
|
const channelId = App.activeId;
|
||||||
if (!channelId) return;
|
if (!channelId) return;
|
||||||
|
|
||||||
const btn = document.getElementById('summarizeBtn');
|
const btn = document.getElementById('summarizeBtn');
|
||||||
@@ -145,6 +162,10 @@ async function summarizeAndContinue() {
|
|||||||
metadata: m.metadata || null,
|
metadata: m.metadata || null,
|
||||||
siblingCount: m.sibling_count || 1,
|
siblingCount: m.sibling_count || 1,
|
||||||
siblingIndex: m.sibling_index || 0,
|
siblingIndex: m.sibling_index || 0,
|
||||||
|
participant_type: m.participant_type || null,
|
||||||
|
participant_id: m.participant_id || null,
|
||||||
|
sender_name: m.sender_name || null,
|
||||||
|
sender_avatar: m.sender_avatar || null,
|
||||||
}));
|
}));
|
||||||
UI.renderMessages(chat.messages);
|
UI.renderMessages(chat.messages);
|
||||||
updateContextWarning(); updateChatTokenCount();
|
updateContextWarning(); updateChatTokenCount();
|
||||||
@@ -164,7 +185,7 @@ async function summarizeAndContinue() {
|
|||||||
// ── Per-Channel Auto-Compaction Toggle ──────
|
// ── Per-Channel Auto-Compaction Toggle ──────
|
||||||
|
|
||||||
async function toggleChannelAutoCompact(enabled) {
|
async function toggleChannelAutoCompact(enabled) {
|
||||||
const chatId = App.currentChatId;
|
const chatId = App.activeId;
|
||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
const chat = App.chats.find(c => c.id === chatId);
|
const chat = App.chats.find(c => c.id === chatId);
|
||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
@@ -193,6 +214,7 @@ async function loadChats() {
|
|||||||
model: c.model || '',
|
model: c.model || '',
|
||||||
messageCount: c.message_count || 0,
|
messageCount: c.message_count || 0,
|
||||||
projectId: c.project_id || null,
|
projectId: c.project_id || null,
|
||||||
|
folderId: c.folder || null,
|
||||||
workspace_id: c.workspace_id || null,
|
workspace_id: c.workspace_id || null,
|
||||||
messages: [],
|
messages: [],
|
||||||
updatedAt: c.updated_at,
|
updatedAt: c.updated_at,
|
||||||
@@ -206,16 +228,17 @@ async function loadChats() {
|
|||||||
|
|
||||||
async function selectChat(chatId) {
|
async function selectChat(chatId) {
|
||||||
clearStaged(); // Discard any staged files from previous chat
|
clearStaged(); // Discard any staged files from previous chat
|
||||||
App.currentChatId = chatId;
|
const chat = App.chats.find(c => c.id === chatId);
|
||||||
try { sessionStorage.setItem('cs-active-chat', chatId); } catch (_) {}
|
App.setActive(chatId, chat?.type || 'direct');
|
||||||
|
try { sessionStorage.setItem('cs-active-conversation', JSON.stringify(App.activeConversation)); } catch (_) {}
|
||||||
UI.renderChatList();
|
UI.renderChatList();
|
||||||
|
UI.renderChannelsSection();
|
||||||
if (window.innerWidth <= 768) {
|
if (window.innerWidth <= 768) {
|
||||||
document.getElementById('sidebar').classList.add('collapsed');
|
document.getElementById('sidebar').classList.add('collapsed');
|
||||||
const ov = document.getElementById('sidebarOverlay');
|
const ov = document.getElementById('sidebarOverlay');
|
||||||
if (ov) ov.style.display = 'none';
|
if (ov) ov.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
const chat = App.chats.find(c => c.id === chatId);
|
|
||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
|
|
||||||
if (chat.messages.length === 0 && chat.messageCount > 0) {
|
if (chat.messages.length === 0 && chat.messageCount > 0) {
|
||||||
@@ -233,6 +256,10 @@ async function selectChat(chatId) {
|
|||||||
metadata: m.metadata || null,
|
metadata: m.metadata || null,
|
||||||
siblingCount: m.sibling_count || 1,
|
siblingCount: m.sibling_count || 1,
|
||||||
siblingIndex: m.sibling_index || 0,
|
siblingIndex: m.sibling_index || 0,
|
||||||
|
participant_type: m.participant_type || null,
|
||||||
|
participant_id: m.participant_id || null,
|
||||||
|
sender_name: m.sender_name || null,
|
||||||
|
sender_avatar: m.sender_avatar || null,
|
||||||
}));
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load messages:', e.message);
|
console.error('Failed to load messages:', e.message);
|
||||||
@@ -250,6 +277,7 @@ async function selectChat(chatId) {
|
|||||||
await loadChannelFiles(chatId);
|
await loadChannelFiles(chatId);
|
||||||
|
|
||||||
UI.renderMessages(chat.messages);
|
UI.renderMessages(chat.messages);
|
||||||
|
UI.updateContextBanner();
|
||||||
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
|
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
|
||||||
Tokens._warningDismissed = false;
|
Tokens._warningDismissed = false;
|
||||||
updateContextWarning(); updateChatTokenCount();
|
updateContextWarning(); updateChatTokenCount();
|
||||||
@@ -264,6 +292,9 @@ async function selectChat(chatId) {
|
|||||||
|
|
||||||
// Update browser URL to reflect selected chat
|
// Update browser URL to reflect selected chat
|
||||||
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chatId}`);
|
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chatId}`);
|
||||||
|
|
||||||
|
// v0.23.2: Mark as read (non-blocking)
|
||||||
|
API.markRead(chatId).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Chat Header Token Count ──────────────────
|
// ── Chat Header Token Count ──────────────────
|
||||||
@@ -271,7 +302,7 @@ async function selectChat(chatId) {
|
|||||||
function updateChatTokenCount() {
|
function updateChatTokenCount() {
|
||||||
const el = document.getElementById('chatTokenCount');
|
const el = document.getElementById('chatTokenCount');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
const chat = App.getActiveChat();
|
||||||
if (!chat || !chat.messages.length) { el.textContent = ''; return; }
|
if (!chat || !chat.messages.length) { el.textContent = ''; return; }
|
||||||
|
|
||||||
const tokens = Tokens.estimateConversation(chat.messages, App.settings?.systemPrompt);
|
const tokens = Tokens.estimateConversation(chat.messages, App.settings?.systemPrompt);
|
||||||
@@ -379,8 +410,9 @@ function _cleanStaleChatModel(chatId) {
|
|||||||
|
|
||||||
async function newChat() {
|
async function newChat() {
|
||||||
clearStaged(); // Discard any staged files
|
clearStaged(); // Discard any staged files
|
||||||
App.currentChatId = null;
|
App.setActive(null);
|
||||||
UI.renderChatList();
|
UI.renderChatList();
|
||||||
|
UI.renderChannelsSection();
|
||||||
UI.showEmptyState();
|
UI.showEmptyState();
|
||||||
UI.showRegenerate(false);
|
UI.showRegenerate(false);
|
||||||
Tokens._warningDismissed = false;
|
Tokens._warningDismissed = false;
|
||||||
@@ -492,7 +524,7 @@ async function newGroupChat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
App.chats.unshift(chat);
|
App.chats.unshift(chat);
|
||||||
App.currentChatId = chat.id;
|
App.setActive(chat.id, 'group');
|
||||||
UI.renderChatList();
|
UI.renderChatList();
|
||||||
Events.emit('chat.created', { chatId: chat.id, projectId: null }, { localOnly: true });
|
Events.emit('chat.created', { chatId: chat.id, projectId: null }, { localOnly: true });
|
||||||
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`);
|
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`);
|
||||||
@@ -526,7 +558,7 @@ async function deleteChat(chatId) {
|
|||||||
delete map[chatId];
|
delete map[chatId];
|
||||||
localStorage.setItem('cs-chat-models', JSON.stringify(map));
|
localStorage.setItem('cs-chat-models', JSON.stringify(map));
|
||||||
} catch (e) { /* */ }
|
} catch (e) { /* */ }
|
||||||
if (App.currentChatId === chatId) { clearPreview(); newChat(); }
|
if (App.activeId === chatId) { clearPreview(); newChat(); }
|
||||||
UI.renderChatList();
|
UI.renderChatList();
|
||||||
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
|
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
|
||||||
}
|
}
|
||||||
@@ -634,7 +666,7 @@ async function sendMessage() {
|
|||||||
const model = modelInfo?.baseModelId || selectedId;
|
const model = modelInfo?.baseModelId || selectedId;
|
||||||
const personaId = modelInfo?.isPersona ? modelInfo.personaId : null;
|
const personaId = modelInfo?.isPersona ? modelInfo.personaId : null;
|
||||||
|
|
||||||
if (!App.currentChatId) {
|
if (!App.activeId) {
|
||||||
try {
|
try {
|
||||||
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
|
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
|
||||||
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
|
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
|
||||||
@@ -647,7 +679,7 @@ async function sendMessage() {
|
|||||||
} catch (_) { /* best effort — chat still works without project */ }
|
} catch (_) { /* best effort — chat still works without project */ }
|
||||||
}
|
}
|
||||||
App.chats.unshift(chat);
|
App.chats.unshift(chat);
|
||||||
App.currentChatId = chat.id;
|
App.setActive(chat.id, 'direct');
|
||||||
UI.renderChatList();
|
UI.renderChatList();
|
||||||
// Notify surfaces about new chat creation (v0.21.6)
|
// Notify surfaces about new chat creation (v0.21.6)
|
||||||
Events.emit('chat.created', { chatId: chat.id, projectId: chat.projectId || null }, { localOnly: true });
|
Events.emit('chat.created', { chatId: chat.id, projectId: chat.projectId || null }, { localOnly: true });
|
||||||
@@ -655,7 +687,7 @@ async function sendMessage() {
|
|||||||
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
|
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
|
||||||
}
|
}
|
||||||
|
|
||||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
const chat = App.getActiveChat();
|
||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
|
|
||||||
// Optimistic: show user message immediately
|
// Optimistic: show user message immediately
|
||||||
@@ -671,8 +703,20 @@ async function sendMessage() {
|
|||||||
UI.setGenerating(true);
|
UI.setGenerating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, personaId, fileIds,
|
const resp = await API.streamCompletion(App.activeId, text, model, App.abortController.signal, modelInfo?.configId, personaId, fileIds,
|
||||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []);
|
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []);
|
||||||
|
|
||||||
|
// v0.23.2: Non-streaming response (DM with mention_only, no @mention)
|
||||||
|
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||||
|
if (ct.includes('application/json')) {
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.ai_skipped) {
|
||||||
|
// Message was persisted server-side; reload to get proper IDs
|
||||||
|
await reloadActivePath();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
||||||
|
|
||||||
// Reload active path from server to get proper IDs and sibling metadata
|
// Reload active path from server to get proper IDs and sibling metadata
|
||||||
@@ -680,11 +724,11 @@ async function sendMessage() {
|
|||||||
UI.showRegenerate(true);
|
UI.showRegenerate(true);
|
||||||
|
|
||||||
// Persist the model/persona selection for this chat
|
// Persist the model/persona selection for this chat
|
||||||
_saveChatModel(App.currentChatId);
|
_saveChatModel(App.activeId);
|
||||||
|
|
||||||
// Auto-name: title the chat after first assistant response (v0.17.0)
|
// Auto-name: title the chat after first assistant response (v0.17.0)
|
||||||
if (chat.messages.length <= 3) {
|
if (chat.messages.length <= 3) {
|
||||||
autoNameChat(App.currentChatId, chat);
|
autoNameChat(App.activeId, chat);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.name === 'AbortError') {
|
if (e.name === 'AbortError') {
|
||||||
@@ -717,12 +761,12 @@ function stopGeneration() { if (App.abortController) App.abortController.abort()
|
|||||||
// ── Reload Active Path from Server ──────────
|
// ── Reload Active Path from Server ──────────
|
||||||
|
|
||||||
async function reloadActivePath() {
|
async function reloadActivePath() {
|
||||||
if (!App.currentChatId) return;
|
if (!App.activeId) return;
|
||||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
const chat = App.getActiveChat();
|
||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await API.getActivePath(App.currentChatId);
|
const resp = await API.getActivePath(App.activeId);
|
||||||
chat.messages = (resp.path || []).map(m => ({
|
chat.messages = (resp.path || []).map(m => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
parent_id: m.parent_id || null,
|
parent_id: m.parent_id || null,
|
||||||
@@ -735,9 +779,13 @@ async function reloadActivePath() {
|
|||||||
metadata: m.metadata || null,
|
metadata: m.metadata || null,
|
||||||
siblingCount: m.sibling_count || 1,
|
siblingCount: m.sibling_count || 1,
|
||||||
siblingIndex: m.sibling_index || 0,
|
siblingIndex: m.sibling_index || 0,
|
||||||
|
participant_type: m.participant_type || null,
|
||||||
|
participant_id: m.participant_id || null,
|
||||||
|
sender_name: m.sender_name || null,
|
||||||
|
sender_avatar: m.sender_avatar || null,
|
||||||
}));
|
}));
|
||||||
chat.messageCount = chat.messages.length;
|
chat.messageCount = chat.messages.length;
|
||||||
await loadChannelFiles(App.currentChatId);
|
await loadChannelFiles(App.activeId);
|
||||||
UI.renderMessages(chat.messages);
|
UI.renderMessages(chat.messages);
|
||||||
updateContextWarning(); updateChatTokenCount();
|
updateContextWarning(); updateChatTokenCount();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -748,7 +796,7 @@ async function reloadActivePath() {
|
|||||||
// ── Regenerate (tree-aware: creates sibling) ─
|
// ── Regenerate (tree-aware: creates sibling) ─
|
||||||
|
|
||||||
async function regenerateMessage(messageId) {
|
async function regenerateMessage(messageId) {
|
||||||
if (App.isGenerating || !App.currentChatId) return;
|
if (App.isGenerating || !App.activeId) return;
|
||||||
|
|
||||||
const selectedId = UI.getModelValue();
|
const selectedId = UI.getModelValue();
|
||||||
const modelInfo = App.findModel(selectedId);
|
const modelInfo = App.findModel(selectedId);
|
||||||
@@ -758,7 +806,7 @@ async function regenerateMessage(messageId) {
|
|||||||
// Truncate display to the parent of the message being regenerated.
|
// Truncate display to the parent of the message being regenerated.
|
||||||
// This makes the stream appear in the correct position — as a fresh
|
// This makes the stream appear in the correct position — as a fresh
|
||||||
// response to the parent user message, not appended at the bottom.
|
// response to the parent user message, not appended at the bottom.
|
||||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
const chat = App.getActiveChat();
|
||||||
if (chat) {
|
if (chat) {
|
||||||
const msgIdx = chat.messages.findIndex(m => m.id === messageId);
|
const msgIdx = chat.messages.findIndex(m => m.id === messageId);
|
||||||
if (msgIdx > 0) {
|
if (msgIdx > 0) {
|
||||||
@@ -773,7 +821,7 @@ async function regenerateMessage(messageId) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await API.streamRegenerate(
|
const resp = await API.streamRegenerate(
|
||||||
App.currentChatId, messageId, App.abortController.signal,
|
App.activeId, messageId, App.abortController.signal,
|
||||||
model, personaId, modelInfo?.configId,
|
model, personaId, modelInfo?.configId,
|
||||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
|
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
|
||||||
);
|
);
|
||||||
@@ -803,8 +851,8 @@ async function regenerateMessage(messageId) {
|
|||||||
|
|
||||||
// Legacy: bottom-bar regenerate button targets the last assistant message
|
// Legacy: bottom-bar regenerate button targets the last assistant message
|
||||||
async function regenerate() {
|
async function regenerate() {
|
||||||
if (App.isGenerating || !App.currentChatId) return;
|
if (App.isGenerating || !App.activeId) return;
|
||||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
const chat = App.getActiveChat();
|
||||||
if (!chat || chat.messages.length === 0) return;
|
if (!chat || chat.messages.length === 0) return;
|
||||||
|
|
||||||
// Find the last assistant message with an ID
|
// Find the last assistant message with an ID
|
||||||
@@ -819,9 +867,9 @@ async function regenerate() {
|
|||||||
// ── Edit Message (creates sibling, triggers completion) ──
|
// ── Edit Message (creates sibling, triggers completion) ──
|
||||||
|
|
||||||
async function editMessage(messageId) {
|
async function editMessage(messageId) {
|
||||||
if (App.isGenerating || !App.currentChatId) return;
|
if (App.isGenerating || !App.activeId) return;
|
||||||
|
|
||||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
const chat = App.getActiveChat();
|
||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
|
|
||||||
const msg = chat.messages.find(m => m.id === messageId);
|
const msg = chat.messages.find(m => m.id === messageId);
|
||||||
@@ -832,7 +880,7 @@ async function editMessage(messageId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitEdit(messageId, newContent) {
|
async function submitEdit(messageId, newContent) {
|
||||||
if (App.isGenerating || !App.currentChatId) return;
|
if (App.isGenerating || !App.activeId) return;
|
||||||
if (!newContent.trim()) return;
|
if (!newContent.trim()) return;
|
||||||
|
|
||||||
const selectedId = UI.getModelValue();
|
const selectedId = UI.getModelValue();
|
||||||
@@ -845,10 +893,10 @@ async function submitEdit(messageId, newContent) {
|
|||||||
UI.setGenerating(true);
|
UI.setGenerating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
const chat = App.getActiveChat();
|
||||||
|
|
||||||
// Step 1: Create sibling user message via edit endpoint
|
// Step 1: Create sibling user message via edit endpoint
|
||||||
const editResp = await API.editMessage(App.currentChatId, messageId, newContent.trim());
|
const editResp = await API.editMessage(App.activeId, messageId, newContent.trim());
|
||||||
|
|
||||||
// Step 2: Reload path to show the new edited message
|
// Step 2: Reload path to show the new edited message
|
||||||
await reloadActivePath();
|
await reloadActivePath();
|
||||||
@@ -858,7 +906,7 @@ async function submitEdit(messageId, newContent) {
|
|||||||
// a child response, not a sibling). This avoids the duplicate user
|
// a child response, not a sibling). This avoids the duplicate user
|
||||||
// message that streamCompletion would create.
|
// message that streamCompletion would create.
|
||||||
const resp = await API.streamRegenerate(
|
const resp = await API.streamRegenerate(
|
||||||
App.currentChatId, editResp.id, App.abortController.signal,
|
App.activeId, editResp.id, App.abortController.signal,
|
||||||
model, personaId, modelInfo?.configId,
|
model, personaId, modelInfo?.configId,
|
||||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
|
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
|
||||||
);
|
);
|
||||||
@@ -885,18 +933,18 @@ async function submitEdit(messageId, newContent) {
|
|||||||
|
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
// Re-render to remove the edit form
|
// Re-render to remove the edit form
|
||||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
const chat = App.getActiveChat();
|
||||||
if (chat) UI.renderMessages(chat.messages);
|
if (chat) UI.renderMessages(chat.messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Switch Branch (sibling navigation) ──────
|
// ── Switch Branch (sibling navigation) ──────
|
||||||
|
|
||||||
async function switchSibling(messageId, direction) {
|
async function switchSibling(messageId, direction) {
|
||||||
if (App.isGenerating || !App.currentChatId) return;
|
if (App.isGenerating || !App.activeId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get siblings for this message
|
// Get siblings for this message
|
||||||
const data = await API.listSiblings(App.currentChatId, messageId);
|
const data = await API.listSiblings(App.activeId, messageId);
|
||||||
const siblings = data.siblings || [];
|
const siblings = data.siblings || [];
|
||||||
const currentIdx = data.current_index ?? 0;
|
const currentIdx = data.current_index ?? 0;
|
||||||
|
|
||||||
@@ -906,10 +954,10 @@ async function switchSibling(messageId, direction) {
|
|||||||
const targetSibling = siblings[targetIdx];
|
const targetSibling = siblings[targetIdx];
|
||||||
|
|
||||||
// Update cursor to the target sibling (backend walks to leaf)
|
// Update cursor to the target sibling (backend walks to leaf)
|
||||||
const resp = await API.updateCursor(App.currentChatId, targetSibling.id);
|
const resp = await API.updateCursor(App.activeId, targetSibling.id);
|
||||||
|
|
||||||
// Update local state with the new path
|
// Update local state with the new path
|
||||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
const chat = App.getActiveChat();
|
||||||
if (chat && resp.path) {
|
if (chat && resp.path) {
|
||||||
chat.messages = resp.path.map(m => ({
|
chat.messages = resp.path.map(m => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
@@ -923,6 +971,10 @@ async function switchSibling(messageId, direction) {
|
|||||||
metadata: m.metadata || null,
|
metadata: m.metadata || null,
|
||||||
siblingCount: m.sibling_count || 1,
|
siblingCount: m.sibling_count || 1,
|
||||||
siblingIndex: m.sibling_index || 0,
|
siblingIndex: m.sibling_index || 0,
|
||||||
|
participant_type: m.participant_type || null,
|
||||||
|
participant_id: m.participant_id || null,
|
||||||
|
sender_name: m.sender_name || null,
|
||||||
|
sender_avatar: m.sender_avatar || null,
|
||||||
}));
|
}));
|
||||||
chat.messageCount = chat.messages.length;
|
chat.messageCount = chat.messages.length;
|
||||||
UI.renderMessages(chat.messages);
|
UI.renderMessages(chat.messages);
|
||||||
@@ -997,7 +1049,26 @@ function _initChatListeners() {
|
|||||||
// runs server-side and delivers the result via WS, not SSE.
|
// runs server-side and delivers the result via WS, not SSE.
|
||||||
Events.on('message.created', (payload) => {
|
Events.on('message.created', (payload) => {
|
||||||
if (!payload || !payload.channel_id) return;
|
if (!payload || !payload.channel_id) return;
|
||||||
if (payload.channel_id !== App.currentChatId) return;
|
|
||||||
|
// v0.23.2: If this channel isn't in our sidebar yet, fetch and add it
|
||||||
|
const knownChannel = (App.channels || []).some(c => c.id === payload.channel_id);
|
||||||
|
const knownChat = App.chats.some(c => c.id === payload.channel_id);
|
||||||
|
if (!knownChannel && !knownChat) {
|
||||||
|
// New channel we're a participant in — reload channels sidebar
|
||||||
|
if (typeof loadChannels === 'function') loadChannels();
|
||||||
|
UI.toast(`New message from ${payload.display_name || payload.user_id?.slice(0, 8) || 'someone'}`, 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a channel/DM we know but it's not the active one, bump unread
|
||||||
|
if (payload.channel_id !== App.activeId) {
|
||||||
|
const ch = (App.channels || []).find(c => c.id === payload.channel_id);
|
||||||
|
if (ch) {
|
||||||
|
ch.unread = (ch.unread || 0) + 1;
|
||||||
|
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const chat = App.chats.find(c => c.id === payload.channel_id);
|
const chat = App.chats.find(c => c.id === payload.channel_id);
|
||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
@@ -1021,16 +1092,18 @@ function _initChatListeners() {
|
|||||||
|
|
||||||
// Remove typing indicator and re-render
|
// Remove typing indicator and re-render
|
||||||
document.getElementById('typingIndicator')?.remove();
|
document.getElementById('typingIndicator')?.remove();
|
||||||
|
document.getElementById('userTypingIndicator')?.remove();
|
||||||
UI.renderMessages(chat.messages);
|
UI.renderMessages(chat.messages);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Typing indicators from persona chain activity
|
// Typing indicators from persona chain activity
|
||||||
Events.on('typing.start', (payload) => {
|
Events.on('typing.start', (payload) => {
|
||||||
if (!payload || payload.channel_id !== App.currentChatId) return;
|
if (!payload || payload.channel_id !== App.activeId) return;
|
||||||
if (payload.participant_type !== 'persona') return;
|
if (payload.participant_type !== 'persona') return;
|
||||||
|
|
||||||
// Show typing indicator with persona name
|
// Show typing indicator with persona name
|
||||||
document.getElementById('typingIndicator')?.remove();
|
document.getElementById('typingIndicator')?.remove();
|
||||||
|
document.getElementById('userTypingIndicator')?.remove();
|
||||||
const container = document.getElementById('chatMessages');
|
const container = document.getElementById('chatMessages');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const typing = document.createElement('div');
|
const typing = document.createElement('div');
|
||||||
@@ -1047,8 +1120,63 @@ function _initChatListeners() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Events.on('typing.stop', (payload) => {
|
Events.on('typing.stop', (payload) => {
|
||||||
if (!payload || payload.channel_id !== App.currentChatId) return;
|
if (!payload || payload.channel_id !== App.activeId) return;
|
||||||
document.getElementById('typingIndicator')?.remove();
|
document.getElementById('typingIndicator')?.remove();
|
||||||
|
document.getElementById('userTypingIndicator')?.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// v0.23.2: Human typing indicator from other participants
|
||||||
|
Events.on('typing.user', (payload) => {
|
||||||
|
if (!payload || payload.channel_id !== App.activeId) return;
|
||||||
|
if (payload.user_id === API.user?.id) return; // ignore self
|
||||||
|
|
||||||
|
// Show or refresh typing indicator
|
||||||
|
let typing = document.getElementById('userTypingIndicator');
|
||||||
|
if (!typing) {
|
||||||
|
const container = document.getElementById('chatMessages');
|
||||||
|
if (!container) return;
|
||||||
|
typing = document.createElement('div');
|
||||||
|
typing.id = 'userTypingIndicator';
|
||||||
|
typing.className = 'message other-user';
|
||||||
|
container.appendChild(typing);
|
||||||
|
}
|
||||||
|
const name = payload.display_name || 'Someone';
|
||||||
|
typing.innerHTML = `
|
||||||
|
<div class="msg-inner">
|
||||||
|
<div class="msg-avatar" style="background:rgba(45,212,191,0.12);color:#2dd4bf;">👤</div>
|
||||||
|
<div class="msg-body" style="background:var(--bg-surface);border:1px solid var(--border);border-radius:16px 16px 16px 4px;padding:8px 12px;">
|
||||||
|
<span style="color:#2dd4bf;font-size:12px;font-weight:600;">${esc(name)}</span>
|
||||||
|
<div class="typing-dots"><span></span><span></span><span></span></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
const container = document.getElementById('chatMessages');
|
||||||
|
if (container) container.scrollTop = container.scrollHeight;
|
||||||
|
|
||||||
|
// Auto-remove after 4s if no new event
|
||||||
|
clearTimeout(typing._timeout);
|
||||||
|
typing._timeout = setTimeout(() => typing.remove(), 4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// v0.23.2: User @mention notification
|
||||||
|
Events.on('user.mentioned', (payload) => {
|
||||||
|
if (!payload) return;
|
||||||
|
const channelId = payload.channel_id;
|
||||||
|
// Increment unread badge on the channel sidebar item
|
||||||
|
const ch = (App.channels || []).find(c => c.id === channelId);
|
||||||
|
if (ch) {
|
||||||
|
ch.unread = (ch.unread || 0) + 1;
|
||||||
|
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||||
|
}
|
||||||
|
// Toast with deep-link
|
||||||
|
const preview = payload.content || 'You were mentioned';
|
||||||
|
UI.toast(`@mention: ${preview}`, 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
// v0.23.2: Live presence updates for sidebar online dots
|
||||||
|
Events.on('user.presence', (payload) => {
|
||||||
|
if (!payload || !payload.user_id) return;
|
||||||
|
App.presence[payload.user_id] = payload.status || 'offline';
|
||||||
|
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close modals on overlay click
|
// Close modals on overlay click
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ const DebugLog = {
|
|||||||
if (typeof App !== 'undefined') {
|
if (typeof App !== 'undefined') {
|
||||||
snap.app = {
|
snap.app = {
|
||||||
chatCount: App.chats?.length || 0,
|
chatCount: App.chats?.length || 0,
|
||||||
currentChatId: App.currentChatId,
|
activeConversation: App.activeConversation,
|
||||||
modelCount: App.models?.length || 0,
|
modelCount: App.models?.length || 0,
|
||||||
isGenerating: App.isGenerating,
|
isGenerating: App.isGenerating,
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ async function stageFile(file) {
|
|||||||
_updateSendBlock();
|
_updateSendBlock();
|
||||||
|
|
||||||
// Ensure a channel exists (files are channel-scoped)
|
// Ensure a channel exists (files are channel-scoped)
|
||||||
if (!App.currentChatId) {
|
if (!App.activeId) {
|
||||||
try {
|
try {
|
||||||
const title = file.name.slice(0, 50);
|
const title = file.name.slice(0, 50);
|
||||||
const selectedId = UI.getModelValue();
|
const selectedId = UI.getModelValue();
|
||||||
@@ -93,7 +93,7 @@ async function stageFile(file) {
|
|||||||
model, messages: [], messageCount: 0, updatedAt: resp.updated_at,
|
model, messages: [], messageCount: 0, updatedAt: resp.updated_at,
|
||||||
};
|
};
|
||||||
App.chats.unshift(chat);
|
App.chats.unshift(chat);
|
||||||
App.currentChatId = chat.id;
|
App.setActive(chat.id, 'direct');
|
||||||
UI.renderChatList();
|
UI.renderChatList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
staged.status = 'error';
|
staged.status = 'error';
|
||||||
@@ -106,7 +106,7 @@ async function stageFile(file) {
|
|||||||
|
|
||||||
// Upload
|
// Upload
|
||||||
try {
|
try {
|
||||||
const result = await API.uploadFile(App.currentChatId, file);
|
const result = await API.uploadFile(App.activeId, file);
|
||||||
staged.serverId = result.id;
|
staged.serverId = result.id;
|
||||||
staged.status = _mapExtractionStatus(result.metadata?.extraction_status);
|
staged.status = _mapExtractionStatus(result.metadata?.extraction_status);
|
||||||
staged.extractionStatus = result.metadata?.extraction_status || null;
|
staged.extractionStatus = result.metadata?.extraction_status || null;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const KnowledgeUI = (() => {
|
|||||||
const popup = document.getElementById('kbPopup');
|
const popup = document.getElementById('kbPopup');
|
||||||
if (!popup) return;
|
if (!popup) return;
|
||||||
|
|
||||||
if (!App.currentChatId) {
|
if (!App.activeId) {
|
||||||
popup.innerHTML = '<div class="kb-popup-empty">Start a conversation first</div>';
|
popup.innerHTML = '<div class="kb-popup-empty">Start a conversation first</div>';
|
||||||
popup.classList.add('open');
|
popup.classList.add('open');
|
||||||
return;
|
return;
|
||||||
@@ -40,7 +40,7 @@ const KnowledgeUI = (() => {
|
|||||||
try {
|
try {
|
||||||
const [allResp, chResp] = await Promise.all([
|
const [allResp, chResp] = await Promise.all([
|
||||||
API.listKnowledgeBases(),
|
API.listKnowledgeBases(),
|
||||||
API.getChannelKBs(App.currentChatId),
|
API.getChannelKBs(App.activeId),
|
||||||
]);
|
]);
|
||||||
_allKBs = allResp.data || [];
|
_allKBs = allResp.data || [];
|
||||||
_channelKBs = chResp.data || [];
|
_channelKBs = chResp.data || [];
|
||||||
@@ -82,7 +82,7 @@ const KnowledgeUI = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function _toggleChannelKB(kbId) {
|
async function _toggleChannelKB(kbId) {
|
||||||
if (!App.currentChatId) return;
|
if (!App.activeId) return;
|
||||||
|
|
||||||
const linkedSet = new Set(_channelKBs.map(kb => kb.kb_id));
|
const linkedSet = new Set(_channelKBs.map(kb => kb.kb_id));
|
||||||
if (linkedSet.has(kbId)) {
|
if (linkedSet.has(kbId)) {
|
||||||
@@ -92,7 +92,7 @@ const KnowledgeUI = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await API.setChannelKBs(App.currentChatId, [...linkedSet]);
|
const resp = await API.setChannelKBs(App.activeId, [...linkedSet]);
|
||||||
_channelKBs = resp.data || [];
|
_channelKBs = resp.data || [];
|
||||||
const popup = document.getElementById('kbPopup');
|
const popup = document.getElementById('kbPopup');
|
||||||
if (popup) _renderChannelPopup(popup);
|
if (popup) _renderChannelPopup(popup);
|
||||||
@@ -506,9 +506,9 @@ const KnowledgeUI = (() => {
|
|||||||
/** Refresh channel KB state when switching chats. */
|
/** Refresh channel KB state when switching chats. */
|
||||||
async function onChatChanged() {
|
async function onChatChanged() {
|
||||||
_channelKBs = [];
|
_channelKBs = [];
|
||||||
if (App.currentChatId) {
|
if (App.activeId) {
|
||||||
try {
|
try {
|
||||||
const resp = await API.getChannelKBs(App.currentChatId);
|
const resp = await API.getChannelKBs(App.activeId);
|
||||||
_channelKBs = resp.data || [];
|
_channelKBs = resp.data || [];
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -596,7 +596,7 @@ async function _navigateDaily(offset) {
|
|||||||
// ── Save-to-Note (from chat messages) ──
|
// ── Save-to-Note (from chat messages) ──
|
||||||
|
|
||||||
async function saveMessageToNote(msgIndex) {
|
async function saveMessageToNote(msgIndex) {
|
||||||
const chat = App.chats?.find(c => c.id === App.currentChatId);
|
const chat = App.chats?.find(c => c.id === App.activeId);
|
||||||
const msg = chat?.messages?.[msgIndex];
|
const msg = chat?.messages?.[msgIndex];
|
||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
|
|
||||||
@@ -617,7 +617,7 @@ async function saveMessageToNote(msgIndex) {
|
|||||||
// Show save-to-note modal
|
// Show save-to-note modal
|
||||||
_showSaveToNoteModal({
|
_showSaveToNoteModal({
|
||||||
content,
|
content,
|
||||||
sourceChannelId: App.currentChatId || '',
|
sourceChannelId: App.activeId || '',
|
||||||
sourceMessageId: msg.id || '',
|
sourceMessageId: msg.id || '',
|
||||||
defaultTitle,
|
defaultTitle,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -299,6 +299,23 @@ function showChatContextMenu(e, chatId) {
|
|||||||
<span class="ctx-icon">📁</span> Set workspace…
|
<span class="ctx-icon">📁</span> Set workspace…
|
||||||
</button>`;
|
</button>`;
|
||||||
|
|
||||||
|
// v0.23.2: Folder options
|
||||||
|
const folders = App.folders || [];
|
||||||
|
if (folders.length > 0 || chat.folderId) {
|
||||||
|
items += '<div class="ctx-divider"></div>';
|
||||||
|
if (chat.folderId) {
|
||||||
|
items += `<button class="ctx-item" onclick="moveChatToFolder('${chatId}',null);dismissChatContextMenu()">
|
||||||
|
<span class="ctx-icon">↩</span> Remove from folder
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
folders.forEach(f => {
|
||||||
|
if (f.id === chat.folderId) return; // skip current
|
||||||
|
items += `<button class="ctx-item" onclick="moveChatToFolder('${chatId}','${f.id}');dismissChatContextMenu()">
|
||||||
|
<span class="ctx-icon">📂</span> ${esc(f.name)}
|
||||||
|
</button>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
menu.innerHTML = items;
|
menu.innerHTML = items;
|
||||||
|
|
||||||
// Position near cursor
|
// Position near cursor
|
||||||
@@ -341,7 +358,8 @@ function onChatDragStart(e, chatId) {
|
|||||||
|
|
||||||
function onChatDragEnd(e) {
|
function onChatDragEnd(e) {
|
||||||
e.target.classList.remove('dragging');
|
e.target.classList.remove('dragging');
|
||||||
document.querySelectorAll('.project-group.drag-over').forEach(el => el.classList.remove('drag-over'));
|
// Clear all drag-over highlights (projects, folders, section bodies, unfiled zone)
|
||||||
|
document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function onProjectDragOver(e) {
|
function onProjectDragOver(e) {
|
||||||
@@ -370,6 +388,64 @@ async function onRecentDrop(e) {
|
|||||||
await moveChatToProject(chatId, null);
|
await moveChatToProject(chatId, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.23.2: Folder drag-and-drop
|
||||||
|
|
||||||
|
async function onFolderDrop(e, folderId) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.currentTarget.classList.remove('drag-over');
|
||||||
|
const chatId = e.dataTransfer.getData('text/plain');
|
||||||
|
if (!chatId) return;
|
||||||
|
await moveChatToFolder(chatId, folderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onChatSectionDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.classList.remove('drag-over');
|
||||||
|
const chatId = e.dataTransfer.getData('text/plain');
|
||||||
|
if (!chatId) return;
|
||||||
|
const chat = App.chats.find(c => c.id === chatId);
|
||||||
|
if (!chat) return;
|
||||||
|
|
||||||
|
// Unfile from folder if filed
|
||||||
|
if (chat.folderId) {
|
||||||
|
await moveChatToFolder(chatId, null);
|
||||||
|
}
|
||||||
|
// Remove from project if in one
|
||||||
|
if (chat.projectId) {
|
||||||
|
await moveChatToProject(chatId, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveChatToFolder(chatId, folderId) {
|
||||||
|
try {
|
||||||
|
await API.updateChannel(chatId, { folder: folderId || '' });
|
||||||
|
const chat = App.chats.find(c => c.id === chatId);
|
||||||
|
if (chat) chat.folderId = folderId || null;
|
||||||
|
UI.renderChatList();
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to move chat: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUnfiledDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.currentTarget.classList.remove('drag-over');
|
||||||
|
const chatId = e.dataTransfer.getData('text/plain');
|
||||||
|
if (!chatId) return;
|
||||||
|
const chat = App.chats.find(c => c.id === chatId);
|
||||||
|
if (!chat) return;
|
||||||
|
// Unfile from folder
|
||||||
|
if (chat.folderId) {
|
||||||
|
await moveChatToFolder(chatId, null);
|
||||||
|
}
|
||||||
|
// Remove from project
|
||||||
|
if (chat.projectId) {
|
||||||
|
await moveChatToProject(chatId, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Project Header Menu ─────────────────────
|
// ── Project Header Menu ─────────────────────
|
||||||
|
|
||||||
function showProjectMenu(e, projectId) {
|
function showProjectMenu(e, projectId) {
|
||||||
@@ -1106,7 +1182,19 @@ async function loadFolders() {
|
|||||||
async function loadChannels() {
|
async function loadChannels() {
|
||||||
try {
|
try {
|
||||||
const resp = await API.listSidebarChannels();
|
const resp = await API.listSidebarChannels();
|
||||||
App.channels = (resp.channels || []).map(normalizeChannel);
|
App.channels = (resp.data || []).map(normalizeChannel);
|
||||||
|
|
||||||
|
// Query presence for DM partners
|
||||||
|
const dmPartnerIds = App.channels
|
||||||
|
.filter(c => c.type === 'dm' && c.dmPartnerId)
|
||||||
|
.map(c => c.dmPartnerId);
|
||||||
|
if (dmPartnerIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const presResp = await API._get(`/api/v1/presence?users=${dmPartnerIds.join(',')}`);
|
||||||
|
App.presence = presResp.presence || {};
|
||||||
|
} catch (_) { /* non-critical */ }
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[channels] load failed:', e.message);
|
console.warn('[channels] load failed:', e.message);
|
||||||
@@ -1127,27 +1215,117 @@ function normalizeChannel(ch) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.23.2: Load user list for @mention autocomplete and DM creation
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
const resp = await API._get('/api/v1/users/search?q=');
|
||||||
|
App.users = (resp.users || []).map(u => ({
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
displayName: u.display_name || u.username,
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[users] load failed:', e.message);
|
||||||
|
App.users = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function selectChannel(channelId) {
|
async function selectChannel(channelId) {
|
||||||
App.currentChannelId = channelId;
|
const ch = (App.channels || []).find(c => c.id === channelId);
|
||||||
App.currentChatId = null; // clear chat selection
|
App.setActive(channelId, ch?.type || 'channel');
|
||||||
if (typeof UI !== 'undefined') {
|
if (typeof UI !== 'undefined') {
|
||||||
UI.renderChannelsSection();
|
UI.renderChannelsSection();
|
||||||
UI.renderChatList();
|
UI.renderChatList();
|
||||||
}
|
}
|
||||||
|
// Ensure conversation object exists in App.chats for message caching
|
||||||
|
if (!App.chats.find(c => c.id === channelId)) {
|
||||||
|
App.chats.unshift({
|
||||||
|
id: channelId, title: ch?.name || 'Channel', type: ch?.type || 'channel',
|
||||||
|
model: '', messages: [], messageCount: 0, projectId: null,
|
||||||
|
workspace_id: null, updatedAt: ch?.updatedAt || new Date().toISOString(),
|
||||||
|
settings: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
// Load messages for this channel
|
// Load messages for this channel
|
||||||
try {
|
try {
|
||||||
|
const chat = App.chats.find(c => c.id === channelId);
|
||||||
const resp = await API._get(`/api/v1/channels/${channelId}/messages`);
|
const resp = await API._get(`/api/v1/channels/${channelId}/messages`);
|
||||||
const messages = resp.messages || [];
|
const messages = (resp.data || []).map(m => ({
|
||||||
if (typeof UI !== 'undefined') UI.renderMessages(messages);
|
id: m.id,
|
||||||
|
parent_id: m.parent_id || null,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
model: m.model || '',
|
||||||
|
modelName: '',
|
||||||
|
timestamp: m.created_at,
|
||||||
|
tool_calls: m.tool_calls || null,
|
||||||
|
metadata: m.metadata || null,
|
||||||
|
siblingCount: m.sibling_count || 1,
|
||||||
|
siblingIndex: m.sibling_index || 0,
|
||||||
|
participant_type: m.participant_type || null,
|
||||||
|
participant_id: m.participant_id || null,
|
||||||
|
sender_name: m.sender_name || null,
|
||||||
|
sender_avatar: m.sender_avatar || null,
|
||||||
|
}));
|
||||||
|
if (chat) chat.messages = messages;
|
||||||
|
if (typeof UI !== 'undefined') {
|
||||||
|
UI.renderMessages(messages);
|
||||||
|
UI.updateContextBanner();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[channel] message load failed:', e.message);
|
console.warn('[channel] message load failed:', e.message);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem('cs-active-channel', channelId);
|
sessionStorage.setItem('cs-active-conversation', JSON.stringify(App.activeConversation));
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
|
// v0.23.2: Mark as read and clear unread badge
|
||||||
|
const selCh = (App.channels || []).find(c => c.id === channelId);
|
||||||
|
if (selCh) selCh.unread = 0;
|
||||||
|
API.markRead(channelId).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function newChannelOrDM() {
|
async function newChannelOrDM() {
|
||||||
|
// Show choice: Channel or DM
|
||||||
|
const choice = await new Promise(resolve => {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay active';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal" style="width:340px;">
|
||||||
|
<div class="modal-header"><h3>New Conversation</h3></div>
|
||||||
|
<div class="modal-body" style="display:flex;flex-direction:column;gap:8px;padding:16px;">
|
||||||
|
<button class="btn btn-primary" id="_ncChannel">
|
||||||
|
<span style="margin-right:6px;">#</span> New Channel
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="_ncDM">
|
||||||
|
<span style="margin-right:6px;">@</span> New Direct Message
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="_ncGroup">
|
||||||
|
<span style="margin-right:6px;">👥</span> New Group Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
overlay.addEventListener('click', e => { if (e.target === overlay) { overlay.remove(); resolve(null); } });
|
||||||
|
overlay.querySelector('#_ncChannel').onclick = () => { overlay.remove(); resolve('channel'); };
|
||||||
|
overlay.querySelector('#_ncDM').onclick = () => { overlay.remove(); resolve('dm'); };
|
||||||
|
overlay.querySelector('#_ncGroup').onclick = () => { overlay.remove(); resolve('group'); };
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (choice === 'channel') {
|
||||||
|
await _createNewChannel();
|
||||||
|
} else if (choice === 'dm') {
|
||||||
|
await _createNewDM();
|
||||||
|
} else if (choice === 'group') {
|
||||||
|
if (typeof newGroupChat === 'function') {
|
||||||
|
await newGroupChat();
|
||||||
|
} else {
|
||||||
|
UI.toast('Group chat not available', 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _createNewChannel() {
|
||||||
const result = await _showCreationDialog({
|
const result = await _showCreationDialog({
|
||||||
title: 'New Channel',
|
title: 'New Channel',
|
||||||
placeholder: 'Channel name',
|
placeholder: 'Channel name',
|
||||||
@@ -1159,11 +1337,139 @@ async function newChannelOrDM() {
|
|||||||
const ch = normalizeChannel(raw);
|
const ch = normalizeChannel(raw);
|
||||||
App.channels = App.channels || [];
|
App.channels = App.channels || [];
|
||||||
App.channels.push(ch);
|
App.channels.push(ch);
|
||||||
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
// Also add to App.chats for message caching / send path
|
||||||
|
App.chats.unshift({
|
||||||
|
id: raw.id, title: raw.title || result.name, type: 'channel',
|
||||||
|
model: '', messages: [], messageCount: 0, projectId: raw.project_id || null,
|
||||||
|
workspace_id: raw.workspace_id || null, updatedAt: raw.updated_at,
|
||||||
|
settings: raw.settings || {},
|
||||||
|
});
|
||||||
|
App.setActive(raw.id, 'channel');
|
||||||
|
if (typeof UI !== 'undefined') {
|
||||||
|
UI.renderChannelsSection();
|
||||||
|
UI.renderChatList();
|
||||||
|
UI.updateContextBanner();
|
||||||
|
}
|
||||||
UI.toast(`#${result.name} created`, 'success');
|
UI.toast(`#${result.name} created`, 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
UI.toast('Failed to create channel', 'error');
|
UI.toast('Failed to create channel', 'error');
|
||||||
console.error('[newChannelOrDM]', e);
|
console.error('[newChannel]', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _createNewDM() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay active';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal" style="width:360px;">
|
||||||
|
<div class="modal-header"><h3>New Direct Message</h3></div>
|
||||||
|
<div class="modal-body" style="padding:16px;">
|
||||||
|
<input type="text" id="_dmSearchInput" class="input"
|
||||||
|
placeholder="Search users…" autocomplete="off"
|
||||||
|
style="width:100%;margin-bottom:8px;">
|
||||||
|
<div id="_dmResults" style="max-height:200px;overflow-y:auto;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn" id="_dmCancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const input = overlay.querySelector('#_dmSearchInput');
|
||||||
|
const results = overlay.querySelector('#_dmResults');
|
||||||
|
let debounce = null;
|
||||||
|
let selectedUser = null;
|
||||||
|
|
||||||
|
const close = () => { overlay.remove(); resolve(); };
|
||||||
|
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
|
||||||
|
overlay.querySelector('#_dmCancel').onclick = close;
|
||||||
|
|
||||||
|
async function search(q) {
|
||||||
|
try {
|
||||||
|
const resp = await API._get(`/api/v1/users/search?q=${encodeURIComponent(q)}`);
|
||||||
|
const users = resp.users || [];
|
||||||
|
if (users.length === 0) {
|
||||||
|
results.innerHTML = '<div style="padding:8px;color:var(--text-3);font-size:12px;">No users found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
results.innerHTML = users.map(u => `
|
||||||
|
<div class="dm-user-item" data-id="${u.id}" data-name="${esc(u.display_name || u.username)}"
|
||||||
|
style="padding:8px 10px;cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:8px;">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;">
|
||||||
|
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/>
|
||||||
|
</svg>
|
||||||
|
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||||
|
<b>${esc(u.display_name || u.username)}</b>
|
||||||
|
${u.display_name ? `<span style="color:var(--text-3);font-size:11px;margin-left:4px;">@${esc(u.username)}</span>` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
results.querySelectorAll('.dm-user-item').forEach(el => {
|
||||||
|
el.onmouseenter = () => el.style.background = 'var(--bg-hover)';
|
||||||
|
el.onmouseleave = () => el.style.background = '';
|
||||||
|
el.onclick = async () => {
|
||||||
|
const userId = el.dataset.id;
|
||||||
|
const userName = el.dataset.name;
|
||||||
|
overlay.remove();
|
||||||
|
await _finishDMCreation(userId, userName);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
results.innerHTML = `<div style="padding:8px;color:var(--text-3);font-size:12px;">Search failed: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
clearTimeout(debounce);
|
||||||
|
const q = input.value.trim();
|
||||||
|
if (q.length === 0) {
|
||||||
|
// Show all users when empty
|
||||||
|
debounce = setTimeout(() => search(''), 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debounce = setTimeout(() => search(q), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
input.focus();
|
||||||
|
// Show all users immediately
|
||||||
|
search('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _finishDMCreation(targetId, targetName) {
|
||||||
|
try {
|
||||||
|
const raw = await API._post('/api/v1/channels', {
|
||||||
|
title: targetName,
|
||||||
|
type: 'dm',
|
||||||
|
participants: [targetId],
|
||||||
|
});
|
||||||
|
const ch = normalizeChannel(raw);
|
||||||
|
// Avoid duplicate in sidebar list (dedup guard may return existing)
|
||||||
|
if (!(App.channels || []).find(c => c.id === ch.id)) {
|
||||||
|
App.channels = App.channels || [];
|
||||||
|
App.channels.push(ch);
|
||||||
|
}
|
||||||
|
if (!App.chats.find(c => c.id === raw.id)) {
|
||||||
|
App.chats.unshift({
|
||||||
|
id: raw.id, title: raw.title || targetName,
|
||||||
|
type: 'dm', model: '', messages: [], messageCount: 0,
|
||||||
|
projectId: null, workspace_id: null, updatedAt: raw.updated_at,
|
||||||
|
settings: raw.settings || {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
App.setActive(raw.id, 'dm');
|
||||||
|
if (typeof UI !== 'undefined') {
|
||||||
|
UI.renderChannelsSection();
|
||||||
|
UI.renderChatList();
|
||||||
|
UI.updateContextBanner();
|
||||||
|
}
|
||||||
|
UI.toast(`DM with ${targetName}`, 'success');
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to create DM: ' + e.message, 'error');
|
||||||
|
console.error('[newDM]', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1191,6 +1497,162 @@ async function newFolder() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Channel CRUD (v0.23.2) ─────────────────────
|
||||||
|
|
||||||
|
function showChannelContextMenu(e, channelId) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dismissChatContextMenu();
|
||||||
|
|
||||||
|
const ch = (App.channels || []).find(c => c.id === channelId);
|
||||||
|
if (!ch) return;
|
||||||
|
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'project-ctx-menu';
|
||||||
|
|
||||||
|
let items = '';
|
||||||
|
items += `<button class="ctx-item" onclick="openParticipantsPanel('${channelId}');dismissChatContextMenu()">
|
||||||
|
<span class="ctx-icon">👥</span> Members
|
||||||
|
</button>`;
|
||||||
|
items += `<button class="ctx-item" onclick="renameChannel('${channelId}');dismissChatContextMenu()">
|
||||||
|
<span class="ctx-icon">✏</span> Rename
|
||||||
|
</button>`;
|
||||||
|
if (ch.type === 'channel' || ch.type === 'group') {
|
||||||
|
items += `<button class="ctx-item" onclick="editChannelTopic('${channelId}');dismissChatContextMenu()">
|
||||||
|
<span class="ctx-icon">💬</span> Set topic
|
||||||
|
</button>`;
|
||||||
|
items += `<button class="ctx-item" onclick="cycleChannelAiMode('${channelId}');dismissChatContextMenu()">
|
||||||
|
<span class="ctx-icon">🤖</span> AI mode: ${esc(ch.aiMode || 'auto')}
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
items += '<div class="ctx-divider"></div>';
|
||||||
|
items += `<button class="ctx-item" onclick="archiveChannel('${channelId}');dismissChatContextMenu()">
|
||||||
|
<span class="ctx-icon">📦</span> Archive
|
||||||
|
</button>`;
|
||||||
|
// Delete gated by retention mode (default=flexible allows delete)
|
||||||
|
const retentionMode = App.policies?.channel_retention_mode || 'flexible';
|
||||||
|
if (retentionMode === 'flexible') {
|
||||||
|
items += `<button class="ctx-item ctx-danger" onclick="deleteChannel('${channelId}');dismissChatContextMenu()">
|
||||||
|
<span class="ctx-icon">🗑</span> Delete
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
menu.innerHTML = items;
|
||||||
|
menu.style.left = Math.min(e.clientX, window.innerWidth - 180) + 'px';
|
||||||
|
menu.style.top = Math.min(e.clientY, window.innerHeight - 150) + 'px';
|
||||||
|
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
_ctxMenu = menu;
|
||||||
|
setTimeout(() => document.addEventListener('click', dismissChatContextMenu, { once: true }), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameChannel(channelId) {
|
||||||
|
const ch = (App.channels || []).find(c => c.id === channelId);
|
||||||
|
if (!ch) return;
|
||||||
|
const result = await _showCreationDialog({
|
||||||
|
title: 'Rename Channel',
|
||||||
|
placeholder: 'Channel name',
|
||||||
|
withColor: false,
|
||||||
|
defaultValue: ch.name,
|
||||||
|
confirmLabel: 'Rename',
|
||||||
|
});
|
||||||
|
if (!result) return;
|
||||||
|
try {
|
||||||
|
await API.updateChannel(channelId, { title: result.name.trim() });
|
||||||
|
ch.name = result.name.trim();
|
||||||
|
// Also update in App.chats if present
|
||||||
|
const chat = App.chats.find(c => c.id === channelId);
|
||||||
|
if (chat) chat.title = result.name.trim();
|
||||||
|
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||||
|
UI.toast('Channel renamed', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to rename channel', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editChannelTopic(channelId) {
|
||||||
|
const ch = (App.channels || []).find(c => c.id === channelId);
|
||||||
|
if (!ch) return;
|
||||||
|
const result = await _showCreationDialog({
|
||||||
|
title: 'Set Topic',
|
||||||
|
placeholder: 'Channel topic (optional)',
|
||||||
|
withColor: false,
|
||||||
|
defaultValue: ch.topic || '',
|
||||||
|
confirmLabel: 'Save',
|
||||||
|
});
|
||||||
|
if (!result) return;
|
||||||
|
try {
|
||||||
|
// Topic is now a proper field on updateChannelRequest (v0.23.2)
|
||||||
|
await API._put(`/api/v1/channels/${channelId}`, { topic: result.name.trim() });
|
||||||
|
ch.topic = result.name.trim();
|
||||||
|
if (typeof UI !== 'undefined') UI.updateContextBanner();
|
||||||
|
UI.toast('Topic updated', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to set topic', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteChannel(channelId) {
|
||||||
|
if (!await showConfirm('Delete this channel and all its messages?')) return;
|
||||||
|
try {
|
||||||
|
await API.deleteChannel(channelId);
|
||||||
|
App.channels = (App.channels || []).filter(c => c.id !== channelId);
|
||||||
|
App.chats = App.chats.filter(c => c.id !== channelId);
|
||||||
|
if (App.activeId === channelId) {
|
||||||
|
App.setActive(null);
|
||||||
|
UI.showEmptyState();
|
||||||
|
}
|
||||||
|
if (typeof UI !== 'undefined') {
|
||||||
|
UI.renderChannelsSection();
|
||||||
|
UI.renderChatList();
|
||||||
|
UI.updateContextBanner();
|
||||||
|
}
|
||||||
|
UI.toast('Channel deleted', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to delete: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveChannel(channelId) {
|
||||||
|
try {
|
||||||
|
await API.updateChannel(channelId, { is_archived: true });
|
||||||
|
// Remove from sidebar (archived channels are hidden)
|
||||||
|
App.channels = (App.channels || []).filter(c => c.id !== channelId);
|
||||||
|
App.chats = App.chats.filter(c => c.id !== channelId);
|
||||||
|
if (App.activeId === channelId) {
|
||||||
|
App.setActive(null);
|
||||||
|
UI.showEmptyState();
|
||||||
|
}
|
||||||
|
if (typeof UI !== 'undefined') {
|
||||||
|
UI.renderChannelsSection();
|
||||||
|
UI.renderChatList();
|
||||||
|
UI.updateContextBanner();
|
||||||
|
}
|
||||||
|
UI.toast('Channel archived', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to archive: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.23.2: Cycle ai_mode on click: auto → mention_only → off → auto
|
||||||
|
async function cycleChannelAiMode(channelId) {
|
||||||
|
const ch = (App.channels || []).find(c => c.id === channelId);
|
||||||
|
if (!ch) return;
|
||||||
|
const order = ['auto', 'mention_only', 'off'];
|
||||||
|
const idx = order.indexOf(ch.aiMode || 'auto');
|
||||||
|
const next = order[(idx + 1) % order.length];
|
||||||
|
try {
|
||||||
|
await API.updateChannel(channelId, { ai_mode: next });
|
||||||
|
ch.aiMode = next;
|
||||||
|
if (typeof UI !== 'undefined') UI.updateContextBanner();
|
||||||
|
UI.toast(`AI mode: ${next === 'mention_only' ? '@mention only' : next}`, 'success');
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to change AI mode: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Folder Menu + CRUD ─────────────────────────
|
||||||
|
|
||||||
function showFolderMenu(e, folderId) {
|
function showFolderMenu(e, folderId) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -1238,13 +1700,69 @@ async function renameFolder(folderId) {
|
|||||||
async function deleteFolder(folderId) {
|
async function deleteFolder(folderId) {
|
||||||
const folder = (App.folders || []).find(f => f.id === folderId);
|
const folder = (App.folders || []).find(f => f.id === folderId);
|
||||||
if (!folder) return;
|
if (!folder) return;
|
||||||
// Move any chats in this folder back to unfiled before deleting
|
|
||||||
(App.chats || []).forEach(c => { if (c.folderId === folderId) c.folderId = null; });
|
const chatsInFolder = (App.chats || []).filter(c => c.folderId === folderId);
|
||||||
|
|
||||||
|
// If folder has chats, ask whether to preserve or delete them
|
||||||
|
let preserveChats = true;
|
||||||
|
if (chatsInFolder.length > 0) {
|
||||||
|
const choice = await new Promise(resolve => {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay active';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal" style="width:380px;">
|
||||||
|
<div class="modal-header"><h3>Delete folder "${esc(folder.name)}"</h3></div>
|
||||||
|
<div class="modal-body" style="padding:16px;font-size:13px;color:var(--text-2);">
|
||||||
|
This folder contains <b>${chatsInFolder.length} chat${chatsInFolder.length > 1 ? 's' : ''}</b>.
|
||||||
|
What should happen to them?
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="display:flex;gap:8px;justify-content:flex-end;">
|
||||||
|
<button class="btn" id="_dfCancel">Cancel</button>
|
||||||
|
<button class="btn btn-danger" id="_dfDeleteAll">Delete chats too</button>
|
||||||
|
<button class="btn btn-primary" id="_dfPreserve">Keep chats</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
overlay.addEventListener('click', e => { if (e.target === overlay) { overlay.remove(); resolve(null); } });
|
||||||
|
overlay.querySelector('#_dfCancel').onclick = () => { overlay.remove(); resolve(null); };
|
||||||
|
overlay.querySelector('#_dfDeleteAll').onclick = () => { overlay.remove(); resolve('delete'); };
|
||||||
|
overlay.querySelector('#_dfPreserve').onclick = () => { overlay.remove(); resolve('preserve'); };
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!choice) return; // cancelled
|
||||||
|
preserveChats = choice === 'preserve';
|
||||||
|
} else {
|
||||||
|
// Empty folder — just confirm
|
||||||
|
if (!await showConfirm(`Delete folder "${folder.name}"?`)) return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (preserveChats) {
|
||||||
|
// Unfile chats before deleting the folder
|
||||||
|
for (const c of chatsInFolder) {
|
||||||
|
c.folderId = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Delete the chats in the folder
|
||||||
|
for (const c of chatsInFolder) {
|
||||||
|
try {
|
||||||
|
await API.deleteChannel(c.id);
|
||||||
|
// Clean up active state if deleting the active chat
|
||||||
|
if (App.activeId === c.id) {
|
||||||
|
App.setActive(null);
|
||||||
|
}
|
||||||
|
} catch (_) { /* best effort */ }
|
||||||
|
}
|
||||||
|
App.chats = App.chats.filter(c => c.folderId !== folderId);
|
||||||
|
}
|
||||||
|
|
||||||
await API.deleteFolder(folderId);
|
await API.deleteFolder(folderId);
|
||||||
App.folders = App.folders.filter(f => f.id !== folderId);
|
App.folders = App.folders.filter(f => f.id !== folderId);
|
||||||
if (typeof UI !== 'undefined') UI.renderChatList();
|
if (typeof UI !== 'undefined') {
|
||||||
UI.toast('Folder deleted', 'success');
|
UI.renderChatList();
|
||||||
|
if (!preserveChats && !App.activeId) UI.showEmptyState();
|
||||||
|
}
|
||||||
|
UI.toast(preserveChats ? 'Folder deleted, chats preserved' : 'Folder and chats deleted', 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
UI.toast('Failed to delete folder', 'error');
|
UI.toast('Failed to delete folder', 'error');
|
||||||
console.error('[deleteFolder]', e);
|
console.error('[deleteFolder]', e);
|
||||||
@@ -1255,11 +1773,172 @@ async function deleteFolder(folderId) {
|
|||||||
|
|
||||||
(function startPresenceHeartbeat() {
|
(function startPresenceHeartbeat() {
|
||||||
const INTERVAL = 30000; // 30s
|
const INTERVAL = 30000; // 30s
|
||||||
|
let timer = null;
|
||||||
async function beat() {
|
async function beat() {
|
||||||
if (!API.isAuthed) return;
|
if (!API.isAuthed) return;
|
||||||
try {
|
try {
|
||||||
await API.presenceHeartbeat();
|
await API.presenceHeartbeat();
|
||||||
} catch (_) { /* non-critical */ }
|
} catch (_) { /* non-critical */ }
|
||||||
}
|
}
|
||||||
setInterval(beat, INTERVAL);
|
function start() {
|
||||||
|
if (timer) return;
|
||||||
|
beat(); // immediate on resume
|
||||||
|
timer = setInterval(beat, INTERVAL);
|
||||||
|
}
|
||||||
|
function stop() {
|
||||||
|
if (timer) { clearInterval(timer); timer = null; }
|
||||||
|
}
|
||||||
|
start();
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) stop(); else start();
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// ── Participants Panel (v0.23.2) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async function openParticipantsPanel(channelId) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay active';
|
||||||
|
overlay.id = '_participantsOverlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal" style="width:440px;max-height:80vh;display:flex;flex-direction:column;">
|
||||||
|
<div class="modal-header"><h3>Channel Participants</h3></div>
|
||||||
|
<div class="modal-body" id="_participantsList" style="padding:12px;overflow-y:auto;flex:1;">
|
||||||
|
<div class="loading">Loading…</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:12px;border-top:1px solid var(--border);display:flex;gap:8px;">
|
||||||
|
<button class="btn btn-primary" id="_addUserBtn" style="flex:1;">+ Add User</button>
|
||||||
|
<button class="btn" id="_addPersonaBtn" style="flex:1;">+ Add Persona</button>
|
||||||
|
</div>
|
||||||
|
<div style="padding:0 12px 12px;">
|
||||||
|
<button class="btn" style="width:100%;" id="_closeParticipants">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
|
||||||
|
overlay.querySelector('#_closeParticipants').onclick = () => overlay.remove();
|
||||||
|
overlay.querySelector('#_addUserBtn').onclick = () => _showAddParticipantSearch(channelId, 'user');
|
||||||
|
overlay.querySelector('#_addPersonaBtn').onclick = () => _showAddParticipantSearch(channelId, 'persona');
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
await _refreshParticipantsList(channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _refreshParticipantsList(channelId) {
|
||||||
|
const el = document.getElementById('_participantsList');
|
||||||
|
if (!el) return;
|
||||||
|
try {
|
||||||
|
const resp = await API._get(`/api/v1/channels/${channelId}/participants`);
|
||||||
|
const parts = resp.participants || [];
|
||||||
|
if (parts.length === 0) {
|
||||||
|
el.innerHTML = '<div class="empty-hint">No participants yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = parts.map(p => {
|
||||||
|
const icon = p.participant_type === 'persona' ? '⚡' : '👤';
|
||||||
|
// Resolve name: display_name from record, or look up in App state
|
||||||
|
let name = p.display_name;
|
||||||
|
if (!name && p.participant_type === 'user') {
|
||||||
|
const u = (App.users || []).find(u => u.id === p.participant_id);
|
||||||
|
name = u?.displayName || u?.username || null;
|
||||||
|
// Also check current user
|
||||||
|
if (!name && API.user && API.user.id === p.participant_id) {
|
||||||
|
name = API.user.display_name || API.user.username;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!name && p.participant_type === 'persona') {
|
||||||
|
const m = (App.models || []).find(m => m.personaId === p.participant_id);
|
||||||
|
name = m?.name || null;
|
||||||
|
}
|
||||||
|
if (!name) name = p.participant_id.slice(0, 8) + '…';
|
||||||
|
const role = p.role || 'member';
|
||||||
|
return `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border-faint);">
|
||||||
|
<span style="font-size:16px;">${icon}</span>
|
||||||
|
<span style="flex:1;font-size:13px;">${esc(name)}</span>
|
||||||
|
<span style="font-size:11px;color:var(--text-3);">${esc(p.participant_type)} · ${esc(role)}</span>
|
||||||
|
${role !== 'owner' ? `<button class="btn-small" style="color:var(--danger);"
|
||||||
|
onclick="_removeParticipant('${channelId}','${p.id}')">✕</button>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _showAddParticipantSearch(channelId, pType) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay active';
|
||||||
|
overlay.style.zIndex = '10001';
|
||||||
|
const label = pType === 'persona' ? 'Persona' : 'User';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal" style="width:360px;">
|
||||||
|
<div class="modal-header"><h3>Add ${label}</h3></div>
|
||||||
|
<div class="modal-body" style="padding:12px;">
|
||||||
|
<input class="input-full" id="_addPartSearch" placeholder="Search ${label.toLowerCase()}s…" autocomplete="off">
|
||||||
|
<div id="_addPartResults" style="margin-top:8px;max-height:200px;overflow-y:auto;"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const input = overlay.querySelector('#_addPartSearch');
|
||||||
|
const results = overlay.querySelector('#_addPartResults');
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
let debounce = null;
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
clearTimeout(debounce);
|
||||||
|
debounce = setTimeout(async () => {
|
||||||
|
const q = input.value.trim();
|
||||||
|
if (q.length < 1) { results.innerHTML = ''; return; }
|
||||||
|
try {
|
||||||
|
let items = [];
|
||||||
|
if (pType === 'user') {
|
||||||
|
const resp = await API._get(`/api/v1/users/search?q=${encodeURIComponent(q)}`);
|
||||||
|
items = (resp.users || []).map(u => ({ id: u.id, name: u.display_name || u.username }));
|
||||||
|
} else {
|
||||||
|
// Search personas from App.models
|
||||||
|
items = (App.models || [])
|
||||||
|
.filter(m => m.isPersona && (m.name || '').toLowerCase().includes(q.toLowerCase()))
|
||||||
|
.map(m => ({ id: m.personaId, name: m.name }));
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
results.innerHTML = '<div class="empty-hint" style="font-size:12px;">No matches</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
results.innerHTML = items.slice(0, 10).map(item =>
|
||||||
|
`<div style="padding:6px 8px;cursor:pointer;border-radius:4px;font-size:13px;"
|
||||||
|
class="hover-bg"
|
||||||
|
onclick="_addParticipant('${channelId}','${pType}','${item.id}');this.closest('.modal-overlay').remove();">
|
||||||
|
${pType === 'persona' ? '⚡' : '👤'} ${esc(item.name)}
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
} catch (_) {
|
||||||
|
results.innerHTML = '<div class="empty-hint" style="font-size:12px;">Search failed</div>';
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _addParticipant(channelId, pType, participantId) {
|
||||||
|
try {
|
||||||
|
await API._post(`/api/v1/channels/${channelId}/participants`, {
|
||||||
|
participant_type: pType,
|
||||||
|
participant_id: participantId,
|
||||||
|
role: 'member',
|
||||||
|
});
|
||||||
|
UI.toast(`${pType === 'persona' ? 'Persona' : 'User'} added`, 'success');
|
||||||
|
await _refreshParticipantsList(channelId);
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to add: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _removeParticipant(channelId, recordId) {
|
||||||
|
try {
|
||||||
|
await API._del(`/api/v1/channels/${channelId}/participants/${recordId}`);
|
||||||
|
UI.toast('Participant removed', 'success');
|
||||||
|
await _refreshParticipantsList(channelId);
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Failed to remove: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ function updateInputTokens() {
|
|||||||
const budget = Tokens.getContextBudget();
|
const budget = Tokens.getContextBudget();
|
||||||
if (budget.maxContext > 0) {
|
if (budget.maxContext > 0) {
|
||||||
// Show relative to available context
|
// Show relative to available context
|
||||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
const chat = App.chats.find(c => c.id === App.activeId);
|
||||||
const convTokens = Tokens.estimateConversation(chat?.messages || [], App.settings.systemPrompt);
|
const convTokens = Tokens.estimateConversation(chat?.messages || [], App.settings.systemPrompt);
|
||||||
// Include staged file estimates
|
// Include staged file estimates
|
||||||
const fileTokens = Tokens.estimateFiles(
|
const fileTokens = Tokens.estimateFiles(
|
||||||
@@ -114,7 +114,7 @@ function updateContextWarning() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
const chat = App.chats.find(c => c.id === App.activeId);
|
||||||
if (!chat || !chat.messages?.length) {
|
if (!chat || !chat.messages?.length) {
|
||||||
warning.style.display = 'none';
|
warning.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const ADMIN_LOADERS = {
|
|||||||
usage: () => UI.loadAdminUsage(),
|
usage: () => UI.loadAdminUsage(),
|
||||||
audit: () => UI.loadAuditLog(),
|
audit: () => UI.loadAuditLog(),
|
||||||
stats: () => UI.loadAdminStats(),
|
stats: () => UI.loadAdminStats(),
|
||||||
|
channels: () => UI.loadAdminChannels(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find which category a section belongs to
|
// Find which category a section belongs to
|
||||||
@@ -1447,4 +1448,55 @@ Object.assign(UI, {
|
|||||||
prev.style.color = fg;
|
prev.style.color = fg;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Admin Channels (v0.23.2) ──────────────
|
||||||
|
|
||||||
|
async loadAdminChannels() {
|
||||||
|
const list = document.getElementById('adminArchivedList');
|
||||||
|
const countEl = document.getElementById('adminArchivedCount');
|
||||||
|
const modeEl = document.getElementById('adminRetentionMode');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await API._get('/api/v1/admin/channels/archived');
|
||||||
|
const channels = resp.channels || [];
|
||||||
|
if (countEl) countEl.textContent = channels.length;
|
||||||
|
if (modeEl) modeEl.textContent = resp.retention_mode || 'flexible';
|
||||||
|
|
||||||
|
if (channels.length === 0) {
|
||||||
|
list.innerHTML = '<div class="empty-hint">No archived channels</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<table class="admin-table"><thead><tr>' +
|
||||||
|
'<th>Title</th><th>Type</th><th>Owner</th><th>Messages</th><th>Archived</th><th></th>' +
|
||||||
|
'</tr></thead><tbody>';
|
||||||
|
for (const ch of channels) {
|
||||||
|
const age = _relativeTime(ch.updated_at);
|
||||||
|
html += `<tr>
|
||||||
|
<td>${esc(ch.title || 'Untitled')}</td>
|
||||||
|
<td>${esc(ch.type)}</td>
|
||||||
|
<td>${esc(ch.owner_name)}</td>
|
||||||
|
<td>${ch.message_count}</td>
|
||||||
|
<td>${age}</td>
|
||||||
|
<td><button class="btn-small btn-danger" onclick="UI._purgeChannel('${ch.id}')">Purge</button></td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
html += '</tbody></table>';
|
||||||
|
list.innerHTML = html;
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async _purgeChannel(channelId) {
|
||||||
|
if (!await showConfirm('Permanently delete this archived channel and all its messages? This cannot be undone.')) return;
|
||||||
|
try {
|
||||||
|
await API._del(`/api/v1/admin/channels/${channelId}/purge`);
|
||||||
|
UI.toast('Channel purged', 'success');
|
||||||
|
UI.loadAdminChannels(); // refresh list
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Purge failed: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function avatarHTML(role, avatarDataURI) {
|
|||||||
if (avatarDataURI) {
|
if (avatarDataURI) {
|
||||||
return `<img src="${avatarDataURI}" alt="" class="msg-avatar-img">`;
|
return `<img src="${avatarDataURI}" alt="" class="msg-avatar-img">`;
|
||||||
}
|
}
|
||||||
return role === 'user' ? '👤' : '🤖';
|
return role === 'user' ? '👤' : role === 'other-user' ? '👤' : '🤖';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the current model/persona avatar for assistant messages.
|
// Look up the current model/persona avatar for assistant messages.
|
||||||
@@ -394,7 +394,7 @@ const UI = {
|
|||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
channels.forEach(ch => {
|
channels.forEach(ch => {
|
||||||
const isActive = App.currentChannelId === ch.id;
|
const isActive = App.activeId === ch.id;
|
||||||
const isDM = ch.type === 'dm';
|
const isDM = ch.type === 'dm';
|
||||||
const isChannel = ch.type === 'channel';
|
const isChannel = ch.type === 'channel';
|
||||||
const online = App.presence?.[ch.dmPartnerId] === 'online';
|
const online = App.presence?.[ch.dmPartnerId] === 'online';
|
||||||
@@ -413,9 +413,11 @@ const UI = {
|
|||||||
|
|
||||||
html += `<div class="sb-channel-item${isActive ? ' active' : ''}"
|
html += `<div class="sb-channel-item${isActive ? ' active' : ''}"
|
||||||
onclick="selectChannel('${ch.id}')"
|
onclick="selectChannel('${ch.id}')"
|
||||||
|
oncontextmenu="showChannelContextMenu(event,'${ch.id}')"
|
||||||
title="${esc(ch.name)}">
|
title="${esc(ch.name)}">
|
||||||
<span class="sb-ch-icon">${icon}</span>
|
<span class="sb-ch-icon">${icon}</span>
|
||||||
${!collapsed ? `<span class="sb-ch-name">${esc(ch.name)}</span>${onlineDot}${unreadBadge}` : unreadBadge}
|
${!collapsed ? `<span class="sb-ch-name">${esc(ch.name)}</span>${onlineDot}${unreadBadge}
|
||||||
|
<button class="sb-ch-menu" onclick="event.stopPropagation();showChannelContextMenu(event,'${ch.id}')" title="Options">⋯</button>` : unreadBadge}
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -470,18 +472,21 @@ const UI = {
|
|||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
// Folder groups — always render, even when empty
|
// Folder groups — always render, even when empty (with drag handlers)
|
||||||
folders.forEach(f => {
|
folders.forEach(f => {
|
||||||
const items = folderMap[f.id] || [];
|
const items = folderMap[f.id] || [];
|
||||||
const isOpen = !App.collapsedFolders?.[f.id];
|
const isOpen = !App.collapsedFolders?.[f.id];
|
||||||
html += `<div class="sb-folder-group">
|
html += `<div class="sb-folder-group"
|
||||||
|
ondragover="event.preventDefault();event.dataTransfer.dropEffect='move';this.classList.add('drag-over')"
|
||||||
|
ondragleave="this.classList.remove('drag-over')"
|
||||||
|
ondrop="onFolderDrop(event,'${f.id}')">
|
||||||
<div class="sb-folder-header" onclick="UI.toggleFolder('${f.id}')">
|
<div class="sb-folder-header" onclick="UI.toggleFolder('${f.id}')">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
|
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="sb-folder-name">${esc(f.name)}</span>
|
<span class="sb-folder-name">${esc(f.name)}</span>
|
||||||
<span class="sb-folder-arrow">${isOpen ? '▾' : '▸'}</span>
|
|
||||||
<button class="sb-folder-menu" onclick="event.stopPropagation();showFolderMenu(event,'${f.id}')" title="Folder options">⋯</button>
|
<button class="sb-folder-menu" onclick="event.stopPropagation();showFolderMenu(event,'${f.id}')" title="Folder options">⋯</button>
|
||||||
|
<span class="sb-folder-arrow">${isOpen ? '▾' : '▸'}</span>
|
||||||
</div>
|
</div>
|
||||||
${isOpen
|
${isOpen
|
||||||
? (items.length > 0
|
? (items.length > 0
|
||||||
@@ -503,6 +508,17 @@ const UI = {
|
|||||||
else groups.older.push(c);
|
else groups.older.push(c);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// When folders exist, wrap unfiled chats in a drop zone
|
||||||
|
if (folders.length > 0) {
|
||||||
|
html += `<div class="sb-unfiled-zone"
|
||||||
|
ondragover="event.preventDefault();event.dataTransfer.dropEffect='move';this.classList.add('drag-over')"
|
||||||
|
ondragleave="this.classList.remove('drag-over')"
|
||||||
|
ondrop="onUnfiledDrop(event)">`;
|
||||||
|
if (unfiled.length === 0) {
|
||||||
|
html += '<div class="sb-unfiled-hint">Drop here to unfile</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const renderTimeGroup = (label, items) => {
|
const renderTimeGroup = (label, items) => {
|
||||||
if (!items.length) return;
|
if (!items.length) return;
|
||||||
html += `<div class="chat-group-label">${label}</div>`;
|
html += `<div class="chat-group-label">${label}</div>`;
|
||||||
@@ -514,6 +530,10 @@ const UI = {
|
|||||||
renderTimeGroup('Previous 7 days', groups.week);
|
renderTimeGroup('Previous 7 days', groups.week);
|
||||||
renderTimeGroup('Older', groups.older);
|
renderTimeGroup('Older', groups.older);
|
||||||
|
|
||||||
|
if (folders.length > 0) {
|
||||||
|
html += '</div>'; // close .sb-unfiled-zone
|
||||||
|
}
|
||||||
|
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
|
|
||||||
// Always keep projects section in sync
|
// Always keep projects section in sync
|
||||||
@@ -528,7 +548,7 @@ const UI = {
|
|||||||
|
|
||||||
_chatItemHTML(c, indented = false) {
|
_chatItemHTML(c, indented = false) {
|
||||||
const time = _relativeTime(c.updatedAt);
|
const time = _relativeTime(c.updatedAt);
|
||||||
const active = c.id === App.currentChatId ? ' active' : '';
|
const active = c.id === App.activeId ? ' active' : '';
|
||||||
const indent = indented ? ' indented' : '';
|
const indent = indented ? ' indented' : '';
|
||||||
const typeIcon = c.type === 'group'
|
const typeIcon = c.type === 'group'
|
||||||
? '<span class="chat-type-icon" title="Group Chat">👥</span> '
|
? '<span class="chat-type-icon" title="Group Chat">👥</span> '
|
||||||
@@ -617,19 +637,40 @@ const UI = {
|
|||||||
// Skip summary messages in normal rendering — they get _summaryHTML
|
// Skip summary messages in normal rendering — they get _summaryHTML
|
||||||
if (_isSummaryMessage(msg)) return '';
|
if (_isSummaryMessage(msg)) return '';
|
||||||
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||||||
let assistantLabel = 'Assistant';
|
|
||||||
if (!isUser) {
|
// v0.23.2: Sender attribution — distinguish self from other humans
|
||||||
// Use stored modelName, or resolve from channel roster, or model list, or fall back
|
const isSelf = !isUser ? false
|
||||||
if (msg.modelName) {
|
: (!msg.participant_id || msg.participant_id === API.user?.id);
|
||||||
assistantLabel = msg.modelName;
|
const isOtherUser = isUser && !isSelf;
|
||||||
|
|
||||||
|
let senderLabel = 'You';
|
||||||
|
let senderAvatarURI = API.user?.avatar || null;
|
||||||
|
|
||||||
|
if (isOtherUser) {
|
||||||
|
senderLabel = msg.sender_name || 'User';
|
||||||
|
senderAvatarURI = msg.sender_avatar || null;
|
||||||
|
} else if (!isUser) {
|
||||||
|
// Assistant message — use model/persona label
|
||||||
|
senderLabel = 'Assistant';
|
||||||
|
senderAvatarURI = assistantAvatarURI(msg);
|
||||||
|
if (msg.sender_name) {
|
||||||
|
senderLabel = msg.sender_name;
|
||||||
|
} else if (msg.modelName) {
|
||||||
|
senderLabel = msg.modelName;
|
||||||
} else if (typeof ChannelModels !== 'undefined' && msg.model) {
|
} else if (typeof ChannelModels !== 'undefined' && msg.model) {
|
||||||
assistantLabel = ChannelModels.resolveDisplayName(msg.model);
|
senderLabel = ChannelModels.resolveDisplayName(msg.model);
|
||||||
} else if (msg.model) {
|
} else if (msg.model) {
|
||||||
const m = App.models.find(x => x.id === msg.model || x.baseModelId === msg.model);
|
const m = App.models.find(x => x.id === msg.model || x.baseModelId === msg.model);
|
||||||
assistantLabel = m?.name || msg.model;
|
senderLabel = m?.name || msg.model;
|
||||||
|
}
|
||||||
|
if (msg.sender_avatar) {
|
||||||
|
senderAvatarURI = msg.sender_avatar;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSS class: self = right-aligned "user", other humans = left-aligned "other-user", AI = "assistant"
|
||||||
|
const roleClass = isSelf ? 'user' : isOtherUser ? 'other-user' : msg.role;
|
||||||
|
|
||||||
// Branch indicator: show ‹ 2/3 › when siblings exist
|
// Branch indicator: show ‹ 2/3 › when siblings exist
|
||||||
const hasSiblings = (msg.siblingCount || 1) > 1;
|
const hasSiblings = (msg.siblingCount || 1) > 1;
|
||||||
const sibPos = (msg.siblingIndex || 0) + 1; // display as 1-indexed
|
const sibPos = (msg.siblingIndex || 0) + 1; // display as 1-indexed
|
||||||
@@ -645,16 +686,16 @@ const UI = {
|
|||||||
|
|
||||||
// Action buttons
|
// Action buttons
|
||||||
const msgId = msg.id ? esc(msg.id) : '';
|
const msgId = msg.id ? esc(msg.id) : '';
|
||||||
const editBtn = isUser && msgId ? `<button class="msg-action-btn" onclick="editMessage('${msgId}')" title="Edit">Edit</button>` : '';
|
const editBtn = isSelf && 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>` : '';
|
const regenBtn = !isUser && msgId ? `<button class="msg-action-btn" onclick="regenerateMessage('${msgId}')" title="Regenerate">Regen</button>` : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="message ${msg.role}${dimmed ? ' msg-dimmed' : ''}" data-msg-id="${msgId}">
|
<div class="message ${roleClass}${dimmed ? ' msg-dimmed' : ''}" data-msg-id="${msgId}">
|
||||||
<div class="msg-inner">
|
<div class="msg-inner">
|
||||||
<div class="msg-avatar">${avatarHTML(msg.role, isUser ? API.user?.avatar : assistantAvatarURI(msg))}</div>
|
<div class="msg-avatar">${avatarHTML(isOtherUser ? 'other-user' : msg.role, senderAvatarURI)}</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">${esc(senderLabel)}</span>
|
||||||
${branchNav}
|
${branchNav}
|
||||||
${time ? `<span class="msg-time">${time}</span>` : ''}
|
${time ? `<span class="msg-time">${time}</span>` : ''}
|
||||||
<div class="msg-actions">
|
<div class="msg-actions">
|
||||||
@@ -680,6 +721,45 @@ const UI = {
|
|||||||
<h2>Chat Switchboard</h2>
|
<h2>Chat Switchboard</h2>
|
||||||
<p>Select a model and start chatting</p>
|
<p>Select a model and start chatting</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
this.updateContextBanner();
|
||||||
|
},
|
||||||
|
|
||||||
|
// v0.23.2: Show/hide context banner based on active conversation type
|
||||||
|
updateContextBanner() {
|
||||||
|
const el = document.getElementById('channelContextBanner');
|
||||||
|
if (!el) return;
|
||||||
|
const ac = App.activeConversation;
|
||||||
|
if (!ac || ac.type === 'direct') {
|
||||||
|
el.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
if (ac.type === 'dm') {
|
||||||
|
const ch = (App.channels || []).find(c => c.id === ac.id);
|
||||||
|
const name = ch?.name || 'someone';
|
||||||
|
html = `<span class="ctx-mode">DM</span>` +
|
||||||
|
`<span>with <b>${esc(name)}</b></span>` +
|
||||||
|
`<span class="ctx-hint">@mention a persona to bring in AI</span>` +
|
||||||
|
`<span style="flex:1;"></span>` +
|
||||||
|
`<button class="ctx-participants-btn" onclick="openParticipantsPanel('${ac.id}')" title="Manage participants">👥 Members</button>`;
|
||||||
|
} else if (ac.type === 'group') {
|
||||||
|
html = `<span class="ctx-mode">Group</span>` +
|
||||||
|
`<span class="ctx-hint">No @mention = leader responds · @mention to route · @all for everyone</span>` +
|
||||||
|
`<span style="flex:1;"></span>` +
|
||||||
|
`<button class="ctx-participants-btn" onclick="openParticipantsPanel('${ac.id}')" title="Manage participants">👥 Members</button>`;
|
||||||
|
} else if (ac.type === 'channel') {
|
||||||
|
const ch = (App.channels || []).find(c => c.id === ac.id);
|
||||||
|
const mode = ch?.aiMode || 'auto';
|
||||||
|
const topic = ch?.topic || '';
|
||||||
|
const modeLabel = mode === 'auto' ? 'AI: auto' : mode === 'mention_only' ? 'AI: @mention only' : 'AI: off';
|
||||||
|
html = `<button class="ctx-mode ctx-mode-btn" onclick="cycleChannelAiMode('${ac.id}')" title="Click to change AI mode">${esc(modeLabel)}</button>`;
|
||||||
|
if (topic) html += `<span class="ctx-topic">${esc(topic)}</span>`;
|
||||||
|
html += `<span style="flex:1;"></span>`;
|
||||||
|
html += `<button class="ctx-participants-btn" onclick="openParticipantsPanel('${ac.id}')" title="Manage participants">👥 Members</button>`;
|
||||||
|
}
|
||||||
|
el.innerHTML = html;
|
||||||
|
el.style.display = '';
|
||||||
},
|
},
|
||||||
|
|
||||||
showEditInline(messageId, currentContent) {
|
showEditInline(messageId, currentContent) {
|
||||||
@@ -1278,7 +1358,7 @@ const UI = {
|
|||||||
// ── Export ────────────────────────────────
|
// ── Export ────────────────────────────────
|
||||||
|
|
||||||
exportChat(format) {
|
exportChat(format) {
|
||||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
const chat = App.getActiveChat();
|
||||||
if (!chat) return UI.toast('No chat to export', 'warning');
|
if (!chat) return UI.toast('No chat to export', 'warning');
|
||||||
|
|
||||||
let content, ext, mime;
|
let content, ext, mime;
|
||||||
@@ -1302,7 +1382,7 @@ const UI = {
|
|||||||
// ── Message Actions ──────────────────────
|
// ── Message Actions ──────────────────────
|
||||||
|
|
||||||
copyMessage(index) {
|
copyMessage(index) {
|
||||||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
const chat = App.getActiveChat();
|
||||||
if (chat?.messages[index]) {
|
if (chat?.messages[index]) {
|
||||||
navigator.clipboard.writeText(chat.messages[index].content)
|
navigator.clipboard.writeText(chat.messages[index].content)
|
||||||
.then(() => UI.toast('Copied', 'success'))
|
.then(() => UI.toast('Copied', 'success'))
|
||||||
|
|||||||
@@ -58,35 +58,44 @@ function formatMessage(content) {
|
|||||||
function _highlightMentions(html) {
|
function _highlightMentions(html) {
|
||||||
if (typeof ChannelModels === 'undefined') return html;
|
if (typeof ChannelModels === 'undefined') return html;
|
||||||
const roster = ChannelModels.getRoster();
|
const roster = ChannelModels.getRoster();
|
||||||
if (!roster || roster.length < 2) return html;
|
|
||||||
|
|
||||||
// Build patterns from handles (primary) and display names (fallback)
|
// Build patterns from roster handles and display names (AI mentions)
|
||||||
const patterns = [];
|
const aiPatterns = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
|
||||||
for (const m of roster) {
|
if (roster && roster.length >= 2) {
|
||||||
// Handle pattern (preferred)
|
for (const m of roster) {
|
||||||
if (m.handle) {
|
if (m.handle) {
|
||||||
const escaped = m.handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escaped = m.handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
if (!seen.has(escaped)) {
|
if (!seen.has(escaped)) {
|
||||||
patterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
aiPatterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
||||||
seen.add(escaped);
|
seen.add(escaped);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (m.display_name) {
|
||||||
// Display name pattern (hyphenated form)
|
const hyphenated = m.display_name.replace(/\s+/g, '-');
|
||||||
if (m.display_name) {
|
const escaped = hyphenated.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
const hyphenated = m.display_name.replace(/\s+/g, '-');
|
if (!seen.has(escaped.toLowerCase())) {
|
||||||
const escaped = hyphenated.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const flexible = escaped.replace(/[-]+/g, '[\\s-]+');
|
||||||
if (!seen.has(escaped.toLowerCase())) {
|
aiPatterns.push(new RegExp('(@' + flexible + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
||||||
const flexible = escaped.replace(/[-]+/g, '[\\s-]+');
|
seen.add(escaped.toLowerCase());
|
||||||
patterns.push(new RegExp('(@' + flexible + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
}
|
||||||
seen.add(escaped.toLowerCase());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// @all
|
// @all is special
|
||||||
patterns.push(new RegExp('(@all)(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
aiPatterns.push(new RegExp('(@all)(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
||||||
|
|
||||||
|
// v0.23.2: Build user mention patterns
|
||||||
|
const userPatterns = [];
|
||||||
|
for (const u of (App.users || [])) {
|
||||||
|
const escaped = u.username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
if (!seen.has(escaped.toLowerCase())) {
|
||||||
|
userPatterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
|
||||||
|
seen.add(escaped.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Don't highlight inside <code>, <pre>, <a> tags
|
// Don't highlight inside <code>, <pre>, <a> tags
|
||||||
const parts = html.split(/(<[^>]+>)/);
|
const parts = html.split(/(<[^>]+>)/);
|
||||||
@@ -101,9 +110,14 @@ function _highlightMentions(html) {
|
|||||||
}
|
}
|
||||||
if (inCode) continue;
|
if (inCode) continue;
|
||||||
let text = part;
|
let text = part;
|
||||||
for (const re of patterns) {
|
// AI mentions first (persona/model)
|
||||||
|
for (const re of aiPatterns) {
|
||||||
text = text.replace(re, '<span class="mention-pill">$1</span>');
|
text = text.replace(re, '<span class="mention-pill">$1</span>');
|
||||||
}
|
}
|
||||||
|
// User mentions (different color)
|
||||||
|
for (const re of userPatterns) {
|
||||||
|
text = text.replace(re, '<span class="mention-pill mention-user">$1</span>');
|
||||||
|
}
|
||||||
parts[i] = text;
|
parts[i] = text;
|
||||||
}
|
}
|
||||||
return parts.join('');
|
return parts.join('');
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ Object.assign(UI, {
|
|||||||
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
||||||
if (scaleEl) prefs.scale = parseInt(scaleEl.value);
|
if (scaleEl) prefs.scale = parseInt(scaleEl.value);
|
||||||
if (msgFontEl) prefs.msgFont = parseInt(msgFontEl.value);
|
if (msgFontEl) prefs.msgFont = parseInt(msgFontEl.value);
|
||||||
|
// v0.23.2: Theme saved via Theme API (localStorage key: switchboard_theme)
|
||||||
|
const activeThemeBtn = document.querySelector('#themeToggle .theme-btn.active');
|
||||||
|
if (activeThemeBtn && typeof Theme !== 'undefined') {
|
||||||
|
Theme.set(activeThemeBtn.dataset.theme);
|
||||||
|
}
|
||||||
localStorage.setItem('cs-appearance', JSON.stringify(prefs));
|
localStorage.setItem('cs-appearance', JSON.stringify(prefs));
|
||||||
UI.toast('Appearance saved', 'success');
|
UI.toast('Appearance saved', 'success');
|
||||||
},
|
},
|
||||||
@@ -141,7 +146,8 @@ Object.assign(UI, {
|
|||||||
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
||||||
const scale = prefs.scale || 100;
|
const scale = prefs.scale || 100;
|
||||||
const msgFont = prefs.msgFont || 14;
|
const msgFont = prefs.msgFont || 14;
|
||||||
const theme = prefs.theme || 'system';
|
// v0.23.2: Read theme from Theme API
|
||||||
|
const theme = typeof Theme !== 'undefined' ? Theme.get() : 'system';
|
||||||
const keymap = prefs.editorKeymap || 'standard';
|
const keymap = prefs.editorKeymap || 'standard';
|
||||||
|
|
||||||
const scaleEl = document.getElementById('settingsScale');
|
const scaleEl = document.getElementById('settingsScale');
|
||||||
@@ -149,9 +155,14 @@ Object.assign(UI, {
|
|||||||
if (scaleEl) { scaleEl.value = scale; document.getElementById('scaleValue').textContent = scale + '%'; }
|
if (scaleEl) { scaleEl.value = scale; document.getElementById('scaleValue').textContent = scale + '%'; }
|
||||||
if (msgFontEl) { msgFontEl.value = msgFont; document.getElementById('msgFontValue').textContent = msgFont + 'px'; }
|
if (msgFontEl) { msgFontEl.value = msgFont; document.getElementById('msgFontValue').textContent = msgFont + 'px'; }
|
||||||
|
|
||||||
// Highlight active theme button
|
// Highlight active theme button and wire click
|
||||||
document.querySelectorAll('#themeToggle .theme-btn').forEach(btn => {
|
document.querySelectorAll('#themeToggle .theme-btn').forEach(btn => {
|
||||||
btn.classList.toggle('active', btn.dataset.theme === theme);
|
btn.classList.toggle('active', btn.dataset.theme === theme);
|
||||||
|
btn.onclick = () => {
|
||||||
|
document.querySelectorAll('#themeToggle .theme-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
if (typeof Theme !== 'undefined') Theme.set(btn.dataset.theme);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Highlight active keymap button
|
// Highlight active keymap button
|
||||||
|
|||||||
Reference in New Issue
Block a user