From 05f55e695b1c582557a5cc2e81e618e593998911 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Tue, 1 Sep 2026 16:02:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E8=AE=BE=E7=BD=AE=E9=87=8C?= =?UTF-8?q?=E5=8A=A0=E3=80=8CAI=20=E7=94=9F=E6=88=90=E9=98=9F=E5=88=97?= =?UTF-8?q?=E3=80=8D=EF=BC=8C=E7=9C=8B=E5=BE=97=E8=A7=81=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=9C=A8=E7=AE=97=E4=BB=80=E4=B9=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 队列本来是完全不可见的:页面上一句「排队生成中」说不出自己是下一个、第二十 个,还是已经放弃了——网关挂掉的时候,「还在生成」和「永远不会好」长得一模 一样。今天排查就是这么排的。 - GET /analysis/insight/queue 返回队列(running 在前,其次按优先级和年龄, 和 worker 实际取任务的顺序一致)、已生成的解读、scope 名到中文标签的映射 (前端不必再抄一份),以及消费者的限流配置 - POST /analysis/insight/queue/retry:手动把「已放弃」的重新排队,不等冷却。 自动重试要等冷却是为了不去捶一个正在抽风的上游;人按下重试是他自己判断值得 再试一次 - 页面在 设置 → AI 生成队列。插队的任务标「插队」——这是整个界面最想让人看见 的一件事:为什么是它排在最前面 - 「已生成」单独列:队列空了意味着「没有待办」,不是「什么都没生成过」, 没有这一节这两件事在界面上没法区分 Co-Authored-By: Claude Opus 5 --- backend/routes/analysis.py | 19 ++- backend/services/analysis.py | 16 +++ backend/services/jobs.py | 54 ++++++++ backend/tests/test_coach.py | 61 +++++++++ client/src/pages/AiQueue.css | 153 +++++++++++++++++++++++ client/src/pages/AiQueuePage.tsx | 199 ++++++++++++++++++++++++++++++ client/src/pages/SettingsPage.tsx | 7 ++ client/src/routes.ts | 4 +- client/src/services/api.ts | 44 ++++++- 9 files changed, 551 insertions(+), 6 deletions(-) create mode 100644 client/src/pages/AiQueue.css create mode 100644 client/src/pages/AiQueuePage.tsx diff --git a/backend/routes/analysis.py b/backend/routes/analysis.py index d97a516..2b1b125 100644 --- a/backend/routes/analysis.py +++ b/backend/routes/analysis.py @@ -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)}) diff --git a/backend/services/analysis.py b/backend/services/analysis.py index 32a87f3..184649a 100644 --- a/backend/services/analysis.py +++ b/backend/services/analysis.py @@ -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( diff --git a/backend/services/jobs.py b/backend/services/jobs.py index 6ef4db1..e8861ca 100644 --- a/backend/services/jobs.py +++ b/backend/services/jobs.py @@ -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 = [] diff --git a/backend/tests/test_coach.py b/backend/tests/test_coach.py index c1475b4..c14875f 100644 --- a/backend/tests/test_coach.py +++ b/backend/tests/test_coach.py @@ -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"] == [] diff --git a/client/src/pages/AiQueue.css b/client/src/pages/AiQueue.css new file mode 100644 index 0000000..5cfda68 --- /dev/null +++ b/client/src/pages/AiQueue.css @@ -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); +} diff --git a/client/src/pages/AiQueuePage.tsx b/client/src/pages/AiQueuePage.tsx new file mode 100644 index 0000000..e509c6e --- /dev/null +++ b/client/src/pages/AiQueuePage.tsx @@ -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 = { + 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 ( +
  • +
    + {label} + {job.interactive && job.status !== 'done' && ( + 插队 + )} + + {job.status === 'running' && +
    +
    + {job.subject} + {job.attempts > 0 && ( + 第 {job.attempts}/{job.maxAttempts} 次 + )} + {relative(job.updatedAt)} +
    + {job.error &&

    {job.error}

    } +
  • + ); +} + +/** + * 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(null); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(true); + const [retrying, setRetrying] = useState(false); + const timer = useRef(); + 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 ; + } + + 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 ( + + {error &&
    {error}
    } + + {settings && !settings.enabled && ( +
    消费者已关闭(AI_JOBS=false),页面只会显示直接计算的结果。
    + )} + +
    +

    + 一次生成要 40 秒到几分钟,所以什么都不在打开页面时现算:页面只负责排队, + 后台逐个生成。你打开哪个页面,哪个就插到队首,同步完成后 + 其余页面按背景优先级慢慢补。 +

    + {settings && ( +

    + 同时只跑 {settings.concurrency} 个,每个之间间隔 {settings.gapSeconds} 秒 + —— 网关是三个项目共用的,跑满会把它打挂。 +

    + )} +
    + + {!!active.length && ( +
    +

    队列

    +
      + {active.map((j) => ( + + ))} +
    +
    + )} + + {!!failed.length && ( +
    +
    +

    已放弃

    + +
    +
      + {failed.map((j) => ( + + ))} +
    + {settings && ( +

    + 放弃 {Math.round(settings.retryAfterSeconds / 60)} 分钟后也会自动再试一次。 +

    + )} +
    + )} + + {!active.length && !failed.length && ( +

    队列是空的,所有页面的解读都是最新的。

    + )} + +
    +

    已生成({data?.insights.length ?? 0})

    + {data?.insights.length ? ( +
      + {data.insights.map((i) => ( +
    • +
      + {label(i.kind)} + + {i.upstream ?? i.model ?? 'AI'} + +
      +
      + {i.subject} + {relative(i.generatedAt)} +
      +
    • + ))} +
    + ) : ( +

    还没有生成过任何解读。

    + )} +
    +
    + ); +} + +export default AiQueuePage; diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 6b38d6a..c8e6a25 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -262,6 +262,13 @@ function SettingsPage() { 已配对设备 + + {FEATURES.ai && ( + + AI 生成队列 + + + )} diff --git a/client/src/routes.ts b/client/src/routes.ts index 8786497..5719fe4 100644 --- a/client/src/routes.ts +++ b/client/src/routes.ts @@ -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. */ diff --git a/client/src/services/api.ts b/client/src/services/api.ts index f7d6fcd..eff349b 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -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; + 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('/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; }