Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
726479a57f | ||
|
|
5dd5a6f23c | ||
|
|
3b97429f20 |
@@ -581,6 +581,64 @@ func (h *MessagesHandler) DMMessages(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// DMPartners returns a list of agents the user has DM conversations with,
|
||||
// ordered by most recent message. Queries ALL messages (not just inbox)
|
||||
// so historical conversations always appear.
|
||||
func (h *MessagesHandler) DMPartners(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID, ok := OwnerIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
|
||||
return
|
||||
}
|
||||
|
||||
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
|
||||
return
|
||||
}
|
||||
|
||||
if len(ownedAgents) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"partners": []any{}})
|
||||
return
|
||||
}
|
||||
|
||||
agentNames := make([]string, len(ownedAgents))
|
||||
for i, a := range ownedAgents {
|
||||
agentNames[i] = a.Name
|
||||
}
|
||||
|
||||
partners, err := h.msgService.GetDMPartners(r.Context(), agentNames)
|
||||
if err != nil {
|
||||
h.logger.Error("get dm partners failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to get DM partners"))
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve display names
|
||||
type partnerWithDisplay struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
LastMessage string `json:"last_message"`
|
||||
LastTime string `json:"last_time"`
|
||||
Unread int `json:"unread"`
|
||||
}
|
||||
result := make([]partnerWithDisplay, len(partners))
|
||||
for i, p := range partners {
|
||||
result[i] = partnerWithDisplay{
|
||||
Name: p.Name,
|
||||
DisplayName: p.Name,
|
||||
LastMessage: p.LastMessage,
|
||||
LastTime: p.LastTime,
|
||||
Unread: p.Unread,
|
||||
}
|
||||
if a, err := h.agentService.GetAgent(r.Context(), p.Name); err == nil {
|
||||
result[i].DisplayName = a.DisplayName
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"partners": result})
|
||||
}
|
||||
|
||||
func (h *MessagesHandler) isAgentOwnedBy(r *http.Request, agentName string, ownerID int64) bool {
|
||||
if agentName == "" {
|
||||
return false
|
||||
|
||||
@@ -131,6 +131,7 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
|
||||
r.Delete("/api/agents/{name}", agentsHandler.DeleteAgent)
|
||||
r.Post("/api/agents/{name}/revoke-key", agentsHandler.RevokeKey)
|
||||
r.Get("/api/agents/{name}/messages", messagesHandler.DMMessages)
|
||||
r.Get("/api/dm/partners", messagesHandler.DMPartners)
|
||||
|
||||
// Notifications
|
||||
r.Get("/api/notifications/unread", notificationsHandler.UnreadCounts)
|
||||
|
||||
@@ -531,6 +531,11 @@ func (s *MessagingService) GetDMMessages(ctx context.Context, ownedAgents []stri
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// GetDMPartners returns all DM conversation partners for the owned agents.
|
||||
func (s *MessagingService) GetDMPartners(ctx context.Context, ownedAgents []string) ([]DMPartner, error) {
|
||||
return s.store.GetDMPartners(ctx, ownedAgents)
|
||||
}
|
||||
|
||||
// GetDMUnreadCounts returns unread DM counts grouped by peer agent.
|
||||
func (s *MessagingService) GetDMUnreadCounts(ctx context.Context, agentName string) ([]DMUnreadCount, error) {
|
||||
return s.store.GetDMUnreadCounts(ctx, agentName)
|
||||
|
||||
@@ -29,6 +29,7 @@ type MessageStore interface {
|
||||
GetChannelMessages(ctx context.Context, channelID int64, limit, offset int) ([]*Message, error)
|
||||
CountChannelMessages(ctx context.Context, channelID int64) (int, error)
|
||||
GetDMMessages(ctx context.Context, agents []string, peerAgent string, limit int) ([]*Message, error)
|
||||
GetDMPartners(ctx context.Context, agents []string) ([]DMPartner, error)
|
||||
AgentExists(ctx context.Context, agentName string) (bool, error)
|
||||
CountPendingDMs(ctx context.Context, agentName string) (int64, error)
|
||||
GetPendingDMs(ctx context.Context, agentName string, limit int) ([]*Message, error)
|
||||
@@ -687,6 +688,108 @@ func (s *SQLiteMessageStore) GetDMMessages(ctx context.Context, agents []string,
|
||||
return scanMessages(rows)
|
||||
}
|
||||
|
||||
// GetDMPartners returns all unique DM conversation partners for the given human agent,
|
||||
// with the most recent message preview and unread count. Queries ALL messages (not just
|
||||
// inbox) so historical conversations always appear. The humanAgent is the primary viewer;
|
||||
// allAgents includes all owned agents to capture DMs sent to/from any of them.
|
||||
func (s *SQLiteMessageStore) GetDMPartners(ctx context.Context, allAgents []string) ([]DMPartner, error) {
|
||||
if len(allAgents) == 0 {
|
||||
return []DMPartner{}, nil
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(allAgents))
|
||||
for i := range allAgents {
|
||||
placeholders[i] = "?"
|
||||
}
|
||||
inClause := strings.Join(placeholders, ",")
|
||||
|
||||
// For each DM, the "peer" is the other party. When from_agent is owned,
|
||||
// peer = to_agent. When to_agent is owned, peer = from_agent.
|
||||
// We want to see ALL conversation partners, including other owned agents
|
||||
// (e.g., research-mcpproxy sending DMs to algis — both owned by same user).
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
peer,
|
||||
body as last_body,
|
||||
created_at as last_time,
|
||||
0 as unread
|
||||
FROM (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN from_agent IN (%s) THEN to_agent
|
||||
ELSE from_agent
|
||||
END as peer,
|
||||
body,
|
||||
created_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY CASE WHEN from_agent IN (%s) THEN to_agent ELSE from_agent END
|
||||
ORDER BY created_at DESC
|
||||
) as rn
|
||||
FROM messages
|
||||
WHERE channel_id IS NULL
|
||||
AND (from_agent IN (%s) OR to_agent IN (%s))
|
||||
) sub
|
||||
WHERE rn = 1 AND peer IS NOT NULL AND peer != ''
|
||||
ORDER BY last_time DESC
|
||||
LIMIT 50`,
|
||||
inClause, inClause, inClause, inClause,
|
||||
)
|
||||
|
||||
fullArgs := make([]any, 0, len(allAgents)*4)
|
||||
for i := 0; i < 4; i++ {
|
||||
for _, a := range allAgents {
|
||||
fullArgs = append(fullArgs, a)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, fullArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get dm partners: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Track owned agent names for unread counting
|
||||
ownedSet := make(map[string]bool, len(allAgents))
|
||||
for _, a := range allAgents {
|
||||
ownedSet[a] = true
|
||||
}
|
||||
|
||||
var partners []DMPartner
|
||||
for rows.Next() {
|
||||
var p DMPartner
|
||||
var body sql.NullString
|
||||
if err := rows.Scan(&p.Name, &body, &p.LastTime, &p.Unread); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body.Valid && len(body.String) > 80 {
|
||||
p.LastMessage = body.String[:80] + "..."
|
||||
} else if body.Valid {
|
||||
p.LastMessage = body.String
|
||||
}
|
||||
partners = append(partners, p)
|
||||
}
|
||||
if partners == nil {
|
||||
partners = []DMPartner{}
|
||||
}
|
||||
|
||||
// Count unread messages per partner (pending DMs TO any owned agent FROM each partner)
|
||||
for i, p := range partners {
|
||||
if ownedSet[p.Name] {
|
||||
continue // Skip unread count for inter-agent DMs
|
||||
}
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
fmt.Sprintf(`SELECT COUNT(*) FROM messages WHERE channel_id IS NULL AND from_agent = ? AND to_agent IN (%s) AND status IN ('pending', 'processing')`, inClause),
|
||||
append([]any{p.Name}, fullArgs[:len(allAgents)]...)...,
|
||||
).Scan(&count)
|
||||
if err == nil {
|
||||
partners[i].Unread = count
|
||||
}
|
||||
}
|
||||
|
||||
return partners, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteMessageStore) CountPendingDMs(ctx context.Context, agentName string) (int64, error) {
|
||||
var count int64
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
|
||||
@@ -101,3 +101,11 @@ type DMUnreadCount struct {
|
||||
UnreadCount int `json:"unread_count"`
|
||||
LastMessageID int64 `json:"last_message_id"`
|
||||
}
|
||||
|
||||
// DMPartner represents a DM conversation partner with summary info.
|
||||
type DMPartner struct {
|
||||
Name string `json:"name"`
|
||||
LastMessage string `json:"last_message"`
|
||||
LastTime string `json:"last_time"`
|
||||
Unread int `json:"unread"`
|
||||
}
|
||||
|
||||
Vendored
+6
-6
@@ -11,30 +11,30 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link href="/_app/immutable/entry/start.C53XvHet.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BYAQw6Ic.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/start.DkfAG9pH.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/Ll39S8uO.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BjgrqnN-.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BK7DUW2U.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/CslSvznw.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/C_dJMdcr.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/Du3f5uIc.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/B3RSY5nb.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.1r_Gkim3.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.koRx5eH6.js" rel="modulepreload">
|
||||
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_1vc73p1 = {
|
||||
__sveltekit_v7zte8 = {
|
||||
base: ""
|
||||
};
|
||||
|
||||
const element = document.currentScript.parentElement;
|
||||
|
||||
Promise.all([
|
||||
import("/_app/immutable/entry/start.C53XvHet.js"),
|
||||
import("/_app/immutable/entry/app.1r_Gkim3.js")
|
||||
import("/_app/immutable/entry/start.DkfAG9pH.js"),
|
||||
import("/_app/immutable/entry/app.koRx5eH6.js")
|
||||
]).then(([kit, app]) => {
|
||||
kit.start(app, element);
|
||||
});
|
||||
|
||||
@@ -83,6 +83,11 @@ export const conversations = {
|
||||
get: (id: number) => request<{ conversation: any; messages: any[] }>('GET', `/api/conversations/${id}`)
|
||||
};
|
||||
|
||||
// DM Partners
|
||||
export const dmPartners = {
|
||||
list: () => request<{ partners: any[] }>('GET', '/api/dm/partners')
|
||||
};
|
||||
|
||||
// Agents
|
||||
export const agents = {
|
||||
list: () => request<{ agents: any[] }>('GET', '/api/agents'),
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { user, logout } from '$lib/stores/auth';
|
||||
import { notifications } from '$lib/stores/notifications';
|
||||
import { channels as channelsApi, agents as agentsApi, deadLetters as deadLettersApi } from '$lib/api/client';
|
||||
import { channels as channelsApi, agents as agentsApi, deadLetters as deadLettersApi, dmPartners as dmPartnersApi } from '$lib/api/client';
|
||||
|
||||
let { open = false, onclose = () => {} }: { open?: boolean; onclose?: () => void } = $props();
|
||||
|
||||
let channelList = $state<any[]>([]);
|
||||
let agentList = $state<any[]>([]);
|
||||
let dmPartnerList = $state<any[]>([]);
|
||||
let deadLetterCount = $state(0);
|
||||
|
||||
let channelsExpanded = $state(true);
|
||||
@@ -25,14 +26,16 @@
|
||||
|
||||
async function loadSidebarData() {
|
||||
try {
|
||||
const [chRes, agRes, dlRes] = await Promise.all([
|
||||
const [chRes, agRes, dlRes, dmRes] = await Promise.all([
|
||||
channelsApi.list(),
|
||||
agentsApi.list(),
|
||||
deadLettersApi.count().catch(() => ({ count: 0 }))
|
||||
deadLettersApi.count().catch(() => ({ count: 0 })),
|
||||
dmPartnersApi.list().catch(() => ({ partners: [] }))
|
||||
]);
|
||||
channelList = chRes.channels ?? [];
|
||||
agentList = agRes.agents ?? [];
|
||||
deadLetterCount = dlRes.count ?? 0;
|
||||
dmPartnerList = dmRes.partners ?? [];
|
||||
} catch {
|
||||
// handled
|
||||
}
|
||||
@@ -55,15 +58,8 @@
|
||||
return count > 99 ? '99+' : String(count);
|
||||
}
|
||||
|
||||
// Filter DM list: only show human agents + AI agents with unread DMs.
|
||||
// This hides agent-to-agent internal conversations from the sidebar.
|
||||
let dmAgentList = $derived(
|
||||
agentList.filter(agent => {
|
||||
if (agent.type !== 'ai') return true;
|
||||
const unread = $notifications.dms.get(agent.name) ?? 0;
|
||||
return unread > 0;
|
||||
})
|
||||
);
|
||||
// DM partners list is loaded from the API — shows agents you have
|
||||
// actual conversations with, ordered by most recent message.
|
||||
|
||||
const adminLinks = [
|
||||
{ href: '/agents', label: 'Agents' },
|
||||
@@ -219,30 +215,23 @@
|
||||
</button>
|
||||
{#if dmsExpanded}
|
||||
<div class="mt-0.5">
|
||||
{#if dmAgentList.length === 0}
|
||||
<p class="px-3 py-1 text-xs text-text-secondary italic">No agents</p>
|
||||
{#if dmPartnerList.length === 0}
|
||||
<p class="px-3 py-1 text-xs text-text-secondary italic">No conversations</p>
|
||||
{:else}
|
||||
{#each dmAgentList as agent}
|
||||
{@const dmUnread = $notifications.dms.get(agent.name) ?? 0}
|
||||
{#each dmPartnerList as partner}
|
||||
<a
|
||||
href="/dm/{agent.name}"
|
||||
class="sidebar-item {isActive('/dm/' + agent.name) ? 'sidebar-item-active' : ''}"
|
||||
href="/dm/{partner.name}"
|
||||
class="sidebar-item {isActive('/dm/' + partner.name) ? 'sidebar-item-active' : ''}"
|
||||
onclick={handleNavClick}
|
||||
>
|
||||
<span class="relative flex-shrink-0">
|
||||
<span class="w-5 h-5 rounded-full bg-bg-tertiary flex items-center justify-center text-[10px] font-bold text-text-secondary">
|
||||
{(agent.display_name || agent.name).charAt(0).toUpperCase()}
|
||||
{(partner.display_name || partner.name).charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span
|
||||
class="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full border border-bg-secondary {agent.status === 'active' ? 'bg-accent-green' : 'bg-text-secondary'}"
|
||||
></span>
|
||||
</span>
|
||||
<span class="truncate {dmUnread > 0 ? 'font-bold text-text-primary' : ''}">{agent.display_name || agent.name}</span>
|
||||
<span class="text-[9px] text-text-secondary flex-shrink-0">(you)</span>
|
||||
{#if dmUnread > 0}
|
||||
<span class="ml-auto text-[10px] font-bold text-white bg-accent-red px-1.5 py-0.5 rounded-full min-w-[18px] text-center flex-shrink-0">{badgeText(dmUnread)}</span>
|
||||
{:else if agent.type === 'ai'}
|
||||
<span class="ml-auto text-[9px] font-mono text-accent-purple bg-accent-purple/10 px-1 rounded flex-shrink-0">AI</span>
|
||||
<span class="truncate {partner.unread > 0 ? 'font-bold text-text-primary' : ''}">{partner.display_name || partner.name}</span>
|
||||
{#if partner.unread > 0}
|
||||
<span class="ml-auto text-[10px] font-bold text-white bg-accent-red px-1.5 py-0.5 rounded-full min-w-[18px] text-center flex-shrink-0">{badgeText(partner.unread)}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user