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:
153
client/src/pages/AiQueue.css
Normal file
153
client/src/pages/AiQueue.css
Normal 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);
|
||||
}
|
||||
199
client/src/pages/AiQueuePage.tsx
Normal file
199
client/src/pages/AiQueuePage.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user