feat(ai): 设置里加「AI 生成队列」,看得见后台在算什么

队列本来是完全不可见的:页面上一句「排队生成中」说不出自己是下一个、第二十
个,还是已经放弃了——网关挂掉的时候,「还在生成」和「永远不会好」长得一模
一样。今天排查就是这么排的。

- GET /analysis/insight/queue 返回队列(running 在前,其次按优先级和年龄,
  和 worker 实际取任务的顺序一致)、已生成的解读、scope 名到中文标签的映射
  (前端不必再抄一份),以及消费者的限流配置
- POST /analysis/insight/queue/retry:手动把「已放弃」的重新排队,不等冷却。
  自动重试要等冷却是为了不去捶一个正在抽风的上游;人按下重试是他自己判断值得
  再试一次
- 页面在 设置 → AI 生成队列。插队的任务标「插队」——这是整个界面最想让人看见
  的一件事:为什么是它排在最前面
- 「已生成」单独列:队列空了意味着「没有待办」,不是「什么都没生成过」,
  没有这一节这两件事在界面上没法区分

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-09-01 16:02:05 +08:00
parent a2d6a5c57f
commit 05f55e695b
9 changed files with 551 additions and 6 deletions

View File

@@ -168,8 +168,23 @@ def insight():
@bp.route("/insight/queue", methods=["GET"])
@require_auth
def insight_queue():
"""What the coach still has to generate — for a progress indicator."""
"""The coach's queue: what is running, what is waiting, what gave up.
Also lists what has already been generated, because "nothing queued" and
"nothing generated" look the same from the queue alone and mean opposite
things.
"""
return jsonify({
"pending": ai_jobs.pending_count(g.user_id),
"enabled": ai_jobs.ENABLED,
"jobs": ai_jobs.list_jobs(g.user_id),
"insights": analysis_svc.list_insights(g.user_id),
"scopes": {name: scope.label for name, scope in scopes.SCOPES.items()},
"settings": ai_jobs.settings(),
})
@bp.route("/insight/queue/retry", methods=["POST"])
@require_auth
def insight_queue_retry():
"""Re-queue everything that gave up, without waiting out the cooldown."""
return jsonify({"pending": ai_jobs.retry_failed(g.user_id)})

View File

@@ -571,6 +571,22 @@ def _run_job(user_id, kind, subject):
jobs.set_runner(_run_job)
def list_insights(user_id, limit=60):
"""Stored answers, newest first — what the coach has actually produced."""
rows = query_all(
"SELECT kind, subject, model, upstream, created_at FROM ai_insights "
"WHERE user_id = ? ORDER BY created_at DESC",
[user_id],
)
return [{
"kind": r["kind"],
"subject": r["subject"],
"model": r["model"],
"upstream": r["upstream"],
"generatedAt": r["created_at"],
} for r in rows[:limit]]
def clear_insight_cache(user_id, kind=None):
if kind:
execute(

View File

@@ -197,6 +197,60 @@ def status_of(user_id, kind, subject):
}
def list_jobs(user_id, limit=60):
"""The queue as it stands, for the 设置 screen.
Ordered the way the worker will actually take them — priority, then age —
so the list reads as "what happens next" rather than as a table of rows.
Finished jobs come last: they are history, not queue.
"""
rows = query_all(
"SELECT * FROM ai_jobs WHERE user_id = ? "
"ORDER BY CASE status WHEN 'running' THEN 0 WHEN 'pending' THEN 1 "
"WHEN 'failed' THEN 2 ELSE 3 END, priority ASC, created_at ASC",
[user_id],
)
return [{
"kind": r["kind"],
"subject": r["subject"],
"status": r["status"],
"priority": r["priority"],
# The queue only distinguishes "the user is looking at this" from
# "backfill"; showing the raw number would mean explaining the scale.
"interactive": r["priority"] <= PRIORITY_INTERACTIVE,
"attempts": r["attempts"],
"maxAttempts": MAX_ATTEMPTS,
"error": r.get("error"),
"updatedAt": r.get("updated_at"),
} for r in rows[:limit]]
def settings():
"""What the consumer is configured to do, for the same screen."""
return {
"enabled": ENABLED,
"concurrency": MAX_CONCURRENT,
"gapSeconds": GAP_SECONDS,
"maxAttempts": MAX_ATTEMPTS,
"retryAfterSeconds": FAILED_RETRY_SECONDS,
}
def retry_failed(user_id):
"""Put every given-up job back in the queue, now, at the user's request.
The automatic retry waits out a cooldown so a flapping upstream is not
hammered; a person pressing 重试 has decided it is worth trying again.
"""
execute(
"UPDATE ai_jobs SET status = 'pending', attempts = 0, error = NULL, "
"holder = NULL, claimed_at = NULL, updated_at = ? "
"WHERE user_id = ? AND status = 'failed'",
[_iso(_now()), user_id],
)
return pending_count(user_id)
def pending_count(user_id=None):
sql = "SELECT COUNT(*) AS n FROM ai_jobs WHERE status IN ('pending', 'running')"
params = []

