@lightupai/polaris 0.0.77 → 0.0.78

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/src/web/app.ts CHANGED
@@ -21,7 +21,20 @@ import {
21
21
  type Sql,
22
22
  } from "../service/db";
23
23
  import { layout, nav } from "./layout";
24
- import { renderSetupView, renderActiveView, renderProfileView, renderErrorView } from "./views";
24
+ import {
25
+ renderSetupView,
26
+ renderActiveView,
27
+ renderProfileView,
28
+ renderErrorView,
29
+ renderTranscriptView,
30
+ renderSearchView,
31
+ renderDecisionsView,
32
+ renderProjectSettingsView,
33
+ type TranscriptEvent,
34
+ type SearchResult,
35
+ type ProjectMemberView,
36
+ } from "./views";
37
+ import { AnnotationKind, type Annotation } from "../types";
25
38
  import { renderLandingPage } from "./pages";
26
39
  import { createSystemChannel, postSystemEvent } from "../slack/system";
27
40
  import {
@@ -137,6 +150,9 @@ setInterval(() => {
137
150
  export function createApp(sql: Sql) {
138
151
  const app = new Hono();
139
152
 
153
+ // Polaris API service (same convention as slack/bridge.ts)
154
+ const apiUrl = process.env.POLARIS_API_URL ?? "http://localhost:4321";
155
+
140
156
  // Start hourly signup rollup
141
157
  startSignupRollup(sql);
142
158
 
@@ -343,13 +359,14 @@ export function createApp(sql: Sql) {
343
359
  const projects = (await listProjects(sql, payload.org_id)).filter((p) => p.name !== "_system");
344
360
  const allSessions = (await listSessions(sql, payload.org_id)).filter((s) => s.project !== "_system");
345
361
  const promptCounts = await getSessionPromptCounts(sql, payload.org_id);
362
+ const participantId = payload.participant_id;
346
363
 
347
364
  function buildSessionFixture(s: typeof allSessions[0]): import("./fixtures").SessionFixture {
348
365
  return {
349
366
  name: s.name,
350
367
  project: s.project,
351
368
  driver: s.driver ?? "",
352
- role: (s.driver === payload.participant_id ? "driver" : "advisor") as "driver" | "advisor",
369
+ role: (s.driver === participantId ? "driver" : "advisor") as "driver" | "advisor",
353
370
  description: "",
354
371
  participants: s.driver ? [{ id: s.driver, role: "driver" as const }] : [],
355
372
  eventCount: promptCounts.get(`${s.project}/${s.name}`) ?? 0,
@@ -409,11 +426,365 @@ export function createApp(sql: Sql) {
409
426
  slackConnected: !!org.slack_team_id,
410
427
  cliInstalled: false,
411
428
  hasConnectedSession: false,
429
+ totalPrompts: 0,
412
430
  };
413
431
 
414
432
  return layout(renderProfileView(ctx, payload.participant_id), "Polaris - Profile");
415
433
  });
416
434
 
435
+ // --- Session transcript ---
436
+
437
+ app.get("/sessions/:proj/:sess", async (c) => {
438
+ const token = c.req.query("token");
439
+ if (!token) return c.redirect("/login");
440
+
441
+ const payload = await verifyToken(token);
442
+ if (!payload) return c.redirect("/login");
443
+
444
+ const org = await getOrg(sql, payload.org_id);
445
+ if (!org) return c.redirect("/login");
446
+
447
+ const proj = c.req.param("proj");
448
+ const sess = c.req.param("sess");
449
+ const before = c.req.query("before");
450
+
451
+ const eventsUrl = new URL(`${apiUrl}/projects/${encodeURIComponent(proj)}/sessions/${encodeURIComponent(sess)}/events`);
452
+ eventsUrl.searchParams.set("limit", "200");
453
+ if (before) eventsUrl.searchParams.set("before", before);
454
+
455
+ let events: TranscriptEvent[];
456
+ let nextCursor: string | null;
457
+ try {
458
+ const res = await fetch(eventsUrl, { headers: { Authorization: `Bearer ${token}` } });
459
+ if (!res.ok) {
460
+ const message = res.status === 404 ? "Session not found." : `Failed to load transcript (${res.status}).`;
461
+ return layout(renderErrorView(message, "Back to dashboard", `/dashboard?token=${token}`));
462
+ }
463
+ const data = (await res.json()) as { events: TranscriptEvent[]; nextCursor: string | null };
464
+ events = data.events ?? [];
465
+ nextCursor = data.nextCursor ?? null;
466
+ } catch {
467
+ return layout(renderErrorView("Could not reach the Polaris API.", "Back to dashboard", `/dashboard?token=${token}`));
468
+ }
469
+
470
+ // Annotations are progressive enhancement — render the transcript even if they fail to load.
471
+ let annotations: Annotation[] = [];
472
+ try {
473
+ const annRes = await fetch(
474
+ `${apiUrl}/projects/${encodeURIComponent(proj)}/sessions/${encodeURIComponent(sess)}/annotations`,
475
+ { headers: { Authorization: `Bearer ${token}` } }
476
+ );
477
+ if (annRes.ok) {
478
+ annotations = ((await annRes.json()) as { annotations: Annotation[] }).annotations ?? [];
479
+ }
480
+ } catch { /* transcript still renders without annotations */ }
481
+
482
+ const ctx = { token, userName: payload.name, orgName: org.name, email: payload.email };
483
+ const title = `Polaris - ${proj}/${sess}`.replace(/[<>&"]/g, "");
484
+ return layout(renderTranscriptView(ctx, proj, sess, events, nextCursor, before, annotations), title);
485
+ });
486
+
487
+ // Proxies to the API inject endpoint; the API derives the sender from the bearer token.
488
+ app.post("/sessions/:proj/:sess/inject", async (c) => {
489
+ const token = c.req.query("token");
490
+ if (!token) return c.redirect("/login");
491
+
492
+ const payload = await verifyToken(token);
493
+ if (!payload) return c.redirect("/login");
494
+
495
+ const proj = c.req.param("proj");
496
+ const sess = c.req.param("sess");
497
+ const transcriptUrl = `/sessions/${encodeURIComponent(proj)}/${encodeURIComponent(sess)}?token=${token}`;
498
+
499
+ const body = await c.req.parseBody();
500
+ const content = typeof body.content === "string" ? body.content.trim() : "";
501
+ if (!content) return c.redirect(transcriptUrl);
502
+
503
+ try {
504
+ const res = await fetch(`${apiUrl}/projects/${encodeURIComponent(proj)}/sessions/${encodeURIComponent(sess)}/inject`, {
505
+ method: "POST",
506
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
507
+ body: JSON.stringify({ content }),
508
+ });
509
+ if (!res.ok) {
510
+ return layout(renderErrorView(`Inject failed (${res.status}).`, "Back to transcript", transcriptUrl));
511
+ }
512
+ } catch {
513
+ return layout(renderErrorView("Could not reach the Polaris API.", "Back to transcript", transcriptUrl));
514
+ }
515
+
516
+ return c.redirect(transcriptUrl);
517
+ });
518
+
519
+ // --- Annotations (star / tag / decision curation) ---
520
+ // Proxies to the API annotations endpoints; the API derives the participant from the bearer token.
521
+
522
+ app.post("/sessions/:proj/:sess/annotations", async (c) => {
523
+ const token = c.req.query("token");
524
+ if (!token) return c.redirect("/login");
525
+
526
+ const payload = await verifyToken(token);
527
+ if (!payload) return c.redirect("/login");
528
+
529
+ const proj = c.req.param("proj");
530
+ const sess = c.req.param("sess");
531
+ const transcriptUrl = `/sessions/${encodeURIComponent(proj)}/${encodeURIComponent(sess)}?token=${token}`;
532
+
533
+ const body = await c.req.parseBody();
534
+ const kind = typeof body.kind === "string" ? body.kind : "";
535
+ if (!AnnotationKind.safeParse(kind).success) return c.redirect(transcriptUrl);
536
+ const eventId = typeof body.event_id === "string" && body.event_id ? body.event_id : undefined;
537
+ const value = typeof body.value === "string" && body.value.trim() ? body.value.trim() : undefined;
538
+
539
+ try {
540
+ const res = await fetch(`${apiUrl}/projects/${encodeURIComponent(proj)}/sessions/${encodeURIComponent(sess)}/annotations`, {
541
+ method: "POST",
542
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
543
+ body: JSON.stringify({ event_id: eventId, kind, value }),
544
+ });
545
+ if (!res.ok) {
546
+ return layout(renderErrorView(`Annotation failed (${res.status}).`, "Back to transcript", transcriptUrl));
547
+ }
548
+ } catch {
549
+ return layout(renderErrorView("Could not reach the Polaris API.", "Back to transcript", transcriptUrl));
550
+ }
551
+
552
+ return c.redirect(transcriptUrl);
553
+ });
554
+
555
+ app.post("/sessions/:proj/:sess/annotations/:id/delete", async (c) => {
556
+ const token = c.req.query("token");
557
+ if (!token) return c.redirect("/login");
558
+
559
+ const payload = await verifyToken(token);
560
+ if (!payload) return c.redirect("/login");
561
+
562
+ const proj = c.req.param("proj");
563
+ const sess = c.req.param("sess");
564
+ const id = c.req.param("id");
565
+ const transcriptUrl = `/sessions/${encodeURIComponent(proj)}/${encodeURIComponent(sess)}?token=${token}`;
566
+
567
+ try {
568
+ const res = await fetch(`${apiUrl}/annotations/${encodeURIComponent(id)}`, {
569
+ method: "DELETE",
570
+ headers: { Authorization: `Bearer ${token}` },
571
+ });
572
+ if (!res.ok) {
573
+ return layout(renderErrorView(`Failed to remove annotation (${res.status}).`, "Back to transcript", transcriptUrl));
574
+ }
575
+ } catch {
576
+ return layout(renderErrorView("Could not reach the Polaris API.", "Back to transcript", transcriptUrl));
577
+ }
578
+
579
+ return c.redirect(transcriptUrl);
580
+ });
581
+
582
+ // --- Decisions (org-wide curation feed) ---
583
+
584
+ app.get("/decisions", async (c) => {
585
+ const token = c.req.query("token");
586
+ if (!token) return c.redirect("/login");
587
+
588
+ const payload = await verifyToken(token);
589
+ if (!payload) return c.redirect("/login");
590
+
591
+ const org = await getOrg(sql, payload.org_id);
592
+ if (!org) return c.redirect("/login");
593
+
594
+ const project = (c.req.query("project") ?? "").trim();
595
+
596
+ let decisions: Annotation[] | null = null;
597
+ let decisionsError: string | undefined;
598
+ const decisionsUrl = new URL(`${apiUrl}/decisions`);
599
+ if (project) decisionsUrl.searchParams.set("project", project);
600
+ decisionsUrl.searchParams.set("limit", "100");
601
+ try {
602
+ const res = await fetch(decisionsUrl, { headers: { Authorization: `Bearer ${token}` } });
603
+ if (res.ok) {
604
+ decisions = ((await res.json()) as { decisions: Annotation[] }).decisions ?? [];
605
+ } else {
606
+ decisionsError = `Failed to load decisions (${res.status}).`;
607
+ }
608
+ } catch {
609
+ decisionsError = "Could not reach the Polaris API.";
610
+ }
611
+
612
+ const ctx = { token, userName: payload.name, orgName: org.name, email: payload.email };
613
+ return layout(renderDecisionsView(ctx, project, decisions, decisionsError), "Polaris - Decisions");
614
+ });
615
+
616
+ // --- Project settings (visibility + members) ---
617
+
618
+ app.get("/projects/:proj/settings", async (c) => {
619
+ const token = c.req.query("token");
620
+ if (!token) return c.redirect("/login");
621
+
622
+ const payload = await verifyToken(token);
623
+ if (!payload) return c.redirect("/login");
624
+
625
+ const org = await getOrg(sql, payload.org_id);
626
+ if (!org) return c.redirect("/login");
627
+
628
+ const proj = c.req.param("proj");
629
+ const projects = await listProjects(sql, payload.org_id);
630
+ const project = projects.find((p) => p.name === proj);
631
+ if (!project) {
632
+ return layout(renderErrorView("Project not found.", "Back to dashboard", `/dashboard?token=${token}`));
633
+ }
634
+
635
+ let members: ProjectMemberView[] = [];
636
+ let membersError: string | undefined;
637
+ try {
638
+ const res = await fetch(`${apiUrl}/projects/${encodeURIComponent(proj)}/members`, {
639
+ headers: { Authorization: `Bearer ${token}` },
640
+ });
641
+ if (res.ok) {
642
+ const data = (await res.json()) as ProjectMemberView[] | { members?: ProjectMemberView[] };
643
+ members = Array.isArray(data) ? data : data.members ?? [];
644
+ } else {
645
+ membersError = `Failed to load members (${res.status}).`;
646
+ }
647
+ } catch {
648
+ membersError = "Could not reach the Polaris API.";
649
+ }
650
+
651
+ const ctx = { token, userName: payload.name, orgName: org.name, email: payload.email };
652
+ const title = `Polaris - ${proj} settings`.replace(/[<>&"]/g, "");
653
+ return layout(renderProjectSettingsView(ctx, proj, project.visibility ?? "org", members, membersError), title);
654
+ });
655
+
656
+ app.post("/projects/:proj/visibility", async (c) => {
657
+ const token = c.req.query("token");
658
+ if (!token) return c.redirect("/login");
659
+
660
+ const payload = await verifyToken(token);
661
+ if (!payload) return c.redirect("/login");
662
+
663
+ const proj = c.req.param("proj");
664
+ const settingsUrl = `/projects/${encodeURIComponent(proj)}/settings?token=${token}`;
665
+
666
+ const body = await c.req.parseBody();
667
+ const visibility = typeof body.visibility === "string" ? body.visibility : "";
668
+ if (visibility !== "org" && visibility !== "members") return c.redirect(settingsUrl);
669
+
670
+ try {
671
+ const res = await fetch(`${apiUrl}/projects/${encodeURIComponent(proj)}/visibility`, {
672
+ method: "POST",
673
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
674
+ body: JSON.stringify({ visibility }),
675
+ });
676
+ if (!res.ok) {
677
+ return layout(renderErrorView(`Failed to update visibility (${res.status}).`, "Back to settings", settingsUrl));
678
+ }
679
+ } catch {
680
+ return layout(renderErrorView("Could not reach the Polaris API.", "Back to settings", settingsUrl));
681
+ }
682
+
683
+ return c.redirect(settingsUrl);
684
+ });
685
+
686
+ app.post("/projects/:proj/members", async (c) => {
687
+ const token = c.req.query("token");
688
+ if (!token) return c.redirect("/login");
689
+
690
+ const payload = await verifyToken(token);
691
+ if (!payload) return c.redirect("/login");
692
+
693
+ const proj = c.req.param("proj");
694
+ const settingsUrl = `/projects/${encodeURIComponent(proj)}/settings?token=${token}`;
695
+
696
+ const body = await c.req.parseBody();
697
+ const participantId = typeof body.participant_id === "string" ? body.participant_id.trim() : "";
698
+ if (!participantId) return c.redirect(settingsUrl);
699
+ const role = typeof body.role === "string" && body.role.trim() ? body.role.trim() : undefined;
700
+
701
+ try {
702
+ const res = await fetch(`${apiUrl}/projects/${encodeURIComponent(proj)}/members`, {
703
+ method: "POST",
704
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
705
+ body: JSON.stringify({ participant_id: participantId, role }),
706
+ });
707
+ if (!res.ok) {
708
+ return layout(renderErrorView(`Failed to add member (${res.status}).`, "Back to settings", settingsUrl));
709
+ }
710
+ } catch {
711
+ return layout(renderErrorView("Could not reach the Polaris API.", "Back to settings", settingsUrl));
712
+ }
713
+
714
+ return c.redirect(settingsUrl);
715
+ });
716
+
717
+ app.post("/projects/:proj/members/:pid/delete", async (c) => {
718
+ const token = c.req.query("token");
719
+ if (!token) return c.redirect("/login");
720
+
721
+ const payload = await verifyToken(token);
722
+ if (!payload) return c.redirect("/login");
723
+
724
+ const proj = c.req.param("proj");
725
+ const pid = c.req.param("pid");
726
+ const settingsUrl = `/projects/${encodeURIComponent(proj)}/settings?token=${token}`;
727
+
728
+ try {
729
+ const res = await fetch(`${apiUrl}/projects/${encodeURIComponent(proj)}/members/${encodeURIComponent(pid)}`, {
730
+ method: "DELETE",
731
+ headers: { Authorization: `Bearer ${token}` },
732
+ });
733
+ if (!res.ok) {
734
+ return layout(renderErrorView(`Failed to remove member (${res.status}).`, "Back to settings", settingsUrl));
735
+ }
736
+ } catch {
737
+ return layout(renderErrorView("Could not reach the Polaris API.", "Back to settings", settingsUrl));
738
+ }
739
+
740
+ return c.redirect(settingsUrl);
741
+ });
742
+
743
+ // --- Search ---
744
+
745
+ app.get("/search", async (c) => {
746
+ const token = c.req.query("token");
747
+ if (!token) return c.redirect("/login");
748
+
749
+ const payload = await verifyToken(token);
750
+ if (!payload) return c.redirect("/login");
751
+
752
+ const org = await getOrg(sql, payload.org_id);
753
+ if (!org) return c.redirect("/login");
754
+
755
+ const query = {
756
+ q: (c.req.query("q") ?? "").trim(),
757
+ project: c.req.query("project") ?? "",
758
+ sender: c.req.query("sender") ?? "",
759
+ source: c.req.query("source") ?? "",
760
+ tag: (c.req.query("tag") ?? "").trim(),
761
+ };
762
+
763
+ let results: SearchResult[] | null = null;
764
+ let searchError: string | undefined;
765
+ if (query.q) {
766
+ const searchUrl = new URL(`${apiUrl}/search`);
767
+ searchUrl.searchParams.set("q", query.q);
768
+ if (query.project) searchUrl.searchParams.set("project", query.project);
769
+ if (query.sender) searchUrl.searchParams.set("sender", query.sender);
770
+ if (query.source) searchUrl.searchParams.set("source", query.source);
771
+ if (query.tag) searchUrl.searchParams.set("tag", query.tag);
772
+ try {
773
+ const res = await fetch(searchUrl, { headers: { Authorization: `Bearer ${token}` } });
774
+ if (res.ok) {
775
+ results = ((await res.json()) as { results: SearchResult[] }).results ?? [];
776
+ } else {
777
+ searchError = `Search failed (${res.status}).`;
778
+ }
779
+ } catch {
780
+ searchError = "Could not reach the Polaris API.";
781
+ }
782
+ }
783
+
784
+ const ctx = { token, userName: payload.name, orgName: org.name, email: payload.email };
785
+ return layout(renderSearchView(ctx, query, results, searchError), "Polaris - Search");
786
+ });
787
+
417
788
  // --- Preview (dev only — all view states on one page) ---
418
789
 
419
790
  app.get("/preview", (c) => {
package/src/web/layout.ts CHANGED
@@ -86,7 +86,9 @@ document.addEventListener('click', function(e) {
86
86
 
87
87
  export function nav(token?: string, opts?: NavOpts): string {
88
88
  const right = token
89
- ? `<div class="relative group">
89
+ ? `<a href="/decisions?token=${token}" class="text-sm font-medium text-gray-600 hover:text-gray-900">Decisions</a>
90
+ <a href="/search?token=${token}" class="text-sm font-medium text-gray-600 hover:text-gray-900">Search</a>
91
+ <div class="relative group">
90
92
  <button class="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-gray-100 transition">
91
93
  <div class="w-7 h-7 rounded-full bg-polaris-600 flex items-center justify-center text-white text-xs font-bold">${(opts?.userName ?? "U").charAt(0).toUpperCase()}</div>
92
94
  <div class="text-left">
@@ -102,6 +104,8 @@ export function nav(token?: string, opts?: NavOpts): string {
102
104
  <p class="text-[10px] text-gray-400">${opts?.orgName ?? ""}</p>
103
105
  </div>
104
106
  <a href="/dashboard?token=${token}" class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">Dashboard</a>
107
+ <a href="/decisions?token=${token}" class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">Decisions</a>
108
+ <a href="/search?token=${token}" class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">Search</a>
105
109
  <a href="/profile?token=${token}" class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">Profile</a>
106
110
  <div class="border-t border-gray-100"></div>
107
111
  <a href="/" class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">Log out</a>