← 使用情境

長時間工作的進度與 AI 回答串流

匯出、匯入、轉檔與 AI 工作:跟著實際工作前進的進度條、一完成就出現的結果,以及邊寫邊出現的回答。

匯出、影片轉檔、產生報表、匯入資料和 AI 工作,可能要十秒到十分鐘。一個沒有任何回饋的轉圈圈,會讓人重新整理、再按一次,或乾脆放棄。顯示真實的進度,並在結果準備好的那一刻呈現,就能把人留在頁面上。AI 功能也一樣適用:即使工作在背景執行,也能把助理的回答邊寫邊串流出來。

使用者得到什麼

  • 跟著實際工作前進的進度條:「正在處理 10,000 列中的第 3,200 列」。
  • 工作一完成,下載連結或結果就出現,即使在另一個分頁。
  • AI 的回答一個字一個字出現,不必等很久才一次出現。
  • 可以放心離開頁面:完成時會通知他們。

用到的功能

  • 使用者驗證與 sendToUser,工作的更新只送給啟動它的人,而且送到每個分頁。
  • 私人頻道(private-job-<id>),用在多人一起追蹤同一個工作時。
  • 任何語言的 worker:Node.js、PHP、Python、Go、Ruby、Java 和 .NET 的官方 Pusher 伺服器函式庫都能從佇列 worker 發布。
  • 推播通知,在使用者關掉 App 後告訴他們長時間的工作已完成。

步驟 1:啟動工作,取得 ID

app.post('/exports', requireLogin, async (req, res) => {
  const job = await queue.add('export', { userId: req.user.id, filters: req.body });
  res.status(202).json({ jobId: job.id });
});

步驟 2:從 worker 回報進度

在有意義的階段回報進度,不要每一列都回報:

worker.process('export', async (job) => {
  const user = String(job.data.userId);
  const rows = await db.orders.query(job.data.filters);
  for (let i = 0; i < rows.length; i += 1000) {
    await writeChunk(rows.slice(i, i + 1000));
    await pusher.sendToUser(user, 'job-progress', { jobId: job.id, done: Math.min(i + 1000, rows.length), total: rows.length });
  }
  const url = await uploadAndSign();
  await pusher.sendToUser(user, 'job-done', { jobId: job.id, url });
});

用 pusher 套件的 Python worker 寫法相同:

pusher_client.send_to_user(str(user_id), "job-progress", {"jobId": job_id, "done": done, "total": total})

步驟 3:在瀏覽器裡顯示

使用者在連線上登入後(請看即時站內通知):

const { jobId } = await fetch('/exports', { method: 'POST', body: form }).then((r) => r.json());

pusher.user.bind('job-progress', (p) => {
  if (p.jobId === jobId) progress.set(p.done / p.total);
});
pusher.user.bind('job-done', (p) => {
  if (p.jobId === jobId) showDownload(p.url);
});

串流 AI 回答

當 AI 模型在背景工作裡撰寫回答時,把輸出分成小批轉給使用者,這樣一個很長的回答是幾十則訊息,而不是幾千則:

let buffer = '';
let last = Date.now();
for await (const chunk of llm.stream(prompt)) {
  buffer += chunk.text;
  if (Date.now() - last > 150 || buffer.length > 2000) {
    await pusher.sendToUser(user, 'ai-delta', { id: answerId, text: buffer });
    buffer = '';
    last = Date.now();
  }
}
await pusher.sendToUser(user, 'ai-done', { id: answerId, text: buffer });
pusher.user.bind('ai-delta', ({ id, text }) => answers.get(id).append(text));
pusher.user.bind('ai-done', ({ id, text }) => answers.get(id).append(text).finish());

也把完整的回答存在你的伺服器上,重新整理後才還在。

小提醒

  • 每次更新都是一則訊息。每 1–5% 或每 150 毫秒更新一次就很順,一個工作也只用幾十則訊息。
  • 在啟動工作之前先訂閱,或在頁面打開時查詢一次工作狀態,就不會漏掉在頁面連線之前送出的更新。
  • 如果使用者已經關掉分頁,工作完成時照推播通知指南發送推播。