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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 94 additions & 16 deletions desktop-shared/src/main/kotlin/io/askimo/ui/plan/PlanDetailView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.semantics.Role
Expand All @@ -71,6 +74,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.askimo.core.i18n.LocalizationManager
import io.askimo.core.plan.PlanStepEvent
import io.askimo.core.plan.domain.PlanInput
import io.askimo.core.util.TimeUtil
Expand Down Expand Up @@ -639,6 +643,7 @@ private fun agenticStepProgressPanel(
}
}

@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun agenticStepRow(
event: PlanStepEvent,
Expand All @@ -647,6 +652,7 @@ private fun agenticStepRow(
val clipboardManager = LocalClipboardManager.current
val coroutineScope = rememberCoroutineScope()
var showCopyFeedback by remember { mutableStateOf(false) }
var isHovered by remember { mutableStateOf(false) }

var outputExpanded by remember { mutableStateOf(true) }

Expand All @@ -669,7 +675,11 @@ private fun agenticStepRow(
null
}

Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)
.onPointerEvent(PointerEventType.Enter) { isHovered = true }
.onPointerEvent(PointerEventType.Exit) { isHovered = false },
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Spacing.medium),
Expand Down Expand Up @@ -725,22 +735,38 @@ private fun agenticStepRow(
)
when (event) {
is PlanStepEvent.Completed -> {
Surface(
shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.secondaryContainer,
Row(
horizontalArrangement = Arrangement.spacedBy(Spacing.extraSmall),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = TimeUtil.formatDurationMs(
durationMs = event.durationMs,
hourLabel = stringResource("plans.steps.duration.hour"),
minuteLabel = stringResource("plans.steps.duration.minute"),
secondLabel = stringResource("plans.steps.duration.second"),
lessThanOne = stringResource("plans.steps.duration.less.than.one.second"),
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
)
Surface(
shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Text(
text = TimeUtil.formatDurationMs(
durationMs = event.durationMs,
hourLabel = stringResource("plans.steps.duration.hour"),
minuteLabel = stringResource("plans.steps.duration.minute"),
secondLabel = stringResource("plans.steps.duration.second"),
lessThanOne = stringResource("plans.steps.duration.less.than.one.second"),
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
)
}
stepTokenUsageLabel(event)?.let { usage ->
if (isHovered) {
themedTooltip(text = stringResource("message.token.usage.tooltip")) {
Text(
text = usage,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
}
}
}
}

Expand Down Expand Up @@ -877,6 +903,43 @@ private fun agenticStepRow(
}
}

private fun stepTokenUsageLabel(event: PlanStepEvent.Completed): String? {
val total = event.totalTokens
val input = event.inputTokens
val output = event.outputTokens
if (total == null || total <= 0) return null

return if (input != null && output != null) {
"${LocalizationManager.formatNumber(total)} tokens (${LocalizationManager.formatNumber(input)} in / ${LocalizationManager.formatNumber(output)} out)"
} else {
"${LocalizationManager.formatNumber(total)} tokens"
}
}

@Composable
private fun planTotalSummaryLabel(viewModel: PlansViewModel): String? {
val total = viewModel.planTotalTokens
val input = viewModel.planTotalInputTokens
val output = viewModel.planTotalOutputTokens
val durationMs = viewModel.planTotalDurationMs
if (total == null || total <= 0) return null

return buildString {
if (input != null && output != null) {
append("${LocalizationManager.formatNumber(total)} tokens (${LocalizationManager.formatNumber(input)} in / ${LocalizationManager.formatNumber(output)} out)")
} else {
append("${LocalizationManager.formatNumber(total)} tokens")
}
if (durationMs != null && durationMs > 0) {
val durationLabel = when {
durationMs < 1_000 -> "${durationMs}ms"
else -> "${"%.1f".format(durationMs / 1000.0)}s"
}
append(" · $durationLabel")
}
}
}

/**
* Inline panel rendered while the plan executor is paused waiting for the user to answer
* an interactive [io.askimo.core.plan.domain.WorkflowNode.Ask] step.
Expand Down Expand Up @@ -1033,9 +1096,11 @@ private fun resultPanel(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
// Title + plan totals on hover
Row(
horizontalArrangement = Arrangement.spacedBy(Spacing.small),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f),
) {
Icon(
if (isPinned) Icons.Default.PushPin else Icons.Default.CheckCircle,
Expand All @@ -1049,7 +1114,20 @@ private fun resultPanel(
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
// Plan-level totals — only for the live (non-pinned) result panel
if (!isPinned) {
planTotalSummaryLabel(viewModel)?.let { summary ->
themedTooltip(text = stringResource("message.token.usage.tooltip")) {
Text(
text = summary,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
}
}
}

Row(
horizontalArrangement = Arrangement.spacedBy(Spacing.extraSmall),
verticalAlignment = Alignment.CenterVertically,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import io.askimo.core.i18n.LocalizationManager
import io.askimo.core.plan.domain.PlanExecution
import io.askimo.core.plan.domain.PlanExecutionStatus
import io.askimo.core.util.TimeUtil
Expand Down Expand Up @@ -282,7 +283,22 @@ private fun planHistoryItem(
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()

themedTooltip(text = stringResource("plans.history.restore")) {
val tooltipText = buildString {
append(stringResource("plans.history.restore"))
val parts = mutableListOf<String>()
execution.totalTokens?.let { parts += "Tokens: ${LocalizationManager.formatNumber(it)}" }
execution.totalInputTokens?.let { parts += "In: ${LocalizationManager.formatNumber(it)}" }
execution.totalOutputTokens?.let { parts += "Out: ${LocalizationManager.formatNumber(it)}" }
execution.totalDurationMs?.let { ms ->
parts += if (ms >= 1_000) "Time: ${"%.1f".format(ms / 1000.0)}s" else "Time: ${ms}ms"
}
if (parts.isNotEmpty()) {
append("\n")
append(parts.joinToString(" · "))
}
}

themedTooltip(text = tooltipText) {
Surface(
modifier = modifier
.hoverable(interactionSource)
Expand Down
42 changes: 37 additions & 5 deletions desktop-shared/src/main/kotlin/io/askimo/ui/plan/PlansViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@ class PlansViewModel(
var runError by mutableStateOf<String?>(null)
private set

/** Plan-level token and duration summary for the current (or restored) run. */
var planTotalInputTokens by mutableStateOf<Int?>(null)
private set
var planTotalOutputTokens by mutableStateOf<Int?>(null)
private set
var planTotalTokens by mutableStateOf<Int?>(null)
private set
var planTotalDurationMs by mutableStateOf<Long?>(null)
private set

var exportError by mutableStateOf<String?>(null)
private set

Expand Down Expand Up @@ -359,7 +369,16 @@ class PlansViewModel(
}.fold(
onSuccess = { result ->
result.fold(
onSuccess = { runResult = it },
onSuccess = { planResult ->
runResult = planResult
val saved = withContext(Dispatchers.IO) {
planService.getExecutions(plan.id).firstOrNull { it.id == planResult.executionId }
}
planTotalInputTokens = saved?.totalInputTokens
planTotalOutputTokens = saved?.totalOutputTokens
planTotalTokens = saved?.totalTokens
planTotalDurationMs = saved?.totalDurationMs
},
onFailure = { runError = it.message ?: "Plan failed" },
)
},
Expand Down Expand Up @@ -392,6 +411,10 @@ class PlansViewModel(
followUpError = null
pendingQuestion = null
pendingAnswerText = ""
planTotalInputTokens = null
planTotalOutputTokens = null
planTotalTokens = null
planTotalDurationMs = null
selectedPlan?.id?.let { planStateCache.remove(it) }
}

Expand Down Expand Up @@ -537,13 +560,16 @@ class PlansViewModel(

// Reconstruct step progress from persisted step outputs so the UI shows
// the breakdown from the previous run when the user clicks a history entry.
stepProgress = execution.stepOutputs.map { (stepName, output) ->
stepProgress = execution.stepOutputs.map { step ->
PlanStepEvent.Completed(
planId = execution.planId,
stepName = stepName,
stepName = step.stepName,
executionId = execution.id,
output = output,
durationMs = 0L,
output = step.output,
inputTokens = step.inputTokens,
outputTokens = step.outputTokens,
totalTokens = step.totalTokens,
durationMs = step.durationMs ?: 0L,
)
}

Expand All @@ -564,6 +590,12 @@ class PlansViewModel(
runError = null
}
}

// Restore plan-level totals from the persisted execution record
planTotalInputTokens = execution.totalInputTokens
planTotalOutputTokens = execution.totalOutputTokens
planTotalTokens = execution.totalTokens
planTotalDurationMs = execution.totalDurationMs
}

/** Opens the editor to create a brand-new plan with a starter template. */
Expand Down
22 changes: 22 additions & 0 deletions shared/src/main/kotlin/io/askimo/core/db/DatabaseManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,28 @@ class DatabaseManager private constructor(
} catch (_: Exception) {
// Column already exists — safe to ignore.
}

// Migration: add token usage columns for plan executions.
try {
stmt.executeUpdate("ALTER TABLE plan_executions ADD COLUMN total_input_tokens INTEGER")
} catch (_: Exception) {
// Column already exists — safe to ignore.
}
try {
stmt.executeUpdate("ALTER TABLE plan_executions ADD COLUMN total_output_tokens INTEGER")
} catch (_: Exception) {
// Column already exists — safe to ignore.
}
try {
stmt.executeUpdate("ALTER TABLE plan_executions ADD COLUMN total_tokens INTEGER")
} catch (_: Exception) {
// Column already exists — safe to ignore.
}
try {
stmt.executeUpdate("ALTER TABLE plan_executions ADD COLUMN total_duration_ms INTEGER")
} catch (_: Exception) {
// Column already exists — safe to ignore.
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import dev.langchain4j.agentic.observability.AgentRequest
import dev.langchain4j.agentic.observability.AgentResponse
import io.askimo.core.event.EventBus
import io.askimo.core.logging.logger
import io.askimo.core.plan.domain.PlanStepOutput
import kotlinx.coroutines.runBlocking
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
Expand Down Expand Up @@ -38,10 +39,9 @@ internal class PlanExecutionListener(

/**
* Outputs of completed steps, in completion order.
* Key = stepName (agent name / outputKey), value = output string.
* Used by [PlanExecutor] to derive the final plan output.
*/
val stepOutputs = CopyOnWriteArrayList<Pair<String, String>>()
val stepOutputs = CopyOnWriteArrayList<PlanStepOutput>()

override fun inheritedBySubagents(): Boolean = true

Expand Down Expand Up @@ -70,11 +70,38 @@ internal class PlanExecutionListener(
val durationMs = elapsed(startTimes.remove(key(agentResponse.agentId(), stepName)))
val output = agentResponse.output()
val outputStr = output?.toString() ?: ""
log.debug("[{}] ✔ step '{}' done in {}ms | output: {}", planId, stepName, durationMs, truncate(outputStr))

val tokenUsage = agentResponse.chatResponse()
?.metadata()
?.tokenUsage()

val inputTokens = tokenUsage?.inputTokenCount()
val outputTokens = tokenUsage?.outputTokenCount()
val totalTokens = tokenUsage?.totalTokenCount()

log.debug(
"[{}] ✔ step '{}' done in {}ms | tokens(total/in/out)={}/{}/{} | output: {}",
planId,
stepName,
durationMs,
totalTokens ?: "-",
inputTokens ?: "-",
outputTokens ?: "-",
truncate(outputStr),
)

// Record non-blank outputs so the executor can find the final result.
if (outputStr.isNotBlank()) {
stepOutputs.add(stepName to outputStr)
stepOutputs.add(
PlanStepOutput(
stepName = stepName,
output = outputStr,
inputTokens = inputTokens,
outputTokens = outputTokens,
totalTokens = totalTokens,
durationMs = durationMs,
),
)
}

post(
Expand All @@ -83,6 +110,9 @@ internal class PlanExecutionListener(
stepName = stepName,
executionId = executionId,
output = output,
inputTokens = inputTokens,
outputTokens = outputTokens,
totalTokens = totalTokens,
durationMs = durationMs,
),
)
Expand Down
Loading
Loading