spagestic commited on
Commit
b3e01f2
路
1 Parent(s): 8746eb1

parallel research carousel UI

Browse files
assets/app.js CHANGED
@@ -32,6 +32,7 @@ const state = {
32
  choices: null,
33
  busy: false,
34
  view: "form",
 
35
  };
36
 
37
  const els = {
@@ -477,9 +478,158 @@ async function renderToolMessage(node, message, metadata) {
477
  node.appendChild(body);
478
  }
479
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
480
  async function renderMessages() {
481
  els.chatMessages.innerHTML = "";
482
- for (const message of state.history) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
  const node = document.createElement("div");
484
  const metadata = message.metadata || {};
485
  const isTool = Boolean(metadata.title || metadata.status);
 
32
  choices: null,
33
  busy: false,
34
  view: "form",
35
+ activeResearchTaskId: null,
36
  };
37
 
38
  const els = {
 
478
  node.appendChild(body);
479
  }
480
 
481
+ function isResearchMessage(message) {
482
+ return Boolean(message?.metadata?.research_task);
483
+ }
484
+
485
+ function researchTaskKey(task) {
486
+ const id = task?.id;
487
+ return id === undefined || id === null ? String(task?.title || "research") : String(id);
488
+ }
489
+
490
+ function groupResearchMessages(messages) {
491
+ const groups = new Map();
492
+ for (const message of messages) {
493
+ const task = message.metadata?.research_task || {};
494
+ const key = researchTaskKey(task);
495
+ if (!groups.has(key)) {
496
+ groups.set(key, {
497
+ key,
498
+ task,
499
+ messages: [],
500
+ hasFinding: false,
501
+ hasPending: false,
502
+ });
503
+ }
504
+ const group = groups.get(key);
505
+ group.messages.push(message);
506
+ group.hasFinding = group.hasFinding || Boolean(message.metadata?.research_finding);
507
+ group.hasPending = group.hasPending || message.metadata?.status === "pending";
508
+ }
509
+ return Array.from(groups.values()).sort((left, right) => {
510
+ const leftId = Number(left.task?.id);
511
+ const rightId = Number(right.task?.id);
512
+ if (Number.isFinite(leftId) && Number.isFinite(rightId)) {
513
+ return leftId - rightId;
514
+ }
515
+ return String(left.task?.title || "").localeCompare(String(right.task?.title || ""));
516
+ });
517
+ }
518
+
519
+ function researchGroupStatus(group) {
520
+ if (group.hasFinding) {
521
+ return "done";
522
+ }
523
+ if (group.hasPending) {
524
+ return "running";
525
+ }
526
+ return "working";
527
+ }
528
+
529
+ function defaultResearchTask(groups) {
530
+ const active = groups.find((group) => group.key === state.activeResearchTaskId);
531
+ if (active) {
532
+ return active;
533
+ }
534
+ return (
535
+ groups.find((group) => researchGroupStatus(group) !== "done") ||
536
+ groups[0]
537
+ );
538
+ }
539
+
540
+ async function renderResearchMessage(parent, message) {
541
+ const node = document.createElement("div");
542
+ const metadata = message.metadata || {};
543
+ node.className = `research-event ${metadata.status === "pending" ? "pending" : ""}`;
544
+
545
+ const title = document.createElement("div");
546
+ title.className = "research-event-title";
547
+ title.textContent = metadata.title || "Research update";
548
+ node.appendChild(title);
549
+
550
+ if (metadata.log) {
551
+ await renderToolMessage(node, message, metadata);
552
+ } else {
553
+ const body = document.createElement("div");
554
+ await renderMessageBody(body, message, false);
555
+ node.appendChild(body);
556
+ }
557
+
558
+ parent.appendChild(node);
559
+ }
560
+
561
+ async function renderResearchCarousel(messages) {
562
+ const groups = groupResearchMessages(messages);
563
+ const active = defaultResearchTask(groups);
564
+ if (!active) {
565
+ return null;
566
+ }
567
+
568
+ const panel = document.createElement("div");
569
+ panel.className = "chat-message assistant research-carousel";
570
+
571
+ const header = document.createElement("div");
572
+ header.className = "research-carousel-header";
573
+ const title = document.createElement("div");
574
+ title.className = "research-carousel-title";
575
+ title.textContent = "Parallel country research";
576
+ const progress = document.createElement("div");
577
+ progress.className = "research-carousel-progress";
578
+ const doneCount = groups.filter((group) => researchGroupStatus(group) === "done").length;
579
+ progress.textContent = `${doneCount}/${groups.length} complete`;
580
+ header.appendChild(title);
581
+ header.appendChild(progress);
582
+ panel.appendChild(header);
583
+
584
+ const tabs = document.createElement("div");
585
+ tabs.className = "research-carousel-tabs";
586
+ for (const group of groups) {
587
+ const status = researchGroupStatus(group);
588
+ const button = document.createElement("button");
589
+ button.type = "button";
590
+ button.className = `research-carousel-tab ${group.key === active.key ? "is-active" : ""}`;
591
+ button.dataset.status = status;
592
+ button.textContent = group.task?.title || `Research ${group.key}`;
593
+ button.addEventListener("click", () => {
594
+ state.activeResearchTaskId = group.key;
595
+ renderMessages();
596
+ });
597
+ tabs.appendChild(button);
598
+ }
599
+ panel.appendChild(tabs);
600
+
601
+ const body = document.createElement("div");
602
+ body.className = "research-carousel-body";
603
+ const activeStatus = document.createElement("div");
604
+ activeStatus.className = "research-carousel-active-status";
605
+ activeStatus.textContent = `${active.task?.title || "Research"} 路 ${researchGroupStatus(active)}`;
606
+ body.appendChild(activeStatus);
607
+
608
+ for (const message of active.messages) {
609
+ await renderResearchMessage(body, message);
610
+ }
611
+ panel.appendChild(body);
612
+ return panel;
613
+ }
614
+
615
  async function renderMessages() {
616
  els.chatMessages.innerHTML = "";
617
+ for (let index = 0; index < state.history.length; index += 1) {
618
+ const message = state.history[index];
619
+ if (isResearchMessage(message)) {
620
+ const researchMessages = [];
621
+ while (index < state.history.length && isResearchMessage(state.history[index])) {
622
+ researchMessages.push(state.history[index]);
623
+ index += 1;
624
+ }
625
+ index -= 1;
626
+ const carousel = await renderResearchCarousel(researchMessages);
627
+ if (carousel) {
628
+ els.chatMessages.appendChild(carousel);
629
+ }
630
+ continue;
631
+ }
632
+
633
  const node = document.createElement("div");
634
  const metadata = message.metadata || {};
635
  const isTool = Boolean(metadata.title || metadata.status);
assets/index.html CHANGED
@@ -155,7 +155,7 @@
155
  </div>
156
 
157
  <script src="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.js"></script>
158
- <script type="module" src="/assets/globe.js?v=2"></script>
159
- <script type="module" src="/assets/app.js?v=2"></script>
160
  </body>
161
  </html>
 
155
  </div>
156
 
157
  <script src="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.js"></script>
158
+ <script type="module" src="/assets/globe.js"></script>
159
+ <script type="module" src="/assets/app.js"></script>
160
  </body>
161
  </html>
assets/server.css CHANGED
@@ -454,6 +454,153 @@ button:disabled {
454
  margin: 0.45em 0;
455
  }
456
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
  .chat-input-row {
458
  display: flex;
459
  gap: 8px;
 
454
  margin: 0.45em 0;
455
  }
456
 
457
+ .research-carousel {
458
+ display: flex;
459
+ flex-direction: column;
460
+ gap: 10px;
461
+ background: rgba(17, 24, 39, 0.94);
462
+ border: 1px solid rgba(245, 158, 11, 0.28);
463
+ }
464
+
465
+ .research-carousel-header {
466
+ display: flex;
467
+ align-items: center;
468
+ justify-content: space-between;
469
+ gap: 12px;
470
+ }
471
+
472
+ .research-carousel-title {
473
+ font-weight: 700;
474
+ color: #fbbf24;
475
+ }
476
+
477
+ .research-carousel-progress {
478
+ flex: 0 0 auto;
479
+ border-radius: 999px;
480
+ border: 1px solid rgba(148, 163, 184, 0.22);
481
+ background: rgba(15, 23, 42, 0.75);
482
+ color: #d1d5db;
483
+ padding: 3px 8px;
484
+ font-size: 0.75rem;
485
+ }
486
+
487
+ .research-carousel-tabs {
488
+ display: flex;
489
+ gap: 8px;
490
+ overflow-x: auto;
491
+ padding-bottom: 2px;
492
+ }
493
+
494
+ .research-carousel-tab {
495
+ flex: 0 0 min(240px, 78%);
496
+ display: flex;
497
+ align-items: center;
498
+ gap: 6px;
499
+ padding: 8px 10px;
500
+ border-radius: 10px;
501
+ border-color: rgba(148, 163, 184, 0.24);
502
+ background: rgba(15, 23, 42, 0.82);
503
+ color: #e5e7eb;
504
+ text-align: left;
505
+ line-height: 1.25;
506
+ }
507
+
508
+ .research-carousel-tab::before {
509
+ content: "";
510
+ width: 8px;
511
+ height: 8px;
512
+ flex: 0 0 auto;
513
+ border-radius: 999px;
514
+ background: #fbbf24;
515
+ box-shadow: 0 0 12px rgba(251, 191, 36, 0.35);
516
+ }
517
+
518
+ .research-carousel-tab[data-status="done"]::before {
519
+ background: #34d399;
520
+ box-shadow: 0 0 12px rgba(52, 211, 153, 0.35);
521
+ }
522
+
523
+ .research-carousel-tab[data-status="running"]::before {
524
+ background: #60a5fa;
525
+ box-shadow: 0 0 12px rgba(96, 165, 250, 0.35);
526
+ }
527
+
528
+ .research-carousel-tab.is-active {
529
+ border-color: #f59e0b;
530
+ background: rgba(245, 158, 11, 0.16);
531
+ color: #f9fafb;
532
+ }
533
+
534
+ .research-carousel-body {
535
+ display: flex;
536
+ flex-direction: column;
537
+ gap: 8px;
538
+ border-radius: 10px;
539
+ border: 1px solid rgba(148, 163, 184, 0.16);
540
+ background: rgba(2, 6, 23, 0.28);
541
+ padding: 10px;
542
+ }
543
+
544
+ .research-carousel-active-status {
545
+ color: #9ca3af;
546
+ font-size: 0.8rem;
547
+ }
548
+
549
+ .research-event {
550
+ border-radius: 9px;
551
+ border: 1px solid rgba(148, 163, 184, 0.14);
552
+ background: rgba(15, 23, 42, 0.62);
553
+ padding: 8px 10px;
554
+ }
555
+
556
+ .research-event.pending {
557
+ opacity: 0.82;
558
+ }
559
+
560
+ .research-event-title {
561
+ margin-bottom: 6px;
562
+ color: #fbbf24;
563
+ font-weight: 600;
564
+ font-size: 0.9rem;
565
+ }
566
+
567
+ .research-event details.tool-log > summary {
568
+ cursor: pointer;
569
+ color: #e5e7eb;
570
+ font-weight: 600;
571
+ }
572
+
573
+ .research-event .tool-log-section {
574
+ margin-top: 8px;
575
+ }
576
+
577
+ .research-event .tool-log-section strong {
578
+ display: block;
579
+ margin-bottom: 4px;
580
+ color: #9ca3af;
581
+ font-size: 0.75rem;
582
+ text-transform: uppercase;
583
+ letter-spacing: 0.04em;
584
+ }
585
+
586
+ .research-event .tool-log-section pre {
587
+ margin: 0;
588
+ padding: 8px;
589
+ max-height: 220px;
590
+ overflow: auto;
591
+ border-radius: 8px;
592
+ border: 1px solid rgba(148, 163, 184, 0.16);
593
+ background: rgba(2, 6, 23, 0.5);
594
+ color: #e5e7eb;
595
+ white-space: pre-wrap;
596
+ word-break: break-word;
597
+ font-size: 0.78rem;
598
+ }
599
+
600
+ .research-event .chat-message-body {
601
+ white-space: normal;
602
+ }
603
+
604
  .chat-input-row {
605
  display: flex;
606
  gap: 8px;
ui/agent/graph/nodes/researcher.py CHANGED
@@ -32,6 +32,7 @@ from .prompts import RESEARCHER_SYSTEM_PROMPT
32
 
33
  def execute_tool_call(
34
  writer: Any,
 
35
  todo_title: str,
36
  tool_name: str,
37
  args: dict[str, Any],
@@ -45,6 +46,7 @@ def execute_tool_call(
45
  "title": f"{title} 路 {todo_title}",
46
  "message": pending_message,
47
  "log": {"tool": tool_name, "arguments": args},
 
48
  }
49
  )
50
 
@@ -66,6 +68,7 @@ def execute_tool_call(
66
  "message": _done_tool_message(tool_name, args, result),
67
  "duration": duration,
68
  "log": _tool_log_metadata(tool_name, args, result),
 
69
  }
70
  )
71
  return result
@@ -101,7 +104,13 @@ def researcher_node(task: ResearchTask, config: RunnableConfig) -> dict[str, Any
101
  messages.append(AIMessage(content=""))
102
 
103
  for tool_name, tool_args, tool_call_id in tool_calls:
104
- result = execute_tool_call(writer, todo["title"], tool_name, tool_args)
 
 
 
 
 
 
105
  messages.append(
106
  ToolMessage(
107
  content=truncate(result, TOOL_RESULT_LIMIT),
@@ -132,5 +141,13 @@ def researcher_node(task: ResearchTask, config: RunnableConfig) -> dict[str, Any
132
  "todo_title": todo["title"],
133
  "summary": summary,
134
  }
135
- writer({"type": "finding", "todo_title": todo["title"], "summary": summary})
 
 
 
 
 
 
 
 
136
  return {"findings": [finding]}
 
32
 
33
  def execute_tool_call(
34
  writer: Any,
35
+ todo_id: int,
36
  todo_title: str,
37
  tool_name: str,
38
  args: dict[str, Any],
 
46
  "title": f"{title} 路 {todo_title}",
47
  "message": pending_message,
48
  "log": {"tool": tool_name, "arguments": args},
49
+ "research_task": {"id": todo_id, "title": todo_title},
50
  }
51
  )
52
 
 
68
  "message": _done_tool_message(tool_name, args, result),
69
  "duration": duration,
70
  "log": _tool_log_metadata(tool_name, args, result),
71
+ "research_task": {"id": todo_id, "title": todo_title},
72
  }
73
  )
74
  return result
 
104
  messages.append(AIMessage(content=""))
105
 
106
  for tool_name, tool_args, tool_call_id in tool_calls:
107
+ result = execute_tool_call(
108
+ writer,
109
+ todo["id"],
110
+ todo["title"],
111
+ tool_name,
112
+ tool_args,
113
+ )
114
  messages.append(
115
  ToolMessage(
116
  content=truncate(result, TOOL_RESULT_LIMIT),
 
141
  "todo_title": todo["title"],
142
  "summary": summary,
143
  }
144
+ writer(
145
+ {
146
+ "type": "finding",
147
+ "todo_id": todo["id"],
148
+ "todo_title": todo["title"],
149
+ "summary": summary,
150
+ "research_task": {"id": todo["id"], "title": todo["title"]},
151
+ }
152
+ )
153
  return {"findings": [finding]}
ui/agent/graph/respond.py CHANGED
@@ -119,6 +119,7 @@ class _UiState:
119
  "title": str(event.get("title") or "Tool"),
120
  "status": "pending",
121
  "log": event.get("log") or {},
 
122
  },
123
  )
124
  )
@@ -135,6 +136,7 @@ class _UiState:
135
  "status": "done",
136
  "duration": event.get("duration"),
137
  "log": event.get("log") or {},
 
138
  },
139
  )
140
  if index is not None and index < len(self.ui_messages):
@@ -159,6 +161,12 @@ class _UiState:
159
  "title": f"Findings 路 {todo_title}",
160
  "status": "done",
161
  "markdown": True,
 
 
 
 
 
 
162
  },
163
  )
164
  )
 
119
  "title": str(event.get("title") or "Tool"),
120
  "status": "pending",
121
  "log": event.get("log") or {},
122
+ "research_task": event.get("research_task"),
123
  },
124
  )
125
  )
 
136
  "status": "done",
137
  "duration": event.get("duration"),
138
  "log": event.get("log") or {},
139
+ "research_task": event.get("research_task"),
140
  },
141
  )
142
  if index is not None and index < len(self.ui_messages):
 
161
  "title": f"Findings 路 {todo_title}",
162
  "status": "done",
163
  "markdown": True,
164
+ "research_task": event.get("research_task")
165
+ or {
166
+ "id": event.get("todo_id"),
167
+ "title": todo_title,
168
+ },
169
+ "research_finding": True,
170
  },
171
  )
172
  )