Files
CST-agent/app/qml/Composer.qml
T
WHC 580d533656 v0.9.6: documents, downloads, PDFs and the phone's own files
Four tools and a document writer, plus the storage half of the phone: until
now the picker's answer (a content:// URI) reached the engine as a path, and
it said "cannot read" -- the app had no way to hand a phone's file over.

- md_to_html (new, src/tools/web.c + cst_web_md_document()): markdown in, one
  self-contained HTML document out -- doctype, utf-8, viewport, escaped
  title, a small stylesheet; no script, no external request. It is written
  into the workspace and delivered as a file card (for_user, so it never
  enters a request), the way deliver_file does. The default name comes from
  the source file's stem or, for pasted markdown, from the title with what a
  path cannot carry turned into '-'. It has a preview, so an approval prompt
  says "render x -> y.html" before anything is written.
- download (src/tools/web.c): an http(s) URL into a file in the workspace,
  streamed with a 128 MB ceiling; a redirect chain, the same CA handling and
  the same refusals as web_fetch, but the bytes stay on disk (a half file is
  removed on any failure, including HTTP >= 400). The name comes from the URL
  when the caller names only a directory.
- read_pdf (src/tools/fs_tools.c): the text layer and page count of a PDF,
  which read_file refuses because the bytes are binary -- and a PDF is
  exactly what arrives from a phone. Same extractor an attachment uses, its
  own 4 MiB input ceiling, 256 KiB of text by default with a note when cut.
- session_search (new, src/tools/session_tools.c): every stored session --
  this one and all the ones before it -- searched for a substring, reporting
  the session's id, message count and title, then the matching lines with
  their number and role. Case-insensitive by default, capped at 20 matches.
- device_info (same file, perm_name "device"): version, system/arch/CPU
  count, workspace, sandbox, model and permissions, config/temp/program
  directories, pid -- so the model stops guessing where it runs.
- Tools 3 and 4 come with tests/test_tools_ext.c (also in both build files):
  the document is a whole page, a script tag and a javascript: link do not
  survive, a hostile title stays text and cannot write outside the workspace,
  the preview names the file, read_pdf/download refuse what they must (no
  network in a test), session_search finds a fixture session and says so when
  nothing matches, device_info names the workspace. README's tool table grew
  five rows.

- Android storage: the picker (SAF) answers with a content:// URI, and the
  engine opens files by path, so the app now copies what it points at into
  its own directory first -- app/android/src/xyz/whcself/cstagent/
  AttachFile.java, called through a new app::materializeAttachment()
  (ToolProvider.h: one API for every platform; a desktop strips file:// and
  refuses content:// with a reason). The copy needs no permission at all: the
  grant travels with the URI.
- "All files access" (MANAGE_EXTERNAL_STORAGE) is the other half: it is what
  lets the engine read a path in shared storage that a task names rather than
  picks. Android has no runtime dialog for it, so main.cpp opens the one
  Settings screen once (AttachFile.java's requestAllFilesAccess, with the
  all-apps screen as a fallback) and never insists: without it everything
  works, one picked file at a time. On the NX789J the screen came up, the
  app-op reads back "allow", and a relaunch no longer asks.
- Composer.qml hands a content:// URI through untouched (it is not a path,
  and decoding it would corrupt it); AgentCore::attachFile() turns whatever
  the picker gave into a path first.

Desktop: build clean, make check 36/36 (the new test included, also under
ASan), --uitest all green, --selftest and cst-app-selftest OK.
The APK builds and installs (86 MB, release-signed) with the new Java class
in its dex.

Version 0.9.6.
2026-10-11 18:33:41 +08:00

