ask_operator: multi-select + free-text fallback

ask_operator now accepts a multi: bool. when true and options is
non-empty, the dashboard renders the choices as checkboxes — operator
picks any subset, answer comes back as a ', '-joined string. when
false (default), options are radio buttons.

independent of multi, a free-text input ('or type your own…') is
always rendered alongside options so the operator is never trapped
by an incomplete list. submit merges checked options + free text into
the single 'answer' field.

schema migration: operator_questions grows a multi INTEGER column
with a one-shot ALTER TABLE on open. backward compatible — old rows
default to 0 (not multi).

prompt + mcp tool description updated; existing dashboard css for
.qform was rewritten around the new vertical layout.
This commit is contained in:
müde 2026-05-15 19:52:44 +02:00
parent c337cc06f8
commit 8344dd9ab7
7 changed files with 130 additions and 35 deletions

View file

@ -205,20 +205,46 @@
method: 'POST', action: '/answer-question/' + q.id,
class: 'qform', 'data-async': '',
});
let input;
if (q.options && q.options.length) {
input = el('select', { name: 'answer', required: '' });
input.append(el('option', { value: '', disabled: '', selected: '' }, 'choose…'));
const hasOptions = q.options && q.options.length;
const isMulti = !!q.multi && hasOptions;
const freeText = el('input', {
type: 'text', name: 'answer-free',
placeholder: hasOptions ? 'or type your own…' : 'your answer',
autocomplete: 'off',
});
const optionGroup = el('div', { class: 'q-options' });
if (hasOptions) {
for (const opt of q.options) {
input.append(el('option', { value: opt }, opt));
const inputType = isMulti ? 'checkbox' : 'radio';
const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8);
const input = el('input', { type: inputType, name: 'choice', value: opt, id });
const label = el('label', { for: id }, ' ' + opt);
optionGroup.append(el('div', { class: 'q-option' }, input, label));
}
} else {
input = el('input', {
name: 'answer', type: 'text', required: '',
placeholder: 'your answer', autocomplete: 'off',
});
}
f.append(input, el('button', { type: 'submit', class: 'btn btn-approve' }, '▸ ANSW3R'));
// On submit, build the final `answer` field from selected
// options + free-text, joined by ', '. This lets the operator
// pick options AND add free text in the same form.
f.addEventListener('submit', (ev) => {
const parts = [];
for (const cb of f.querySelectorAll('input[name="choice"]:checked')) {
parts.push(cb.value);
}
const ft = (freeText.value || '').trim();
if (ft) parts.push(ft);
const merged = parts.join(', ');
// Replace the existing hidden `answer` (if any) with the merged value.
const existing = f.querySelector('input[name="answer"]');
if (existing) existing.remove();
f.append(el('input', { type: 'hidden', name: 'answer', value: merged }));
if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); }
}, true);
if (hasOptions) f.append(optionGroup);
f.append(
el('div', { class: 'q-free' }, freeText),
el('button', { type: 'submit', class: 'btn btn-approve' },
isMulti ? '▸ ANSW3R · ' + (q.options.length) + ' opts' : '▸ ANSW3R'),
);
li.append(f);
ul.append(li);
}

View file

@ -292,18 +292,36 @@ summary:hover { color: var(--purple); }
white-space: pre-wrap;
word-break: break-word;
}
.qform { display: flex; gap: 0.6em; align-items: stretch; margin-top: 0.3em; }
.qform input, .qform select {
.qform {
display: flex;
flex-direction: column;
gap: 0.5em;
margin-top: 0.4em;
}
.qform .q-options {
display: flex;
flex-direction: column;
gap: 0.25em;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.4em 0.6em;
}
.qform .q-option label { cursor: pointer; user-select: none; }
.qform .q-option input { margin-right: 0.4em; accent-color: var(--amber); }
.qform .q-free { display: flex; }
.qform .q-free input {
flex: 1;
font-family: inherit;
font-size: 1em;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
padding: 0.4em 0.6em;
flex: 1;
}
.qform input::placeholder { color: var(--muted); }
.qform input:focus, .qform select:focus { outline: 1px solid var(--amber); }
.qform .q-free input::placeholder { color: var(--muted); }
.qform .q-free input:focus { outline: 1px solid var(--amber); }
.qform button { align-self: flex-start; }
.inbox {
background: var(--bg-elev);
border: 1px solid var(--border);

View file

@ -182,9 +182,16 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse
},
}
}
ManagerRequest::AskOperator { question, options } => {
tracing::info!(%question, ?options, "manager: ask_operator");
match coord.questions.submit(MANAGER_AGENT, question, options) {
ManagerRequest::AskOperator {
question,
options,
multi,
} => {
tracing::info!(%question, ?options, multi, "manager: ask_operator");
match coord
.questions
.submit(MANAGER_AGENT, question, options, *multi)
{
Ok(id) => {
tracing::info!(%id, "operator question queued");
ManagerResponse::QuestionQueued { id }

View file

@ -25,12 +25,29 @@ CREATE INDEX IF NOT EXISTS idx_operator_questions_pending
ON operator_questions (id) WHERE answered_at IS NULL;
";
/// Add the `multi` column to pre-existing databases. `ALTER TABLE ADD COLUMN`
/// has no `IF NOT EXISTS` form in sqlite, so we check pragma_table_info first.
fn ensure_multi_column(conn: &Connection) -> Result<()> {
let has: bool = conn
.prepare("SELECT 1 FROM pragma_table_info('operator_questions') WHERE name = 'multi'")?
.exists([])?;
if !has {
conn.execute_batch(
"ALTER TABLE operator_questions ADD COLUMN multi INTEGER NOT NULL DEFAULT 0;",
)
.context("add operator_questions.multi column")?;
}
Ok(())
}
#[derive(Debug, Clone, Serialize)]
#[allow(clippy::doc_markdown)]
pub struct OpQuestion {
pub id: i64,
pub asker: String,
pub question: String,
pub options: Vec<String>,
pub multi: bool,
pub asked_at: i64,
pub answered_at: Option<i64>,
pub answer: Option<String>,
@ -51,18 +68,25 @@ impl OperatorQuestions {
.with_context(|| format!("open operator_questions db {}", path.display()))?;
conn.execute_batch(SCHEMA)
.context("apply operator_questions schema")?;
ensure_multi_column(&conn).context("migrate operator_questions.multi")?;
Ok(Self {
conn: Mutex::new(conn),
})
}
pub fn submit(&self, asker: &str, question: &str, options: &[String]) -> Result<i64> {
pub fn submit(
&self,
asker: &str,
question: &str,
options: &[String],
multi: bool,
) -> Result<i64> {
let conn = self.conn.lock().unwrap();
let options_json = serde_json::to_string(options).unwrap_or_else(|_| "[]".into());
conn.execute(
"INSERT INTO operator_questions (asker, question, options_json, asked_at)
VALUES (?1, ?2, ?3, ?4)",
params![asker, question, options_json, now_unix()],
"INSERT INTO operator_questions (asker, question, options_json, multi, asked_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![asker, question, options_json, i64::from(multi), now_unix()],
)?;
Ok(conn.last_insert_rowid())
}
@ -95,7 +119,7 @@ impl OperatorQuestions {
pub fn get(&self, id: i64) -> Result<Option<OpQuestion>> {
let conn = self.conn.lock().unwrap();
conn.query_row(
"SELECT id, asker, question, options_json, asked_at, answered_at, answer
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer
FROM operator_questions WHERE id = ?1",
params![id],
row_to_question,
@ -107,7 +131,7 @@ impl OperatorQuestions {
pub fn pending(&self) -> Result<Vec<OpQuestion>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, asker, question, options_json, asked_at, answered_at, answer
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer
FROM operator_questions
WHERE answered_at IS NULL
ORDER BY id ASC",
@ -121,14 +145,16 @@ impl OperatorQuestions {
fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result<OpQuestion> {
let options_json: String = row.get(3)?;
let options: Vec<String> = serde_json::from_str(&options_json).unwrap_or_default();
let multi: i64 = row.get(4)?;
Ok(OpQuestion {
id: row.get(0)?,
asker: row.get(1)?,
question: row.get(2)?,
options,
asked_at: row.get(4)?,
answered_at: row.get(5)?,
answer: row.get(6)?,
multi: multi != 0,
asked_at: row.get(5)?,
answered_at: row.get(6)?,
answer: row.get(7)?,
})
}