agent-nuvira 2.7.0 → 2.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/orchestrator.d.ts.map +1 -1
- package/dist/agents/orchestrator.js +17 -3
- package/dist/agents/orchestrator.js.map +1 -1
- package/dist/app.js +26 -0
- package/dist/cli/chat.d.ts.map +1 -1
- package/dist/cli/chat.js +108 -2
- package/dist/cli/chat.js.map +1 -1
- package/dist/config/auth.js +18 -0
- package/dist/config/jwt.js +24 -0
- package/dist/config/keys.js +14 -0
- package/dist/gateway/chat-store.d.ts +32 -5
- package/dist/gateway/chat-store.d.ts.map +1 -1
- package/dist/gateway/chat-store.js +52 -6
- package/dist/gateway/chat-store.js.map +1 -1
- package/dist/gateway/registry.d.ts.map +1 -1
- package/dist/gateway/registry.js +54 -10
- package/dist/gateway/registry.js.map +1 -1
- package/dist/inference/model-validator.d.ts.map +1 -1
- package/dist/inference/model-validator.js +11 -1
- package/dist/inference/model-validator.js.map +1 -1
- package/dist/inference/tool-call-utils.d.ts +31 -0
- package/dist/inference/tool-call-utils.d.ts.map +1 -1
- package/dist/inference/tool-call-utils.js +41 -0
- package/dist/inference/tool-call-utils.js.map +1 -1
- package/dist/learning/reasoning-trace.d.ts +5 -3
- package/dist/learning/reasoning-trace.d.ts.map +1 -1
- package/dist/learning/reasoning-trace.js +2 -2
- package/dist/learning/reasoning-trace.js.map +1 -1
- package/dist/middleware/auth.js +30 -0
- package/dist/nlu/conversation-gate.js +1 -1
- package/dist/nlu/conversation-gate.js.map +1 -1
- package/dist/nlu/entities.d.ts.map +1 -1
- package/dist/nlu/entities.js +12 -0
- package/dist/nlu/entities.js.map +1 -1
- package/dist/nlu/intent.d.ts.map +1 -1
- package/dist/nlu/intent.js +83 -15
- package/dist/nlu/intent.js.map +1 -1
- package/dist/passport.js +34 -0
- package/dist/routes/auth.js +42 -0
- package/dist/routes/user.js +31 -0
- package/dist/server.js +17 -0
- package/dist/tools/pipeline-tool.d.ts.map +1 -1
- package/dist/tools/pipeline-tool.js +14 -0
- package/dist/tools/pipeline-tool.js.map +1 -1
- package/dist/tools/release-sync.d.ts +52 -16
- package/dist/tools/release-sync.d.ts.map +1 -1
- package/dist/tools/release-sync.js +143 -79
- package/dist/tools/release-sync.js.map +1 -1
- package/dist/web-dashboard/src/App.js +85 -0
- package/dist/web-dashboard/src/admin-auth.test.js +186 -0
- package/dist/web-dashboard/src/ansi.js +23 -0
- package/dist/web-dashboard/src/ansi.test.js +31 -0
- package/dist/web-dashboard/src/api-admin-auth.test.js +172 -0
- package/dist/web-dashboard/src/api-admin.test.js +65 -0
- package/dist/web-dashboard/src/api-hub.test.js +117 -0
- package/dist/web-dashboard/src/api.js +1421 -0
- package/dist/web-dashboard/src/api.test.js +51 -0
- package/dist/web-dashboard/src/artifacts.js +128 -0
- package/dist/web-dashboard/src/artifacts.test.js +139 -0
- package/dist/web-dashboard/src/components/AdminPanel.js +567 -0
- package/dist/web-dashboard/src/components/AdminPanel.test.js +288 -0
- package/dist/web-dashboard/src/components/AgentHub.js +1580 -0
- package/dist/web-dashboard/src/components/AgentHub.test.js +343 -0
- package/dist/web-dashboard/src/components/BedrockOnboarding.js +320 -0
- package/dist/web-dashboard/src/components/BenchmarkCharts.js +228 -0
- package/dist/web-dashboard/src/components/ChatPage.js +1586 -0
- package/dist/web-dashboard/src/components/ChatPage.test.js +899 -0
- package/dist/web-dashboard/src/components/ContactsPage.js +141 -0
- package/dist/web-dashboard/src/components/CostDashboard.js +105 -0
- package/dist/web-dashboard/src/components/DAGView.js +477 -0
- package/dist/web-dashboard/src/components/DAGView.test.js +147 -0
- package/dist/web-dashboard/src/components/EnvVarEditor.js +138 -0
- package/dist/web-dashboard/src/components/EvalsPage.js +73 -0
- package/dist/web-dashboard/src/components/EvalsPage.test.js +120 -0
- package/dist/web-dashboard/src/components/ExecutionHistory.js +201 -0
- package/dist/web-dashboard/src/components/GatewayPage.js +40 -0
- package/dist/web-dashboard/src/components/GatewayPage.test.js +74 -0
- package/dist/web-dashboard/src/components/HealthPanel.js +68 -0
- package/dist/web-dashboard/src/components/HistoryBrowser.js +34 -0
- package/dist/web-dashboard/src/components/Layout.js +50 -0
- package/dist/web-dashboard/src/components/Markdown.js +152 -0
- package/dist/web-dashboard/src/components/Markdown.test.js +126 -0
- package/dist/web-dashboard/src/components/MarkdownZeroDep.js +237 -0
- package/dist/web-dashboard/src/components/MemoryPanel.js +81 -0
- package/dist/web-dashboard/src/components/ModelTimeline.js +298 -0
- package/dist/web-dashboard/src/components/ModelsPanel.js +1484 -0
- package/dist/web-dashboard/src/components/ModelsPanel.test.js +460 -0
- package/dist/web-dashboard/src/components/Overview.js +123 -0
- package/dist/web-dashboard/src/components/PhaseTimeline.js +359 -0
- package/dist/web-dashboard/src/components/PhaseTimeline.test.js +234 -0
- package/dist/web-dashboard/src/components/PlatformConfigSection.js +384 -0
- package/dist/web-dashboard/src/components/PlatformConfigSection.test.js +106 -0
- package/dist/web-dashboard/src/components/PlatformsPage.js +250 -0
- package/dist/web-dashboard/src/components/QuotaPanel.js +159 -0
- package/dist/web-dashboard/src/components/QuotaPanel.test.js +85 -0
- package/dist/web-dashboard/src/components/RequestsPanel.js +229 -0
- package/dist/web-dashboard/src/components/RequestsPanel.test.js +103 -0
- package/dist/web-dashboard/src/components/RoutingInsightsPanel.js +938 -0
- package/dist/web-dashboard/src/components/RoutingInsightsPanel.test.js +339 -0
- package/dist/web-dashboard/src/components/RoutingWalkthrough.js +408 -0
- package/dist/web-dashboard/src/components/RoutingWalkthrough.test.js +209 -0
- package/dist/web-dashboard/src/components/TaskConsole.js +119 -0
- package/dist/web-dashboard/src/components/TaskConsole.test.js +123 -0
- package/dist/web-dashboard/src/components/TasksPage.js +256 -0
- package/dist/web-dashboard/src/components/TasksPage.test.js +103 -0
- package/dist/web-dashboard/src/components/TracePanel.js +306 -0
- package/dist/web-dashboard/src/components/WhatsAppPanel.js +195 -0
- package/dist/web-dashboard/src/components/WhatsAppPanel.test.js +122 -0
- package/dist/web-dashboard/src/jsonOrNull.js +37 -0
- package/dist/web-dashboard/src/main.js +15 -0
- package/dist/web-dashboard/src/mask.js +11 -0
- package/dist/web-dashboard/vite.config.js +23 -0
- package/dist/web-dashboard/vitest.config.js +15 -0
- package/package.json +5 -3
|
@@ -0,0 +1,1580 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Agent Hub panel (I4 + I5) — a 4-tab management page the user picked out:
|
|
4
|
+
* **Skills / Tools / Channels / Artifacts** on one screen.
|
|
5
|
+
*
|
|
6
|
+
* - **Tools** — I1 toolsets with enable/disable switches. Writes are
|
|
7
|
+
* admin-gated (PUT /api/admin/hub/toolsets/<name>, routing.operate) and
|
|
8
|
+
* honored by the runtime gate, so a toggle is never cosmetic.
|
|
9
|
+
* - **Channels** — I2 gateway delivery ledger (pending/sent/failed + recent
|
|
10
|
+
* entries) and the channel directory aliases.
|
|
11
|
+
* - **Artifacts** — I3 per-session artifact store (sessions + recent items).
|
|
12
|
+
* - **Skills** — compiled SkillStore skills + hub-installed SKILL.md skills.
|
|
13
|
+
*
|
|
14
|
+
* Reads are open; the toggle surface follows the AdminPanel auth pattern:
|
|
15
|
+
* unconfigured → setup form, configured → login form, authed → role-gated
|
|
16
|
+
* switches (admin/operator can toggle, viewer reads).
|
|
17
|
+
*/
|
|
18
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
19
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
20
|
+
};
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.default = AgentHub;
|
|
23
|
+
const react_1 = require("react");
|
|
24
|
+
const api_1 = require("../api");
|
|
25
|
+
const WhatsAppPanel_1 = __importDefault(require("./WhatsAppPanel"));
|
|
26
|
+
const PlatformConfigSection_1 = require("./PlatformConfigSection");
|
|
27
|
+
const mask_1 = require("../mask");
|
|
28
|
+
const TABS = [
|
|
29
|
+
{ id: 'tools', label: 'Tools', icon: '🧰' },
|
|
30
|
+
{ id: 'channels', label: 'Channels', icon: '📡' },
|
|
31
|
+
{ id: 'conversations', label: 'Conversations', icon: '💬' },
|
|
32
|
+
{ id: 'artifacts', label: 'Artifacts', icon: '📦' },
|
|
33
|
+
{ id: 'skills', label: 'Skills', icon: '🧠' },
|
|
34
|
+
];
|
|
35
|
+
const STATUS_LABEL = { pending: '⏳ pending', sent: '✅ sent', failed: '❌ failed' };
|
|
36
|
+
function AgentHub() {
|
|
37
|
+
const [tab, setTab] = (0, react_1.useState)('tools');
|
|
38
|
+
const [data, setData] = (0, react_1.useState)(null);
|
|
39
|
+
const [loading, setLoading] = (0, react_1.useState)(true);
|
|
40
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
41
|
+
const [refreshing, setRefreshing] = (0, react_1.useState)(false);
|
|
42
|
+
// Auth (same control layer as AdminPanel).
|
|
43
|
+
const [authStatus, setAuthStatus] = (0, react_1.useState)(null);
|
|
44
|
+
const [authed, setAuthed] = (0, react_1.useState)(false);
|
|
45
|
+
const [role, setRole] = (0, react_1.useState)(null);
|
|
46
|
+
const [userName, setUserName] = (0, react_1.useState)(null);
|
|
47
|
+
const [user, setUser] = (0, react_1.useState)('');
|
|
48
|
+
const [password, setPassword] = (0, react_1.useState)('');
|
|
49
|
+
const [authError, setAuthError] = (0, react_1.useState)(null);
|
|
50
|
+
const [authBusy, setAuthBusy] = (0, react_1.useState)(false);
|
|
51
|
+
const [pendingToggle, setPendingToggle] = (0, react_1.useState)(null);
|
|
52
|
+
const [toggling, setToggling] = (0, react_1.useState)(null);
|
|
53
|
+
const [rowMsg, setRowMsg] = (0, react_1.useState)({});
|
|
54
|
+
// I11 — Channels-tab test send (mirrors `buff gateway send <target> <text>`).
|
|
55
|
+
const [sendTarget, setSendTarget] = (0, react_1.useState)('');
|
|
56
|
+
const [sendText, setSendText] = (0, react_1.useState)('');
|
|
57
|
+
const [sending, setSending] = (0, react_1.useState)(false);
|
|
58
|
+
const [sendMsg, setSendMsg] = (0, react_1.useState)(null);
|
|
59
|
+
// P1 — Permissions (validated senders): per-platform policy drafts + saves.
|
|
60
|
+
const [permissionsExpanded, setPermissionsExpanded] = (0, react_1.useState)(false);
|
|
61
|
+
const [policyDraft, setPolicyDraft] = (0, react_1.useState)({});
|
|
62
|
+
const [policyBusy, setPolicyBusy] = (0, react_1.useState)(false);
|
|
63
|
+
const [policyMsg, setPolicyMsg] = (0, react_1.useState)(null);
|
|
64
|
+
const [policyUserInput, setPolicyUserInput] = (0, react_1.useState)({});
|
|
65
|
+
const [policyGroupInput, setPolicyGroupInput] = (0, react_1.useState)({});
|
|
66
|
+
// Privacy — sender ids are masked by default; admins/operators can reveal
|
|
67
|
+
// full ids while working (per-session, resets on reload).
|
|
68
|
+
const [revealIds, setRevealIds] = (0, react_1.useState)(false);
|
|
69
|
+
// Conversations tab — infinite scroll state.
|
|
70
|
+
const [expandedConv, setExpandedConv] = (0, react_1.useState)(null);
|
|
71
|
+
const [convSearch, setConvSearch] = (0, react_1.useState)('');
|
|
72
|
+
const [clearingConv, setClearingConv] = (0, react_1.useState)(null);
|
|
73
|
+
const [showAnalytics, setShowAnalytics] = (0, react_1.useState)(false);
|
|
74
|
+
const [typingContacts, setTypingContacts] = (0, react_1.useState)(new Map());
|
|
75
|
+
const [selectedConvs, setSelectedConvs] = (0, react_1.useState)(new Set());
|
|
76
|
+
const [exporting, setExporting] = (0, react_1.useState)(false);
|
|
77
|
+
const [allTags, setAllTags] = (0, react_1.useState)([]);
|
|
78
|
+
const [tagFilter, setTagFilter] = (0, react_1.useState)('');
|
|
79
|
+
const [addingTagTo, setAddingTagTo] = (0, react_1.useState)(null);
|
|
80
|
+
const [newTagValue, setNewTagValue] = (0, react_1.useState)('');
|
|
81
|
+
const [convList, setConvList] = (0, react_1.useState)([]);
|
|
82
|
+
const [convTotal, setConvTotal] = (0, react_1.useState)(0);
|
|
83
|
+
const [convOffset, setConvOffset] = (0, react_1.useState)(0);
|
|
84
|
+
const [convHasMore, setConvHasMore] = (0, react_1.useState)(true);
|
|
85
|
+
const [convLoading, setConvLoading] = (0, react_1.useState)(false);
|
|
86
|
+
const [convInitialized, setConvInitialized] = (0, react_1.useState)(false);
|
|
87
|
+
const convPageSize = 20;
|
|
88
|
+
const convSentinelRef = (0, react_1.useRef)(null);
|
|
89
|
+
/** Load the next page of conversations (or the first page on search/init). */
|
|
90
|
+
const loadConversations = (0, react_1.useCallback)(async (reset = false) => {
|
|
91
|
+
if (convLoading)
|
|
92
|
+
return;
|
|
93
|
+
const offset = reset ? 0 : convOffset;
|
|
94
|
+
setConvLoading(true);
|
|
95
|
+
try {
|
|
96
|
+
const r = await api_1.dashboardAPI.fetchGatewayConversations({ offset, limit: convPageSize, q: convSearch || undefined });
|
|
97
|
+
if (r.ok && r.conversations) {
|
|
98
|
+
setConvList((prev) => reset ? r.conversations : [...prev, ...r.conversations]);
|
|
99
|
+
setConvTotal(r.total ?? 0);
|
|
100
|
+
setConvOffset(offset + r.conversations.length);
|
|
101
|
+
setConvHasMore(r.hasMore ?? false);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch { /* best-effort */ }
|
|
105
|
+
setConvLoading(false);
|
|
106
|
+
}, [convLoading, convOffset, convSearch]);
|
|
107
|
+
// Load conversations when the tab is first opened.
|
|
108
|
+
(0, react_1.useEffect)(() => {
|
|
109
|
+
if (tab === 'conversations' && !convInitialized && !convLoading) {
|
|
110
|
+
setConvInitialized(true);
|
|
111
|
+
void loadConversations(true);
|
|
112
|
+
}
|
|
113
|
+
}, [tab, convInitialized, convLoading, loadConversations]);
|
|
114
|
+
// Infinite scroll — observe the sentinel div at the bottom of the list.
|
|
115
|
+
(0, react_1.useEffect)(() => {
|
|
116
|
+
const sentinel = convSentinelRef.current;
|
|
117
|
+
if (!sentinel)
|
|
118
|
+
return;
|
|
119
|
+
const observer = new IntersectionObserver((entries) => {
|
|
120
|
+
if (entries[0]?.isIntersecting && convHasMore && !convLoading) {
|
|
121
|
+
void loadConversations(false);
|
|
122
|
+
}
|
|
123
|
+
}, { threshold: 0.1 });
|
|
124
|
+
observer.observe(sentinel);
|
|
125
|
+
return () => observer.disconnect();
|
|
126
|
+
}, [convHasMore, convLoading, loadConversations]);
|
|
127
|
+
// Reset and reload conversations when search changes (debounced).
|
|
128
|
+
(0, react_1.useEffect)(() => {
|
|
129
|
+
const timer = setTimeout(() => {
|
|
130
|
+
if (tab === 'conversations' && convInitialized) {
|
|
131
|
+
setConvList([]);
|
|
132
|
+
setConvOffset(0);
|
|
133
|
+
setConvHasMore(true);
|
|
134
|
+
void loadConversations(true);
|
|
135
|
+
}
|
|
136
|
+
}, 300);
|
|
137
|
+
return () => clearTimeout(timer);
|
|
138
|
+
}, [convSearch]);
|
|
139
|
+
// Subscribe to typing events from SSE.
|
|
140
|
+
(0, react_1.useEffect)(() => {
|
|
141
|
+
const unsub = api_1.dashboardAPI.onTypingEvent((event) => {
|
|
142
|
+
setTypingContacts((prev) => {
|
|
143
|
+
const next = new Map(prev);
|
|
144
|
+
const key = `${event.platform}:${event.channelId}`;
|
|
145
|
+
if (event.typing) {
|
|
146
|
+
next.set(key, { platform: event.platform, channelId: event.channelId, startedAt: Date.now() });
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
next.delete(key);
|
|
150
|
+
}
|
|
151
|
+
return next;
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
return unsub;
|
|
155
|
+
}, []);
|
|
156
|
+
/** Toggle selection of a conversation. */
|
|
157
|
+
const toggleConvSelection = (0, react_1.useCallback)((key, e) => {
|
|
158
|
+
e.stopPropagation();
|
|
159
|
+
setSelectedConvs((prev) => {
|
|
160
|
+
const next = new Set(prev);
|
|
161
|
+
if (next.has(key))
|
|
162
|
+
next.delete(key);
|
|
163
|
+
else
|
|
164
|
+
next.add(key);
|
|
165
|
+
return next;
|
|
166
|
+
});
|
|
167
|
+
}, []);
|
|
168
|
+
/** Select/deselect all visible conversations. */
|
|
169
|
+
const toggleSelectAll = (0, react_1.useCallback)(() => {
|
|
170
|
+
setSelectedConvs((prev) => {
|
|
171
|
+
if (prev.size === convList.length)
|
|
172
|
+
return new Set();
|
|
173
|
+
return new Set(convList.map((c) => c.key));
|
|
174
|
+
});
|
|
175
|
+
}, [convList]);
|
|
176
|
+
/** Export selected conversations as a ZIP file. */
|
|
177
|
+
const handleBulkExport = (0, react_1.useCallback)(async () => {
|
|
178
|
+
if (selectedConvs.size === 0)
|
|
179
|
+
return;
|
|
180
|
+
setExporting(true);
|
|
181
|
+
try {
|
|
182
|
+
await api_1.dashboardAPI.exportGatewayConversations([...selectedConvs]);
|
|
183
|
+
}
|
|
184
|
+
catch { /* best-effort */ }
|
|
185
|
+
setExporting(false);
|
|
186
|
+
}, [selectedConvs]);
|
|
187
|
+
/** Load all tags on mount. */
|
|
188
|
+
(0, react_1.useEffect)(() => {
|
|
189
|
+
if (tab === 'conversations') {
|
|
190
|
+
void api_1.dashboardAPI.getAllConversationTags().then((r) => {
|
|
191
|
+
if (r.ok && r.tags)
|
|
192
|
+
setAllTags(r.tags);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}, [tab]);
|
|
196
|
+
/** Add a tag to a conversation. */
|
|
197
|
+
const handleAddTag = (0, react_1.useCallback)(async (key, tag) => {
|
|
198
|
+
if (!tag.trim())
|
|
199
|
+
return;
|
|
200
|
+
const r = await api_1.dashboardAPI.addConversationTag(key, tag.trim());
|
|
201
|
+
if (r.ok) {
|
|
202
|
+
setConvList((prev) => prev.map((c) => c.key === key ? { ...c, tags: [...(c.tags ?? []), tag.trim().toLowerCase()] } : c));
|
|
203
|
+
setAllTags((prev) => prev.includes(tag.trim().toLowerCase()) ? prev : [...prev, tag.trim().toLowerCase()].sort());
|
|
204
|
+
}
|
|
205
|
+
setAddingTagTo(null);
|
|
206
|
+
setNewTagValue('');
|
|
207
|
+
}, []);
|
|
208
|
+
/** Remove a tag from a conversation. */
|
|
209
|
+
const handleRemoveTag = (0, react_1.useCallback)(async (key, tag) => {
|
|
210
|
+
const r = await api_1.dashboardAPI.removeConversationTag(key, tag);
|
|
211
|
+
if (r.ok) {
|
|
212
|
+
setConvList((prev) => prev.map((c) => c.key === key ? { ...c, tags: (c.tags ?? []).filter((t) => t !== tag) } : c));
|
|
213
|
+
}
|
|
214
|
+
}, []);
|
|
215
|
+
/** Export a conversation as a formatted text file download. */
|
|
216
|
+
const exportConversationText = (0, react_1.useCallback)((c, e) => {
|
|
217
|
+
e.stopPropagation();
|
|
218
|
+
const displayName = c.contactName || c.channelId;
|
|
219
|
+
const lines = [
|
|
220
|
+
`Conversation with ${displayName} (${c.platform})`,
|
|
221
|
+
`Exported: ${new Date().toLocaleString()}`,
|
|
222
|
+
`Messages: ${c.messageCount}`,
|
|
223
|
+
'─'.repeat(50),
|
|
224
|
+
'',
|
|
225
|
+
];
|
|
226
|
+
if (c.messages) {
|
|
227
|
+
for (const m of c.messages) {
|
|
228
|
+
const role = m.role === 'user' ? displayName : 'Agent';
|
|
229
|
+
const ts = new Date(m.ts).toLocaleString();
|
|
230
|
+
lines.push(`[${ts}] ${role}:`);
|
|
231
|
+
lines.push(m.content);
|
|
232
|
+
lines.push('');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const blob = new Blob([lines.join('\n')], { type: 'text/plain;charset=utf-8' });
|
|
236
|
+
const url = URL.createObjectURL(blob);
|
|
237
|
+
const a = document.createElement('a');
|
|
238
|
+
a.href = url;
|
|
239
|
+
a.download = `conversation-${c.platform}-${c.channelId.replace(/[^\w]/g, '_')}-${new Date().toISOString().slice(0, 10)}.txt`;
|
|
240
|
+
document.body.appendChild(a);
|
|
241
|
+
a.click();
|
|
242
|
+
document.body.removeChild(a);
|
|
243
|
+
URL.revokeObjectURL(url);
|
|
244
|
+
}, []);
|
|
245
|
+
/** Export a conversation as a printable HTML page (user can Save as PDF). */
|
|
246
|
+
const exportConversationPDF = (0, react_1.useCallback)((c, e) => {
|
|
247
|
+
e.stopPropagation();
|
|
248
|
+
const displayName = c.contactName || c.channelId;
|
|
249
|
+
const messages = c.messages ?? [];
|
|
250
|
+
const html = `<!DOCTYPE html>
|
|
251
|
+
<html>
|
|
252
|
+
<head>
|
|
253
|
+
<meta charset="utf-8">
|
|
254
|
+
<title>Conversation with ${displayName}</title>
|
|
255
|
+
<style>
|
|
256
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 700px; margin: 40px auto; padding: 0 20px; color: #1a1a1a; line-height: 1.6; }
|
|
257
|
+
h1 { font-size: 20px; margin-bottom: 4px; }
|
|
258
|
+
.meta { color: #666; font-size: 13px; margin-bottom: 20px; }
|
|
259
|
+
.msg { margin-bottom: 16px; }
|
|
260
|
+
.msg-user { text-align: right; }
|
|
261
|
+
.msg-assistant { text-align: left; }
|
|
262
|
+
.bubble { display: inline-block; max-width: 80%; padding: 10px 14px; border-radius: 12px; font-size: 14px; white-space: pre-wrap; word-break: break-word; text-align: left; }
|
|
263
|
+
.bubble-user { background: #007bff; color: #fff; border-bottom-right-radius: 4px; }
|
|
264
|
+
.bubble-assistant { background: #f0f0f0; color: #1a1a1a; border-bottom-left-radius: 4px; }
|
|
265
|
+
.timestamp { font-size: 11px; color: #999; margin-top: 2px; }
|
|
266
|
+
@media print { body { margin: 0; } }
|
|
267
|
+
</style>
|
|
268
|
+
</head>
|
|
269
|
+
<body>
|
|
270
|
+
<h1>Conversation with ${displayName}</h1>
|
|
271
|
+
<div class="meta">Platform: ${c.platform} · ${c.messageCount} messages · Exported: ${new Date().toLocaleString()}</div>
|
|
272
|
+
${messages.map((m) => {
|
|
273
|
+
const role = m.role === 'user' ? 'user' : 'assistant';
|
|
274
|
+
const label = m.role === 'user' ? displayName : 'Agent';
|
|
275
|
+
return `<div class="msg msg-${role}">
|
|
276
|
+
<div class="bubble bubble-${role}">${m.content.replace(/</g, '<').replace(/>/g, '>')}</div>
|
|
277
|
+
<div class="timestamp">${label} · ${new Date(m.ts).toLocaleString()}</div>
|
|
278
|
+
</div>`;
|
|
279
|
+
}).join('\n')}
|
|
280
|
+
</body>
|
|
281
|
+
</html>`;
|
|
282
|
+
const w = window.open('', '_blank');
|
|
283
|
+
if (w) {
|
|
284
|
+
w.document.write(html);
|
|
285
|
+
w.document.close();
|
|
286
|
+
// Auto-trigger print dialog which includes Save as PDF.
|
|
287
|
+
setTimeout(() => w.print(), 300);
|
|
288
|
+
}
|
|
289
|
+
}, []);
|
|
290
|
+
// Real-time SSE sync: when data.conversations is updated by a conversation SSE
|
|
291
|
+
// event, merge new/updated entries into the infinite-scroll list without a
|
|
292
|
+
// full reload. Only auto-syncs when the user is on the first page (offset=0)
|
|
293
|
+
// and no search is active — otherwise a manual reload is cleaner.
|
|
294
|
+
(0, react_1.useEffect)(() => {
|
|
295
|
+
const sseConvs = data?.conversations?.recent;
|
|
296
|
+
if (!sseConvs || sseConvs.length === 0)
|
|
297
|
+
return;
|
|
298
|
+
if (!convInitialized)
|
|
299
|
+
return;
|
|
300
|
+
// Only auto-sync on the first page with no search filter.
|
|
301
|
+
// Guard: if we've loaded more than one page, don't overwrite.
|
|
302
|
+
if (convOffset > convPageSize || convSearch)
|
|
303
|
+
return;
|
|
304
|
+
// Merge: update existing entries, prepend new ones.
|
|
305
|
+
setConvList((prev) => {
|
|
306
|
+
const existingKeys = new Set(prev.map((c) => c.key));
|
|
307
|
+
const updated = sseConvs.map((c) => ({ ...c }));
|
|
308
|
+
// Replace existing entries that match, keep the rest.
|
|
309
|
+
const prevMap = new Map(prev.map((c) => [c.key, c]));
|
|
310
|
+
for (const c of updated) {
|
|
311
|
+
if (prevMap.has(c.key)) {
|
|
312
|
+
prevMap.set(c.key, c);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Add any truly new entries at the top.
|
|
316
|
+
const newEntries = updated.filter((c) => !existingKeys.has(c.key));
|
|
317
|
+
const merged = [...newEntries, ...prevMap.values()];
|
|
318
|
+
// Sort by lastActiveAt descending.
|
|
319
|
+
merged.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
|
320
|
+
return merged;
|
|
321
|
+
});
|
|
322
|
+
}, [data?.conversations?.recent]);
|
|
323
|
+
// P6d — marketplace import surface (search every configured registry).
|
|
324
|
+
const [marketQuery, setMarketQuery] = (0, react_1.useState)('');
|
|
325
|
+
const [marketResults, setMarketResults] = (0, react_1.useState)([]);
|
|
326
|
+
const [marketSearched, setMarketSearched] = (0, react_1.useState)(false);
|
|
327
|
+
const [marketBusy, setMarketBusy] = (0, react_1.useState)(false);
|
|
328
|
+
const [marketMsg, setMarketMsg] = (0, react_1.useState)(null);
|
|
329
|
+
const [marketBusyName, setMarketBusyName] = (0, react_1.useState)(null);
|
|
330
|
+
// P1 — verified contacts (name + contact no): the validated list.
|
|
331
|
+
const [contactNameInput, setContactNameInput] = (0, react_1.useState)({});
|
|
332
|
+
const [contacts, setContacts] = (0, react_1.useState)([]);
|
|
333
|
+
// Status recipients — always get pipeline completion summaries.
|
|
334
|
+
const [statusRecipients, setStatusRecipients] = (0, react_1.useState)([]);
|
|
335
|
+
const [statusRecipientInput, setStatusRecipientInput] = (0, react_1.useState)('');
|
|
336
|
+
/** Show the full id when the admin reveal toggle is on, else the masked form. */
|
|
337
|
+
const showId = (id) => (revealIds ? String(id ?? '') : (0, mask_1.maskSenderId)(id));
|
|
338
|
+
/** Extract a readable phone number from a WhatsApp channel id (918800663237@s.whatsapp.net). */
|
|
339
|
+
const extractPhone = (channelId) => {
|
|
340
|
+
const match = channelId.match(/^(\d+)@/);
|
|
341
|
+
if (!match)
|
|
342
|
+
return '';
|
|
343
|
+
const num = match[1];
|
|
344
|
+
// Format Indian numbers: +91 XXXXX XXXXX
|
|
345
|
+
if (num.length === 12 && num.startsWith('91')) {
|
|
346
|
+
return `+${num.slice(0, 2)} ${num.slice(2, 7)} ${num.slice(7)}`;
|
|
347
|
+
}
|
|
348
|
+
if (num.length === 10) {
|
|
349
|
+
return `+91 ${num.slice(0, 5)} ${num.slice(5)}`;
|
|
350
|
+
}
|
|
351
|
+
return `+${num}`;
|
|
352
|
+
};
|
|
353
|
+
/** Build a display label for a conversation card: Name (phone) or just phone. */
|
|
354
|
+
const convDisplayLabel = (c) => {
|
|
355
|
+
const phone = extractPhone(c.channelId);
|
|
356
|
+
if (c.contactName) {
|
|
357
|
+
return { name: c.contactName, phone };
|
|
358
|
+
}
|
|
359
|
+
return { name: phone || showId(c.channelId), phone: '' };
|
|
360
|
+
};
|
|
361
|
+
const refresh = (0, react_1.useCallback)(async () => {
|
|
362
|
+
setRefreshing(true);
|
|
363
|
+
setError(null);
|
|
364
|
+
const d = await api_1.dashboardAPI.fetchHub();
|
|
365
|
+
setData(d);
|
|
366
|
+
if (d?.channels?.statusRecipients)
|
|
367
|
+
setStatusRecipients(d.channels.statusRecipients);
|
|
368
|
+
if (d?.channels?.contacts)
|
|
369
|
+
setContacts(d.channels.contacts);
|
|
370
|
+
if (!d)
|
|
371
|
+
setError('Could not reach the dashboard server, or the server is older than this panel. Run `buff dashboard` to start it.');
|
|
372
|
+
setLoading(false);
|
|
373
|
+
setRefreshing(false);
|
|
374
|
+
}, []);
|
|
375
|
+
(0, react_1.useEffect)(() => {
|
|
376
|
+
void refresh();
|
|
377
|
+
api_1.dashboardAPI.fetchAdminAuthStatus().then((s) => {
|
|
378
|
+
if (!s)
|
|
379
|
+
return;
|
|
380
|
+
setAuthStatus({ configured: s.configured, authenticated: s.authenticated });
|
|
381
|
+
setAuthed(s.authenticated);
|
|
382
|
+
setRole(s.role ?? null);
|
|
383
|
+
setUserName(s.user ?? null);
|
|
384
|
+
});
|
|
385
|
+
}, [refresh]);
|
|
386
|
+
/** routing.operate — admin or operator may toggle capabilities. */
|
|
387
|
+
const canWrite = authed && (role === 'admin' || role === 'operator');
|
|
388
|
+
/** P6d — search the marketplace (reads OTHER registries; the repo stays private). */
|
|
389
|
+
const searchMarket = async (e) => {
|
|
390
|
+
e?.preventDefault();
|
|
391
|
+
const q = marketQuery.trim();
|
|
392
|
+
if (!q)
|
|
393
|
+
return;
|
|
394
|
+
setMarketBusy(true);
|
|
395
|
+
setMarketMsg(null);
|
|
396
|
+
const results = await api_1.dashboardAPI.marketplaceSearch(q);
|
|
397
|
+
setMarketResults(results);
|
|
398
|
+
setMarketSearched(true);
|
|
399
|
+
setMarketBusy(false);
|
|
400
|
+
};
|
|
401
|
+
/** P6d — install a skill from the marketplace into <project>/.agents/skills/. */
|
|
402
|
+
const installMarketSkill = async (name) => {
|
|
403
|
+
if (!authed) {
|
|
404
|
+
setMarketMsg({ kind: 'err', text: '🔐 Log in (or set up admin access) to install skills.' });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (!canWrite) {
|
|
408
|
+
setMarketMsg({ kind: 'err', text: 'Your role cannot install skills — admin or operator only.' });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
setMarketBusyName(name);
|
|
412
|
+
setMarketMsg(null);
|
|
413
|
+
const r = await api_1.dashboardAPI.marketplaceInstall(name);
|
|
414
|
+
if (r.ok) {
|
|
415
|
+
setMarketMsg({ kind: 'ok', text: `✅ Installed ${name} (quarantine checked) — it appears in the Skills list and loads via the skill tool next turn.` });
|
|
416
|
+
void refresh();
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
setMarketMsg({
|
|
420
|
+
kind: 'err',
|
|
421
|
+
text: r.quarantined ? `⛔ ${r.error || 'checksum mismatch — quarantined'}` : `❌ ${r.error || 'Install failed.'}`,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
setMarketBusyName(null);
|
|
425
|
+
};
|
|
426
|
+
/** P6d — uninstall a hub skill (removes the dir + provenance). */
|
|
427
|
+
const uninstallSkill = async (name) => {
|
|
428
|
+
if (!authed) {
|
|
429
|
+
setMarketMsg({ kind: 'err', text: '🔐 Log in (or set up admin access) to uninstall skills.' });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (!canWrite) {
|
|
433
|
+
setMarketMsg({ kind: 'err', text: 'Your role cannot uninstall skills — admin or operator only.' });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
setMarketBusyName(name);
|
|
437
|
+
setMarketMsg(null);
|
|
438
|
+
const r = await api_1.dashboardAPI.marketplaceUninstall(name);
|
|
439
|
+
if (r.ok) {
|
|
440
|
+
setMarketMsg({ kind: 'ok', text: `🗑️ Uninstalled ${name} — removed from .agents/skills/ and provenance.` });
|
|
441
|
+
void refresh();
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
setMarketMsg({ kind: 'err', text: `❌ ${r.error || 'Uninstall failed.'}` });
|
|
445
|
+
}
|
|
446
|
+
setMarketBusyName(null);
|
|
447
|
+
};
|
|
448
|
+
const sessionExpired = () => {
|
|
449
|
+
setAuthed(false);
|
|
450
|
+
setAuthStatus((s) => (s ? { ...s, authenticated: false } : s));
|
|
451
|
+
setAuthError('Session expired — log in again to toggle capabilities.');
|
|
452
|
+
};
|
|
453
|
+
const handleLogin = async (e) => {
|
|
454
|
+
e.preventDefault();
|
|
455
|
+
setAuthBusy(true);
|
|
456
|
+
setAuthError(null);
|
|
457
|
+
const r = await api_1.dashboardAPI.adminLogin(user, password);
|
|
458
|
+
if (r.ok) {
|
|
459
|
+
setAuthed(true);
|
|
460
|
+
setAuthStatus({ configured: true, authenticated: true });
|
|
461
|
+
setRole(r.role ?? 'admin');
|
|
462
|
+
setUserName(r.user ?? null);
|
|
463
|
+
setUser('');
|
|
464
|
+
setPassword('');
|
|
465
|
+
const pending = pendingToggle;
|
|
466
|
+
setPendingToggle(null);
|
|
467
|
+
if (pending)
|
|
468
|
+
await applyToggle(pending.kind, pending.name, pending.enabled);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
setAuthError(r.error || 'Login failed.');
|
|
472
|
+
}
|
|
473
|
+
setAuthBusy(false);
|
|
474
|
+
};
|
|
475
|
+
const handleSetup = async (e) => {
|
|
476
|
+
e.preventDefault();
|
|
477
|
+
setAuthBusy(true);
|
|
478
|
+
setAuthError(null);
|
|
479
|
+
if (password.length < 8) {
|
|
480
|
+
setAuthError('Password must be at least 8 characters.');
|
|
481
|
+
setAuthBusy(false);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const r = await api_1.dashboardAPI.adminSetup(user, password);
|
|
485
|
+
if (r.ok) {
|
|
486
|
+
setAuthed(true);
|
|
487
|
+
setAuthStatus({ configured: true, authenticated: true });
|
|
488
|
+
setRole(r.role ?? 'admin');
|
|
489
|
+
setUserName(r.user ?? null);
|
|
490
|
+
setUser('');
|
|
491
|
+
setPassword('');
|
|
492
|
+
const pending = pendingToggle;
|
|
493
|
+
setPendingToggle(null);
|
|
494
|
+
if (pending)
|
|
495
|
+
await applyToggle(pending.kind, pending.name, pending.enabled);
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
setAuthError(r.error || 'Setup failed.');
|
|
499
|
+
}
|
|
500
|
+
setAuthBusy(false);
|
|
501
|
+
};
|
|
502
|
+
const handleLogout = async () => {
|
|
503
|
+
await api_1.dashboardAPI.adminLogout();
|
|
504
|
+
setAuthed(false);
|
|
505
|
+
setAuthStatus((s) => (s ? { ...s, authenticated: false } : s));
|
|
506
|
+
setRole(null);
|
|
507
|
+
setUserName(null);
|
|
508
|
+
};
|
|
509
|
+
const applyToggle = async (kind, name, enabled) => {
|
|
510
|
+
setToggling(name);
|
|
511
|
+
const r = kind === 'skill'
|
|
512
|
+
? await api_1.dashboardAPI.setSkillEnabled(name, enabled)
|
|
513
|
+
: await api_1.dashboardAPI.setToolsetEnabled(name, enabled);
|
|
514
|
+
if (r.ok) {
|
|
515
|
+
setRowMsg((m) => ({ ...m, [name]: `✅ ${enabled ? 'Enabled' : 'Disabled'} — live for new agent runs` }));
|
|
516
|
+
void refresh();
|
|
517
|
+
}
|
|
518
|
+
else if (r.unauthorized) {
|
|
519
|
+
sessionExpired();
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
setRowMsg((m) => ({ ...m, [name]: `❌ ${r.error || 'Toggle failed'}` }));
|
|
523
|
+
if (r.forbidden)
|
|
524
|
+
setAuthError(r.error || 'Your role cannot change capabilities.');
|
|
525
|
+
}
|
|
526
|
+
setToggling(null);
|
|
527
|
+
};
|
|
528
|
+
const toggleToolset = (name, enabled) => {
|
|
529
|
+
if (!authed) {
|
|
530
|
+
// Queue the toggle behind the login/setup gate.
|
|
531
|
+
setPendingToggle({ kind: 'toolset', name, enabled });
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
void applyToggle('toolset', name, enabled);
|
|
535
|
+
};
|
|
536
|
+
const toggleSkill = (name, enabled) => {
|
|
537
|
+
if (!authed) {
|
|
538
|
+
// Queue the toggle behind the login/setup gate.
|
|
539
|
+
setPendingToggle({ kind: 'skill', name, enabled });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
void applyToggle('skill', name, enabled);
|
|
543
|
+
};
|
|
544
|
+
/** I11: send a test message through the gateway (admin/operator only). */
|
|
545
|
+
const handleSendMessage = async (e) => {
|
|
546
|
+
e.preventDefault();
|
|
547
|
+
if (!authed) {
|
|
548
|
+
setSendMsg({ kind: 'err', text: '🔐 Log in (or set up admin access) to send test messages.' });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (!canWrite) {
|
|
552
|
+
setSendMsg({ kind: 'err', text: '🔒 Your role cannot send channel messages — requires the admin or operator role.' });
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
setSending(true);
|
|
556
|
+
setSendMsg(null);
|
|
557
|
+
const r = await api_1.dashboardAPI.sendChannelMessage(sendTarget.trim(), sendText);
|
|
558
|
+
if (r.ok) {
|
|
559
|
+
setSendMsg({ kind: 'ok', text: `✅ Sent to ${sendTarget.trim()} (${r.platform}:${showId(r.channelId)})` });
|
|
560
|
+
setSendText('');
|
|
561
|
+
}
|
|
562
|
+
else if (r.unauthorized) {
|
|
563
|
+
sessionExpired();
|
|
564
|
+
setSendMsg({ kind: 'err', text: r.error || 'Session expired — log in again.' });
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
setSendMsg({ kind: 'err', text: r.error || 'Send failed.' });
|
|
568
|
+
}
|
|
569
|
+
setSending(false);
|
|
570
|
+
};
|
|
571
|
+
/** The SAVED policy id list for a key — what the draft edits ON TOP of. */
|
|
572
|
+
const savedPolicyIds = (platform, key) => {
|
|
573
|
+
const saved = (data?.channels?.policies?.[platform] ?? {});
|
|
574
|
+
return saved[key] ?? [];
|
|
575
|
+
};
|
|
576
|
+
/**
|
|
577
|
+
* P1 — add a user/group id to a platform's allowed list (draft only). The
|
|
578
|
+
* draft is seeded from the SAVED list when it has no entry for the key, so
|
|
579
|
+
* adding ONE user never hides (and on save, never silently deletes) the
|
|
580
|
+
* rest of the saved list.
|
|
581
|
+
*/
|
|
582
|
+
const addPolicyId = (platform, kind, value) => {
|
|
583
|
+
const id = value.trim();
|
|
584
|
+
if (!id)
|
|
585
|
+
return;
|
|
586
|
+
setPolicyDraft((prev) => {
|
|
587
|
+
const pol = { ...(prev[platform] ?? {}) };
|
|
588
|
+
const key = kind === 'user' ? 'allowedUsers' : 'allowedGroups';
|
|
589
|
+
const base = prev[platform]?.[key] ?? savedPolicyIds(platform, key);
|
|
590
|
+
const list = [...base];
|
|
591
|
+
if (!list.includes(id))
|
|
592
|
+
list.push(id);
|
|
593
|
+
pol[key] = list;
|
|
594
|
+
return { ...prev, [platform]: pol };
|
|
595
|
+
});
|
|
596
|
+
};
|
|
597
|
+
/**
|
|
598
|
+
* P1 — remove a user/group id from a platform's allowed list (draft only).
|
|
599
|
+
* Seeded from the SAVED list too, so removing ONE entry keeps the rest of
|
|
600
|
+
* the saved ones visible. Writing the list back — even when empty — keeps
|
|
601
|
+
* the explicit "these are the allowed ids" statement (empty = no one may
|
|
602
|
+
* trigger, per the verified-list rule). One guard: when nothing was saved
|
|
603
|
+
* for this platform AND nothing remains after the removal, the draft key is
|
|
604
|
+
* dropped so an OPEN platform stays open instead of flipping to "blank =
|
|
605
|
+
* none" from a stray ✕.
|
|
606
|
+
*/
|
|
607
|
+
const removePolicyId = (platform, kind, id) => {
|
|
608
|
+
setPolicyDraft((prev) => {
|
|
609
|
+
const key = kind === 'user' ? 'allowedUsers' : 'allowedGroups';
|
|
610
|
+
const saved = savedPolicyIds(platform, key);
|
|
611
|
+
const base = prev[platform]?.[key] ?? saved;
|
|
612
|
+
const next = base.filter((x) => x !== id);
|
|
613
|
+
if (next.length === 0 && saved.length === 0) {
|
|
614
|
+
// No saved list before and nothing left after — no change needed.
|
|
615
|
+
const pol = { ...(prev[platform] ?? {}) };
|
|
616
|
+
delete pol[key];
|
|
617
|
+
const updated = { ...prev };
|
|
618
|
+
if (Object.keys(pol).length === 0)
|
|
619
|
+
delete updated[platform];
|
|
620
|
+
else
|
|
621
|
+
updated[platform] = pol;
|
|
622
|
+
return updated;
|
|
623
|
+
}
|
|
624
|
+
const pol = { ...(prev[platform] ?? {}) };
|
|
625
|
+
pol[key] = next;
|
|
626
|
+
return { ...prev, [platform]: pol };
|
|
627
|
+
});
|
|
628
|
+
};
|
|
629
|
+
/** Digit/format-tolerant sender-id equality (mirrors the gateway's normalize). */
|
|
630
|
+
const sameVerifiedId = (a, b) => {
|
|
631
|
+
const na = a.trim().toLowerCase();
|
|
632
|
+
const nb = b.trim().toLowerCase();
|
|
633
|
+
if (na === '*' || nb === '*')
|
|
634
|
+
return true; // Allow-All wildcard
|
|
635
|
+
if (na === nb)
|
|
636
|
+
return true;
|
|
637
|
+
const da = na.replace(/\D+/g, '');
|
|
638
|
+
const db = nb.replace(/\D+/g, '');
|
|
639
|
+
return da.length > 0 && da === db;
|
|
640
|
+
};
|
|
641
|
+
/**
|
|
642
|
+
* P1 — add a verified USER as <Name> <Contact No> (CLI parity): the number
|
|
643
|
+
* goes into the allowedUsers draft, the name into the saved-contacts list.
|
|
644
|
+
*/
|
|
645
|
+
const addVerifiedUser = (platform) => {
|
|
646
|
+
const name = (contactNameInput[platform] ?? '').trim();
|
|
647
|
+
const id = (policyUserInput[platform] ?? '').trim();
|
|
648
|
+
if (!id)
|
|
649
|
+
return;
|
|
650
|
+
addPolicyId(platform, 'user', id);
|
|
651
|
+
if (name) {
|
|
652
|
+
setContacts((prev) => {
|
|
653
|
+
const rest = prev.filter((c) => !(c.platform === platform && (sameVerifiedId(c.id, id) || c.name.toLowerCase() === name.toLowerCase())));
|
|
654
|
+
return [...rest, { name, platform, id, addedAt: Date.now() }];
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
setPolicyUserInput((s) => ({ ...s, [platform]: '' }));
|
|
658
|
+
setContactNameInput((s) => ({ ...s, [platform]: '' }));
|
|
659
|
+
};
|
|
660
|
+
/** P1 — remove a verified user: allowedUsers draft + its saved contact. */
|
|
661
|
+
const removeVerifiedUser = (platform, id) => {
|
|
662
|
+
removePolicyId(platform, 'user', id);
|
|
663
|
+
setContacts((prev) => prev.filter((c) => !(c.platform === platform && sameVerifiedId(c.id, id))));
|
|
664
|
+
};
|
|
665
|
+
/** P1 — remove a saved contact from the validated list (+ its allowedUsers). */
|
|
666
|
+
const removeContact = (c) => {
|
|
667
|
+
setContacts((prev) => prev.filter((x) => !(x.platform === c.platform && sameVerifiedId(x.id, c.id))));
|
|
668
|
+
removePolicyId(c.platform, 'user', c.id);
|
|
669
|
+
};
|
|
670
|
+
/** P1 — toggle a boolean policy flag on a platform (draft only). */
|
|
671
|
+
const togglePolicyFlag = (platform, flag) => {
|
|
672
|
+
setPolicyDraft((prev) => {
|
|
673
|
+
const pol = { ...(prev[platform] ?? {}) };
|
|
674
|
+
if (flag === 'silentDrop') {
|
|
675
|
+
// HARD POLICY: silent is the default — turning the toggle OFF must
|
|
676
|
+
// write `silentDrop: false` (polite opt-in), NOT delete the key
|
|
677
|
+
// (deleting would keep the silent default).
|
|
678
|
+
pol.silentDrop = pol.silentDrop === false ? true : false;
|
|
679
|
+
}
|
|
680
|
+
else if (pol[flag])
|
|
681
|
+
delete pol[flag];
|
|
682
|
+
else
|
|
683
|
+
pol[flag] = true;
|
|
684
|
+
return { ...prev, [platform]: pol };
|
|
685
|
+
});
|
|
686
|
+
};
|
|
687
|
+
/**
|
|
688
|
+
* True when the Permissions section has UNSAVED changes (the draft holds
|
|
689
|
+
* edits, or contacts / status recipients differ from what was loaded). The
|
|
690
|
+
* whole reason this exists: "+ User" only edits the local draft — without
|
|
691
|
+
* a visible unsaved indicator, users add a contact, see the chip appear,
|
|
692
|
+
* and assume it's saved when it isn't (until "💾 Save permissions").
|
|
693
|
+
*/
|
|
694
|
+
const hasUnsavedPolicyChanges = Object.keys(policyDraft).length > 0 ||
|
|
695
|
+
(contacts.length !== (data?.channels?.contacts?.length ?? 0)) ||
|
|
696
|
+
contacts.some((c, i) => !data?.channels?.contacts?.[i] ||
|
|
697
|
+
c.platform !== data.channels.contacts[i].platform ||
|
|
698
|
+
c.id !== data.channels.contacts[i].id ||
|
|
699
|
+
c.name !== data.channels.contacts[i].name) ||
|
|
700
|
+
(statusRecipients.length !== (data?.channels?.statusRecipients?.length ?? 0)) ||
|
|
701
|
+
statusRecipients.some((r, i) => r !== data?.channels?.statusRecipients?.[i]);
|
|
702
|
+
/** P1 — save the draft policies to the gateway config (admin/operator only). */
|
|
703
|
+
const handleSavePolicies = async () => {
|
|
704
|
+
if (!authed) {
|
|
705
|
+
setPolicyMsg({ kind: 'err', text: '🔐 Log in (or set up admin access) to change permissions.' });
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (!canWrite) {
|
|
709
|
+
setPolicyMsg({ kind: 'err', text: '🔒 Your role cannot change permissions — requires the admin or operator role.' });
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
setPolicyBusy(true);
|
|
713
|
+
setPolicyMsg(null);
|
|
714
|
+
const r = await api_1.dashboardAPI.saveGatewayPolicies(policyDraft, statusRecipients, contacts);
|
|
715
|
+
if (r.ok) {
|
|
716
|
+
const warnMsg = r.contactErrors?.length ? `\n⚠️ Some contacts were rejected: ${r.contactErrors.join('; ')}` : '';
|
|
717
|
+
setPolicyMsg({ kind: warnMsg ? 'warn' : 'ok', text: `✅ Permissions + saved contacts + status recipients saved — the running gateway applies them immediately.${warnMsg}` });
|
|
718
|
+
setPolicyDraft({});
|
|
719
|
+
void refresh();
|
|
720
|
+
}
|
|
721
|
+
else if (r.unauthorized) {
|
|
722
|
+
sessionExpired();
|
|
723
|
+
setPolicyMsg({ kind: 'err', text: r.error || 'Session expired — log in again.' });
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
setPolicyMsg({ kind: 'err', text: r.error || 'Failed to save permissions.' });
|
|
727
|
+
}
|
|
728
|
+
setPolicyBusy(false);
|
|
729
|
+
};
|
|
730
|
+
// ─── Loading / auth gate ─────────────────────────────────────────────────
|
|
731
|
+
if (loading) {
|
|
732
|
+
return (<div className="admin-header">
|
|
733
|
+
<h2 className="section-title">🧰 Agent Hub</h2>
|
|
734
|
+
<div className="loading-state"><div className="loading-spinner"/><p>Loading hub…</p></div>
|
|
735
|
+
</div>);
|
|
736
|
+
}
|
|
737
|
+
const needsAuth = !authed && pendingToggle !== null;
|
|
738
|
+
return (<>
|
|
739
|
+
<div className="admin-header">
|
|
740
|
+
<h2 className="section-title">
|
|
741
|
+
🧰 Agent Hub
|
|
742
|
+
{userName ? (<span className={`admin-role-badge admin-role-${role || 'viewer'}`}>
|
|
743
|
+
{userName} · {role || 'viewer'}
|
|
744
|
+
</span>) : null}
|
|
745
|
+
</h2>
|
|
746
|
+
<div className="admin-header-actions">
|
|
747
|
+
<button className="admin-refresh-btn" onClick={() => void refresh()} disabled={refreshing}>
|
|
748
|
+
{refreshing ? '⏳ Refreshing…' : '🔄 Refresh'}
|
|
749
|
+
</button>
|
|
750
|
+
{authed ? (<button className="admin-logout-btn" onClick={() => void handleLogout()}>🚪 Log out</button>) : null}
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|
|
753
|
+
|
|
754
|
+
<p className="admin-subtitle">
|
|
755
|
+
Capabilities, messaging, artifacts and skills in one place — a single 4-view
|
|
756
|
+
management page. Toggles persist to <code>buffconfig</code> and are
|
|
757
|
+
honored by the runtime immediately (never cosmetic).
|
|
758
|
+
</p>
|
|
759
|
+
|
|
760
|
+
{error ? <div className="admin-error">{error}</div> : null}
|
|
761
|
+
|
|
762
|
+
{/* ── Auth banner (only when a toggle was attempted) ───────────────── */}
|
|
763
|
+
{needsAuth ? (<div className="admin-header">
|
|
764
|
+
<h3 className="section-subtitle">
|
|
765
|
+
{!authStatus?.configured
|
|
766
|
+
? `🔐 Set up admin access to ${pendingToggle.enabled ? 'enable' : 'disable'} '${pendingToggle.name}'`
|
|
767
|
+
: `🔐 Log in to ${pendingToggle.enabled ? 'enable' : 'disable'} '${pendingToggle.name}'`}
|
|
768
|
+
</h3>
|
|
769
|
+
<form className="admin-gate-form" onSubmit={authStatus?.configured ? handleLogin : handleSetup}>
|
|
770
|
+
<label>
|
|
771
|
+
<span>Username</span>
|
|
772
|
+
<input type="text" value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" placeholder="admin"/>
|
|
773
|
+
</label>
|
|
774
|
+
<label>
|
|
775
|
+
<span>Password</span>
|
|
776
|
+
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete={authStatus?.configured ? 'current-password' : 'new-password'} placeholder="••••••••"/>
|
|
777
|
+
</label>
|
|
778
|
+
{authError ? <div className="admin-error">{authError}</div> : null}
|
|
779
|
+
<button className="admin-refresh-btn" type="submit" disabled={authBusy || !user || !password}>
|
|
780
|
+
{authBusy ? '⏳ …' : authStatus?.configured ? '🔐 Log in & apply' : '🔐 Create admin & apply'}
|
|
781
|
+
</button>
|
|
782
|
+
{!authStatus?.configured ? (<p className="admin-hint">
|
|
783
|
+
The first user is always an admin. Automation alternative: set{' '}
|
|
784
|
+
<code>BUFF_DASHBOARD_ADMIN_USER</code> / <code>BUFF_DASHBOARD_ADMIN_PASSWORD</code>.
|
|
785
|
+
</p>) : null}
|
|
786
|
+
</form>
|
|
787
|
+
</div>) : null}
|
|
788
|
+
|
|
789
|
+
{!needsAuth && !canWrite && authed ? (<div className="admin-readonly-note">
|
|
790
|
+
🔒 Your role (<code>{role || 'viewer'}</code>) can view but not change capabilities —
|
|
791
|
+
toggles require the <code>admin</code> or <code>operator</code> role.
|
|
792
|
+
</div>) : null}
|
|
793
|
+
|
|
794
|
+
{/* ── Tabs ─────────────────────────────────────────────────────────── */}
|
|
795
|
+
<div className="hub-tabs" role="tablist">
|
|
796
|
+
{TABS.map((t) => {
|
|
797
|
+
const count = t.id === 'tools' ? (data ? `${data.toolsets.enabled}/${data.toolsets.toolsets.length}` : '')
|
|
798
|
+
: t.id === 'channels' ? (data ? String(data.channels.delivery.total) : '')
|
|
799
|
+
: t.id === 'conversations' ? (data ? String(data.conversations?.total ?? 0) : '')
|
|
800
|
+
: t.id === 'artifacts' ? (data ? String(data.artifacts.totalSessions) : '')
|
|
801
|
+
: (data ? `${data.skills.enabled}/${data.skills.total}` : '');
|
|
802
|
+
return (<button key={t.id} role="tab" aria-selected={tab === t.id} className={`hub-tab${tab === t.id ? ' active' : ''}`} onClick={() => setTab(t.id)}>
|
|
803
|
+
{t.icon} {t.label}
|
|
804
|
+
{count ? <span className="hub-tab-count">{count}</span> : null}
|
|
805
|
+
</button>);
|
|
806
|
+
})}
|
|
807
|
+
</div>
|
|
808
|
+
|
|
809
|
+
{/* ── Tools tab ────────────────────────────────────────────────────── */}
|
|
810
|
+
{tab === 'tools' && data ? (<div role="tabpanel">
|
|
811
|
+
<div className="admin-summary-grid">
|
|
812
|
+
<div className="admin-summary-card">
|
|
813
|
+
<div className="admin-summary-value">{data.toolsets.enabled}</div>
|
|
814
|
+
<div className="admin-summary-label">Enabled toolsets</div>
|
|
815
|
+
</div>
|
|
816
|
+
<div className="admin-summary-card">
|
|
817
|
+
<div className="admin-summary-value">{data.toolsets.disabled}</div>
|
|
818
|
+
<div className="admin-summary-label">Disabled toolsets</div>
|
|
819
|
+
</div>
|
|
820
|
+
<div className="admin-summary-card">
|
|
821
|
+
<div className="admin-summary-value">{data.toolsets.totalTools}</div>
|
|
822
|
+
<div className="admin-summary-label">Registered tools</div>
|
|
823
|
+
</div>
|
|
824
|
+
</div>
|
|
825
|
+
<div className="hub-toolset-grid">
|
|
826
|
+
{data.toolsets.toolsets.map((t) => (<div className={`hub-card${t.enabled ? '' : ' hub-card-disabled'}`} key={t.name}>
|
|
827
|
+
<div className="hub-card-top">
|
|
828
|
+
<div className="hub-card-title">
|
|
829
|
+
<span className="hub-card-name">{t.label}</span>
|
|
830
|
+
<span className="hub-card-id">{t.name}</span>
|
|
831
|
+
</div>
|
|
832
|
+
<button role="switch" aria-checked={t.enabled} aria-label={`${t.enabled ? 'Disable' : 'Enable'} ${t.label}`} className={`hub-switch${t.enabled ? ' on' : ''}`}
|
|
833
|
+
// Disabled while a toggle is in flight, once a toggle is
|
|
834
|
+
// queued behind the login gate (no flip-flopping), or when
|
|
835
|
+
// an AUTHENTICATED role lacks permission (viewer). When
|
|
836
|
+
// not authenticated the switch stays clickable — it queues
|
|
837
|
+
// the toggle behind the login gate instead of failing.
|
|
838
|
+
disabled={toggling === t.name || pendingToggle !== null || (authed && !canWrite)} onClick={() => toggleToolset(t.name, !t.enabled)}>
|
|
839
|
+
<span className="hub-switch-knob"/>
|
|
840
|
+
</button>
|
|
841
|
+
</div>
|
|
842
|
+
<p className="hub-card-desc">{t.description}</p>
|
|
843
|
+
<div className="hub-card-tools">
|
|
844
|
+
{t.tools.length > 0
|
|
845
|
+
? t.tools.map((tool) => <span className="hub-chip" key={tool}>{tool}</span>)
|
|
846
|
+
: <span className="admin-hint">gates dynamic MCP tools (no static names)</span>}
|
|
847
|
+
</div>
|
|
848
|
+
{rowMsg[t.name] ? <div className="admin-row-msg">{rowMsg[t.name]}</div> : null}
|
|
849
|
+
{!t.enabled ? <div className="hub-card-note">⛔ Removed from the model schema — calls return an explicit error</div> : null}
|
|
850
|
+
</div>))}
|
|
851
|
+
</div>
|
|
852
|
+
</div>) : null}
|
|
853
|
+
|
|
854
|
+
{/* ── Channels tab ─────────────────────────────────────────────────── */}
|
|
855
|
+
{tab === 'channels' && data ? (<div role="tabpanel">
|
|
856
|
+
<div className="hub-reveal-row">
|
|
857
|
+
<label className="hub-reveal-toggle">
|
|
858
|
+
<input type="checkbox" checked={revealIds} onChange={(e) => setRevealIds(e.target.checked)} disabled={!canWrite}/>
|
|
859
|
+
Show full sender ids <span className="admin-hint">(masks phone numbers by default)</span>
|
|
860
|
+
</label>
|
|
861
|
+
{!canWrite ? <span className="admin-hint">— admins and operators only</span> : null}
|
|
862
|
+
</div>
|
|
863
|
+
<div className="admin-summary-grid">
|
|
864
|
+
<div className="admin-summary-card">
|
|
865
|
+
<div className="admin-summary-value">{data.channels.delivery.pending}</div>
|
|
866
|
+
<div className="admin-summary-label">⏳ Queued for retry</div>
|
|
867
|
+
</div>
|
|
868
|
+
<div className="admin-summary-card">
|
|
869
|
+
<div className="admin-summary-value">{data.channels.delivery.sent}</div>
|
|
870
|
+
<div className="admin-summary-label">✅ Delivered</div>
|
|
871
|
+
</div>
|
|
872
|
+
<div className="admin-summary-card">
|
|
873
|
+
<div className="admin-summary-value">{data.channels.delivery.failed}</div>
|
|
874
|
+
<div className="admin-summary-label">❌ Failed (max retries)</div>
|
|
875
|
+
</div>
|
|
876
|
+
<div className="admin-summary-card">
|
|
877
|
+
<div className="admin-summary-value">{data.channels.aliases.length}</div>
|
|
878
|
+
<div className="admin-summary-label">Channel aliases</div>
|
|
879
|
+
</div>
|
|
880
|
+
</div>
|
|
881
|
+
|
|
882
|
+
<h3 className="section-subtitle">📮 Delivery ledger (guaranteed delivery)</h3>
|
|
883
|
+
{data.channels.delivery.recent.length > 0 ? (<div className="admin-table-wrapper">
|
|
884
|
+
<table className="admin-table">
|
|
885
|
+
<thead>
|
|
886
|
+
<tr>
|
|
887
|
+
<th>Target</th>
|
|
888
|
+
<th>Platform</th>
|
|
889
|
+
<th>Status</th>
|
|
890
|
+
<th>Attempts</th>
|
|
891
|
+
<th>Next retry</th>
|
|
892
|
+
<th>Message</th>
|
|
893
|
+
</tr>
|
|
894
|
+
</thead>
|
|
895
|
+
<tbody>
|
|
896
|
+
{data.channels.delivery.recent.map((e) => (<tr key={e.id}>
|
|
897
|
+
<td className="admin-provider-type">{showId(e.target)}</td>
|
|
898
|
+
<td>{e.platform}</td>
|
|
899
|
+
<td>
|
|
900
|
+
<span className={`admin-check-badge admin-check-${e.status === 'sent' ? 'pass' : e.status === 'failed' ? 'fail' : 'warn'}`}>
|
|
901
|
+
{STATUS_LABEL[e.status] || e.status}
|
|
902
|
+
</span>
|
|
903
|
+
</td>
|
|
904
|
+
<td>{e.attempts}/5</td>
|
|
905
|
+
<td>
|
|
906
|
+
{e.status === 'pending'
|
|
907
|
+
? `${Math.max(0, Math.round((e.nextAttemptAt - Date.now()) / 1000))}s`
|
|
908
|
+
: '—'}
|
|
909
|
+
</td>
|
|
910
|
+
<td className="admin-hint">{e.lastError ? `⚠️ ${e.lastError}` : (e.text || '').slice(0, 60)}</td>
|
|
911
|
+
</tr>))}
|
|
912
|
+
</tbody>
|
|
913
|
+
</table>
|
|
914
|
+
</div>) : (<div className="empty-state">Delivery ledger is empty — every gateway send has gone through.</div>)}
|
|
915
|
+
|
|
916
|
+
<h3 className="section-subtitle">📥 Inbox (received messages)</h3>
|
|
917
|
+
{data.channels.inbox.recent.length > 0 ? (<div className="admin-table-wrapper">
|
|
918
|
+
<table className="admin-table">
|
|
919
|
+
<thead>
|
|
920
|
+
<tr>
|
|
921
|
+
<th>Platform</th>
|
|
922
|
+
<th>From</th>
|
|
923
|
+
<th>Message</th>
|
|
924
|
+
<th>Handled</th>
|
|
925
|
+
<th>When</th>
|
|
926
|
+
</tr>
|
|
927
|
+
</thead>
|
|
928
|
+
<tbody>
|
|
929
|
+
{data.channels.inbox.recent.map((e) => (<tr key={e.id}>
|
|
930
|
+
<td className="admin-provider-type">
|
|
931
|
+
{e.platform}
|
|
932
|
+
{e.isGroup ? <span className="hub-chip">group</span> : null}
|
|
933
|
+
</td>
|
|
934
|
+
<td>{showId(e.from || e.senderId || e.channelId)}</td>
|
|
935
|
+
<td className="admin-hint">{(e.text || '').slice(0, 60)}</td>
|
|
936
|
+
<td>
|
|
937
|
+
<span className={`admin-check-badge admin-check-${e.handled === 'pipeline' ? 'pass' : e.handled === 'refused' ? 'fail' : 'warn'}`}>
|
|
938
|
+
{e.handled}
|
|
939
|
+
</span>
|
|
940
|
+
{e.reply ? <div className="admin-hint">{e.reply.slice(0, 40)}</div> : null}
|
|
941
|
+
</td>
|
|
942
|
+
<td className="admin-hint">{new Date(e.at).toLocaleString()}</td>
|
|
943
|
+
</tr>))}
|
|
944
|
+
</tbody>
|
|
945
|
+
</table>
|
|
946
|
+
</div>) : (<div className="empty-state">Inbox is empty — messages the gateway receives will appear here.</div>)}
|
|
947
|
+
<p className="admin-hint">
|
|
948
|
+
{data.channels.inbox.total} received · {data.channels.inbox.pipeline} triggered the pipeline ·{' '}
|
|
949
|
+
{data.channels.inbox.help} got the help line · {data.channels.inbox.refused} refused by policy.
|
|
950
|
+
</p>
|
|
951
|
+
|
|
952
|
+
<h3 className="section-subtitle">🔌 Platform transports</h3>
|
|
953
|
+
<PlatformConfigSection_1.PlatformConfigSection canWrite={canWrite} sessionExpired={sessionExpired} mode="table"/>
|
|
954
|
+
|
|
955
|
+
<h3 className="section-subtitle">🔗 Channel aliases (buff gateway status)</h3>
|
|
956
|
+
{data.channels.aliases.length > 0 ? (<div className="hub-alias-list">
|
|
957
|
+
{data.channels.aliases.map((a) => (<div className="hub-alias-row" key={a.alias}>
|
|
958
|
+
<span className="hub-chip">{a.alias}</span>
|
|
959
|
+
<span className="admin-hint">→ {a.platform}:{showId(a.channelId)}</span>
|
|
960
|
+
</div>))}
|
|
961
|
+
</div>) : (<div className="empty-state">
|
|
962
|
+
No aliases yet — register one with <code>buff gateway alias <name> <platform> <channelId></code>.
|
|
963
|
+
</div>)}
|
|
964
|
+
|
|
965
|
+
{hasUnsavedPolicyChanges ? (<div className="hub-unsaved-banner" role="status">
|
|
966
|
+
<span>
|
|
967
|
+
⚠️ <strong>Unsaved changes</strong> — what you see below is a <em>draft</em>. Press{' '}
|
|
968
|
+
<strong>💾 Save permissions</strong> to apply it to the gateway; leaving the page discards it.
|
|
969
|
+
</span>
|
|
970
|
+
<button className="admin-refresh-btn" type="button" disabled={policyBusy} onClick={() => void handleSavePolicies()}>
|
|
971
|
+
{policyBusy ? '⏳ Saving…' : '💾 Save permissions'}
|
|
972
|
+
</button>
|
|
973
|
+
</div>) : null}
|
|
974
|
+
<div style={{ cursor: 'pointer', userSelect: 'none', display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }} onClick={() => setPermissionsExpanded(!permissionsExpanded)}>
|
|
975
|
+
<h3 className="section-subtitle" style={{ margin: 0 }}>
|
|
976
|
+
{permissionsExpanded ? '▼' : '▶'} 🔐 Permissions — who can TRIGGER the agent
|
|
977
|
+
</h3>
|
|
978
|
+
</div>
|
|
979
|
+
<p className="admin-hint" style={{ marginBottom: permissionsExpanded ? 10 : 0 }}>
|
|
980
|
+
This list controls <strong>who may trigger the agent</strong> when they message you on each
|
|
981
|
+
platform (WhatsApp, Telegram, email, …). Click to {permissionsExpanded ? 'collapse' : 'expand'} and manage permissions.
|
|
982
|
+
</p>
|
|
983
|
+
{permissionsExpanded ? (<>
|
|
984
|
+
<div className="admin-hint" style={{ marginBottom: 10 }}>
|
|
985
|
+
⚠ <strong>Not the same as send-by-name contacts:</strong> a WhatsApp contact name (from{' '}
|
|
986
|
+
<code>buff whatsapp contact add</code>, shown in the WhatsApp bridge panel below) only lets{' '}
|
|
987
|
+
<em>you</em> send <em>to</em> it by name — it does <strong>not</strong> let that number trigger
|
|
988
|
+
the agent. Add the number here (or <code>buff config gateway allow <platform> user
|
|
989
|
+
<id></code>) to grant inbound access.
|
|
990
|
+
</div>
|
|
991
|
+
<div className="hub-permissions">
|
|
992
|
+
{data.channels.platforms.map((p) => {
|
|
993
|
+
// Merge the draft OVER the saved policy: a partial draft (e.g. a
|
|
994
|
+
// single removed user, or a flag toggle) must not blank the
|
|
995
|
+
// saved list — otherwise removing one contact hid all of them.
|
|
996
|
+
const pol = { ...(data.channels.policies[p.platform] ?? {}), ...(policyDraft[p.platform] ?? {}) };
|
|
997
|
+
const users = pol.allowedUsers ?? [];
|
|
998
|
+
const groups = pol.allowedGroups ?? [];
|
|
999
|
+
return (<div className="hub-card" key={p.platform}>
|
|
1000
|
+
<div className="hub-card-top">
|
|
1001
|
+
<span className="hub-chip">{p.label}</span>
|
|
1002
|
+
<span className="hub-card-id">{p.platform}</span>
|
|
1003
|
+
{pol.disabled ? <span className="hub-chip">disabled</span> : null}
|
|
1004
|
+
</div>
|
|
1005
|
+
<div className="admin-hint" style={{ margin: '4px 0 8px' }}>Allowed users — who may trigger the agent</div>
|
|
1006
|
+
<div className="hub-alias-list">
|
|
1007
|
+
{users.length === 0 ? <span className="admin-hint">(blank — no one may trigger; add <code>Allow-All</code> to allow everyone)</span> : null}
|
|
1008
|
+
{users.map((u) => {
|
|
1009
|
+
// A chip that only exists in the DRAFT (not yet saved) is
|
|
1010
|
+
// marked pending so adding a user can't be mistaken for
|
|
1011
|
+
// having saved it.
|
|
1012
|
+
const pending = (policyDraft[p.platform]?.allowedUsers ?? []).includes(u) &&
|
|
1013
|
+
!(data?.channels?.policies?.[p.platform]?.allowedUsers ?? []).includes(u);
|
|
1014
|
+
return (<div className="hub-alias-row" key={`u-${u}`}>
|
|
1015
|
+
<span className={`hub-chip${pending ? ' hub-chip-pending' : ''}`} title={pending ? 'Not saved yet — press 💾 Save permissions' : undefined}>
|
|
1016
|
+
{showId(u)}{pending ? ' · pending' : ''}
|
|
1017
|
+
</span>
|
|
1018
|
+
<button className="admin-refresh-btn" disabled={policyBusy} onClick={() => removeVerifiedUser(p.platform, u)}>✕</button>
|
|
1019
|
+
</div>);
|
|
1020
|
+
})}
|
|
1021
|
+
</div>
|
|
1022
|
+
<div className="hub-send-form" style={{ margin: '6px 0 10px' }}>
|
|
1023
|
+
<input type="text" value={contactNameInput[p.platform] ?? ''} onChange={(e) => setContactNameInput((s) => ({ ...s, [p.platform]: e.target.value }))} placeholder="Name (optional)" disabled={policyBusy}/>
|
|
1024
|
+
<input type="text" value={policyUserInput[p.platform] ?? ''} onChange={(e) => setPolicyUserInput((s) => ({ ...s, [p.platform]: e.target.value }))} placeholder="Contact no / sender id, or Allow-All" disabled={policyBusy}/>
|
|
1025
|
+
<button className="admin-refresh-btn" disabled={policyBusy || !(policyUserInput[p.platform] ?? '').trim()} onClick={() => addVerifiedUser(p.platform)}>+ User</button>
|
|
1026
|
+
</div>
|
|
1027
|
+
<div className="admin-hint" style={{ margin: '4px 0 8px' }}>Allowed groups</div>
|
|
1028
|
+
<div className="hub-alias-list">
|
|
1029
|
+
{groups.length === 0 ? <span className="admin-hint">(none — any group may trigger)</span> : null}
|
|
1030
|
+
{groups.map((g) => {
|
|
1031
|
+
const pending = (policyDraft[p.platform]?.allowedGroups ?? []).includes(g) &&
|
|
1032
|
+
!(data?.channels?.policies?.[p.platform]?.allowedGroups ?? []).includes(g);
|
|
1033
|
+
return (<div className="hub-alias-row" key={`g-${g}`}>
|
|
1034
|
+
<span className={`hub-chip${pending ? ' hub-chip-pending' : ''}`} title={pending ? 'Not saved yet — press 💾 Save permissions' : undefined}>
|
|
1035
|
+
{g}{pending ? ' · pending' : ''}
|
|
1036
|
+
</span>
|
|
1037
|
+
<button className="admin-refresh-btn" disabled={policyBusy} onClick={() => removePolicyId(p.platform, 'group', g)}>✕</button>
|
|
1038
|
+
</div>);
|
|
1039
|
+
})}
|
|
1040
|
+
</div>
|
|
1041
|
+
<div className="hub-send-form" style={{ margin: '6px 0 10px' }}>
|
|
1042
|
+
<input type="text" value={policyGroupInput[p.platform] ?? ''} onChange={(e) => setPolicyGroupInput((s) => ({ ...s, [p.platform]: e.target.value }))} placeholder="group id, e.g. 1203630283471234@g.us" disabled={policyBusy}/>
|
|
1043
|
+
<button className="admin-refresh-btn" disabled={policyBusy || !(policyGroupInput[p.platform] ?? '').trim()} onClick={() => { addPolicyId(p.platform, 'group', policyGroupInput[p.platform] ?? ''); setPolicyGroupInput((s) => ({ ...s, [p.platform]: '' })); }}>+ Group</button>
|
|
1044
|
+
</div>
|
|
1045
|
+
<div className="hub-send-form" style={{ margin: '6px 0 0' }}>
|
|
1046
|
+
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, marginRight: 12 }}>
|
|
1047
|
+
<input type="checkbox" checked={pol.silentDrop !== false} disabled={policyBusy} onChange={() => togglePolicyFlag(p.platform, 'silentDrop')}/>
|
|
1048
|
+
<span className="admin-hint">Silent (no reply to unapproved) — default</span>
|
|
1049
|
+
</label>
|
|
1050
|
+
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, marginRight: 12 }}>
|
|
1051
|
+
<input type="checkbox" checked={Boolean(pol.requireMention)} disabled={policyBusy} onChange={() => togglePolicyFlag(p.platform, 'requireMention')}/>
|
|
1052
|
+
<span className="admin-hint">Groups: mention only</span>
|
|
1053
|
+
</label>
|
|
1054
|
+
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
1055
|
+
<input type="checkbox" checked={Boolean(pol.disabled)} disabled={policyBusy} onChange={() => togglePolicyFlag(p.platform, 'disabled')}/>
|
|
1056
|
+
<span className="admin-hint">Disabled (off)</span>
|
|
1057
|
+
</label>
|
|
1058
|
+
</div>
|
|
1059
|
+
</div>);
|
|
1060
|
+
})}
|
|
1061
|
+
</div>
|
|
1062
|
+
|
|
1063
|
+
</>) : null}
|
|
1064
|
+
<h3 className="section-subtitle" style={{ marginTop: 22 }}>📇 Telegram User Onboarding</h3>
|
|
1065
|
+
<div className="onboarding-summary">
|
|
1066
|
+
<p className="admin-hint" style={{ marginBottom: 12 }}>
|
|
1067
|
+
Users who message your bot are auto-registered here. Approve them to enable outbound messaging.
|
|
1068
|
+
</p>
|
|
1069
|
+
{(() => {
|
|
1070
|
+
const pending = contacts.filter((c) => c.status === 'pending').length;
|
|
1071
|
+
const approved = contacts.filter((c) => c.status === 'approved').length;
|
|
1072
|
+
const rejected = contacts.filter((c) => c.status === 'rejected').length;
|
|
1073
|
+
return (<div className="onboarding-stats">
|
|
1074
|
+
<span className="onboarding-stat pending">⏳ {pending} pending</span>
|
|
1075
|
+
<span className="onboarding-stat approved">✅ {approved} approved</span>
|
|
1076
|
+
<span className="onboarding-stat rejected">🚫 {rejected} rejected</span>
|
|
1077
|
+
</div>);
|
|
1078
|
+
})()}
|
|
1079
|
+
<div className="onboarding-steps">
|
|
1080
|
+
<div className="onboarding-step">
|
|
1081
|
+
<span className="onboarding-step-num">1</span>
|
|
1082
|
+
<span>User sends any message to <code>@agent_nuvira_bot</code></span>
|
|
1083
|
+
</div>
|
|
1084
|
+
<div className="onboarding-step">
|
|
1085
|
+
<span className="onboarding-step-num">2</span>
|
|
1086
|
+
<span>Bot auto-registers them as <strong>⏳ pending</strong></span>
|
|
1087
|
+
</div>
|
|
1088
|
+
<div className="onboarding-step">
|
|
1089
|
+
<span className="onboarding-step-num">3</span>
|
|
1090
|
+
<span>Admin approves → they can receive outbound messages</span>
|
|
1091
|
+
</div>
|
|
1092
|
+
</div>
|
|
1093
|
+
<a href="/contacts" className="admin-refresh-btn" style={{ marginTop: 12, display: 'inline-block', textDecoration: 'none' }}>
|
|
1094
|
+
📋 Go to Contacts tab to manage →
|
|
1095
|
+
</a>
|
|
1096
|
+
</div>
|
|
1097
|
+
|
|
1098
|
+
<h3 className="section-subtitle" style={{ marginTop: 22 }}>📊 Status recipients</h3>
|
|
1099
|
+
<p className="admin-hint">
|
|
1100
|
+
Contacts/groups that ALWAYS receive the pipeline completion summary, whoever triggered it.
|
|
1101
|
+
Use a contact name, number, or <code>platform:channelId</code> — e.g.{' '}
|
|
1102
|
+
<code>whatsapp:Name</code>, <code>whatsapp:+91***</code>, <code>telegram:123456</code>,{' '}
|
|
1103
|
+
<code>slack:ops</code>.
|
|
1104
|
+
</p>
|
|
1105
|
+
{statusRecipients.length > 0 ? (<div className="hub-alias-list">
|
|
1106
|
+
{statusRecipients.map((t) => (<div className="hub-alias-row" key={t}>
|
|
1107
|
+
<span className="hub-chip">📊</span>
|
|
1108
|
+
<span className="admin-hint">{data?.channels?.statusRecipientDisplay?.[t] ?? t}</span>
|
|
1109
|
+
<button className="admin-refresh-btn" disabled={policyBusy} onClick={() => setStatusRecipients((s) => s.filter((x) => x !== t))}>✕</button>
|
|
1110
|
+
</div>))}
|
|
1111
|
+
</div>) : (<div className="empty-state" style={{ padding: '14px' }}>No status recipients — add one below.</div>)}
|
|
1112
|
+
<div className="hub-send-form">
|
|
1113
|
+
<input type="text" value={statusRecipientInput} onChange={(e) => setStatusRecipientInput(e.target.value)} placeholder="whatsapp:Name or whatsapp:+91***" disabled={policyBusy} maxLength={128}/>
|
|
1114
|
+
<button className="admin-refresh-btn" disabled={policyBusy || !statusRecipientInput.trim() || statusRecipients.includes(statusRecipientInput.trim())} onClick={() => { setStatusRecipients((s) => [...s, statusRecipientInput.trim()]); setStatusRecipientInput(''); }}>+ Add</button>
|
|
1115
|
+
</div>
|
|
1116
|
+
<div className="hub-send-form" style={{ marginTop: 10 }}>
|
|
1117
|
+
<button className="admin-refresh-btn" type="button" disabled={policyBusy} onClick={() => void handleSavePolicies()}>
|
|
1118
|
+
{policyBusy ? '⏳ Saving…' : '💾 Save permissions'}
|
|
1119
|
+
</button>
|
|
1120
|
+
{Object.keys(policyDraft).length > 0 ? (<button className="admin-refresh-btn" type="button" disabled={policyBusy} onClick={() => setPolicyDraft({})}>
|
|
1121
|
+
Discard draft
|
|
1122
|
+
</button>) : null}
|
|
1123
|
+
</div>
|
|
1124
|
+
{policyMsg ? (<div className={`admin-row-msg${policyMsg.kind === 'ok' ? '' : ' admin-row-msg-err'}`}>{policyMsg.text}</div>) : null}
|
|
1125
|
+
|
|
1126
|
+
<h3 className="section-subtitle">📤 Test a channel (buff gateway send)</h3>
|
|
1127
|
+
<form className="hub-send-form" onSubmit={(e) => void handleSendMessage(e)}>
|
|
1128
|
+
<label className="hub-send-target">
|
|
1129
|
+
<span className="admin-hint">Target — an alias or platform:channelId</span>
|
|
1130
|
+
<input type="text" value={sendTarget} onChange={(e) => setSendTarget(e.target.value)} placeholder="ops or slack:C0123 or email:team@example.com" disabled={sending} maxLength={128}/>
|
|
1131
|
+
</label>
|
|
1132
|
+
<label className="hub-send-message">
|
|
1133
|
+
<span className="admin-hint">Message</span>
|
|
1134
|
+
<input type="text" value={sendText} onChange={(e) => setSendText(e.target.value)} placeholder="nightly build done 🎉" disabled={sending} maxLength={4000}/>
|
|
1135
|
+
</label>
|
|
1136
|
+
<button className="admin-refresh-btn" type="submit" disabled={sending || !sendTarget.trim() || !sendText}>
|
|
1137
|
+
{sending ? '⏳ Sending…' : '📤 Send test message'}
|
|
1138
|
+
</button>
|
|
1139
|
+
</form>
|
|
1140
|
+
{sendMsg ? (<div className={`admin-row-msg${sendMsg.kind === 'ok' ? '' : ' admin-row-msg-err'}`}>{sendMsg.text}</div>) : null}
|
|
1141
|
+
<p className="admin-hint">
|
|
1142
|
+
Sends through the same gateway the CLI uses — the dashboard process must have the
|
|
1143
|
+
platform's env token set (e.g. <code>BUFF_SMTP_HOST</code>). Requires admin or operator.
|
|
1144
|
+
</p>
|
|
1145
|
+
|
|
1146
|
+
<h3 className="section-subtitle">🟢 WhatsApp bridge (buff whatsapp pair)</h3>
|
|
1147
|
+
<WhatsAppPanel_1.default authed={authed} canWrite={canWrite} sessionExpired={sessionExpired} reveal={revealIds}/>
|
|
1148
|
+
</div>) : null}
|
|
1149
|
+
|
|
1150
|
+
{/* ── Conversations tab ──────────────────────────────────────────── */}
|
|
1151
|
+
{tab === 'conversations' && data ? (<div role="tabpanel">
|
|
1152
|
+
<div className="admin-summary-grid">
|
|
1153
|
+
<div className="admin-summary-card">
|
|
1154
|
+
<div className="admin-summary-value">{data.conversations?.total ?? 0}</div>
|
|
1155
|
+
<div className="admin-summary-label">Stored conversations</div>
|
|
1156
|
+
</div>
|
|
1157
|
+
<div className="admin-summary-card">
|
|
1158
|
+
<div className="admin-summary-value">{convList.filter((c) => Date.now() - c.lastActiveAt < 86_400_000).length}</div>
|
|
1159
|
+
<div className="admin-summary-label">Active today</div>
|
|
1160
|
+
</div>
|
|
1161
|
+
<div className="admin-summary-card">
|
|
1162
|
+
<div className="admin-summary-value">{convList.filter((c) => c.platform === 'whatsapp').length}</div>
|
|
1163
|
+
<div className="admin-summary-label">WhatsApp</div>
|
|
1164
|
+
</div>
|
|
1165
|
+
<div className="admin-summary-card">
|
|
1166
|
+
<div className="admin-summary-value">{convList.filter((c) => c.platform === 'telegram').length}</div>
|
|
1167
|
+
<div className="admin-summary-label">Telegram</div>
|
|
1168
|
+
</div>
|
|
1169
|
+
</div>
|
|
1170
|
+
{/* Analytics toggle + section */}
|
|
1171
|
+
{data.conversations?.analytics && data.conversations.analytics.totalConversations > 0 && (<>
|
|
1172
|
+
<button type="button" className="admin-refresh-btn" onClick={() => setShowAnalytics(!showAnalytics)} style={{ marginBottom: 12 }}>
|
|
1173
|
+
{showAnalytics ? '📊 Hide Analytics' : '📊 Show Analytics'}
|
|
1174
|
+
</button>
|
|
1175
|
+
{showAnalytics ? (<div className="hub-session-list" style={{ marginBottom: 16 }}>
|
|
1176
|
+
{/* Summary stats */}
|
|
1177
|
+
<div className="admin-summary-grid">
|
|
1178
|
+
<div className="admin-summary-card">
|
|
1179
|
+
<div className="admin-summary-value">{data.conversations.analytics.totalMessages}</div>
|
|
1180
|
+
<div className="admin-summary-label">Total messages</div>
|
|
1181
|
+
</div>
|
|
1182
|
+
<div className="admin-summary-card">
|
|
1183
|
+
<div className="admin-summary-value">{data.conversations.analytics.avgMessagesPerConversation}</div>
|
|
1184
|
+
<div className="admin-summary-label">Avg msgs/conversation</div>
|
|
1185
|
+
</div>
|
|
1186
|
+
<div className="admin-summary-card">
|
|
1187
|
+
<div className="admin-summary-value">{data.conversations.analytics.avgUserMessageLength}</div>
|
|
1188
|
+
<div className="admin-summary-label">Avg user msg length</div>
|
|
1189
|
+
</div>
|
|
1190
|
+
<div className="admin-summary-card">
|
|
1191
|
+
<div className="admin-summary-value">{data.conversations.analytics.avgAssistantMessageLength}</div>
|
|
1192
|
+
<div className="admin-summary-label">Avg assistant msg length</div>
|
|
1193
|
+
</div>
|
|
1194
|
+
</div>
|
|
1195
|
+
{/* Top contacts */}
|
|
1196
|
+
<div className="hub-card" style={{ marginTop: 12 }}>
|
|
1197
|
+
<div className="hub-card-top">
|
|
1198
|
+
<div className="hub-card-title">
|
|
1199
|
+
<span className="hub-card-name">🏆 Most Active Contacts</span>
|
|
1200
|
+
</div>
|
|
1201
|
+
</div>
|
|
1202
|
+
<div style={{ marginTop: 8 }}>
|
|
1203
|
+
{data.conversations.analytics.topContacts.map((c, i) => {
|
|
1204
|
+
const maxCount = data.conversations.analytics.topContacts[0]?.messageCount || 1;
|
|
1205
|
+
const pct = Math.round((c.messageCount / maxCount) * 100);
|
|
1206
|
+
return (<div key={i} style={{ display: 'flex', alignItems: 'center', marginBottom: 6, fontSize: 13 }}>
|
|
1207
|
+
<span style={{ width: 24, textAlign: 'center', fontWeight: 600, color: i < 3 ? '#f59e0b' : 'var(--muted)' }}>
|
|
1208
|
+
{i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`}
|
|
1209
|
+
</span>
|
|
1210
|
+
<span style={{ flex: 1, marginLeft: 8 }}>
|
|
1211
|
+
{c.platform === 'whatsapp' ? '📱' : '✈️'} {c.name}
|
|
1212
|
+
</span>
|
|
1213
|
+
<span style={{ width: 60, textAlign: 'right', fontWeight: 600 }}>{c.messageCount}</span>
|
|
1214
|
+
<div style={{ width: 100, height: 8, background: 'var(--border)', borderRadius: 4, marginLeft: 8 }}>
|
|
1215
|
+
<div style={{ width: `${pct}%`, height: '100%', background: '#3b82f6', borderRadius: 4 }}/>
|
|
1216
|
+
</div>
|
|
1217
|
+
</div>);
|
|
1218
|
+
})}
|
|
1219
|
+
</div>
|
|
1220
|
+
</div>
|
|
1221
|
+
{/* Hourly distribution */}
|
|
1222
|
+
<div className="hub-card" style={{ marginTop: 12 }}>
|
|
1223
|
+
<div className="hub-card-top">
|
|
1224
|
+
<div className="hub-card-title">
|
|
1225
|
+
<span className="hub-card-name">🕐 Peak Hours</span>
|
|
1226
|
+
</div>
|
|
1227
|
+
</div>
|
|
1228
|
+
<div style={{ display: 'flex', alignItems: 'flex-end', height: 80, marginTop: 8, gap: 2 }}>
|
|
1229
|
+
{data.conversations.analytics.hourlyDistribution.map(({ hour, count }) => {
|
|
1230
|
+
const maxCount = Math.max(...data.conversations.analytics.hourlyDistribution.map((h) => h.count), 1);
|
|
1231
|
+
const h = Math.round((count / maxCount) * 70);
|
|
1232
|
+
return (<div key={hour} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
|
1233
|
+
<div style={{ width: '100%', height: h, background: hour >= 9 && hour <= 21 ? '#3b82f6' : '#6b7280', borderRadius: 2 }} title={`${hour}:00 — ${count} messages`}/>
|
|
1234
|
+
</div>);
|
|
1235
|
+
})}
|
|
1236
|
+
</div>
|
|
1237
|
+
<div style={{ display: 'flex', fontSize: 10, color: 'var(--muted)', marginTop: 4 }}>
|
|
1238
|
+
{[0, 6, 12, 18, 23].map((h) => (<span key={h} style={{ flex: 1, textAlign: h === 0 ? 'left' : h === 23 ? 'right' : 'center' }}>{h}:00</span>))}
|
|
1239
|
+
</div>
|
|
1240
|
+
</div>
|
|
1241
|
+
{/* Platform breakdown */}
|
|
1242
|
+
<div className="hub-card" style={{ marginTop: 12 }}>
|
|
1243
|
+
<div className="hub-card-top">
|
|
1244
|
+
<div className="hub-card-title">
|
|
1245
|
+
<span className="hub-card-name">📱 Platform Breakdown</span>
|
|
1246
|
+
</div>
|
|
1247
|
+
</div>
|
|
1248
|
+
<div style={{ marginTop: 8 }}>
|
|
1249
|
+
{data.conversations.analytics.platformBreakdown.map((p) => (<div key={p.platform} style={{ display: 'flex', alignItems: 'center', marginBottom: 6, fontSize: 13 }}>
|
|
1250
|
+
<span className="hub-chip" style={{ width: 80, textAlign: 'center' }}>{p.platform}</span>
|
|
1251
|
+
<span style={{ marginLeft: 8, flex: 1 }}>{p.conversations} conversations</span>
|
|
1252
|
+
<span style={{ fontWeight: 600 }}>{p.messages} msgs</span>
|
|
1253
|
+
</div>))}
|
|
1254
|
+
</div>
|
|
1255
|
+
</div>
|
|
1256
|
+
{/* Daily volume (last 14 days) */}
|
|
1257
|
+
<div className="hub-card" style={{ marginTop: 12 }}>
|
|
1258
|
+
<div className="hub-card-top">
|
|
1259
|
+
<div className="hub-card-title">
|
|
1260
|
+
<span className="hub-card-name">📈 Daily Volume (14 days)</span>
|
|
1261
|
+
</div>
|
|
1262
|
+
</div>
|
|
1263
|
+
<div style={{ display: 'flex', alignItems: 'flex-end', height: 60, marginTop: 8, gap: 2 }}>
|
|
1264
|
+
{data.conversations.analytics.dailyVolume.map(({ date, count }) => {
|
|
1265
|
+
const maxCount = Math.max(...data.conversations.analytics.dailyVolume.map((d) => d.count), 1);
|
|
1266
|
+
const h = Math.round((count / maxCount) * 50);
|
|
1267
|
+
return (<div key={date} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
|
1268
|
+
<div style={{ width: '100%', height: h, background: '#10b981', borderRadius: 2 }} title={`${date}: ${count} messages`}/>
|
|
1269
|
+
</div>);
|
|
1270
|
+
})}
|
|
1271
|
+
</div>
|
|
1272
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, color: 'var(--muted)', marginTop: 4 }}>
|
|
1273
|
+
<span>{data.conversations.analytics.dailyVolume[0]?.date}</span>
|
|
1274
|
+
<span>{data.conversations.analytics.dailyVolume[data.conversations.analytics.dailyVolume.length - 1]?.date}</span>
|
|
1275
|
+
</div>
|
|
1276
|
+
</div>
|
|
1277
|
+
</div>) : null}
|
|
1278
|
+
</>)}
|
|
1279
|
+
<h3 className="section-subtitle">💬 Per-contact chat history</h3>
|
|
1280
|
+
<p className="admin-hint">Conversation history is stored per-contact and survives gateway restarts. Messages older than 7 days are auto-pruned. Click a conversation to expand the full chat thread.</p>
|
|
1281
|
+
{convList.length > 0 || convLoading ? (<>
|
|
1282
|
+
{/* Tag filter chips */}
|
|
1283
|
+
{allTags.length > 0 && (<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
|
|
1284
|
+
<span className="admin-hint" style={{ marginRight: 4 }}>Filter by tag:</span>
|
|
1285
|
+
{allTags.map((tag) => (<button key={tag} type="button" onClick={() => setTagFilter(tagFilter === tag ? '' : tag)} style={{ padding: '2px 10px', borderRadius: 12, fontSize: 11, cursor: 'pointer', border: `1px solid ${tagFilter === tag ? '#3b82f6' : 'var(--border)'}`, background: tagFilter === tag ? '#3b82f620' : 'transparent', color: tagFilter === tag ? '#3b82f6' : 'var(--muted)' }}>
|
|
1286
|
+
🏷️ {tag}
|
|
1287
|
+
</button>))}
|
|
1288
|
+
</div>)}
|
|
1289
|
+
{/* Search filter + selection toolbar */}
|
|
1290
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
|
1291
|
+
<input type="text" value={convSearch} onChange={(e) => setConvSearch(e.target.value)} placeholder="🔍 Search by contact name, phone number, or message content…" style={{ flex: 1, minWidth: 200 }}/>
|
|
1292
|
+
<span className="admin-hint">
|
|
1293
|
+
{convTotal} conversation{convTotal === 1 ? '' : 's'}
|
|
1294
|
+
{convSearch ? ` matching "${convSearch}"` : ''}
|
|
1295
|
+
</span>
|
|
1296
|
+
<button type="button" className="admin-refresh-btn" onClick={toggleSelectAll} style={{ fontSize: 12, padding: '4px 10px' }}>
|
|
1297
|
+
{selectedConvs.size === convList.length ? '☑ Deselect All' : '☐ Select All'}
|
|
1298
|
+
</button>
|
|
1299
|
+
{selectedConvs.size > 0 && (<button type="button" className="admin-refresh-btn" disabled={exporting} onClick={() => void handleBulkExport()} style={{ fontSize: 12, padding: '4px 10px', background: '#3b82f6', color: '#fff', border: 'none' }}>
|
|
1300
|
+
{exporting ? '⏳ Exporting…' : `📦 Export ${selectedConvs.size} as ZIP`}
|
|
1301
|
+
</button>)}
|
|
1302
|
+
</div>
|
|
1303
|
+
{/* Conversation cards (not a table — each card expands into a chat thread) */}
|
|
1304
|
+
<div className="hub-session-list">
|
|
1305
|
+
{convList
|
|
1306
|
+
.filter((c) => !tagFilter || (c.tags ?? []).includes(tagFilter))
|
|
1307
|
+
.map((c) => {
|
|
1308
|
+
const age = Date.now() - c.lastActiveAt;
|
|
1309
|
+
const ageStr = age < 60_000 ? 'just now' : age < 3_600_000 ? `${Math.round(age / 60_000)}m ago` : age < 86_400_000 ? `${Math.round(age / 3_600_000)}h ago` : `${Math.round(age / 86_400_000)}d ago`;
|
|
1310
|
+
const isExpanded = expandedConv === c.key;
|
|
1311
|
+
const { name: displayName, phone } = convDisplayLabel(c);
|
|
1312
|
+
const handleClear = async (e) => {
|
|
1313
|
+
e.stopPropagation();
|
|
1314
|
+
if (!authed || !canWrite) {
|
|
1315
|
+
setPolicyMsg({ kind: 'err', text: '🔐 Log in (admin/operator) to clear conversations.' });
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
setClearingConv(c.key);
|
|
1319
|
+
try {
|
|
1320
|
+
// We need a DELETE endpoint — for now call the gateway history CLI endpoint.
|
|
1321
|
+
// TODO: Add proper API endpoint.
|
|
1322
|
+
await api_1.dashboardAPI.clearGatewayConversation(c.key);
|
|
1323
|
+
void refresh();
|
|
1324
|
+
}
|
|
1325
|
+
catch { /* best-effort */ }
|
|
1326
|
+
setClearingConv(null);
|
|
1327
|
+
};
|
|
1328
|
+
const isSelected = selectedConvs.has(c.key);
|
|
1329
|
+
return (<div className="hub-card" key={c.key} style={{ cursor: 'pointer', borderLeft: `3px solid ${c.platform === 'whatsapp' ? '#25d366' : c.platform === 'telegram' ? '#0088cc' : '#6c757d'}`, opacity: selectedConvs.size > 0 && !isSelected ? 0.5 : 1, transition: 'opacity 0.2s' }} onClick={() => setExpandedConv(isExpanded ? null : c.key)}>
|
|
1330
|
+
<div className="hub-card-top">
|
|
1331
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
1332
|
+
<input type="checkbox" checked={isSelected} onClick={(e) => toggleConvSelection(c.key, e)} onChange={() => { }} style={{ cursor: 'pointer', width: 16, height: 16 }}/>
|
|
1333
|
+
<div className="hub-card-title">
|
|
1334
|
+
<span className="hub-card-name">
|
|
1335
|
+
{c.platform === 'whatsapp' ? '📱' : c.platform === 'telegram' ? '✈️' : '💬'} {displayName}
|
|
1336
|
+
</span>
|
|
1337
|
+
<span className="hub-card-id">{phone && <span style={{ color: 'var(--text-secondary, #8b949e)', marginRight: 6 }}>{phone}</span>}{c.platform} · {ageStr}</span>
|
|
1338
|
+
</div>
|
|
1339
|
+
</div>
|
|
1340
|
+
<div className="hub-card-actions" style={{ gap: 6 }}>
|
|
1341
|
+
<span className="hub-chip">{c.messageCount} msgs</span>
|
|
1342
|
+
{isExpanded && c.messages && c.messages.length > 0 && (<>
|
|
1343
|
+
<button type="button" className="hub-mini-btn" onClick={(e) => exportConversationText(c, e)} title="Export as text file">
|
|
1344
|
+
📄
|
|
1345
|
+
</button>
|
|
1346
|
+
<button type="button" className="hub-mini-btn" onClick={(e) => exportConversationPDF(c, e)} title="Export as PDF (opens print dialog)">
|
|
1347
|
+
📑
|
|
1348
|
+
</button>
|
|
1349
|
+
</>)}
|
|
1350
|
+
<button type="button" className="hub-mini-btn" disabled={clearingConv === c.key} onClick={handleClear} title="Clear conversation history" style={{ color: '#ef4444' }}>
|
|
1351
|
+
🗑️
|
|
1352
|
+
</button>
|
|
1353
|
+
<span style={{ fontSize: 18, transition: 'transform 0.2s', transform: isExpanded ? 'rotate(180deg)' : 'none' }}>▼</span>
|
|
1354
|
+
</div>
|
|
1355
|
+
</div>
|
|
1356
|
+
<p className="hub-card-desc" style={{ fontStyle: 'italic', color: 'var(--text-muted, #6e7681)' }}>
|
|
1357
|
+
<span style={{ color: 'var(--text-primary, #e6edf3)', fontWeight: 500 }}>Last message:</span>{' '}
|
|
1358
|
+
{c.lastUserMessage.slice(0, 120)}{c.lastUserMessage.length > 120 ? '…' : ''}
|
|
1359
|
+
</p>
|
|
1360
|
+
{/* Tags */}
|
|
1361
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
|
|
1362
|
+
{(c.tags ?? []).map((tag) => (<span key={tag} style={{ display: 'inline-flex', alignItems: 'center', gap: 2, padding: '2px 8px', borderRadius: 12, fontSize: 11, background: '#3b82f620', color: '#3b82f6', border: '1px solid #3b82f640' }}>
|
|
1363
|
+
🏷️ {tag}
|
|
1364
|
+
<button type="button" onClick={(e) => { e.stopPropagation(); void handleRemoveTag(c.key, tag); }} style={{ background: 'none', border: 'none', color: '#3b82f6', cursor: 'pointer', padding: 0, fontSize: 12, lineHeight: 1 }}>
|
|
1365
|
+
×
|
|
1366
|
+
</button>
|
|
1367
|
+
</span>))}
|
|
1368
|
+
{addingTagTo === c.key ? (<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>
|
|
1369
|
+
<input type="text" value={newTagValue} onChange={(e) => setNewTagValue(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') {
|
|
1370
|
+
void handleAddTag(c.key, newTagValue);
|
|
1371
|
+
} if (e.key === 'Escape') {
|
|
1372
|
+
setAddingTagTo(null);
|
|
1373
|
+
setNewTagValue('');
|
|
1374
|
+
} }} placeholder="tag name" autoFocus onClick={(e) => e.stopPropagation()} style={{ width: 80, padding: '1px 6px', fontSize: 11, borderRadius: 8, border: '1px solid var(--border)' }}/>
|
|
1375
|
+
</span>) : (<button type="button" onClick={(e) => { e.stopPropagation(); setAddingTagTo(c.key); setNewTagValue(''); }} style={{ display: 'inline-flex', alignItems: 'center', gap: 2, padding: '2px 8px', borderRadius: 12, fontSize: 11, background: 'var(--border)', color: 'var(--muted)', border: 'none', cursor: 'pointer' }}>
|
|
1376
|
+
+ tag
|
|
1377
|
+
</button>)}
|
|
1378
|
+
</div>
|
|
1379
|
+
{isExpanded && c.messages && c.messages.length > 0 ? (<div className="conversation-thread" style={{ marginTop: 10, borderTop: '1px solid var(--border)', paddingTop: 10, maxHeight: 400, overflowY: 'auto' }}>
|
|
1380
|
+
{c.messages.map((m, i) => (<div key={i} style={{
|
|
1381
|
+
marginBottom: 8,
|
|
1382
|
+
display: 'flex',
|
|
1383
|
+
flexDirection: 'column',
|
|
1384
|
+
alignItems: m.role === 'user' ? 'flex-end' : 'flex-start',
|
|
1385
|
+
}}>
|
|
1386
|
+
<div style={{
|
|
1387
|
+
maxWidth: '80%',
|
|
1388
|
+
padding: '8px 12px',
|
|
1389
|
+
borderRadius: 12,
|
|
1390
|
+
backgroundColor: m.role === 'user' ? '#007bff' : 'var(--bg-card, #1a1f2e)',
|
|
1391
|
+
color: m.role === 'user' ? '#fff' : 'var(--text-primary, #e6edf3)',
|
|
1392
|
+
fontSize: 13,
|
|
1393
|
+
lineHeight: 1.5,
|
|
1394
|
+
whiteSpace: 'pre-wrap',
|
|
1395
|
+
wordBreak: 'break-word',
|
|
1396
|
+
}}>
|
|
1397
|
+
{m.content.slice(0, 500)}{m.content.length > 500 ? '…' : ''}
|
|
1398
|
+
</div>
|
|
1399
|
+
<span style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2, padding: '0 4px' }}>
|
|
1400
|
+
{m.role === 'user' ? '👤' : '🤖'} {new Date(m.ts).toLocaleString()}
|
|
1401
|
+
</span>
|
|
1402
|
+
</div>))}
|
|
1403
|
+
{/* Typing indicator */}
|
|
1404
|
+
{typingContacts.has(c.key) && (<div style={{ marginBottom: 8, display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
|
1405
|
+
<div style={{
|
|
1406
|
+
padding: '8px 16px',
|
|
1407
|
+
borderRadius: 12,
|
|
1408
|
+
backgroundColor: 'var(--bg-card, #1a1f2e)',
|
|
1409
|
+
fontSize: 13,
|
|
1410
|
+
display: 'flex',
|
|
1411
|
+
alignItems: 'center',
|
|
1412
|
+
gap: 4,
|
|
1413
|
+
}}>
|
|
1414
|
+
<span style={{ animation: 'typingBlink 1.4s infinite ease-in-out' }}>●</span>
|
|
1415
|
+
<span style={{ animation: 'typingBlink 1.4s infinite ease-in-out 0.2s' }}>●</span>
|
|
1416
|
+
<span style={{ animation: 'typingBlink 1.4s infinite ease-in-out 0.4s' }}>●</span>
|
|
1417
|
+
</div>
|
|
1418
|
+
<span style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2, padding: '0 4px' }}>
|
|
1419
|
+
🤖 typing…
|
|
1420
|
+
</span>
|
|
1421
|
+
</div>)}
|
|
1422
|
+
</div>) : isExpanded ? (<p className="admin-hint" style={{ marginTop: 8 }}>No message details available.</p>) : null}
|
|
1423
|
+
</div>);
|
|
1424
|
+
})}
|
|
1425
|
+
{/* Infinite scroll sentinel */}
|
|
1426
|
+
{convHasMore && !convLoading && (<div ref={convSentinelRef} style={{ height: 1 }}/>)}
|
|
1427
|
+
{convLoading && (<div className="loading-state" style={{ padding: 12 }}>
|
|
1428
|
+
<div className="loading-spinner"/>
|
|
1429
|
+
<span className="admin-hint" style={{ marginLeft: 8 }}>Loading more conversations…</span>
|
|
1430
|
+
</div>)}
|
|
1431
|
+
{!convLoading && !convHasMore && convList.length > 0 && (<p className="admin-hint" style={{ textAlign: 'center', padding: 12 }}>
|
|
1432
|
+
All {convTotal} conversation{convTotal === 1 ? '' : 's'} loaded.
|
|
1433
|
+
</p>)}
|
|
1434
|
+
</div>
|
|
1435
|
+
</>) : convLoading ? (<div className="loading-state">
|
|
1436
|
+
<div className="loading-spinner"/>
|
|
1437
|
+
<p className="admin-hint">Loading conversations…</p>
|
|
1438
|
+
</div>) : (<div className="empty-state">No conversations stored yet. Conversations are created when users message the agent via WhatsApp/Telegram/etc.</div>)}
|
|
1439
|
+
</div>) : null}
|
|
1440
|
+
|
|
1441
|
+
{/* ── Artifacts tab ────────────────────────────────────────────────── */}
|
|
1442
|
+
{tab === 'artifacts' && data ? (<div role="tabpanel">
|
|
1443
|
+
<div className="admin-summary-grid">
|
|
1444
|
+
<div className="admin-summary-card">
|
|
1445
|
+
<div className="admin-summary-value">{data.artifacts.totalSessions}</div>
|
|
1446
|
+
<div className="admin-summary-label">Sessions</div>
|
|
1447
|
+
</div>
|
|
1448
|
+
<div className="admin-summary-card">
|
|
1449
|
+
<div className="admin-summary-value">{data.artifacts.totalArtifacts}</div>
|
|
1450
|
+
<div className="admin-summary-label">Artifacts</div>
|
|
1451
|
+
</div>
|
|
1452
|
+
</div>
|
|
1453
|
+
{data.artifacts.sessions.length > 0 ? (<div className="hub-session-list">
|
|
1454
|
+
{data.artifacts.sessions.map((s) => (<div className="hub-card" key={s.sessionId}>
|
|
1455
|
+
<div className="hub-card-top">
|
|
1456
|
+
<div className="hub-card-title">
|
|
1457
|
+
<span className="hub-card-name">{s.sessionId}</span>
|
|
1458
|
+
<span className="hub-card-id">{s.count} artifact{s.count === 1 ? '' : 's'} · {new Date(s.latestAt).toLocaleString()}</span>
|
|
1459
|
+
</div>
|
|
1460
|
+
</div>
|
|
1461
|
+
{s.recent.length > 0 ? (<ul className="hub-artifact-list">
|
|
1462
|
+
{s.recent.map((a, i) => (<li key={i}>
|
|
1463
|
+
<span className="hub-chip">{a.kind}</span>
|
|
1464
|
+
<span className="hub-artifact-title">{a.title || '(untitled)'}</span>
|
|
1465
|
+
{a.preview ? <div className="hub-artifact-preview">{a.preview}</div> : null}
|
|
1466
|
+
</li>))}
|
|
1467
|
+
</ul>) : (<div className="admin-hint">No artifact details.</div>)}
|
|
1468
|
+
</div>))}
|
|
1469
|
+
</div>) : (<div className="empty-state">No artifacts yet — tools that return a deliverable payload land here per session.</div>)}
|
|
1470
|
+
</div>) : null}
|
|
1471
|
+
|
|
1472
|
+
{/* ── Skills tab ───────────────────────────────────────────────────── */}
|
|
1473
|
+
{tab === 'skills' && data ? (<div role="tabpanel">
|
|
1474
|
+
<div className="admin-summary-grid">
|
|
1475
|
+
<div className="admin-summary-card">
|
|
1476
|
+
<div className="admin-summary-value">{data.skills.enabled}</div>
|
|
1477
|
+
<div className="admin-summary-label">Enabled skills</div>
|
|
1478
|
+
</div>
|
|
1479
|
+
<div className="admin-summary-card">
|
|
1480
|
+
<div className="admin-summary-value">{data.skills.disabled}</div>
|
|
1481
|
+
<div className="admin-summary-label">Disabled skills</div>
|
|
1482
|
+
</div>
|
|
1483
|
+
<div className="admin-summary-card">
|
|
1484
|
+
<div className="admin-summary-value">{data.skills.hub.length}</div>
|
|
1485
|
+
<div className="admin-summary-label">Hub skills (SKILL.md)</div>
|
|
1486
|
+
</div>
|
|
1487
|
+
</div>
|
|
1488
|
+
|
|
1489
|
+
<h3 className="section-subtitle">📚 Compiled skills (SkillStore — matched at plan time)</h3>
|
|
1490
|
+
{data.skills.compiled.length > 0 ? (<div className="hub-session-list">
|
|
1491
|
+
{data.skills.compiled.map((s) => (<div className={`hub-card${s.enabled ? '' : ' hub-card-disabled'}`} key={`c-${s.id}`}>
|
|
1492
|
+
<div className="hub-card-top">
|
|
1493
|
+
<div className="hub-card-title">
|
|
1494
|
+
<span className="hub-card-name">{s.name}</span>
|
|
1495
|
+
<span className="hub-card-id">{s.id}{s.version ? ` · v${s.version}` : ''}</span>
|
|
1496
|
+
</div>
|
|
1497
|
+
<div className="hub-card-actions">
|
|
1498
|
+
{/* P6e — provenance badge: bundled ships with the product. */}
|
|
1499
|
+
{s.bundled === true ? <span className="hub-chip hub-chip-bundled">🧠 bundled</span> : <span className="hub-chip">community</span>}
|
|
1500
|
+
<button role="switch" aria-checked={s.enabled} aria-label={`${s.enabled ? 'Disable' : 'Enable'} ${s.name}`} className={`hub-switch${s.enabled ? ' on' : ''}`} disabled={toggling === s.id || pendingToggle !== null || (authed && !canWrite)} onClick={() => toggleSkill(s.id, !s.enabled)}>
|
|
1501
|
+
<span className="hub-switch-knob"/>
|
|
1502
|
+
</button>
|
|
1503
|
+
</div>
|
|
1504
|
+
</div>
|
|
1505
|
+
<p className="hub-card-desc">{s.description}</p>
|
|
1506
|
+
<div className="hub-card-tools">
|
|
1507
|
+
{typeof s.usageCount === 'number' ? <span className="hub-chip">{s.usageCount} uses</span> : null}
|
|
1508
|
+
{!s.enabled ? <span className="hub-chip">disabled</span> : null}
|
|
1509
|
+
</div>
|
|
1510
|
+
{/* Edge note: a hub dir named exactly like a compiled id would
|
|
1511
|
+
share the rowMsg key — both cards represent the SAME
|
|
1512
|
+
disabled[] entry then, so toggling either is coherent. */}
|
|
1513
|
+
{rowMsg[s.id] ? <div className="admin-row-msg">{rowMsg[s.id]}</div> : null}
|
|
1514
|
+
{!s.enabled ? <div className="hub-card-note">⛔ Excluded from skill matching — not injected into the planner</div> : null}
|
|
1515
|
+
</div>))}
|
|
1516
|
+
</div>) : (<div className="empty-state">No compiled skills.</div>)}
|
|
1517
|
+
|
|
1518
|
+
<h3 className="section-subtitle">🌍 Hub skills (installed SKILL.md)</h3>
|
|
1519
|
+
{data.skills.hub.length > 0 ? (<div className="hub-session-list">
|
|
1520
|
+
{data.skills.hub.map((s) => (<div className={`hub-card${s.enabled ? '' : ' hub-card-disabled'}`} key={`h-${s.id}`}>
|
|
1521
|
+
<div className="hub-card-top">
|
|
1522
|
+
<div className="hub-card-title">
|
|
1523
|
+
<span className="hub-card-name">{s.name}</span>
|
|
1524
|
+
<span className="hub-card-id">{s.id}</span>
|
|
1525
|
+
</div>
|
|
1526
|
+
<div className="hub-card-actions">
|
|
1527
|
+
<button type="button" className="hub-mini-btn" disabled={marketBusyName === s.id || (authed && !canWrite)} onClick={() => void uninstallSkill(s.id)}>
|
|
1528
|
+
🗑 Uninstall
|
|
1529
|
+
</button>
|
|
1530
|
+
<button role="switch" aria-checked={s.enabled} aria-label={`${s.enabled ? 'Disable' : 'Enable'} ${s.name}`} className={`hub-switch${s.enabled ? ' on' : ''}`} disabled={toggling === s.id || pendingToggle !== null || (authed && !canWrite)} onClick={() => toggleSkill(s.id, !s.enabled)}>
|
|
1531
|
+
<span className="hub-switch-knob"/>
|
|
1532
|
+
</button>
|
|
1533
|
+
</div>
|
|
1534
|
+
</div>
|
|
1535
|
+
<p className="hub-card-desc">{s.description}</p>
|
|
1536
|
+
<div className="hub-card-tools">
|
|
1537
|
+
<span className="hub-chip">SKILL.md</span>
|
|
1538
|
+
{!s.enabled ? <span className="hub-chip">disabled</span> : null}
|
|
1539
|
+
</div>
|
|
1540
|
+
{rowMsg[s.id] ? <div className="admin-row-msg">{rowMsg[s.id]}</div> : null}
|
|
1541
|
+
{!s.enabled ? <div className="hub-card-note">⛔ Excluded from skill matching — not injected into the planner</div> : null}
|
|
1542
|
+
</div>))}
|
|
1543
|
+
</div>) : (<div className="empty-state">
|
|
1544
|
+
No hub skills installed — search the marketplace below or run <code>buff skills install <name></code>.
|
|
1545
|
+
</div>)}
|
|
1546
|
+
|
|
1547
|
+
{/* P6d — marketplace import surface: search every configured registry
|
|
1548
|
+
and install into .agents/skills/ (sandboxed + checksum-verified).
|
|
1549
|
+
The repo stays private — this READS other people's registries. */}
|
|
1550
|
+
<h3 className="section-subtitle">🛒 Marketplace (community skills)</h3>
|
|
1551
|
+
<form className="hub-market-search" onSubmit={searchMarket}>
|
|
1552
|
+
<input type="text" value={marketQuery} onChange={(e) => setMarketQuery(e.target.value)} placeholder="Search community skills (e.g. code-assist, deploy, testing)…"/>
|
|
1553
|
+
<button type="submit" className="admin-refresh-btn" disabled={marketBusy}>
|
|
1554
|
+
{marketBusy ? 'Searching…' : '🔎 Search'}
|
|
1555
|
+
</button>
|
|
1556
|
+
</form>
|
|
1557
|
+
{marketMsg ? <div className={`admin-row-msg${marketMsg.kind === 'err' ? ' admin-row-msg-err' : ''}`}>{marketMsg.text}</div> : null}
|
|
1558
|
+
{marketSearched && !marketBusy ? (marketResults.length > 0 ? (<div className="hub-session-list">
|
|
1559
|
+
{marketResults.map((r) => (<div className="hub-card" key={`m-${r.name}`}>
|
|
1560
|
+
<div className="hub-card-top">
|
|
1561
|
+
<div className="hub-card-title">
|
|
1562
|
+
<span className="hub-card-name">{r.name}</span>
|
|
1563
|
+
<span className="hub-card-id">v{r.version} · {r.sourceKind}</span>
|
|
1564
|
+
</div>
|
|
1565
|
+
<button type="button" className="admin-refresh-btn" disabled={marketBusyName === r.name || (authed && !canWrite)} onClick={() => void installMarketSkill(r.name)}>
|
|
1566
|
+
{marketBusyName === r.name ? 'Installing…' : '⬇ Install'}
|
|
1567
|
+
</button>
|
|
1568
|
+
</div>
|
|
1569
|
+
<p className="hub-card-desc">{r.description}</p>
|
|
1570
|
+
<div className="hub-card-tools">
|
|
1571
|
+
<span className="hub-chip">{r.author}</span>
|
|
1572
|
+
{r.tags.slice(0, 3).map((t) => <span className="hub-chip" key={t}>{t}</span>)}
|
|
1573
|
+
</div>
|
|
1574
|
+
</div>))}
|
|
1575
|
+
</div>) : (<div className="empty-state">
|
|
1576
|
+
No skills match "{marketQuery}" — try a broader term, or add a registry to <code>skills.registries</code> in config.
|
|
1577
|
+
</div>)) : null}
|
|
1578
|
+
</div>) : null}
|
|
1579
|
+
</>);
|
|
1580
|
+
}
|