diff --git a/agentscope-examples/advanced/src/main/java/io/agentscope/examples/advanced/hitl/AddCalendarEventTool.java b/agentscope-examples/advanced/src/main/java/io/agentscope/examples/advanced/hitl/AddCalendarEventTool.java new file mode 100644 index 000000000..2654b0ee0 --- /dev/null +++ b/agentscope-examples/advanced/src/main/java/io/agentscope/examples/advanced/hitl/AddCalendarEventTool.java @@ -0,0 +1,71 @@ +/* + * 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; + +/** + * Mock tool that simulates adding an event to the user's calendar. + * + *

This tool is configured as a "dangerous" tool that requires user approval + * before execution via the {@link ToolConfirmationHook}. When the agent decides + * to call this tool, the hook intercepts it, stops the agent, and the frontend + * renders approve/reject buttons for the user. + */ +public class AddCalendarEventTool { + + public static final String TOOL_NAME = "add_calendar_event"; + + @Tool( + name = TOOL_NAME, + description = + "Add a workout event to the user's calendar. Call this tool for EACH day's" + + " workout separately. For example, if the plan has workouts on Monday," + + " Tuesday, and Wednesday, call this tool 3 times with each day's" + + " details.") + public String addCalendarEvent( + @ToolParam(name = "title", description = "Event title, e.g. 'Chest + Triceps Workout'") + String title, + @ToolParam( + name = "date", + description = "Event date in YYYY-MM-DD format, e.g. '2026-03-02'") + String date, + @ToolParam( + name = "time", + description = + "Start time in HH:mm format, e.g. '08:00'. Defaults to" + + " '09:00'.", + required = false) + String time, + @ToolParam( + name = "duration_minutes", + description = "Duration in minutes, e.g. 60", + required = false) + Integer durationMinutes, + @ToolParam( + name = "description", + description = "Detailed workout content for this session", + required = false) + String description) { + String startTime = (time != null && !time.isEmpty()) ? time : "09:00"; + int duration = (durationMinutes != null && durationMinutes > 0) ? durationMinutes : 60; + + return String.format( + "Successfully added calendar event: '%s' on %s at %s (%d min)", + title, date, startTime, duration); + } +} diff --git a/agentscope-examples/advanced/src/main/java/io/agentscope/examples/advanced/hitl/HitlInteractionExample.java b/agentscope-examples/advanced/src/main/java/io/agentscope/examples/advanced/hitl/HitlInteractionExample.java new file mode 100644 index 000000000..056271fb0 --- /dev/null +++ b/agentscope-examples/advanced/src/main/java/io/agentscope/examples/advanced/hitl/HitlInteractionExample.java @@ -0,0 +1,512 @@ +/* + * 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 com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.ReActAgent; +import io.agentscope.core.agent.Event; +import io.agentscope.core.agent.StreamOptions; +import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; +import io.agentscope.core.memory.InMemoryMemory; +import io.agentscope.core.message.GenerateReason; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.core.session.InMemorySession; +import io.agentscope.core.session.Session; +import io.agentscope.core.state.SimpleSessionKey; +import io.agentscope.core.tool.Toolkit; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Flux; + +/** + * HITL (Human-in-the-Loop) Interactive UI Example. + * + *

This example demonstrates how an AI agent can proactively ask users for structured input + * through dynamic UI components. When the agent encounters ambiguous or incomplete requests, + * it uses the {@code ask_user} tool to request clarification, and the frontend dynamically + * renders the appropriate UI component (text input, select buttons, forms, etc.). + * + *

Architecture

+ *
+ * User Input → Agent Reasoning → LLM decides info is missing
+ *     → Calls ask_user tool → ToolSuspendException → Agent pauses
+ *     → SSE sends USER_INTERACTION event → Frontend renders UI
+ *     → User responds → Agent resumes with response → Completes task
+ * 
+ * + *

Running

+ *
+ * export DASHSCOPE_API_KEY=your_api_key
+ * java -cp ... io.agentscope.examples.advanced.hitl.HitlInteractionExample
+ * 
+ * Then open http://localhost:8080/hitl-interaction/index.html + */ +@SpringBootApplication +@RestController +@RequestMapping("/api") +public class HitlInteractionExample { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final Set TOOLS_REQUIRING_CONFIRMATION = + Set.of(AddCalendarEventTool.TOOL_NAME); + + private static final String SYS_PROMPT = + """ + You are a professional fitness coach assistant that creates personalized + workout plans. + + RULES: + - NEVER ask questions in plain text. ALWAYS use the ask_user tool instead. + - Call ask_user ONLY ONCE per response. + - Respond in the same language as the user's input. + + Information to collect (one at a time, skip what the user already provided): + - Fitness goal: select — Fat Loss, Muscle Gain, General Fitness, Flexibility + - Body info (age, height, weight): form with number fields + - Available equipment: multi_select with allow_other=true — e.g. dumbbells, + barbells, treadmills, pull-up bars, resistance bands (tailor to user's goal) + - Workout days per week: number + - Injury / health concerns: confirm first, then text if yes + - Plan start date: date + + Workflow: + 1. Collect missing information one at a time via ask_user. + 2. Generate a detailed weekly plan with exercises, sets, reps, and rest times. + 3. Call add_calendar_event once per workout day to add it to the calendar. + """; + + private final Session session = new InMemorySession(); + + private final ConcurrentHashMap runningAgents = new ConcurrentHashMap<>(); + + private final Toolkit toolkit; + + private final DashScopeChatModel model; + + { + String apiKey = System.getenv("DASHSCOPE_API_KEY"); + + toolkit = new Toolkit(); + toolkit.registerTool(new UserInteractionTool()); + toolkit.registerTool(new AddCalendarEventTool()); + + model = + DashScopeChatModel.builder().apiKey(apiKey).modelName("qwen-max").stream(true) + .enableThinking(false) + .formatter(new DashScopeChatFormatter()) + .build(); + } + + public static void main(String[] args) { + String apiKey = System.getenv("DASHSCOPE_API_KEY"); + if (apiKey == null || apiKey.isEmpty()) { + System.err.println("Error: DASHSCOPE_API_KEY environment variable not set."); + System.err.println("Please set it with: export DASHSCOPE_API_KEY=your_api_key"); + System.exit(1); + } + + System.out.println("\n" + "=".repeat(70)); + System.out.println(" HITL Interactive UI Example"); + System.out.println(" Agent with dynamic UI-based user interaction"); + System.out.println("=".repeat(70)); + System.out.println(" Open: http://localhost:8080/hitl-interaction/index.html"); + System.out.println("=".repeat(70) + "\n"); + + SpringApplication.run(HitlInteractionExample.class, args); + } + + // ==================== Chat Endpoint ==================== + + /** + * Send a chat message and receive streaming response via SSE. + */ + @PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux>> chat( + @RequestBody Map request) { + String sessionId = request.getOrDefault("sessionId", "default"); + String message = request.get("message"); + if (message == null || message.isBlank()) { + return Flux.just( + ServerSentEvent.>builder() + .data(errorEvent("Missing required parameter: message")) + .build()); + } + + ReActAgent agent = createAgent(sessionId); + runningAgents.put(sessionId, agent); + + Msg userMsg = + Msg.builder() + .name("User") + .role(MsgRole.USER) + .content(TextBlock.builder().text(message).build()) + .build(); + + Flux> 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 = + '
' + + '🔧' + + 'Tool: ' + escapeHtml(toolName) + '' + + 'executing' + + '
' + + '
' + + '
' + escapeHtml(inputJson) + '
' + + '
'; + 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 = + '
' + + '' + + 'Result: ' + escapeHtml(toolName) + '' + + '
' + + '
' + + '
' + escapeHtml(result || '(empty)') + '
' + + '
'; + 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 = + '
' + + '' + (tool.needsConfirm ? '⚠️' : '🔧') + '' + + 'Tool: ' + escapeHtml(tool.name) + '' + + '' + t('pendingApproval') + '' + + '
' + + '
' + + '
' + escapeHtml(inputJson) + '
' + + '
'; + 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') + '' + + '
' + + '' + + '' + + '
'; + 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 + + + +
+
+
+
+

HITL Interactive UI

+

Agent with Dynamic UI-based User Interaction

+
+
+ +
+ + +
+
+
+
+ +
+
+
+
🤖
+

Welcome!

+

I'm your personal fitness coach. Tell me your goals, and I'll create a customized workout plan through interactive forms.

+
+ + + +
+
+
+ +
+
+ +
+ + +
+
+
+
+
+ + + + + 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; + } +}