Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 56 additions & 4 deletions app/src/main/java/ai/javaclaw/chat/ChatChannel.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.Ordered;
Expand All @@ -19,7 +20,9 @@

import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicReference;

Expand Down Expand Up @@ -108,28 +111,77 @@ public List<String> conversationIds() {
/**
* Loads conversation history for the given conversationId as HTML bubbles.
* Returns a single welcome bubble if no history exists yet.
* Tool-call messages are grouped with their final assistant response.
*/
public List<String> loadHistoryAsHtml(String conversationId) {
List<Message> history = chatMemoryRepository.findByConversationId(conversationId);
if (history.isEmpty()) {
return List.of(ChatHtml.agentBubble("Hi! I'm your JavaClaw assistant. How can I help you today?"));
}
List<String> bubbles = new ArrayList<>();
List<Message> turnMessages = new ArrayList<>();
for (Message msg : history) {
if (msg instanceof UserMessage) bubbles.add(ChatHtml.userBubble(msg.getText()));
else if (msg instanceof AssistantMessage) bubbles.add(ChatHtml.agentBubble(msg.getText()));
if (msg instanceof UserMessage) {
turnMessages.clear();
bubbles.add(ChatHtml.userBubble(msg.getText()));
} else if (msg instanceof AssistantMessage am) {
turnMessages.add(am);
if (!am.hasToolCalls()) {
bubbles.add(ChatHtml.agentTurn(am.getText(), buildToolSteps(turnMessages)));
turnMessages.clear();
}
} else {
turnMessages.add(msg); // ToolResponseMessage — needed for call/result pairing
}
}
return bubbles;
}

private static List<ChatHtml.ToolStep> buildToolSteps(List<Message> turnMessages) {
Map<String, String> resultById = new LinkedHashMap<>();
for (Message msg : turnMessages) {
if (msg instanceof ToolResponseMessage trm) {
for (ToolResponseMessage.ToolResponse tr : trm.getResponses()) {
resultById.put(tr.id(), tr.responseData());
}
}
}
List<ChatHtml.ToolStep> steps = new ArrayList<>();
for (Message msg : turnMessages) {
if (msg instanceof AssistantMessage am && am.hasToolCalls()) {
for (AssistantMessage.ToolCall tc : am.getToolCalls()) {
steps.add(new ChatHtml.ToolStep(tc.name(), tc.arguments(), resultById.getOrDefault(tc.id(), "")));
}
}
}
return steps;
}

/**
* Handles a chat message from the web UI for the given conversationId.
* Returns a {@link ChatResult} containing the agent's text response plus any tool calls
* that were executed during this turn.
*/
public String chat(String conversationId, String message) {
public ChatResult chat(String conversationId, String message) {
channelRegistry.publishMessageReceivedEvent(new ChannelMessageReceivedEvent(getName(), message));
return agent.respondTo(conversationId, message);
String text = agent.respondTo(conversationId, message);
return new ChatResult(text, extractCurrentTurnToolSteps(conversationId));
}

private List<ChatHtml.ToolStep> extractCurrentTurnToolSteps(String conversationId) {
List<Message> history = chatMemoryRepository.findByConversationId(conversationId);
int start = 0;
for (int i = history.size() - 1; i >= 0; i--) {
if (history.get(i) instanceof UserMessage) {
start = i + 1;
break;
}
}
return buildToolSteps(history.subList(start, history.size()));
}

public record ChatResult(String text, List<ChatHtml.ToolStep> toolSteps) {}

private static String buildBackgroundMessageHtml(String text) {
return Htmx.oobAppend("chat-messages", ChatHtml.agentBubble(text));
}
Expand Down
38 changes: 37 additions & 1 deletion app/src/main/java/ai/javaclaw/chat/ChatHtml.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,48 @@ public class ChatHtml {

private ChatHtml() {}

public record ToolStep(String name, String arguments, String result) {}

/**
* Renders the full agent turn: a collapsible thinking row (if tool steps exist)
* immediately followed by the response bubble.
*/
public static String agentTurn(String text, List<ToolStep> steps) {
if (steps.isEmpty()) return agentBubble(text);
return thinkingRow(steps) + agentBubble(text);
}

private static String thinkingRow(List<ToolStep> steps) {
String label = steps.size() == 1 ? "Used 1 tool" : "Used " + steps.size() + " tools";
StringBuilder stepsHtml = new StringBuilder();
for (ToolStep step : steps) {
String resultBlock = step.result() != null && !step.result().isBlank()
? "\n<pre class=\"ar-thinking__step-result\">%s</pre>".formatted(HtmlUtils.htmlEscape(step.result()))
: "";
stepsHtml.append("""
<details class="ar-thinking__step" open>
<summary class="ar-thinking__step-summary">&#128295; %s</summary>
<pre class="ar-thinking__step-args">%s</pre>%s
</details>""".formatted(
HtmlUtils.htmlEscape(step.name()),
HtmlUtils.htmlEscape(step.arguments()),
resultBlock));
}
return """
<div class="ar-thinking">
<details class="ar-thinking__body">
<summary class="ar-thinking__toggle">%s</summary>
<div class="ar-thinking__steps">%s</div>
</details>
</div>""".formatted(label, stepsHtml);
}

public static String agentBubble(String text) {
return """
<article class="ar-msg ar-msg--agent">\
<div class="ar-msg__avatar">JC</div>\
<div class="ar-msg__bubble">%s</div>\
</article>""".formatted(HtmlUtils.htmlEscape(text));
</article>""".formatted(text != null && !text.isBlank() ? HtmlUtils.htmlEscape(text) : "");
}

public static String userBubble(String text) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,10 @@ private void handleUserMessage(Map<String, Object> payload) throws Exception {

try {
// Call agent (blocking — background tasks may push messages via ChatChannel during this)
String response = chatChannel.chat(conversationId, userMessage);
ChatChannel.ChatResult result = chatChannel.chat(conversationId, userMessage);

chatChannel.sendHtml(
Htmx.oobAppend("chat-messages", ChatHtml.agentBubble(response)),
Htmx.oobAppend("chat-messages", ChatHtml.agentTurn(result.text(), result.toolSteps())),
Htmx.oobReplace("typing-indicator", ""));
} catch (RuntimeException ex) {
log.warn("Chat request failed for conversation {}", conversationId, ex);
Expand Down
107 changes: 107 additions & 0 deletions app/src/main/resources/templates/chat.html.peb
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,113 @@
.chat-readonly-notice strong {
color: rgba(180, 195, 235, .7);
}

/* Thinking row — shown above the agent bubble when tool calls were made */
.ar-thinking {
align-self: flex-start;
max-width: 76%;
padding-left: calc(30px + .55rem);
animation: msg-in .22s cubic-bezier(.22, 1, .36, 1);
}

.ar-thinking__body {
display: flex;
flex-direction: column;
gap: .2rem;
}

.ar-thinking__toggle {
background: none;
border: none;
cursor: pointer;
padding: .15rem .4rem;
font-size: .75rem;
color: rgba(160, 180, 225, .45);
text-align: left;
transition: color .15s;
display: flex;
align-items: center;
gap: .3rem;
border-radius: .3rem;
list-style: none;
user-select: none;
}
.ar-thinking__toggle::-webkit-details-marker { display: none; }
.ar-thinking__toggle::before {
content: "\25BA";
font-size: .6rem;
display: inline-block;
transition: transform .2s;
}
.ar-thinking__body[open] > .ar-thinking__toggle::before { transform: rotate(90deg); }
.ar-thinking__toggle:hover { color: rgba(160, 180, 225, .8); }
.ar-thinking__body[open] > .ar-thinking__toggle { color: rgba(160, 180, 225, .9); }

.ar-thinking__steps {
display: flex;
flex-direction: column;
gap: .35rem;
}

.ar-thinking__step {
font-size: .78rem;
border-radius: .45rem;
background: hsl(228, 28%, 9%);
border: 1px solid rgba(255, 255, 255, .07);
overflow: hidden;
}
.ar-thinking__step > summary {
display: flex;
align-items: center;
gap: .35rem;
padding: .28rem .6rem;
cursor: pointer;
list-style: none;
font-family: monospace;
letter-spacing: .01em;
color: rgba(160, 180, 225, .75);
background: hsl(228, 28%, 9%);
user-select: none;
transition: color .15s;
}
.ar-thinking__step > summary::-webkit-details-marker { display: none; }
.ar-thinking__step > summary::before {
content: "\25BA";
font-size: .58rem;
display: inline-block;
transition: transform .2s;
flex-shrink: 0;
opacity: .5;
}
.ar-thinking__step[open] > summary::before { transform: rotate(90deg); }
.ar-thinking__step > summary:hover { color: rgba(160, 180, 225, 1); }
.ar-thinking__step-args {
padding: .35rem .75rem;
border-top: 1px solid rgba(255, 255, 255, .06);
font-family: monospace;
font-size: .72rem;
color: hsl(230, 15%, 58%);
white-space: pre-wrap;
word-break: break-all;
margin: 0;
background: hsl(228, 32%, 7%);
}
.ar-thinking__step-result {
padding: .35rem .75rem;
border-top: 1px solid rgba(255, 255, 255, .06);
font-family: monospace;
font-size: .72rem;
color: hsl(135, 20%, 58%);
white-space: pre-wrap;
word-break: break-all;
margin: 0;
background: hsl(135, 20%, 6%);
}

/* Visually group thinking row with its response bubble */
.ar-thinking + .ar-msg--agent {
margin-top: -0.575rem;
}
</style>
{% endblock %}

Expand Down
7 changes: 5 additions & 2 deletions app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,19 @@ void loadHistoryUsesSuppliedConversationId() {
@Test
void chatDelegatesToAgentWithConversationId() {
when(agent.respondTo("web", "hello")).thenReturn("hi");
when(chatMemoryRepository.findByConversationId("web")).thenReturn(List.of());

String response = chatChannel.chat("web", "hello");
ChatChannel.ChatResult result = chatChannel.chat("web", "hello");

assertThat(response).isEqualTo("hi");
assertThat(result.text()).isEqualTo("hi");
assertThat(result.toolSteps()).isEmpty();
verify(agent).respondTo(eq("web"), eq("hello"));
}

@Test
void chatUsesSuppliedConversationId() {
when(agent.respondTo(eq("telegram-42"), any())).thenReturn("reply");
when(chatMemoryRepository.findByConversationId("telegram-42")).thenReturn(List.of());

chatChannel.chat("telegram-42", "hello");

Expand Down
Loading
Loading