feat: add river-backed async task queue
This commit is contained in:
@@ -46,7 +46,6 @@ import {
|
||||
getHealth,
|
||||
getNetworkProxyConfig,
|
||||
getRunnerPolicy,
|
||||
getTask,
|
||||
getWalletSummary,
|
||||
listAccessRules,
|
||||
listAuditLogs,
|
||||
@@ -72,6 +71,7 @@ import {
|
||||
listUserGroups,
|
||||
listUsers,
|
||||
loginLocalAccount,
|
||||
pollTaskUntilSettled,
|
||||
registerLocalAccount,
|
||||
replacePlatformModels,
|
||||
setUserWalletBalance,
|
||||
@@ -788,7 +788,11 @@ export function App() {
|
||||
setCoreMessage('');
|
||||
try {
|
||||
const response = await runTask(credential, taskForm);
|
||||
const detail = await getTask(credential, response.task.id);
|
||||
const syncTask = (detail: GatewayTask) => {
|
||||
setTaskResult(detail);
|
||||
setTasks((current) => [detail, ...current.filter((item) => item.id !== detail.id)]);
|
||||
};
|
||||
const detail = await pollTaskUntilSettled(credential, response.task, { onUpdate: syncTask });
|
||||
setTaskResult(detail);
|
||||
setTasks((current) => [detail, ...current.filter((item) => item.id !== detail.id)]);
|
||||
invalidateDataKeys('tasks', 'wallet', 'walletTransactions');
|
||||
|
||||
+37
-2
@@ -521,6 +521,7 @@ export async function createChatTask(
|
||||
): Promise<{ task: GatewayTask; next: Record<string, string> }> {
|
||||
return request<{ task: GatewayTask; next: Record<string, string> }>('/api/v1/chat/completions', {
|
||||
body: input,
|
||||
headers: { 'X-Async': 'true' },
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
@@ -598,6 +599,7 @@ export async function createImageGenerationTask(
|
||||
): Promise<{ task: GatewayTask; next: Record<string, string> }> {
|
||||
return request<{ task: GatewayTask; next: Record<string, string> }>('/api/v1/images/generations', {
|
||||
body: input,
|
||||
headers: { 'X-Async': 'true' },
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
@@ -609,6 +611,7 @@ export async function createImageEditTask(
|
||||
): Promise<{ task: GatewayTask; next: Record<string, string> }> {
|
||||
return request<{ task: GatewayTask; next: Record<string, string> }>('/api/v1/images/edits', {
|
||||
body: input,
|
||||
headers: { 'X-Async': 'true' },
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
@@ -636,6 +639,7 @@ export async function createVideoGenerationTask(
|
||||
): Promise<{ task: GatewayTask; next: Record<string, string> }> {
|
||||
return request<{ task: GatewayTask; next: Record<string, string> }>('/api/v1/videos/generations', {
|
||||
body: input,
|
||||
headers: { 'X-Async': 'true' },
|
||||
method: 'POST',
|
||||
token,
|
||||
});
|
||||
@@ -656,6 +660,33 @@ export async function getTask(token: string, taskId: string): Promise<GatewayTas
|
||||
return request<GatewayTask>(`/api/workspace/tasks/${taskId}`, { token });
|
||||
}
|
||||
|
||||
export async function pollTaskUntilSettled(
|
||||
token: string,
|
||||
task: GatewayTask,
|
||||
options: { intervalMs?: number; maxAttempts?: number | null; onUpdate?: (task: GatewayTask) => void } = {},
|
||||
): Promise<GatewayTask> {
|
||||
let detail = task;
|
||||
const intervalMs = options.intervalMs ?? 1200;
|
||||
const maxAttempts = options.maxAttempts ?? Number.POSITIVE_INFINITY;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
if (!taskIsPending(detail.status)) return detail;
|
||||
try {
|
||||
detail = await getTask(token, detail.id);
|
||||
options.onUpdate?.(detail);
|
||||
if (!taskIsPending(detail.status)) return detail;
|
||||
} catch {
|
||||
// Backend restarts or short network gaps should not turn a durable task into a failed UI run.
|
||||
// Only an explicit terminal task status from the task detail endpoint settles the run.
|
||||
}
|
||||
await delay(intervalMs);
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
export function taskIsPending(status: string) {
|
||||
return status === 'queued' || status === 'running' || status === 'submitting';
|
||||
}
|
||||
|
||||
export async function listTasks(token: string, query: WorkspaceTaskQuery): Promise<ListResponse<GatewayTask>> {
|
||||
const search = new URLSearchParams({
|
||||
page: String(query.page),
|
||||
@@ -707,9 +738,9 @@ export async function getNetworkProxyConfig(token: string): Promise<GatewayNetwo
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: { token?: string; auth?: boolean; method?: string; body?: unknown } = {},
|
||||
options: { token?: string; auth?: boolean; method?: string; body?: unknown; headers?: Record<string, string> } = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
const headers: Record<string, string> = { ...(options.headers ?? {}) };
|
||||
if (options.auth !== false && options.token) {
|
||||
headers.Authorization = `Bearer ${options.token}`;
|
||||
}
|
||||
@@ -731,6 +762,10 @@ async function request<T>(
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function parseErrorMessage(body: string) {
|
||||
return formatGatewayErrorDetails(parseErrorDetails(body));
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { mermaid } from '@streamdown/mermaid';
|
||||
import type { GatewayApiKey, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||
import { Bot, ChevronDown, Image as ImageIcon, MessageSquarePlus, Paperclip, Send, Sparkles, Video } from 'lucide-react';
|
||||
import { Badge, Button, Select, Textarea } from '../components/ui';
|
||||
import { GatewayApiError, createImageGenerationTask, createVideoGenerationTask, getTask, streamChatCompletionText } from '../api';
|
||||
import { GatewayApiError, createImageGenerationTask, createVideoGenerationTask, pollTaskUntilSettled, streamChatCompletionText, taskIsPending } from '../api';
|
||||
import type { PlaygroundMode } from '../types';
|
||||
import {
|
||||
defaultMediaGenerationSettings,
|
||||
@@ -170,14 +170,12 @@ export function PlaygroundPage(props: {
|
||||
resumableRuns.forEach((run) => {
|
||||
if (!run.task?.id) return;
|
||||
resumedTaskIdsRef.current.add(run.task.id);
|
||||
void pollTaskUntilSettled(credential, run.task)
|
||||
void pollTaskUntilSettled(credential, run.task, {
|
||||
onUpdate: (detail) => updateMediaRunFromTask(run.localId, detail),
|
||||
})
|
||||
.then((detail) => {
|
||||
if (!isMountedRef.current) return;
|
||||
setMediaRuns((current) => updateMediaRun(current, run.localId, {
|
||||
error: gatewayTaskErrorText(detail, '任务执行失败'),
|
||||
status: detail.status,
|
||||
task: detail,
|
||||
}));
|
||||
updateMediaRunFromTask(run.localId, detail);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!isMountedRef.current) return;
|
||||
@@ -249,13 +247,11 @@ export function PlaygroundPage(props: {
|
||||
|
||||
async function pollMediaRunUntilSettled(credential: string, localId: string, task: GatewayTask) {
|
||||
try {
|
||||
const detail = await pollTaskUntilSettled(credential, task);
|
||||
const detail = await pollTaskUntilSettled(credential, task, {
|
||||
onUpdate: (nextTask) => updateMediaRunFromTask(localId, nextTask),
|
||||
});
|
||||
if (!isMountedRef.current) return;
|
||||
setMediaRuns((current) => updateMediaRun(current, localId, {
|
||||
error: gatewayTaskErrorText(detail, '任务执行失败'),
|
||||
status: detail.status,
|
||||
task: detail,
|
||||
}));
|
||||
updateMediaRunFromTask(localId, detail);
|
||||
} catch (err) {
|
||||
if (!isMountedRef.current) return;
|
||||
const errorMessage = err instanceof Error ? err.message : '任务状态同步失败';
|
||||
@@ -263,6 +259,15 @@ export function PlaygroundPage(props: {
|
||||
}
|
||||
}
|
||||
|
||||
function updateMediaRunFromTask(localId: string, task: GatewayTask) {
|
||||
if (!isMountedRef.current) return;
|
||||
setMediaRuns((current) => updateMediaRun(current, localId, {
|
||||
error: taskIsPending(task.status) ? '' : gatewayTaskErrorText(task, '任务执行失败'),
|
||||
status: task.status,
|
||||
task,
|
||||
}));
|
||||
}
|
||||
|
||||
function editMediaRun(run: MediaGenerationRun) {
|
||||
setPrompt(run.prompt);
|
||||
setMediaSettings(run.settings);
|
||||
@@ -1042,20 +1047,6 @@ function updateMediaRun(runs: MediaGenerationRun[], localId: string, patch: Part
|
||||
return runs.map((run) => run.localId === localId ? { ...run, ...patch } : run);
|
||||
}
|
||||
|
||||
async function pollTaskUntilSettled(token: string, task: GatewayTask) {
|
||||
let detail = task;
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
detail = await getTask(token, detail.id);
|
||||
if (!taskIsPending(detail.status)) return detail;
|
||||
await delay(1200);
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
function taskIsPending(status: string) {
|
||||
return status === 'queued' || status === 'running' || status === 'submitting';
|
||||
}
|
||||
|
||||
function readStoredMediaRuns(): MediaGenerationRun[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
@@ -1182,10 +1173,6 @@ function booleanFromUnknown(value: unknown, fallback: boolean) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function newLocalId() {
|
||||
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
|
||||
@@ -593,11 +593,14 @@ function RateLimitStatusTable(props: { statuses: ModelRateLimitStatus[]; platfor
|
||||
<TableRow className="shTableHeader">
|
||||
<TableHead>模型</TableHead>
|
||||
<TableHead>平台</TableHead>
|
||||
<TableHead>并发</TableHead>
|
||||
<TableHead>TPM</TableHead>
|
||||
<TableHead>RPM</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>满载率</TableHead>
|
||||
<TableHead className="platformLimitMetricHead platformLimitNumberHead" title="正在执行 / 并发上限 / 排队任务">
|
||||
<span>并发</span>
|
||||
<small>正在执行 / 并发 / 排队</small>
|
||||
</TableHead>
|
||||
<TableHead className="platformLimitNumberHead">TPM</TableHead>
|
||||
<TableHead className="platformLimitNumberHead">RPM</TableHead>
|
||||
<TableHead className="platformLimitStatusHead">状态</TableHead>
|
||||
<TableHead className="platformLimitNumberHead">满载率</TableHead>
|
||||
</TableRow>
|
||||
{props.statuses.map((status) => {
|
||||
const platform = props.platformMap.get(status.platformId);
|
||||
@@ -615,12 +618,12 @@ function RateLimitStatusTable(props: { statuses: ModelRateLimitStatus[]; platfor
|
||||
<small>{status.provider}</small>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{metricCell(status.concurrent)}</TableCell>
|
||||
<TableCell>{metricCell(status.tpm, true)}</TableCell>
|
||||
<TableCell>{metricCell(status.rpm)}</TableCell>
|
||||
<TableCell>{modelRuntimeStatusCell(status, props.now)}</TableCell>
|
||||
<TableCell>
|
||||
<span className="rateLoadCell">
|
||||
<TableCell className="platformLimitNumberCell">{concurrencyMetricCell(status)}</TableCell>
|
||||
<TableCell className="platformLimitNumberCell">{metricCell(status.tpm, true)}</TableCell>
|
||||
<TableCell className="platformLimitNumberCell">{metricCell(status.rpm)}</TableCell>
|
||||
<TableCell className="platformLimitStatusCell">{modelRuntimeStatusCell(status, props.now)}</TableCell>
|
||||
<TableCell className="platformLimitNumberCell">
|
||||
<span className="rateLoadCell" data-overloaded={status.loadRatio > 0.8 ? 'true' : undefined}>
|
||||
<strong>{formatPercent(status.loadRatio)}</strong>
|
||||
<span className="rateLoadTrack"><i style={{ width: `${Math.min(status.loadRatio * 100, 100)}%` }} /></span>
|
||||
</span>
|
||||
@@ -1210,6 +1213,16 @@ function metricCell(metric: ModelRateLimitStatus['rpm'], includeReserved = false
|
||||
);
|
||||
}
|
||||
|
||||
function concurrencyMetricCell(status: ModelRateLimitStatus) {
|
||||
const queuedTasks = status.queuedTasks ?? 0;
|
||||
const limitText = status.concurrent.limited ? formatLimit(status.concurrent.limitValue) : '不限';
|
||||
return (
|
||||
<span className="rateMetricCell" title="正在执行 / 并发上限 / 排队任务">
|
||||
<strong>{formatLimit(status.concurrent.currentValue)} / {limitText} / {formatLimit(queuedTasks)}</strong>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function reservedMetricText(metric: ModelRateLimitStatus['rpm']) {
|
||||
return `已结算 ${formatLimit(metric.usedValue)} + 预占 ${formatLimit(metric.reservedValue)}`;
|
||||
}
|
||||
|
||||
@@ -1016,27 +1016,65 @@
|
||||
}
|
||||
|
||||
.platformLimitTable .shTableRow {
|
||||
grid-template-columns: clamp(150px, 16vw, 220px) minmax(148px, 1fr) minmax(104px, max-content) minmax(136px, max-content) minmax(122px, max-content) minmax(132px, max-content) minmax(128px, max-content);
|
||||
min-width: 920px;
|
||||
grid-template-columns: minmax(180px, 1.15fr) minmax(160px, 0.95fr) 150px 170px 140px 132px 132px;
|
||||
min-width: 1064px;
|
||||
}
|
||||
|
||||
.platformLimitTable .shTableHead,
|
||||
.platformLimitTable .shTableCell {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
min-height: 68px;
|
||||
padding-right: 10px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.platformLimitTable .shTableHead {
|
||||
min-height: 74px;
|
||||
}
|
||||
|
||||
.platformLimitMetricHead {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 3px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.platformLimitMetricHead small {
|
||||
overflow: hidden;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.platformLimitNumberHead,
|
||||
.platformLimitNumberCell {
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.platformLimitStatusHead,
|
||||
.platformLimitStatusCell {
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.rateMetricCell,
|
||||
.rateLoadCell {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
align-content: start;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rateMetricCell strong,
|
||||
.rateLoadCell strong {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.rateMetricCell small {
|
||||
@@ -1050,6 +1088,7 @@
|
||||
.rateLoadTrack {
|
||||
display: block;
|
||||
height: 6px;
|
||||
width: min(112px, 100%);
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #eef2f6;
|
||||
@@ -1062,6 +1101,18 @@
|
||||
background: #0f766e;
|
||||
}
|
||||
|
||||
.rateLoadCell[data-overloaded="true"] strong {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.rateLoadCell[data-overloaded="true"] .rateLoadTrack {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.rateLoadCell[data-overloaded="true"] .rateLoadTrack i {
|
||||
background: var(--destructive);
|
||||
}
|
||||
|
||||
.platformModelToolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.6fr) minmax(260px, 1fr);
|
||||
|
||||
Reference in New Issue
Block a user