Changelog, dev startup, README, AgentHub 0.4.0, model catalog, and landing site (#7)
Branch-length batch covering tooling, the model layer, the Web App and the public surfaces. Highlights: - Changelog: a per-release `changelog/<version>/` tree, grouped by the surface each change touches, with a root CHANGELOG.md holding one line per release. - Dev startup: `scripts/dev-prebuild.mjs` serializes the skills+core prebuild behind a lock and keeps `pnpm install` current; `pnpm dev` runs server+web together. - AgentHub 0.3.3 -> 0.4.0: OmniMessage complete payloads carry one opaque `fidelity` object in place of item-level `signature`/`phase`, threaded verbatim through Trace, replay and resume; malformed classification adapted to the new error types. - Model layer: a model is always referenced by an explicit `(provider, model_id)` pair. The provider is never inferred, guessed or defaulted -- both the catalog inference and the unique-match config resolution are gone, and CLI, SDK, server routes and run_subagent all require the complete pair. Catalog gains the Qwen Token Plan, Qwen Pay-As-You-Go and Fireworks AI gateways, plus an expanded OpenRouter group. - Web App: catalog preset sync and per-group speed test on the Models page, positional slash commands, a markdown renderer, skill-library update reminders, and a vertically centred draft page whose upward menus size themselves to the room available. - Public surfaces: restructured READMEs, the penguin.ooo landing site and blog, refreshed benchmark results for both suites, and the demo videos playing on the landing page. Includes the fixes from a full review of the branch: 23 confirmed findings, among them a provider-inference bug that could send one vendor's API key to another vendor's endpoint, and an Escape handler that destroyed the composer's contents unrecoverably. Verified on the branch head: pnpm test (1127 passing, 7 packages), pnpm typecheck and pnpm format:check clean, Playwright e2e 14/14.
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* One-click sync of the Project model table with the built-in catalog ("sync presets" next
|
||||
* to the search box): union semantics — catalog entries not configured locally are added;
|
||||
* entries present on both sides are reset to the catalog's fields (context window, pricing,
|
||||
* protocol, base URL, vision — the catalog wins wherever the two differ, including removing
|
||||
* pricing the catalog doesn't carry); locally added models (including user-defined groups)
|
||||
* are kept untouched. Credentials are never touched: merged rows carry no apiKey input (the
|
||||
* PUT keeps the stored key) and existing rows keep their credential display state.
|
||||
*/
|
||||
import { presetModelEntries } from "@prismshadow/penguin-core/model-catalog";
|
||||
import type { RowState } from "./models-page";
|
||||
|
||||
type PresetEntry = ReturnType<typeof presetModelEntries>[number];
|
||||
|
||||
/** The catalog-owned fields of a row, in RowState's string-typed form (mirrors toRow). */
|
||||
function presetFields(p: PresetEntry) {
|
||||
return {
|
||||
vision: p.vision !== false,
|
||||
contextWindow: p.context_window !== undefined ? String(p.context_window) : "",
|
||||
clientType: p.client_type ?? "",
|
||||
cacheRead: p.pricing ? String(p.pricing.cache_read) : "",
|
||||
cacheWrite: p.pricing ? String(p.pricing.cache_write) : "",
|
||||
output: p.pricing ? String(p.pricing.output) : "",
|
||||
baseUrl: p.base_url ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** A brand-new row for a catalog entry not configured locally (original: null -> added on PUT). */
|
||||
function presetToRow(p: PresetEntry): RowState {
|
||||
return {
|
||||
provider: p.provider,
|
||||
modelId: p.model_id,
|
||||
original: null,
|
||||
...presetFields(p),
|
||||
originalBaseUrl: "",
|
||||
apiKeyInput: "",
|
||||
clearApiKey: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the current rows with the built-in catalog. Existing rows keep their identity,
|
||||
* credential state, and list position (fields are updated in place); catalog-only entries
|
||||
* are appended in catalog order. Returns the merged rows plus added/updated counts for the
|
||||
* success toast (updated counts only rows whose catalog-owned fields actually changed).
|
||||
*/
|
||||
export function syncRowsWithCatalog(
|
||||
rows: RowState[],
|
||||
preset: PresetEntry[] = presetModelEntries(),
|
||||
): { rows: RowState[]; added: number; updated: number } {
|
||||
const key = (provider: string, modelId: string) => `${provider}\0${modelId}`;
|
||||
const index = new Map(rows.map((r, i) => [key(r.provider, r.modelId), i]));
|
||||
const next = [...rows];
|
||||
let added = 0;
|
||||
let updated = 0;
|
||||
for (const p of preset) {
|
||||
const i = index.get(key(p.provider, p.model_id));
|
||||
if (i === undefined) {
|
||||
next.push(presetToRow(p));
|
||||
added += 1;
|
||||
continue;
|
||||
}
|
||||
const row = next[i]!;
|
||||
const fields = presetFields(p);
|
||||
const changed = (Object.keys(fields) as (keyof typeof fields)[]).some(
|
||||
(k) => row[k] !== fields[k],
|
||||
);
|
||||
if (changed) {
|
||||
next[i] = { ...row, ...fields };
|
||||
updated += 1;
|
||||
}
|
||||
}
|
||||
return { rows: next, added, updated };
|
||||
}
|
||||
@@ -88,3 +88,13 @@ export function groupModelRows<T extends ModelRowLike>(
|
||||
(g) => g.rows.length > 0 || (!searching && g.provider.id === "custom"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens the library grouping into one ordered list (the chat model dropdown uses this):
|
||||
* rows ordered exactly as the model page shows them — built-in provider groups in
|
||||
* MODEL_PROVIDERS order, then user-defined groups, custom last; in-group row order
|
||||
* preserved.
|
||||
*/
|
||||
export function orderModelsLikeLibrary<T extends ModelRowLike>(rows: T[]): T[] {
|
||||
return groupModelRows(rows, "").flatMap((g) => g.rows);
|
||||
}
|
||||
|
||||
@@ -18,12 +18,13 @@
|
||||
* the protocol follows group semantics: a first-party vendor group doesn't persist
|
||||
* client_type (AgentHub auto-routes by upstream id, with env fallback resolved live from the
|
||||
* id), while custom / user-defined groups / gateways use a fixed OpenAI protocol, and
|
||||
* gateways (OpenRouter / SiliconFlow) additionally pre-fill their endpoint base URL; the
|
||||
* "get model id / API key" external links sit next to the corresponding input's label
|
||||
* (shown in both add and edit dialogs). The group list ends with an "add group" action
|
||||
* (user-defined groups share custom's semantics; the group appears once the first model
|
||||
* saves successfully — groups are carried by the model entry's provider field, not
|
||||
* persisted separately).
|
||||
* gateways (OpenRouter / SiliconFlow / Qwen Token Plan) additionally pre-fill their endpoint
|
||||
* base URL; the "get model id / API key" external links sit next to the corresponding
|
||||
* input's label (shown in both add and edit dialogs). The group list ends with an "add
|
||||
* group" action (user-defined groups share custom's semantics; the group appears once the
|
||||
* first model saves successfully — groups are carried by the model entry's provider field,
|
||||
* not persisted separately). The header also holds an owner-only "sync presets" action next
|
||||
* to the search box (union-merge with the built-in catalog, see catalog-sync.ts).
|
||||
*
|
||||
* Saving does a PUT full-table replace (models not present are deleted; an empty apiKey
|
||||
* means keep the existing value); only the owner can edit.
|
||||
@@ -42,15 +43,18 @@ import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { useDocumentTitle } from "../../lib/use-document-title";
|
||||
import { useProject } from "../../state/project";
|
||||
import { useAuth } from "../../state/auth";
|
||||
import { USD_TO_CNY, useTheme } from "../../state/theme";
|
||||
import type { Currency } from "../../state/theme";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { PasswordInput } from "../../components/ui/password-input";
|
||||
import { Modal } from "../../components/ui/modal";
|
||||
import { Select } from "../../components/ui/select";
|
||||
import { toastError, toastSuccess } from "../../components/ui/toast";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Chevron } from "../../components/ui/chevron";
|
||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
import { ProviderLogo } from "../../components/ui/provider-logo";
|
||||
import { SkeletonList } from "../../components/ui/skeleton";
|
||||
import { EmptyState } from "../../components/ui/empty-state";
|
||||
@@ -58,11 +62,16 @@ import { formatDateTime, humanizeTokens } from "../../lib/format";
|
||||
import {
|
||||
MODEL_PROVIDERS,
|
||||
catalogEntryFor,
|
||||
modelHomepageUrl,
|
||||
providerInfo,
|
||||
resolveModelEnv,
|
||||
} from "@prismshadow/penguin-core/model-catalog";
|
||||
import type { ModelProviderInfo } from "@prismshadow/penguin-core/model-catalog";
|
||||
import { groupModelRows, sameModelRef, userProviderInfo } from "./model-grouping";
|
||||
import { draftKey, loadDraft, saveDraft } from "../chat/draft-cache";
|
||||
import { syncRowsWithCatalog } from "./catalog-sync";
|
||||
import { tpsTone, ttftTone } from "./speed-test";
|
||||
import type { SpeedResult, SpeedTone } from "./speed-test";
|
||||
|
||||
/** Display currency follows the user setting (pricing is always stored in USD/million tokens; conversion happens only for display and input). */
|
||||
const CURRENCY_SYMBOL: Record<Currency, string> = { USD: "$", CNY: "¥" };
|
||||
@@ -104,6 +113,26 @@ function inputToUsd(inputStr: string, currency: Currency): string {
|
||||
return currency === "CNY" ? trimNum(n / USD_TO_CNY) : trimNum(n);
|
||||
}
|
||||
|
||||
/** Group-header action glyphs (24x24 line paths): add, bulk key, gauge for speed test. */
|
||||
const PLUS_ICON = "M12 5v14M5 12h14";
|
||||
const KEY_ICON =
|
||||
"M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4";
|
||||
|
||||
/** Speed-test glyphs (24x24 line paths): gauge for the group action, clock = TTFT, zap = TPS. */
|
||||
const GAUGE_ICON = "M12 14l3.5-3.5M20.49 17A10 10 0 1 0 3.5 17";
|
||||
const CLOCK_ICON = "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20ZM12 7v5l3.5 2";
|
||||
const ZAP_ICON = "M13 2 3 14h9l-1 8 10-12h-9l1-8Z";
|
||||
|
||||
/** Metric tone -> text color classes for the card speed badges. */
|
||||
const TONE_CLASS: Record<SpeedTone, string> = {
|
||||
green: "text-green-600 dark:text-green-400",
|
||||
yellow: "text-amber-600 dark:text-amber-400",
|
||||
red: "text-red-600 dark:text-red-400",
|
||||
};
|
||||
|
||||
/** In-page key for one model's speed result. */
|
||||
const speedKey = (provider: string, modelId: string) => `${provider}\u0000${modelId}`;
|
||||
|
||||
/** Default context window (tokens) for custom models when left unset. */
|
||||
const CUSTOM_CONTEXT_DEFAULT = 128000;
|
||||
|
||||
@@ -299,6 +328,13 @@ export function ModelsPage() {
|
||||
const { currentProject } = useProject();
|
||||
const projectId = currentProject?.projectId ?? null;
|
||||
const isOwner = currentProject?.role === "owner";
|
||||
const userId = useAuth().user?.userId ?? null;
|
||||
/** Per-model speed results (in-memory, reset on every project switch; "pending" while that model's turn is running). */
|
||||
const [speedResults, setSpeedResults] = useState<Map<string, SpeedResult | "pending">>(new Map());
|
||||
/** Group whose speed-test confirmation dialog is open (provider id). */
|
||||
const [speedFor, setSpeedFor] = useState<string | null>(null);
|
||||
/** Group currently being speed-tested (provider id); tests run strictly one model at a time. */
|
||||
const [speedRunning, setSpeedRunning] = useState<string | null>(null);
|
||||
|
||||
const [rows, setRows] = useState<RowState[] | null>(null);
|
||||
const [defaultModel, setDefaultModel] = useState<ModelRefDto | undefined>(undefined);
|
||||
@@ -327,6 +363,10 @@ export function ModelsPage() {
|
||||
if (!projectId) return;
|
||||
setRows(null);
|
||||
setLoadError(null);
|
||||
// Speed results are keyed by (provider, model_id) only, so another Project's identically
|
||||
// named model would inherit a timing measured against a different endpoint and key —
|
||||
// drop them along with the rows they annotate whenever the active Project changes.
|
||||
setSpeedResults(new Map());
|
||||
try {
|
||||
const res = await api.getModels(projectId);
|
||||
setRows(res.models.map(toRow));
|
||||
@@ -368,6 +408,13 @@ export function ModelsPage() {
|
||||
setRows(res.models.map(toRow));
|
||||
setDefaultModel(res.defaultModel);
|
||||
setVisionModel(res.visionModel);
|
||||
// Default model changed: drop the stored draft's model selection so the draft chat
|
||||
// follows the new default (a stored pick would otherwise pin the old model forever).
|
||||
if (userId && res.defaultModel && !sameModelRef(res.defaultModel, defaultModel)) {
|
||||
const key = draftKey(userId, projectId);
|
||||
const draft = loadDraft(key);
|
||||
if (draft.modelRef) saveDraft(key, { ...draft, modelRef: undefined });
|
||||
}
|
||||
toastSuccess(successText ?? S.common.saved);
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -383,6 +430,60 @@ export function ModelsPage() {
|
||||
|
||||
const groups = useMemo(() => (rows ? groupModelRows(rows, query) : []), [rows, query]);
|
||||
|
||||
/**
|
||||
* "Sync presets": merge the built-in catalog into the current table (union; the catalog
|
||||
* wins on differing preset entries, local additions and API keys stay untouched — see
|
||||
* catalog-sync.ts). No-op with a toast when everything is already up to date.
|
||||
*/
|
||||
const syncPresets = async () => {
|
||||
if (!rows) return;
|
||||
const merged = syncRowsWithCatalog(rows);
|
||||
if (merged.added === 0 && merged.updated === 0) {
|
||||
toastSuccess(S.models.syncUpToDate);
|
||||
return;
|
||||
}
|
||||
await persist(
|
||||
merged.rows,
|
||||
defaultModel,
|
||||
visionModel,
|
||||
S.models.syncDone(merged.added, merged.updated),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Group speed test: one real request per model, strictly sequential (concurrent probes
|
||||
* trip provider rate limits), each result written to the card as it lands. The
|
||||
* confirmation dialog (speedFor) has already warned about quota by the time this runs.
|
||||
*/
|
||||
const runSpeedTest = async (providerId: string) => {
|
||||
if (!projectId || !rows) return;
|
||||
const targets = rows.filter((r) => r.provider === providerId);
|
||||
setSpeedRunning(providerId);
|
||||
try {
|
||||
for (const row of targets) {
|
||||
const key = speedKey(row.provider, row.modelId);
|
||||
setSpeedResults((prev) => new Map(prev).set(key, "pending"));
|
||||
try {
|
||||
const res = await api.testModel(projectId, {
|
||||
provider: row.provider,
|
||||
modelId: row.modelId,
|
||||
speed: true,
|
||||
});
|
||||
setSpeedResults((prev) => new Map(prev).set(key, res));
|
||||
} catch (e) {
|
||||
setSpeedResults((prev) =>
|
||||
new Map(prev).set(key, {
|
||||
ok: false,
|
||||
message: e instanceof ApiError ? e.message : S.common.unknownError,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setSpeedRunning(null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* "Add group" confirm: a valid name that doesn't conflict with a built-in group or an
|
||||
* existing provider proceeds directly to that group's add-model dialog — groups are
|
||||
@@ -426,9 +527,9 @@ export function ModelsPage() {
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{S.models.readOnlyHint}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* The header only holds search (add-model entry points live in each group header);
|
||||
on narrow screens (flex-wrap wraps it to its own line) the search box shrinks
|
||||
flexibly, fixed width at >=sm. */}
|
||||
{/* The header holds search plus the owner-only "sync presets" action (add-model
|
||||
entry points live in each group header); on narrow screens (flex-wrap wraps it
|
||||
to its own line) the search box shrinks flexibly, fixed width at >=sm. */}
|
||||
<div className="flex min-w-0 max-w-full grow items-center gap-2 sm:grow-0">
|
||||
<div className="min-w-0 flex-1 sm:w-56 sm:flex-none">
|
||||
<Input
|
||||
@@ -438,6 +539,16 @@ export function ModelsPage() {
|
||||
placeholder={S.models.searchPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void syncPresets()}
|
||||
disabled={busy || rows === null}
|
||||
title={S.models.syncCatalogHint}
|
||||
>
|
||||
{S.models.syncCatalog}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -498,6 +609,7 @@ export function ModelsPage() {
|
||||
disabled={busy}
|
||||
onClick={() => setAddingTo(group.provider.id)}
|
||||
>
|
||||
<GlyphIcon d={PLUS_ICON} size={13} />
|
||||
{S.models.addToGroup}
|
||||
</Button>
|
||||
</span>
|
||||
@@ -514,10 +626,34 @@ export function ModelsPage() {
|
||||
disabled={busy}
|
||||
onClick={() => setGroupKeyFor(group.provider.id)}
|
||||
>
|
||||
<GlyphIcon d={KEY_ICON} size={13} />
|
||||
{S.models.groupApiKey}
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
{isOwner && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="shrink-0"
|
||||
disabled={busy || speedRunning !== null}
|
||||
aria-label={`${S.models.speedTest} ${group.provider.label}`}
|
||||
title={
|
||||
speedRunning === group.provider.id
|
||||
? S.models.speedPending
|
||||
: S.models.speedTest
|
||||
}
|
||||
onClick={() => setSpeedFor(group.provider.id)}
|
||||
>
|
||||
<GlyphIcon d={GAUGE_ICON} size={13} />
|
||||
{/* Phone width: icon only (the header can't fit three labeled actions at 390px). */}
|
||||
<span className="hidden sm:inline">
|
||||
{speedRunning === group.provider.id
|
||||
? S.models.speedPending
|
||||
: S.models.speedTest}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{group.provider.apiKeyUrl && (
|
||||
// Not enough room on phone width for all group-level actions: collapse
|
||||
// it the same way as "bulk configure key", keeping the add entry and
|
||||
@@ -568,6 +704,7 @@ export function ModelsPage() {
|
||||
currency={currency}
|
||||
isDefault={sameModelRef(rowRef(row), defaultModel)}
|
||||
isVisionModel={sameModelRef(rowRef(row), visionModel)}
|
||||
speed={speedResults.get(speedKey(row.provider, row.modelId))}
|
||||
onOpen={() => setEditing(rowRef(row))}
|
||||
/>
|
||||
))
|
||||
@@ -627,6 +764,26 @@ export function ModelsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{speedFor !== null && (
|
||||
<Modal open title={S.models.speedTestTitle} onClose={() => setSpeedFor(null)}>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{S.models.speedTestConfirm(rows?.filter((r) => r.provider === speedFor).length ?? 0)}
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button onClick={() => setSpeedFor(null)}>{S.common.cancel}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const id = speedFor;
|
||||
setSpeedFor(null);
|
||||
if (id) void runSpeedTest(id);
|
||||
}}
|
||||
>
|
||||
{S.models.speedTestStart}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
{addGroupOpen && (
|
||||
<Modal
|
||||
open
|
||||
@@ -718,18 +875,25 @@ export function ModelsPage() {
|
||||
// Model card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Card: display name + upstream id + status badges; context / pricing / key status folded into one line of small text. The whole card is clickable. */
|
||||
/**
|
||||
* Card: display name + upstream id + status badges; context / pricing / key status folded
|
||||
* into one line of small text; group speed-test results (TTFT / TPS, tone-colored) ride the
|
||||
* title row's right edge. The whole card is clickable (the model homepage link lives in the
|
||||
* config dialog).
|
||||
*/
|
||||
function ModelCard({
|
||||
row,
|
||||
currency,
|
||||
isDefault,
|
||||
isVisionModel,
|
||||
speed,
|
||||
onOpen,
|
||||
}: {
|
||||
row: RowState;
|
||||
currency: Currency;
|
||||
isDefault: boolean;
|
||||
isVisionModel: boolean;
|
||||
speed?: SpeedResult | "pending";
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const priced = row.cacheRead || row.cacheWrite || row.output;
|
||||
@@ -747,6 +911,40 @@ function ModelCard({
|
||||
: S.models.noKey,
|
||||
].filter((v): v is string => v !== null);
|
||||
|
||||
const speedBadges =
|
||||
speed === "pending" ? (
|
||||
<span className="shrink-0 text-[11px] text-gray-400">{S.models.speedPending}</span>
|
||||
) : speed ? (
|
||||
speed.ok ? (
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium">
|
||||
{speed.ttftMs !== undefined && (
|
||||
<span
|
||||
className={`flex items-center gap-0.5 ${TONE_CLASS[ttftTone(speed.ttftMs)]}`}
|
||||
title={S.models.ttftTitle}
|
||||
>
|
||||
<GlyphIcon d={CLOCK_ICON} size={11} />
|
||||
{Math.round(speed.ttftMs)}ms
|
||||
</span>
|
||||
)}
|
||||
{speed.tps !== undefined && (
|
||||
<span
|
||||
className={`flex items-center gap-0.5 ${TONE_CLASS[tpsTone(speed.tps)]}`}
|
||||
title={S.models.tpsTitle}
|
||||
>
|
||||
<GlyphIcon d={ZAP_ICON} size={11} />
|
||||
{speed.tps} tok/s
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="shrink-0 text-[11px] font-medium text-red-600 dark:text-red-400"
|
||||
title={speed.message}
|
||||
>
|
||||
{S.models.speedFailed}
|
||||
</span>
|
||||
)
|
||||
) : null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -767,8 +965,13 @@ function ModelCard({
|
||||
{row.modelId}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-[11px] text-gray-400 dark:text-gray-500">
|
||||
{meta.join(" · ")}
|
||||
{/* Meta line: the truncating text takes the flexible space; speed badges keep their own
|
||||
non-shrinking slot on the right so the numbers never wrap or get pushed out. */}
|
||||
<span className="flex w-full items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-gray-400 dark:text-gray-500">
|
||||
{meta.join(" · ")}
|
||||
</span>
|
||||
{speedBadges}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -1020,16 +1223,29 @@ function ModelDialog({
|
||||
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400">
|
||||
{S.models.modelId}
|
||||
</span>
|
||||
{dialogProvider?.modelsUrl && (
|
||||
<a
|
||||
href={dialogProvider.modelsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="shrink-0 text-xs text-brand-600 underline-offset-2 hover:underline dark:text-brand-300"
|
||||
>
|
||||
{S.models.getModelIds} ↗
|
||||
</a>
|
||||
)}
|
||||
<span className="flex shrink-0 items-baseline gap-2.5">
|
||||
{/* Model homepage (the model's own page; gateway groups have per-model URLs) — only for existing rows, whose identity is settled. */}
|
||||
{row && modelHomepageUrl(row.provider, row.modelId) && (
|
||||
<a
|
||||
href={modelHomepageUrl(row.provider, row.modelId)}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="shrink-0 text-xs text-brand-600 underline-offset-2 hover:underline dark:text-brand-300"
|
||||
>
|
||||
{S.models.homepage} ↗
|
||||
</a>
|
||||
)}
|
||||
{dialogProvider?.modelsUrl && (
|
||||
<a
|
||||
href={dialogProvider.modelsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="shrink-0 text-xs text-brand-600 underline-offset-2 hover:underline dark:text-brand-300"
|
||||
>
|
||||
{S.models.getModelIds} ↗
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<Input
|
||||
size="sm"
|
||||
@@ -1210,8 +1426,10 @@ function ModelDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 1) API key — the most commonly used, placed first in the field section; "get API key" link next to the label. */}
|
||||
<label className="block">
|
||||
{/* 1) API key — the most commonly used, placed first in the field section; "get API key"
|
||||
link next to the label. PasswordInput carries its own show/hide toggle and brings its
|
||||
own <label> wrapper, so this outer container is a <div> (a nested <label> is invalid). */}
|
||||
<div className="block">
|
||||
<span className="mb-1 flex items-baseline justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400">
|
||||
{S.models.apiKey}
|
||||
@@ -1227,9 +1445,8 @@ function ModelDialog({
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
<Input
|
||||
<PasswordInput
|
||||
size="sm"
|
||||
type="password"
|
||||
value={form.apiKeyInput}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => set({ apiKeyInput: e.target.value, clearApiKey: false })}
|
||||
@@ -1238,7 +1455,7 @@ function ModelDialog({
|
||||
autoFocus={!isNew}
|
||||
placeholder={apiKeyHint}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{envNote && <p className="text-xs text-gray-400 dark:text-gray-500">{envNote}</p>}
|
||||
{form.credential?.apiKeyMasked && !form.apiKeyInput && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Group speed test support: the per-model result shape and the color thresholds for the
|
||||
* card badges. Tones grade each metric independently — green is good, yellow middling,
|
||||
* red poor; thresholds are deliberately coarse (a card badge, not a benchmark).
|
||||
*/
|
||||
export interface SpeedResult {
|
||||
ok: boolean;
|
||||
/** Time to first streamed content, ms. */
|
||||
ttftMs?: number;
|
||||
/** Output tokens per second over the streaming window. */
|
||||
tps?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type SpeedTone = "green" | "yellow" | "red";
|
||||
|
||||
/** TTFT quality: under 1s green, up to 3s yellow, beyond red. */
|
||||
export function ttftTone(ms: number): SpeedTone {
|
||||
return ms < 1000 ? "green" : ms <= 3000 ? "yellow" : "red";
|
||||
}
|
||||
|
||||
/** TPS quality: 40+ tok/s green, 15+ yellow, below red. */
|
||||
export function tpsTone(tps: number): SpeedTone {
|
||||
return tps >= 40 ? "green" : tps >= 15 ? "yellow" : "red";
|
||||
}
|
||||
Reference in New Issue
Block a user