View File

@@ -1105,3 +1105,64 @@ class TestGatewayCourtesy:
def test_there_is_a_gap_between_jobs(self):
"""Back-to-back is what saturates a four-thread box."""
assert jobs.GAP_SECONDS > 0
class TestQueueScreen:
def test_queue_endpoint_requires_auth(self, client):
assert client.get("/api/analysis/insight/queue").status_code == 401
assert client.post("/api/analysis/insight/queue/retry").status_code == 401
def test_lists_jobs_labels_and_limits(self, client, auth, month):
client.get("/api/analysis/insight?scope=sleep", headers=auth)
body = client.get("/api/analysis/insight/queue", headers=auth).get_json()
assert body["pending"] >= 1
assert any(j["kind"] == "sleep" for j in body["jobs"])
assert body["scopes"]["sleep"] == "睡眠", "the UI must not restate the list"
assert body["settings"]["concurrency"] == jobs.MAX_CONCURRENT
def test_an_open_screen_is_marked_as_having_jumped_the_queue(
self, client, auth, month
):
jobs.enqueue(month["id"], "trends", "2026-08-30", priority=jobs.PRIORITY_PREFETCH)
client.get("/api/analysis/insight?scope=sleep", headers=auth)
by_kind = {j["kind"]: j for j in
client.get("/api/analysis/insight/queue", headers=auth).get_json()["jobs"]}
assert by_kind["sleep"]["interactive"] is True
assert by_kind["trends"]["interactive"] is False
def test_running_jobs_are_listed_before_waiting_ones(self, client, auth, month):
jobs.enqueue(month["id"], "health", "a")
jobs.enqueue(month["id"], "sleep", "b")
jobs._claim_next()
listed = client.get("/api/analysis/insight/queue", headers=auth).get_json()["jobs"]
assert listed[0]["status"] == "running"
def test_generated_answers_are_listed_separately(
self, client, auth, month, gateway, monkeypatch
):
"""An empty queue means "nothing left to do", not "nothing was done"
the two look the same without this."""
answer(monkeypatch, json.dumps({
"headline": "h", "points": [], "actions": [], "confidence": "low",
}, ensure_ascii=False))
analysis_svc.generate_scope_insight(month["id"], "sleep")
body = client.get("/api/analysis/insight/queue", headers=auth).get_json()
assert any(i["kind"] == "sleep" for i in body["insights"])
def test_retry_puts_given_up_jobs_back(self, client, auth, month):
jobs.enqueue(month["id"], "sleep", "s")
row = jobs._claim_next()
for _ in range(jobs.MAX_ATTEMPTS):
jobs._finish(row["id"], "boom")
assert jobs.status_of(month["id"], "sleep", "s")["status"] == "failed"
resp = client.post("/api/analysis/insight/queue/retry", headers=auth)
assert resp.status_code == 200
assert jobs.status_of(month["id"], "sleep", "s")["status"] == "pending"
assert jobs.status_of(month["id"], "sleep", "s")["attempts"] == 0
def test_the_queue_is_per_account(self, client, auth, month, make_user):
other = make_user("other@example.com")
jobs.enqueue(other["id"], "sleep", "s")
body = client.get("/api/analysis/insight/queue", headers=auth).get_json()
assert body["jobs"] == []

