Files
multica/packages/core/api/schemas.ts
Naiyuan Qing 5f2222b83d feat(agents): one-click create-from-template UI
Frontend half of Phase 1. CreateAgentDialog becomes a state machine
spanning four steps:

  chooser          → Start blank / From template cards
  blank-form       → existing manual form (post-chooser)
  duplicate-form   → existing form pre-filled from a duplicated agent
  template-picker  → grid of templates, click navigates to detail
  template-detail  → instructions + skill list preview + one-click Use

Picking a template never lands on the form: name auto-deduped against
existingAgentNames, runtime = first usable one, visibility = private.
Refinement happens on the agent detail page if needed. Same rationale
the doc spells out — templates exist precisely to skip configuration.

New components, all collapsible-by-default so quick-create stays fast:
  - template-picker.tsx — categorised grid, lucide icons + semantic
    accent tokens resolved through static maps so Tailwind's JIT picks
    up every variant (dynamic class strings would silently miss).
  - template-detail.tsx — instructions preview, skill list with cached
    descriptions, Use CTA. Renders the failedURLs banner when a 422
    fires — the only step that can trigger that response.
  - instructions-editor.tsx — collapsed preview-card / expanded full
    ContentEditor.
  - skill-multi-select.tsx + skill-picker-list.tsx — shared multi-
    select surface, also adopted by the existing skill-add-dialog.
  - avatar-picker.tsx — agent avatar upload, mirrors the inspector's
    visual language.

Schema-defended client (CLAUDE.md → API Response Compatibility): the
three new endpoints are wired through parseWithFallback with lenient
zod schemas. Desktop builds outlive any given server — a future
field rename / wrapping must not white-screen older installs.
listAgentTemplates accepts both the current bare array and a future
{templates: [...]} envelope. Coverage: 7 new schema-test cases in
schema.test.ts (null body, missing skills/instructions, malformed
create response, envelope migration).

Catalog + detail go through TanStack Query with staleTime: Infinity —
workspace-independent static data, no per-mount refetch.

Other:
- skill-add-dialog becomes a true multi-select (Confirm button +
  checkbox list); attached skills are filtered out of the list.
- agents-page hands the freshly-created Agent back to the dialog so a
  follow-up setAgentSkills can attach the form-selected skills.
- agent-overview-pane drops the mx-auto/max-w-2xl frame on config-
  tab content; the wider dialog visual language reads better with
  tabs filling the column.
- Every new UI string lives in both en/agents.json and
  zh-Hans/agents.json under create_dialog.* / tab_body.skills.* —
  locales/parity.test.ts blocks drift in CI.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:47:21 +08:00

309 lines
11 KiB
TypeScript

