patches: queue typed chat segments
This commit is contained in:
parent
f17fa7c31b
commit
52f379f230
1 changed files with 484 additions and 0 deletions
|
|
@ -0,0 +1,484 @@
|
|||
From f297008054bea93babc5c56d1669f33f5803473e Mon Sep 17 00:00:00 2001
|
||||
From: Fimeg <casey.tunturi@gmail.com>
|
||||
Date: Tue, 11 Aug 2026 08:54:36 -0400
|
||||
Subject: [PATCH] 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.
|
||||
---
|
||||
.../ii/sidebarLeft/aiChat/AiMessage.qml | 15 +-
|
||||
.../ii/sidebarLeft/aiChat/ToolCallBlock.qml | 168 ++++++++++++++++++
|
||||
.../ii-base/services/ai/AiMessageData.qml | 4 +
|
||||
surfaces/quickshell/services/Ai.qml | 154 ++++++++++++----
|
||||
4 files changed, 299 insertions(+), 42 deletions(-)
|
||||
create mode 100644 surfaces/quickshell/ii-base/modules/ii/sidebarLeft/aiChat/ToolCallBlock.qml
|
||||
|
||||
diff --git a/surfaces/quickshell/ii-base/modules/ii/sidebarLeft/aiChat/AiMessage.qml b/surfaces/quickshell/ii-base/modules/ii/sidebarLeft/aiChat/AiMessage.qml
|
||||
index 8a6dc2f..03cf283 100644
|
||||
--- a/surfaces/quickshell/ii-base/modules/ii/sidebarLeft/aiChat/AiMessage.qml
|
||||
+++ b/surfaces/quickshell/ii-base/modules/ii/sidebarLeft/aiChat/AiMessage.qml
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
}
|
||||
-
|
||||
diff --git a/surfaces/quickshell/ii-base/modules/ii/sidebarLeft/aiChat/ToolCallBlock.qml b/surfaces/quickshell/ii-base/modules/ii/sidebarLeft/aiChat/ToolCallBlock.qml
|
||||
new file mode 100644
|
||||
index 0000000..452eac3
|
||||
--- /dev/null
|
||||
+++ b/surfaces/quickshell/ii-base/modules/ii/sidebarLeft/aiChat/ToolCallBlock.qml
|
||||
@@ -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
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
diff --git a/surfaces/quickshell/ii-base/services/ai/AiMessageData.qml b/surfaces/quickshell/ii-base/services/ai/AiMessageData.qml
|
||||
index 3db787b..9c3b5eb 100644
|
||||
--- a/surfaces/quickshell/ii-base/services/ai/AiMessageData.qml
|
||||
+++ b/surfaces/quickshell/ii-base/services/ai/AiMessageData.qml
|
||||
@@ -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
|
||||
diff --git a/surfaces/quickshell/services/Ai.qml b/surfaces/quickshell/services/Ai.qml
|
||||
index cd065c8..aaec22f 100644
|
||||
--- a/surfaces/quickshell/services/Ai.qml
|
||||
+++ b/surfaces/quickshell/services/Ai.qml
|
||||
@@ -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,
|
||||
});
|
||||
--
|
||||
2.55.0
|
||||
|
||||
Loading…
Reference in a new issue