spagestic commited on
Commit
c3f3998
·
1 Parent(s): 3ba9db2

Updated the chat UI and backend so MiniCPM thinking renders

Browse files
assets/app.js CHANGED
@@ -2,7 +2,8 @@ import { gradioPredict, gradioStream } from "/assets/gradio_api.js?v=2";
2
  import {
3
  ensureMarkdownTools,
4
  renderMarkdownInto,
5
- } from "/assets/markdown.js?v=1";
 
6
 
7
  const CHAT_SESSIONS_KEY = "borderless-chat-sessions";
8
 
@@ -530,23 +531,77 @@ function shouldRenderMarkdown(message, isTool) {
530
  return !isTool;
531
  }
532
 
533
- async function renderMessageBody(body, message, isTool) {
534
- const content = message.content || "";
535
- body.className = "chat-message-body";
 
 
 
 
 
 
 
 
 
 
 
 
536
 
537
- if (!shouldRenderMarkdown(message, isTool)) {
538
- body.textContent = content;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
  return;
540
  }
541
 
542
- body.classList.add("markdown-body");
543
- try {
544
- await renderMarkdownInto(body, content);
545
- } catch {
546
- body.textContent = content;
 
 
 
 
547
  }
548
  }
549
 
 
 
 
 
550
  function appendToolLogSection(parent, label, value) {
551
  const section = document.createElement("div");
552
  section.className = "tool-log-section";
@@ -648,9 +703,19 @@ async function renderPlanMessage(node, message, metadata) {
648
  }
649
 
650
  async function renderToolMessage(node, message, metadata, options = {}) {
651
- const isThinking = metadata.title === "Thinking";
 
652
  if (isThinking) {
653
  node.classList.add("thinking");
 
 
 
 
 
 
 
 
 
654
  }
655
 
656
  if (metadata.display === "plan") {
@@ -806,6 +871,7 @@ async function renderResearchMessage(parent, message, task) {
806
  await renderToolMessage(node, message, metadata, { compact: true });
807
  } else {
808
  const body = document.createElement("div");
 
809
  await renderMessageBody(body, message, false);
810
  node.appendChild(body);
811
  }
@@ -966,6 +1032,7 @@ async function renderMessages() {
966
  }
967
  } else {
968
  const body = document.createElement("div");
 
969
  await renderMessageBody(body, message, isTool);
970
  node.appendChild(body);
971
  }
 
2
  import {
3
  ensureMarkdownTools,
4
  renderMarkdownInto,
5
+ splitThinkingContent,
6
+ } from "/assets/markdown.js?v=2";
7
 
8
  const CHAT_SESSIONS_KEY = "borderless-chat-sessions";
9
 
 
531
  return !isTool;
532
  }
533
 
534
+ async function renderThinkingBlock(parent, thinkingText, options = {}) {
535
+ const text = String(thinkingText || "").trim();
536
+ if (!text) {
537
+ return null;
538
+ }
539
+
540
+ const wrapper = document.createElement("div");
541
+ wrapper.className = "thinking-block-wrap";
542
+
543
+ if (options.label) {
544
+ const label = document.createElement("div");
545
+ label.className = "thinking-block-label";
546
+ label.textContent = options.label;
547
+ wrapper.appendChild(label);
548
+ }
549
 
550
+ const block = document.createElement("div");
551
+ block.className = "thinking-block markdown-body";
552
+ await renderMarkdownInto(block, text);
553
+ wrapper.appendChild(block);
554
+ parent.appendChild(wrapper);
555
+ return wrapper;
556
+ }
557
+
558
+ async function renderAssistantContent(parent, message, isTool) {
559
+ const metadata = message.metadata || {};
560
+ const { thinking, answer } = splitThinkingContent(message.content || "", {
561
+ thinking: metadata.thinking,
562
+ });
563
+
564
+ parent.replaceChildren();
565
+
566
+ if (thinking) {
567
+ await renderThinkingBlock(parent, thinking, {
568
+ label: metadata.display === "thinking" ? null : "Thinking",
569
+ });
570
+ }
571
+
572
+ if (answer) {
573
+ const body = document.createElement("div");
574
+ body.className = "chat-message-body";
575
+ if (shouldRenderMarkdown(message, isTool)) {
576
+ body.classList.add("markdown-body");
577
+ try {
578
+ await renderMarkdownInto(body, answer);
579
+ } catch {
580
+ body.textContent = answer;
581
+ }
582
+ } else {
583
+ body.textContent = answer;
584
+ }
585
+ parent.appendChild(body);
586
  return;
587
  }
588
 
589
+ if (thinking && metadata.display === "thinking") {
590
+ return;
591
+ }
592
+
593
+ if (!thinking && !answer) {
594
+ const body = document.createElement("div");
595
+ body.className = "chat-message-body";
596
+ body.textContent = message.content || "";
597
+ parent.appendChild(body);
598
  }
599
  }
600
 
601
+ async function renderMessageBody(body, message, isTool) {
602
+ await renderAssistantContent(body, message, isTool);
603
+ }
604
+
605
  function appendToolLogSection(parent, label, value) {
606
  const section = document.createElement("div");
607
  section.className = "tool-log-section";
 
703
  }
704
 
705
  async function renderToolMessage(node, message, metadata, options = {}) {
706
+ const isThinking =
707
+ metadata.display === "thinking" || metadata.title === "Thinking";
708
  if (isThinking) {
709
  node.classList.add("thinking");
710
+ const header = document.createElement("div");
711
+ header.className = "thinking-message-header";
712
+ header.textContent = metadata.title || "Thinking";
713
+ node.appendChild(header);
714
+ const panel = document.createElement("div");
715
+ panel.className = "thinking-message";
716
+ await renderAssistantContent(panel, message, true);
717
+ node.appendChild(panel);
718
+ return;
719
  }
720
 
721
  if (metadata.display === "plan") {
 
871
  await renderToolMessage(node, message, metadata, { compact: true });
872
  } else {
873
  const body = document.createElement("div");
874
+ body.className = "assistant-message-content";
875
  await renderMessageBody(body, message, false);
876
  node.appendChild(body);
877
  }
 
1032
  }
1033
  } else {
1034
  const body = document.createElement("div");
1035
+ body.className = "assistant-message-content";
1036
  await renderMessageBody(body, message, isTool);
1037
  node.appendChild(body);
1038
  }