View File

@@ -0,0 +1,153 @@
/* AI 生成队列 —— 设置里的一个只读视图,看后台在算什么。 */
.aq-note {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 14px;
padding: 0.8rem 0.9rem;
margin-bottom: 1.25rem;
}
.aq-note p {
margin: 0;
font-size: 0.82rem;
line-height: 1.6;
color: var(--text-secondary);
}
.aq-limits {
margin-top: 0.5rem !important;
padding-top: 0.5rem;
border-top: 1px solid var(--grid);
font-size: 0.76rem !important;
color: var(--text-muted) !important;
}
.aq-off {
background: var(--accent-soft);
border-radius: 10px;
padding: 0.6rem 0.7rem;
margin-bottom: 1rem;
font-size: 0.82rem;
color: var(--text-secondary);
}
.aq-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.aq-head .sec-title { margin: 0; }
.aq-retry {
background: none;
border: none;
padding: 0;
font-size: 0.78rem;
font-weight: 600;
color: var(--accent);
cursor: pointer;
}
.aq-retry:disabled { color: var(--text-muted); cursor: default; }
.aq-list {
list-style: none;
margin: 0.4rem 0 0;
padding: 0;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
}
.aq-row {
padding: 0.6rem 0.85rem;
border-bottom: 1px solid var(--grid);
}
.aq-row:last-child { border-bottom: none; }
.aq-main {
display: flex;
align-items: center;
gap: 0.4rem;
}
.aq-name {
flex: 1;
font-size: 0.86rem;
font-weight: 600;
color: var(--text-primary);
}
/* The one thing this screen exists to make visible: why this row is first. */
.aq-jump {
font-size: 0.62rem;
font-weight: 700;
padding: 0.1rem 0.35rem;
border-radius: 5px;
color: var(--accent);
background: var(--accent-soft);
}
.aq-status {
font-size: 0.72rem;
font-weight: 600;
color: var(--text-muted);
display: inline-flex;
align-items: center;
gap: 0.28rem;
white-space: nowrap;
}
.aq-status-running { color: var(--accent); }
.aq-status-failed { color: var(--status-critical); }
.aq-status-done { color: var(--status-good); }
.aq-dot {
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: var(--accent);
animation: aq-pulse 1.1s ease-in-out infinite;
}
@keyframes aq-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.25; }
}
.aq-meta {
display: flex;
gap: 0.6rem;
margin-top: 0.15rem;
font-size: 0.7rem;
color: var(--text-muted);
}
/* The subject can be long (a metric window, an activity id); it truncates so
one odd row cannot widen the list past the screen. */
.aq-subject {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.aq-err {
margin: 0.3rem 0 0;
font-size: 0.7rem;
line-height: 1.45;
color: var(--status-critical);
word-break: break-word;
}
.aq-hint {
margin: 0.45rem 0 0;
font-size: 0.72rem;
color: var(--text-muted);
}

View File

@@ -0,0 +1,199 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { apiClient, AiJob, AiQueue, errorMessage, parseUtc } from '../services/api';
import Screen from '../components/Screen';
import Skeleton from '../components/Skeleton';
import './AiQueue.css';
/* Fast enough that a job starting or finishing shows up while you are looking
at the screen — this is the one place whose whole purpose is watching the
queue move. Stopped on unmount so it does not poll from a background tab. */
const POLL_MS = 5000;
const STATUS_LABEL: Record<string, string> = {
running: '生成中',
pending: '排队中',
failed: '已放弃',
done: '已完成',
};
function relative(value: string | null): string {
const at = parseUtc(value);
if (!at) return '';
const seconds = Math.round((Date.now() - at.getTime()) / 1000);
if (seconds < 60) return `${Math.max(seconds, 0)} 秒前`;
if (seconds < 3600) return `${Math.round(seconds / 60)} 分钟前`;
if (seconds < 86400) return `${Math.round(seconds / 3600)} 小时前`;
return `${Math.round(seconds / 86400)} 天前`;
}
function JobRow({ job, label }: { job: AiJob; label: string }) {
return (
<li className={`aq-row aq-${job.status}`}>
<div className="aq-main">
<span className="aq-name">{label}</span>
{job.interactive && job.status !== 'done' && (
<span className="aq-jump" title="因为你打开了这个页面而插到队首"></span>
)}
<span className={`aq-status aq-status-${job.status}`}>
{job.status === 'running' && <span className="aq-dot" aria-hidden="true" />}
{STATUS_LABEL[job.status] ?? job.status}
</span>
</div>
<div className="aq-meta">
<span className="aq-subject">{job.subject}</span>
{job.attempts > 0 && (
<span> {job.attempts}/{job.maxAttempts} </span>
)}
<span>{relative(job.updatedAt)}</span>
</div>
{job.error && <p className="aq-err">{job.error}</p>}
</li>
);
}
/**
* What the AI coach is working on.
*
* Exists because the queue is otherwise invisible: a screen showing 排队生成中
* cannot say whether it is next, twentieth, or given up on — and when the
* gateway is down, "still generating" and "never going to" look identical.
*/
function AiQueuePage() {
const [data, setData] = useState<AiQueue | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [retrying, setRetrying] = useState(false);
const timer = useRef<number>();
const live = useRef(true);
const load = useCallback(async () => {
try {
const next = await apiClient.getInsightQueue();
if (!live.current) return;
setData(next);
setError('');
} catch (err: any) {
if (live.current) setError(errorMessage(err, '读取队列失败'));
} finally {
if (live.current) setLoading(false);
}
}, []);
useEffect(() => {
live.current = true;
const tick = async () => {
await load();
if (live.current) timer.current = window.setTimeout(tick, POLL_MS);
};
tick();
return () => { live.current = false; window.clearTimeout(timer.current); };
}, [load]);
const retry = async () => {
setRetrying(true);
try {
await apiClient.retryInsightQueue();
await load();
} catch (err: any) {
setError(errorMessage(err, '重试失败'));
} finally {
setRetrying(false);
}
};
if (loading && !data) {
return <Screen title="AI 生成队列" backLink><Skeleton count={4} /></Screen>;
}
const jobs = data?.jobs ?? [];
const label = (kind: string) => data?.scopes[kind] ?? (kind === 'briefing' ? '今日晨报' : kind);
const active = jobs.filter((j) => j.status === 'running' || j.status === 'pending');
const failed = jobs.filter((j) => j.status === 'failed');
const settings = data?.settings;
return (
<Screen title="AI 生成队列" backLink subtitle={`${active.length} 项待生成`}>
{error && <div className="screen-error">{error}</div>}
{settings && !settings.enabled && (
<div className="aq-off">AI_JOBS=false</div>
)}
<section className="aq-note">
<p>
40
<strong></strong>
</p>
{settings && (
<p className="aq-limits">
{settings.concurrency} {settings.gapSeconds}
</p>
)}
</section>
{!!active.length && (
<section className="sec">
<h3 className="sec-title"></h3>
<ul className="aq-list">
{active.map((j) => (
<JobRow key={j.kind + j.subject} job={j} label={label(j.kind)} />
))}
</ul>
</section>
)}
{!!failed.length && (
<section className="sec">
<div className="aq-head">
<h3 className="sec-title"></h3>
<button className="aq-retry" onClick={retry} disabled={retrying}>
{retrying ? '重新排队中…' : '全部重试'}
</button>
</div>
<ul className="aq-list">
{failed.map((j) => (
<JobRow key={j.kind + j.subject} job={j} label={label(j.kind)} />
))}
</ul>
{settings && (
<p className="aq-hint">
{Math.round(settings.retryAfterSeconds / 60)}
</p>
)}
</section>
)}
{!active.length && !failed.length && (
<p className="screen-note"></p>
)}
<section className="sec">
<h3 className="sec-title">{data?.insights.length ?? 0}</h3>
{data?.insights.length ? (
<ul className="aq-list">
{data.insights.map((i) => (
<li className="aq-row aq-done" key={i.kind + i.subject}>
<div className="aq-main">
<span className="aq-name">{label(i.kind)}</span>
<span className="aq-status aq-status-done">
{i.upstream ?? i.model ?? 'AI'}
</span>
</div>
<div className="aq-meta">
<span className="aq-subject">{i.subject}</span>
<span>{relative(i.generatedAt)}</span>
</div>
</li>
))}
</ul>
) : (
<p className="screen-note"></p>
)}
</section>
</Screen>
);
}
export default AiQueuePage;

View File

@@ -262,6 +262,13 @@ function SettingsPage() {
<span className="set-label"></span>
<span className="set-chevron" aria-hidden="true"></span>
</Link>
{FEATURES.ai && (
<Link href="/ai-queue/" className="set-row">
<span className="set-label">AI </span>
<span className="set-chevron" aria-hidden="true"></span>
</Link>
)}
</div>
</section>

View File

@@ -16,6 +16,7 @@ import ExercisePage from './pages/ExercisePage';
import SleepPage from './pages/SleepPage';
import SyncPage from './pages/SyncPage';
import SettingsPage from './pages/SettingsPage';
import AiQueuePage from './pages/AiQueuePage';
import LoginPage from './pages/LoginPage';
import NotFoundPage from './pages/NotFoundPage';
@@ -44,6 +45,7 @@ const SCREENS: Router.RouteParameters[] = [
{ path: '/devices/', component: DevicesPage },
{ path: '/sync/', component: SyncPage },
{ path: '/settings/', component: SettingsPage },
{ path: '/ai-queue/', component: AiQueuePage },
{ path: '/login/', component: LoginPage },
// auth-hub OAuth 回调落地页LoginPage 的 useEffect 会读取 URL 中的 code
// 并调用 /api/auth/callback 交换 token
@@ -84,7 +86,7 @@ const TAB_OWNERS: Array<[RegExp, string]> = [
[/^\/metric\//, 'trends'],
[/^\/(exercise|race|challenges)\/?$/, 'exercise'],
[/^\/activity\//, 'exercise'],
[/^\/(settings|sync|devices|rating-basis)\/?$/, 'settings'],
[/^\/(settings|sync|devices|rating-basis|ai-queue)\/?$/, 'settings'],
];
/** The tab a cold URL should open in, or null for the root and unknown paths. */

View File

@@ -448,6 +448,38 @@ export type InsightScope =
| 'health' | 'sleep' | 'exercise' | 'trends' | 'daily'
| 'body' | 'race' | 'bodyAge' | 'challenges' | 'activity';
/** One entry in the coach's work queue. */
export interface AiJob {
kind: string;
subject: string;
status: 'pending' | 'running' | 'failed' | 'done';
priority: number;
/** Queued because a screen is open, rather than as post-sync backfill. */
interactive: boolean;
attempts: number;
maxAttempts: number;
error: string | null;
updatedAt: string | null;
}
export interface AiQueue {
pending: number;
jobs: AiJob[];
insights: Array<{
kind: string; subject: string; model: string | null;
upstream: string | null; generatedAt: string | null;
}>;
/** Scope name -> its Chinese label, so the UI need not duplicate the list. */
scopes: Record<string, string>;
settings: {
enabled: boolean;
concurrency: number;
gapSeconds: number;
maxAttempts: number;
retryAfterSeconds: number;
};
}
export interface CopilotTurn {
role: 'user' | 'assistant';
content: string;
@@ -818,10 +850,16 @@ class ApiClient {
return data;
}
/** How much the coach still has to generate — for a progress hint. */
/** The coach's queue: running, waiting, given up, and already generated. */
async getInsightQueue() {
const { data } = await this.client.get<{ pending: number; enabled: boolean }>(
'/analysis/insight/queue'
const { data } = await this.client.get<AiQueue>('/analysis/insight/queue');
return data;
}
/** Re-queue everything that gave up, without waiting out the cooldown. */
async retryInsightQueue() {
const { data } = await this.client.post<{ pending: number }>(
'/analysis/insight/queue/retry'
);
return data;
}