v0.9.2: files the agent delivers come back as cards, and the saved key is used

A turn could write a report and say "here it is" -- and there was nothing to
open. The engine had no way to hand a finished file to the person, and the
front end had no way to show one. This release adds the whole path: a tool to
deliver, a store that keeps the copy, a card in the transcript, and open /
share through the platform. It also fixes two bugs the phone exposed while it
was being tried there.

deliver_file (engine)
- cst_attachment_t.for_user marks a copy that belongs to the person, not to
  the model; cst_attach_import_artifact() stores any bytes up to
  CST_ARTIFACT_MAX_BYTES (64 MB) with the mime taken from the name's
  extension. Attachments are unchanged (content decides the type, 16 MB).
- It is a read-policy engine tool: path resolves inside the workspace, a
  directory is refused, title (optional) is the display name.
- The wire is what keeps it out of the conversation: parts_add_attachments()
  skips for_user entries without even a "not sent" note, and a turn that only
  delivered a file no longer invents an empty user message. tests/test_artifacts.c
  pins that down end to end, including a session-file round trip.

The card (Qt front end)
- filesText() replaced imagesText(): one line per file -- kind, stored name,
  name, origin, mime, bytes, pixels -- decoded by ImageStrip.qml. A for_user
  entry is a file card (extension badge, name, type and size, Open, Share);
  a picture stays a thumbnail and opens on tap. Both shapes are read, so
  sessions written by an older version still draw.
- AgentCore::openArtifact() / shareArtifact() accept a name only when one of
  the session's own messages carries it, and hand the display name to the
  platform layer for the status line.
- Android opens through the app's FileProvider (content:// + ACTION_VIEW,
  resolveActivity() checked, read permission granted) and shares through the
  share sheet; a desktop uses QDesktopServices / the clipboard.
  CST_APP_NO_OPEN=1 makes --uitest run without opening anything.

Two bugs the device found
- The session writer had its own copy of the attachment JSON and forgot
  `for_user`: the file survived the file, but came back as an attachment --
  no card, and put back in front of the model. It now uses the one writer
  (cst_attachments_to_json()), with the round trip covered by a test.
- engineToolImages() looked the card up with findTool(), which only finds a
  card that is still running -- and the engine reports the result *before*
  the files, so the card was already complete and the files were dropped.
  A live turn therefore showed no card while a replay of the same session
  did. It now matches on the call id.
