Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- `cds.agents.retention` (default 30d) to configure retention of Tasks and related assets stored for A2A and the agent
- Outgoing MCP and A2A connections now consider `credentials.path` together with the destination
- Added additional OpenTelemetry span attributes detailing how many content filters were active
- Thinking steps are shown in the preview

### Fixed

Expand Down
203 changes: 142 additions & 61 deletions lib/preview/chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,26 +41,6 @@
header .back:hover {
color: #fff;
}
.header-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
color: #c8def0;
cursor: pointer;
user-select: none;
flex-shrink: 0;
}
.header-toggle[hidden] {
display: none;
}
.header-toggle input[type="checkbox"] {
accent-color: #7fbce8;
width: 14px;
height: 14px;
cursor: pointer;
}

header .status-dot {
width: 8px;
height: 8px;
Expand Down Expand Up @@ -397,22 +377,57 @@
background: #0f2d45;
}
}

/* Thinking summary: per-turn reasoning text superseded by the final
answer. Rendered as a collapsible <details> strip above the current
streaming bubble. Auto-collapses at task completion. */
.thinking-summary {
margin: 4px 0 8px 0;
font-size: 0.88em;
color: #666;
border-left: 2px solid #ccc;
padding-left: 10px;
}
.thinking-summary summary {
cursor: pointer;
user-select: none;
color: #888;
list-style: none;
}
.thinking-summary summary::before {
content: "▶ ";
font-size: 0.8em;
}
.thinking-summary[open] summary::before {
content: "▼ ";
}
.thinking-step {
padding: 4px 0;
font-style: italic;
}
.thinking-step + .thinking-step {
border-top: 1px dashed #e0e0e0;
margin-top: 4px;
}
@media (prefers-color-scheme: dark) {
.thinking-summary {
color: #999;
border-left-color: #444;
}
.thinking-summary summary {
color: #aaa;
}
.thinking-step + .thinking-step {
border-top-color: #333;
}
}
</style>
<script src="marked.min.js"></script>
</head>
<body>
<header>
<a class="back" id="back" title="Back to index">&#8592;</a>
<h1>{{agentName}}</h1>
<label
class="header-toggle"
title="Stream tokens as they arrive"
style="margin-left: auto"
hidden
>
<input type="checkbox" id="streamToggle" hidden />
Streaming
</label>
<div class="status-dot" id="dot"></div>
</header>

Expand Down Expand Up @@ -459,16 +474,27 @@ <h1>{{agentName}}</h1>
const prompt = document.getElementById("prompt")
const sendBtn = document.getElementById("send")
const dot = document.getElementById("dot")
const streamToggle = document.getElementById("streamToggle")

let useStreaming = false

let busy = false
let contextId = null
let pendingTaskId = null
let activeTaskId = null
let activeAbort = null
// Live streaming bubble: created on first token, updated incrementally
let streamingBubble = null
let streamingText = ""
let thinkingSummaryEl = null
let thinkingBubble = null
let thinkingStepCount = 0

// Auto-enable streaming when agent card advertises streaming capability
fetch(AGENT_URL + ".well-known/agent-card.json", { headers: authHeaders() })
.then((r) => (r.ok ? r.json() : null))
.then((card) => {
if (card?.capabilities?.streaming) useStreaming = true
})
.catch(() => {})

const HISTORY_KEY = "chatHistory_" + AGENT_URL
let inputHistory = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]")
Expand Down Expand Up @@ -523,11 +549,50 @@ <h1>{{agentName}}</h1>
el.className = "msg " + role
if (role === "agent") el.innerHTML = md(text, { sanitize: true })
else el.textContent = text
messages.insertBefore(el, typing)
messages.insertBefore(
el,
thinkingBubble ?? (typing.parentElement === messages ? typing : statusText),
)
messages.scrollTop = messages.scrollHeight
return el
}

function addThinkingStep(text) {
if (!thinkingSummaryEl) {
thinkingSummaryEl = document.createElement("details")
thinkingSummaryEl.className = "thinking-summary"
thinkingSummaryEl.open = true
const summary = document.createElement("summary")
summary.textContent = "Thinking (0 steps)"
thinkingSummaryEl.appendChild(summary)
// Initially inside a temporary bubble that also holds the typing indicator
thinkingBubble = document.createElement("div")
thinkingBubble.className = "msg agent"
thinkingBubble.appendChild(thinkingSummaryEl)
thinkingBubble.appendChild(typing)
messages.insertBefore(thinkingBubble, statusText)
}
thinkingStepCount++
const step = document.createElement("div")
step.className = "thinking-step"
step.innerHTML = md(text)
thinkingSummaryEl.appendChild(step)
thinkingSummaryEl.querySelector("summary").textContent =
`Thinking (${thinkingStepCount} step${thinkingStepCount === 1 ? "" : "s"})`
messages.scrollTop = messages.scrollHeight
}

function promoteThinkingPanel(beforeEl) {
if (!thinkingSummaryEl) return
// Move typing indicator back to messages before dismantling the bubble
messages.insertBefore(typing, statusText)
if (thinkingBubble) {
thinkingBubble.remove()
thinkingBubble = null
}
messages.insertBefore(thinkingSummaryEl, beforeEl)
}

