Two strands land together: the window draws pictures now, and the whole front end builds, installs and runs on the phone with the engine linked in-process. Images (the "make a picture actually show up" part) - Engine, additively: cst_agent_hooks_t.on_tool_images(call, list, n, ud) fires when a call left attachments behind, and cst_agent_pending_rel() / cst_agent_pending_is_image() read that list without the app touching the store. tests/test_attach.c::test_agent_pending pins the three cases (an image, a stored name that passes cst_attach_rel_ok, out-of-range NULL). - App: image://cstattach/<stored name> (app/src/ui/SessionImages.cpp) serves only the current session's attachment directory, checked with cst_attach_rel_ok, decodes down to the requested sourceSize and never upscales, and holds a mutex because requestImage() runs on the render thread while the directory can change underneath it. AgentCore gained attachmentDir(), pendingImages(), the toolImages()/promptImages() signals and hookToolImages(); history() gives replayed rows their images too. - QML: ImageStrip.qml (a thumb strip, <=150 px tall, shaped by the picture, tooltip with name/size/origin, the file's name when it went missing), wired into MessageCard and ToolCard. Qt 6's ListModel cannot hold a list-valued role (append() reads back undefined, setProperty drops it), so a row carries one image per line as <stored>|<name>|<origin>|<w>x<h> and ImageStrip decodes that. Android (the engine is already in the APK; this is the front end) - app/src/main.cpp: on Android the workspace, config, tmp and HOME go under the app's own data directory before the engine opens anything, because QDir::currentPath() is "/" there and /tmp is not writable. - app/android/: the manifest (QtApplication/QtActivity, INTERNET and POST_NOTIFICATIONS, adjustResize, no storage permission -- the picker will be the system one), strings, a vector icon; minSdk 24 and targetSdk 35 come from app/CMakeLists.txt through QT_ANDROID_MIN/TARGET_SDK_VERSION. - app/src/platform/android/AndroidTools.cpp: the Android file behind PlatformInit(), owner "app:android" (notification, clipboard, share sheet, file picker are stubs for now, so the tool list does not lie about them). - app/tools/selftest.c decides its scratch workspace with os_temp_dir(), so the C smoke test runs on the device too. - scripts/build_apk_android.sh: sources to installed APK in one command (environment checks, qt-cmake configure, --target apk, adb install), and the host-side plumbing the NixOS + qt.io combination needs -- the NDK sysroot include dirs for Qt's FindGLESv2/FindEGL (they only look under <sysroot>/include), -L$PREFIX/lib on the shared/module link lines as well as the executables', the Android debug keystore created on first use with QT_ANDROID_SIGN_APK + QT_ANDROID_KEYSTORE_* (androiddeployqt copies the unsigned gradle output and signs it itself), and the #!/bin/bash shebang of the build-tools wrappers, which NixOS does not have. - scripts/gradle-android-init.gradle (installed into ~/.gradle/init.d/): the release lint task is switched off -- lintVitalAnalyzeRelease resolves its own tool jars straight from dl.google.com, bypassing every repository the build configures, and fails the build after all the real work is done. - Icons: app/icons/*.svg (Breeze's 24@3x files, the 19 names the window asks for) are compiled in as :/cst-icons and ThemeIcons.cpp falls back to them when the platform has no icon theme. On the phone the Qt theme lookup found nothing and the QML glyph fallback was an empty box in the phone's fonts. - docs/android.md: "The APK (Qt front end)" -- the SDK assembled without sdkmanager, the host Qt's missing libraries on NixOS, the Gradle mirrors, lint, signing -- and a Status that says what runs where. app/README.md gains the images and APK lines, and stops claiming Android platform tools that are still stubs. Verified: engine `make check` green; desktop `cst-app-selftest: OK` and --uitest all checks pass; phone (NX789J, Android 16, arm64-v8a) -- the APK installs, starts on the workspace under /data/user/0/xyz.whcself.cstagent/ files/, a turn reaches a configured provider over HTTPS from inside the ART process and renders the reply, rotation keeps the session and docks the sidebar, and the toolbar/card icons draw. Version 0.9.0. mode = coding
639 lines
22 KiB
QML
639 lines
22 KiB
QML
// The window: a sidebar with the engine's settings, the transcript, the
|
|
// composer, and the two dialogs the engine can block on.
|
|
//
|
|
// One shell, three sizes: the sidebar is docked on a desktop (and can be
|
|
// dragged), slides over the transcript when the window is narrow, and every
|
|
// control keeps a finger-sized hit area for touch screens. Qt Quick Controls
|
|
// only, laid out the way Kirigami expects (sidebar + page, cards, header), so
|
|
// swapping in org.kde.kirigami later stays a QML change.
|
|
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Layouts
|
|
|
|
ApplicationWindow {
|
|
id: win
|
|
width: initialWidth > 0 ? initialWidth : 1180
|
|
height: initialHeight > 0 ? initialHeight : 800
|
|
minimumWidth: 360
|
|
minimumHeight: 420
|
|
visible: true
|
|
color: Theme.bg
|
|
title: "CST-agent — " + (agent.workspace.length ? agent.workspace : "no workspace")
|
|
|
|
property string statusText: ""
|
|
property var sessionsView: [] // the store, for the sidebar
|
|
property bool navOpen: false // the sliding sidebar, compact only
|
|
property int sidebarWidth: 0 // 0 = follow the window width
|
|
readonly property bool compact: width < Theme.compactBreakpoint
|
|
readonly property int sidebarWide: sidebarWidth > 0
|
|
? sidebarWidth
|
|
: Math.max(228, Math.min(360, Math.round(width * 0.26)))
|
|
|
|
// The settings panel, used docked and in the sliding panel: one definition,
|
|
// two places.
|
|
component ShellSidebar: Sidebar {
|
|
workspace: agent.workspace
|
|
provider: agent.provider
|
|
model: agent.model
|
|
mode: agent.mode
|
|
approval: agent.approval
|
|
planMode: agent.planMode
|
|
running: agent.running
|
|
status: win.statusText
|
|
todo: agent.todo
|
|
sessions: win.sessionsView
|
|
sessionId: agent.sessionId
|
|
onModeRequested: function (m) { agent.setMode(m) }
|
|
onSessionRequested: function (id) { win.switchSession(id) }
|
|
onSessionNewRequested: win.newSession()
|
|
onSessionDeleteRequested: function (id) { win.deleteSession(id) }
|
|
onApprovalRequested: function (a) { agent.setApproval(a) }
|
|
onPlanModeRequested: function (on) { agent.setPlanMode(on) }
|
|
}
|
|
|
|
// ---- the transcript --------------------------------------------------
|
|
ListModel { id: transcript }
|
|
|
|
function lastIndex() { return transcript.count - 1 }
|
|
function lastRow() { return transcript.count ? transcript.get(lastIndex()) : null }
|
|
|
|
function addRow(kind, role, state) {
|
|
transcript.append({ kind: kind, role: role, text: "", html: "", complete: false,
|
|
name: "", callId: "", state: state, detail: "", output: "",
|
|
exitCode: 0, images: "" })
|
|
}
|
|
|
|
// The engine streams one kind at a time: close whatever was open before.
|
|
function closeOpenRows(exceptKind) {
|
|
const last = lastRow()
|
|
if (last && !last.complete && last.kind !== exceptKind)
|
|
transcript.setProperty(lastIndex(), "complete", true)
|
|
}
|
|
|
|
function appendDelta(kind, role, text) {
|
|
closeOpenRows(kind)
|
|
if (!lastRow() || lastRow().kind !== kind || lastRow().complete)
|
|
addRow(kind, role, "running")
|
|
transcript.setProperty(lastIndex(), "text", lastRow().text + text)
|
|
}
|
|
|
|
// The open card of this call, -1 when none. The id is what tells two calls
|
|
// of the same tool apart (pairing by name left the first card running
|
|
// forever); a call without one -- an old text-protocol session -- falls
|
|
// back to the name.
|
|
function findTool(callId, name) {
|
|
for (let i = transcript.count - 1; i >= 0; i--) {
|
|
const r = transcript.get(i)
|
|
if (r.kind !== "tool" || r.complete)
|
|
continue
|
|
if (callId.length) {
|
|
if (r.callId === callId)
|
|
return i
|
|
continue /* another call: its card is further back */
|
|
}
|
|
if (r.callId.length)
|
|
continue
|
|
if (r.name === name)
|
|
return i
|
|
return -1
|
|
}
|
|
return -1
|
|
}
|
|
|
|
function openTool(callId, name, state) {
|
|
closeOpenRows("tool")
|
|
addRow("tool", "tool", state)
|
|
transcript.setProperty(lastIndex(), "name", name)
|
|
transcript.setProperty(lastIndex(), "callId", callId)
|
|
return lastIndex()
|
|
}
|
|
|
|
function submit(text) {
|
|
transcript.append({ kind: "message", role: "user", text: text, html: "", complete: true,
|
|
name: "", callId: "", state: "", detail: "", output: "",
|
|
exitCode: 0, images: "" })
|
|
agent.send(text)
|
|
}
|
|
|
|
// A turn is over: nothing else is coming, so nothing may keep spinning.
|
|
function closeAllRows() {
|
|
for (let i = 0; i < transcript.count; i++) {
|
|
const r = transcript.get(i)
|
|
if (r.complete)
|
|
continue
|
|
if (r.kind === "tool" && (r.state === "pending" || r.state === "ready" ||
|
|
r.state === "running"))
|
|
transcript.setProperty(i, "state", "unfinished")
|
|
transcript.setProperty(i, "complete", true)
|
|
}
|
|
}
|
|
|
|
// Markdown while it streams: the open answer is re-rendered with the
|
|
// engine's own renderer (the timer below), so the text fills in formatted
|
|
// instead of turning into markdown when the turn ends.
|
|
function renderStreaming() {
|
|
for (let i = transcript.count - 1; i >= 0; i--) {
|
|
const r = transcript.get(i)
|
|
if (r.kind !== "message" || r.role !== "agent" || r.complete)
|
|
continue
|
|
const html = agent.renderMarkdown(r.text)
|
|
if (html !== r.html)
|
|
transcript.setProperty(i, "html", html)
|
|
return
|
|
}
|
|
}
|
|
|
|
// ---- sessions --------------------------------------------------------
|
|
function reloadSessions() { win.sessionsView = agent.sessions() }
|
|
|
|
// A stored session comes back as rows: the engine holds the transcript
|
|
// (AgentCore::history()), these rows only mirror it.
|
|
function replay() {
|
|
transcript.clear()
|
|
const rows = agent.history()
|
|
for (let i = 0; i < rows.length; i++)
|
|
transcript.append(rows[i])
|
|
chat.stickBottom = true
|
|
}
|
|
|
|
function switchSession(id) {
|
|
if (agent.switchSession(id))
|
|
win.reloadSessions()
|
|
}
|
|
|
|
function newSession() {
|
|
if (agent.newSession()) {
|
|
transcript.clear()
|
|
win.reloadSessions()
|
|
}
|
|
}
|
|
|
|
function deleteSession(id) {
|
|
if (agent.deleteSession(id))
|
|
win.reloadSessions()
|
|
}
|
|
|
|
// ---- provider, model, key --------------------------------------------
|
|
function openSettings() {
|
|
settings.load(agent.providers(), agent.provider, agent.model)
|
|
settings.open()
|
|
}
|
|
|
|
function applySettings(provider, model, key, temperature, topP, maxTokens) {
|
|
/* The parameters are stored first: useProvider() then picks up what
|
|
* was remembered for whichever provider it ends on. */
|
|
const perr = agent.setGenParams(provider, temperature, topP, maxTokens)
|
|
const err = perr.length ? perr : agent.useProvider(provider, model, key)
|
|
settings.note = err.length ? err
|
|
: "using " + agent.provider + " / " + agent.model
|
|
settings.choices = agent.modelChoices(provider)
|
|
settings.syncPick()
|
|
}
|
|
|
|
Component.onCompleted: win.reloadSessions()
|
|
|
|
// ---- the shell -------------------------------------------------------
|
|
header: ToolBar {
|
|
id: appBar
|
|
implicitHeight: Theme.headerHeight
|
|
background: Rectangle {
|
|
color: Theme.surface
|
|
Rectangle {
|
|
anchors.bottom: parent.bottom
|
|
width: parent.width
|
|
height: 1
|
|
color: Theme.border
|
|
}
|
|
}
|
|
RowLayout {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: Theme.pad
|
|
anchors.rightMargin: Theme.gap
|
|
spacing: Theme.gap
|
|
|
|
// The docked sidebar has its own title: only the sliding one needs
|
|
// a way in.
|
|
ToolButton {
|
|
objectName: "navButton"
|
|
visible: win.compact
|
|
implicitWidth: Theme.touchTarget
|
|
implicitHeight: Theme.touchTarget
|
|
ToolTip.visible: hovered && !win.compact
|
|
ToolTip.text: "Settings and plan"
|
|
contentItem: ThemeIcon {
|
|
name: "application-menu"
|
|
glyph: "☰"
|
|
size: 22
|
|
color: parent.hovered ? Theme.text : Theme.textDim
|
|
}
|
|
background: Rectangle {
|
|
radius: Theme.radiusSm
|
|
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
|
}
|
|
onClicked: win.navOpen = !win.navOpen
|
|
}
|
|
|
|
Text {
|
|
visible: !win.compact
|
|
text: "CST-agent"
|
|
color: Theme.text
|
|
font.pixelSize: Theme.bodySize
|
|
font.bold: true
|
|
}
|
|
Text {
|
|
Layout.fillWidth: true
|
|
elide: Text.ElideMiddle
|
|
text: agent.workspace.length ? agent.workspace : "(no workspace)"
|
|
color: Theme.textDim
|
|
font.pixelSize: Theme.smallSize
|
|
}
|
|
Text {
|
|
objectName: "statusText"
|
|
visible: win.statusText.length > 0 && !win.compact
|
|
text: win.statusText
|
|
color: Theme.textFaint
|
|
font.pixelSize: Theme.smallSize
|
|
elide: Text.ElideRight
|
|
Layout.maximumWidth: 260
|
|
}
|
|
BusyIndicator {
|
|
running: agent.running
|
|
visible: agent.running
|
|
implicitWidth: 22
|
|
implicitHeight: 22
|
|
}
|
|
ToolButton {
|
|
objectName: "settingsButton"
|
|
implicitWidth: Theme.touchTarget
|
|
implicitHeight: Theme.touchTarget
|
|
ToolTip.visible: hovered
|
|
ToolTip.text: "Provider, model and key"
|
|
contentItem: ThemeIcon {
|
|
name: "settings-configure"
|
|
glyph: "⚙"
|
|
size: 22
|
|
color: parent.hovered ? Theme.text : Theme.textDim
|
|
}
|
|
background: Rectangle {
|
|
radius: Theme.radiusSm
|
|
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
|
}
|
|
onClicked: win.openSettings()
|
|
}
|
|
ToolButton {
|
|
objectName: "clearButton"
|
|
enabled: transcript.count > 0 && !agent.running
|
|
implicitWidth: win.compact ? Theme.touchTarget : 56
|
|
implicitHeight: Theme.touchTarget
|
|
ToolTip.visible: hovered
|
|
ToolTip.text: "Clear the transcript (Ctrl+L) — the session file keeps its history"
|
|
contentItem: Text {
|
|
text: "Clear"
|
|
color: parent.enabled ? (parent.hovered ? Theme.text : Theme.textDim) : Theme.textFaint
|
|
font.pixelSize: Theme.smallSize
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
background: Rectangle {
|
|
radius: Theme.radiusSm
|
|
color: parent.hovered && parent.enabled ? Theme.surfaceHi : "transparent"
|
|
}
|
|
onClicked: {
|
|
transcript.clear()
|
|
win.statusText = ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
anchors.fill: parent
|
|
spacing: 0
|
|
|
|
ShellSidebar {
|
|
id: dockedSidebar
|
|
objectName: "dockedSidebar"
|
|
visible: !win.compact
|
|
Layout.fillHeight: true
|
|
Layout.preferredWidth: win.sidebarWide
|
|
}
|
|
|
|
// Drag the divider: a mouse affordance that costs nothing on touch
|
|
// (the handle is only there while the sidebar is docked).
|
|
Item {
|
|
id: divider
|
|
visible: !win.compact
|
|
Layout.fillHeight: true
|
|
implicitWidth: 5
|
|
|
|
Rectangle {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
width: 1
|
|
height: parent.height
|
|
color: dividerMa.pressed ? Theme.accentDim
|
|
: dividerMa.containsMouse ? Theme.surfaceHi : Theme.border
|
|
}
|
|
MouseArea {
|
|
id: dividerMa
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.SplitHCursor
|
|
property real startX: 0
|
|
property int startW: 0
|
|
onPressed: function (mouse) {
|
|
startX = mouse.x
|
|
startW = win.sidebarWide
|
|
}
|
|
onPositionChanged: function (mouse) {
|
|
if (pressed)
|
|
win.sidebarWidth = Math.max(200, Math.min(560, startW + Math.round(mouse.x - startX)))
|
|
}
|
|
onDoubleClicked: win.sidebarWidth = 0
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
spacing: 0
|
|
|
|
ChatView {
|
|
id: chat
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
model: transcript
|
|
running: agent.running
|
|
}
|
|
|
|
Composer {
|
|
id: composer
|
|
Layout.fillWidth: true
|
|
Layout.leftMargin: win.compact ? Theme.gap : Theme.gapL
|
|
Layout.rightMargin: win.compact ? Theme.gap : Theme.gapL
|
|
Layout.bottomMargin: win.compact ? Theme.gap : Theme.gapL
|
|
running: agent.running
|
|
compact: win.compact
|
|
attachments: agent.pending
|
|
onSubmitted: function (text) { win.submit(text) }
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- compact: the sidebar slides in over the transcript --------------
|
|
/* Files dropped anywhere on the window: the engine copies them next to
|
|
* the session and the next prompt carries them. The picker and Ctrl+V in
|
|
* the composer land in the same list. */
|
|
DropArea {
|
|
id: dropZone
|
|
objectName: "dropZone"
|
|
anchors.fill: parent
|
|
onDropped: function (drop) {
|
|
if (!drop.hasUrls)
|
|
return
|
|
let note = ""
|
|
let n = 0
|
|
for (let i = 0; i < drop.urls.length; i++) {
|
|
const r = agent.attachFile(composer.localPath(drop.urls[i]))
|
|
if (r.length)
|
|
note = r
|
|
else
|
|
n++
|
|
}
|
|
composer.note = note.length ? note
|
|
: n + (n === 1 ? " file attached" : " files attached")
|
|
drop.accept()
|
|
}
|
|
}
|
|
|
|
// While something is dragged over the window, say what will happen.
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
visible: dropZone.containsDrag
|
|
color: "transparent"
|
|
border.width: 2
|
|
border.color: Theme.accent
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: "drop to attach"
|
|
color: Theme.accent
|
|
font.pixelSize: Theme.bodySize
|
|
font.bold: true
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
id: scrim
|
|
objectName: "navScrim"
|
|
visible: win.navOpen && win.compact
|
|
anchors.fill: parent
|
|
color: "#000000"
|
|
opacity: 0.45
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: win.navOpen = false
|
|
onWheel: function (wheel) { wheel.accepted = true }
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
id: navPanel
|
|
objectName: "navPanel"
|
|
visible: win.navOpen && win.compact
|
|
anchors {
|
|
left: parent.left
|
|
top: parent.top
|
|
bottom: parent.bottom
|
|
}
|
|
width: Math.min(320, win.width * 0.88)
|
|
color: Theme.surface
|
|
border.width: 1
|
|
border.color: Theme.border
|
|
x: win.navOpen && win.compact ? 0 : -width
|
|
Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } }
|
|
|
|
Flickable {
|
|
anchors.fill: parent
|
|
contentHeight: panelSidebar.implicitHeight
|
|
clip: true
|
|
ScrollBar.vertical: ScrollBar {}
|
|
|
|
ShellSidebar {
|
|
id: panelSidebar
|
|
width: parent.width
|
|
border.width: 0
|
|
}
|
|
}
|
|
}
|
|
|
|
ConsentDialog {
|
|
id: consent
|
|
onAnswered: function (choice) { agent.answerPermission(choice) }
|
|
}
|
|
|
|
SettingsDialog {
|
|
id: settings
|
|
onApplyRequested: function (provider, model, key, temperature, topP, maxTokens) {
|
|
win.applySettings(provider, model, key, temperature, topP, maxTokens)
|
|
}
|
|
}
|
|
|
|
AskDialog {
|
|
id: ask
|
|
onAnswered: function (text) { agent.answerQuestion(text) }
|
|
}
|
|
|
|
Shortcut { sequence: "Escape"; enabled: agent.running; onActivated: agent.cancel() }
|
|
Shortcut {
|
|
sequence: "Ctrl+L"
|
|
enabled: !agent.running
|
|
onActivated: {
|
|
transcript.clear()
|
|
win.statusText = ""
|
|
}
|
|
}
|
|
|
|
// ---- engine -> UI ----------------------------------------------------
|
|
/* The signal handlers only forward: every one of them has a function
|
|
* here, so the same path can be driven from --uitest (which is how the
|
|
* two-calls-of-one-tool case stays fixed). */
|
|
function engineAssistant(text) { win.appendDelta("message", "agent", text) }
|
|
function engineThink(text) { win.appendDelta("think", "agent", text) }
|
|
|
|
function engineToolEvent(phase, id, name, detail) {
|
|
win.closeOpenRows("tool")
|
|
let i = win.findTool(id, name)
|
|
if (phase === "open" || phase === "arg") {
|
|
if (i < 0)
|
|
i = win.openTool(id, name, "pending")
|
|
if (detail.length)
|
|
transcript.setProperty(i, "detail", transcript.get(i).detail + detail)
|
|
return
|
|
}
|
|
if (phase === "ready" || phase === "run") {
|
|
if (i < 0)
|
|
i = win.openTool(id, name, phase)
|
|
transcript.setProperty(i, "state", phase === "run" ? "running" : "queued")
|
|
transcript.setProperty(i, "detail", detail)
|
|
return
|
|
}
|
|
// done | denied
|
|
if (i < 0)
|
|
i = win.openTool(id, name, phase)
|
|
transcript.setProperty(i, "state", phase)
|
|
if (detail.length && transcript.get(i).output.length === 0)
|
|
transcript.setProperty(i, "output", detail)
|
|
}
|
|
|
|
function engineToolResult(id, name, exitCode, output) {
|
|
let i = win.findTool(id, name)
|
|
if (i < 0) /* a result whose call is not in the view: its own card */
|
|
i = win.openTool(id, name, "done")
|
|
transcript.setProperty(i, "output", output)
|
|
transcript.setProperty(i, "exitCode", exitCode)
|
|
transcript.setProperty(i, "state", "done")
|
|
transcript.setProperty(i, "complete", true)
|
|
}
|
|
|
|
function engineTurnFinished(rc, err) {
|
|
win.renderStreaming(); /* one last render while the row is still open */
|
|
win.closeAllRows()
|
|
if (err.length)
|
|
transcript.append({ kind: "note", role: "error", text: err, html: "",
|
|
complete: true, name: "", callId: "", state: "error",
|
|
detail: "", output: "", exitCode: 0 })
|
|
win.statusText = ""
|
|
}
|
|
|
|
/* The images the prompt just carried (AgentCore::promptImages): the
|
|
* composer has appended the user row, so they attach to it. */
|
|
function enginePromptImages(images) {
|
|
if (!images.length)
|
|
return
|
|
for (let i = transcript.count - 1; i >= 0; i--) {
|
|
const r = transcript.get(i)
|
|
if (r.kind === "message" && r.role === "user") {
|
|
transcript.setProperty(i, "images", images)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Images a tool produced, on the card of the call that made them. */
|
|
function engineToolImages(callId, images) {
|
|
if (!images.length)
|
|
return
|
|
const i = win.findTool(callId, "")
|
|
if (i >= 0)
|
|
transcript.setProperty(i, "images", images)
|
|
}
|
|
|
|
/* For --uitest: one transcript row, or null. */
|
|
function rowAt(i) {
|
|
return (i >= 0 && i < transcript.count) ? transcript.get(i) : null
|
|
}
|
|
|
|
/* For --uitest: the same row as JSON (a QObject's return value does not
|
|
* survive QMetaObject::invokeMethod, a string does). */
|
|
function rowJson(i) {
|
|
return JSON.stringify(rowAt(i))
|
|
}
|
|
|
|
/* While a turn runs the open answer is re-rendered a few times a second:
|
|
* markdown text fills in formatted rather than at the end of the turn. */
|
|
Timer {
|
|
interval: 120
|
|
repeat: true
|
|
running: agent.running
|
|
onTriggered: win.renderStreaming()
|
|
}
|
|
|
|
Connections {
|
|
target: agent
|
|
|
|
function onSessionChanged() { win.reloadSessions(); win.replay() }
|
|
function onSessionListChanged() { win.reloadSessions() }
|
|
|
|
function onAssistantDelta(text) { win.engineAssistant(text) }
|
|
function onThinkDelta(text) { win.engineThink(text) }
|
|
|
|
function onStatusChanged(text) { win.statusText = text }
|
|
|
|
function onToolEvent(phase, id, name, detail) {
|
|
win.engineToolEvent(phase, id, name, detail)
|
|
}
|
|
|
|
function onToolResult(id, name, exitCode, output) {
|
|
win.engineToolResult(id, name, exitCode, output)
|
|
}
|
|
|
|
function onToolImages(callId, images) {
|
|
win.engineToolImages(callId, images)
|
|
}
|
|
|
|
function onPromptImages(images) {
|
|
win.enginePromptImages(images)
|
|
}
|
|
|
|
function onPermissionRequested(tool, detail, preview) {
|
|
consent.tool = tool
|
|
consent.detail = detail
|
|
consent.preview = preview
|
|
consent.open()
|
|
}
|
|
|
|
function onQuestionAsked(question, options) {
|
|
ask.question = question
|
|
ask.optionText = options
|
|
ask.open()
|
|
}
|
|
|
|
function onTurnFinished(rc, err) { win.engineTurnFinished(rc, err) }
|
|
|
|
function onRunningChanged() {
|
|
if (!agent.running)
|
|
win.closeAllRows()
|
|
}
|
|
}
|
|
}
|