Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export { marketplaceEnv } from "./skill-market/ingest/env.js";
// search + the enumeration seam
export { searchSkills, listUnlockable } from "./search/index.js";
export type { SearchFilters, SortBy, SearchOptions, UnlockableWorkflow, UnlockOptions } from "./search/index.js";
export { dasSource, indexerSource, ownedSkills } from "./core/skillSource.js";
export { dasSource, indexerSource, ownedSkills, workflowMintsAmong } from "./core/skillSource.js";
export type { SkillSource } from "./core/skillSource.js";
// reputation (derived live from supply + reviews)
export { getReputation, getLeaderboard } from "./reputation/index.js";
Expand Down
9 changes: 8 additions & 1 deletion surfaces/localhost/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
maskedGithubToken,
loadGithubToken,
registerVerifiedWork,
workflowMintsAmong,
} from "@iqlabs-official/agent-sdk";

const PORT = Number(process.env.AGENTNET_PORT ?? 4317);
Expand Down Expand Up @@ -470,7 +471,13 @@ function attachMarketHandlers(c: Client) {
]);
const names = cards.map((card) => card.name);
const mints = Object.fromEntries(cards.map((card) => [card.name, card.id]));
c.send({ type: "ownedSkills", names, mints, disposedMints, cards });
// Ground-truth which owned mints are workflows (not `card.type`: a mint missing from
// the indexer catalog falls back to type "skill" even when it's actually a workflow —
// see ownedSkillCards). The workflow-publish picker needs this to keep owned workflows
// out of the required-skills checklist (a workflow can't require another workflow).
const allMints = [...Object.values(mints), ...Object.values(disposedMints)];
const workflowMints = allMints.length ? await workflowMintsAmong(allMints).catch(() => [] as string[]) : [];
c.send({ type: "ownedSkills", names, mints, disposedMints, cards, workflowMints });
}
// `quiet` = expected transient (no wallet yet): answer reads with empty results and
// skip every toast, so the market just shows clean empty states until the wallet lands.
Expand Down
5 changes: 3 additions & 2 deletions surfaces/webview/src/market/MarketScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,12 @@ export function MarketScreen({ tab }: { tab: ShellTab }) {
);
}

