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/1629-busy-ack-natural.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The busy-ack bubble no longer repeats one canned sentence. While a Bot is working, a new message gets a short one-line ack composed on the spot from what you wrote and what the Bot is doing (e.g. 「看到你说「帮我改周报」了,我正在跑命令,弄完马上接上。」), in the language of your message, with varied wording that never repeats the previous ack — still instant, no extra model call ([#1629](https://github.com/ThinkFlowLab/FlyBot/issues/1629)).
4 changes: 2 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -838,7 +838,7 @@ var ZH = {
"Message actions": "消息操作",
Download: "下载",
"Thought it through": "思考过程",
"Got it — I’ll answer properly once I finish what’s in progress.": "收到,我先记着,手头忙完再详细回你。",
"Seen — I’ll get to this right after the current step.": "看到了,这步做完就接着处理你这条。",
"Earlier messages": "更早的消息",
"Loading earlier messages…": "正在加载更早的消息…",
"Couldn’t load earlier messages · Retry": "更早的消息没加载出来 · 重试",
Expand Down Expand Up @@ -9815,7 +9815,7 @@ var SIDEBAR_CSS = `
`;

// src/client/busy-ack-logic.js
var BUSY_ACK_KEY = "Got it — I’ll answer properly once I finish what’s in progress.";
var BUSY_ACK_KEY = "Seen — I’ll get to this right after the current step.";
function pendingBusyPlacement(submissions) {
if (!Array.isArray(submissions)) return false;
return submissions.some((item) => item?.placement === "queued" || item?.placement === "steering");
Expand Down
2 changes: 1 addition & 1 deletion core/src/client/busy-ack-logic.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Pure busy-ack helpers (#1584). Kept free of React so unit tests import them without a DOM.

export const BUSY_ACK_KEY = 'Got it — I’ll answer properly once I finish what’s in progress.'
export const BUSY_ACK_KEY = 'Seen — I’ll get to this right after the current step.'

export function pendingBusyPlacement(submissions) {
if (!Array.isArray(submissions)) return false
Expand Down
2 changes: 1 addition & 1 deletion core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -785,7 +785,7 @@ const ZH = {
'Message actions': '消息操作',
Download: '下载',
'Thought it through': '思考过程',
'Got it — I’ll answer properly once I finish what’s in progress.': '收到,我先记着,手头忙完再详细回你。',
'Seen — I’ll get to this right after the current step.': '看到了,这步做完就接着处理你这条。',
'Earlier messages': '更早的消息',
'Loading earlier messages…': '正在加载更早的消息…',
'Couldn’t load earlier messages · Retry': '更早的消息没加载出来 · 重试',
Expand Down
152 changes: 140 additions & 12 deletions core/src/host/busy-ack.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,171 @@
// gets a short spoken acknowledgment on the live payload immediately. Steer admits
// to next-step, but a blocking job wait may never yield a step (#1509), so without
// this the chat stays silent until the wait ends.
export const BUSY_ACK_EN = 'Got it — I’ll answer properly once I finish what’s in progress.'
export const BUSY_ACK_ZH = '收到,我先记着,手头忙完再详细回你。'
//
// The line is composed here, deterministically, from the message itself and what the
// turn is doing right now (its running tool), not a fixed sentence (#1629). A model
// call would wait behind the very turn that is busy, cost a call per message and need
// a fallback anyway; a template is instant, free and cannot fail. Wording rotates and
// never repeats the previous ack of the same chat; the language follows the message.
export const COALESCE_MS = 3_000
export const TOPIC_MAX = { zh: 16, en: 36 }

export const busyAckText = (language) => (/chinese|简体|\bzh\b/i.test(String(language ?? '')) ? BUSY_ACK_ZH : BUSY_ACK_EN)
const CJK = /[\u3400-\u9fff\uf900-\ufaff]/g

/** 'zh' when the message is mostly Chinese, 'en' when mostly Latin, else the UI language. */
export function ackLanguage(text, uiLanguage) {
const value = String(text ?? '')
const cjk = (value.match(CJK) ?? []).length
const latin = (value.match(/[A-Za-z]/g) ?? []).length
if (cjk > 0 && cjk * 2 >= latin) return 'zh'
if (latin > 0) return 'en'
return /chinese|简体|\bzh\b/i.test(String(uiLanguage ?? '')) ? 'zh' : 'en'
}

/** A short topic out of the message: first line, trimmed, clipped; '' when too thin to echo. */
export function topicOf(text, lang) {
const line = String(text ?? '').split(/\r?\n/).map(part => part.trim()).find(Boolean) ?? ''
const clean = line.replace(/\s+/g, ' ').replace(/^[@#]\S+\s*/, '').replace(/[。!?!?.,,、~~…]+$/u, '').trim()
if (clean === '' || /^(hi|hello|hey|ok|okay|嗯+|好的?|在吗|在不在|\?+|?+)$/i.test(clean)) return ''
const max = TOPIC_MAX[lang] ?? TOPIC_MAX.en
const chars = [...clean]
if (chars.length <= max) return clean
let cut = chars.slice(0, max - 1).join('')
if (lang === 'en' && cut.lastIndexOf(' ') > max / 2) cut = cut.slice(0, cut.lastIndexOf(' '))
return `${cut.trimEnd()}…`
}

const TOOL_WORDS = [
[/^(bash|shell|exec|run|command|terminal)$/i, { zh: '跑命令', en: 'running a command' }],
[/^(wait|job|poll|await)$/i, { zh: '等一个任务跑完', en: 'waiting on a job' }],
[/^(search|grep|glob|find)$/i, { zh: '查资料', en: 'searching' }],
[/^(fetch|browse|browser|web|http|url)$/i, { zh: '看网页', en: 'reading a page' }],
[/^(write|edit|patch|apply)$/i, { zh: '改文件', en: 'editing files' }],
[/^(read|cat|open|view|ls)$/i, { zh: '翻文件', en: 'reading files' }],
[/^(message|delegate|subagent|agent)$/i, { zh: '和别的 Bot 对接', en: 'checking with another Bot' }],
[/^(task|todo|plan)$/i, { zh: '整理任务', en: 'sorting out tasks' }],
]

/** What the running tool is, in a few words; undefined with no tool. */
export function doingOf(tool, lang) {
if (typeof tool !== 'string' || tool.trim() === '' || tool === 'ask_user') return undefined
// Match whole name parts (`web_fetch` → web, fetch) so `frobnicate` is not "cat".
const parts = tool.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
const hit = TOOL_WORDS.find(([pattern]) => parts.some(part => pattern.test(part)) )
if (hit) return hit[1][lang]
return lang === 'zh' ? `用 ${tool}` : `using ${tool}`
}

const TEMPLATES = {
zh: {
topicDoing: [
'看到你说「{t}」了,我正在{d},弄完马上接上。',
'「{t}」收到,手上在{d},这步完了就来。',
'记下「{t}」了,{d}这边一结束就处理。',
],
topic: [
'看到「{t}」了,当前这步一完就回你。',
'「{t}」记下了,马上接着处理。',
'好,「{t}」这个我排上了,稍等一下。',
],
doing: [
'看到了,我正在{d},弄完就回你。',
'收到新消息,手上在{d},稍等片刻。',
'嗯,{d}这边一结束就回来。',
],
bare: [
'看到了,这步做完就回你。',
'新消息收到,稍等我一下。',
'好的,马上接上你这条。',
],
},
en: {
topicDoing: [
'Saw "{t}" — I’m {d} right now, back on it in a moment.',
'Noted "{t}" — still {d}, then it’s next.',
'Got "{t}" — I’ll get to it once I’m done {d}.',
],
topic: [
'Saw "{t}" — I’ll pick it up right after this step.',
'Noted "{t}", coming to it shortly.',
'"{t}" is next on my list — one moment.',
],
doing: [
'Seen — I’m {d}, back to you shortly.',
'New message noted; still {d}, one moment.',
'On it as soon as I’m done {d}.',
],
bare: [
'Seen — back to you right after this step.',
'Noted, one moment.',
'Picking this up as soon as the current step ends.',
],
},
}

/**
* One short ack line for `message` while the turn is doing `tool`. `seed` picks the
* variant; `previous` is never returned again (the next variant is taken instead).
*/
export function composeBusyAck({ message, tool, uiLanguage, seed = 0, previous } = {}) {
const lang = ackLanguage(message, uiLanguage)
const topic = topicOf(message, lang)
const doing = doingOf(tool, lang)
const kind = topic && doing ? 'topicDoing' : topic ? 'topic' : doing ? 'doing' : 'bare'
const pool = TEMPLATES[lang][kind]
const fill = index => pool[index % pool.length].replaceAll('{t}', topic).replaceAll('{d}', doing ?? '')
const start = Math.abs(Math.trunc(seed)) % pool.length
for (let step = 0; step < pool.length; step += 1) {
const text = fill(start + step)
if (text !== previous) return text
}
return fill(start)
}

export function shouldNoteBusyAck({ sourceKind, turnRunning, ownChat }) {
return sourceKind === 'user' && turnRunning === true && ownChat === true
}

const textOf = (content) => {
if (typeof content === 'string') return content
if (!Array.isArray(content)) return ''
return content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text).join('')
}

export function install(rt) {
const { ctx } = rt
/** @type {Map<string, { text: string, at: number }>} */
const acks = new Map()
/** Last ack text per chat, kept across clears so consecutive acks differ. */
const lastText = new Map()
let counter = 0

const turnRunning = sessionId => rt.live?.get(sessionId)?.turn !== undefined
const ownChat = sessionId => rt.chatOwners?.has(sessionId) === true

const note = (sessionId) => {
const note = (sessionId, message) => {
const now = Date.now()
const existing = acks.get(sessionId)
if (existing && now - existing.at < COALESCE_MS) {
existing.at = now
return existing
}
const entry = { text: busyAckText(rt.uiLanguage?.()), at: now }
counter += 1
const text = composeBusyAck({
message,
tool: rt.live?.get(sessionId)?.tool,
uiLanguage: rt.uiLanguage?.(),
seed: counter + now,
previous: lastText.get(sessionId),
})
lastText.set(sessionId, text)
const entry = { text, at: now }
acks.set(sessionId, entry)
return entry
}

const clear = (sessionId) => { acks.delete(sessionId) }

const textOfAssistant = (content) => {
if (!Array.isArray(content)) return ''
return content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text).join('')
}

ctx.on('session/event', (session, event) => {
const id = session?.id
if (id === undefined || event?.type === undefined) return
Expand All @@ -48,11 +176,11 @@ export function install(rt) {
turnRunning: turnRunning(id),
ownChat: ownChat(id),
})) return
note(id)
note(id, textOf(event.data?.content ?? event.data?.message?.content))
return
}
if (event.type === 'assistant/message') {
if (textOfAssistant(event.data?.message?.content).trim() !== '') clear(id)
if (textOf(event.data?.message?.content).trim() !== '') clear(id)
return
}
if (event.type === 'turn/end') clear(id)
Expand Down
2 changes: 1 addition & 1 deletion core/test/busy-ack-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ test('busyAckVisible prefers host or pending ack and hides once spoken text stre
assert.equal(busyAckVisible({ hostAck: { text: '收到' }, pendingBusy: false, streamingText: false }), true)
assert.equal(busyAckVisible({ hostAck: null, pendingBusy: true, streamingText: false }), true)
assert.equal(busyAckVisible({ hostAck: { text: '收到' }, pendingBusy: true, streamingText: true }), false)
assert.equal(BUSY_ACK_KEY.includes('Got it'), true)
assert.equal(BUSY_ACK_KEY.includes('answer properly once I finish'), false)
})
Loading
Loading