assets/markdown.js CHANGED
@@ -50,3 +50,34 @@ export function plainTextSummary(content, limit = 280) {
50
  }
51
  return `${text.slice(0, limit - 1)}…`;
52
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  }
51
  return `${text.slice(0, limit - 1)}…`;
52
  }
53
+
54
+ const THINK_OPEN = "<" + "think" + ">";
55
+ const THINK_CLOSE = "</" + "think" + ">";
56
+
57
+ export function splitThinkingContent(fullText, options = {}) {
58
+ const metaThinking = String(options.thinking || "").trim();
59
+ let text = String(fullText || "")
60
+ .replace(/<\|redacted_im_end\|>/g, "")
61
+ .replace(/<think>/g, THINK_OPEN)
62
+ .replace(/<\/redacted_thinking>/g, THINK_CLOSE);
63
+
64
+ let thinking = metaThinking;
65
+ let answer = text.trim();
66
+ const openIdx = text.indexOf(THINK_OPEN);
67
+ const closeIdx = text.indexOf(THINK_CLOSE);
68
+
69
+ if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) {
70
+ const extracted = text.slice(openIdx + THINK_OPEN.length, closeIdx).trim();
71
+ thinking = thinking ? `${thinking}\n\n${extracted}` : extracted;
72
+ answer = `${text.slice(0, openIdx)}${text.slice(closeIdx + THINK_CLOSE.length)}`.trim();
73
+ } else if (openIdx !== -1 && closeIdx === -1) {
74
+ const extracted = text.slice(openIdx + THINK_OPEN.length).trim();
75
+ thinking = thinking ? `${thinking}\n\n${extracted}` : extracted;
76
+ answer = text.slice(0, openIdx).trim();
77
+ }
78
+
79
+ return {
80
+ thinking: thinking.trim(),
81
+ answer: answer.trim(),
82
+ };
83
+ }
assets/server.css CHANGED
@@ -267,10 +267,93 @@ button:disabled {
267
  }
268
 
