Skip to content

Commit 2d1f25a

Browse files
netteebone3deep1962-collab
andauthored
feat(web): add message center (#5920)
* feat(web): add message center demo * fix(web): align message center actions * fix(web): remove message read toggles * fix(web): show unread count on message filter * feat(web): connect message center to vela (#5777) * feat(web): connect message center to vela * fix(web): harden message center read syncing Generated-By: looper 0.10.6 (runner=fixer, agent=codex) * fix(web): preserve cached message center reads Generated-By: looper 0.10.6 (runner=fixer, agent=codex) * fix(web): repair message center read sync and visual selector Generated-By: looper 0.10.6 (runner=fixer, agent=codex) * fix(web): refresh message center write auth and bound pagination Generated-By: looper 0.10.6 (runner=fixer, agent=codex) * fix(web): preserve message center state across locale and logout Generated-By: looper 0.10.6 (runner=fixer, agent=codex) * fix(message-center): stabilize login and anonymous delivery (#5910) * journal: stop repeated amr login polling * fix(web): drop anonymous message-center window gate Align with Vela audience+TTL visibility by stopping startedAt local windows so anonymous clients can pull all non-expired global messages. * refactor(web): promote message center from demo * fix(daemon): guard vela message center proxy stream Generated-By: looper 0.11.0 (runner=fixer, agent=codex) * fix(cli): add message center command Generated-By: looper 0.11.0 (runner=fixer, agent=codex) * test(e2e): harden visual workspace file readiness Generated-By: looper 0.11.0 (runner=fixer, agent=codex) * fix(web): separate message center sync empty states Generated-By: looper 0.11.0 (runner=fixer, agent=codex) --------- Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com>
1 parent 142edb4 commit 2d1f25a

32 files changed

Lines changed: 2274 additions & 2 deletions

apps/daemon/src/cli.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,14 @@ const CONFIG_STRING_FLAGS = new Set(['daemon-url', 'value', 'value-json']);
202202
const CONFIG_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']);
203203
const AMR_STRING_FLAGS = new Set(['daemon-url']);
204204
const AMR_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'refresh']);
205+
const MESSAGE_CENTER_STRING_FLAGS = new Set([
206+
'daemon-url',
207+
'locale',
208+
'filter',
209+
'limit',
210+
'cursor',
211+
]);
212+
const MESSAGE_CENTER_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']);
205213
const PROJECT_STRING_FLAGS = new Set([
206214
'daemon-url', 'name', 'skill', 'design-system', 'plugin', 'metadata-json',
207215
'pending-prompt', 'project', 'conversation', 'message', 'prompt',
@@ -326,6 +334,7 @@ const SUBCOMMAND_MAP = {
326334
media: runMedia,
327335
mcp: runMcp,
328336
amr: runAmr,
337+
'message-center': runMessageCenter,
329338
research: runResearch,
330339
plugin: runPlugin,
331340
ui: runUi,
@@ -654,6 +663,10 @@ function printRootHelp() {
654663
schedule, trigger, or harvest results from a routine without
655664
opening the web UI.
656665
666+
od message-center <list|read|read-all> [args]
667+
Read and acknowledge message-center inbox items through the same
668+
daemon endpoints the bell UI uses.
669+
657670
od memory tree <list|view|edit|move> [args]
658671
Inspect and edit the memory tree that is injected into agent prompts.
659672
@@ -776,6 +789,170 @@ Options:
776789
}
777790
}
778791

792+
// ---------------------------------------------------------------------------
793+
// Subcommand: od message-center …
794+
// ---------------------------------------------------------------------------
795+
796+
async function runMessageCenter(args) {
797+
const sub = args[0];
798+
if (!sub || sub === 'help' || args.includes('--help') || args.includes('-h')) {
799+
printMessageCenterHelp();
800+
process.exit(sub === 'help' || args.includes('--help') || args.includes('-h') ? 0 : 2);
801+
}
802+
const rest = args.slice(1);
803+
let flags;
804+
try {
805+
flags = parseFlags(rest, {
806+
string: MESSAGE_CENTER_STRING_FLAGS,
807+
boolean: MESSAGE_CENTER_BOOLEAN_FLAGS,
808+
});
809+
} catch (err) {
810+
console.error(err.message);
811+
printMessageCenterHelp();
812+
process.exit(2);
813+
}
814+
const base = await cliDaemonBaseUrl(flags);
815+
switch (sub) {
816+
case 'list':
817+
return runMessageCenterList(rest, flags, base);
818+
case 'read':
819+
return runMessageCenterRead(rest, flags, base);
820+
case 'read-all':
821+
return runMessageCenterReadAll(flags, base);
822+
default:
823+
console.error(`unknown subcommand: od message-center ${sub}`);
824+
printMessageCenterHelp();
825+
process.exit(2);
826+
}
827+
}
828+
829+
async function runMessageCenterList(rawArgs, flags, base) {
830+
const limit = flags.limit == null ? 100 : Number(flags.limit);
831+
if (!Number.isInteger(limit) || limit <= 0) {
832+
console.error('--limit must be a positive integer');
833+
process.exit(2);
834+
}
835+
const filter = flags.filter == null ? 'all' : String(flags.filter);
836+
if (filter !== 'all' && filter !== 'unread' && filter !== 'read') {
837+
console.error('--filter must be one of: all | unread | read');
838+
process.exit(2);
839+
}
840+
const query = new URLSearchParams({
841+
locale: messageCenterApiLocale(flags.locale == null ? 'en' : String(flags.locale)),
842+
filter,
843+
limit: String(limit),
844+
});
845+
if (typeof flags.cursor === 'string' && flags.cursor.length > 0) query.set('cursor', flags.cursor);
846+
let resp;
847+
try {
848+
resp = await fetch(`${base}/api/integrations/vela/message-center/messages?${query}`);
849+
} catch (err) {
850+
surfaceFetchError(err, base);
851+
process.exit(3);
852+
}
853+
if (!resp.ok) return structuredHttpFailure(resp);
854+
const payload = await resp.json();
855+
if (flags.json) {
856+
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
857+
return;
858+
}
859+
const messages = Array.isArray(payload?.messages) ? payload.messages : [];
860+
if (messages.length === 0) {
861+
console.log('No message-center messages.');
862+
return;
863+
}
864+
for (const message of messages) {
865+
const status = message?.readAt ? 'read' : 'unread';
866+
const id = typeof message?.id === 'string' ? message.id : '(missing-id)';
867+
const typeName = typeof message?.typeName === 'string' ? message.typeName : '-';
868+
const publishedAt = typeof message?.publishedAt === 'string' ? message.publishedAt : '-';
869+
const title = typeof message?.title === 'string' ? message.title : '';
870+
console.log(`${id}\t${status}\t${typeName}\t${publishedAt}\t${title}`);
871+
}
872+
if (payload?.nextCursor) console.log(`nextCursor\t${payload.nextCursor}`);
873+
if (typeof payload?.unreadCount === 'number') console.log(`unreadCount\t${payload.unreadCount}`);
874+
}
875+
876+
async function runMessageCenterRead(rawArgs, flags, base) {
877+
const id = positionalArgs(rawArgs, MESSAGE_CENTER_STRING_FLAGS)[0];
878+
if (!id) {
879+
console.error('Usage: od message-center read <id> [--json] [--daemon-url <url>]');
880+
process.exit(2);
881+
}
882+
let resp;
883+
try {
884+
resp = await fetch(`${base}/api/integrations/vela/message-center/messages/${encodeURIComponent(id)}/read`, {
885+
method: 'POST',
886+
});
887+
} catch (err) {
888+
surfaceFetchError(err, base);
889+
process.exit(3);
890+
}
891+
if (!resp.ok) return structuredHttpFailure(resp);
892+
const bodyText = await resp.text();
893+
const payload = bodyText ? safeJsonParse(bodyText) : null;
894+
if (flags.json) {
895+
process.stdout.write(
896+
JSON.stringify(payload ?? { ok: true, id }, null, 2) + '\n',
897+
);
898+
return;
899+
}
900+
console.log(`Marked message as read\t${id}`);
901+
}
902+
903+
async function runMessageCenterReadAll(flags, base) {
904+
let resp;
905+
try {
906+
resp = await fetch(`${base}/api/integrations/vela/message-center/read-all`, {
907+
method: 'POST',
908+
});
909+
} catch (err) {
910+
surfaceFetchError(err, base);
911+
process.exit(3);
912+
}
913+
if (!resp.ok) return structuredHttpFailure(resp);
914+
const bodyText = await resp.text();
915+
const payload = bodyText ? safeJsonParse(bodyText) : null;
916+
if (flags.json) {
917+
process.stdout.write(
918+
JSON.stringify(payload ?? { ok: true }, null, 2) + '\n',
919+
);
920+
return;
921+
}
922+
console.log('Marked all message-center messages as read');
923+
}
924+
925+
function printMessageCenterHelp() {
926+
console.log(`Usage:
927+
od message-center list [--locale <locale>] [--filter <all|unread|read>] [--limit <n>] [--cursor <token>] [--json] [--daemon-url <url>]
928+
od message-center read <id> [--json] [--daemon-url <url>]
929+
od message-center read-all [--json] [--daemon-url <url>]
930+
931+
Mirrors the message-center inbox surface exposed in the web UI through the
932+
same /api/integrations/vela/message-center daemon routes.
933+
934+
Options:
935+
--locale <locale> Defaults to en. Mapped to the daemon API locale shape.
936+
--filter <value> all | unread | read (default: all).
937+
--limit <n> Positive integer page size (default: 100).
938+
--cursor <token> Forward a server pagination cursor for list.
939+
--json Emit raw JSON for scripts and external agents.
940+
--daemon-url <url> Open Design daemon HTTP base.`);
941+
}
942+
943+
function messageCenterApiLocale(locale) {
944+
const mapping = { en: 'en-US', 'es-ES': 'es', 'pt-BR': 'pt' };
945+
return mapping[locale] ?? locale;
946+
}
947+
948+
function safeJsonParse(text) {
949+
try {
950+
return JSON.parse(text);
951+
} catch {
952+
return null;
953+
}
954+
}
955+
779956
// ---------------------------------------------------------------------------
780957
// Subcommand: od research …
781958
// ---------------------------------------------------------------------------

apps/daemon/src/routes/vela.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Express, Request, Response } from 'express';
22
import dns from 'node:dns';
3+
import http from 'node:http';
34
import https from 'node:https';
45

56
import {
@@ -23,6 +24,7 @@ import {
2324
parseVelaLoginAttribution,
2425
peekVelaLiveAccount,
2526
readVelaCredentialRevision,
27+
readVelaControlApiContext,
2628
readVelaLoginStatus,
2729
setVelaLiveAccount,
2830
shouldRefreshVelaLiveAccount,
@@ -43,6 +45,7 @@ import {
4345
} from '../runtimes/defs/amr.js';
4446

4547
const AMR_API_PROXY_PREFIX = '/api/integrations/vela/api-proxy';
48+
const VELA_MESSAGE_CENTER_PREFIX = '/api/integrations/vela/message-center';
4649
const AMR_API_UPSTREAM_ORIGIN = 'https://amr-api.open-design.ai';
4750

4851
type ReadAppConfig = (dataDir: string) => Promise<AppConfigPrefs>;
@@ -168,6 +171,70 @@ function proxyAmrApiRequest(req: Request, res: Response): void {
168171
}
169172
}
170173

174+
function isAllowedMessageCenterRequest(method: string, pathname: string): boolean {
175+
if (method === 'GET' && pathname === '/messages') return true;
176+
if (method !== 'POST') return false;
177+
return pathname === '/read-all' || /^\/messages\/[^/]+\/read$/.test(pathname);
178+
}
179+
180+
function proxyVelaMessageCenterRequest(
181+
req: Request,
182+
res: Response,
183+
context: { apiUrl: string; controlKey: string },
184+
): void {
185+
const suffix = req.originalUrl.slice(VELA_MESSAGE_CENTER_PREFIX.length);
186+
const parsedSuffix = new URL(suffix, 'http://message-center.local');
187+
if (!isAllowedMessageCenterRequest(req.method, parsedSuffix.pathname)) {
188+
res.status(404).json({ error: 'unknown_message_center_path' });
189+
return;
190+
}
191+
const target = new URL(
192+
`/api/v1/message-center${parsedSuffix.pathname}${parsedSuffix.search}`,
193+
context.apiUrl,
194+
);
195+
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
196+
res.status(500).json({ error: 'invalid_vela_api_url' });
197+
return;
198+
}
199+
const body = velaProxyRequestBody(req);
200+
const headers: Record<string, string> = {
201+
accept: typeof req.headers.accept === 'string' ? req.headers.accept : 'application/json',
202+
authorization: `Bearer ${context.controlKey}`,
203+
};
204+
if (typeof req.headers['content-type'] === 'string') {
205+
headers['content-type'] = req.headers['content-type'];
206+
}
207+
if (body) headers['content-length'] = String(body.length);
208+
const transport = target.protocol === 'https:' ? https : http;
209+
const upstream = transport.request(
210+
target,
211+
{ method: req.method, headers },
212+
(upstreamRes) => {
213+
res.status(upstreamRes.statusCode ?? 502);
214+
for (const [key, value] of Object.entries(upstreamRes.headers)) {
215+
if (value !== undefined) res.setHeader(key, value);
216+
}
217+
pipeProxyStreamWithGuard(upstreamRes, res, (err) => {
218+
if (!res.headersSent) {
219+
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
220+
} else {
221+
res.end();
222+
}
223+
});
224+
},
225+
);
226+
upstream.setTimeout(30_000, () => upstream.destroy(new Error('Vela Message Center timed out')));
227+
upstream.on('error', (err) => {
228+
if (!res.headersSent) {
229+
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
230+
} else {
231+
res.end();
232+
}
233+
});
234+
if (body) upstream.write(body);
235+
upstream.end();
236+
}
237+
171238
export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps): void {
172239
const env = deps.env ?? process.env;
173240
const { RUNTIME_DATA_DIR } = deps.paths;
@@ -354,6 +421,21 @@ export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps):
354421

355422
app.all('/api/integrations/vela/api-proxy/*splat', proxyAmrApiRequest);
356423

424+
app.all('/api/integrations/vela/message-center/*splat', async (req, res) => {
425+
try {
426+
const appConfig = await readAppConfig(RUNTIME_DATA_DIR);
427+
const configuredEnv = agentCliEnvForAgent(appConfig.agentCliEnv, 'amr');
428+
const context = readVelaControlApiContext(env, configuredEnv);
429+
if (!context) {
430+
res.status(401).json({ error: 'vela_control_key_required' });
431+
return;
432+
}
433+
proxyVelaMessageCenterRequest(req, res, context);
434+
} catch (err) {
435+
res.status(500).json({ error: String(err) });
436+
}
437+
});
438+
357439
app.post('/api/integrations/vela/login', async (req, res) => {
358440
try {
359441
const appConfig = await readAppConfig(RUNTIME_DATA_DIR);

0 commit comments

Comments
 (0)