Skip to content

Commit 45a6880

Browse files
authored
Merge pull request #490 from phodal/feat/nano-action-dispatch
feat(nano): Implement cross-platform action dispatch system
2 parents 5c42f21 + 96a5207 commit 45a6880

File tree

25 files changed

+2300
-53
lines changed

25 files changed

+2300
-53
lines changed

mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/CodingAgentRenderer.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,22 @@ interface CodingAgentRenderer {
5050

5151
fun updateTokenInfo(tokenInfo: TokenInfo) {}
5252

53+
/**
54+
* Handle task-boundary tool call to update task progress display.
55+
* Called when the agent uses the task-boundary tool to mark task status.
56+
*
57+
* This is an optional method primarily used by UI renderers that display
58+
* task progress visually. Console and server renderers typically don't need
59+
* to implement this.
60+
*
61+
* @param taskName The name of the task
62+
* @param status The task status (e.g., "WORKING", "DONE", "FAILED")
63+
* @param summary Optional summary of the task progress
64+
*/
65+
fun handleTaskBoundary(taskName: String, status: String, summary: String = "") {
66+
// Default: no-op for renderers that don't display task progress
67+
}
68+
5369
/**
5470
* Render a compact plan summary bar.
5571
* Called when plan is created or updated to show progress in a compact format.

mpp-idea/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,8 @@ project(":") {
344344
exclude(group = "cc.unitmesh.viewer.web", module = "mpp-viewer-web")
345345
exclude(group = "cc.unitmesh", module = "mpp-viewer-web")
346346
}
347+
348+
testImplementation(kotlin("test"))
347349
}
348350

349351
tasks {

mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import cc.unitmesh.agent.render.BaseRenderer
66
import cc.unitmesh.devins.llm.MessageRole
77
import cc.unitmesh.agent.render.RendererUtils
88

9+
import cc.unitmesh.agent.render.TaskInfo
10+
import cc.unitmesh.agent.render.TaskStatus
911
import cc.unitmesh.agent.render.TimelineItem
1012
import cc.unitmesh.agent.render.ToolCallDisplayInfo
1113
import cc.unitmesh.agent.render.ToolCallInfo
@@ -82,6 +84,10 @@ class JewelRenderer : BaseRenderer() {
8284
private val _currentPlan = MutableStateFlow<AgentPlan?>(null)
8385
val currentPlan: StateFlow<AgentPlan?> = _currentPlan.asStateFlow()
8486

87+
// Task tracking (from task-boundary tool)
88+
private val _tasks = MutableStateFlow<List<TaskInfo>>(emptyList())
89+
val tasks: StateFlow<List<TaskInfo>> = _tasks.asStateFlow()
90+
8591
/**
8692
* Set the current plan directly.
8793
* Used to sync with PlanStateService from CodingAgent.
@@ -152,6 +158,11 @@ class JewelRenderer : BaseRenderer() {
152158

153159
jewelRendererLogger.info("renderToolCall: parsed params keys=${params.keys}")
154160

161+
// Handle task-boundary tool - update task list
162+
if (toolName == "task-boundary") {
163+
updateTaskFromToolCall(params)
164+
}
165+
155166
// Handle plan management tool - update plan state
156167
if (toolName == "plan") {
157168
jewelRendererLogger.info("renderToolCall: detected plan tool, calling updatePlanFromToolCall")
@@ -178,6 +189,11 @@ class JewelRenderer : BaseRenderer() {
178189
// Convert Map<String, Any> to Map<String, String> for internal use
179190
val stringParams = params.mapValues { it.value.toString() }
180191

192+
// Handle task-boundary tool - update task list
193+
if (toolName == "task-boundary") {
194+
updateTaskFromToolCall(stringParams)
195+
}
196+
181197
// Handle plan management tool - update plan state with original params
182198
if (toolName == "plan") {
183199
updatePlanFromToolCallWithAnyParams(params)
@@ -322,6 +338,49 @@ class JewelRenderer : BaseRenderer() {
322338
}
323339
}
324340

341+
/**
342+
* Update task list from task-boundary tool call
343+
*/
344+
private fun updateTaskFromToolCall(params: Map<String, String>) {
345+
val taskName = params["taskName"] ?: return
346+
val statusStr = params["status"] ?: "WORKING"
347+
val summary = params["summary"] ?: ""
348+
val status = TaskStatus.fromString(statusStr)
349+
350+
_tasks.update { currentTasks ->
351+
val existingIndex = currentTasks.indexOfFirst { it.taskName == taskName }
352+
if (existingIndex >= 0) {
353+
// Update existing task
354+
currentTasks.toMutableList().apply {
355+
this[existingIndex] = currentTasks[existingIndex].copy(
356+
status = status,
357+
summary = summary,
358+
timestamp = System.currentTimeMillis()
359+
)
360+
}
361+
} else {
362+
// Add new task
363+
currentTasks + TaskInfo(
364+
taskName = taskName,
365+
status = status,
366+
summary = summary
367+
)
368+
}
369+
}
370+
}
371+
372+
/**
373+
* Handle task-boundary tool call to update task progress display.
374+
* Overrides the interface method to provide UI-specific task tracking.
375+
*/
376+
override fun handleTaskBoundary(taskName: String, status: String, summary: String) {
377+
updateTaskFromToolCall(mapOf(
378+
"taskName" to taskName,
379+
"status" to status,
380+
"summary" to summary
381+
))
382+
}
383+
325384
override fun renderToolResult(
326385
toolName: String,
327386
success: Boolean,

mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/JewelRendererTest.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package cc.unitmesh.devins.idea.renderer
22

33
import cc.unitmesh.devins.llm.MessageRole
4-
import cc.unitmesh.agent.render.TaskStatus
54
import cc.unitmesh.agent.render.TimelineItem
65
import cc.unitmesh.llm.compression.TokenInfo
76
import kotlinx.coroutines.flow.first

mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,18 @@ class ComposeRenderer : BaseRenderer() {
280280
}
281281
}
282282

283+
/**
284+
* Handle task-boundary tool call to update task progress display.
285+
* Overrides the interface method to provide UI-specific task tracking.
286+
*/
287+
override fun handleTaskBoundary(taskName: String, status: String, summary: String) {
288+
updateTaskFromToolCall(mapOf(
289+
"taskName" to taskName,
290+
"status" to status,
291+
"summary" to summary
292+
))
293+
}
294+
283295
/**
284296
* Update plan state from plan management tool call (string params version)
285297
*/
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package cc.unitmesh.devins.ui.nano
2+
3+
import cc.unitmesh.xuiper.action.*
4+
import kotlinx.coroutines.CoroutineScope
5+
import kotlinx.coroutines.Dispatchers
6+
import kotlinx.coroutines.launch
7+
import java.awt.Desktop
8+
import java.net.URI
9+
import java.net.http.HttpClient
10+
import java.net.http.HttpRequest
11+
import java.net.http.HttpResponse
12+
import javax.swing.JOptionPane
13+
14+
/**
15+
* Compose/Desktop implementation of NanoActionHandler
16+
*
17+
* Handles NanoUI actions in a Compose Desktop environment.
18+
* Provides platform-specific implementations for navigation, toast, and fetch.
19+
*
20+
* Example:
21+
* ```kotlin
22+
* val handler = ComposeActionHandler(
23+
* scope = rememberCoroutineScope(),
24+
* onNavigate = { route -> navController.navigate(route) },
25+
* onToast = { message -> snackbarHostState.showSnackbar(message) }
26+
* )
27+
*
28+
* handler.registerCustomAction("AddTask") { payload, context ->
29+
* val title = payload["title"] as? String ?: ""
30+
* taskRepository.add(Task(title))
31+
* ActionResult.Success
32+
* }
33+
* ```
34+
*/
35+
class ComposeActionHandler(
36+
private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO),
37+
private val onNavigate: ((String) -> Unit)? = null,
38+
private val onToast: ((String) -> Unit)? = null,
39+
private val onFetchComplete: ((String, Boolean, String?) -> Unit)? = null
40+
) : BaseNanoActionHandler() {
41+
42+
private val httpClient = HttpClient.newBuilder().build()
43+
44+
override fun handleNavigate(
45+
navigate: NanoAction.Navigate,
46+
context: NanoActionContext
47+
): ActionResult {
48+
return try {
49+
if (onNavigate != null) {
50+
onNavigate.invoke(navigate.to)
51+
} else {
52+
// Default: open in browser if it's a URL
53+
if (navigate.to.startsWith("http://") || navigate.to.startsWith("https://")) {
54+
Desktop.getDesktop().browse(URI(navigate.to))
55+
}
56+
}
57+
ActionResult.Success
58+
} catch (e: Exception) {
59+
ActionResult.Error("Navigation failed: ${e.message}", e)
60+
}
61+
}
62+
63+
override fun handleFetch(
64+
fetch: NanoAction.Fetch,
65+
context: NanoActionContext
66+
): ActionResult {
67+
// Set loading state if specified
68+
fetch.loadingState?.let { path ->
69+
context.set(path, true)
70+
}
71+
72+
scope.launch {
73+
try {
74+
val requestBuilder = HttpRequest.newBuilder()
75+
.uri(URI(fetch.url))
76+
77+
// Set method
78+
when (fetch.method) {
79+
HttpMethod.GET -> requestBuilder.GET()
80+
HttpMethod.POST -> {
81+
val body = buildRequestBody(fetch.body, context)
82+
requestBuilder.POST(HttpRequest.BodyPublishers.ofString(body))
83+
requestBuilder.header("Content-Type", fetch.contentType.mimeType)
84+
}
85+
HttpMethod.PUT -> {
86+
val body = buildRequestBody(fetch.body, context)
87+
requestBuilder.PUT(HttpRequest.BodyPublishers.ofString(body))
88+
requestBuilder.header("Content-Type", fetch.contentType.mimeType)
89+
}
90+
HttpMethod.DELETE -> requestBuilder.DELETE()
91+
else -> requestBuilder.GET()
92+
}
93+
94+
// Add headers
95+
fetch.headers?.forEach { (key, value) ->
96+
requestBuilder.header(key, value)
97+
}
98+
99+
val response = httpClient.send(
100+
requestBuilder.build(),
101+
HttpResponse.BodyHandlers.ofString()
102+
)
103+
104+
// Update loading state
105+
fetch.loadingState?.let { path ->
106+
context.set(path, false)
107+
}
108+
109+
if (response.statusCode() in 200..299) {
110+
// Success
111+
fetch.responseBinding?.let { path ->
112+
context.set(path, response.body())
113+
}
114+
115+
fetch.onSuccess?.let { successAction ->
116+
handleAction(successAction, context)
117+
}
118+
119+
onFetchComplete?.invoke(fetch.url, true, response.body())
120+
} else {
121+
// Error
122+
val errorMsg = "HTTP ${response.statusCode()}: ${response.body()}"
123+
fetch.errorBinding?.let { path ->
124+
context.set(path, errorMsg)
125+
}
126+
127+
fetch.onError?.let { errorAction ->
128+
handleAction(errorAction, context)
129+
}
130+
131+
onFetchComplete?.invoke(fetch.url, false, errorMsg)
132+
}
133+
134+
} catch (e: Exception) {
135+
fetch.loadingState?.let { path ->
136+
context.set(path, false)
137+
}
138+
fetch.errorBinding?.let { path ->
139+
context.set(path, e.message)
140+
}
141+
fetch.onError?.let { errorAction ->
142+
handleAction(errorAction, context)
143+
}
144+
onFetchComplete?.invoke(fetch.url, false, e.message)
145+
}
146+
}
147+
148+
return ActionResult.Pending { /* async operation */ }
149+
}
150+
151+
override fun handleShowToast(
152+
toast: NanoAction.ShowToast,
153+
context: NanoActionContext
154+
): ActionResult {
155+
return try {
156+
if (onToast != null) {
157+
onToast.invoke(toast.message)
158+
} else {
159+
// Default: use Swing dialog (for desktop)
160+
javax.swing.SwingUtilities.invokeLater {
161+
JOptionPane.showMessageDialog(null, toast.message)
162+
}
163+
}
164+
ActionResult.Success
165+
} catch (e: Exception) {
166+
ActionResult.Error("Toast failed: ${e.message}", e)
167+
}
168+
}
169+
170+
private fun buildRequestBody(
171+
body: Map<String, BodyField>?,
172+
context: NanoActionContext
173+
): String {
174+
if (body == null) return ""
175+
176+
val resolvedBody = body.mapValues { (_, field) ->
177+
when (field) {
178+
is BodyField.Literal -> field.value
179+
is BodyField.StateBinding -> context.get(field.path)?.toString() ?: ""
180+
}
181+
}
182+
183+
// Simple JSON serialization with proper escaping
184+
return buildString {
185+
append("{")
186+
resolvedBody.entries.forEachIndexed { index, (key, value) ->
187+
if (index > 0) append(",")
188+
append("\"$key\":")
189+
when (value) {
190+
is String -> append("\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
191+
is Number, is Boolean -> append(value)
192+
null -> append("null")
193+
else -> append("\"${value.toString().replace("\\", "\\\\").replace("\"", "\\\"")}\"")
194+
}
195+
}
196+
append("}")
197+
}
198+
}
199+
}
200+

mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/nano/NanoDSLDemo.kt

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ fun NanoDSLDemo(
256256
.verticalScroll(rememberScrollState())
257257
) {
258258
parsedIR?.let { ir ->
259-
ComposeNanoRenderer.Render(ir)
259+
StatefulNanoRenderer.Render(ir)
260260
} ?: run {
261261
Box(
262262
modifier = Modifier.fillMaxSize(),
@@ -305,13 +305,18 @@ component ShoppingItem:
305305

306306
private val COUNTER_DSL = """
307307
component Counter:
308+
state:
309+
count: int = 0
310+
308311
Card(padding="lg", shadow="md"):
309312
VStack(spacing="md"):
310313
Text("Counter Example", style="h2")
311314
HStack(spacing="md", align="center", justify="center"):
312-
Button("-", intent="secondary")
313-
Text("0", style="h1")
314-
Button("+", intent="primary")
315+
Button("-", intent="secondary"):
316+
on_click: state.count -= 1
317+
Text(content << state.count, style="h1")
318+
Button("+", intent="primary"):
319+
on_click: state.count += 1
315320
Divider
316321
Text("Click buttons to change value", style="caption")
317322
""".trimIndent()

0 commit comments

Comments
 (0)