269
  .chat-message.tool.thinking {
270
- background: rgba(148, 163, 184, 0.08);
271
- border-color: rgba(148, 163, 184, 0.2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  font-style: italic;
273
- color: #d1d5db;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  }
275
 
276
  .chat-message.tool details.tool-log {
@@ -284,10 +367,6 @@ button:disabled {
284
  list-style-position: outside;
285
  }
286
 
287
- .chat-message.tool.thinking details.tool-log > summary {
288
- color: #cbd5e1;
289
- }
290
-
291
  .chat-message.tool .tool-summary-text {
292
  margin-top: 6px;
293
  white-space: pre-wrap;
 
267
  }
268
 
269
  .chat-message.tool.thinking {
270
+ background: rgba(79, 70, 229, 0.08);
271
+ border-color: rgba(99, 102, 241, 0.28);
272
+ font-style: normal;
273
+ color: #e5e7eb;
274
+ padding: 0;
275
+ overflow: hidden;
276
+ }
277
+
278
+ .thinking-message-header {
279
+ display: flex;
280
+ align-items: center;
281
+ gap: 8px;
282
+ padding: 10px 12px 0;
283
+ font-size: 0.72rem;
284
+ font-weight: 700;
285
+ letter-spacing: 0.12em;
286
+ text-transform: uppercase;
287
+ color: #a5b4fc;
288
+ }
289
+
290
+ .thinking-message-header::before {
291
+ content: "";
292
+ width: 6px;
293
+ height: 6px;
294
+ border-radius: 50%;
295
+ background: #818cf8;
296
+ box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.15);
297
+ }
298
+
299
+ .thinking-message {
300
+ padding: 8px 12px 12px;
301
+ }
302
+
303
+ .thinking-block-wrap {
304
+ margin-bottom: 10px;
305
+ }
306
+
307
+ .thinking-block-wrap:last-child {
308
+ margin-bottom: 0;
309
+ }
310
+
311
+ .thinking-block-label {
312
+ margin-bottom: 6px;
313
+ font-size: 0.72rem;
314
+ font-weight: 700;
315
+ letter-spacing: 0.1em;
316
+ text-transform: uppercase;
317
+ color: #a5b4fc;
318
+ }
319
+
320
+ .thinking-block {
321
+ background: rgba(79, 70, 229, 0.12);
322
+ border-left: 3px solid #6366f1;
323
+ border-radius: 4px 12px 12px 4px;
324
+ padding: 12px 14px;
325
+ font-size: 0.92rem;
326
+ color: #c7d2fe;
327
  font-style: italic;
328
+ }
329
+
330
+ .thinking-block.markdown-body {
331
+ white-space: normal;
332
+ font-style: italic;
333
+ }
334
+
335
+ .thinking-block.markdown-body p,
336
+ .thinking-block.markdown-body li {
337
+ color: #c7d2fe;
338
+ }
339
+
340
+ .thinking-block.markdown-body code {
341
+ color: #e0e7ff;
342
+ background: rgba(15, 23, 42, 0.35);
343
+ }
344
+
345
+ .assistant-message-content .thinking-block-wrap + .chat-message-body {
346
+ margin-top: 4px;
347
+ }
348
+
349
+ .chat-message.assistant .assistant-message-content {
350
+ display: flex;
351
+ flex-direction: column;
352
+ gap: 0;
353
+ }
354
+
355
+ .chat-message.tool.thinking details.tool-log > summary {
356
+ color: #cbd5e1;
357
  }
358
 
359
  .chat-message.tool details.tool-log {
 
367
  list-style-position: outside;
368
  }
369
 
 
 
 
 
370
  .chat-message.tool .tool-summary-text {
371
  margin-top: 6px;
372
  white-space: pre-wrap;
tests/test_research_findings.py CHANGED
@@ -11,7 +11,11 @@ os.environ.setdefault("BORDERLESS_PRELOAD_MODEL", "0")
11
 
12
  from langchain_core.messages import AIMessage, ToolMessage
13
 
14
- from ui.agent.graph.nodes.helpers import extract_assistant_text, research_tool_calls
 
 
 
 
15
  from ui.agent.synthesis import synthesize_finding_from_tool_messages
16
 
17
 
@@ -52,6 +56,22 @@ class ExtractAssistantTextTests(unittest.TestCase):
52
  additional_kwargs={"reasoning_content": "Hidden reasoning"},
53
  )
54
  self.assertEqual(extract_assistant_text(message), "Visible answer")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
 
57
  class ResearchToolCallsTests(unittest.TestCase):
 
11
 
12
  from langchain_core.messages import AIMessage, ToolMessage
13
 
14
+ from ui.agent.graph.nodes.helpers import (
15
+ extract_assistant_text,
16
+ extract_thinking_text,
17
+ research_tool_calls,
18
+ )
19
  from ui.agent.synthesis import synthesize_finding_from_tool_messages
20
 
21
 
 
56
  additional_kwargs={"reasoning_content": "Hidden reasoning"},
57
  )
58
  self.assertEqual(extract_assistant_text(message), "Visible answer")
59
+ self.assertEqual(extract_thinking_text(message), "Hidden reasoning")
60
+
61
+ def test_extract_thinking_text_from_think_tags(self) -> None:
62
+ open_tag = "<" + "think" + ">"
63
+ close_tag = "</" + "think" + ">"
64
+ message = AIMessage(
65
+ content=(
66
+ f"{open_tag}Compare Canada and Germany pathways.{close_tag}\n\n"
67
+ "## Recommended Countries\n- Canada"
68
+ )
69
+ )
70
+ self.assertIn("Canada", extract_assistant_text(message))
71
+ self.assertEqual(
72
+ extract_thinking_text(message),
73
+ "Compare Canada and Germany pathways.",
74
+ )
75
 
76
 
77
  class ResearchToolCallsTests(unittest.TestCase):
ui/agent/graph/nodes/consolidator.py CHANGED
@@ -10,7 +10,7 @@ from ..llm import build_llm
10
  from ..state import AgentState
11
  from ...synthesis import build_structured_final_answer
12
  from .config import CONSOLIDATOR_MAX_TOKENS, FINDING_SUMMARY_LIMIT
13
- from .helpers import extract_assistant_text, format_todo_label
14
  from .prompts import CONSOLIDATOR_SYSTEM_PROMPT
15
 
16
 
@@ -43,6 +43,7 @@ def consolidator_node(state: AgentState, config: RunnableConfig) -> dict[str, An
43
  ]
44
  response = llm.invoke(messages)
45
  answer = extract_assistant_text(response)
 
46
  if not answer:
47
  answer = build_structured_final_answer(
48
  profile_summary=str(state.get("profile_summary") or "").strip(),
@@ -53,4 +54,4 @@ def consolidator_node(state: AgentState, config: RunnableConfig) -> dict[str, An
53
  "directly from the parallel country research notes below."
54
  ),
55
  )
56
- return {"final_answer": answer}
 
10
  from ..state import AgentState
11
  from ...synthesis import build_structured_final_answer
12
  from .config import CONSOLIDATOR_MAX_TOKENS, FINDING_SUMMARY_LIMIT
13
+ from .helpers import extract_assistant_text, extract_thinking_text, format_todo_label
14
  from .prompts import CONSOLIDATOR_SYSTEM_PROMPT
15
 
16
 
 
43
  ]