import { z } from "zod";
import type {
Agent,
AgentTemplate,
AgentTemplateSummary,
Attachment,
CreateAgentFromTemplateResponse,
ListIssuesResponse,
TimelineEntry,
} from "../types";
// ---------------------------------------------------------------------------
// Schemas for the highest-risk API endpoints — those whose responses drive
// the issue detail page (timeline, comments, subscribers) and the issues
// list. These are the surfaces that white-screened in #2143 / #2147 / #2192.
//
// These schemas are intentionally LENIENT:
// - String enums are stored as `z.string()` rather than `z.enum([...])`.
// A new server-side enum value should render as a generic fallback in
// the UI, never crash a `safeParse`.
// - Optional fields are unioned with `null` and given fallbacks where
// existing UI code already coerces them.
// - Arrays default to `[]` so a missing `reactions` / `attachments` /
// `entries` field doesn't take the page down.
// - Every object schema ends with `.loose()` so unknown server-side
// fields pass through unchanged. zod 4's `.object()` defaults to STRIP,
// which would silently delete fields the schema didn't explicitly list
// — fine while the TS type doesn't claim them, but the moment a future
// PR adds a TS field without updating the schema, the cast `as T` lies
// and the field shows up as `undefined` at runtime. `.loose()` removes
// that synchronisation hazard.
//
// These schemas are deliberately not typed as `z.ZodType<TimelineEntry>` /
// `z.ZodType<Issue>` etc. — the strict TS types narrow string fields to
// literal unions, which would defeat the leniency above. `parseWithFallback`
// returns the parsed value cast to the caller-supplied `T`, so the strict
// type still flows out at the call site; the schema only guards shape.
// ---------------------------------------------------------------------------
const ReactionSchema = z.object({
id: z.string(),
comment_id: z.string(),
actor_type: z.string(),
actor_id: z.string(),
emoji: z.string(),
created_at: z.string(),
});
// Nested attachments embedded in timeline/comment responses stay lenient on
// purpose: a single malformed attachment must not knock the whole timeline
// into the fallback `[]`.
const AttachmentSchema = z.object({
id: z.string(),
}).loose();
// Standalone attachment lookup (`GET /api/attachments/{id}`) is the source of
// truth for click-time download URLs. The two fields the download flow opens
// in a new tab — `download_url` and `url` — must be strings, otherwise we'd
// happily `window.open(undefined)`. `filename` gates the toast/title and is
// also enforced so a missing value falls back to the empty record below.
export const AttachmentResponseSchema = z.object({
id: z.string(),
url: z.string(),
download_url: z.string(),
filename: z.string(),
chat_session_id: z.string().nullable().optional(),
chat_message_id: z.string().nullable().optional(),
}).loose();
export const EMPTY_ATTACHMENT: Attachment = {
id: "",
workspace_id: "",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "",
uploader_id: "",
filename: "",
url: "",
download_url: "",
content_type: "",
size_bytes: 0,
created_at: "",
};
// All object schemas use `.loose()` so unknown server-side fields pass
// through unchanged. zod 4's `.object()` defaults to STRIP, which would
// silently drop new fields and surface as a "field neither showed up in
// the UI" mystery the next time the TS type adopted them but the schema
// wasn't updated in lock-step. `.loose()` removes that synchronisation
// hazard — the schema validates the shape it knows about and leaves the
// rest alone.
const TimelineEntrySchema = z.object({
type: z.string(),
id: z.string(),
actor_type: z.string(),
actor_id: z.string(),
created_at: z.string(),
action: z.string().optional(),
details: z.record(z.string(), z.unknown()).optional(),
content: z.string().optional(),
parent_id: z.string().nullable().optional(),
updated_at: z.string().optional(),
comment_type: z.string().optional(),
reactions: z.array(ReactionSchema).optional(),
attachments: z.array(AttachmentSchema).optional(),
coalesced_count: z.number().optional(),
}).loose();
// /timeline returns a flat array of TimelineEntry, oldest first. The
// previously cursor-paginated wrapper was removed (#1929) — at observed data
// sizes (p99 ~30 entries per issue) paged delivery only created bugs.
export const TimelineEntriesSchema = z.array(TimelineEntrySchema);
export const EMPTY_TIMELINE_ENTRIES: TimelineEntry[] = [];
export const CommentSchema = z.object({
id: z.string(),
issue_id: z.string(),
author_type: z.string(),
author_id: z.string(),
content: z.string(),
type: z.string(),
parent_id: z.string().nullable(),
reactions: z.array(ReactionSchema).default([]),
attachments: z.array(AttachmentSchema).default([]),
created_at: z.string(),
updated_at: z.string(),
}).loose();
export const CommentsListSchema = z.array(CommentSchema);
const IssueSchema = z.object({
id: z.string(),
workspace_id: z.string(),
number: z.number(),
identifier: z.string(),
title: z.string(),
description: z.string().nullable(),
status: z.string(),
priority: z.string(),
assignee_type: z.string().nullable(),
assignee_id: z.string().nullable(),
creator_type: z.string(),
creator_id: z.string(),
parent_issue_id: z.string().nullable(),
project_id: z.string().nullable(),
position: z.number(),
due_date: z.string().nullable(),
reactions: z.array(z.unknown()).optional(),
labels: z.array(z.unknown()).optional(),
created_at: z.string(),
updated_at: z.string(),
}).loose();
export const ListIssuesResponseSchema = z.object({
issues: z.array(IssueSchema).default([]),
total: z.number().default(0),
}).loose();
export const EMPTY_LIST_ISSUES_RESPONSE: ListIssuesResponse = {
issues: [],
total: 0,
};
const SubscriberSchema = z.object({
issue_id: z.string(),
user_type: z.string(),
user_id: z.string(),
reason: z.string(),
created_at: z.string(),
}).loose();
export const SubscribersListSchema = z.array(SubscriberSchema);
export const ChildIssuesResponseSchema = z.object({
issues: z.array(IssueSchema).default([]),
}).loose();
// ---------------------------------------------------------------------------
// Workspace dashboard schemas
//
// The dashboard hits three independent rollup endpoints. Each returns a flat
// array, and every field is consumed by chart / KPI math — a missing number
// silently degrades to NaN downstream, so we coerce missing numbers to 0.
// String fields stay lenient (no enum narrowing) to survive future model /
// agent ID drift.
// ---------------------------------------------------------------------------
const DashboardUsageDailySchema = z.object({
date: z.string(),
model: z.string(),
input_tokens: z.number().default(0),
output_tokens: z.number().default(0),
cache_read_tokens: z.number().default(0),
cache_write_tokens: z.number().default(0),
task_count: z.number().default(0),
}).loose();
export const DashboardUsageDailyListSchema = z.array(DashboardUsageDailySchema);
const DashboardUsageByAgentSchema = z.object({
agent_id: z.string(),
model: z.string(),
input_tokens: z.number().default(0),
output_tokens: z.number().default(0),
cache_read_tokens: z.number().default(0),
cache_write_tokens: z.number().default(0),
task_count: z.number().default(0),
}).loose();
export const DashboardUsageByAgentListSchema = z.array(DashboardUsageByAgentSchema);
const DashboardAgentRunTimeSchema = z.object({
agent_id: z.string(),
total_seconds: z.number().default(0),
task_count: z.number().default(0),
failed_count: z.number().default(0),
}).loose();
export const DashboardAgentRunTimeListSchema = z.array(DashboardAgentRunTimeSchema);
// ---------------------------------------------------------------------------
// Agent template catalog — `/api/agent-templates*` and the
// create-from-template response. The desktop app's create-agent picker
// reaches these endpoints, and a future server change to the template shape
// would white-screen older installed builds (#2192 pattern) without these
// parsers. Lenient by the same rules as IssueSchema above: arrays default to
// `[]`, optional fields stay optional, `.loose()` lets unknown fields pass
// through unchanged.
// ---------------------------------------------------------------------------
const AgentTemplateSkillRefSchema = z.object({
source_url: z.string(),
cached_name: z.string().default(""),
cached_description: z.string().default(""),
}).loose();
const AgentTemplateSummarySchemaBase = z.object({
slug: z.string(),
name: z.string(),
description: z.string().default(""),
category: z.string().optional(),
icon: z.string().optional(),
accent: z.string().optional(),
// skills MUST default to [] — picker code reads `template.skills.length`
// and `.map(...)`, both of which crash on `undefined`. The most common
// future drift (field renamed / wrapped) lands here.
skills: z.array(AgentTemplateSkillRefSchema).default([]),
}).loose();
export const AgentTemplateSummarySchema = AgentTemplateSummarySchemaBase;
// List endpoint historically returns a bare array. Server could legitimately
// migrate to `{templates: [...]}` later — we accept either shape so an old
// desktop survives the upgrade.
export const AgentTemplateSummaryListSchema = z.union([
z.array(AgentTemplateSummarySchemaBase),
z.object({ templates: z.array(AgentTemplateSummarySchemaBase).default([]) })
.loose()
.transform((v) => v.templates),
]);
export const EMPTY_AGENT_TEMPLATE_SUMMARY_LIST: AgentTemplateSummary[] = [];
export const AgentTemplateSchema = AgentTemplateSummarySchemaBase.extend({
// Detail-only field. Default "" so a malformed detail still renders the
// header + skill list; the user just sees an empty Instructions block.
instructions: z.string().default(""),
}).loose();
// Used as the parse fallback for `GET /api/agent-templates/:slug`. Slug comes
// from the URL, so we round-trip the requested one back into the fallback
// at the call site (see `getAgentTemplate` in client.ts).
export const EMPTY_AGENT_TEMPLATE_DETAIL: AgentTemplate = {
slug: "",
name: "",
description: "",
skills: [],
instructions: "",
};
// `agent` is a full Agent record — schematising every field would duplicate
// a 50-field interface and bit-rot fast. We keep it loose and require only
// `id`, the one field the create-from-template flow consumes (used to
// navigate to the new agent's detail page). Downstream code already
// optional-chains the rest.
const MinimalAgentSchema = z.object({
id: z.string(),
}).loose();
export const CreateAgentFromTemplateResponseSchema = z.object({
agent: MinimalAgentSchema,
imported_skill_ids: z.array(z.string()).default([]),
reused_skill_ids: z.array(z.string()).default([]),
}).loose();
// Fallback when the success response fails to parse. The agent server-side
// has likely been created already, so we can't pretend nothing happened —
// the caller (`create-agent-dialog.tsx`) is responsible for noticing
// `agent.id === ""` and skipping navigation while keeping the list
// invalidation, so the user finds their new agent in the list.
export const EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE: CreateAgentFromTemplateResponse = {
agent: { id: "" } as Agent,
imported_skill_ids: [],
reused_skill_ids: [],
};