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.
253 lines
9.5 KiB
QML
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))
|
|
}
|
|
}
|