44
  response = llm.invoke(messages)
45
  answer = extract_assistant_text(response)
46
+ thinking = extract_thinking_text(response)
47
  if not answer:
48
  answer = build_structured_final_answer(
49
  profile_summary=str(state.get("profile_summary") or "").strip(),
 
54
  "directly from the parallel country research notes below."
55
  ),
56
  )
57
+ return {"final_answer": answer, "consolidator_thinking": thinking}
ui/agent/graph/nodes/helpers.py CHANGED
@@ -447,6 +447,33 @@ def extract_assistant_text(message: AIMessage) -> str:
447
  return ""
448
 
449
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  def research_tool_calls(
451
  response: AIMessage,
452
  ) -> list[tuple[str, dict[str, Any], str]]:
 
447
  return ""
448
 
449
 
450
+ def extract_thinking_text(message: AIMessage) -> str:
451
+ """Extract model reasoning / thinking text separate from the user-facing answer."""
452
+ content_text, reasoning_text = assistant_text_sources(message)
453
+ answer = extract_assistant_text(message)
454
+ parts: list[str] = []
455
+
456
+ for text in (reasoning_text, content_text):
457
+ if not text.strip():
458
+ continue
459
+ inner_parts = _THINK_INNER_PATTERN.findall(text)
460
+ if inner_parts:
461
+ parts.extend(part.strip() for part in inner_parts if part.strip())
462
+ continue
463
+ outside = _THINK_BLOCK_PATTERN.sub("", text).strip()
464
+ candidate = outside or text.strip()
465
+ if candidate and candidate != answer:
466
+ parts.append(candidate)
467
+
468
+ deduped: list[str] = []
469
+ seen: set[str] = set()
470
+ for part in parts:
471
+ if part not in seen:
472
+ seen.add(part)
473
+ deduped.append(part)
474
+ return "\n\n".join(deduped).strip()
475
+
476
+
477
  def research_tool_calls(
478
  response: AIMessage,
479
  ) -> list[tuple[str, dict[str, Any], str]]:
