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:
@@ -53,6 +53,9 @@ const CARD_ICONS = {
|
||||
vaultKeys: "M15.5 7.5l3 3L22 7l-3-3M21 2l-9.6 9.6M13 15.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z",
|
||||
/** Schedule count (alarm clock: dial + hands + twin bells, distinct from the plain clock face used for "last modified") */
|
||||
schedules: "M12 21a7 7 0 1 0 0-14 7 7 0 0 0 0 14zm0-10v3l2 1.5M5 3L2.5 5.5M19 3l2.5 2.5",
|
||||
/** Installed skill count (open book, same family as the skill library) */
|
||||
skills:
|
||||
"M12 6.5C10.5 5 8 4.5 4 5v12c4-.5 6.5 0 8 1.5 1.5-1.5 4-2 8-1.5V5c-4-.5-6.5 0-8 1.5zm0 0V18",
|
||||
/** Usage (bar chart, same as sidebar "Usage Center") */
|
||||
usage: "M4 20V10m6 10V4m6 16v-7m4 7H2",
|
||||
/** Traces (eye line icon: observability; follows text color, no fill) */
|
||||
@@ -245,6 +248,13 @@ export function AgentsPage() {
|
||||
<GlyphIcon d={CARD_ICONS.schedules} size={12} />
|
||||
{a.scheduleCount}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
|
||||
title={S.skills.skillCount(a.skillCount)}
|
||||
>
|
||||
<GlyphIcon d={CARD_ICONS.skills} size={12} />
|
||||
{a.skillCount}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1"
|
||||
title={`${S.agent.updatedAt} ${a.updatedAt ? formatDateTime(a.updatedAt) : "—"}`}
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
* Readable by any member; toggle/edit/delete are owner-only — PUT has whole-file
|
||||
* replace semantics, so toggling also resends every field and only flips `enabled`.
|
||||
* startAt/endAt use datetime-local inputs (local timezone), converted to ISO 8601 on
|
||||
* submit; the "new Session each run" mode can also pick a Model (a provider + modelId
|
||||
* pair; defaults to the Project default; mutual exclusivity with sessionId is
|
||||
* validated server-side).
|
||||
* submit; the "new Session each run" mode can also pick a Model — always a complete
|
||||
* (provider, modelId) pair, since provider is never inferred; omitting it entirely falls
|
||||
* back to the Project default. Mutual exclusivity with sessionId is validated server-side.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type {
|
||||
ModelInfo,
|
||||
ModelRefDto,
|
||||
ScheduleItem,
|
||||
SchedulesResponse,
|
||||
ScheduleStatus,
|
||||
@@ -49,37 +50,35 @@ function toLocalInput(iso: string | undefined): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model selection value in the form: a paired reference. provider may be
|
||||
* omitted — when a hand-written TOML only specifies model_id, it's resolved by
|
||||
* unique match at parse time; editing never fills in a provider on its own.
|
||||
*/
|
||||
interface ScheduleModelRef {
|
||||
provider?: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model reference ↔ native select option value: encoded as a JSON array
|
||||
* ([provider ?? null, modelId]), used only as a transient DOM-value serialization —
|
||||
* ([provider, modelId]), used only as a transient DOM-value serialization —
|
||||
* it never enters storage or requests; the persisted/submitted data still keeps
|
||||
* provider and modelId as two separate fields (no concatenation anywhere in the
|
||||
* pipeline). "" means the Project default.
|
||||
*/
|
||||
const modelOptionValue = (ref: ScheduleModelRef): string =>
|
||||
JSON.stringify([ref.provider ?? null, ref.modelId]);
|
||||
const modelOptionValue = (ref: ModelRefDto): string => JSON.stringify([ref.provider, ref.modelId]);
|
||||
|
||||
const parseModelOption = (v: string): ScheduleModelRef | null => {
|
||||
const parseModelOption = (v: string): ModelRefDto | null => {
|
||||
if (!v) return null;
|
||||
const [provider, modelId] = JSON.parse(v) as [string | null, string];
|
||||
return provider === null ? { modelId } : { provider, modelId };
|
||||
const [provider, modelId] = JSON.parse(v) as [string, string];
|
||||
return { provider, modelId };
|
||||
};
|
||||
|
||||
/** Display label for a model option: upstream id + provider name (shown side by side, not a composite id). */
|
||||
const modelOptionLabel = (ref: ScheduleModelRef): string =>
|
||||
ref.provider === undefined
|
||||
? ref.modelId
|
||||
: `${ref.modelId} · ${providerInfo(ref.provider)?.label ?? ref.provider}`;
|
||||
const modelOptionLabel = (ref: ModelRefDto): string =>
|
||||
`${ref.modelId} · ${providerInfo(ref.provider)?.label ?? ref.provider}`;
|
||||
|
||||
/**
|
||||
* Stored schedule fields → a model reference. A reference is always the complete
|
||||
* (provider, model_id) pair, since provider is never inferred: the DTO types the two
|
||||
* fields independently, so this guard is what keeps the form and the upsert body from
|
||||
* ever assembling half a reference. A file that sets only one half is rejected by the
|
||||
* server when parsed (it surfaces under invalidFiles, never as a listed row), so in
|
||||
* practice this returns null only when the schedule uses the Project's default model.
|
||||
*/
|
||||
const itemModelRef = (item: Pick<ScheduleItem, "provider" | "modelId">): ModelRefDto | null =>
|
||||
item.modelId && item.provider ? { provider: item.provider, modelId: item.modelId } : null;
|
||||
|
||||
/** Modal form state (shared by create/edit): non-null editing means editing that task (name locked). */
|
||||
interface FormState {
|
||||
@@ -94,8 +93,8 @@ interface FormState {
|
||||
target: "new" | "session";
|
||||
sessionId: string;
|
||||
workspace: string;
|
||||
/** Model for the new-Session mode (null = Project default, modelId/provider omitted). */
|
||||
model: ScheduleModelRef | null;
|
||||
/** Model for the new-Session mode (null = Project default, provider and modelId both omitted). */
|
||||
model: ModelRefDto | null;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
@@ -191,10 +190,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
? { workspace: form.workspace.trim() }
|
||||
: {}),
|
||||
...(form.target === "new" && form.model
|
||||
? {
|
||||
modelId: form.model.modelId,
|
||||
...(form.model.provider !== undefined ? { provider: form.model.provider } : {}),
|
||||
}
|
||||
? { modelId: form.model.modelId, provider: form.model.provider }
|
||||
: {}),
|
||||
};
|
||||
setBusy(true);
|
||||
@@ -218,6 +214,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
const model = itemModelRef(item);
|
||||
try {
|
||||
await api.updateSchedule(projectId, agentId, item.name, {
|
||||
prompt: item.prompt,
|
||||
@@ -227,9 +224,8 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
...(item.endAt !== undefined ? { endAt: item.endAt } : {}),
|
||||
...(item.sessionId !== undefined ? { sessionId: item.sessionId } : {}),
|
||||
...(item.workspace !== undefined ? { workspace: item.workspace } : {}),
|
||||
// Model reference is resent as a pair (a hand-written file with provider omitted stays omitted).
|
||||
...(item.modelId !== undefined ? { modelId: item.modelId } : {}),
|
||||
...(item.provider !== undefined ? { provider: item.provider } : {}),
|
||||
// Model reference is resent as a whole pair or not at all — never half of one.
|
||||
...(model ? { modelId: model.modelId, provider: model.provider } : {}),
|
||||
});
|
||||
setNotice(S.common.saved);
|
||||
await load();
|
||||
@@ -240,7 +236,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
/** Edit: prefill this row into the modal form (submits via PUT; model reference prefilled as a pair, provider not auto-filled when absent). */
|
||||
/** Edit: prefill this row into the modal form (submits via PUT; the model reference is prefilled only as a complete pair). */
|
||||
const startEdit = (item: ScheduleItem) => {
|
||||
openForm({
|
||||
editing: item.name,
|
||||
@@ -253,13 +249,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
target: item.sessionId ? "session" : "new",
|
||||
sessionId: item.sessionId ?? "",
|
||||
workspace: item.workspace ?? "",
|
||||
model:
|
||||
item.modelId !== undefined
|
||||
? {
|
||||
modelId: item.modelId,
|
||||
...(item.provider !== undefined ? { provider: item.provider } : {}),
|
||||
}
|
||||
: null,
|
||||
model: itemModelRef(item),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -494,9 +484,9 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
onChange={(e) => set({ model: parseModelOption(e.target.value) })}
|
||||
>
|
||||
<option value="">{S.schedule.modelDefault}</option>
|
||||
{/* When the prefilled reference is no longer in the model config (including a
|
||||
hand-written file missing only provider), add an extra option so it isn't
|
||||
displayed as "Project default". */}
|
||||
{/* When the prefilled pair is no longer in the model config (the entry was
|
||||
renamed or deleted), add an extra option so it isn't displayed as
|
||||
"Project default". */}
|
||||
{form.model &&
|
||||
!models.some(
|
||||
(m) =>
|
||||
|
||||
Reference in New Issue
Block a user