The settings dialog could pick a preset, a model and a key and nothing else.
Two things were missing: a provider the presets do not know, and what the
model is asked to do while it answers. Both live under "Advanced options"
now, next to the entry they belong to.
Custom providers (engine)
- cst_models_user_set_provider() / cst_models_user_drop_provider() write the
engine's own user layer, <config>/cst-agent/models.json:
{"providers": {"myllm": {"api_base": ..., "key_env": ...,
"default_model": ..., "models": [...]}}}
and reload the registry, so the provider is at once in the provider list,
in the wire models[] and in the CLI's /provider -- the file a hand-written
override would go into is the same one, so the GUI and an editor agree.
The name is checked (1-31 of letters, digits, '.', '_', '-'), a preset's
name overrides that preset (a self-hosted endpoint, a proxy), everything
else in the file (model rules) is kept, and a file that is not a JSON
object is never overwritten. Dropping only removes the user's own entry.
- AgentCore::providers() gained "custom" (source == "user"), and
addProvider() / removeProvider() drive the two calls above.
Model parameters (engine + wire)
- cst_agent_config_t and cst_llm_request_t carry temperature, top_p and
max_tokens. 0 means the field is not sent at all -- an absent field is the
provider's own default, which "temperature": 0 is not (deterministic
decoding is a real choice), so cst_llm_build_body() only adds what was set.
tests/test_llm_body.c:test_gen_params pins both halves.
- settings.json keeps them per provider ("params": {...}) via
cst_settings_remember_params(); cst_settings_remember() carries them along
with the provider choice, and cst_agent_create() reads them, so a CLI or
TUI run sends what the window was set to. An all-zero config (the CLI)
leaves what the GUI saved alone.
- AgentCore::genParams() / setGenParams(), and useProvider() applies both the
remembered parameters of the provider it lands on and a base URL that was
edited in the dialog.
The dialog
- Rewritten around one scrolling body with a fixed footer: a long model list
used to push the key field and the buttons out of the dialog. The model
chips are filtered (a filter field appears past twelve) and capped at 40
with "... +N more", which is what a router with hundreds of models needs.
- "Advanced options" folds out the provider entry (name, base URL, key
variable, default model, models; Save provider / Remove from models.json)
and the model parameters (temperature, top-p, max tokens; empty = not
sent). The fields follow the picked provider and are what gets saved, so a
hand-typed edit is never lost to a stale property.
Verified: --uitest 67/67 (15 new checks: parameters round-trip through the
engine, a provider added through the form and removed again, a bad name
refused, a preset not removable), --selftest, ctest cst-app-selftest, and
make check with the new body test.
Version 0.8.15.
mode = coding
608 lines
21 KiB
QML
608 lines
21 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 })
|
|
}
|
|
|
|
// 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 })
|
|
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 = ""
|
|
}
|
|
|
|
/* 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 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()
|
|
}
|
|
}
|
|
}
|