ui/agent/graph/respond.py CHANGED
@@ -78,11 +78,17 @@ class _UiState:
78
  def handle(self, event: dict[str, Any]) -> bool:
79
  kind = event.get("type")
80
  if kind == "thinking":
 
81
  self.ui_messages.append(
82
  ChatMessage(
83
  role="assistant",
84
- content=str(event.get("text") or ""),
85
- metadata={"title": "Thinking", "status": "done"},
 
 
 
 
 
86
  )
87
  )
88
  return True
@@ -317,4 +323,8 @@ def respond_with_graph(
317
  ui_state.ui_messages,
318
  final_answer,
319
  ui_state.globe_state,
 
 
 
 
320
  )
 
78
  def handle(self, event: dict[str, Any]) -> bool:
79
  kind = event.get("type")
80
  if kind == "thinking":
81
+ thinking_text = str(event.get("text") or "").strip()
82
  self.ui_messages.append(
83
  ChatMessage(
84
  role="assistant",
85
+ content=thinking_text,
86
+ metadata={
87
+ "title": "Thinking",
88
+ "status": "done",
89
+ "display": "thinking",
90
+ "thinking": thinking_text,
91
+ },
92
  )
93
  )
94
  return True
 
323
  ui_state.ui_messages,
324
  final_answer,
325
  ui_state.globe_state,
326
+ assistant_metadata={
327
+ "markdown": True,
328
+ "thinking": str(last_state.get("consolidator_thinking") or "").strip(),
329
+ },
330
  )
ui/agent/graph/state.py CHANGED
@@ -37,6 +37,7 @@ class AgentState(TypedDict, total=False):
37
  todos: list[TodoItem]
38
  findings: Annotated[list[Finding], operator.add]
39
  final_answer: str
 
40
 
41
 
42
  class ResearchTask(TypedDict):
 
37
  todos: list[TodoItem]
38
  findings: Annotated[list[Finding], operator.add]
39
  final_answer: str
40
+ consolidator_thinking: str
41
 
42
 
43
  class ResearchTask(TypedDict):
ui/agent/streaming.py CHANGED
@@ -29,16 +29,23 @@ def yield_streaming_messages(
29
  ui_messages: list[ChatMessage],
30
  text: str,
31
  globe_state: dict[str, Any],
 
 
32
  ):
33
  messages = list(ui_messages)
 
34
  if not text:
35
- messages.append(ChatMessage(role="assistant", content=""))
36
  yield messages, globe_state
37
  return
38
 
39
- messages.append(ChatMessage(role="assistant", content=""))
40
  for end in _chunk_end_indices(text):
41
- messages[-1] = ChatMessage(role="assistant", content=text[:end])
 
 
 
 
42
  yield messages, globe_state
43
 
44
 
@@ -46,8 +53,15 @@ def yield_response(
46
  ui_messages: list[ChatMessage],
47
  text: str,
48
  globe_state: dict[str, Any],
 
 
49
  ):
50
  if ui_messages:
51
- yield from yield_streaming_messages(ui_messages, text, globe_state)
 
 
 
 
 
52
  else:
53
  yield from yield_streaming_string(text, globe_state)
 
29
  ui_messages: list[ChatMessage],
30
  text: str,
31
  globe_state: dict[str, Any],
32
+ *,
33
+ assistant_metadata: dict[str, Any] | None = None,
34
  ):
35
  messages = list(ui_messages)
36
+ metadata = dict(assistant_metadata or {})
37
  if not text:
38
+ messages.append(ChatMessage(role="assistant", content="", metadata=metadata or None))
39
  yield messages, globe_state
40
  return
41
 
42
+ messages.append(ChatMessage(role="assistant", content="", metadata=metadata or None))
43
  for end in _chunk_end_indices(text):
44
+ messages[-1] = ChatMessage(
45
+ role="assistant",
46
+ content=text[:end],
47
+ metadata=metadata or None,
48
+ )
49
  yield messages, globe_state
50
 
51
 
 
53
  ui_messages: list[ChatMessage],
54
  text: str,
55
  globe_state: dict[str, Any],
56
+ *,
57
+ assistant_metadata: dict[str, Any] | None = None,
58
  ):
59
  if ui_messages:
60
+ yield from yield_streaming_messages(
61
+ ui_messages,
62
+ text,
63
+ globe_state,
64
+ assistant_metadata=assistant_metadata,
65
+ )
66
  else:
67
  yield from yield_streaming_string(text, globe_state)