> events = agent.stream(userMsg).flatMap(this::convertEvent);
+ return wrapAsSSE(sessionId, agent, events);
+ }
+
+ // ==================== User Interaction Response Endpoint ====================
+
+ /**
+ * Submit user's response to an ask_user interaction.
+ *
+ * When the agent calls the ask_user tool and suspends, the frontend renders
+ * a UI component. After the user responds, this endpoint is called to resume
+ * the agent with the user's response as a ToolResultBlock.
+ */
+ @PostMapping(value = "/chat/respond", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+ public Flux>> respond(
+ @RequestBody Map request) {
+ String sessionId = (String) request.getOrDefault("sessionId", "default");
+ String toolId = (String) request.get("toolId");
+ if (toolId == null || toolId.isBlank()) {
+ return Flux.just(
+ ServerSentEvent.>builder()
+ .data(errorEvent("Missing required parameter: toolId"))
+ .build());
+ }
+ Object response = request.get("response");
+
+ ReActAgent agent = createAgent(sessionId);
+ runningAgents.put(sessionId, agent);
+
+ String responseText;
+ if (response instanceof String s) {
+ responseText = s;
+ } else {
+ try {
+ responseText = OBJECT_MAPPER.writeValueAsString(response);
+ } catch (Exception e) {
+ responseText = String.valueOf(response);
+ }
+ }
+
+ // Create ToolResultBlock with user's response
+ ToolResultBlock result =
+ ToolResultBlock.of(
+ toolId,
+ UserInteractionTool.TOOL_NAME,
+ TextBlock.builder().text("User responded: " + responseText).build());
+
+ Msg responseMsg = Msg.builder().role(MsgRole.TOOL).content(result).build();
+
+ Flux> events = agent.stream(responseMsg).flatMap(this::convertEvent);
+ return wrapAsSSE(sessionId, agent, events);
+ }
+
+ // ==================== Session Management ====================
+
+ /**
+ * Clear a chat session.
+ */
+ @DeleteMapping("/chat/session/{sessionId}")
+ public ResponseEntity> clearSession(@PathVariable String sessionId) {
+ session.delete(SimpleSessionKey.of(sessionId));
+ return ResponseEntity.ok(Map.of("success", true));
+ }
+
+ /**
+ * Interrupt a running agent.
+ */
+ @PostMapping("/chat/interrupt/{sessionId}")
+ public ResponseEntity> interrupt(@PathVariable String sessionId) {
+ ReActAgent agent = runningAgents.get(sessionId);
+ if (agent != null) {
+ agent.interrupt();
+ return ResponseEntity.ok(Map.of("success", true, "interrupted", true));
+ }
+ return ResponseEntity.ok(Map.of("success", true, "interrupted", false));
+ }
+
+ // ==================== Tool Confirmation Endpoint ====================
+
+ /**
+ * Approve or reject pending tool execution.
+ *
+ * When the agent calls a tool that requires confirmation (e.g. add_calendar_event),
+ * the {@linkplain ToolConfirmationHook} stops the agent before execution. The frontend
+ * displays approve/reject buttons. This endpoint handles the user's decision:
+ *
+ * Approved: resumes the agent to execute the pending tools
+ * Rejected: feeds synthetic "cancelled" tool results so the agent can continue
+ *
+ */
+ @SuppressWarnings("unchecked")
+ @PostMapping(value = "/chat/confirm", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+ public Flux>> confirmTool(
+ @RequestBody Map request) {
+ String sessionId = (String) request.getOrDefault("sessionId", "default");
+ boolean confirmed = Boolean.TRUE.equals(request.get("confirmed"));
+ List> toolCalls = (List>) request.get("toolCalls");
+
+ ReActAgent agent = createAgent(sessionId);
+ runningAgents.put(sessionId, agent);
+
+ Flux> eventFlux;
+ if (confirmed) {
+ eventFlux = agent.stream(StreamOptions.defaults()).flatMap(this::convertEvent);
+ } else {
+ String reason = (String) request.getOrDefault("reason", "Cancelled by user");
+ List results = new ArrayList<>();
+ if (toolCalls != null) {
+ for (Map tc : toolCalls) {
+ results.add(
+ ToolResultBlock.of(
+ tc.get("id"),
+ tc.get("name"),
+ TextBlock.builder().text(reason).build()));
+ }
+ }
+ Msg cancelMsg =
+ Msg.builder()
+ .role(MsgRole.TOOL)
+ .content(results.toArray(new ToolResultBlock[0]))
+ .build();
+ eventFlux = agent.stream(cancelMsg).flatMap(this::convertEvent);
+ }
+
+ return wrapAsSSE(sessionId, agent, eventFlux);
+ }
+
+ // ==================== Agent Factory ====================
+
+ private ReActAgent createAgent(String sessionId) {
+ ReActAgent agent =
+ ReActAgent.builder()
+ .name("FitnessCoach")
+ .sysPrompt(SYS_PROMPT)
+ .model(model)
+ .toolkit(toolkit)
+ .memory(new InMemoryMemory())
+ .hook(new ToolConfirmationHook(TOOLS_REQUIRING_CONFIRMATION))
+ .hook(new ObservationHook())
+ .build();
+
+ agent.loadIfExists(session, sessionId);
+ return agent;
+ }
+
+ // ==================== SSE Wrapping ====================
+
+ private Flux>> wrapAsSSE(
+ String sessionId, ReActAgent agent, Flux> events) {
+ return events.concatWith(Flux.just(completeEvent()))
+ .onErrorResume(error -> Flux.just(errorEvent(error.getMessage()), completeEvent()))
+ .doFinally(
+ signal -> {
+ runningAgents.remove(sessionId);
+ agent.saveTo(session, sessionId);
+ })
+ .map(data -> ServerSentEvent.>builder().data(data).build());
+ }
+
+ // ==================== Event Conversion ====================
+
+ /**
+ * Convert agent events to SSE-friendly maps.
+ *
+ * Key logic: when the agent returns with {@code TOOL_SUSPENDED} and the suspended
+ * tool is {@code ask_user}, we emit a {@code USER_INTERACTION} event containing the
+ * UI specification from the tool's input parameters.
+ */
+ private Flux> convertEvent(Event event) {
+ List> events = new ArrayList<>();
+ Msg msg = event.getMessage();
+
+ switch (event.getType()) {
+ case REASONING -> {
+ if (event.isLast() && msg.hasContentBlocks(ToolUseBlock.class)) {
+ List toolCalls = msg.getContentBlocks(ToolUseBlock.class);
+ boolean needsConfirm =
+ toolCalls.stream()
+ .anyMatch(
+ t ->
+ TOOLS_REQUIRING_CONFIRMATION.contains(
+ t.getName()));
+
+ if (needsConfirm) {
+ // Tools require user approval — emit TOOL_CONFIRM
+ events.add(toolConfirmEvent(toolCalls));
+ } else {
+ // Normal tool calls — show non-ask_user tools
+ for (ToolUseBlock tool : toolCalls) {
+ if (!UserInteractionTool.TOOL_NAME.equals(tool.getName())) {
+ events.add(toolUseEvent(tool));
+ }
+ }
+ }
+ } else {
+ // Streaming text chunks
+ String text = extractText(msg);
+ if (text != null && !text.isEmpty()) {
+ events.add(textEvent(text, !event.isLast()));
+ }
+ }
+ }
+ case TOOL_RESULT -> {
+ for (ToolResultBlock result : msg.getContentBlocks(ToolResultBlock.class)) {
+ if (!UserInteractionTool.TOOL_NAME.equals(result.getName())) {
+ events.add(toolResultEvent(result));
+ }
+ }
+ }
+ case AGENT_RESULT -> {
+ GenerateReason reason = msg.getGenerateReason();
+ if (reason == GenerateReason.TOOL_SUSPENDED) {
+ List toolCalls = msg.getContentBlocks(ToolUseBlock.class);
+ for (ToolUseBlock tool : toolCalls) {
+ if (UserInteractionTool.TOOL_NAME.equals(tool.getName())) {
+ events.add(userInteractionEvent(tool));
+ }
+ }
+ }
+ }
+ default -> {
+ // HINT, SUMMARY, etc. - ignore for simplicity
+ }
+ }
+
+ return Flux.fromIterable(events);
+ }
+
+ // ==================== Event Builders ====================
+
+ private Map textEvent(String content, boolean incremental) {
+ return Map.of("type", "TEXT", "content", content, "incremental", incremental);
+ }
+
+ private Map toolUseEvent(ToolUseBlock tool) {
+ return Map.of(
+ "type", "TOOL_USE",
+ "toolId", tool.getId(),
+ "toolName", tool.getName(),
+ "toolInput", convertInput(tool.getInput()));
+ }
+
+ private Map toolResultEvent(ToolResultBlock result) {
+ return Map.of(
+ "type", "TOOL_RESULT",
+ "toolId", result.getId(),
+ "toolName", result.getName(),
+ "toolResult", ObservationHook.extractToolOutputText(result, ""));
+ }
+
+ /**
+ * Build a TOOL_CONFIRM event containing all pending tool calls that need user approval.
+ */
+ private Map toolConfirmEvent(List toolCalls) {
+ List> pending =
+ toolCalls.stream()
+ .map(
+ tool ->
+ Map.of(
+ "id", tool.getId(),
+ "name", tool.getName(),
+ "input", convertInput(tool.getInput()),
+ "needsConfirm",
+ TOOLS_REQUIRING_CONFIRMATION.contains(
+ tool.getName())))
+ .toList();
+ return Map.of("type", "TOOL_CONFIRM", "pendingToolCalls", pending);
+ }
+
+ /**
+ * Build a USER_INTERACTION event from the ask_user tool's ToolUseBlock.
+ *
+ * The tool's input parameters contain the UI specification:
+ * question, ui_type, options, fields, default_value.
+ */
+ private Map userInteractionEvent(ToolUseBlock tool) {
+ Map input = tool.getInput();
+ Map event = new LinkedHashMap<>();
+ event.put("type", "USER_INTERACTION");
+ event.put("toolId", tool.getId());
+ event.put("question", input.getOrDefault("question", "Please provide more information"));
+ event.put("uiType", input.getOrDefault("ui_type", "text"));
+
+ if (input.containsKey("options")) {
+ event.put("options", input.get("options"));
+ }
+ if (input.containsKey("fields")) {
+ event.put("fields", input.get("fields"));
+ }
+ if (input.containsKey("default_value")) {
+ event.put("defaultValue", input.get("default_value"));
+ }
+ if (Boolean.TRUE.equals(input.get("allow_other"))) {
+ event.put("allowOther", true);
+ }
+
+ return event;
+ }
+
+ private static Map completeEvent() {
+ return Map.of("type", "COMPLETE");
+ }
+
+ private static Map errorEvent(String error) {
+ return Map.of("type", "ERROR", "error", error != null ? error : "Unknown error");
+ }
+
+ // ==================== Helpers ====================
+
+ private String extractText(Msg msg) {
+ List textBlocks = msg.getContentBlocks(TextBlock.class);
+ if (textBlocks.isEmpty()) {
+ return null;
+ }
+ return textBlocks.stream().map(TextBlock::getText).collect(Collectors.joining());
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map convertInput(Object input) {
+ if (input == null) {
+ return Map.of();
+ }
+ if (input instanceof Map) {
+ return (Map) input;
+ }
+ try {
+ return OBJECT_MAPPER.convertValue(input, new TypeReference>() {});
+ } catch (Exception e) {
+ return Map.of("value", input.toString());
+ }
+ }
+}
diff --git a/agentscope-examples/advanced/src/main/java/io/agentscope/examples/advanced/hitl/ObservationHook.java b/agentscope-examples/advanced/src/main/java/io/agentscope/examples/advanced/hitl/ObservationHook.java
new file mode 100644
index 000000000..275b184ba
--- /dev/null
+++ b/agentscope-examples/advanced/src/main/java/io/agentscope/examples/advanced/hitl/ObservationHook.java
@@ -0,0 +1,312 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.agentscope.examples.advanced.hitl;
+
+import io.agentscope.core.hook.ErrorEvent;
+import io.agentscope.core.hook.Hook;
+import io.agentscope.core.hook.HookEvent;
+import io.agentscope.core.hook.PostActingEvent;
+import io.agentscope.core.hook.PostCallEvent;
+import io.agentscope.core.hook.PostReasoningEvent;
+import io.agentscope.core.hook.PreActingEvent;
+import io.agentscope.core.hook.PreCallEvent;
+import io.agentscope.core.hook.PreReasoningEvent;
+import io.agentscope.core.message.ContentBlock;
+import io.agentscope.core.message.Msg;
+import io.agentscope.core.message.TextBlock;
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.message.ToolUseBlock;
+import java.util.List;
+import java.util.Map;
+import java.util.StringJoiner;
+import java.util.stream.Collectors;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Mono;
+
+/**
+ * Observation hook that logs agent conversation and tool I/O to the console.
+ *
+ * Logs the following events with structured formatting:
+ *
+ * {@link PreCallEvent} — Agent invocation with input messages
+ * {@link PreReasoningEvent} — Messages sent to the LLM
+ * {@link PostReasoningEvent} — LLM response (text + tool calls)
+ * {@link PreActingEvent} — Tool invocation with parameters
+ * {@link PostActingEvent} — Tool execution result
+ * {@link PostCallEvent} — Final agent response
+ * {@link ErrorEvent} — Errors during execution
+ *
+ */
+public class ObservationHook implements Hook {
+
+ private static final Logger log = LoggerFactory.getLogger(ObservationHook.class);
+
+ private static final String CYAN = "\u001B[36m";
+ private static final String GREEN = "\u001B[32m";
+ private static final String YELLOW = "\u001B[33m";
+ private static final String RED = "\u001B[31m";
+ private static final String MAGENTA = "\u001B[35m";
+ private static final String BOLD = "\u001B[1m";
+ private static final String DIM = "\u001B[2m";
+ private static final String RESET = "\u001B[0m";
+
+ private static final String SEPARATOR = DIM + "─".repeat(60) + RESET;
+
+ @Override
+ public int priority() {
+ // Low priority — pure observation, runs after business hooks
+ return 900;
+ }
+
+ @Override
+ public Mono onEvent(T event) {
+ if (event instanceof PreCallEvent e) {
+ logPreCall(e);
+ } else if (event instanceof PreReasoningEvent e) {
+ logPreReasoning(e);
+ } else if (event instanceof PostReasoningEvent e) {
+ logPostReasoning(e);
+ } else if (event instanceof PreActingEvent e) {
+ logPreActing(e);
+ } else if (event instanceof PostActingEvent e) {
+ logPostActing(e);
+ } else if (event instanceof PostCallEvent e) {
+ logPostCall(e);
+ } else if (event instanceof ErrorEvent e) {
+ logError(e);
+ }
+ return Mono.just(event);
+ }
+
+ private void logPreCall(PreCallEvent event) {
+ List inputs = event.getInputMessages();
+ String meta = "(" + inputs.size() + " input message(s))";
+ logMessages(log::info, CYAN, "▶ AGENT CALL", meta, inputs);
+ }
+
+ private void logPreReasoning(PreReasoningEvent event) {
+ List messages = event.getInputMessages();
+ String meta = "model=" + event.getModelName() + ", messages=" + messages.size();
+ logMessages(log::info, CYAN, "🧠 PRE-REASONING", meta, messages);
+ }
+
+ private void logPostReasoning(PostReasoningEvent event) {
+ Msg msg = event.getReasoningMessage();
+ if (msg == null) return;
+
+ StringBuilder sb = logHeader(GREEN, "🧠 POST-REASONING", null);
+ sb.append('\n').append(formatMsg(msg));
+
+ List toolCalls = msg.getContentBlocks(ToolUseBlock.class);
+ if (!toolCalls.isEmpty()) {
+ sb.append('\n')
+ .append(YELLOW)
+ .append(" ↳ Tool calls: ")
+ .append(toolCalls.size())
+ .append(RESET);
+ for (ToolUseBlock tool : toolCalls) {
+ sb.append('\n')
+ .append(YELLOW)
+ .append(" • ")
+ .append(tool.getName())
+ .append(RESET)
+ .append(DIM)
+ .append(" (id=")
+ .append(tool.getId())
+ .append(")")
+ .append(RESET);
+ sb.append('\n')
+ .append(DIM)
+ .append(" input: ")
+ .append(formatMap(tool.getInput()))
+ .append(RESET);
+ }
+ }
+
+ if (event.isStopRequested()) {
+ sb.append('\n').append(RED).append(" ⚠ Stop requested").append(RESET);
+ }
+ log.info(sb.toString());
+ }
+
+ private void logPreActing(PreActingEvent event) {
+ ToolUseBlock tool = event.getToolUse();
+ StringBuilder sb = logHeader(YELLOW, "🔧 TOOL CALL → " + tool.getName(), null);
+ sb.append('\n').append(DIM).append(" id: ").append(RESET).append(tool.getId());
+ sb.append('\n')
+ .append(DIM)
+ .append(" input: ")
+ .append(RESET)
+ .append(formatMap(tool.getInput()));
+ log.info(sb.toString());
+ }
+
+ private void logPostActing(PostActingEvent event) {
+ ToolResultBlock result = event.getToolResult();
+ StringBuilder sb = logHeader(GREEN, "✅ TOOL RESULT ← " + result.getName(), null);
+ sb.append('\n').append(DIM).append(" id: ").append(RESET).append(result.getId());
+ sb.append('\n')
+ .append(DIM)
+ .append(" output: ")
+ .append(RESET)
+ .append(extractToolOutputText(result, "(empty)"));
+
+ if (result.isSuspended()) {
+ sb.append('\n')
+ .append(MAGENTA)
+ .append(" ⏸ Suspended — waiting for user response")
+ .append(RESET);
+ }
+ log.info(sb.toString());
+ }
+
+ private void logPostCall(PostCallEvent event) {
+ Msg msg = event.getFinalMessage();
+ if (msg == null) return;
+
+ String meta = msg.getGenerateReason() != null ? "reason=" + msg.getGenerateReason() : null;
+ logMessages(log::info, GREEN, "◀ AGENT RESULT", meta, List.of(msg));
+ }
+
+ private void logError(ErrorEvent event) {
+ Throwable err = event.getError();
+ StringBuilder sb = logHeader(RED, "❌ ERROR", null);
+ sb.append('\n')
+ .append(RED)
+ .append(" ")
+ .append(err.getClass().getSimpleName())
+ .append(": ")
+ .append(err.getMessage())
+ .append(RESET);
+ log.error(sb.toString());
+ }
+
+ // ==================== Log Helpers ====================
+
+ private StringBuilder logHeader(String color, String title, String meta) {
+ StringBuilder sb = new StringBuilder();
+ sb.append('\n').append(SEPARATOR).append('\n');
+ sb.append(BOLD).append(color).append(title).append(RESET);
+ if (meta != null) {
+ sb.append(DIM).append(" ").append(meta).append(RESET);
+ }
+ sb.append('\n').append(SEPARATOR);
+ return sb;
+ }
+
+ private void logMessages(
+ java.util.function.Consumer logFn,
+ String color,
+ String title,
+ String meta,
+ List messages) {
+ StringBuilder sb = logHeader(color, title, meta);
+ for (Msg msg : messages) {
+ sb.append('\n').append(formatMsg(msg));
+ }
+ logFn.accept(sb.toString());
+ }
+
+ // ==================== Formatting Helpers ====================
+
+ private String formatMsg(Msg msg) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(" ")
+ .append(BOLD)
+ .append(roleColor(msg.getRole().name()))
+ .append('[')
+ .append(msg.getRole())
+ .append(']')
+ .append(RESET);
+
+ if (msg.getName() != null) {
+ sb.append(DIM).append(" (").append(msg.getName()).append(")").append(RESET);
+ }
+
+ for (ContentBlock block : msg.getContent()) {
+ if (block instanceof TextBlock tb) {
+ String text = tb.getText();
+ if (text != null && !text.isEmpty()) {
+ sb.append('\n').append(" ").append(truncate(text, 200));
+ }
+ } else if (block instanceof ToolUseBlock tu) {
+ sb.append('\n')
+ .append(YELLOW)
+ .append(" [ToolUse] ")
+ .append(tu.getName())
+ .append(RESET)
+ .append(DIM)
+ .append(" → ")
+ .append(truncate(formatMap(tu.getInput()), 150))
+ .append(RESET);
+ } else if (block instanceof ToolResultBlock tr) {
+ sb.append('\n')
+ .append(GREEN)
+ .append(" [ToolResult] ")
+ .append(tr.getName())
+ .append(RESET)
+ .append(DIM)
+ .append(" → ")
+ .append(truncate(extractToolOutputText(tr, "(empty)"), 150))
+ .append(RESET);
+ } else {
+ sb.append('\n')
+ .append(DIM)
+ .append(" [")
+ .append(block.getClass().getSimpleName())
+ .append("]")
+ .append(RESET);
+ }
+ }
+ return sb.toString();
+ }
+
+ private String roleColor(String role) {
+ return switch (role) {
+ case "USER" -> CYAN;
+ case "ASSISTANT" -> GREEN;
+ case "SYSTEM" -> MAGENTA;
+ case "TOOL" -> YELLOW;
+ default -> "";
+ };
+ }
+
+ static String extractToolOutputText(ToolResultBlock result, String fallback) {
+ List outputs = result.getOutput();
+ if (outputs == null || outputs.isEmpty()) return fallback;
+ String text =
+ outputs.stream()
+ .filter(TextBlock.class::isInstance)
+ .map(b -> ((TextBlock) b).getText())
+ .collect(Collectors.joining());
+ return text.isEmpty() ? fallback : text;
+ }
+
+ private String formatMap(Map map) {
+ if (map == null || map.isEmpty()) return "{}";
+ StringJoiner sj = new StringJoiner(", ", "{", "}");
+ map.forEach((k, v) -> sj.add(k + "=" + v));
+ return sj.toString();
+ }
+
+ private String truncate(String text, int maxLen) {
+ if (text == null) return "";
+ String oneLine = text.replace('\n', ' ').replace('\r', ' ');
+ if (oneLine.length() <= maxLen) return oneLine;
+ return oneLine.substring(0, maxLen) + "...";
+ }
+}
diff --git a/agentscope-examples/advanced/src/main/java/io/agentscope/examples/advanced/hitl/ToolConfirmationHook.java b/agentscope-examples/advanced/src/main/java/io/agentscope/examples/advanced/hitl/ToolConfirmationHook.java
new file mode 100644
index 000000000..e7697999d
--- /dev/null
+++ b/agentscope-examples/advanced/src/main/java/io/agentscope/examples/advanced/hitl/ToolConfirmationHook.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.agentscope.examples.advanced.hitl;
+
+import io.agentscope.core.hook.Hook;
+import io.agentscope.core.hook.HookEvent;
+import io.agentscope.core.hook.PostReasoningEvent;
+import io.agentscope.core.message.Msg;
+import io.agentscope.core.message.ToolUseBlock;
+import java.util.Set;
+import reactor.core.publisher.Mono;
+
+/**
+ * Hook that intercepts tool calls requiring user confirmation.
+ *
+ * When the agent's reasoning output contains a tool call whose name is in
+ * {@link #toolsRequiringConfirmation}, this hook stops the agent so the
+ * frontend can render approve/reject buttons before execution proceeds.
+ */
+public class ToolConfirmationHook implements Hook {
+
+ private final Set toolsRequiringConfirmation;
+
+ public ToolConfirmationHook(Set toolsRequiringConfirmation) {
+ this.toolsRequiringConfirmation = toolsRequiringConfirmation;
+ }
+
+ @Override
+ public Mono onEvent(T event) {
+ if (event instanceof PostReasoningEvent post) {
+ Msg reasoning = post.getReasoningMessage();
+ if (reasoning != null && hasToolRequiringConfirmation(reasoning)) {
+ post.stopAgent();
+ }
+ }
+ return Mono.just(event);
+ }
+
+ private boolean hasToolRequiringConfirmation(Msg reasoning) {
+ return reasoning.getContentBlocks(ToolUseBlock.class).stream()
+ .anyMatch(t -> toolsRequiringConfirmation.contains(t.getName()));
+ }
+}
diff --git a/agentscope-examples/advanced/src/main/java/io/agentscope/examples/advanced/hitl/UserInteractionTool.java b/agentscope-examples/advanced/src/main/java/io/agentscope/examples/advanced/hitl/UserInteractionTool.java
new file mode 100644
index 000000000..c69af687e
--- /dev/null
+++ b/agentscope-examples/advanced/src/main/java/io/agentscope/examples/advanced/hitl/UserInteractionTool.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.agentscope.examples.advanced.hitl;
+
+import io.agentscope.core.tool.Tool;
+import io.agentscope.core.tool.ToolParam;
+import io.agentscope.core.tool.ToolSuspendException;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Built-in tool for Human-in-the-Loop (HITL) interactions.
+ *
+ * When the LLM determines that user input is needed, it calls this tool. The tool always
+ * throws {@link ToolSuspendException} to suspend agent execution. The tool's input parameters
+ * (question, ui_type, options) are preserved in the {@code ToolUseBlock} and used by the
+ * frontend to render the appropriate UI.
+ *
+ *
Supported UI types:
+ *
+ * {@code text} - Free-form text input (default)
+ * {@code select} - Single selection from predefined options
+ * {@code multi_select} - Multiple selection from predefined options
+ * {@code confirm} - Yes/No confirmation dialog
+ * {@code form} - Multi-field structured form
+ * {@code date} - Date picker
+ * {@code number} - Numeric input
+ *
+ */
+public class UserInteractionTool {
+
+ /** Tool name constant, referenced by {@link HitlInteractionExample} for event routing. */
+ public static final String TOOL_NAME = "ask_user";
+
+ /**
+ * Ask the user for clarification or additional information.
+ *
+ * This method always throws {@link ToolSuspendException} to pause the agent.
+ * The framework converts this into a pending {@code ToolResultBlock} with
+ * {@code GenerateReason.TOOL_SUSPENDED}, allowing the frontend to extract the
+ * tool's input parameters and render the appropriate UI component.
+ *
+ * @param question the question to ask the user
+ * @param uiType UI component type (defaults to "text")
+ * @param options options for select/multi_select
+ * @param fields field definitions for form ui_type
+ * @param defaultValue default value for the input field
+ * @param allowOther if true, adds an "Other" option with free-text input (select/multi_select)
+ * @return never returns normally
+ * @throws ToolSuspendException always thrown to suspend agent execution
+ */
+ @Tool(
+ name = TOOL_NAME,
+ description =
+ "Ask the user for clarification or additional information when the request is"
+ + " ambiguous or missing required details. Choose the appropriate ui_type:"
+ + " 'text' for free-form input, 'select' for choosing one from a list"
+ + " (provide options), 'multi_select' for choosing multiple from a list,"
+ + " 'confirm' for yes/no questions, 'form' for collecting multiple fields"
+ + " at once (provide fields), 'date' for date selection, 'number' for"
+ + " numeric input.")
+ public String askUser(
+ @ToolParam(name = "question", description = "The question to ask the user")
+ String question,
+ @ToolParam(
+ name = "ui_type",
+ description =
+ "UI component type: text, select, multi_select, confirm, form,"
+ + " date, number. Defaults to 'text'.",
+ required = false)
+ String uiType,
+ @ToolParam(
+ name = "options",
+ description =
+ "Options for select/multi_select. Simple string array,"
+ + " e.g. [\"Beijing\", \"Shanghai\", \"Tokyo\"]",
+ required = false)
+ List options,
+ @ToolParam(
+ name = "fields",
+ description =
+ "Field definitions for 'form' ui_type. Array of objects with"
+ + " name, label, type (text/number/date/select/textarea),"
+ + " placeholder, required, options, min, max, step.",
+ required = false)
+ List> fields,
+ @ToolParam(
+ name = "default_value",
+ description = "Default value for the input field (string only)",
+ required = false)
+ Object defaultValue,
+ @ToolParam(
+ name = "allow_other",
+ description =
+ "If true, adds an 'Other' option with a free-text input so"
+ + " users can enter custom values not in the predefined"
+ + " list. Use with select or multi_select.",
+ required = false)
+ Boolean allowOther) {
+ String reason = question != null ? question : "Waiting for user input";
+ throw new ToolSuspendException(reason);
+ }
+}
diff --git a/agentscope-examples/advanced/src/main/resources/static/hitl-interaction/app.js b/agentscope-examples/advanced/src/main/resources/static/hitl-interaction/app.js
new file mode 100644
index 000000000..362f06604
--- /dev/null
+++ b/agentscope-examples/advanced/src/main/resources/static/hitl-interaction/app.js
@@ -0,0 +1,774 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// ==================== i18n ====================
+
+const i18n = {
+ en: {
+ subtitle: 'AI Fitness Coach with Interactive UI',
+ welcomeTitle: 'Welcome!',
+ welcomeDesc: "I'm your personal fitness coach. Tell me your goals, and I'll create a customized workout plan through interactive forms.",
+ inputPlaceholder: 'Type a message...',
+ you: 'You',
+ assistant: 'Assistant',
+ submit: 'Submit',
+ yes: 'Yes',
+ no: 'No',
+ error: 'Error',
+ selectPlaceholder: 'Select an option...',
+ responded: 'Responded',
+ approveAll: 'Approve All',
+ rejectAll: 'Reject All',
+ approved: 'Approved',
+ rejected: 'Rejected',
+ pendingApproval: 'Pending Approval',
+ toolsPendingApproval: 'tool(s) pending approval',
+ other: 'Other',
+ otherPlaceholder: 'Please specify...',
+ clearConfirm: 'Clear the current session?',
+ clearFailed: 'Failed to clear session'
+ },
+ zh: {
+ subtitle: 'AI 健身教练 · 交互式智能规划',
+ welcomeTitle: '欢迎!',
+ welcomeDesc: '我是你的私人健身教练。告诉我你的目标,我会通过交互式表单为你量身定制训练计划。',
+ inputPlaceholder: '输入消息...',
+ you: '你',
+ assistant: '助手',
+ submit: '提交',
+ yes: '是',
+ no: '否',
+ error: '错误',
+ selectPlaceholder: '请选择...',
+ responded: '已回复',
+ approveAll: '全部批准',
+ rejectAll: '全部拒绝',
+ approved: '已批准',
+ rejected: '已拒绝',
+ pendingApproval: '等待批准',
+ toolsPendingApproval: '个工具等待批准',
+ other: '其他',
+ otherPlaceholder: '请输入...',
+ clearConfirm: '确定清除当前会话?',
+ clearFailed: '清除会话失败'
+ }
+};
+
+// ==================== State ====================
+
+const state = {
+ sessionId: 'session_' + Date.now(),
+ isProcessing: false,
+ currentAssistantMessage: null,
+ currentAssistantRawText: '',
+ currentAbortController: null,
+ pendingToolCalls: null,
+ lang: navigator.language.startsWith('zh') ? 'zh' : 'en'
+};
+
+// ==================== DOM References ====================
+
+const elements = {
+ chatMessages: document.getElementById('chat-messages'),
+ messageInput: document.getElementById('message-input'),
+ sendBtn: document.getElementById('send-btn'),
+ stopBtn: document.getElementById('stop-btn'),
+ clearBtn: document.getElementById('clear-btn'),
+ langEn: document.getElementById('lang-en'),
+ langZh: document.getElementById('lang-zh'),
+ subtitle: document.getElementById('subtitle'),
+ welcomeTitle: document.getElementById('welcome-title'),
+ welcomeDesc: document.getElementById('welcome-desc')
+};
+
+function t(key) {
+ return i18n[state.lang][key] || key;
+}
+
+function updateI18n() {
+ elements.subtitle.textContent = t('subtitle');
+ elements.welcomeTitle.textContent = t('welcomeTitle');
+ elements.welcomeDesc.textContent = t('welcomeDesc');
+ elements.messageInput.placeholder = t('inputPlaceholder');
+ elements.langEn.classList.toggle('active', state.lang === 'en');
+ elements.langZh.classList.toggle('active', state.lang === 'zh');
+}
+
+function setLanguage(lang) {
+ state.lang = lang;
+ updateI18n();
+}
+
+// ==================== Initialization ====================
+
+document.addEventListener('DOMContentLoaded', () => {
+ setupEventListeners();
+ updateI18n();
+ autoResizeInput();
+});
+
+function setupEventListeners() {
+ elements.sendBtn.addEventListener('click', sendMessage);
+ elements.messageInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ sendMessage();
+ }
+ });
+ elements.messageInput.addEventListener('input', autoResizeInput);
+ elements.stopBtn.addEventListener('click', stopGeneration);
+ elements.clearBtn.addEventListener('click', clearSession);
+ elements.langEn.addEventListener('click', () => setLanguage('en'));
+ elements.langZh.addEventListener('click', () => setLanguage('zh'));
+}
+
+function autoResizeInput() {
+ const textarea = elements.messageInput;
+ textarea.style.height = 'auto';
+ textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
+}
+
+// ==================== Chat Logic ====================
+
+/**
+ * Send a POST request and stream SSE events from the response.
+ */
+async function sseRequest(url, body) {
+ setProcessing(true);
+ state.currentAssistantMessage = null;
+ state.currentAbortController = new AbortController();
+
+ try {
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ signal: state.currentAbortController.signal
+ });
+ await processSSEStream(response);
+ } catch (error) {
+ if (error.name !== 'AbortError') {
+ console.error('Request error:', error);
+ addMessage('assistant', t('error') + ': ' + error.message);
+ }
+ } finally {
+ setProcessing(false);
+ state.currentAbortController = null;
+ }
+}
+
+async function sendMessage() {
+ const message = elements.messageInput.value.trim();
+ if (!message || state.isProcessing) return;
+
+ const welcome = document.querySelector('.welcome-message');
+ if (welcome) welcome.remove();
+
+ elements.messageInput.value = '';
+ autoResizeInput();
+ addMessage('user', message);
+
+ await sseRequest('/api/chat', { sessionId: state.sessionId, message });
+}
+
+// Called from example buttons in HTML
+function sendExample(btn) {
+ elements.messageInput.value = btn.textContent;
+ sendMessage();
+}
+
+async function stopGeneration() {
+ // Abort the client-side SSE stream immediately so the UI stops
+ if (state.currentAbortController) {
+ state.currentAbortController.abort();
+ }
+ finalizeAssistantMessage();
+
+ // Tell the server to interrupt the agent's reasoning loop
+ try {
+ await fetch(`/api/chat/interrupt/${encodeURIComponent(state.sessionId)}`, {
+ method: 'POST'
+ });
+ } catch (error) {
+ console.error('Interrupt failed:', error);
+ }
+}
+
+async function clearSession() {
+ if (!confirm(t('clearConfirm'))) return;
+ try {
+ await fetch(`/api/chat/session/${encodeURIComponent(state.sessionId)}`, {
+ method: 'DELETE'
+ });
+ location.reload();
+ } catch (error) {
+ console.error(t('clearFailed'), error);
+ }
+}
+
+// ==================== SSE Processing ====================
+
+async function processSSEStream(response) {
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (value) buffer += decoder.decode(value, { stream: true });
+
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+
+ for (const line of lines) {
+ if (line.startsWith('data:')) {
+ const data = line.slice(5).trim();
+ if (data) {
+ try {
+ handleChatEvent(JSON.parse(data));
+ } catch (e) {
+ console.error('Parse failed:', data, e);
+ }
+ }
+ }
+ }
+ if (done) break;
+ }
+ } finally {
+ reader.releaseLock();
+ }
+}
+
+function handleChatEvent(event) {
+ switch (event.type) {
+ case 'TEXT':
+ if (event.incremental) {
+ appendToAssistantMessage(event.content);
+ } else {
+ // Non-incremental text = final event (content already streamed).
+ // Just finalize the current message; don't create a duplicate.
+ finalizeAssistantMessage();
+ }
+ break;
+ case 'TOOL_USE':
+ finalizeAssistantMessage();
+ addToolUseEvent(event.toolId, event.toolName, event.toolInput);
+ break;
+ case 'TOOL_RESULT':
+ addToolResultEvent(event.toolId, event.toolName, event.toolResult);
+ break;
+ case 'USER_INTERACTION':
+ finalizeAssistantMessage();
+ renderUserInteraction(event);
+ break;
+ case 'TOOL_CONFIRM':
+ finalizeAssistantMessage();
+ state.pendingToolCalls = event.pendingToolCalls;
+ renderToolConfirmation(event.pendingToolCalls);
+ break;
+ case 'ERROR':
+ finalizeAssistantMessage();
+ addMessage('assistant', t('error') + ': ' + event.error);
+ break;
+ case 'COMPLETE':
+ finalizeAssistantMessage();
+ break;
+ }
+}
+
+// ==================== Message Rendering ====================
+
+function addMessage(role, content) {
+ const div = document.createElement('div');
+ div.className = `message ${role}`;
+
+ const contentHtml = role === 'assistant'
+ ? renderMarkdown(content)
+ : escapeHtml(content);
+
+ div.innerHTML = `
+ ${role === 'user' ? '👤' : '🤖'}
+
+
${role === 'user' ? t('you') : t('assistant')}
+
${contentHtml}
+
+ `;
+ elements.chatMessages.appendChild(div);
+ scrollToBottom();
+ if (role === 'assistant') {
+ state.currentAssistantMessage = div.querySelector('.message-content');
+ state.currentAssistantRawText = content;
+ }
+}
+
+function appendToAssistantMessage(content) {
+ if (!state.currentAssistantMessage) {
+ addMessage('assistant', content);
+ } else {
+ state.currentAssistantRawText += content;
+ state.currentAssistantMessage.innerHTML = renderMarkdown(state.currentAssistantRawText);
+ scrollToBottom();
+ }
+}
+
+function finalizeAssistantMessage() {
+ if (state.currentAssistantMessage && state.currentAssistantRawText) {
+ state.currentAssistantMessage.innerHTML = renderMarkdown(state.currentAssistantRawText);
+ }
+ state.currentAssistantMessage = null;
+ state.currentAssistantRawText = '';
+}
+
+function renderMarkdown(text) {
+ if (!text) return '';
+ if (typeof marked !== 'undefined') {
+ return marked.parse(text);
+ }
+ return escapeHtml(text);
+}
+
+function addToolUseEvent(toolId, toolName, input) {
+ const div = document.createElement('div');
+ div.className = 'tool-event';
+ div.id = `tool-${toolId}`;
+ const inputJson = input ? JSON.stringify(input, null, 2) : '{}';
+ div.innerHTML =
+ '' +
+ '';
+ elements.chatMessages.appendChild(div);
+ scrollToBottom();
+}
+
+function addToolResultEvent(toolId, toolName, result) {
+ const toolDiv = document.getElementById(`tool-${toolId}`);
+ if (toolDiv) {
+ const badge = toolDiv.querySelector('.tool-status-badge');
+ if (badge) {
+ badge.className = 'tool-status-badge done';
+ badge.textContent = 'done';
+ }
+ }
+ const div = document.createElement('div');
+ div.className = 'tool-event result';
+ div.innerHTML =
+ '' +
+ '';
+ elements.chatMessages.appendChild(div);
+ scrollToBottom();
+}
+
+// ==================== Tool Confirmation ====================
+
+function renderToolConfirmation(pendingToolCalls) {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'tool-confirm-group';
+
+ for (const tool of pendingToolCalls) {
+ const card = document.createElement('div');
+ card.className = 'tool-confirm-card';
+ card.id = `tool-${tool.id}`;
+ const inputJson = tool.input ? JSON.stringify(tool.input, null, 2) : '{}';
+ card.innerHTML =
+ '' +
+ '';
+ wrapper.appendChild(card);
+ }
+
+ const actionBar = document.createElement('div');
+ actionBar.className = 'tool-confirm-batch-actions';
+ actionBar.id = 'tool-confirm-batch';
+ actionBar.innerHTML =
+ '' + pendingToolCalls.length + ' ' + t('toolsPendingApproval') + ' ' +
+ '' +
+ '' + t('approveAll') + ' ' +
+ '' + t('rejectAll') + ' ' +
+ '
';
+ wrapper.appendChild(actionBar);
+
+ elements.chatMessages.appendChild(wrapper);
+ scrollToBottom();
+}
+
+async function confirmToolCall(confirmed) {
+ if (!state.pendingToolCalls) return;
+
+ const toolCalls = state.pendingToolCalls;
+
+ for (const tool of toolCalls) {
+ const badge = document.querySelector(`#tool-${tool.id} .tool-status-badge`);
+ if (badge) {
+ badge.className = confirmed ? 'tool-status-badge done' : 'tool-status-badge rejected';
+ badge.textContent = confirmed ? t('approved') : t('rejected');
+ }
+ }
+ const batchBar = document.getElementById('tool-confirm-batch');
+ if (batchBar) {
+ batchBar.innerHTML = confirmed
+ ? `✓ ${t('approved')} `
+ : `✗ ${t('rejected')} `;
+ }
+
+ const callInfos = toolCalls.map(tc => ({ id: tc.id, name: tc.name }));
+ state.pendingToolCalls = null;
+
+ await sseRequest('/api/chat/confirm', {
+ sessionId: state.sessionId,
+ confirmed,
+ reason: confirmed ? null : 'Cancelled by user',
+ toolCalls: callInfos
+ });
+}
+
+// ========================================================
+// USER INTERACTION - Dynamic UI Component Registry
+// ========================================================
+
+/**
+ * Component Registry: maps ui_type → render function.
+ * Each render function receives the event data and returns a DOM element.
+ */
+const componentRegistry = {
+ text: renderTextInput,
+ select: (e) => renderSelectGroup(e, false),
+ multi_select: (e) => renderSelectGroup(e, true),
+ confirm: (e) => renderSelectGroup(
+ { ...e, options: [{ value: 'yes', label: t('yes') }, { value: 'no', label: t('no') }] },
+ false),
+ form: renderForm,
+ date: renderDateInput,
+ number: renderNumberInput
+};
+
+/**
+ * Main entry: render the appropriate UI based on the USER_INTERACTION event.
+ */
+function renderUserInteraction(event) {
+ const container = document.createElement('div');
+ container.className = 'message assistant';
+ container.id = `interaction-${event.toolId}`;
+
+ const body = document.createElement('div');
+ body.className = 'message-body';
+
+ // Avatar
+ const avatar = document.createElement('div');
+ avatar.className = 'message-avatar';
+ avatar.textContent = '🤖';
+ container.appendChild(avatar);
+
+ // Label
+ const label = document.createElement('div');
+ label.className = 'message-label';
+ label.textContent = t('assistant');
+ body.appendChild(label);
+
+ // Interaction card
+ const card = document.createElement('div');
+ card.className = 'interaction-card';
+
+ // Question
+ const questionDiv = document.createElement('div');
+ questionDiv.className = 'interaction-question';
+ questionDiv.textContent = event.question;
+ card.appendChild(questionDiv);
+
+ // Dynamic UI component
+ const uiType = event.uiType || 'text';
+ const renderer = componentRegistry[uiType] || renderTextInput;
+ const componentDiv = renderer(event);
+ componentDiv.className = (componentDiv.className || '') + ' interaction-component';
+ card.appendChild(componentDiv);
+
+ // Submit button
+ const submitRow = document.createElement('div');
+ submitRow.className = 'interaction-submit-row';
+ const submitBtn = document.createElement('button');
+ submitBtn.className = 'btn btn-primary interaction-submit-btn';
+ submitBtn.textContent = t('submit');
+ submitBtn.onclick = () => submitInteraction(event.toolId, uiType, card);
+ submitRow.appendChild(submitBtn);
+ card.appendChild(submitRow);
+
+ body.appendChild(card);
+ container.appendChild(body);
+ elements.chatMessages.appendChild(container);
+ scrollToBottom();
+}
+
+// ==================== UI Components ====================
+
+function renderTextInput(event) {
+ const div = document.createElement('div');
+ const input = document.createElement('textarea');
+ input.className = 'interaction-text-input';
+ input.name = '_response';
+ input.placeholder = event.defaultValue || '';
+ input.rows = 2;
+ div.appendChild(input);
+ return div;
+}
+
+function renderSelectGroup(event, multi = false) {
+ const div = document.createElement('div');
+ div.className = 'interaction-select-group' + (multi ? ' multi' : '');
+
+ const deselectAll = () => {
+ div.querySelectorAll('.select-option-btn').forEach(b => b.classList.remove('selected'));
+ const otherInput = div.querySelector('.select-other-input');
+ if (otherInput) otherInput.classList.add('hidden');
+ };
+
+ for (const option of (event.options || [])) {
+ const btn = document.createElement('button');
+ btn.className = 'select-option-btn';
+ btn.dataset.value = option.value || option.label || option;
+ btn.textContent = option.label || option.value || option;
+ btn.onclick = () => {
+ if (!multi) deselectAll();
+ btn.classList.toggle('selected');
+ };
+ div.appendChild(btn);
+ }
+
+ if (event.allowOther) {
+ const otherBtn = document.createElement('button');
+ otherBtn.className = 'select-option-btn select-other-btn';
+ otherBtn.dataset.value = '__other__';
+ otherBtn.textContent = '✏️ ' + t('other');
+
+ const otherInput = document.createElement('input');
+ otherInput.type = 'text';
+ otherInput.className = 'select-other-input hidden';
+ otherInput.name = '_other';
+ otherInput.placeholder = t('otherPlaceholder');
+
+ otherBtn.onclick = () => {
+ if (!multi) deselectAll();
+ otherBtn.classList.toggle('selected');
+ otherInput.classList.toggle('hidden', !otherBtn.classList.contains('selected'));
+ if (otherBtn.classList.contains('selected')) otherInput.focus();
+ };
+
+ div.appendChild(otherBtn);
+ div.appendChild(otherInput);
+ }
+
+ return div;
+}
+
+function renderForm(event) {
+ const div = document.createElement('div');
+ div.className = 'interaction-form';
+
+ const fields = event.fields || [];
+ for (const field of fields) {
+ const group = document.createElement('div');
+ group.className = 'form-field';
+
+ const label = document.createElement('label');
+ label.className = 'form-field-label';
+ label.textContent = (field.label || field.name) + (field.required ? ' *' : '');
+ group.appendChild(label);
+
+ if (field.type === 'select' && field.options) {
+ const select = document.createElement('select');
+ select.className = 'form-field-input';
+ select.name = field.name;
+ if (field.required) select.required = true;
+
+ const defaultOpt = document.createElement('option');
+ defaultOpt.value = '';
+ defaultOpt.textContent = t('selectPlaceholder');
+ select.appendChild(defaultOpt);
+
+ for (const opt of field.options) {
+ const option = document.createElement('option');
+ option.value = opt.value || opt.label || opt;
+ option.textContent = opt.label || opt.value || opt;
+ select.appendChild(option);
+ }
+ group.appendChild(select);
+ } else if (field.type === 'textarea') {
+ const textarea = document.createElement('textarea');
+ textarea.className = 'form-field-input';
+ textarea.name = field.name;
+ textarea.placeholder = field.placeholder || '';
+ textarea.rows = 3;
+ if (field.required) textarea.required = true;
+ group.appendChild(textarea);
+ } else {
+ const input = document.createElement('input');
+ input.className = 'form-field-input';
+ input.type = field.type || 'text';
+ input.name = field.name;
+ input.placeholder = field.placeholder || '';
+ if (field.required) input.required = true;
+ if (field.type === 'number') {
+ if (field.min !== undefined) input.min = field.min;
+ if (field.max !== undefined) input.max = field.max;
+ if (field.step !== undefined) input.step = field.step;
+ }
+ if (field.type === 'date') {
+ if (field.min) input.min = field.min;
+ if (field.max) input.max = field.max;
+ }
+ group.appendChild(input);
+ }
+
+ div.appendChild(group);
+ }
+ return div;
+}
+
+function renderDateInput(event) {
+ const div = document.createElement('div');
+ const input = document.createElement('input');
+ input.type = 'date';
+ input.className = 'form-field-input';
+ input.name = '_response';
+ if (event.defaultValue) input.value = event.defaultValue;
+ div.appendChild(input);
+ return div;
+}
+
+function renderNumberInput(event) {
+ const div = document.createElement('div');
+ const input = document.createElement('input');
+ input.type = 'number';
+ input.className = 'form-field-input';
+ input.name = '_response';
+ if (event.defaultValue) input.value = event.defaultValue;
+ div.appendChild(input);
+ return div;
+}
+
+// ==================== Submit Interaction ====================
+
+/**
+ * Collect the user's response from the UI component and send it back to the server.
+ */
+async function submitInteraction(toolId, uiType, card) {
+ let response = collectResponse(uiType, card);
+ if (response === null || response === undefined) return;
+ if (typeof response === 'string' && response.trim() === '') return;
+
+ // Disable the interaction card
+ card.classList.add('responded');
+ card.querySelectorAll('button, input, select, textarea').forEach(el => el.disabled = true);
+
+ // Replace submit button with responded badge
+ const submitRow = card.querySelector('.interaction-submit-row');
+ if (submitRow) {
+ submitRow.innerHTML = `✓ ${t('responded')} `;
+ }
+
+ await sseRequest('/api/chat/respond', {
+ sessionId: state.sessionId,
+ toolId,
+ response
+ });
+}
+
+/**
+ * Extract the user's response from the rendered UI component.
+ */
+function collectResponse(uiType, card) {
+ switch (uiType) {
+ case 'select':
+ case 'confirm': {
+ const selected = card.querySelector('.select-option-btn.selected');
+ if (!selected) return null;
+ if (selected.dataset.value === '__other__') {
+ const otherInput = card.querySelector('.select-other-input');
+ return otherInput && otherInput.value.trim() ? otherInput.value.trim() : null;
+ }
+ return selected.dataset.value;
+ }
+ case 'multi_select': {
+ const selected = card.querySelectorAll('.select-option-btn.selected');
+ if (selected.length === 0) return null;
+ const values = Array.from(selected)
+ .map(b => b.dataset.value)
+ .filter(v => v !== '__other__');
+ const otherInput = card.querySelector('.select-other-input');
+ if (otherInput && otherInput.value.trim()) {
+ values.push(otherInput.value.trim());
+ }
+ return values.length > 0 ? values : null;
+ }
+ case 'form': {
+ const result = {};
+ let valid = true;
+ card.querySelectorAll('.form-field-input').forEach(el => {
+ if (el.name) result[el.name] = el.value;
+ if (el.required && !el.value.trim()) {
+ el.classList.add('invalid');
+ valid = false;
+ } else {
+ el.classList.remove('invalid');
+ }
+ });
+ if (!valid) return null;
+ return result;
+ }
+ case 'text':
+ case 'date':
+ case 'number':
+ default: {
+ const input = card.querySelector('input, textarea');
+ return input ? input.value : null;
+ }
+ }
+}
+
+// ==================== Utilities ====================
+
+function setProcessing(processing) {
+ state.isProcessing = processing;
+ elements.sendBtn.disabled = processing;
+ elements.messageInput.disabled = processing;
+ elements.stopBtn.classList.toggle('hidden', !processing);
+ elements.sendBtn.classList.toggle('hidden', processing);
+}
+
+function scrollToBottom() {
+ elements.chatMessages.scrollTop = elements.chatMessages.scrollHeight;
+}
+
+function escapeHtml(text) {
+ if (text === null || text === undefined) return '';
+ const div = document.createElement('div');
+ div.textContent = String(text);
+ return div.innerHTML;
+}
diff --git a/agentscope-examples/advanced/src/main/resources/static/hitl-interaction/index.html b/agentscope-examples/advanced/src/main/resources/static/hitl-interaction/index.html
new file mode 100644
index 000000000..d8dab4701
--- /dev/null
+++ b/agentscope-examples/advanced/src/main/resources/static/hitl-interaction/index.html
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+ HITL Interactive UI - AgentScope
+
+
+
+
+
+
+
+
+
+
🤖
+
Welcome!
+
I'm your personal fitness coach. Tell me your goals, and I'll create a customized workout plan through interactive forms.
+
+ 帮我制定一个健身计划
+ I want to lose weight
+ 我想增肌,每周能练4天
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/agentscope-examples/advanced/src/main/resources/static/hitl-interaction/style.css b/agentscope-examples/advanced/src/main/resources/static/hitl-interaction/style.css
new file mode 100644
index 000000000..62f77192c
--- /dev/null
+++ b/agentscope-examples/advanced/src/main/resources/static/hitl-interaction/style.css
@@ -0,0 +1,860 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/* ==================== Reset & Variables ==================== */
+
+*,
+*::before,
+*::after {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+:root {
+ --bg-primary: #f8f9fb;
+ --bg-surface: #ffffff;
+ --bg-hover: #f0f2f5;
+ --bg-user-msg: #e8f0fe;
+ --text-primary: #1a1a2e;
+ --text-secondary: #6b7280;
+ --text-muted: #9ca3af;
+ --border-color: #e5e7eb;
+ --accent: #4f46e5;
+ --accent-hover: #4338ca;
+ --accent-light: #eef2ff;
+ --success: #10b981;
+ --error: #ef4444;
+ --radius-sm: 8px;
+ --radius-md: 12px;
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
+ --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
+ 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
+ --font-mono: 'SF Mono', 'Fira Code', 'Consolas', monospace;
+}
+
+body {
+ font-family: var(--font-sans);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ line-height: 1.6;
+ height: 100vh;
+ overflow: hidden;
+}
+
+/* ==================== Layout ==================== */
+
+.container {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ max-width: 800px;
+ margin: 0 auto;
+}
+
+.header {
+ padding: 12px 20px;
+ background: var(--bg-surface);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.header-content {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.header-title h1 {
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--text-primary);
+ letter-spacing: -0.3px;
+}
+
+.header-title p {
+ font-size: 12px;
+ color: var(--text-secondary);
+ margin-top: 1px;
+}
+
+.header-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* ==================== Buttons ==================== */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ cursor: pointer;
+ font-family: var(--font-sans);
+ font-size: 14px;
+ font-weight: 500;
+ transition: all 0.15s ease;
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: #fff;
+ padding: 8px 16px;
+ border-radius: var(--radius-sm);
+}
+
+.btn-primary:hover:not(:disabled) {
+ background: var(--accent-hover);
+}
+
+.btn-primary:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-warning {
+ background: var(--error);
+ color: #fff;
+ padding: 8px 16px;
+ border-radius: var(--radius-sm);
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--text-secondary);
+ padding: 6px;
+ border-radius: var(--radius-sm);
+}
+
+.btn-ghost:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+
+.hidden {
+ display: none !important;
+}
+
+/* ==================== Language Switch ==================== */
+
+.lang-switch {
+ display: flex;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+}
+
+.lang-btn {
+ padding: 4px 10px;
+ font-size: 11px;
+ font-weight: 500;
+ border: none;
+ background: transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.lang-btn.active {
+ background: var(--accent);
+ color: #fff;
+}
+
+/* ==================== Chat Area ==================== */
+
+.chat-area {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+}
+
+.chat-messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.chat-messages::-webkit-scrollbar {
+ width: 6px;
+}
+
+.chat-messages::-webkit-scrollbar-thumb {
+ background: var(--border-color);
+ border-radius: 3px;
+}
+
+/* ==================== Welcome Message ==================== */
+
+.welcome-message {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ padding: 40px 20px;
+ margin: auto 0;
+}
+
+.welcome-icon {
+ font-size: 48px;
+ margin-bottom: 16px;
+}
+
+.welcome-message h2 {
+ font-size: 22px;
+ font-weight: 700;
+ margin-bottom: 8px;
+}
+
+.welcome-message p {
+ color: var(--text-secondary);
+ max-width: 460px;
+ margin-bottom: 24px;
+ font-size: 14px;
+}
+
+.example-queries {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ justify-content: center;
+}
+
+.example-btn {
+ padding: 8px 16px;
+ font-size: 13px;
+ background: var(--bg-surface);
+ border: 1px solid var(--border-color);
+ border-radius: 20px;
+ cursor: pointer;
+ color: var(--text-primary);
+ transition: all 0.15s ease;
+ font-family: var(--font-sans);
+}
+
+.example-btn:hover {
+ background: var(--accent-light);
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+/* ==================== Messages ==================== */
+
+.message {
+ display: flex;
+ gap: 10px;
+ max-width: 100%;
+ animation: fadeInUp 0.2s ease;
+}
+
+@keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.message-avatar {
+ flex-shrink: 0;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ border-radius: 50%;
+ background: var(--bg-hover);
+}
+
+.message-body {
+ flex: 1;
+ min-width: 0;
+}
+
+.message-label {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ margin-bottom: 4px;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.message-content {
+ font-size: 14px;
+ line-height: 1.65;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.message.user .message-content {
+ background: var(--bg-user-msg);
+ padding: 10px 14px;
+ border-radius: var(--radius-md);
+ display: inline-block;
+}
+
+/* ==================== Tool Events ==================== */
+
+.tool-event {
+ margin-left: 42px;
+ margin-bottom: 8px;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ background: var(--bg-surface);
+ font-size: 13px;
+ animation: fadeInUp 0.2s ease;
+}
+
+.tool-event.result {
+ border-color: #d1fae5;
+ background: #f0fdf4;
+}
+
+.tool-event-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ background: var(--bg-hover);
+ font-weight: 500;
+}
+
+.tool-event.result .tool-event-header {
+ background: #ecfdf5;
+}
+
+.tool-event-params {
+ padding: 0 12px 8px;
+}
+
+.tool-icon {
+ font-size: 14px;
+}
+
+.tool-label {
+ flex: 1;
+}
+
+.tool-label code {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ background: rgba(0, 0, 0, 0.06);
+ padding: 1px 6px;
+ border-radius: 4px;
+}
+
+.tool-status-badge {
+ font-size: 11px;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-weight: 500;
+}
+
+.tool-status-badge.executing {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+.tool-status-badge.done {
+ background: #d1fae5;
+ color: #065f46;
+}
+
+.tool-status-badge.pending {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+.tool-status-badge.rejected {
+ background: #fee2e2;
+ color: #991b1b;
+}
+
+/* ==================== Tool Confirmation ==================== */
+
+.tool-confirm-group {
+ margin-left: 42px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ animation: fadeInUp 0.2s ease;
+}
+
+.tool-confirm-card {
+ border: 2px solid #fbbf24;
+ border-radius: var(--radius-md);
+ background: var(--bg-surface);
+}
+
+.tool-confirm-card-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ background: #fffbeb;
+ font-weight: 500;
+ font-size: 13px;
+}
+
+.tool-confirm-card-params {
+ padding: 0 12px 8px;
+}
+
+.btn-sm {
+ padding: 6px 16px;
+ font-size: 13px;
+ font-weight: 500;
+ border: none;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ font-family: var(--font-sans);
+ transition: all 0.15s ease;
+}
+
+.btn-confirm-approve {
+ background: var(--success);
+ color: #fff;
+}
+
+.btn-confirm-approve:hover {
+ background: #059669;
+}
+
+.btn-confirm-reject {
+ background: var(--bg-hover);
+ color: var(--text-secondary);
+ border: 1px solid var(--border-color);
+}
+
+.btn-confirm-reject:hover {
+ background: #fee2e2;
+ color: #991b1b;
+ border-color: #fca5a5;
+}
+
+/* ==================== Batch Approval Bar ==================== */
+
+.tool-confirm-batch-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ background: #fffbeb;
+ border: 2px solid #fbbf24;
+ border-radius: var(--radius-md);
+}
+
+.batch-label {
+ font-size: 13px;
+ font-weight: 600;
+ color: #92400e;
+}
+
+.batch-buttons {
+ display: flex;
+ gap: 8px;
+}
+
+.tool-params-pre {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ line-height: 1.5;
+ background: var(--bg-primary);
+ padding: 8px 10px;
+ border-radius: var(--radius-sm);
+ overflow-x: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ margin: 0;
+}
+
+/* ==================== Interaction Card ==================== */
+
+.interaction-card {
+ background: var(--bg-surface);
+ border: 2px solid var(--accent);
+ border-radius: var(--radius-md);
+ padding: 16px;
+ animation: fadeInUp 0.3s ease;
+ box-shadow: var(--shadow-md);
+}
+
+.interaction-card.responded {
+ border-color: var(--border-color);
+ opacity: 0.75;
+ box-shadow: none;
+}
+
+.interaction-question {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 14px;
+ line-height: 1.5;
+}
+
+.interaction-component {
+ margin-bottom: 14px;
+}
+
+.interaction-submit-row {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.interaction-submit-btn {
+ min-width: 80px;
+}
+
+.responded-badge {
+ font-size: 13px;
+ color: var(--success);
+ font-weight: 500;
+}
+
+/* ==================== Text Input Component ==================== */
+
+.interaction-text-input {
+ width: 100%;
+ padding: 10px 12px;
+ font-size: 14px;
+ font-family: var(--font-sans);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ resize: vertical;
+ transition: border-color 0.15s ease;
+ background: var(--bg-surface);
+}
+
+.interaction-text-input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
+}
+
+/* ==================== Select / MultiSelect Component ==================== */
+
+.interaction-select-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.select-option-btn {
+ padding: 8px 18px;
+ font-size: 14px;
+ font-family: var(--font-sans);
+ border: 1.5px solid var(--border-color);
+ border-radius: 20px;
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ cursor: pointer;
+ transition: all 0.15s ease;
+ font-weight: 500;
+}
+
+.select-option-btn:hover:not(:disabled) {
+ border-color: var(--accent);
+ background: var(--accent-light);
+ color: var(--accent);
+}
+
+.select-option-btn.selected {
+ background: var(--accent);
+ color: #fff;
+ border-color: var(--accent);
+}
+
+.select-option-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.select-other-btn {
+ border-style: dashed;
+}
+
+.select-other-input {
+ width: 100%;
+ flex-basis: 100%;
+ margin-top: 8px;
+ padding: 9px 12px;
+ font-size: 14px;
+ font-family: var(--font-sans);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ background: var(--bg-surface);
+ transition: border-color 0.15s ease;
+}
+
+.select-other-input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
+}
+
+/* ==================== Form Component ==================== */
+
+.interaction-form {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.form-field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.form-field-label {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.form-field-input {
+ width: 100%;
+ padding: 9px 12px;
+ font-size: 14px;
+ font-family: var(--font-sans);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ background: var(--bg-surface);
+ transition: border-color 0.15s ease;
+}
+
+.form-field-input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
+}
+
+.form-field-input.invalid {
+ border-color: var(--error);
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
+}
+
+select.form-field-input {
+ appearance: auto;
+ cursor: pointer;
+}
+
+textarea.form-field-input {
+ resize: vertical;
+}
+
+/* ==================== Input Area ==================== */
+
+.input-area {
+ padding: 12px 20px 16px;
+ background: var(--bg-surface);
+ border-top: 1px solid var(--border-color);
+}
+
+.input-wrapper {
+ display: flex;
+ align-items: flex-end;
+ gap: 8px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ padding: 8px 8px 8px 14px;
+ transition: border-color 0.15s ease;
+}
+
+.input-wrapper:focus-within {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.08);
+}
+
+.input-wrapper textarea {
+ flex: 1;
+ border: none;
+ outline: none;
+ background: transparent;
+ font-size: 14px;
+ font-family: var(--font-sans);
+ line-height: 1.5;
+ resize: none;
+ min-height: 24px;
+ max-height: 120px;
+ color: var(--text-primary);
+}
+
+.input-wrapper textarea::placeholder {
+ color: var(--text-muted);
+}
+
+.input-buttons {
+ flex-shrink: 0;
+}
+
+.input-buttons .btn {
+ width: 36px;
+ height: 36px;
+ padding: 0;
+ border-radius: 50%;
+}
+
+/* ==================== Markdown Body ==================== */
+
+.markdown-body {
+ white-space: normal;
+}
+
+.markdown-body h1,
+.markdown-body h2,
+.markdown-body h3,
+.markdown-body h4 {
+ margin-top: 16px;
+ margin-bottom: 8px;
+ font-weight: 700;
+ line-height: 1.4;
+}
+
+.markdown-body h1 { font-size: 1.4em; }
+.markdown-body h2 { font-size: 1.25em; }
+.markdown-body h3 { font-size: 1.1em; }
+.markdown-body h4 { font-size: 1em; }
+
+.markdown-body h1:first-child,
+.markdown-body h2:first-child,
+.markdown-body h3:first-child {
+ margin-top: 0;
+}
+
+.markdown-body p {
+ margin: 8px 0;
+}
+
+.markdown-body ul,
+.markdown-body ol {
+ margin: 8px 0;
+ padding-left: 24px;
+}
+
+.markdown-body li {
+ margin: 4px 0;
+}
+
+.markdown-body li > ul,
+.markdown-body li > ol {
+ margin: 2px 0;
+}
+
+.markdown-body strong {
+ font-weight: 700;
+}
+
+.markdown-body em {
+ font-style: italic;
+}
+
+.markdown-body code {
+ font-family: var(--font-mono);
+ font-size: 0.88em;
+ background: rgba(0, 0, 0, 0.06);
+ padding: 2px 6px;
+ border-radius: 4px;
+}
+
+.markdown-body pre {
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ padding: 12px 14px;
+ margin: 10px 0;
+ overflow-x: auto;
+}
+
+.markdown-body pre code {
+ background: none;
+ padding: 0;
+ font-size: 12px;
+ line-height: 1.6;
+}
+
+.markdown-body hr {
+ border: none;
+ border-top: 1px solid var(--border-color);
+ margin: 14px 0;
+}
+
+.markdown-body table {
+ border-collapse: collapse;
+ width: 100%;
+ margin: 10px 0;
+ font-size: 13px;
+}
+
+.markdown-body th,
+.markdown-body td {
+ border: 1px solid var(--border-color);
+ padding: 8px 12px;
+ text-align: left;
+}
+
+.markdown-body th {
+ background: var(--bg-hover);
+ font-weight: 600;
+}
+
+.markdown-body blockquote {
+ border-left: 3px solid var(--accent);
+ margin: 10px 0;
+ padding: 4px 14px;
+ color: var(--text-secondary);
+ background: var(--accent-light);
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
+}
+
+/* ==================== Responsive ==================== */
+
+@media (max-width: 600px) {
+ .container {
+ max-width: 100%;
+ }
+
+ .chat-messages {
+ padding: 12px;
+ }
+
+ .interaction-card {
+ padding: 12px;
+ }
+
+ .example-queries {
+ flex-direction: column;
+ }
+
+ .tool-event {
+ margin-left: 0;
+ }
+
+ .tool-confirm-group {
+ margin-left: 0;
+ }
+}