// Publish form (market tab)
// Publish form (market tab) — opens pre-set to whichever kind you were browsing (skill
// or workflow tab), same as the VSCode builder.
if (view === "publish") {
return (
<div className="flex flex-col h-full bg-zinc-950">
<PublishForm onBack={() => setView("browse")} />
<PublishForm initialKind={state.marketTab} onBack={() => setView("browse")} />
</div>
);
}
Expand Down
147 changes: 124 additions & 23 deletions surfaces/webview/src/market/PublishForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,18 @@ function PublishProgressView({ progress }: { progress: { phase: "store" | "mint"

interface Props {
onBack: () => void;
// Which kind the form opens to — e.g. hitting Publish from the Workflow browse tab should
// land straight in workflow mode, same as the VSCode builder. Defaults to "skill".
initialKind?: "skill" | "workflow";
}

export function PublishForm({ onBack }: Props) {
// A workflow can require at most 16 skills (MAX_REQUIRED_SKILLS in the agent-workflow-nft
// contract) and at least 1 — the on-chain gate that gives a workflow meaning.
const MAX_REQUIRED_SKILLS = 16;

export function PublishForm({ onBack, initialKind = "skill" }: Props) {
const { send, state, clearPublishResult } = useStore();
const [kind, setKind] = useState<"skill" | "workflow">(initialKind);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [text, setText] = useState("");
Expand All @@ -75,27 +83,59 @@ export function PublishForm({ onBack }: Props) {
const [priceSol, setPriceSol] = useState("0");
const [imageUrl, setImageUrl] = useState(""); // http/on-chain link only — no file upload
const [submitting, setSubmitting] = useState(false);
// Required-skills picker (workflow mode only): mint -> selected.
const [reqSel, setReqSel] = useState<Record<string, boolean>>({});

const t = PUBLISH_THEME[kind];
const result = state.publishResult;

// Skills the wallet actually owns and can require: must have a resolvable mint, and must
// NOT itself be a workflow (the on-chain gate rejects a workflow as a required_skill).
const ownedSkillNames = state.marketOwned.filter((n) => {
const mint = state.marketOwnedMints[n];
return !!mint && !state.marketOwnedWorkflowMints.includes(mint);
});
const chosenReqMints = Object.keys(reqSel).filter((m) => reqSel[m]);

// A result (success OR failure) ends the in-flight state so the form/button come back.
useEffect(() => { if (result) setSubmitting(false); }, [result]);

function handleSubmit() {
if (!name.trim() || !text.trim()) return;
if (!name.trim()) return;
if (kind === "skill" && !text.trim()) return;
if (kind === "workflow" && (chosenReqMints.length === 0 || chosenReqMints.length > MAX_REQUIRED_SKILLS)) return;
setSubmitting(true);
clearPublishResult();
// Workflow mode: synthesize the SKILL.md frontmatter (type: workflow + requiredSkills) so
// the current backend's frontmatter sniff (env.ts) mints it as a workflow — same trick the
// VSCode webview builder uses. No SKILL.md body to author; the workflow IS its required skills.
const body = kind === "workflow"
? [
"---",
`name: ${name.trim()}`,
`description: ${description.trim().replace(/\s*\n\s*/g, " ")}`,
"type: workflow",
`requiredSkills: [${chosenReqMints.join(", ")}]`,
"---",
"",
`# ${name.trim()}`,
"",
description.trim(),
"",
].join("\n")
: text.trim();
// Image is a link ONLY (http URL or on-chain address). The app can't attach/upload a
// file, and a link stays tiny so the body doesn't chunk on-chain (keeps signatures low).
send({
type: "publishSkill",
name: name.trim(),
description: description.trim(),
text: text.trim(),
text: body,
category: category.trim() || undefined,
hashtags: hashtags.split(",").map((h) => h.trim()).filter(Boolean),
priceSol: priceSol || "0",
image: imageUrl.trim() || undefined,
...(kind === "workflow" ? { kind: "workflow" as const, requiredSkills: chosenReqMints } : {}),
});
setTimeout(() => setSubmitting(false), 15000);
}
Expand All @@ -105,13 +145,13 @@ export function PublishForm({ onBack }: Props) {
if (result?.ok) {
return (
<div className="flex flex-col h-full">
<header className="flex items-center gap-2 border-b border-zinc-800 px-3 py-2 shrink-0">
<header className={`flex items-center gap-2 border-b ${t.border} px-3 py-2 shrink-0`}>
<button onClick={() => { clearPublishResult(); onBack(); }} className="text-zinc-400 active:text-zinc-200 px-1 text-lg">←</button>
<span className="font-medium text-sm">Publish Skill</span>
<span className="font-medium text-sm">Publish {kind === "workflow" ? "Workflow" : "Skill"}</span>
</header>
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-6 text-center bg-gradient-to-b from-purple-900/25 to-transparent">
<SkillIcon className="h-10 w-10 text-purple-400" />
<p className="text-purple-300 font-semibold">Skill minted!</p>
<div className={`flex-1 flex flex-col items-center justify-center gap-3 p-6 text-center bg-gradient-to-b ${t.wash} to-transparent`}>
<SkillIcon className={`h-10 w-10 ${t.icon}`} />
<p className={`${t.label} font-semibold`}>{kind === "workflow" ? "Workflow minted!" : "Skill minted!"}</p>
{result.mint && <p className="font-mono text-xs text-zinc-500">{result.mint}</p>}
<button onClick={() => { clearPublishResult(); onBack(); }} className="mt-2 text-sm text-zinc-400 underline">Back to market</button>
</div>
Expand Down Expand Up @@ -144,35 +184,91 @@ export function PublishForm({ onBack }: Props) {
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><path d="M15 6l-6 6 6 6" /></svg>
</button>
<span className="an-term-title text-[16px]">Publish Skill</span>
<span className="an-term-title text-[16px]">Publish {kind === "workflow" ? "Workflow" : "Skill"}</span>
</header>

<div className="flex-1 overflow-y-auto p-3.5 space-y-4">
{/* Skill/workflow toggle: a workflow is defined by the skills it requires (the
on-chain gate), so workflow mode swaps the SKILL.md box below for a checklist. */}
<div className="flex gap-2">
{(["skill", "workflow"] as const).map((k) => {
const active = kind === k;
const kt = PUBLISH_THEME[k];
return (
<button
key={k}
type="button"
onClick={() => setKind(k)}
className={`flex-1 rounded-lg border py-2 text-[10px] font-bold uppercase tracking-wider transition-colors ${active ? `${kt.border} ${kt.onText}` : "border-zinc-800 text-zinc-500"}`}
style={active ? { background: k === "skill" ? "rgba(139,92,246,0.12)" : "rgba(240,145,62,0.12)" } : undefined}
>
{k}
</button>
);
})}
</div>
<Field label="Name *">
<input
className="an-term-field"
placeholder="My Skill"
placeholder={kind === "workflow" ? "My Workflow" : "My Skill"}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<Field label="Description">
<input
className="an-term-field"
placeholder="What does this skill do?"
placeholder={kind === "workflow" ? "What does this workflow do?" : "What does this skill do?"}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</Field>
<Field label="SKILL.md content *">
<textarea
className="an-term-field"
rows={8}
placeholder="# My Skill&#10;&#10;## Description&#10;…"
value={text}
onChange={(e) => setText(e.target.value)}
/>
</Field>
{kind === "workflow" ? (
<Field label="Required skills *">
<p className="mb-1.5 text-[10px] leading-relaxed text-zinc-600">
Pick the skills you own that this workflow combines. Buyers must hold every one to unlock it (max {MAX_REQUIRED_SKILLS}).
</p>
{ownedSkillNames.length === 0 ? (
<div className="rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2.5 text-xs text-zinc-600">
You don&apos;t own any skills yet. Buy at least one before publishing a workflow.
</div>
) : (
<>
<div className="flex max-h-[220px] flex-col gap-0.5 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-950 p-2">
{ownedSkillNames.map((n) => {
const mint = state.marketOwnedMints[n];
return (
<label key={mint} className="flex items-center gap-2.5 py-1 text-[13px] text-zinc-300 active:opacity-80">
<input
type="checkbox"
checked={!!reqSel[mint]}
onChange={(e) => setReqSel((s) => ({ ...s, [mint]: e.target.checked }))}
className="h-4 w-4 shrink-0 accent-amber-500"
/>
<span className="truncate">{n}</span>
</label>
);
})}
</div>
{chosenReqMints.length > 0 && (
<p className="mt-1.5 font-mono text-[10px] text-zinc-500">
{chosenReqMints.length} selected{chosenReqMints.length > MAX_REQUIRED_SKILLS ? ` — max ${MAX_REQUIRED_SKILLS}, deselect some` : ""}
</p>
)}
</>
)}
</Field>
) : (
<Field label="SKILL.md content *">
<textarea
className="an-term-field"
rows={8}
placeholder="# My Skill&#10;&#10;## Description&#10;…"
value={text}
onChange={(e) => setText(e.target.value)}
/>
</Field>
)}
<Field label="Category">
<input
className="an-term-field"
Expand Down Expand Up @@ -240,10 +336,15 @@ export function PublishForm({ onBack }: Props) {
)}
<button
onClick={handleSubmit}
disabled={submitting || !name.trim() || !text.trim() || !imageValid}
className="an-btn an-btn-violet"
disabled={
submitting ||
!name.trim() ||
!imageValid ||
(kind === "skill" ? !text.trim() : chosenReqMints.length === 0 || chosenReqMints.length > MAX_REQUIRED_SKILLS)
}
className={`an-btn ${kind === "workflow" ? "an-btn-orange" : "an-btn-violet"}`}
>
{submitting ? "Minting NFT…" : "Mint & Publish"}
{submitting ? "Minting NFT…" : `Mint & Publish${kind === "workflow" ? " Workflow" : ""}`}
</button>
</div>
</div>
Expand Down
7 changes: 7 additions & 0 deletions surfaces/webview/src/state/store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export interface State {
marketOwned: string[];
marketOwnedMints: Record<string, string>;
marketOwnedCards: SkillCard[]; // wallet's on-chain owned skill cards (My Skills grid)
// Of the owned mints, which are workflows (ground-truth on-chain read, not `card.type`).
// The workflow-publish picker excludes these — a workflow can't require another workflow.
marketOwnedWorkflowMints: string[];
marketDisposed: Record<string, string>;
marketBalance: number | null;
// Fund prompt (Get devnet SOL): opened when a buy fails for insufficient funds; `funding`
Expand Down Expand Up @@ -164,6 +167,7 @@ const initialState: State = {
marketOwned: [],
marketOwnedMints: {},
marketOwnedCards: [],
marketOwnedWorkflowMints: [],
marketDisposed: {},
marketBalance: null,
fundOpen: false,
Expand Down Expand Up @@ -522,6 +526,9 @@ function reducer(state: State, ev: Action): State {
// Only chain-sourced emits carry `cards`; keep the existing grid on a names-only
// emit (chat panel / post-buy refresh) instead of blanking My Skills.
marketOwnedCards: ev.cards ?? state.marketOwnedCards,
// Only a chain-sourced emit carries workflowMints; keep the existing set on a
// names-only emit instead of wrongly clearing it back to [].
marketOwnedWorkflowMints: ev.workflowMints ?? state.marketOwnedWorkflowMints,
marketDisposed: ev.disposedMints ?? {},
};
case "balance":
Expand Down
6 changes: 5 additions & 1 deletion surfaces/webview/src/transport/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ export type ClientMessage =
hashtags?: string[];
priceSol: string;
image?: string;
// forward-compat with the newer contract path (#95): current backend ignores these
// and instead sniffs `type: workflow` / `requiredSkills` out of the frontmatter in `text`.
kind?: "skill" | "workflow";
requiredSkills?: string[];
}
| { type: "submitGithubToken"; token: string }
| { type: "clearGithubToken" }
Expand Down Expand Up @@ -211,7 +215,7 @@ export type ServerMessage =
| { type: "buyResult"; skillId: string; ok: boolean; slug?: string; error?: string; code?: "insufficient_funds" }
| { type: "disposeResult"; skillId: string; ok: boolean; slug?: string; error?: string }
| { type: "reEquipResult"; skillId: string; ok: boolean; slug?: string; error?: string }
| { type: "ownedSkills"; names: string[]; mints?: Record<string, string>; disposedMints?: Record<string, string>; cards?: import("@iqlabs-official/agent-sdk").SkillCard[] }
| { type: "ownedSkills"; names: string[]; mints?: Record<string, string>; disposedMints?: Record<string, string>; cards?: import("@iqlabs-official/agent-sdk").SkillCard[]; workflowMints?: string[] }
| { type: "balance"; lamports: number | null }
| { type: "airdropResult"; ok: boolean; lamports?: number; error?: string }
| { type: "skillActive"; name: string }
Expand Down