shell: render typed chat segments
Keep assistant, reasoning, tool-call, and tool-return frames structured through the sidebar delegate. Tool cards bind returns by wire id and fold their output by default. Reasoned from the SSE contract and existing TUI renderer. Diff check passed. Untested against a running shell: Casey applies and reloads staged patches.
This commit is contained in:
parent
a8f7f62471
commit
8c434dfb74
4 changed files with 299 additions and 42 deletions
|
|
@ -20,7 +20,14 @@ Rectangle {
|
|||
property bool renderMarkdown: true
|
||||
property bool editing: false
|
||||
|
||||
property list<var> messageBlocks: StringUtils.splitMarkdownBlocks(root.messageData?.content)
|
||||
// Souveraine messages arrive as typed blocks. Legacy/provider messages
|
||||
// still use ii's markdown splitter, so the override remains compatible.
|
||||
property var messageBlocks: {
|
||||
const segments = root.messageData ? root.messageData.segments : [];
|
||||
return segments.length > 0
|
||||
? segments
|
||||
: StringUtils.splitMarkdownBlocks(root.messageData?.content);
|
||||
}
|
||||
|
||||
anchors.left: parent?.left
|
||||
anchors.right: parent?.right
|
||||
|
|
@ -358,7 +365,10 @@ Rectangle {
|
|||
segmentContent: modelData.content
|
||||
messageData: root.messageData
|
||||
done: root.messageData?.done ?? false
|
||||
completed: modelData.completed ?? false
|
||||
completed: modelData.completed ?? (root.messageData?.done ?? false)
|
||||
} }
|
||||
DelegateChoice { roleValue: "tool"; ToolCallBlock {
|
||||
segment: modelData
|
||||
} }
|
||||
DelegateChoice { roleValue: "text"; MessageTextBlock {
|
||||
editing: root.editing
|
||||
|
|
@ -410,4 +420,3 @@ Rectangle {
|
|||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var segment: ({})
|
||||
property bool expanded: segment.status === "running" || segment.failed === true
|
||||
property string name: String(segment.name ?? "tool")
|
||||
property string status: String(segment.status ?? "running")
|
||||
property string output: String(segment.output ?? "")
|
||||
property bool failed: segment.failed === true
|
||||
property string summary: toolSummary()
|
||||
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: card.implicitHeight
|
||||
|
||||
function clip(text, max) {
|
||||
const value = String(text ?? "");
|
||||
return value.length > max ? value.slice(0, Math.max(0, max - 1)) + "…" : value;
|
||||
}
|
||||
|
||||
function argumentsObject() {
|
||||
try {
|
||||
const parsed = JSON.parse(String(segment.arguments ?? "{}"));
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function toolSummary() {
|
||||
const args = argumentsObject();
|
||||
switch (name) {
|
||||
case "bash": return "$ " + clip(args.command ?? segment.arguments, 88);
|
||||
case "read": return clip(args.path ?? segment.arguments, 88);
|
||||
case "write": return "write → " + clip(args.path ?? segment.arguments, 78);
|
||||
case "edit": return "edit → " + clip(args.path ?? segment.arguments, 80);
|
||||
case "grep": return "\"" + clip(args.pattern ?? segment.arguments, 48) + "\" " + clip(args.path ?? "", 30);
|
||||
case "glob": return clip(args.pattern ?? segment.arguments, 88);
|
||||
case "list_dir": return clip(args.path ?? ".", 88);
|
||||
case "memory": return clip((args.command ?? "list") + " " + (args.path ?? ""), 88);
|
||||
default: return clip(segment.arguments ?? "", 88);
|
||||
}
|
||||
}
|
||||
|
||||
function iconForTool() {
|
||||
switch (name) {
|
||||
case "bash": return "terminal";
|
||||
case "read": return "article";
|
||||
case "write":
|
||||
case "edit": return "edit_note";
|
||||
case "grep": return "manage_search";
|
||||
case "glob":
|
||||
case "list_dir": return "folder";
|
||||
case "memory": return "psychology";
|
||||
default: return "sensors";
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel() {
|
||||
if (status === "running") return Translation.tr("running");
|
||||
if (status === "unresolved") return Translation.tr("unresolved");
|
||||
if (failed) return Translation.tr("failed");
|
||||
return Translation.tr("done");
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
width: parent.width
|
||||
implicitHeight: cardLayout.implicitHeight + 14
|
||||
radius: Appearance.rounding.small
|
||||
color: root.failed ? Appearance.colors.colErrorContainer : Appearance.colors.colLayer2
|
||||
border.width: 1
|
||||
border.color: root.failed ? Appearance.colors.colError : Appearance.colors.colOutlineVariant
|
||||
|
||||
ColumnLayout {
|
||||
id: cardLayout
|
||||
anchors.fill: parent
|
||||
anchors.margins: 7
|
||||
spacing: 5
|
||||
|
||||
MouseArea {
|
||||
id: header
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: headerRow.implicitHeight
|
||||
hoverEnabled: true
|
||||
enabled: root.status !== "running" || root.output.length > 0
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: root.expanded = !root.expanded
|
||||
|
||||
RowLayout {
|
||||
id: headerRow
|
||||
anchors.fill: parent
|
||||
spacing: 7
|
||||
|
||||
MaterialSymbol {
|
||||
text: root.iconForTool()
|
||||
iconSize: Appearance.font.pixelSize.large
|
||||
color: root.failed ? Appearance.colors.colError : Appearance.colors.colPrimary
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: false
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
font.bold: true
|
||||
text: root.name
|
||||
color: Appearance.colors.colOnLayer2
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
text: root.summary
|
||||
color: Appearance.colors.colSubtext
|
||||
}
|
||||
MaterialSymbol {
|
||||
visible: root.status === "running"
|
||||
text: "sync"
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
color: Appearance.colors.colPrimary
|
||||
RotationAnimation on rotation {
|
||||
running: root.status === "running"
|
||||
from: 0
|
||||
to: 360
|
||||
duration: 900
|
||||
loops: Animation.Infinite
|
||||
}
|
||||
}
|
||||
StyledText {
|
||||
visible: root.status !== "running"
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
text: root.statusLabel()
|
||||
color: root.failed ? Appearance.colors.colError : Appearance.colors.colSubtext
|
||||
}
|
||||
MaterialSymbol {
|
||||
visible: header.enabled
|
||||
text: root.expanded ? "expand_less" : "expand_more"
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
color: Appearance.colors.colSubtext
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextArea {
|
||||
Layout.fillWidth: true
|
||||
visible: root.expanded && root.output.length > 0
|
||||
implicitHeight: visible ? Math.min(contentHeight + topPadding + bottomPadding, 240) : 0
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: TextArea.Wrap
|
||||
textFormat: TextEdit.PlainText
|
||||
text: root.output
|
||||
font.family: Appearance.font.family.monospace
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
color: root.failed ? Appearance.colors.colOnErrorContainer : Appearance.colors.colOnLayer2
|
||||
background: Rectangle {
|
||||
radius: Appearance.rounding.small / 2
|
||||
color: Appearance.colors.colLayer1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,10 @@ QtObject {
|
|||
property string role
|
||||
property string content
|
||||
property string rawContent
|
||||
// First-class stream blocks. The Souveraine adapter fills these from the
|
||||
// typed SSE events; legacy/provider messages leave this empty and the
|
||||
// existing markdown splitter remains their renderer.
|
||||
property var segments: []
|
||||
property string fileMimeType
|
||||
property string fileUri
|
||||
property string localFilePath
|
||||
|
|
|
|||
|
|
@ -230,7 +230,6 @@ Singleton {
|
|||
|
||||
// ── Streaming message shaping ────────────────────────────────────────
|
||||
property AiMessageData streamingMessage
|
||||
property bool inThinkBlock: false
|
||||
|
||||
// ── Subconscious three-tier visibility ───────────────────────────────
|
||||
// See docs/tasks/subconscious-surfacing-threshold.md. The subconscious's
|
||||
|
|
@ -253,6 +252,89 @@ Singleton {
|
|||
root.streamingMessage.content += text;
|
||||
}
|
||||
|
||||
// The server already says what each frame is. Keep that fact attached to
|
||||
// the message instead of smuggling it through markdown fences and asking
|
||||
// the delegate to parse it back out again.
|
||||
function appendStreamingTextSegment(type, content) {
|
||||
if (!root.streamingMessage) return;
|
||||
const text = String(content ?? "");
|
||||
if (text.length === 0) return;
|
||||
const segments = root.streamingMessage.segments ?? [];
|
||||
const last = segments.length > 0 ? segments[segments.length - 1] : null;
|
||||
if (last && last.type === type) {
|
||||
root.streamingMessage.segments = [
|
||||
...segments.slice(0, -1),
|
||||
{ ...last, content: String(last.content ?? "") + text }
|
||||
];
|
||||
} else {
|
||||
root.streamingMessage.segments = [...segments, { type, content: text }];
|
||||
}
|
||||
// `content` remains a plain compatibility projection for Copy, TTS,
|
||||
// and old snapshot consumers. It no longer drives the renderer.
|
||||
root.appendToStreaming(text);
|
||||
}
|
||||
|
||||
function appendToolCallSegment(call, round) {
|
||||
if (!root.streamingMessage) return;
|
||||
const tool = call ?? {};
|
||||
const fn = tool.function ?? {};
|
||||
const name = String(fn.name ?? "tool");
|
||||
const arguments = String(fn.arguments ?? "");
|
||||
const id = String(tool.id ?? "");
|
||||
root.streamingMessage.segments = [...root.streamingMessage.segments, {
|
||||
type: "tool",
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
round: Number(round ?? 0),
|
||||
status: "running",
|
||||
output: "",
|
||||
failed: false,
|
||||
}];
|
||||
root.appendToStreaming(`\n${name}(${arguments})\n`);
|
||||
}
|
||||
|
||||
function bindToolReturnSegment(toolReturn) {
|
||||
if (!root.streamingMessage) return;
|
||||
const result = toolReturn ?? {};
|
||||
const segments = root.streamingMessage.segments ?? [];
|
||||
const resultId = String(result.id ?? "");
|
||||
let index = -1;
|
||||
if (resultId.length > 0) {
|
||||
index = segments.findIndex(segment => segment.type === "tool" && segment.id === resultId);
|
||||
}
|
||||
// Older servers did not include the tool id. Bind their return to the
|
||||
// newest still-running call rather than silently inventing a second one.
|
||||
if (index < 0) {
|
||||
for (let i = segments.length - 1; i >= 0; i--) {
|
||||
if (segments[i].type === "tool" && segments[i].status === "running") {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const status = String(result.status ?? "done");
|
||||
const failed = /error|fail/i.test(status);
|
||||
const output = String(result.output ?? "");
|
||||
if (index >= 0) {
|
||||
root.streamingMessage.segments = segments.map((segment, i) => i === index
|
||||
? { ...segment, status, output, failed }
|
||||
: segment);
|
||||
} else {
|
||||
root.streamingMessage.segments = [...segments, {
|
||||
type: "tool",
|
||||
id: resultId,
|
||||
name: String(result.name ?? "tool"),
|
||||
arguments: "",
|
||||
round: 0,
|
||||
status,
|
||||
output,
|
||||
failed,
|
||||
}];
|
||||
}
|
||||
root.appendToStreaming(`\n[${status}] ${output}\n`);
|
||||
}
|
||||
|
||||
// Push a line onto the Tier-1 live stream. kind ∈ token|tool_call|tool_result.
|
||||
// Consecutive tokens are coalesced onto the current line so the ticker reads
|
||||
// as a thought forming (a sentence), not a single flickering word replaced
|
||||
|
|
@ -308,10 +390,12 @@ Singleton {
|
|||
|
||||
function finishStreaming() {
|
||||
if (!root.streamingMessage) return;
|
||||
if (root.inThinkBlock) {
|
||||
root.appendToStreaming("\n</think>\n");
|
||||
root.inThinkBlock = false;
|
||||
}
|
||||
root.streamingMessage.segments = (root.streamingMessage.segments ?? []).map(segment => {
|
||||
if (segment.type === "tool" && segment.status === "running") {
|
||||
return { ...segment, status: "unresolved", failed: true };
|
||||
}
|
||||
return segment;
|
||||
});
|
||||
root.streamingMessage.thinking = false;
|
||||
root.streamingMessage.done = true;
|
||||
// If the turn finished while the session is locked, the finished
|
||||
|
|
@ -342,36 +426,17 @@ Singleton {
|
|||
|
||||
switch (event.message_type) {
|
||||
case "assistant_message":
|
||||
if (root.inThinkBlock) {
|
||||
root.appendToStreaming("\n</think>\n");
|
||||
root.inThinkBlock = false;
|
||||
}
|
||||
root.appendToStreaming(event.content);
|
||||
root.appendStreamingTextSegment("text", event.content);
|
||||
break;
|
||||
case "reasoning_message":
|
||||
if (!root.inThinkBlock) {
|
||||
root.appendToStreaming("\n<think>\n");
|
||||
root.inThinkBlock = true;
|
||||
}
|
||||
root.appendToStreaming(event.content);
|
||||
root.appendStreamingTextSegment("think", event.content);
|
||||
break;
|
||||
case "tool_call_message": {
|
||||
// A sensor firing mid-reasoning must not close the reasoning
|
||||
// fence. When a <think> block is already open we append inside it;
|
||||
// only a sensor arriving in prose opens a fence of its own.
|
||||
const call = event.tool_call;
|
||||
const body = `sensor: ${call.function.name}(${call.function.arguments})`;
|
||||
root.appendToStreaming(root.inThinkBlock
|
||||
? `\n${body}\n`
|
||||
: `\n\n<think>\n${body}\n</think>\n`);
|
||||
root.appendToolCallSegment(event.tool_call, event.round);
|
||||
break;
|
||||
}
|
||||
case "tool_return_message": {
|
||||
const ret = event.tool_return;
|
||||
const body = `[${ret.status}] ${ret.output}`;
|
||||
root.appendToStreaming(root.inThinkBlock
|
||||
? `\n${body}\n`
|
||||
: `\n<think>\n${body}\n</think>\n`);
|
||||
root.bindToolReturnSegment(event.tool_return);
|
||||
break;
|
||||
}
|
||||
case "interstitial":
|
||||
|
|
@ -455,13 +520,14 @@ Singleton {
|
|||
}
|
||||
|
||||
// ── Message store ────────────────────────────────────────────────────
|
||||
function addMessage(message, role) {
|
||||
function addMessage(message, role, segments = []) {
|
||||
if (message.length === 0) return;
|
||||
const aiMessage = aiMessageComponent.createObject(root, {
|
||||
"role": role,
|
||||
"model": Souveraine.currentAgentId,
|
||||
"content": message,
|
||||
"rawContent": message,
|
||||
"segments": segments,
|
||||
"thinking": false,
|
||||
"done": true,
|
||||
});
|
||||
|
|
@ -494,7 +560,6 @@ Singleton {
|
|||
root.messageIDs = [];
|
||||
root.messageByID = ({});
|
||||
root.streamingMessage = null;
|
||||
root.inThinkBlock = false;
|
||||
root.subconsciousActive = false;
|
||||
root.subconsciousStream = [];
|
||||
root.tokenCount.input = -1;
|
||||
|
|
@ -502,21 +567,32 @@ Singleton {
|
|||
root.tokenCount.total = -1;
|
||||
|
||||
messages.forEach(message => {
|
||||
const content = (message.blocks ?? []).map(block => {
|
||||
const segments = (message.blocks ?? []).map(block => {
|
||||
switch (block.type) {
|
||||
case "text": return block.text ?? "";
|
||||
case "reasoning": return `<think>\n${block.reasoning ?? ""}\n</think>`;
|
||||
case "tool_use": return `sensor: ${block.name ?? "?"}(${block.input ?? ""})`;
|
||||
case "tool_result": return `[${block.tool_name ?? "tool"}] ${block.output ?? ""}`;
|
||||
case "image": return "[image]";
|
||||
default: return "";
|
||||
case "text": return { type: "text", content: block.text ?? "" };
|
||||
case "reasoning": return { type: "think", content: block.reasoning ?? "" };
|
||||
case "tool_use": return {
|
||||
type: "tool", id: block.id ?? "", name: block.name ?? "tool",
|
||||
arguments: block.input ?? "", round: 0, status: "done", output: "", failed: false,
|
||||
};
|
||||
case "tool_result": return {
|
||||
type: "tool", id: block.tool_use_id ?? "", name: block.tool_name ?? "tool",
|
||||
arguments: "", round: 0, status: block.is_error ? "error" : "done",
|
||||
output: block.output ?? "", failed: block.is_error ?? false,
|
||||
};
|
||||
case "image": return { type: "text", content: "[image]" };
|
||||
default: return null;
|
||||
}
|
||||
}).filter(Boolean);
|
||||
const content = segments.map(segment => {
|
||||
if (segment.type === "tool") return `${segment.name}(${segment.arguments})\n${segment.output}`;
|
||||
return segment.content;
|
||||
}).filter(Boolean).join("\n");
|
||||
if (content.length === 0) return;
|
||||
const role = message.role === "user"
|
||||
? "user"
|
||||
: message.role === "assistant" ? "assistant" : root.interfaceRole;
|
||||
root.addMessage(content, role);
|
||||
root.addMessage(content, role, segments);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -554,12 +630,12 @@ Singleton {
|
|||
// Set up the streaming assistant message container. Called after a
|
||||
// successful send (or after a step-up auth retry succeeds).
|
||||
function _startStreaming() {
|
||||
root.inThinkBlock = false;
|
||||
root.streamingMessage = root.aiMessageComponent.createObject(root, {
|
||||
"role": "assistant",
|
||||
"model": Souveraine.currentAgentId,
|
||||
"content": "",
|
||||
"rawContent": "",
|
||||
"segments": [],
|
||||
"thinking": true,
|
||||
"done": false,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue