Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/456-face-held-replies.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Face-to-face rooms: a Bot reply held for your approval shows only on its approval card, greyed, and becomes a room bubble only after you approve it; a rejected reply collapses to one grey "Not sent" line, also after a reload and in Past rooms. A decided card leaves no "Approved" / "Rejected" stub, the card names the Bot with its person ("Researcher (Alice) → the room"), and each Bot answers a person's line at most once ([#456](https://github.com/ThinkFlowLab/FlyBot/issues/456)).
49 changes: 25 additions & 24 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ var ZH = {
"Messages waiting for your approval": "等你批准的消息",
"Waiting for approval": "等待批准",
"Not sent": "未发出",
"{name} · Not sent": "{name} · 未发出",
"Sent to {to}": "已发给 {to}",
"Auto-sent to {to}": "已自动发给 {to}",
"Not sent: delivery failed": "未发出:发送失败",
Expand Down Expand Up @@ -3163,7 +3164,7 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default}

.bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0}
.bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere}
.bt-bubble-held{background:transparent;border:1px dashed var(--bt-line-2);color:var(--bt-ink-2)}
.bt-event.bt-not-sent{gap:6px;color:var(--bt-ink-3)}
.bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px}
.bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px}
.bt-bubble [class*="_markdown"]>*+*{margin-top:10px}
Expand Down Expand Up @@ -5307,7 +5308,28 @@ function isHeldLine(room2, text, bot) {
return held !== "" && (held === line || held.length >= 799 && line.startsWith(held));
});
}
function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) {
var HELD_SENDING_MS = 6e4;
function heldLineState(room2, { seq, botId, text } = {}, now = Date.now()) {
if (room2?.origin !== "face-to-face" || !Array.isArray(room2.heldLines)) return void 0;
const said = String(text ?? "").trim();
const entry = room2.heldLines.filter((item) => seq !== void 0 && item?.seq !== void 0 ? item.seq === seq : item?.botId === botId && String(item?.text ?? "").trim() === said).at(-1);
if (entry === void 0) return void 0;
if (entry.status === "rejected") return "rejected";
if (entry.status === "sending" && now - (Number(entry.at) || 0) > HELD_SENDING_MS) return void 0;
if (entry.status !== "pending" && entry.status !== "sending") return void 0;
return room2.face?.status === "left" || room2.face?.status === "closed" ? "rejected" : "held";
}
function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi, seq }) {
const held = heldLineState(room2, { seq, botId: bot.id, text });
if (held === "rejected") {
return (0, import_react8.createElement)(
"div",
{ className: "bt-event bt-not-sent", "data-flybot": "held-not-sent", role: "status" },
(0, import_react8.createElement)(BotMark, { bot, size: 16 }),
(0, import_react8.createElement)("span", null, t("{name} · Not sent", { name: bot.name }))
);
}
if (held === "held" || isHeldLine(room2, text, bot)) return null;
const lead = (0, import_react8.createElement)(
"button",
{ type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) },
Expand All @@ -5319,27 +5341,6 @@ function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }
bot.name,
room2?.admin === bot.id ? (0, import_react8.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null
);
if (isHeldLine(room2, text, bot)) {
return (0, import_react8.createElement)(
"div",
{ className: "bt-group-msg" },
name,
(0, import_react8.createElement)(
"div",
{ className: "bt-astack", "data-lead": "" },
(0, import_react8.createElement)(
"div",
{ className: "bt-msg" },
(0, import_react8.createElement)(
"div",
{ className: "bt-msg-line" },
lead,
(0, import_react8.createElement)("div", { className: "bt-bubble bt-bubble-held", "data-flybot": "held-line", role: "status" }, t("Waiting for approval"))
)
)
)
);
}
return (0, import_react8.createElement)(
"div",
{ className: "bt-group-msg" },
Expand Down Expand Up @@ -5936,7 +5937,7 @@ function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi
useUi
});
}
return (0, import_react8.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi });
return (0, import_react8.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq });
}),
pending.map((id) => {
const bot = roster.byId[id];
Expand Down
44 changes: 32 additions & 12 deletions core/src/client/cells.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,12 +228,12 @@ function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) {
return h('div', { className: 'bt-event' }, parts)
}

// A Bot line that Bot Social holds for the owner's approval has its text in the approval card; the
// bubble keeps only the state, so the text shows once (#306). The host's pending list (`pendingApprovals`)
// A Bot line that Bot Social holds for the owner's approval has its text in the approval card only, and
// the room draws no bubble for it until Approve (#306, #456). The host's pending list (`pendingApprovals`)
// carries the held text whitespace-collapsed and capped at 800 characters (799 once trimmed), without a "To the room:" lead.
const heldForm = text => String(text ?? '').replace(/\s+/g, ' ').trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, '').trim()
// The card names the sending Bot in `from`; a bubble is held only when its own Bot sent the held text, so
// another Bot's identical words are not turned into "Waiting for approval" (#306). A card that names no sender
// another Bot's identical words are not hidden (#306). A card that names no sender
// matches by text alone, as before.
const sameName = (a, b) => String(a ?? '').replace(/\s+/g, ' ').trim().toLowerCase() === String(b ?? '').replace(/\s+/g, ' ').trim().toLowerCase()
export function isHeldLine(room, text, bot) {
Expand All @@ -248,19 +248,39 @@ export function isHeldLine(room, text, bot) {
})
}

// A face-room line my own Bot wrote, as the host tracks it on the room (`heldLines`, #456): `held` while it is
// being sent or waits for the owner's Approve / Reject, `rejected` once rejected, or when the room ended before
// anyone decided (it never left), so it reads the same after a reload and in Past rooms. A line drawn with its
// log seq is found by it; a late post (no seq) by its Bot and its exact text.
const HELD_SENDING_MS = 60_000
export function heldLineState(room, { seq, botId, text } = {}, now = Date.now()) {
if (room?.origin !== 'face-to-face' || !Array.isArray(room.heldLines)) return undefined
const said = String(text ?? '').trim()
const entry = room.heldLines.filter(item => (seq !== undefined && item?.seq !== undefined
? item.seq === seq
: item?.botId === botId && String(item?.text ?? '').trim() === said)).at(-1)
if (entry === undefined) return undefined
if (entry.status === 'rejected') return 'rejected'
// A send a restart cut off never got an answer: the line is drawn as before.
if (entry.status === 'sending' && now - (Number(entry.at) || 0) > HELD_SENDING_MS) return undefined
if (entry.status !== 'pending' && entry.status !== 'sending') return undefined
return room.face?.status === 'left' || room.face?.status === 'closed' ? 'rejected' : 'held'
}

// One Bot message inside a group chat: avatar, name (with its group role), bubble.
function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi }) {
function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi, seq }) {
const held = heldLineState(room, { seq, botId: bot.id, text })
// Rejected: one grey line where the bubble would have been, never the words (#456).
if (held === 'rejected') {
return h('div', { className: 'bt-event bt-not-sent', 'data-flybot': 'held-not-sent', role: 'status' },
h(BotMark, { bot, size: 16 }), h('span', null, t('{name} · Not sent', { name: bot.name })))
}
// Waiting: the words are on the owner's approval card only; the bubble comes after Approve (#456).
if (held === 'held' || isHeldLine(room, text, bot)) return null
const lead = h('button', { type: 'button', className: 'bt-lead', 'aria-label': t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) },
h(BotMark, { bot, size: 22 }))
const name = h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, bot.name,
room?.admin === bot.id ? h('span', { className: 'bt-group-role' }, t('Admin')) : null)
if (isHeldLine(room, text, bot)) {
return h('div', { className: 'bt-group-msg' }, name,
h('div', { className: 'bt-astack', 'data-lead': '' },
h('div', { className: 'bt-msg' },
h('div', { className: 'bt-msg-line' }, lead,
h('div', { className: 'bt-bubble bt-bubble-held', 'data-flybot': 'held-line', role: 'status' }, t('Waiting for approval'))))))
}
return h('div', { className: 'bt-group-msg' }, name,
h(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room?.id, msgKey, useUi, lead }))
}
Expand Down Expand Up @@ -859,7 +879,7 @@ export function GroupReplies({ sessionId, callId, done, content, roster, actions
useUi,
})
}
return h(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi })
return h(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : undefined, useUi, seq: reply.seq })
}),
pending.map((id) => {
const bot = roster.byId[id]
Expand Down
1 change: 1 addition & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ const ZH = {
'Messages waiting for your approval': '等你批准的消息',
'Waiting for approval': '等待批准',
'Not sent': '未发出',
'{name} · Not sent': '{name} · 未发出',
'Sent to {to}': '已发给 {to}',
'Auto-sent to {to}': '已自动发给 {to}',
'Not sent: delivery failed': '未发出:发送失败',
Expand Down
2 changes: 1 addition & 1 deletion core/src/client/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default}

.bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0}
.bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere}
.bt-bubble-held{background:transparent;border:1px dashed var(--bt-line-2);color:var(--bt-ink-2)}
.bt-event.bt-not-sent{gap:6px;color:var(--bt-ink-3)}
.bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px}
.bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px}
.bt-bubble [class*="_markdown"]>*+*{margin-top:10px}
Expand Down
2 changes: 1 addition & 1 deletion core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function install(rt) {
}
}),
// `parts`: the members' hidden group Sessions and their earlier segments, for usage.
rooms: Object.values(state.rooms).map(({ log, seen, botRounds, lateRounds, sessions, segments, ...room }) => {
rooms: Object.values(state.rooms).map(({ log, seen, botRounds, lateRounds, sessions, segments, faceLine, ...room }) => {
const parts = [...Object.values(sessions ?? {}), ...Object.values(segments ?? {}).flat().map(segment => segment.sessionId)]
const last = (log ?? []).filter(line => line.kind === undefined).at(-1)
const previewText = last ? unwrapUntrusted(last.text) : ''
Expand Down
57 changes: 53 additions & 4 deletions core/src/host/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,31 @@ const appendRoom = (room, line) => {
const stamped = { ...line, seq: room.seq }
room.log = [...room.log, stamped].slice(-ROOM_LOG_MAX)
void fanoutRoomLine(room, stamped)
return stamped
}

// One answer per Bot to a person's plain line in a face-to-face room (#295, #456). The room keeps the
// line's seq, the Bots that answered it and the turns it asked for; a Bot asked again for the same line
// (the line delivered twice, or a turn that went on working and posts late after a second one) passes.
// A newer person line ends it, so a follow-up is always answered.
const lastPersonLine = (room) => {
const log = room.log ?? []
for (let at = log.length - 1; at >= 0; at -= 1) {
const line = log[at]
if (!line.kind && line.botId === undefined && line.authorKind !== 'bot') return line
}
return undefined
}
const lastPersonSeq = room => lastPersonLine(room)?.seq ?? 0
const FACE_ASKED_KEPT = 24
const currentFaceLine = (room, seq) => (room.faceLine !== undefined && room.faceLine.seq === seq && lastPersonSeq(room) === seq ? room.faceLine : undefined)
// `authorId`: a friend's line is only tracked once it is in the log (applyFaceLine logs it first).
function openFaceLine(room, authorId) {
const person = lastPersonLine(room)
const seq = person?.seq ?? 0
if (seq === 0 || (authorId !== undefined && person.authorId !== authorId)) return undefined
if (room.faceLine?.seq !== seq) room.faceLine = { seq, answered: [], asked: [] }
return seq
}

// Lines a face-room send already put in the log and on the wire; the round that answers them
Expand Down Expand Up @@ -296,6 +321,7 @@ export function install(rt) {
if (route) {
round.route = route
round.single = true
round.faceLine = openFaceLine(room)
speakers = lastFirst(speakers, route)
}
}
Expand Down Expand Up @@ -390,7 +416,7 @@ export function install(rt) {
}
// One answer for a line that is not for everyone (#299): once a Bot has answered, the Bots still waiting are not
// asked at all (no model call, no line), so three Bots never post three similar answers.
if (answeredAlready(round)) {
if (answeredAlready(round) || currentFaceLine(room, round.faceLine)?.answered.includes(member.id)) {
slot.status = 'passed'
round.passed.push(member.name)
continue
Expand All @@ -414,7 +440,11 @@ export function install(rt) {
round.shown?.push(member.id)
const text = session.brief === undefined ? prompt : `${session.brief}\n\n${prompt}`
if (!signal.aborted) room.seen[member.id] = room.seq
const result = await participant.runTurn({ room, member, sessionId: session.id, text, note: slot.note ?? 'your turn', signal, started: round.started, by: slot.by, origin: roundOrigins.get(round) })
const exchangeId = randomUUID()
// A turn asked for this line may post late; it still counts as this line's answer (#456).
const asked = currentFaceLine(room, round.faceLine)
if (asked !== undefined) asked.asked = [...asked.asked, exchangeId].slice(-FACE_ASKED_KEPT)
const result = await participant.runTurn({ room, member, sessionId: session.id, text, note: slot.note ?? 'your turn', signal, started: round.started, by: slot.by, origin: roundOrigins.get(round), exchangeId })
const reply = result === null || typeof result === 'object' ? result : stripOwnName(member.name, String(result ?? '').trim())
round.speaking = null
if (reply?.failure !== undefined) {
Expand Down Expand Up @@ -442,9 +472,18 @@ export function install(rt) {
if (reply?.missed) round.replies.push({ kind: 'missed', botId: member.id, name: member.name, color: member.color, time: Date.now() })
continue
}
const heard = currentFaceLine(room, round.faceLine)
// A second answer to the same line is dropped, never posted (#456).
if (heard?.answered.includes(member.id)) {
slot.status = 'passed'
round.passed.push(member.name)
continue
}
heard?.answered.push(member.id)
slot.status = 'posted'
appendRoom(room, rt.authoredRoomLine?.(room, { who: member.name, botId: member.id, authorId: member.id, authorKind: 'bot', text: reply }) ?? { who: member.name, botId: member.id, text: reply })
round.replies.push({ botId: member.id, name: member.name, color: member.color, text: reply, time: Date.now() })
const posted = appendRoom(room, rt.authoredRoomLine?.(room, { who: member.name, botId: member.id, authorId: member.id, authorKind: 'bot', text: reply }) ?? { who: member.name, botId: member.id, text: reply })
// The line's seq lets the chat find its held state (#456).
round.replies.push({ botId: member.id, name: member.name, color: member.color, text: reply, time: Date.now(), seq: posted.seq })
void rt.save().catch(() => {})
// Only the admin's "@all" calls everyone, so a member cannot set off a storm.
const leads = rt.isAdminOf(room, member.id)
Expand Down Expand Up @@ -517,6 +556,9 @@ export function install(rt) {
})),
// One pass: a plain line's speakers do not call each other on (#295), so Bots in two homes cannot loop.
...(isPlainFaceLine(room, opener) ? { single: true } : {}),
// A person's plain line, the friend's attested one too, is answered once per Bot (#456).
...(isPlainFaceLine(room, opener) ? { faceLine: openFaceLine(room) } : {}),
...(!isPlainFaceLine(room, opener) && opener.plain === true && isFaceRoom(room) ? { faceLine: openFaceLine(room, String(opener.human?.authorId ?? '')) } : {}),
speaking: null,
turns: 0,
replies: [],
Expand Down Expand Up @@ -720,6 +762,13 @@ export function install(rt) {
if (room === undefined || member === undefined || !room.members.includes(member.id)) return
const text = stripOwnName(member.name, reply)
if (isPass(text)) return
// A late answer to a face-room line this Bot already answered is dropped (#456).
const heard = isFaceRoom(room) && room.faceLine?.asked?.includes(info.exchangeId) ? currentFaceLine(room, room.faceLine.seq) : undefined
if (heard?.answered.includes(member.id)) {
rt.warn('ds-bot: %s already answered this line in %s; its second answer is not posted', member.name, room.name)
return
}
heard?.answered.push(member.id)
const quiet = (room.lateRounds ?? 0) >= ROOM_LATE_ROUNDS
if (!quiet) room.lateRounds = (room.lateRounds ?? 0) + 1
await rt.save()
Expand Down
6 changes: 5 additions & 1 deletion core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -997,7 +997,8 @@ export function install(rt) {
dropFace: roomId => rt.dropFaceRoom?.(roomId),
// `fn(room, line, { mentions })`: for the room owner's own typed line, `mentions` is the server handle of
// every Bot its "@" names (the picker's labels read against this room), so the transport can send the
// explicit list with the line (#293). Bot lines carry none.
// explicit list with the line (#293). Bot lines carry none. `fn` answers `{ held: true, approvalId }` for a
// Bot line the server holds for the owner's approval (#456); the room then shows it on its card only.
onFaceLine(fn) {
if (typeof fn !== 'function') { rt.faceRoomFanout = undefined; return () => {} }
const wrapped = (room, line) => {
Expand All @@ -1007,6 +1008,9 @@ export function install(rt) {
rt.faceRoomFanout = wrapped
return () => { if (rt.faceRoomFanout === wrapped) rt.faceRoomFanout = undefined }
},
// The owner's Approve / Reject of a held face-room line (#456), by its Bot Social approval id: an
// approved line is a bubble from then on, a rejected one a grey "Not sent" line.
settleHeld: (approvalId, decision) => rt.settleHeldLine?.(approvalId, decision) === true,
postNotice: (roomId, text) => {
const room = rt.roomOf(roomId)
if (!room || typeof rt.postToRoom !== 'function') return
Expand Down
Loading
Loading