- A tap that could not work said nothing: ImageStrip.qml's errorText was
  never drawn. The card now carries the reason ("no app on the phone opens
  report.md").

Also
- The saved provider key reaches the engine on every start: AgentCore::open()
  was handed an empty key and the engine never resolves credentials itself
  (only the CLI did), so every start asked for it again. It resolves the same
  order main.c uses (saved key, provider environment, CST_API_KEY) and wipes
  the copy it made; --selftest checks it against a scratch credentials file.
- Android: notification_send reads the Java exception text into its result,
  and keeps failing on the NX789J -- the build() returns null and Qt clears
  the exception before C++ can read it, so the call has to be wrapped in Java
  next (docs/android.md says so).
- --uitest grew the delivered-file case (card, Open, Share, a forged name
  refused), the live order of result-then-files, and the startup row count.

Version 0.9.2.
This commit is contained in:
WHC
2026-10-11 16:34:47 +08:00
parent 84f9585588
commit 60cbce3c5c
26 changed files with 2082 additions and 148 deletions
+210 -51
View File
@@ -2,21 +2,28 @@ import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The images a message or a tool call carries: thumbnails from the session's
// own store (image://cstattach/, app/src/ui/SessionImages.cpp). The names are
// the ones the engine wrote next to the session -- view_image, an MCP image, a
// picture the user attached -- never a path the view invented.
// The files a message or a tool call carries.
//
// One row of thumbnails: the height is capped so a screenshot cannot push the
// conversation off the screen, the width follows the picture's shape.
// A picture is a thumbnail from the session's own store (image://cstattach/,
// app/src/ui/SessionImages.cpp) and opens with whatever the system uses for
// images when it is tapped. A file the agent delivered (deliver_file) is a card
// with the name, the type, the size and two buttons: open and share. The names
// are the ones the engine wrote next to the session -- a card never invents a
// path.
//
// One line per file, written by AgentCore.cpp (filesText):
// <kind>|<stored name>|<name>|<origin>|<mime>|<bytes>|<w>x<h>
// A session saved by an older version wrote "<stored name>|<name>|<origin>|
// <w>x<h>" for pictures; both shapes are read here.
Flow {
id: strip
objectName: "imageStrip"
/* One line per picture, written by the engine side (AgentCore.cpp:
* "<stored name>|<name>|<origin>|<w>x<h>"). A row carries this as text
* because a ListModel role cannot hold a list. */
/* A row carries this as text: a ListModel role cannot hold a list. */
property string text: ""
property var images: decode(text)
property var files: decode(text)
/* The last refusal, so a tap that cannot work says why (the header's own
* status line is the engine's). */
property string errorText: ""
spacing: 6
function decode(t) {
@@ -28,83 +35,235 @@ Flow {
const f = lines[i].split("|")
if (!f[0].length)
continue
const old = f.length < 7 // a picture line from an old session
const dim = String(old ? f[3] : f[6]).split("x")
let w = 0, h = 0
const dim = String(f[3] || "").split("x")
if (dim.length === 2) {
w = parseInt(dim[0], 10) || 0
h = parseInt(dim[1], 10) || 0
}
out.push({ url: "image://cstattach/" + f[0], rel: f[0],
name: f[1] || f[0], origin: f[2] || "", w: w, h: h })
const rel = old ? f[0] : f[1]
out.push({ kind: old ? "img" : f[0],
rel: rel,
name: (old ? f[1] : f[2]) || rel,
origin: (old ? f[2] : f[3]) || "",
mime: old ? "image/*" : (f[4] || ""),
bytes: old ? 0 : (parseInt(f[5], 10) || 0),
w: w, h: h,
url: "image://cstattach/" + rel })
}
return out
}
function humanSize(n) {
if (!n || n <= 0)
return ""
if (n >= 1024 * 1024)
return (n / (1024 * 1024)).toFixed(1) + " MB"
if (n >= 1024)
return Math.round(n / 1024) + " KB"
return n + " B"
}
/* The two things a file card does. The engine answers with "" when it
* worked (it puts the line in the header itself) or a reason. */
function openFile(rel) {
errorText = agent.openArtifact(rel)
}
function shareFile(rel) {
errorText = agent.shareArtifact(rel)
}
Repeater {
model: strip.images
model: strip.files
delegate: Rectangle {
id: thumb
id: cell
required property var modelData
required property int index
readonly property bool isFile: String(modelData.kind) !== "img"
readonly property bool hovered: mouse.containsMouse
/* Images keep their own shape, capped; a file card is a fixed
* width so a long name cannot stretch the transcript. */
readonly property real ratio: {
const w = Number(modelData.w), h = Number(modelData.h)
return (w > 0 && h > 0) ? w / h : 1.5
}
readonly property int cap: 150 // the tallest a thumbnail gets
objectName: "imageThumb" + index
width: Math.max(56, Math.min(Math.round(cap * ratio), 320))
height: Math.max(40, Math.min(cap, Math.round(width / ratio)))
readonly property int cap: 150
objectName: isFile ? ("fileCard" + index) : ("imageThumb" + index)
width: isFile ? 300
: Math.max(56, Math.min(Math.round(cap * ratio), 320))
height: isFile ? 52
: Math.max(40, Math.min(cap, Math.round(width / ratio)))
radius: Theme.radiusSm
color: Theme.bg
border.width: 1
border.color: mouse.containsMouse ? Theme.accentDim : Theme.border
border.color: hovered ? Theme.accentDim : Theme.border
clip: true
Image {
id: pic
anchors.fill: parent
anchors.margins: 2
source: String(thumb.modelData.url)
sourceSize.width: 512
sourceSize.height: 512
fillMode: Image.PreserveAspectFit
/* Not asynchronous: the provider already hands back a
* thumbnail-sized picture, so there is nothing to wait for. */
asynchronous: false
visible: status === Image.Ready
}
// A file the store no longer has is not worth shouting about: the
// frame says which image it was.
Text {
anchors.centerIn: parent
width: parent.width - 8
visible: pic.status === Image.Error
text: String(thumb.modelData.name)
elide: Text.ElideMiddle
horizontalAlignment: Text.AlignHCenter
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
}
ToolTip.visible: mouse.containsMouse
// --- a picture: tap it to look at it ---------------------------
Image {
id: pic
anchors.fill: parent
anchors.margins: 2
visible: !cell.isFile && status === Image.Ready
source: String(cell.modelData.url)
sourceSize.width: 512
sourceSize.height: 512
fillMode: Image.PreserveAspectFit
asynchronous: false
}
Text {
anchors.centerIn: parent
width: parent.width - 8
visible: !cell.isFile && pic.status === Image.Error
text: String(cell.modelData.name)
elide: Text.ElideMiddle
horizontalAlignment: Text.AlignHCenter
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
MouseArea {
anchors.fill: parent
enabled: !cell.isFile
cursorShape: Qt.PointingHandCursor
onClicked: strip.openFile(String(cell.modelData.rel))
}
// --- a delivered file: name, type, open, share -----------------
RowLayout {
id: row
visible: cell.isFile
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 6
spacing: 6
/* The extension stands in for an icon: no theme lookup, and a
* ZIP that says ZIP is more use than a generic sheet. */
Rectangle {
implicitWidth: 40
implicitHeight: 30
radius: Theme.radiusSm
color: Theme.surfaceHi
Text {
anchors.centerIn: parent
text: {
const n = String(cell.modelData.name)
const dot = n.lastIndexOf(".")
if (dot < 0 || dot === n.length - 1)
return "FILE"
return n.slice(dot + 1, dot + 5).toUpperCase()
}
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
Text {
Layout.fillWidth: true
text: String(cell.modelData.name)
color: Theme.text
font.pixelSize: Theme.bodySize
elide: Text.ElideMiddle
}
Text {
Layout.fillWidth: true
text: {
const m = cell.modelData
let s = m.mime ? String(m.mime) : ""
const size = strip.humanSize(m.bytes)
if (size)
s += (s ? " · " : "") + size
if (m.origin && m.origin !== "user" && m.origin !== "tool")
s += (s ? " · " : "") + m.origin
return s
}
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
}
}
ToolButton {
objectName: "fileOpen" + cell.index
implicitWidth: Theme.touchTarget
implicitHeight: Theme.touchTarget
onClicked: strip.openFile(String(cell.modelData.rel))
contentItem: Text {
text: "Open"
color: parent.hovered ? Theme.text : Theme.textDim
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
border.width: 1
border.color: Theme.border
}
}
ToolButton {
objectName: "fileShare" + cell.index
implicitWidth: Theme.touchTarget
implicitHeight: Theme.touchTarget
onClicked: strip.shareFile(String(cell.modelData.rel))
contentItem: Text {
text: "Share"
color: parent.hovered ? Theme.text : Theme.textDim
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
border.width: 1
border.color: Theme.border
}
}
}
ToolTip.visible: hovered
ToolTip.delay: 400
ToolTip.text: {
const m = thumb.modelData
const m = cell.modelData
let s = String(m.name)
if (m.w > 0 && m.h > 0)
s += " · " + m.w + "×" + m.h
if (m.origin && m.origin !== "user")
s += " · " + m.origin
if (m.mime && m.kind !== "img")
s += " · " + m.mime
return s
}
}
}
/* A tap that could not work says why, right where the card is: a phone
* with nothing installed for the type is the common case ("no app on the
* phone opens report.md"), and a silence there looks like a dead button. */
Text {
objectName: "fileError"
visible: strip.errorText.length > 0
width: strip.width
wrapMode: Text.WordWrap
text: strip.errorText
color: Theme.err
font.pixelSize: Theme.smallSize
}
}