function addApprovalPrompt(description) {
const el = document.createElement("div")
el.className = "msg approval"
Expand All @@ -554,7 +619,10 @@ <h1>{{agentName}}</h1>
actions.appendChild(approveBtn)
actions.appendChild(rejectBtn)
el.appendChild(actions)
messages.insertBefore(el, typing)
messages.insertBefore(
el,
thinkingBubble ?? (typing.parentElement === messages ? typing : statusText),
)
messages.scrollTop = messages.scrollHeight
return el
}
Expand All @@ -566,6 +634,7 @@ <h1>{{agentName}}</h1>
function setBusy(val) {
busy = val
prompt.disabled = val
if (typing.parentElement !== messages) messages.insertBefore(typing, statusText)
typing.style.display = val ? "block" : "none"
dot.className = "status-dot" + (val ? " busy" : "")
if (val) {
Expand All @@ -579,9 +648,6 @@ <h1>{{agentName}}</h1>
activeTaskId = null
activeAbort = null
statusText.textContent = ""
// Reset streaming state on any transition to idle
streamingBubble = null
streamingText = ""
}
messages.scrollTop = messages.scrollHeight
}
Expand All @@ -595,8 +661,6 @@ <h1>{{agentName}}</h1>

const abort = new AbortController()
activeAbort = abort
streamingBubble = null
streamingText = ""

try {
const resp = await fetch(AGENT_URL, {
Expand All @@ -605,7 +669,7 @@ <h1>{{agentName}}</h1>
signal: abort.signal,
body: JSON.stringify({
jsonrpc: "2.0",
method: streamToggle.checked ? "message/stream" : "message/send",
method: useStreaming ? "message/stream" : "message/send",
id: crypto.randomUUID(),
params: {
message: {
Expand All @@ -619,7 +683,7 @@ <h1>{{agentName}}</h1>
}),
})

if (streamToggle.checked) {
if (useStreaming) {
await handleSSEResponse(resp)
} else {
const envelope = await resp.json()
Expand Down Expand Up @@ -652,8 +716,8 @@ <h1>{{agentName}}</h1>
return
}

// If streaming was active and already rendered the final text via lastChunk, skip.
if (streamToggle.checked && streamingText !== "") {
// If streaming already rendered the final text via lastChunk, skip.
if (useStreaming && streamingText !== "") {
streamingText = ""
return
}
Expand Down Expand Up @@ -694,8 +758,11 @@ <h1>{{agentName}}</h1>
activeAbort = abort
streamingBubble = null
streamingText = ""
thinkingSummaryEl = null
thinkingBubble = null
thinkingStepCount = 0

const useStream = streamToggle.checked
const useStream = useStreaming
try {
const resp = await fetch(AGENT_URL, {
method: "POST",
Expand Down Expand Up @@ -804,44 +871,58 @@ <h1>{{agentName}}</h1>
return null
}

// Incremental artifact-update: render tokens as they arrive
if (event.kind === "artifact-update" && streamToggle.checked) {
if (event.kind === "artifact-update") {
const parts = event.artifact?.parts ?? []
const text = partsToText(parts)

if (event.artifact?.artifactId?.startsWith("thinking")) {
if (text) addThinkingStep(text)
return null
}

if (event.artifact?.artifactId !== "response") return null

// A2A spec: append/lastChunk are event-level fields (siblings of `artifact`).
const append = event.append ?? false
const lastChunk = event.lastChunk ?? false

if (lastChunk) {
// lastChunk carries the authoritative full text — replace accumulated content.
// Terminal: current bubble = final answer. Replace with authoritative text.
if (text) {
if (streamingBubble) {
streamingBubble.innerHTML = md(text)
messages.scrollTop = messages.scrollHeight
} else {
typing.style.display = "none"
addMessage("agent", text)
streamingBubble = addMessage("agent", text)
promoteThinkingPanel(streamingBubble)
}
}
if (thinkingSummaryEl) thinkingSummaryEl.open = false
streamingBubble = null
streamingText = text // keep so handleResult skips duplicate render
streamingText = text // dedupe with handleResult
return null
}

if (text) {
if (!append || !streamingBubble) {
// First token — create a live bubble, hide typing indicator
typing.style.display = "none"
streamingBubble = addMessage("agent", text)
streamingText = text
} else {
// Subsequent token — accumulate and re-render markdown
streamingText += text
streamingBubble.innerHTML = md(streamingText)
messages.scrollTop = messages.scrollHeight
}
if (!text) return null

if (!append) {
typing.style.display = "none"
streamingBubble = addMessage("agent", text)
promoteThinkingPanel(streamingBubble)
streamingText = text
} else if (streamingBubble) {
// Subsequent token — accumulate into the current turn's bubble.
streamingText += text
streamingBubble.innerHTML = md(streamingText)
} else {
// Defensive: append:true with no bubble yet (unexpected). Open one.
typing.style.display = "none"
streamingBubble = addMessage("agent", text)
promoteThinkingPanel(streamingBubble)
streamingText = text
}

messages.scrollTop = messages.scrollHeight
return null
}

Expand Down
Loading