253 lines
9.5 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Dialogs
import QtQuick.Layouts
// The input line: multiline, Enter sends, the button changes with the turn
// state. Sized for a thumb as much as for a mouse, and it asks the platform
// keyboard not to autocorrect what is usually code or a path.
//
// Files arrive three ways -- the clip picker, a drop on the window (Main.qml)
// and an image on the clipboard (Ctrl+V) -- and all of them end up in the
// engine's pending list, which the next prompt carries.
Rectangle {
id: root
objectName: "composer"
property bool running: false
property bool compact: false
property string note: "" // a failure, or what was just attached
property var attachments: [] // AgentCore::pending, "name (mime, 12 KB)"
property alias text: input.text
signal submitted(string text)
function submit() {
const t = input.text.trim()
if (!t.length || running)
return
input.text = ""
submitted(t)
}
/* A file: from the picker, or a URL dropped on the window. */
function localPath(url) {
let s = String(url)
// A phone's picker (SAF) hands over content:// URIs: they are not
// paths and must not be decoded -- the app copies what they point at
// into its own directory before the engine sees it (see
// app::materializeAttachment).
if (s.startsWith("content://"))
return s
if (s.startsWith("file://"))
s = s.substring(7)
/* Windows keeps its drive letter after the slash. */
if (/^\/[A-Za-z]:/.test(s))
s = s.substring(1)
return decodeURIComponent(s)
}
function attachPath(path) {
const r = agent.attachFile(path)
note = r.length ? r : ""
}
radius: Theme.radius
color: Theme.surface
border.width: 1
border.color: input.activeFocus ? Theme.accentDim : Theme.border
/* One pad of frame on every side of the column: with the top margin
* alone the last row (the clip and Send) hung over the lower border,
* which is what put the clip on the frame instead of inside it. */
implicitHeight: layout.implicitHeight + Theme.pad * 2
ColumnLayout {
id: layout
anchors {
fill: parent
margins: Theme.pad
}
spacing: 6
// What the next prompt will carry, in the engine's own words.
Flow {
Layout.fillWidth: true
visible: root.attachments.length > 0
spacing: 6
Repeater {
model: root.attachments
delegate: Rectangle {
id: chip
required property var modelData
required property int index
objectName: "attachChip" + chip.index
implicitWidth: chipText.implicitWidth + 14
implicitHeight: 24
radius: Theme.radiusSm
color: Theme.surfaceAlt
border.width: 1
border.color: Theme.border
Text {
id: chipText
anchors.centerIn: parent
text: String(chip.modelData)
color: Theme.textDim
font.pixelSize: Theme.smallSize
}
}
}
Button {
id: clearAtt
objectName: "clearAttachments"
text: "clear all"
implicitHeight: 24
onClicked: {
agent.clearPending()
root.note = ""
}
background: Rectangle {
radius: Theme.radiusSm
color: clearAtt.hovered ? Theme.surfaceHi : "transparent"
border.width: 1
border.color: Theme.border
}
contentItem: Text {
text: clearAtt.text
color: clearAtt.hovered ? Theme.text : Theme.textFaint
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
Text {
Layout.fillWidth: true
visible: root.note.length > 0
text: root.note
color: Theme.warn
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
TextArea {
id: input
objectName: "composerInput"
Layout.fillWidth: true
Layout.preferredHeight: Math.min(root.compact ? 120 : 170,
Math.max(56, contentHeight + 12))
placeholderText: root.running
? "the turn is running — Esc stops it"
: (root.compact
? "Ask for something…"
: "Ask for something (Enter sends, Shift+Enter for a new line)")
placeholderTextColor: Theme.textFaint
color: Theme.text
font.pixelSize: Theme.bodySize
wrapMode: TextArea.Wrap
selectByMouse: true
selectionColor: Theme.accentDim
inputMethodHints: Qt.ImhMultiLine | Qt.ImhNoPredictiveText |
Qt.ImhNoAutoUppercase
background: Rectangle { color: "transparent" }
enabled: !root.running
Keys.onPressed: function (ev) {
if (ev.key === Qt.Key_Return || ev.key === Qt.Key_Enter) {
if (!(ev.modifiers & Qt.ShiftModifier)) {
root.submit()
ev.accepted = true
}
return
}
/* An image has nothing the editor could paste, so Ctrl+V
* becomes an attachment there; text stays with the
* editor's own paste. */
if ((ev.modifiers & Qt.ControlModifier) && ev.key === Qt.Key_V &&
agent.clipboardHasImage()) {
root.note = agent.attachClipboard()
ev.accepted = true
}
}
}
ToolButton {
id: attach
objectName: "attachButton"
Layout.alignment: Qt.AlignBottom
implicitWidth: root.compact ? Theme.touchTarget : 40
implicitHeight: Theme.touchTarget + 4
enabled: !root.running
ToolTip.visible: hovered
ToolTip.text: "Attach a file — or drop one on the window, or paste an image"
contentItem: ThemeIcon {
name: "mail-attachment"
glyph: "📎"
size: 20
color: attach.enabled ? (attach.hovered ? Theme.text : Theme.textDim)
: Theme.textFaint
}
background: Rectangle {
radius: Theme.radiusSm
color: attach.hovered && attach.enabled ? Theme.surfaceHi
: "transparent"
}
onClicked: picker.open()
}
Button {
id: action
objectName: "sendButton"
Layout.alignment: Qt.AlignBottom
implicitWidth: root.compact ? 68 : 88
implicitHeight: Theme.touchTarget + 4
text: root.running ? "Stop" : "Send"
enabled: root.running || input.text.trim().length > 0
onClicked: root.running ? agent.cancel() : root.submit()
background: Rectangle {
radius: Theme.radiusSm
color: !action.enabled ? Theme.surfaceAlt
: root.running ? (action.hovered ? "#e05666" : Theme.err)
: (action.hovered ? "#5cc0f0" : Theme.accent)
}
contentItem: Text {
text: action.text
color: action.enabled ? Theme.accentInk : Theme.textFaint
font.pixelSize: Theme.bodySize
font.bold: true
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
// On a narrow window the hint would wrap into the editor's space.
Text {
Layout.fillWidth: true
visible: !root.compact
text: {
if (root.running)
return "working — Esc cancels the turn"
let s = "Enter sends · Shift+Enter newline · Esc stops · the clip attaches a file, Ctrl+V an image"
if (root.attachments.length > 0 && agent.seesImages() === 0)
s += " · this model is text-only, images arrive as notes"
return s
}
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
FileDialog {
id: picker
title: "Attach a file"
fileMode: FileDialog.OpenFile
onAccepted: root.attachPath(root.localPath(selectedFile))
}
}