Files
ttstd-infinite-canvas/web/src/services/api/logical-models.ts
xupengchong-del 8ec7e5a9a7 feat(model-catalog): 模型选择器 - 支持展示往前推最近10次调用红绿状态指示条 (#768)
* feat(catalog): 模型可用性 - 模型窗口支持展示往前推最近10次调用的红绿状态条

在模型选择弹窗中,单纯展示 24h 成功率难以反映模型短期的真实状态。
现支持往前推取最近最多 10 次实际调用(create 请求)的执行结果:
- 成功一次展示绿色小点,失败一次展示红色小点,一共固定显示 10 个槽位点
- 槽位未发生调用时展示半透明灰色点;鼠标悬浮显示成功与失败次数的详细统计
- 后端从当前时刻往前推查询最近的 create 日志,按时间演进正序(最右侧为最新)输出 recentResults 切片

验证:
- 后端:`go test -v ./internal/app -run TestPublicModelAvailability` 通过
- 前端:`tsc --noEmit`、`bun run build` 通过;`bun test` 2210 项通过
- 新增 `web/test/model-picker-availability-runs.test.ts` 单测并完成反向验证(改坏时测试变红)
- 后端已无损平滑重启并监听 8080 端口

* feat(create): 模型选择器 - 左侧列表模型项直接展示10点可用性状态条

用户在模型选择弹窗中,希望无需点进二级卡片就能直接感知模型的可用率。
现将 10 点状态指示条挂载到左侧模型目录列表的每一项中:
- 在每一个模型的子描述栏右侧常驻展示 10 个圆点的状态条
- 成功=绿点、失败=红点、未调用=灰点;鼠标悬浮显示最近成功与失败统计
- 保持列表项排版整齐,小点与渠道描述自适应两端对齐

验证:
- `tsc --noEmit`、`bun run build` 通过
- `bun test` 2211 项测试通过(唯一失败为上游基线问题)
- `test/model-picker-availability-runs.test.ts` 补充左侧列表挂载断言并通过
2026-10-14 09:37:30 +08:00

254 lines
8.5 KiB
TypeScript

import { http } from "@/services/api/request";
import type { ModelTag } from "@/lib/model-tags";
import type { ImageResolutionOption } from "@/lib/image-resolution-tiers";
export type InputConstraint = { min: number; max: number };
export type OptionConstraint = { values?: unknown[]; min?: number; max?: number; step?: number };
export type CapabilityImageSizePreset = ImageResolutionOption;
export type CapabilityImageSize = {
parameter?: "size" | "aspect_ratio";
allowCustom?: boolean;
presets?: CapabilityImageSizePreset[];
};
export type CapabilitySpec = {
imageParameters?: import("@/lib/image-parameters").ImageParameter[];
version: 1;
capability: "text" | "image" | "video" | "audio";
operations?: string[];
inputs?: Record<string, InputConstraint>;
options?: Record<string, OptionConstraint>;
imageSize?: CapabilityImageSize;
};
export type ModelRequestIntent = {
capability: CapabilitySpec["capability"];
operation?: string;
inputs?: Record<string, number>;
options?: Record<string, unknown>;
};
export type PublicLogicalModel = {
id: string;
code: string;
name: string;
icon?: string;
description: string;
capability: CapabilitySpec["capability"];
sortOrder: number;
pricePolicy: "channel" | "unified";
billingMode: "fixed_request" | "per_second" | "token";
unitPriceMicrocredits: number;
inputPriceMicrocredits: number;
outputPriceMicrocredits: number;
cachedPriceMicrocredits: number;
priceTiers: PublicLogicalModelPriceTier[];
legacyModelIds: string[];
capabilitySpec: CapabilitySpec;
capabilityProfiles: CapabilitySpec[];
defaultOptions: Record<string, unknown>;
available: boolean;
};
export type PublicLogicalModelPriceTier = {
selector: Record<string, string>;
resolution: string;
videoSeconds: number;
billingMode: "fixed_request" | "per_second" | "token";
unitPriceMicrocredits: number;
inputTokenPriceMicrocredits: number;
outputTokenPriceMicrocredits: number;
cachedTokenPriceMicrocredits: number;
};
export type AdminLogicalRoute = {
id: string;
channelModelId: string;
channelId: string;
channelModelKey: string;
channelModelName: string;
enabled: boolean;
priority: number;
weight: number;
available: boolean;
capabilitySpec: CapabilitySpec;
};
export type AdminLogicalModel = PublicLogicalModel & {
enabled: boolean;
activeRevisionId: string;
revisionVersion: number;
configurationError?: string;
availabilityError?: string;
routes: AdminLogicalRoute[];
};
/**
* Keep admin model responses usable when a server is upgraded before its
* existing process or historical response shape has caught up with the
* current contract. Collection fields must always be arrays for the page.
*/
export function normalizeAdminLogicalModel(model: AdminLogicalModel): AdminLogicalModel {
return {
...model,
priceTiers: Array.isArray(model.priceTiers) ? model.priceTiers : [],
legacyModelIds: Array.isArray(model.legacyModelIds) ? model.legacyModelIds : [],
capabilityProfiles: Array.isArray(model.capabilityProfiles) ? model.capabilityProfiles : [],
defaultOptions: model.defaultOptions && typeof model.defaultOptions === "object" ? model.defaultOptions : {},
routes: Array.isArray(model.routes) ? model.routes : [],
};
}
export type LogicalModelMutation = {
code: string;
name: string;
icon: string;
description: string;
capability: CapabilitySpec["capability"];
enabled: boolean;
sortOrder: number;
pricePolicy: PublicLogicalModel["pricePolicy"];
billingMode: PublicLogicalModel["billingMode"];
unitPriceMicrocredits: number;
inputPriceMicrocredits: number;
outputPriceMicrocredits: number;
cachedPriceMicrocredits: number;
legacyModelIds?: string[];
capabilitySpec: CapabilitySpec;
defaultOptions: Record<string, unknown>;
routes: Array<{ channelModelId: string; enabled: boolean; priority: number; weight: number }>;
};
export type RouteSimulationResult = {
productMatch: { matched: boolean; reasons?: string[] };
candidates: Array<{ routeId: string; channelModelId: string; channelModelKey: string; channelModelName: string; priority: number; weight: number; enabled: boolean; matched: boolean; blocked: boolean; inPool: boolean; reasons?: string[] }>;
};
export type LogicalModelQuote = {
logicalModelId: string;
billingMode: PublicLogicalModel["billingMode"];
quantity: number;
amountMicrocredits: number;
estimated: boolean;
videoTokenEstimate?: {
formulaTokens: number;
reservedTokens: number;
outputWidth: number;
outputHeight: number;
framesPerSecond: number;
outputSeconds: number;
referenceSeconds: number;
referenceDurationEstimated: boolean;
dimensionsEstimated: boolean;
reservationMarginPercent: number;
};
};
export type ModelQuoteRequest = {
logicalModelID?: string;
channelId?: string;
modelKey?: string;
intent: ModelRequestIntent;
};
export type ModelCatalogSource = "system";
export type PublicChannelCatalog = {
id: string;
name: string;
displayName: string;
sortOrder?: number;
models: PublicChannelModel[];
};
export type PublicChannelModel = {
id: string;
modelKey: string;
displayName: string;
channelLabel?: string;
tags?: ModelTag[];
description?: string;
sortOrder?: number;
icon: string;
capability: string;
protocol?: string;
capabilityConfig?: Record<string, any>;
priceTiers: PublicChannelModelPriceTier[];
pricingMode: string;
displayPrice?: number;
priceLabel: string;
available: boolean;
availability?: PublicChannelModelAvailability;
};
export type PublicChannelModelAvailability = {
rate24h: number | null;
sampleCount24h: number;
trend7d: PublicChannelModelAvailabilityDay[];
recentResults?: boolean[];
dataState: "ready" | "insufficient" | "no_data";
computedAt: string;
dataThrough?: string;
};
export type PublicChannelModelAvailabilityDay = {
day: string;
rate: number | null;
sampleCount: number;
dataState: "ready" | "insufficient" | "no_data";
};
export type PublicChannelModelPriceTier = {
id: string;
selector?: Record<string, string>;
resolution: string;
videoSeconds: number;
billingMode: string;
unitPriceMicrocredits: number;
inputTokenPriceMicrocredits: number;
outputTokenPriceMicrocredits: number;
cachedTokenPriceMicrocredits: number;
};
export type ModelCatalogResponse = {
source: ModelCatalogSource;
models?: PublicLogicalModel[];
channels?: PublicChannelCatalog[];
};
// 创作目录直接读取系统渠道模型,不使用逻辑模型及其功能开关。
export function getModelCatalog() {
return http.get<ModelCatalogResponse>("/model-catalog");
}
export function quoteLogicalModel(id: string, intent: ModelRequestIntent, signal?: AbortSignal) {
return http.post<{ quote: LogicalModelQuote }>(`/models/${encodeURIComponent(id)}/quote`, intent, { signal });
}
export function quoteModel(request: ModelQuoteRequest, signal?: AbortSignal) {
if (request.logicalModelID) return quoteLogicalModel(request.logicalModelID, request.intent, signal);
if (!request.channelId || !request.modelKey) return Promise.reject(new Error("请选择需要报价的系统模型"));
return http.post<{ quote: LogicalModelQuote }>("/model-catalog/quote", { channelId: request.channelId, modelKey: request.modelKey, intent: request.intent }, { signal });
}
export function listAdminLogicalModels() {
return http.get<{ models?: AdminLogicalModel[] }>("/admin/logical-models").then((result) => ({
models: Array.isArray(result?.models) ? result.models.filter(Boolean).map(normalizeAdminLogicalModel) : [],
}));
}
export function createAdminLogicalModel(input: LogicalModelMutation) {
return http.post<{ model: AdminLogicalModel }>("/admin/logical-models", input);
}
export function updateAdminLogicalModel(id: string, input: LogicalModelMutation) {
return http.patch<{ model: AdminLogicalModel }>(`/admin/logical-models/${encodeURIComponent(id)}`, input);
}
export function deleteAdminLogicalModel(id: string) {
return http.delete<{ ok: boolean }>(`/admin/logical-models/${encodeURIComponent(id)}`);
}
export function simulateAdminLogicalModel(id: string, intent: ModelRequestIntent) {
return http.post<RouteSimulationResult>(`/admin/logical-models/${encodeURIComponent(id)}/simulate`, intent);
}