mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(daemon, web): implement plugin sharing project creation and enhance CLI functionality
- Added new flags for conversation, message, agent, and model in the CLI to support enhanced plugin sharing features. - Introduced a new API endpoint for creating share projects for plugins, allowing users to publish to GitHub or contribute to Open Design. - Updated the UI components to facilitate the new sharing functionalities, including prompts for user input during the sharing process. - Enhanced the project management system to handle new plugin share actions, improving user interaction and experience. - Added tests to ensure the reliability of the new sharing features and their integration within the existing plugin management system. This update significantly enhances the plugin ecosystem by enabling users to share their creations more effectively and streamline collaboration.
This commit is contained in:
parent
1f4259a190
commit
c36609c47d
34 changed files with 2025 additions and 79 deletions
|
|
@ -74,6 +74,10 @@ const PLUGIN_STRING_FLAGS = new Set([
|
|||
'source',
|
||||
'inputs',
|
||||
'project',
|
||||
'conversation',
|
||||
'message',
|
||||
'agent',
|
||||
'model',
|
||||
'snapshot-id',
|
||||
'capabilities',
|
||||
'grant-caps',
|
||||
|
|
@ -128,6 +132,25 @@ const DAEMON_BOOLEAN_FLAGS = new Set([
|
|||
]);
|
||||
const LIBRARY_STRING_FLAGS = new Set(['daemon-url', 'query', 'tag']);
|
||||
const LIBRARY_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']);
|
||||
const PROJECT_STRING_FLAGS = new Set([
|
||||
'daemon-url', 'name', 'skill', 'design-system', 'plugin', 'metadata-json',
|
||||
'pending-prompt', 'project', 'conversation', 'message', 'path', 'as',
|
||||
'agent', 'model', 'snapshot-id', 'inputs', 'grant-caps',
|
||||
]);
|
||||
const PROJECT_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'follow']);
|
||||
const RECOVERABLE_EXIT_CODES = {
|
||||
'daemon-not-running': 64,
|
||||
'plugin-not-found': 65,
|
||||
'snapshot-not-found': 65,
|
||||
'capabilities-required': 66,
|
||||
'missing-input': 67,
|
||||
'project-not-found': 68,
|
||||
'run-not-found': 69,
|
||||
'provider-not-configured': 70,
|
||||
'plugin-requires-daemon': 71,
|
||||
'snapshot-stale': 72,
|
||||
'genui-surface-awaiting': 73,
|
||||
};
|
||||
const PLUGIN_LIST_FILTER_FLAGS = new Set([
|
||||
...PLUGIN_STRING_FLAGS,
|
||||
'task-kind', 'mode', 'tag', 'trust',
|
||||
|
|
@ -808,20 +831,6 @@ must be running locally for tool calls to succeed.`);
|
|||
// code + a JSON envelope on stderr. Code agents read these to decide
|
||||
// whether the failure is recoverable (re-grant capabilities, prompt
|
||||
// the user, retry with --grant-caps, etc.).
|
||||
const RECOVERABLE_EXIT_CODES = {
|
||||
'daemon-not-running': 64,
|
||||
'plugin-not-found': 65,
|
||||
'snapshot-not-found': 65,
|
||||
'capabilities-required': 66,
|
||||
'missing-input': 67,
|
||||
'project-not-found': 68,
|
||||
'run-not-found': 69,
|
||||
'provider-not-configured': 70,
|
||||
'plugin-requires-daemon': 71,
|
||||
'snapshot-stale': 72,
|
||||
'genui-surface-awaiting': 73,
|
||||
};
|
||||
|
||||
function exitWithStructuredError({ code, message, data }) {
|
||||
const exit = RECOVERABLE_EXIT_CODES[code] ?? 1;
|
||||
const envelope = { error: { code, message, data: data ?? {} } };
|
||||
|
|
@ -1446,8 +1455,7 @@ async function runPluginSnapshots(args) {
|
|||
|
||||
// Plan §3.B3: `od plugin run <id>` shorthand. Today this is a thin
|
||||
// wrapper around `od plugin apply` + `POST /api/runs` so a code agent
|
||||
// can drive the apply→start→follow loop without two hops. Phase 4
|
||||
// adds full ND-JSON event streaming through `od run watch`.
|
||||
// can drive the apply→start→follow loop without two hops.
|
||||
async function runPluginRun(rest) {
|
||||
const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS });
|
||||
const id = rest.find((a) => !a.startsWith('-')
|
||||
|
|
@ -1455,11 +1463,15 @@ async function runPluginRun(rest) {
|
|||
&& a !== flags.source
|
||||
&& a !== flags.inputs
|
||||
&& a !== flags.project
|
||||
&& a !== flags.conversation
|
||||
&& a !== flags.message
|
||||
&& a !== flags.agent
|
||||
&& a !== flags.model
|
||||
&& a !== flags['snapshot-id']
|
||||
&& a !== flags.capabilities
|
||||
&& a !== flags['grant-caps']);
|
||||
if (!id) {
|
||||
console.error('Usage: od plugin run <id> --project <projectId> [--inputs <json>] [--grant-caps a,b]');
|
||||
console.error('Usage: od plugin run <id> --project <projectId> [--inputs <json>] [--agent <id>] [--message "<text>"] [--grant-caps a,b] [--follow]');
|
||||
process.exit(2);
|
||||
}
|
||||
if (!flags.project) {
|
||||
|
|
@ -1492,6 +1504,11 @@ async function runPluginRun(rest) {
|
|||
pluginId: id,
|
||||
pluginInputs: inputs,
|
||||
grantCaps,
|
||||
...(flags.conversation ? { conversationId: flags.conversation } : {}),
|
||||
...(flags.message ? { message: flags.message } : {}),
|
||||
...(flags.agent ? { agentId: flags.agent } : {}),
|
||||
...(flags.model ? { model: flags.model } : {}),
|
||||
...(flags['snapshot-id'] ? { appliedPluginSnapshotId: flags['snapshot-id'] } : {}),
|
||||
}),
|
||||
});
|
||||
const runData = await runResp.json().catch(() => ({}));
|
||||
|
|
@ -1507,11 +1524,12 @@ async function runPluginRun(rest) {
|
|||
}
|
||||
if (flags.json) {
|
||||
process.stdout.write(JSON.stringify({ apply: applyData, run: runData }, null, 2) + '\n');
|
||||
if (flags.follow) await streamRunEvents(base, runData.runId);
|
||||
return;
|
||||
}
|
||||
console.log(`[run] started run ${runData.runId} (snapshot ${applyData?.appliedPlugin?.snapshotId ?? 'n/a'})`);
|
||||
console.log(`[run] started run ${runData.runId} (snapshot ${runData.appliedPluginSnapshotId ?? applyData?.appliedPlugin?.snapshotId ?? 'n/a'})`);
|
||||
if (flags.follow) {
|
||||
console.log(`[run] follow stream: GET ${base}/api/runs/${runData.runId}/events`);
|
||||
await streamRunEvents(base, runData.runId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3162,13 +3180,6 @@ function projectDaemonUrl(flags) {
|
|||
return (flags && flags['daemon-url']) || process.env.OD_DAEMON_URL || 'http://127.0.0.1:7456';
|
||||
}
|
||||
|
||||
const PROJECT_STRING_FLAGS = new Set([
|
||||
'daemon-url', 'name', 'skill', 'design-system', 'plugin', 'metadata-json',
|
||||
'pending-prompt', 'project', 'conversation', 'message', 'path', 'as',
|
||||
'agent', 'model', 'snapshot-id', 'inputs', 'grant-caps',
|
||||
]);
|
||||
const PROJECT_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'follow']);
|
||||
|
||||
function safeReadJsonFile(p) {
|
||||
try {
|
||||
const fs = (require ? require('node:fs') : null);
|
||||
|
|
|
|||
|
|
@ -35,6 +35,40 @@ Three hard rules govern the start of every new design task. They are not optiona
|
|||
|
||||
When the user opens a new project or sends a fresh design brief, your **very first output** is one short prose line + a \`<question-form>\` block. Nothing else. No file reads. No Bash. No TodoWrite. No extended thinking. The form is your time-to-first-byte.
|
||||
|
||||
Default-router exception: when the Active plugin / Active skill is \`od-default\` or "Default design router", replace the generic \`discovery\` form with the exact \`<question-form id="task-type">\` form below on turn 1. Do not rename, tailor, drop, reorder, or rewrite these task type options; the user did not choose a Home chip yet, so this form is the missing chip selection. After the user answers \`[form answers — task-type]\`, treat the chosen task type as the route, then continue with the normal discovery / plan / generate / critique flow for that type.
|
||||
|
||||
\`\`\`
|
||||
<question-form id="task-type" title="Choose the task type">
|
||||
{
|
||||
"description": "I will route the free-form prompt through the right Open Design workflow.",
|
||||
"questions": [
|
||||
{
|
||||
"id": "taskType",
|
||||
"label": "What should I build?",
|
||||
"type": "radio",
|
||||
"required": true,
|
||||
"options": [
|
||||
"Prototype",
|
||||
"Live artifact",
|
||||
"Slide deck",
|
||||
"Image",
|
||||
"Video",
|
||||
"HyperFrames",
|
||||
"Audio",
|
||||
"Other"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "constraints",
|
||||
"label": "Any important constraints?",
|
||||
"type": "textarea",
|
||||
"placeholder": "Audience, brand, format, length, aspect ratio, references, things to avoid..."
|
||||
}
|
||||
]
|
||||
}
|
||||
</question-form>
|
||||
\`\`\`
|
||||
|
||||
\`\`\`
|
||||
<question-form id="discovery" title="Quick brief — 30 seconds">
|
||||
{
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ import path from 'node:path';
|
|||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import { defaultScenarioPluginIdForKind } from '@open-design/contracts';
|
||||
import {
|
||||
defaultScenarioPluginIdForKind,
|
||||
PLUGIN_SHARE_ACTION_PLUGIN_IDS,
|
||||
} from '@open-design/contracts';
|
||||
import {
|
||||
composeSystemPrompt,
|
||||
renderCodexImagegenOverride,
|
||||
|
|
@ -307,6 +310,16 @@ export function composeLiveInstructionPrompt({
|
|||
return parts.join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
function renderPluginBriefTemplate(template, inputs = {}) {
|
||||
if (typeof template !== 'string' || template.length === 0) return '';
|
||||
return template.replace(/\{\{\s*([a-zA-Z_][\w-]*)\s*\}\}/g, (full, key) => {
|
||||
if (!Object.hasOwn(inputs, key)) return full;
|
||||
const value = inputs[key];
|
||||
if (value === undefined || value === null || value === '') return full;
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveResearchCommandContract(research, message) {
|
||||
if (!research || !research.enabled) return '';
|
||||
const researchQuery =
|
||||
|
|
@ -1156,6 +1169,111 @@ function githubRepoNameFromPluginName(name) {
|
|||
return slug || 'open-design-plugin';
|
||||
}
|
||||
|
||||
const PLUGIN_SHARE_ACTION_LABELS = {
|
||||
'publish-github': 'Publish to GitHub',
|
||||
'contribute-open-design': 'Contribute to Open Design',
|
||||
};
|
||||
|
||||
const USER_PLUGIN_SOURCE_KINDS = new Set([
|
||||
'user',
|
||||
'project',
|
||||
'marketplace',
|
||||
'github',
|
||||
'url',
|
||||
'local',
|
||||
]);
|
||||
|
||||
const PLUGIN_CONTEXT_SKIP_DIRS = new Set([
|
||||
'.git',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.od',
|
||||
'.output',
|
||||
'.tmp',
|
||||
'.turbo',
|
||||
'.venv',
|
||||
'__pycache__',
|
||||
'build',
|
||||
'coverage',
|
||||
'dist',
|
||||
'node_modules',
|
||||
'out',
|
||||
'target',
|
||||
'vendor',
|
||||
]);
|
||||
|
||||
const PLUGIN_CONTEXT_SKIP_FILES = new Set([
|
||||
'.DS_Store',
|
||||
'Thumbs.db',
|
||||
]);
|
||||
|
||||
function normalizePluginShareAction(input) {
|
||||
const value = typeof input === 'string' ? input.trim() : '';
|
||||
return Object.prototype.hasOwnProperty.call(PLUGIN_SHARE_ACTION_PLUGIN_IDS, value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function renderPluginSharePrompt({ action, sourcePlugin, stagedPath }) {
|
||||
const title = sourcePlugin.title || sourcePlugin.id;
|
||||
if (action === 'publish-github') {
|
||||
return [
|
||||
`Publish the local Open Design plugin "${title}" as a new public GitHub repository.`,
|
||||
'',
|
||||
`The plugin source files have been copied into this project at \`${stagedPath}\`.`,
|
||||
'Use the GitHub CLI (`gh`) for GitHub operations. Check `gh auth status` first, create a clean repository from the staged plugin folder, push the initial commit, and report the final repository URL.',
|
||||
'',
|
||||
'Do not rewrite the plugin unless publishing requires a small metadata fix. If you make any fix, explain it before publishing.',
|
||||
].join('\n');
|
||||
}
|
||||
return [
|
||||
`Open a pull request to add the local Open Design plugin "${title}" to the Open Design repository.`,
|
||||
'',
|
||||
`The plugin source files have been copied into this project at \`${stagedPath}\`.`,
|
||||
'Use the GitHub CLI (`gh`) for GitHub operations. Check `gh auth status` first, fork or reuse the fork of `nexu-io/open-design`, create a branch, copy the staged plugin into `plugins/community/`, push the branch, and open a PR against `nexu-io/open-design:main`.',
|
||||
'',
|
||||
'Keep the PR focused on this plugin. Report the PR URL and any validation you ran.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function copyPluginFolderForProjectContext(sourceRoot, destRoot) {
|
||||
const rootReal = await fs.promises.realpath(sourceRoot);
|
||||
const stat = await fs.promises.stat(rootReal);
|
||||
if (!stat.isDirectory()) {
|
||||
const err = new Error('plugin source path is not a directory');
|
||||
err.code = 'ENOTDIR';
|
||||
throw err;
|
||||
}
|
||||
await copyPluginContextDir(rootReal, destRoot, rootReal);
|
||||
}
|
||||
|
||||
async function copyPluginContextDir(src, dest, rootReal) {
|
||||
await fs.promises.mkdir(dest, { recursive: true });
|
||||
const entries = await fs.promises.readdir(src, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (shouldSkipPluginContextEntry(entry.name)) continue;
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
|
||||
const from = path.join(src, entry.name);
|
||||
const to = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const childReal = await fs.promises.realpath(from).catch(() => null);
|
||||
if (!childReal || (childReal !== rootReal && !childReal.startsWith(rootReal + path.sep))) {
|
||||
continue;
|
||||
}
|
||||
await copyPluginContextDir(childReal, to, rootReal);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
await fs.promises.mkdir(path.dirname(to), { recursive: true });
|
||||
await fs.promises.copyFile(from, to);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSkipPluginContextEntry(name) {
|
||||
return PLUGIN_CONTEXT_SKIP_DIRS.has(name) || PLUGIN_CONTEXT_SKIP_FILES.has(name);
|
||||
}
|
||||
|
||||
async function ensureGhReady() {
|
||||
const version = await execFileBuffered('gh', ['--version'], { timeout: 10_000 });
|
||||
if (!version.ok) {
|
||||
|
|
@ -3972,6 +4090,114 @@ export async function startServer({
|
|||
}
|
||||
});
|
||||
|
||||
app.post('/api/plugins/:id/share-project', async (req, res) => {
|
||||
try {
|
||||
const sourcePlugin = getInstalledPlugin(db, req.params.id);
|
||||
if (!sourcePlugin) {
|
||||
sendApiError(res, 404, 'NOT_FOUND', 'plugin not found');
|
||||
return;
|
||||
}
|
||||
if (!USER_PLUGIN_SOURCE_KINDS.has(sourcePlugin.sourceKind)) {
|
||||
res.status(409).json({
|
||||
ok: false,
|
||||
code: 'plugin-not-shareable',
|
||||
message: 'Only user-installed plugins can start a share project.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
||||
const action = normalizePluginShareAction(body.action);
|
||||
if (!action) {
|
||||
sendApiError(res, 400, 'BAD_REQUEST', 'action must be publish-github or contribute-open-design');
|
||||
return;
|
||||
}
|
||||
const actionPluginId = PLUGIN_SHARE_ACTION_PLUGIN_IDS[action];
|
||||
const actionPlugin = getInstalledPlugin(db, actionPluginId);
|
||||
if (!actionPlugin) {
|
||||
res.status(409).json({
|
||||
ok: false,
|
||||
code: 'share-action-plugin-missing',
|
||||
message: `The bundled action plugin "${actionPluginId}" is not installed. Restart the daemon so bundled plugins are registered.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const id = randomId();
|
||||
const cid = randomId();
|
||||
const sourceSlug = githubRepoNameFromPluginName(sourcePlugin.id);
|
||||
const stagedPath = `plugin-source/${sourceSlug}`;
|
||||
const prompt = renderPluginSharePrompt({ action, sourcePlugin, stagedPath });
|
||||
const metadata = { kind: 'prototype' };
|
||||
const projectRoot = await ensureProject(PROJECTS_DIR, id, metadata);
|
||||
await copyPluginFolderForProjectContext(
|
||||
sourcePlugin.fsPath,
|
||||
path.join(projectRoot, 'plugin-source', sourceSlug),
|
||||
);
|
||||
|
||||
insertProject(db, {
|
||||
id,
|
||||
name: `${PLUGIN_SHARE_ACTION_LABELS[action]}: ${sourcePlugin.title || sourcePlugin.id}`,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
pendingPrompt: prompt,
|
||||
metadata,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
insertConversation(db, {
|
||||
id: cid,
|
||||
projectId: id,
|
||||
title: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const registry = await loadPluginRegistryView();
|
||||
const resolved = resolvePluginSnapshot({
|
||||
db,
|
||||
body: {
|
||||
pluginId: actionPluginId,
|
||||
pluginInputs: {
|
||||
source_plugin_id: sourcePlugin.id,
|
||||
source_plugin_title: sourcePlugin.title || sourcePlugin.id,
|
||||
source_plugin_version: sourcePlugin.version,
|
||||
source_plugin_path: sourcePlugin.fsPath,
|
||||
plugin_context_path: stagedPath,
|
||||
},
|
||||
locale: typeof body.locale === 'string' ? body.locale : undefined,
|
||||
},
|
||||
projectId: id,
|
||||
conversationId: cid,
|
||||
registry,
|
||||
});
|
||||
if (resolved && !resolved.ok) {
|
||||
res.status(resolved.status).json(resolved.body);
|
||||
return;
|
||||
}
|
||||
|
||||
const project = getProject(db, id);
|
||||
if (!project) {
|
||||
sendApiError(res, 500, 'INTERNAL_ERROR', 'created project could not be loaded');
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
ok: true,
|
||||
project,
|
||||
conversationId: cid,
|
||||
...(resolved?.ok ? { appliedPluginSnapshotId: resolved.snapshotId } : {}),
|
||||
actionPluginId,
|
||||
sourcePluginId: sourcePlugin.id,
|
||||
stagedPath,
|
||||
prompt,
|
||||
message: `Created a ${PLUGIN_SHARE_ACTION_LABELS[action]} task for ${sourcePlugin.title || sourcePlugin.id}.`,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(400).json({ ok: false, message: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/plugins/:id/doctor', async (req, res) => {
|
||||
try {
|
||||
const plugin = getInstalledPlugin(db, req.params.id);
|
||||
|
|
@ -6867,6 +7093,21 @@ export async function startServer({
|
|||
// non-plain adapters and we'd emit the panel for a run the orchestrator
|
||||
// skips. Gating the threading itself keeps composer + orchestrator in
|
||||
// exact lockstep regardless of which side enforces eligibility.
|
||||
let pluginBlock;
|
||||
if (
|
||||
typeof appliedPluginSnapshotId === 'string'
|
||||
&& appliedPluginSnapshotId.length > 0
|
||||
) {
|
||||
try {
|
||||
const snap = getSnapshot(db, appliedPluginSnapshotId);
|
||||
if (snap) pluginBlock = pluginPromptBlock(snap);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[plugins] pluginBlock build failed: ${err?.message ?? err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Plan §3.M2 / §3.V1 / spec §23.4 — render each stage's atoms[]
|
||||
// into `## Active stage` blocks via the contracts helper when
|
||||
// the run carries a snapshot with a pipeline. Default is now ON
|
||||
|
|
@ -6918,6 +7159,7 @@ export async function startServer({
|
|||
connectedExternalMcp: Array.isArray(connectedExternalMcp)
|
||||
? connectedExternalMcp
|
||||
: undefined,
|
||||
...(pluginBlock ? { pluginBlock } : {}),
|
||||
...(activeStageBlocks ? { activeStageBlocks } : {}),
|
||||
});
|
||||
// The chat handler also needs to know where the active skill lives
|
||||
|
|
@ -8220,6 +8462,13 @@ export async function startServer({
|
|||
if (resolvedSnapshot?.ok) {
|
||||
meta.appliedPluginSnapshotId = resolvedSnapshot.snapshotId;
|
||||
if (!meta.pluginId) meta.pluginId = resolvedSnapshot.snapshot.pluginId;
|
||||
if (typeof meta.message !== 'string' || meta.message.trim().length === 0) {
|
||||
const renderedQuery = renderPluginBriefTemplate(
|
||||
resolvedSnapshot.snapshot.query,
|
||||
resolvedSnapshot.snapshot.inputs,
|
||||
).trim();
|
||||
if (renderedQuery.length > 0) meta.message = renderedQuery;
|
||||
}
|
||||
}
|
||||
const run = design.runs.create(meta);
|
||||
if (resolvedSnapshot?.ok) {
|
||||
|
|
@ -8231,7 +8480,15 @@ export async function startServer({
|
|||
}
|
||||
}
|
||||
/** @type {import('@open-design/contracts').ChatRunCreateResponse} */
|
||||
const body = { runId: run.id };
|
||||
const body = {
|
||||
runId: run.id,
|
||||
...(resolvedSnapshot?.ok
|
||||
? {
|
||||
appliedPluginSnapshotId: resolvedSnapshot.snapshotId,
|
||||
pluginId: resolvedSnapshot.snapshot.pluginId,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
res.status(202).json(body);
|
||||
// Plan §3.I1 / spec §10.1 — fire the pipeline schedule on the run's
|
||||
// SSE stream BEFORE the agent process is started. The first
|
||||
|
|
@ -8250,7 +8507,7 @@ export async function startServer({
|
|||
db,
|
||||
});
|
||||
}
|
||||
design.runs.start(run, () => startChatRun(req.body || {}, run));
|
||||
design.runs.start(run, () => startChatRun(meta, run));
|
||||
});
|
||||
|
||||
app.get('/api/runs', (req, res) => {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ const CANONICAL = new Map<string, { taskKind: string; pipelineStages: string[] }
|
|||
// starters sit here too: they are user-facing plugins for downstream
|
||||
// handoff, but they must not become the canonical tune-collab fallback.
|
||||
const SIBLINGS = new Map<string, { taskKind: string }>([
|
||||
['od-default', { taskKind: 'new-generation' }],
|
||||
['od-media-generation', { taskKind: 'new-generation' }],
|
||||
['od-plugin-authoring', { taskKind: 'new-generation' }],
|
||||
['od-design-refine', { taskKind: 'tune-collab' }],
|
||||
|
|
@ -81,4 +82,20 @@ describe('plugins/_official/scenarios roster', () => {
|
|||
expect(folder).not.toBe(`od-${expected.taskKind}`);
|
||||
});
|
||||
}
|
||||
|
||||
it('od-default is hidden and asks for task type through a GenUI surface', async () => {
|
||||
const manifestPath = path.join(scenariosRoot, 'od-default', 'open-design.json');
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
expect(manifest.od.hidden).toBe(true);
|
||||
expect(manifest.od.pipeline.stages[0].id).toBe('task-type');
|
||||
expect(manifest.od.genui.surfaces).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'task-type',
|
||||
kind: 'choice',
|
||||
trigger: expect.objectContaining({ stageId: 'task-type' }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,13 +23,22 @@
|
|||
// gets extended to assert the first SSE event is `pipeline_stage_started`.
|
||||
|
||||
import type http from 'node:http';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import url from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import { startServer } from '../src/server.js';
|
||||
|
||||
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '../../..');
|
||||
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'plugin-fixtures', 'sample-plugin');
|
||||
const CLI_SRC = path.join(__dirname, '../src/cli.ts');
|
||||
const TSX_CLI = path.join(REPO_ROOT, 'node_modules', 'tsx', 'dist', 'cli.mjs');
|
||||
const execFileP = promisify(execFile);
|
||||
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
|
|
@ -51,6 +60,55 @@ afterAll(async () => {
|
|||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
async function withFakeAgent<T>(
|
||||
binName: string,
|
||||
script: string,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'od-headless-agent-bin-'));
|
||||
const oldPath = process.env.PATH;
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const runner = path.join(dir, `${binName}-runner.cjs`);
|
||||
await writeFile(runner, script);
|
||||
await writeFile(
|
||||
path.join(dir, `${binName}.cmd`),
|
||||
`@echo off\r\nnode "${runner}" %*\r\n`,
|
||||
);
|
||||
} else {
|
||||
const bin = path.join(dir, binName);
|
||||
await writeFile(bin, `#!/usr/bin/env node\n${script}`);
|
||||
await chmod(bin, 0o755);
|
||||
}
|
||||
process.env.PATH = `${dir}${path.delimiter}${oldPath ?? ''}`;
|
||||
return await run();
|
||||
} finally {
|
||||
if (oldPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = oldPath;
|
||||
}
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function runCli(
|
||||
args: string[],
|
||||
options: { timeout?: number } = {},
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
OD_DAEMON_URL: baseUrl,
|
||||
};
|
||||
delete env.NODE_OPTIONS;
|
||||
return await execFileP(process.execPath, [TSX_CLI, CLI_SRC, ...args], {
|
||||
cwd: path.join(__dirname, '..'),
|
||||
env,
|
||||
timeout: options.timeout ?? 20_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
}) as { stdout: string; stderr: string };
|
||||
}
|
||||
|
||||
async function readSseUntilSuccess(resp: Response) {
|
||||
if (!resp.body) throw new Error('install: no body');
|
||||
const reader = resp.body.getReader();
|
||||
|
|
@ -119,8 +177,14 @@ describe('Plan §8 e2e-3 (entry slice) — headless install → project → run'
|
|||
}),
|
||||
});
|
||||
expect(runResp.status).toBe(202);
|
||||
const runBody = (await runResp.json()) as { runId: string };
|
||||
const runBody = (await runResp.json()) as {
|
||||
runId: string;
|
||||
pluginId?: string;
|
||||
appliedPluginSnapshotId?: string;
|
||||
};
|
||||
expect(runBody.runId).toBeTruthy();
|
||||
expect(runBody.pluginId).toBe('sample-plugin');
|
||||
expect(runBody.appliedPluginSnapshotId).toBe(createBody.appliedPluginSnapshotId);
|
||||
|
||||
// 4. The run status surfaces the snapshot id so a polling client
|
||||
// can reach replay without parsing the SSE stream.
|
||||
|
|
@ -138,14 +202,204 @@ describe('Plan §8 e2e-3 (entry slice) — headless install → project → run'
|
|||
// 5. Replay reads the same snapshot row.
|
||||
const snapResp = await fetch(`${baseUrl}/api/applied-plugins/${encodeURIComponent(createBody.appliedPluginSnapshotId!)}`);
|
||||
expect(snapResp.status).toBe(200);
|
||||
const snap = (await snapResp.json()) as { snapshotId: string; pluginId: string };
|
||||
const snap = (await snapResp.json()) as {
|
||||
snapshotId: string;
|
||||
pluginId: string;
|
||||
query?: string;
|
||||
inputs?: Record<string, string | number | boolean>;
|
||||
};
|
||||
expect(snap.snapshotId).toBe(createBody.appliedPluginSnapshotId);
|
||||
expect(snap.pluginId).toBe('sample-plugin');
|
||||
expect(snap.query).toBe('Generate a {{topic}} brief for {{audience}}.');
|
||||
expect(snap.inputs).toEqual({ audience: 'general', topic: 'agentic design' });
|
||||
|
||||
// Cancel the run so the test cleans up the in-memory child path.
|
||||
await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}/cancel`, { method: 'POST' });
|
||||
});
|
||||
|
||||
it('creates a share project for publishing a user plugin to GitHub', async () => {
|
||||
const installResp = await fetch(`${baseUrl}/api/plugins/install`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
||||
body: JSON.stringify({ source: FIXTURE_DIR }),
|
||||
});
|
||||
expect(installResp.status).toBe(200);
|
||||
const installSuccess = await readSseUntilSuccess(installResp);
|
||||
expect(installSuccess?.plugin?.id).toBe('sample-plugin');
|
||||
|
||||
const shareResp = await fetch(`${baseUrl}/api/plugins/sample-plugin/share-project`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'publish-github', locale: 'en' }),
|
||||
});
|
||||
expect(shareResp.status).toBe(200);
|
||||
const shareBody = (await shareResp.json()) as {
|
||||
ok: boolean;
|
||||
project: { id: string; pendingPrompt?: string };
|
||||
conversationId: string;
|
||||
appliedPluginSnapshotId?: string;
|
||||
actionPluginId: string;
|
||||
sourcePluginId: string;
|
||||
stagedPath: string;
|
||||
prompt: string;
|
||||
};
|
||||
expect(shareBody.ok).toBe(true);
|
||||
expect(shareBody.actionPluginId).toBe('od-plugin-publish-github');
|
||||
expect(shareBody.sourcePluginId).toBe('sample-plugin');
|
||||
expect(shareBody.appliedPluginSnapshotId).toBeTruthy();
|
||||
expect(shareBody.stagedPath).toBe('plugin-source/sample-plugin');
|
||||
expect(shareBody.prompt).toContain('Publish the local Open Design plugin');
|
||||
expect(shareBody.prompt).toContain('gh');
|
||||
expect(shareBody.project.pendingPrompt).toBe(shareBody.prompt);
|
||||
|
||||
const filesResp = await fetch(
|
||||
`${baseUrl}/api/projects/${encodeURIComponent(shareBody.project.id)}/files`,
|
||||
);
|
||||
expect(filesResp.status).toBe(200);
|
||||
const filesBody = (await filesResp.json()) as { files: Array<{ name: string }> };
|
||||
const fileNames = filesBody.files.map((file) => file.name).sort();
|
||||
expect(fileNames).toContain('plugin-source/sample-plugin/open-design.json');
|
||||
expect(fileNames).toContain('plugin-source/sample-plugin/SKILL.md');
|
||||
|
||||
const snapshotResp = await fetch(
|
||||
`${baseUrl}/api/applied-plugins/${encodeURIComponent(shareBody.appliedPluginSnapshotId!)}`,
|
||||
);
|
||||
expect(snapshotResp.status).toBe(200);
|
||||
const snapshot = (await snapshotResp.json()) as {
|
||||
pluginId: string;
|
||||
inputs?: Record<string, string | number | boolean>;
|
||||
};
|
||||
expect(snapshot.pluginId).toBe('od-plugin-publish-github');
|
||||
expect(snapshot.inputs).toMatchObject({
|
||||
source_plugin_id: 'sample-plugin',
|
||||
plugin_context_path: 'plugin-source/sample-plugin',
|
||||
});
|
||||
});
|
||||
|
||||
it('runs the CLI install → project create → plugin run path with query and local SKILL.md in the agent prompt', async () => {
|
||||
const pluginRoot = await mkdtemp(path.join(tmpdir(), 'od-headless-cli-plugin-'));
|
||||
const pluginId = `headless-cli-plugin-${randomUUID().slice(0, 8)}`;
|
||||
const fixture = path.join(pluginRoot, pluginId);
|
||||
await mkdir(fixture, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(fixture, 'open-design.json'),
|
||||
JSON.stringify({
|
||||
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
|
||||
name: pluginId,
|
||||
title: 'Headless CLI Plugin',
|
||||
version: '1.0.0',
|
||||
description: 'Fixture that binds a local SKILL.md for headless CLI tests.',
|
||||
license: 'MIT',
|
||||
od: {
|
||||
kind: 'skill',
|
||||
taskKind: 'new-generation',
|
||||
useCase: { query: 'Generate a {{topic}} brief for {{audience}}.' },
|
||||
context: {
|
||||
skills: [{ path: './SKILL.md' }],
|
||||
atoms: ['todo-write', 'discovery-question-form'],
|
||||
},
|
||||
inputs: [
|
||||
{ name: 'topic', type: 'string', required: true, label: 'Topic' },
|
||||
{ name: 'audience', type: 'string', default: 'general', label: 'Audience' },
|
||||
],
|
||||
capabilities: ['prompt:inject'],
|
||||
},
|
||||
}, null, 2),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(fixture, 'SKILL.md'),
|
||||
[
|
||||
'---',
|
||||
`name: ${pluginId}`,
|
||||
'description: Local skill loaded by the headless CLI e2e test.',
|
||||
'---',
|
||||
'# Headless Local Skill',
|
||||
'',
|
||||
'Follow this local skill during headless runs.',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
try {
|
||||
const install = await runCli(['plugin', 'install', fixture]);
|
||||
expect(install.stdout).toContain('[install] ok');
|
||||
|
||||
const topic = `headless cli ${randomUUID().slice(0, 8)}`;
|
||||
const created = await runCli([
|
||||
'project',
|
||||
'create',
|
||||
'--name',
|
||||
'CLI headless plugin run',
|
||||
'--plugin',
|
||||
pluginId,
|
||||
'--inputs',
|
||||
JSON.stringify({ topic }),
|
||||
'--json',
|
||||
]);
|
||||
const createBody = JSON.parse(created.stdout) as {
|
||||
project: { id: string };
|
||||
appliedPluginSnapshotId?: string;
|
||||
};
|
||||
expect(createBody.appliedPluginSnapshotId).toBeTruthy();
|
||||
|
||||
const captureRoot = await mkdtemp(path.join(tmpdir(), 'od-headless-cli-capture-'));
|
||||
const capturePath = path.join(captureRoot, 'prompt.txt');
|
||||
const previousCapture = process.env.OD_PROMPT_CAPTURE;
|
||||
process.env.OD_PROMPT_CAPTURE = capturePath;
|
||||
try {
|
||||
await withFakeAgent(
|
||||
'opencode',
|
||||
`
|
||||
const fs = require('node:fs');
|
||||
let input = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => { input += chunk; });
|
||||
process.stdin.on('end', () => {
|
||||
fs.writeFileSync(process.env.OD_PROMPT_CAPTURE, input);
|
||||
console.log(JSON.stringify({ type: 'text', part: { text: 'headless-ok' } }));
|
||||
});
|
||||
`,
|
||||
async () => {
|
||||
const run = await runCli([
|
||||
'plugin',
|
||||
'run',
|
||||
pluginId,
|
||||
'--project',
|
||||
createBody.project.id,
|
||||
'--inputs',
|
||||
JSON.stringify({ topic }),
|
||||
'--agent',
|
||||
'opencode',
|
||||
'--follow',
|
||||
]);
|
||||
expect(run.stdout).toContain('[run] started run');
|
||||
expect(run.stdout).toContain('"event":"agent"');
|
||||
expect(run.stdout).toContain('headless-ok');
|
||||
expect(run.stdout).toContain('"event":"end"');
|
||||
expect(run.stdout).toContain('"status":"succeeded"');
|
||||
},
|
||||
);
|
||||
|
||||
const prompt = await readFile(capturePath, 'utf8');
|
||||
expect(prompt).toContain('# Headless Local Skill');
|
||||
expect(prompt).toContain('Follow this local skill during headless runs.');
|
||||
expect(prompt).toContain('## Active plugin');
|
||||
expect(prompt).toContain('The plugin\'s example brief is: _Generate a {{topic}} brief for {{audience}}._');
|
||||
expect(prompt).toContain(`- **topic**: ${topic}`);
|
||||
expect(prompt).toContain('- **audience**: general');
|
||||
expect(prompt).toContain(`# User request\n\nGenerate a ${topic} brief for general.`);
|
||||
} finally {
|
||||
if (previousCapture === undefined) {
|
||||
delete process.env.OD_PROMPT_CAPTURE;
|
||||
} else {
|
||||
process.env.OD_PROMPT_CAPTURE = previousCapture;
|
||||
}
|
||||
await rm(captureRoot, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
await rm(pluginRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Full §8 e2e-3 contract — once the pipeline runner fires on a run
|
||||
// with a declared pipeline, the first ND-JSON event should be
|
||||
// `pipeline_stage_started`. Plan §3.I1 wires firePipelineForRun into
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import {
|
|||
import { applyAppearanceToDocument } from './state/appearance';
|
||||
import {
|
||||
createProject,
|
||||
createPluginShareProject,
|
||||
deleteProject as deleteProjectApi,
|
||||
importClaudeDesignZip,
|
||||
importFolderProject,
|
||||
|
|
@ -44,6 +45,10 @@ import {
|
|||
listTemplates,
|
||||
patchProject,
|
||||
} from './state/projects';
|
||||
import type {
|
||||
PluginShareAction,
|
||||
PluginShareProjectOutcome,
|
||||
} from './state/projects';
|
||||
import { liveArtifactTabId } from './types';
|
||||
import type {
|
||||
AgentInfo,
|
||||
|
|
@ -543,6 +548,7 @@ export function App() {
|
|||
pendingPrompt?: string;
|
||||
pluginId?: string;
|
||||
appliedPluginSnapshotId?: string;
|
||||
pluginInputs?: Record<string, unknown>;
|
||||
autoSendFirstMessage?: boolean;
|
||||
},
|
||||
) => {
|
||||
|
|
@ -560,6 +566,7 @@ export function App() {
|
|||
...(input.appliedPluginSnapshotId
|
||||
? { appliedPluginSnapshotId: input.appliedPluginSnapshotId }
|
||||
: {}),
|
||||
...(input.pluginInputs ? { pluginInputs: input.pluginInputs } : {}),
|
||||
});
|
||||
if (!result) return;
|
||||
// PluginLoopHome flow: the user already typed (or accepted) the
|
||||
|
|
@ -578,19 +585,56 @@ export function App() {
|
|||
back to manual send. */
|
||||
}
|
||||
}
|
||||
const project = result.appliedPluginSnapshotId
|
||||
? {
|
||||
...result.project,
|
||||
appliedPluginSnapshotId: result.appliedPluginSnapshotId,
|
||||
}
|
||||
: result.project;
|
||||
setProjects((curr) => [
|
||||
result.project,
|
||||
...curr.filter((p) => p.id !== result.project.id),
|
||||
project,
|
||||
...curr.filter((p) => p.id !== project.id),
|
||||
]);
|
||||
navigate({
|
||||
kind: 'project',
|
||||
projectId: result.project.id,
|
||||
projectId: project.id,
|
||||
fileName: null,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCreatePluginShareProject = useCallback(
|
||||
async (
|
||||
pluginId: string,
|
||||
action: PluginShareAction,
|
||||
locale?: string,
|
||||
): Promise<PluginShareProjectOutcome> => {
|
||||
const outcome = await createPluginShareProject(pluginId, action, locale);
|
||||
if (!outcome.ok) return outcome;
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
`od:auto-send-first:${outcome.project.id}`,
|
||||
'1',
|
||||
);
|
||||
} catch {
|
||||
// If sessionStorage is unavailable, the project still opens with
|
||||
// the prepared prompt in the composer.
|
||||
}
|
||||
setProjects((curr) => [
|
||||
outcome.project,
|
||||
...curr.filter((p) => p.id !== outcome.project.id),
|
||||
]);
|
||||
navigate({
|
||||
kind: 'project',
|
||||
projectId: outcome.project.id,
|
||||
fileName: null,
|
||||
});
|
||||
return outcome;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleImportClaudeDesign = useCallback(async (file: File) => {
|
||||
const result = await importClaudeDesignZip(file);
|
||||
if (!result) return;
|
||||
|
|
@ -846,6 +890,7 @@ export function App() {
|
|||
projectsLoading={projectsLoading}
|
||||
promptTemplatesLoading={promptTemplatesLoading}
|
||||
onCreateProject={handleCreateProject}
|
||||
onCreatePluginShareProject={handleCreatePluginShareProject}
|
||||
onImportClaudeDesign={handleImportClaudeDesign}
|
||||
onImportFolder={handleImportFolder}
|
||||
onOpenProject={handleOpenProject}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ import { NewProjectModal } from './NewProjectModal';
|
|||
import { PluginsView } from './PluginsView';
|
||||
import type { CreateInput } from './NewProjectPanel';
|
||||
import type { PluginLoopSubmit } from './PluginLoopHome';
|
||||
import type {
|
||||
PluginShareAction,
|
||||
PluginShareProjectOutcome,
|
||||
} from '../state/projects';
|
||||
import { TasksView } from './TasksView';
|
||||
|
||||
// The topbar chips (GitHub star, model switcher, Use everywhere)
|
||||
|
|
@ -168,9 +172,15 @@ interface Props {
|
|||
pendingPrompt?: string;
|
||||
pluginId?: string;
|
||||
appliedPluginSnapshotId?: string;
|
||||
pluginInputs?: Record<string, unknown>;
|
||||
autoSendFirstMessage?: boolean;
|
||||
},
|
||||
) => void;
|
||||
onCreatePluginShareProject: (
|
||||
pluginId: string,
|
||||
action: PluginShareAction,
|
||||
locale?: string,
|
||||
) => Promise<PluginShareProjectOutcome>;
|
||||
onImportClaudeDesign: (file: File) => Promise<void> | void;
|
||||
onImportFolder?: (baseDir: string) => Promise<void> | void;
|
||||
onOpenProject: (id: string) => void;
|
||||
|
|
@ -219,6 +229,7 @@ export function EntryShell({
|
|||
onApiModelChange,
|
||||
onThemeChange,
|
||||
onCreateProject,
|
||||
onCreatePluginShareProject,
|
||||
onImportClaudeDesign,
|
||||
onImportFolder,
|
||||
onOpenProject,
|
||||
|
|
@ -306,11 +317,11 @@ export function EntryShell({
|
|||
// message so the user lands inside a running pipeline.
|
||||
//
|
||||
// Stage B of plugin-driven-flow-plan: the rail can stamp a
|
||||
// `projectKind` on the payload so the daemon-side default binding
|
||||
// resolves to the matching scenario plugin (image / video / audio
|
||||
// → od-media-generation, etc.). When the chip carried no kind we
|
||||
// keep the historical 'prototype' default so legacy callers behave
|
||||
// as before.
|
||||
// `projectKind` on the payload so the created project records the
|
||||
// chosen surface (image / video / audio, etc.). Free-form Home
|
||||
// submits now arrive with the hidden od-default router plugin and
|
||||
// projectKind='other', so the agent asks for the exact task type
|
||||
// before continuing.
|
||||
function handlePluginLoopSubmit(payload: PluginLoopSubmit) {
|
||||
const head = payload.prompt.trim().split(/\s+/).slice(0, 8).join(' ');
|
||||
const fallbackName = head.length > 0 ? head : 'Untitled';
|
||||
|
|
@ -702,7 +713,10 @@ export function EntryShell({
|
|||
/>
|
||||
) : null}
|
||||
{view === 'plugins' ? (
|
||||
<PluginsView onCreatePlugin={startPluginAuthoring} />
|
||||
<PluginsView
|
||||
onCreatePlugin={startPluginAuthoring}
|
||||
onCreatePluginShareProject={onCreatePluginShareProject}
|
||||
/>
|
||||
) : null}
|
||||
{view === 'design-systems' ? (
|
||||
designSystemsLoading ? (
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ import {
|
|||
fetchConnectors,
|
||||
fetchConnectorStatuses,
|
||||
} from '../providers/registry';
|
||||
import type {
|
||||
PluginShareAction,
|
||||
PluginShareProjectOutcome,
|
||||
} from '../state/projects';
|
||||
|
||||
interface Props {
|
||||
skills: SkillSummary[];
|
||||
|
|
@ -72,9 +76,15 @@ interface Props {
|
|||
pendingPrompt?: string;
|
||||
pluginId?: string;
|
||||
appliedPluginSnapshotId?: string;
|
||||
pluginInputs?: Record<string, unknown>;
|
||||
autoSendFirstMessage?: boolean;
|
||||
},
|
||||
) => void;
|
||||
onCreatePluginShareProject: (
|
||||
pluginId: string,
|
||||
action: PluginShareAction,
|
||||
locale?: string,
|
||||
) => Promise<PluginShareProjectOutcome>;
|
||||
onImportClaudeDesign: (file: File) => Promise<void> | void;
|
||||
onImportFolder?: (baseDir: string) => Promise<void> | void;
|
||||
onOpenProject: (id: string) => void;
|
||||
|
|
@ -221,6 +231,7 @@ export function EntryView({
|
|||
projectsLoading = false,
|
||||
promptTemplatesLoading: _promptTemplatesLoading = false,
|
||||
onCreateProject,
|
||||
onCreatePluginShareProject,
|
||||
onImportClaudeDesign,
|
||||
onImportFolder,
|
||||
onOpenProject,
|
||||
|
|
@ -304,6 +315,7 @@ export function EntryView({
|
|||
onApiModelChange={onApiModelChange}
|
||||
onThemeChange={onThemeChange}
|
||||
onCreateProject={onCreateProject}
|
||||
onCreatePluginShareProject={onCreatePluginShareProject}
|
||||
onImportClaudeDesign={onImportClaudeDesign}
|
||||
{...(onImportFolder ? { onImportFolder } : {})}
|
||||
onOpenProject={onOpenProject}
|
||||
|
|
|
|||
|
|
@ -241,6 +241,7 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
|
|||
activeChipId={activeChipId}
|
||||
pendingChipId={pendingChipId}
|
||||
pendingPluginId={pendingPluginId}
|
||||
pluginsLoading={pluginsLoading}
|
||||
onPickChip={onPickChip}
|
||||
/>
|
||||
<span className="home-hero__rail-divider" aria-hidden />
|
||||
|
|
@ -249,6 +250,7 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
|
|||
activeChipId={activeChipId}
|
||||
pendingChipId={pendingChipId}
|
||||
pendingPluginId={pendingPluginId}
|
||||
pluginsLoading={pluginsLoading}
|
||||
onPickChip={onPickChip}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -299,6 +301,7 @@ interface RailGroupProps {
|
|||
activeChipId: string | null;
|
||||
pendingChipId: string | null;
|
||||
pendingPluginId: string | null;
|
||||
pluginsLoading: boolean;
|
||||
onPickChip: (chip: HomeHeroChip) => void;
|
||||
}
|
||||
|
||||
|
|
@ -307,6 +310,7 @@ function RailGroup({
|
|||
activeChipId,
|
||||
pendingChipId,
|
||||
pendingPluginId,
|
||||
pluginsLoading,
|
||||
onPickChip,
|
||||
}: RailGroupProps) {
|
||||
const chips = useMemo(() => chipsForGroup(group), [group]);
|
||||
|
|
@ -329,7 +333,7 @@ function RailGroup({
|
|||
data-chip-id={chip.id}
|
||||
data-testid={`home-hero-rail-${chip.id}`}
|
||||
onClick={() => onPickChip(chip)}
|
||||
disabled={isPending || pendingPluginId !== null}
|
||||
disabled={pluginsLoading || isPending || pendingPluginId !== null}
|
||||
aria-pressed={isActive}
|
||||
title={chip.hint ?? chip.label}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
InstalledPluginRecord,
|
||||
ProjectKind,
|
||||
} from '@open-design/contracts';
|
||||
import { DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID } from '@open-design/contracts';
|
||||
import {
|
||||
applyPlugin,
|
||||
listPlugins,
|
||||
|
|
@ -254,14 +255,15 @@ export function HomeView({
|
|||
function submit() {
|
||||
const trimmed = prompt.trim();
|
||||
if (!trimmed) return;
|
||||
const defaultInputs = { prompt: trimmed };
|
||||
onSubmit({
|
||||
prompt: trimmed,
|
||||
pluginId: active?.record.id ?? null,
|
||||
pluginId: active?.record.id ?? DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID,
|
||||
appliedPluginSnapshotId: active?.result.appliedPlugin?.snapshotId ?? null,
|
||||
pluginTitle: active?.record.title ?? null,
|
||||
taskKind: active?.result.appliedPlugin?.taskKind ?? null,
|
||||
pluginInputs: active ? active.inputs : null,
|
||||
projectKind: active?.projectKind ?? fallbackProjectKind,
|
||||
pluginInputs: active ? active.inputs : defaultInputs,
|
||||
projectKind: active?.projectKind ?? fallbackProjectKind ?? 'other',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,8 +26,9 @@ export interface PluginLoopSubmit {
|
|||
// stamp on the new project's metadata. The daemon-side default
|
||||
// binding then resolves to the matching scenario plugin (image /
|
||||
// video / audio → od-media-generation, others → od-new-generation).
|
||||
// Null means "no chip selected; use the historical 'prototype'
|
||||
// default so legacy callers behave as before".
|
||||
// Null means the caller did not stamp an explicit kind. HomeView's
|
||||
// free-form fallback uses `other` and binds the hidden od-default
|
||||
// router plugin so the agent asks for the exact task type in-chat.
|
||||
projectKind?: 'prototype' | 'deck' | 'template' | 'image' | 'video' | 'audio' | 'other' | null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
// owns layout only.
|
||||
|
||||
import type { InstalledPluginRecord } from '@open-design/contracts';
|
||||
import type { PluginShareAction } from '../state/projects';
|
||||
import { Icon } from './Icon';
|
||||
import { PluginCard } from './plugins-home/PluginCard';
|
||||
import {
|
||||
|
|
@ -29,8 +30,13 @@ interface Props {
|
|||
loading: boolean;
|
||||
activePluginId: string | null;
|
||||
pendingApplyId: string | null;
|
||||
pendingShareAction?: { pluginId: string; action: PluginShareAction } | null;
|
||||
onUse: (record: InstalledPluginRecord) => void;
|
||||
onOpenDetails: (record: InstalledPluginRecord) => void;
|
||||
onPluginShareAction?: (
|
||||
record: InstalledPluginRecord,
|
||||
action: PluginShareAction,
|
||||
) => void;
|
||||
onCreatePlugin?: (goal?: string) => void;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
|
|
@ -44,8 +50,10 @@ export function PluginsHomeSection({
|
|||
loading,
|
||||
activePluginId,
|
||||
pendingApplyId,
|
||||
pendingShareAction = null,
|
||||
onUse,
|
||||
onOpenDetails,
|
||||
onPluginShareAction,
|
||||
onCreatePlugin,
|
||||
title = 'Community',
|
||||
subtitle = 'Things you can do and tasks to complete — packaged as plugins. Pick one to load a starter prompt, or type freely above.',
|
||||
|
|
@ -148,9 +156,11 @@ export function PluginsHomeSection({
|
|||
isActive={activePluginId === p.id}
|
||||
isPending={pendingApplyId === p.id}
|
||||
pendingAny={pendingApplyId !== null}
|
||||
pendingShareAction={pendingShareAction}
|
||||
isFeatured={featuredList.some((f) => f.id === p.id)}
|
||||
onUse={onUse}
|
||||
onOpenDetails={onOpenDetails}
|
||||
onShareAction={onPluginShareAction}
|
||||
/>
|
||||
))}
|
||||
{showContributionCard && contributionTarget ? (
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import {
|
|||
listPluginMarketplaces,
|
||||
listPlugins,
|
||||
type PluginInstallOutcome,
|
||||
type PluginShareAction,
|
||||
type PluginShareProjectOutcome,
|
||||
type PluginMarketplace,
|
||||
uploadPluginFolder,
|
||||
uploadPluginZip,
|
||||
|
|
@ -38,7 +40,19 @@ const PLUGINS_TABS: ReadonlyArray<{
|
|||
{ id: 'team', label: 'Team / Enterprise', hint: 'Coming soon' },
|
||||
];
|
||||
|
||||
export function PluginsView({ onCreatePlugin }: { onCreatePlugin?: (goal?: string) => void }) {
|
||||
interface PluginsViewProps {
|
||||
onCreatePlugin?: (goal?: string) => void;
|
||||
onCreatePluginShareProject?: (
|
||||
pluginId: string,
|
||||
action: PluginShareAction,
|
||||
locale?: string,
|
||||
) => Promise<PluginShareProjectOutcome>;
|
||||
}
|
||||
|
||||
export function PluginsView({
|
||||
onCreatePlugin,
|
||||
onCreatePluginShareProject,
|
||||
}: PluginsViewProps) {
|
||||
const { locale } = useI18n();
|
||||
const [plugins, setPlugins] = useState<InstalledPluginRecord[]>([]);
|
||||
const [marketplaces, setMarketplaces] = useState<PluginMarketplace[]>([]);
|
||||
|
|
@ -46,6 +60,10 @@ export function PluginsView({ onCreatePlugin }: { onCreatePlugin?: (goal?: strin
|
|||
const [activeTab, setActiveTab] = useState<PluginsTab>('community');
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [pendingApplyId, setPendingApplyId] = useState<string | null>(null);
|
||||
const [pendingShareAction, setPendingShareAction] = useState<{
|
||||
pluginId: string;
|
||||
action: PluginShareAction;
|
||||
} | null>(null);
|
||||
const [activePlugin, setActivePlugin] = useState<{
|
||||
record: InstalledPluginRecord;
|
||||
result: ApplyResult;
|
||||
|
|
@ -108,6 +126,29 @@ export function PluginsView({ onCreatePlugin }: { onCreatePlugin?: (goal?: strin
|
|||
});
|
||||
}
|
||||
|
||||
async function handleCreatePluginShareTask(
|
||||
record: InstalledPluginRecord,
|
||||
action: PluginShareAction,
|
||||
) {
|
||||
if (!onCreatePluginShareProject) {
|
||||
setNotice({
|
||||
ok: false,
|
||||
message: 'Plugin sharing is not available in this shell.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPendingShareAction({ pluginId: record.id, action });
|
||||
setNotice(null);
|
||||
const outcome = await onCreatePluginShareProject(record.id, action, locale);
|
||||
setPendingShareAction(null);
|
||||
if (!outcome.ok) {
|
||||
setNotice({
|
||||
ok: false,
|
||||
message: outcome.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="plugins-view" aria-labelledby="plugins-title">
|
||||
<header className="plugins-view__hero">
|
||||
|
|
@ -195,6 +236,7 @@ export function PluginsView({ onCreatePlugin }: { onCreatePlugin?: (goal?: strin
|
|||
loading={false}
|
||||
activePluginId={activePlugin?.record.id ?? null}
|
||||
pendingApplyId={pendingApplyId}
|
||||
pendingShareAction={pendingShareAction}
|
||||
onUse={(record) => void handleUsePlugin(record)}
|
||||
onOpenDetails={setDetailsRecord}
|
||||
onCreatePlugin={onCreatePlugin}
|
||||
|
|
@ -210,8 +252,12 @@ export function PluginsView({ onCreatePlugin }: { onCreatePlugin?: (goal?: strin
|
|||
loading={false}
|
||||
activePluginId={activePlugin?.record.id ?? null}
|
||||
pendingApplyId={pendingApplyId}
|
||||
pendingShareAction={pendingShareAction}
|
||||
onUse={(record) => void handleUsePlugin(record)}
|
||||
onOpenDetails={setDetailsRecord}
|
||||
onPluginShareAction={(record, action) =>
|
||||
void handleCreatePluginShareTask(record, action)
|
||||
}
|
||||
onCreatePlugin={onCreatePlugin}
|
||||
title="My plugins"
|
||||
subtitle="Your imported workflow plugins. Tag them by intent so they appear beside the official Import, Create, Export, Refine, and Extend starters."
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
|
||||
import { useMemo } from 'react';
|
||||
import type { InstalledPluginRecord } from '@open-design/contracts';
|
||||
import type { PluginShareAction } from '../../state/projects';
|
||||
import { Icon } from '../Icon';
|
||||
import { PreviewSurface } from './cards/PreviewSurface';
|
||||
import { inferPluginPreview } from './preview';
|
||||
|
|
@ -23,9 +24,14 @@ interface Props {
|
|||
isActive: boolean;
|
||||
isPending: boolean;
|
||||
pendingAny: boolean;
|
||||
pendingShareAction?: { pluginId: string; action: PluginShareAction } | null;
|
||||
isFeatured: boolean;
|
||||
onUse: (record: InstalledPluginRecord) => void;
|
||||
onOpenDetails: (record: InstalledPluginRecord) => void;
|
||||
onShareAction?: (
|
||||
record: InstalledPluginRecord,
|
||||
action: PluginShareAction,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_TAGS = 3;
|
||||
|
|
@ -35,9 +41,11 @@ export function PluginCard({
|
|||
isActive,
|
||||
isPending,
|
||||
pendingAny,
|
||||
pendingShareAction = null,
|
||||
isFeatured,
|
||||
onUse,
|
||||
onOpenDetails,
|
||||
onShareAction,
|
||||
}: Props) {
|
||||
const preview = useMemo(() => inferPluginPreview(record), [record]);
|
||||
const description = record.manifest?.description ?? '';
|
||||
|
|
@ -49,6 +57,9 @@ export function PluginCard({
|
|||
[record.manifest?.tags],
|
||||
);
|
||||
const hasQuery = Boolean(record.manifest?.od?.useCase?.query);
|
||||
const sharePendingAction =
|
||||
pendingShareAction?.pluginId === record.id ? pendingShareAction.action : null;
|
||||
const shareBusy = sharePendingAction !== null;
|
||||
|
||||
return (
|
||||
<article
|
||||
|
|
@ -56,6 +67,7 @@ export function PluginCard({
|
|||
className={[
|
||||
'plugins-home__card',
|
||||
`plugins-home__card--${preview.kind}`,
|
||||
onShareAction ? 'plugins-home__card--shareable' : '',
|
||||
isActive ? 'is-active' : '',
|
||||
isFeatured ? 'is-featured' : '',
|
||||
]
|
||||
|
|
@ -100,34 +112,73 @@ export function PluginCard({
|
|||
) : null}
|
||||
</div>
|
||||
<div className="plugins-home__overlay-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="plugins-home__action plugins-home__action--secondary"
|
||||
onClick={() => onOpenDetails(record)}
|
||||
aria-label={`View details for ${record.title}`}
|
||||
data-testid={`plugins-home-details-${record.id}`}
|
||||
>
|
||||
<Icon name="eye" size={12} />
|
||||
<span>Details</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="plugins-home__action plugins-home__action--primary"
|
||||
onClick={() => onUse(record)}
|
||||
disabled={isPending || pendingAny}
|
||||
aria-busy={isPending ? 'true' : undefined}
|
||||
data-testid={`plugins-home-use-${record.id}`}
|
||||
>
|
||||
{isPending
|
||||
? 'Applying…'
|
||||
: hasQuery
|
||||
? isActive
|
||||
? 'Reload'
|
||||
: 'Use'
|
||||
: isActive
|
||||
? 'Active'
|
||||
: 'Use'}
|
||||
</button>
|
||||
<div className="plugins-home__overlay-actions-main">
|
||||
<button
|
||||
type="button"
|
||||
className="plugins-home__action plugins-home__action--secondary"
|
||||
onClick={() => onOpenDetails(record)}
|
||||
aria-label={`View details for ${record.title}`}
|
||||
data-testid={`plugins-home-details-${record.id}`}
|
||||
>
|
||||
<Icon name="eye" size={12} />
|
||||
<span>Details</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="plugins-home__action plugins-home__action--primary"
|
||||
onClick={() => onUse(record)}
|
||||
disabled={isPending || pendingAny || shareBusy}
|
||||
aria-busy={isPending ? 'true' : undefined}
|
||||
data-testid={`plugins-home-use-${record.id}`}
|
||||
>
|
||||
{isPending
|
||||
? 'Applying…'
|
||||
: hasQuery
|
||||
? isActive
|
||||
? 'Reload'
|
||||
: 'Use'
|
||||
: isActive
|
||||
? 'Active'
|
||||
: 'Use'}
|
||||
</button>
|
||||
</div>
|
||||
{onShareAction ? (
|
||||
<div
|
||||
className="plugins-home__share-actions"
|
||||
aria-label={`Share ${record.title}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="plugins-home__action plugins-home__action--secondary plugins-home__action--compact"
|
||||
onClick={() => onShareAction(record, 'publish-github')}
|
||||
disabled={pendingAny || shareBusy}
|
||||
aria-busy={sharePendingAction === 'publish-github' ? 'true' : undefined}
|
||||
title="Publish as a GitHub repository"
|
||||
data-testid={`plugins-home-publish-github-${record.id}`}
|
||||
>
|
||||
<Icon
|
||||
name={sharePendingAction === 'publish-github' ? 'spinner' : 'github'}
|
||||
size={12}
|
||||
/>
|
||||
<span>{sharePendingAction === 'publish-github' ? 'Starting…' : 'Repo'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="plugins-home__action plugins-home__action--secondary plugins-home__action--compact"
|
||||
onClick={() => onShareAction(record, 'contribute-open-design')}
|
||||
disabled={pendingAny || shareBusy}
|
||||
aria-busy={sharePendingAction === 'contribute-open-design' ? 'true' : undefined}
|
||||
title="Open an Open Design pull request"
|
||||
data-testid={`plugins-home-contribute-open-design-${record.id}`}
|
||||
>
|
||||
<Icon
|
||||
name={sharePendingAction === 'contribute-open-design' ? 'spinner' : 'share'}
|
||||
size={12}
|
||||
/>
|
||||
<span>{sharePendingAction === 'contribute-open-design' ? 'Starting…' : 'PR'}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@
|
|||
import type {
|
||||
AppliedPluginSnapshot,
|
||||
ApplyResult,
|
||||
CreatePluginShareProjectResponse,
|
||||
ImportFolderRequest,
|
||||
ImportFolderResponse,
|
||||
InstalledPluginRecord,
|
||||
PluginInstallOutcome,
|
||||
PluginShareAction,
|
||||
ProjectPluginFolderInstallRequest,
|
||||
} from '@open-design/contracts';
|
||||
import { randomUUID } from '../utils/uuid';
|
||||
|
|
@ -25,6 +27,7 @@ import type {
|
|||
} from '../types';
|
||||
|
||||
export type { PluginInstallOutcome } from '@open-design/contracts';
|
||||
export type { PluginShareAction } from '@open-design/contracts';
|
||||
|
||||
export async function listProjects(): Promise<Project[]> {
|
||||
try {
|
||||
|
|
@ -364,12 +367,17 @@ export async function listPlugins(): Promise<InstalledPluginRecord[]> {
|
|||
const resp = await fetch('/api/plugins');
|
||||
if (!resp.ok) return [];
|
||||
const json = (await resp.json()) as { plugins?: InstalledPluginRecord[] };
|
||||
return json.plugins ?? [];
|
||||
return (json.plugins ?? []).filter(isVisiblePlugin);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function isVisiblePlugin(plugin: InstalledPluginRecord): boolean {
|
||||
const od = (plugin.manifest?.od ?? {}) as Record<string, unknown>;
|
||||
return od.hidden !== true;
|
||||
}
|
||||
|
||||
interface PluginInstallEvent {
|
||||
kind?: 'progress' | 'success' | 'error';
|
||||
phase?: string;
|
||||
|
|
@ -492,6 +500,59 @@ export async function contributeGeneratedPluginToOpenDesign(
|
|||
return postGeneratedPluginShareAction(projectId, relativePath, 'contribute-open-design');
|
||||
}
|
||||
|
||||
export type PluginShareProjectOutcome =
|
||||
| (CreatePluginShareProjectResponse & { ok: true })
|
||||
| {
|
||||
ok: false;
|
||||
message: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
export async function createPluginShareProject(
|
||||
pluginId: string,
|
||||
action: PluginShareAction,
|
||||
locale?: string,
|
||||
): Promise<PluginShareProjectOutcome> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/plugins/${encodeURIComponent(pluginId)}/share-project`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
...(locale ? { locale } : {}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
const body = (await resp.json().catch(() => null)) as
|
||||
| (Partial<CreatePluginShareProjectResponse> & {
|
||||
error?: string | { code?: string; message?: string };
|
||||
code?: string;
|
||||
})
|
||||
| null;
|
||||
if (resp.ok && body?.ok && body.project && body.conversationId) {
|
||||
return body as CreatePluginShareProjectResponse & { ok: true };
|
||||
}
|
||||
const errorMessage =
|
||||
typeof body?.error === 'string' ? body.error : body?.error?.message;
|
||||
const fallbackMessage = resp.statusText || 'Could not create plugin share project.';
|
||||
const message = body?.message ?? errorMessage ?? fallbackMessage;
|
||||
const code =
|
||||
body?.code ?? (typeof body?.error === 'object' ? body.error.code : undefined);
|
||||
return {
|
||||
ok: false,
|
||||
message,
|
||||
...(code ? { code } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
message: (err as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function postGeneratedPluginShareAction(
|
||||
projectId: string,
|
||||
relativePath: string,
|
||||
|
|
|
|||
|
|
@ -730,7 +730,8 @@
|
|||
z-index: 3;
|
||||
}
|
||||
.plugins-home__card:hover .plugins-home__card-overlay,
|
||||
.plugins-home__card:focus-within .plugins-home__card-overlay {
|
||||
.plugins-home__card:focus-within .plugins-home__card-overlay,
|
||||
.plugins-home__card--shareable .plugins-home__card-overlay {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
|
@ -793,9 +794,19 @@
|
|||
}
|
||||
.plugins-home__overlay-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.plugins-home__overlay-actions-main,
|
||||
.plugins-home__share-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.plugins-home__share-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ---------- Resting metadata foot ----------
|
||||
Always visible at the bottom of the card, showing title +
|
||||
|
|
@ -875,6 +886,16 @@
|
|||
transition: background-color 120ms ease, border-color 120ms ease,
|
||||
color 120ms ease;
|
||||
}
|
||||
.plugins-home__action--compact {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
.plugins-home__action--compact span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.plugins-home__action--primary {
|
||||
flex: 1;
|
||||
background: white;
|
||||
|
|
@ -900,6 +921,10 @@
|
|||
background: rgba(255, 255, 255, 0.24);
|
||||
border-color: rgba(255, 255, 255, 0.48);
|
||||
}
|
||||
.plugins-home__action--secondary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
/* Touch/coarse-pointer fallback — overlays should be visible
|
||||
without hover so phone/tablet users can still see metadata. */
|
||||
|
|
|
|||
|
|
@ -46,6 +46,23 @@ const DEFAULT_PLUGIN = {
|
|||
},
|
||||
};
|
||||
|
||||
const HIDDEN_DEFAULT_PLUGIN = {
|
||||
...DEFAULT_PLUGIN,
|
||||
id: 'od-default',
|
||||
title: 'Default design router',
|
||||
source: '/tmp/default-router',
|
||||
fsPath: '/tmp/default-router',
|
||||
manifest: {
|
||||
...DEFAULT_PLUGIN.manifest,
|
||||
name: 'od-default',
|
||||
title: 'Default design router',
|
||||
od: {
|
||||
...DEFAULT_PLUGIN.manifest.od,
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const AUTHORING_APPLY_RESULT = {
|
||||
query: 'Create a plugin.',
|
||||
contextItems: [],
|
||||
|
|
@ -164,6 +181,42 @@ describe('HomeView prompt handoff', () => {
|
|||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
});
|
||||
|
||||
it('routes free-form submits through the hidden default plugin without applying a visible chip', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>(async (url) => {
|
||||
if (typeof url === 'string' && url === '/api/plugins') {
|
||||
return new Response(JSON.stringify({ plugins: [HIDDEN_DEFAULT_PLUGIN, DEFAULT_PLUGIN] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
render(
|
||||
<HomeView
|
||||
projects={[]}
|
||||
onSubmit={onSubmit}
|
||||
onOpenProject={() => undefined}
|
||||
onViewAllProjects={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = await screen.findByTestId('home-hero-input');
|
||||
fireEvent.change(input, { target: { value: 'Make a launch page for a robotics studio' } });
|
||||
fireEvent.click(screen.getByTestId('home-hero-submit'));
|
||||
|
||||
expect(screen.queryByTestId('home-hero-active-plugin')).toBeNull();
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
prompt: 'Make a launch page for a robotics studio',
|
||||
pluginId: 'od-default',
|
||||
appliedPluginSnapshotId: null,
|
||||
pluginInputs: { prompt: 'Make a launch page for a robotics studio' },
|
||||
projectKind: 'other',
|
||||
}));
|
||||
});
|
||||
|
||||
it('falls back to od-new-generation when od-plugin-authoring is not registered yet', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>(async (url) => {
|
||||
if (typeof url === 'string' && url === '/api/plugins') {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||
import {
|
||||
applyPlugin,
|
||||
contributeGeneratedPluginToOpenDesign,
|
||||
createPluginShareProject,
|
||||
installGeneratedPluginFolder,
|
||||
listPlugins,
|
||||
publishGeneratedPluginToGitHub,
|
||||
} from '../../src/state/projects';
|
||||
|
||||
|
|
@ -56,6 +58,33 @@ describe('applyPlugin', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('listPlugins', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('hides plugins marked od.hidden from UI-facing lists', async () => {
|
||||
const visible = {
|
||||
id: 'od-new-generation',
|
||||
title: 'New generation',
|
||||
manifest: { od: { kind: 'scenario' } },
|
||||
};
|
||||
const hidden = {
|
||||
id: 'od-default',
|
||||
title: 'Default design router',
|
||||
manifest: { od: { kind: 'scenario', hidden: true } },
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>(async () => new Response(
|
||||
JSON.stringify({ plugins: [hidden, visible] }),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
)));
|
||||
|
||||
const rows = await listPlugins();
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual(['od-new-generation']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installGeneratedPluginFolder', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
|
|
@ -152,3 +181,79 @@ describe('generated plugin share actions', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPluginShareProject', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('creates an agent-backed share project for an installed plugin', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
project: {
|
||||
id: 'project-1',
|
||||
name: 'Publish to GitHub: Sample Plugin',
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
pendingPrompt: 'Publish it',
|
||||
metadata: { kind: 'prototype' },
|
||||
},
|
||||
conversationId: 'conversation-1',
|
||||
appliedPluginSnapshotId: 'snapshot-1',
|
||||
actionPluginId: 'od-plugin-publish-github',
|
||||
sourcePluginId: 'sample-plugin',
|
||||
stagedPath: 'plugin-source/sample-plugin',
|
||||
prompt: 'Publish it',
|
||||
message: 'Created a Publish to GitHub task.',
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const outcome = await createPluginShareProject(
|
||||
'sample-plugin',
|
||||
'publish-github',
|
||||
'zh-CN',
|
||||
);
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
ok: true,
|
||||
project: { id: 'project-1' },
|
||||
appliedPluginSnapshotId: 'snapshot-1',
|
||||
stagedPath: 'plugin-source/sample-plugin',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/plugins/sample-plugin/share-project',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'publish-github', locale: 'zh-CN' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces share project errors from the daemon', async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
code: 'share-action-plugin-missing',
|
||||
message: 'Restart the daemon.',
|
||||
}),
|
||||
{ status: 409, headers: { 'content-type': 'application/json' } },
|
||||
));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const outcome = await createPluginShareProject(
|
||||
'sample-plugin',
|
||||
'contribute-open-design',
|
||||
);
|
||||
|
||||
expect(outcome).toEqual({
|
||||
ok: false,
|
||||
code: 'share-action-plugin-missing',
|
||||
message: 'Restart the daemon.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { playwrightUiScenarios } from '../../resources/playwright.ts';
|
||||
|
||||
export type ScenarioKind = 'prototype' | 'deck' | 'template' | 'workspace';
|
||||
export type ScenarioKind = 'prototype' | 'deck' | 'hyperframes' | 'template' | 'workspace';
|
||||
|
||||
export interface MockArtifactScenario {
|
||||
identifier: string;
|
||||
|
|
@ -33,12 +33,17 @@ export interface UiScenario {
|
|||
| 'deck-pagination-next-prev-correctness'
|
||||
| 'deck-pagination-per-file-isolated'
|
||||
| 'uploaded-image-renders-in-preview'
|
||||
| 'python-source-preview';
|
||||
| 'python-source-preview'
|
||||
| 'plugin-create-import'
|
||||
| 'home-rail-generation';
|
||||
automated: boolean;
|
||||
description: string;
|
||||
create: {
|
||||
projectName: string;
|
||||
tab?: 'prototype' | 'deck' | 'template' | 'other';
|
||||
railChip?: 'prototype' | 'deck' | 'hyperframes';
|
||||
expectedProjectKind?: 'prototype' | 'deck' | 'video';
|
||||
expectedPluginId?: 'od-new-generation' | 'example-hyperframes';
|
||||
};
|
||||
prompt: string;
|
||||
secondaryPrompt?: string;
|
||||
|
|
|
|||
|
|
@ -52,6 +52,88 @@ export const playwrightUiScenarios: UiScenario[] = [
|
|||
'Confirms the deck creation tab still routes into the same generation path.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'home-rail-prototype-generation',
|
||||
title: 'Home rail Prototype chip creates a project and generated content',
|
||||
kind: 'prototype',
|
||||
flow: 'home-rail-generation',
|
||||
automated: true,
|
||||
description:
|
||||
'Clicks the Home rail Prototype chip, submits a prompt, verifies the project is persisted as a prototype, and confirms generated HTML content is saved and previewed.',
|
||||
create: {
|
||||
projectName: 'Rail prototype project',
|
||||
railChip: 'prototype',
|
||||
expectedProjectKind: 'prototype',
|
||||
expectedPluginId: 'od-new-generation',
|
||||
},
|
||||
prompt: 'Create a compact onboarding prototype from the Home rail prototype chip',
|
||||
mockArtifact: {
|
||||
identifier: 'rail-prototype-artifact',
|
||||
title: 'Rail Prototype Artifact',
|
||||
fileName: 'rail-prototype-artifact.html',
|
||||
heading: 'Rail Prototype Artifact',
|
||||
html:
|
||||
'<!doctype html><html><body><main><h1>Rail Prototype Artifact</h1><p>Generated from the Prototype rail chip.</p><button>Start flow</button></main></body></html>',
|
||||
},
|
||||
notes: [
|
||||
'Covers the screenshot toolbar chip instead of the New Project modal.',
|
||||
'Persists through the real daemon project and file APIs while mocking only the agent run stream.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'home-rail-deck-generation',
|
||||
title: 'Home rail Slide deck chip creates a project and generated deck',
|
||||
kind: 'deck',
|
||||
flow: 'home-rail-generation',
|
||||
automated: true,
|
||||
description:
|
||||
'Clicks the Home rail Slide deck chip, submits a prompt, verifies the project is persisted as a deck, and confirms a deck-shaped artifact is saved and previewed.',
|
||||
create: {
|
||||
projectName: 'Rail slide deck project',
|
||||
railChip: 'deck',
|
||||
expectedProjectKind: 'deck',
|
||||
expectedPluginId: 'od-new-generation',
|
||||
},
|
||||
prompt: 'Create a three-slide strategy deck from the Home rail slide deck chip',
|
||||
mockArtifact: {
|
||||
identifier: 'rail-slide-deck',
|
||||
title: 'Rail Slide Deck',
|
||||
fileName: 'rail-slide-deck.html',
|
||||
heading: 'Rail Slide Deck',
|
||||
html:
|
||||
'<!doctype html><html><body><section class="slide"><h1>Rail Slide Deck</h1><p>Opening thesis generated from the Slide deck rail chip.</p></section><section class="slide" hidden><h1>Roadmap</h1></section><section class="slide" hidden><h1>Launch Plan</h1></section></body></html>',
|
||||
},
|
||||
notes: [
|
||||
'Keeps the requested scope to slides without exercising unrelated media chips.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'home-rail-hyperframes-generation',
|
||||
title: 'Home rail HyperFrames chip creates a video project and generated motion content',
|
||||
kind: 'hyperframes',
|
||||
flow: 'home-rail-generation',
|
||||
automated: true,
|
||||
description:
|
||||
'Clicks the Home rail HyperFrames chip, submits a prompt, verifies the project is persisted as a video project, and confirms generated HyperFrames-style HTML content is saved and previewed.',
|
||||
create: {
|
||||
projectName: 'Rail HyperFrames project',
|
||||
railChip: 'hyperframes',
|
||||
expectedProjectKind: 'video',
|
||||
expectedPluginId: 'example-hyperframes',
|
||||
},
|
||||
prompt: 'Create a five-second HyperFrames product reveal from the Home rail HyperFrames chip',
|
||||
mockArtifact: {
|
||||
identifier: 'rail-hyperframes-motion',
|
||||
title: 'Rail HyperFrames Motion',
|
||||
fileName: 'rail-hyperframes-motion.html',
|
||||
heading: 'Rail HyperFrames Motion',
|
||||
html:
|
||||
'<!doctype html><html><body><main data-duration="5" data-width="1920" data-height="1080"><h1>Rail HyperFrames Motion</h1><p>Generated from the HyperFrames rail chip with a timed motion canvas.</p></main></body></html>',
|
||||
},
|
||||
notes: [
|
||||
'Uses the HyperFrames scenario plugin but keeps the run stream deterministic for CI.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'comment-attachment-flow',
|
||||
title: 'Preview comments attach to chat and send as structured context',
|
||||
|
|
@ -406,4 +488,22 @@ export const playwrightUiScenarios: UiScenario[] = [
|
|||
'Seeds a deterministic .py file through the project files API, opens it from the file list, and asserts the source viewer renders readable code text.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'plugin-create-import',
|
||||
title: 'Plugin creation and import flow applies a test query end to end',
|
||||
kind: 'workspace',
|
||||
flow: 'plugin-create-import',
|
||||
automated: true,
|
||||
description:
|
||||
'Exercises the Plugins view create entry point, installs a local fixture plugin through the import dialog, applies it from Home search, and verifies the seeded query creates and auto-runs a project.',
|
||||
create: {
|
||||
projectName: 'Plugin create import flow',
|
||||
tab: 'prototype',
|
||||
},
|
||||
prompt: 'Generate a release QA brief for general.',
|
||||
notes: [
|
||||
'Uses the daemon plugin install endpoint with a generated query plugin fixture instead of mocking the import response.',
|
||||
'Mocks only the final agent run SSE so the UI path stays deterministic while the plugin install/apply APIs remain real.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,9 +1,58 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Dialog, Page, Request, Response } from '@playwright/test';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { automatedUiScenarios } from '@/playwright/resources';
|
||||
import type { UiScenario } from '@/playwright/resources';
|
||||
|
||||
const STORAGE_KEY = 'open-design:config';
|
||||
const QUERY_PLUGIN_MANIFEST = {
|
||||
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
|
||||
name: 'query-plugin',
|
||||
title: 'Query Plugin',
|
||||
version: '1.0.0',
|
||||
description: 'E2E fixture for import, apply, and query rendering.',
|
||||
license: 'MIT',
|
||||
tags: ['e2e', 'query'],
|
||||
od: {
|
||||
kind: 'skill',
|
||||
taskKind: 'new-generation',
|
||||
useCase: {
|
||||
query: 'Generate a {{topic}} brief for {{audience}}.',
|
||||
},
|
||||
inputs: [
|
||||
{
|
||||
name: 'topic',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: 'release QA',
|
||||
label: 'Topic',
|
||||
},
|
||||
{
|
||||
name: 'audience',
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: 'general',
|
||||
label: 'Audience',
|
||||
},
|
||||
],
|
||||
capabilities: ['prompt:inject'],
|
||||
},
|
||||
};
|
||||
const QUERY_PLUGIN_SKILL = [
|
||||
'---',
|
||||
'name: query-plugin',
|
||||
'description: E2E fixture for plugin import and query rendering.',
|
||||
'od:',
|
||||
' kind: skill',
|
||||
' taskKind: new-generation',
|
||||
'---',
|
||||
'',
|
||||
'# Query Plugin',
|
||||
'',
|
||||
'Use this fixture to verify that a user-installed plugin can render a starter query and bind that query to a project run.',
|
||||
].join('\n');
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((key) => {
|
||||
|
|
@ -239,6 +288,14 @@ for (const entry of automatedUiScenarios()) {
|
|||
await runExampleUsePromptFlow(page, entry);
|
||||
return;
|
||||
}
|
||||
if (entry.flow === 'plugin-create-import') {
|
||||
await runPluginCreateImportFlow(page, entry);
|
||||
return;
|
||||
}
|
||||
if (entry.flow === 'home-rail-generation') {
|
||||
await runHomeRailGenerationFlow(page, entry);
|
||||
return;
|
||||
}
|
||||
|
||||
await createProject(page, entry);
|
||||
await expectWorkspaceReady(page);
|
||||
|
|
@ -484,6 +541,50 @@ async function routeMockAgents(page: Page) {
|
|||
});
|
||||
}
|
||||
|
||||
async function routeMockSuccessfulRun(page: Page, runId: string) {
|
||||
await page.route('**/api/runs', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 202,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ runId }),
|
||||
});
|
||||
});
|
||||
await page.route('**/api/runs/*/events', async (route) => {
|
||||
const body = [
|
||||
'event: start',
|
||||
'data: {"bin":"mock-agent"}',
|
||||
'',
|
||||
'event: stdout',
|
||||
'data: {"chunk":"Plugin flow completed."}',
|
||||
'',
|
||||
'event: end',
|
||||
'data: {"code":0,"status":"succeeded"}',
|
||||
'',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
},
|
||||
body,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function createQueryPluginFixture(): Promise<string> {
|
||||
const folder = await mkdtemp(path.join(tmpdir(), 'od-query-plugin-'));
|
||||
await writeFile(
|
||||
path.join(folder, 'open-design.json'),
|
||||
`${JSON.stringify(QUERY_PLUGIN_MANIFEST, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
await writeFile(path.join(folder, 'SKILL.md'), `${QUERY_PLUGIN_SKILL}\n`, 'utf8');
|
||||
return folder;
|
||||
}
|
||||
|
||||
async function createEmptyProject(page: Page, name: string): Promise<string> {
|
||||
await page.goto('/');
|
||||
await expect(page.getByTestId('new-project-panel')).toBeVisible();
|
||||
|
|
@ -595,6 +696,12 @@ async function expectWorkspaceReady(page: Page) {
|
|||
await expect(page.getByText('Start a conversation')).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectProjectShellReady(page: Page) {
|
||||
await expect(page).toHaveURL(/\/projects\//);
|
||||
await expect(page.getByTestId('chat-composer')).toBeVisible();
|
||||
await expect(page.getByTestId('file-workspace')).toBeVisible();
|
||||
}
|
||||
|
||||
async function sendPrompt(
|
||||
page: Page,
|
||||
prompt: string,
|
||||
|
|
@ -641,11 +748,21 @@ function isCreateRunResponse(resp: Response): boolean {
|
|||
return url.pathname === '/api/runs' && resp.request().method() === 'POST';
|
||||
}
|
||||
|
||||
function isCreateProjectResponse(resp: Response): boolean {
|
||||
const url = new URL(resp.url());
|
||||
return url.pathname === '/api/projects' && resp.request().method() === 'POST';
|
||||
}
|
||||
|
||||
function isCreateRunRequest(request: Request): boolean {
|
||||
const url = new URL(request.url());
|
||||
return url.pathname === '/api/runs' && request.method() === 'POST';
|
||||
}
|
||||
|
||||
function isCreateProjectRequest(request: Request): boolean {
|
||||
const url = new URL(request.url());
|
||||
return url.pathname === '/api/projects' && request.method() === 'POST';
|
||||
}
|
||||
|
||||
async function runDesignSystemSelectionFlow(
|
||||
page: Page,
|
||||
entry: UiScenario,
|
||||
|
|
@ -678,6 +795,159 @@ async function runExampleUsePromptFlow(
|
|||
await expect(page.getByTestId('project-meta')).toContainText('Warm Utility Example');
|
||||
}
|
||||
|
||||
async function runHomeRailGenerationFlow(
|
||||
page: Page,
|
||||
entry: UiScenario,
|
||||
) {
|
||||
const chipId = entry.create.railChip;
|
||||
const expectedProjectKind = entry.create.expectedProjectKind;
|
||||
const expectedPluginId = entry.create.expectedPluginId;
|
||||
const artifact = entry.mockArtifact;
|
||||
if (!chipId || !expectedProjectKind || !expectedPluginId || !artifact) {
|
||||
throw new Error(`home rail scenario ${entry.id} is missing required test data`);
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('home-hero-rail')).toBeVisible();
|
||||
const chip = page.getByTestId(`home-hero-rail-${chipId}`);
|
||||
await expect(chip).toBeVisible();
|
||||
await expect(chip).toBeEnabled();
|
||||
await chip.click();
|
||||
await expect(chip).toHaveAttribute('aria-pressed', 'true', { timeout: 10_000 });
|
||||
await expect(page.getByTestId('home-hero-active-plugin')).toBeVisible();
|
||||
|
||||
const input = page.getByTestId('home-hero-input');
|
||||
await input.fill(entry.prompt);
|
||||
await expect(input).toHaveValue(entry.prompt);
|
||||
await expect(page.getByTestId('home-hero-submit')).toBeEnabled();
|
||||
|
||||
const createProjectResponse = page.waitForResponse(isCreateProjectResponse);
|
||||
const runRequest = page.waitForRequest(isCreateRunRequest);
|
||||
const runResponse = page.waitForResponse(isCreateRunResponse);
|
||||
await page.getByTestId('home-hero-submit').click();
|
||||
|
||||
const createResponse = await createProjectResponse;
|
||||
expect(createResponse.ok()).toBeTruthy();
|
||||
const createBody = (await createResponse.json()) as {
|
||||
project: { id: string };
|
||||
appliedPluginSnapshotId?: string;
|
||||
};
|
||||
expect(createBody.appliedPluginSnapshotId).toBeTruthy();
|
||||
|
||||
await expectProjectShellReady(page);
|
||||
|
||||
const request = await runRequest;
|
||||
const runBody = request.postDataJSON() as {
|
||||
projectId?: string;
|
||||
message?: string;
|
||||
};
|
||||
expect(runBody.projectId).toBe(createBody.project.id);
|
||||
expect(runBody.message).toContain(entry.prompt);
|
||||
|
||||
const response = await runResponse;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
await expectArtifactVisible(page, entry);
|
||||
|
||||
const { projectId } = await getCurrentProjectContext(page);
|
||||
expect(projectId).toBe(createBody.project.id);
|
||||
|
||||
const project = await fetchProjectFromApi(page, projectId);
|
||||
expect(project.metadata?.kind).toBe(expectedProjectKind);
|
||||
expect(project.appliedPluginSnapshotId).toBe(createBody.appliedPluginSnapshotId);
|
||||
|
||||
const snapshot = await fetchAppliedPluginSnapshotFromApi(
|
||||
page,
|
||||
project.appliedPluginSnapshotId!,
|
||||
);
|
||||
expect(snapshot.pluginId).toBe(expectedPluginId);
|
||||
|
||||
await expect(page.getByTestId('msg-plugin-chip')).toBeVisible();
|
||||
await expect(page.getByTestId('msg-plugin-chip')).toContainText(snapshot.pluginTitle);
|
||||
await expectProjectFileToContain(page, projectId, artifact.fileName, artifact.heading);
|
||||
|
||||
await page.reload();
|
||||
await expectProjectShellReady(page);
|
||||
await expectProjectFileToContain(page, projectId, artifact.fileName, artifact.heading);
|
||||
await expect(page.getByText(artifact.fileName, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function runPluginCreateImportFlow(
|
||||
page: Page,
|
||||
entry: UiScenario,
|
||||
) {
|
||||
await routeMockSuccessfulRun(page, 'plugin-create-import-run');
|
||||
|
||||
await page.getByTestId('entry-nav-plugins').click();
|
||||
await expect(page.getByTestId('plugins-create-button')).toBeVisible();
|
||||
|
||||
await page.getByTestId('plugins-create-button').click();
|
||||
const homeInput = page.getByTestId('home-hero-input');
|
||||
await expect(homeInput).toHaveValue(/Create an Open Design plugin/);
|
||||
await expect(page.getByTestId('home-hero-active-plugin')).toContainText('Plugin authoring');
|
||||
|
||||
await page.getByTestId('entry-nav-plugins').click();
|
||||
await expect(page.getByTestId('plugins-import-button')).toBeVisible();
|
||||
await page.getByTestId('plugins-import-button').click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Create or import a plugin' });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: /From GitHub/i })).toBeVisible();
|
||||
|
||||
const queryPluginFixture = await createQueryPluginFixture();
|
||||
try {
|
||||
await dialog.getByLabel('GitHub, archive, or marketplace source').fill(queryPluginFixture);
|
||||
const installResponse = page.waitForResponse(
|
||||
(resp) =>
|
||||
new URL(resp.url()).pathname === '/api/plugins/install' &&
|
||||
resp.request().method() === 'POST',
|
||||
);
|
||||
await dialog.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
expect((await installResponse).ok()).toBeTruthy();
|
||||
|
||||
await expect(page.getByText('Installed Query Plugin.')).toBeVisible();
|
||||
await expect(page.getByTestId('plugins-tab-mine')).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.locator('[data-plugin-id="query-plugin"]')).toBeVisible();
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByTestId('home-hero')).toBeVisible();
|
||||
await homeInput.click();
|
||||
await homeInput.fill('@query');
|
||||
const picker = page.getByTestId('home-hero-plugin-picker');
|
||||
await expect(picker).toBeVisible();
|
||||
const queryOption = picker.getByRole('option', { name: /Query Plugin/ });
|
||||
await expect(queryOption).toBeEnabled();
|
||||
await queryOption.click();
|
||||
|
||||
await expect(page.getByTestId('home-hero-active-plugin')).toContainText('Query Plugin');
|
||||
await expect(homeInput).toHaveValue('Generate a release QA brief for general.');
|
||||
await homeInput.fill(entry.prompt);
|
||||
await expect(page.getByTestId('home-hero-submit')).toBeEnabled();
|
||||
|
||||
const projectRequestPromise = page.waitForRequest(isCreateProjectRequest);
|
||||
const runRequestPromise = page.waitForRequest(isCreateRunRequest);
|
||||
await page.getByTestId('home-hero-submit').click();
|
||||
|
||||
const projectRequest = await projectRequestPromise;
|
||||
const projectBody = projectRequest.postDataJSON() as {
|
||||
pluginId?: string;
|
||||
pendingPrompt?: string;
|
||||
metadata?: { kind?: string };
|
||||
};
|
||||
expect(projectBody.pluginId).toBe('query-plugin');
|
||||
expect(projectBody.pendingPrompt).toBe(entry.prompt);
|
||||
expect(projectBody.metadata?.kind).toBe('other');
|
||||
|
||||
await expect(page).toHaveURL(/\/projects\//);
|
||||
|
||||
const runRequest = await runRequestPromise;
|
||||
const runBody = runRequest.postDataJSON() as { message?: string };
|
||||
expect(runBody.message).toContain(entry.prompt);
|
||||
await expect(page.getByText(entry.prompt, { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await rm(queryPluginFixture, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function runQuestionFormSelectionLimitFlow(
|
||||
page: Page,
|
||||
entry: UiScenario,
|
||||
|
|
@ -1002,6 +1272,33 @@ async function getCurrentProjectContext(
|
|||
return { projectId, conversationId: active.id };
|
||||
}
|
||||
|
||||
async function fetchProjectFromApi(
|
||||
page: Page,
|
||||
projectId: string,
|
||||
): Promise<{
|
||||
metadata?: { kind?: string };
|
||||
appliedPluginSnapshotId?: string;
|
||||
}> {
|
||||
const response = await page.request.get(`/api/projects/${projectId}`);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const { project } = (await response.json()) as {
|
||||
project: {
|
||||
metadata?: { kind?: string };
|
||||
appliedPluginSnapshotId?: string;
|
||||
};
|
||||
};
|
||||
return project;
|
||||
}
|
||||
|
||||
async function fetchAppliedPluginSnapshotFromApi(
|
||||
page: Page,
|
||||
snapshotId: string,
|
||||
): Promise<{ pluginId: string; pluginTitle: string }> {
|
||||
const response = await page.request.get(`/api/applied-plugins/${snapshotId}`);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
return (await response.json()) as { pluginId: string; pluginTitle: string };
|
||||
}
|
||||
|
||||
async function listProjectFilesFromApi(
|
||||
page: Page,
|
||||
projectId: string,
|
||||
|
|
@ -1012,6 +1309,21 @@ async function listProjectFilesFromApi(
|
|||
return files;
|
||||
}
|
||||
|
||||
async function expectProjectFileToContain(
|
||||
page: Page,
|
||||
projectId: string,
|
||||
fileName: string,
|
||||
expected: string,
|
||||
) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const response = await page.request.get(`/api/projects/${projectId}/files/${fileName}`);
|
||||
if (!response.ok()) return '';
|
||||
return response.text();
|
||||
}, { timeout: 15_000 })
|
||||
.toContain(expected);
|
||||
}
|
||||
|
||||
async function expectArtifactVisible(
|
||||
page: Page,
|
||||
entry: UiScenario,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ export type ChatRunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'can
|
|||
|
||||
export interface ChatRunCreateResponse {
|
||||
runId: string;
|
||||
appliedPluginSnapshotId?: string;
|
||||
pluginId?: string;
|
||||
}
|
||||
|
||||
export interface ChatRunStatusResponse {
|
||||
|
|
@ -44,6 +46,8 @@ export interface ChatRunStatusResponse {
|
|||
conversationId: string | null;
|
||||
assistantMessageId: string | null;
|
||||
agentId: string | null;
|
||||
appliedPluginSnapshotId?: string | null;
|
||||
pluginId?: string | null;
|
||||
status: ChatRunStatus;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
|
|
|
|||
|
|
@ -129,6 +129,9 @@ export interface CreateProjectRequest {
|
|||
designSystemId?: string | null;
|
||||
pendingPrompt?: string;
|
||||
metadata?: ProjectMetadata;
|
||||
pluginId?: string;
|
||||
appliedPluginSnapshotId?: string;
|
||||
pluginInputs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdateProjectRequest {
|
||||
|
|
@ -149,6 +152,7 @@ export interface ProjectResponse {
|
|||
|
||||
export interface CreateProjectResponse extends ProjectResponse {
|
||||
conversationId?: string;
|
||||
appliedPluginSnapshotId?: string;
|
||||
}
|
||||
|
||||
// POST /api/import/folder — create a project rooted at an existing local
|
||||
|
|
|
|||
|
|
@ -5,3 +5,4 @@ export * from './marketplace.js';
|
|||
export * from './installed.js';
|
||||
export * from './events.js';
|
||||
export * from './scenario-defaults.js';
|
||||
export * from './share-actions.js';
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export type TaskKind = AppliedPluginSnapshot['taskKind'];
|
|||
// string-literal union so a typo here surfaces as a type error in both
|
||||
// the web shell and the daemon resolver.
|
||||
export type DefaultScenarioPluginId =
|
||||
| 'od-default'
|
||||
| 'od-new-generation'
|
||||
| 'od-media-generation'
|
||||
| 'od-plugin-authoring'
|
||||
|
|
@ -28,6 +29,9 @@ export type DefaultScenarioPluginId =
|
|||
| 'od-code-migration'
|
||||
| 'od-tune-collab';
|
||||
|
||||
export const DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID =
|
||||
'od-default' satisfies DefaultScenarioPluginId;
|
||||
|
||||
export const DEFAULT_SCENARIO_PLUGIN_BY_KIND: Record<ProjectKind, DefaultScenarioPluginId> = {
|
||||
prototype: 'od-new-generation',
|
||||
deck: 'od-new-generation',
|
||||
|
|
|
|||
30
packages/contracts/src/plugins/share-actions.ts
Normal file
30
packages/contracts/src/plugins/share-actions.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { Project } from '../api/projects.js';
|
||||
|
||||
export const PLUGIN_SHARE_ACTIONS = [
|
||||
'publish-github',
|
||||
'contribute-open-design',
|
||||
] as const;
|
||||
|
||||
export type PluginShareAction = (typeof PLUGIN_SHARE_ACTIONS)[number];
|
||||
|
||||
export const PLUGIN_SHARE_ACTION_PLUGIN_IDS: Record<PluginShareAction, string> = {
|
||||
'publish-github': 'od-plugin-publish-github',
|
||||
'contribute-open-design': 'od-plugin-contribute-open-design',
|
||||
};
|
||||
|
||||
export interface CreatePluginShareProjectRequest {
|
||||
action: PluginShareAction;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export interface CreatePluginShareProjectResponse {
|
||||
ok: true;
|
||||
project: Project;
|
||||
conversationId: string;
|
||||
appliedPluginSnapshotId?: string;
|
||||
actionPluginId: string;
|
||||
sourcePluginId: string;
|
||||
stagedPath: string;
|
||||
prompt: string;
|
||||
message: string;
|
||||
}
|
||||
|
|
@ -35,6 +35,40 @@ Three hard rules govern the start of every new design task. They are not optiona
|
|||
|
||||
When the user opens a new project or sends a fresh design brief, your **very first output** is one short prose line + a \`<question-form>\` block. Nothing else. No file reads. No Bash. No TodoWrite. No extended thinking. The form is your time-to-first-byte.
|
||||
|
||||
Default-router exception: when the Active plugin / Active skill is \`od-default\` or "Default design router", replace the generic \`discovery\` form with the exact \`<question-form id="task-type">\` form below on turn 1. Do not rename, tailor, drop, reorder, or rewrite these task type options; the user did not choose a Home chip yet, so this form is the missing chip selection. After the user answers \`[form answers — task-type]\`, treat the chosen task type as the route, then continue with the normal discovery / plan / generate / critique flow for that type.
|
||||
|
||||
\`\`\`
|
||||
<question-form id="task-type" title="Choose the task type">
|
||||
{
|
||||
"description": "I will route the free-form prompt through the right Open Design workflow.",
|
||||
"questions": [
|
||||
{
|
||||
"id": "taskType",
|
||||
"label": "What should I build?",
|
||||
"type": "radio",
|
||||
"required": true,
|
||||
"options": [
|
||||
"Prototype",
|
||||
"Live artifact",
|
||||
"Slide deck",
|
||||
"Image",
|
||||
"Video",
|
||||
"HyperFrames",
|
||||
"Audio",
|
||||
"Other"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "constraints",
|
||||
"label": "Any important constraints?",
|
||||
"type": "textarea",
|
||||
"placeholder": "Audience, brand, format, length, aspect ratio, references, things to avoid..."
|
||||
}
|
||||
]
|
||||
}
|
||||
</question-form>
|
||||
\`\`\`
|
||||
|
||||
\`\`\`
|
||||
<question-form id="discovery" title="Quick brief — 30 seconds">
|
||||
{
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest';
|
|||
import {
|
||||
DEFAULT_SCENARIO_PLUGIN_BY_KIND,
|
||||
DEFAULT_SCENARIO_PLUGIN_BY_TASK_KIND,
|
||||
DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID,
|
||||
defaultScenarioPluginIdForKind,
|
||||
defaultScenarioPluginIdForTaskKind,
|
||||
} from '../src/plugins/scenario-defaults.js';
|
||||
|
|
@ -31,6 +32,11 @@ describe('defaultScenarioPluginIdForKind', () => {
|
|||
it('returns null for an undefined kind so the daemon can skip the fallback', () => {
|
||||
expect(defaultScenarioPluginIdForKind(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('exposes the hidden free-form Home fallback plugin separately from kind defaults', () => {
|
||||
expect(DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID).toBe('od-default');
|
||||
expect(DEFAULT_SCENARIO_PLUGIN_BY_KIND.other).toBe('od-new-generation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultScenarioPluginIdForTaskKind', () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
name: od-plugin-contribute-open-design
|
||||
description: Open a pull request adding a local Open Design plugin to the Open Design community catalog using gh CLI.
|
||||
triggers:
|
||||
- contribute plugin
|
||||
- open design pr
|
||||
- github pull request
|
||||
od:
|
||||
mode: utility
|
||||
platform: desktop
|
||||
scenario: plugin-sharing
|
||||
---
|
||||
|
||||
# Contribute Plugin to Open Design
|
||||
|
||||
Use this workflow when the active project contains a copied plugin folder and the user wants to propose it for the Open Design community catalog.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read the active plugin inputs. `plugin_context_path` is the copied plugin folder relative to the project working directory.
|
||||
2. Inspect the copied plugin's manifest, skill instructions, examples, and compatibility metadata.
|
||||
3. Verify `gh auth status --hostname github.com`. If authentication is missing, stop with the exact command the user needs to run.
|
||||
4. Fork or reuse a fork of `nexu-io/open-design`, then clone a clean working copy.
|
||||
5. Create a branch named like `plugin/<source_plugin_id>`.
|
||||
6. Copy the staged plugin folder into `plugins/community/<source_plugin_id>` in that working copy. Create parent directories when needed.
|
||||
7. Commit only that plugin folder, push the branch to the user's fork, and run `gh pr create --repo nexu-io/open-design --base main`.
|
||||
8. Report the PR URL, branch name, and any validation performed.
|
||||
|
||||
Keep the pull request focused. Do not modify unrelated Open Design files unless a manifest validation issue requires a tiny supporting change.
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
{
|
||||
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
|
||||
"name": "od-plugin-contribute-open-design",
|
||||
"title": "Contribute Plugin to Open Design",
|
||||
"version": "0.1.0",
|
||||
"description": "Opens a pull request that adds a local Open Design plugin to the Open Design community catalog using the GitHub CLI.",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Open Design",
|
||||
"url": "https://github.com/nexu-io"
|
||||
},
|
||||
"homepage": "https://github.com/nexu-io/open-design/tree/main/plugins/_official/examples/od-plugin-contribute-open-design",
|
||||
"tags": [
|
||||
"share",
|
||||
"github",
|
||||
"github-pr",
|
||||
"pull-request",
|
||||
"open-design",
|
||||
"plugin-authoring",
|
||||
"community-plugin",
|
||||
"first-party"
|
||||
],
|
||||
"compat": {
|
||||
"agentSkills": [
|
||||
{
|
||||
"path": "./SKILL.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
"od": {
|
||||
"kind": "scenario",
|
||||
"taskKind": "new-generation",
|
||||
"mode": "utility",
|
||||
"platform": "desktop",
|
||||
"scenario": "plugin-sharing",
|
||||
"surface": "github",
|
||||
"useCase": {
|
||||
"query": {
|
||||
"en": "Open a pull request to add the staged Open Design plugin to nexu-io/open-design using gh CLI.",
|
||||
"zh-CN": "使用 gh CLI 给 nexu-io/open-design 提交一个 PR,加入已暂存的 Open Design 插件。"
|
||||
}
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source_plugin_id",
|
||||
"label": "Source plugin id",
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "source_plugin_title",
|
||||
"label": "Source plugin title",
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "source_plugin_version",
|
||||
"label": "Source plugin version",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "plugin_context_path",
|
||||
"label": "Copied plugin folder",
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "source_plugin_path",
|
||||
"label": "Original plugin folder",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"context": {
|
||||
"skills": [
|
||||
{
|
||||
"path": "./SKILL.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": [
|
||||
"fs:read",
|
||||
"fs:write",
|
||||
"bash",
|
||||
"subprocess",
|
||||
"network"
|
||||
]
|
||||
}
|
||||
}
|
||||
28
plugins/_official/examples/od-plugin-publish-github/SKILL.md
Normal file
28
plugins/_official/examples/od-plugin-publish-github/SKILL.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
name: od-plugin-publish-github
|
||||
description: Publish a local Open Design plugin to a new public GitHub repository using gh CLI.
|
||||
triggers:
|
||||
- publish plugin
|
||||
- github repo
|
||||
- open source plugin
|
||||
od:
|
||||
mode: utility
|
||||
platform: desktop
|
||||
scenario: plugin-sharing
|
||||
---
|
||||
|
||||
# Publish Plugin to GitHub
|
||||
|
||||
Use this workflow when the active project contains a copied plugin folder and the user wants it published as a new public GitHub repository.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read the active plugin inputs. `plugin_context_path` is the copied plugin folder relative to the project working directory.
|
||||
2. Inspect `open-design.json`, `SKILL.md`, and any compatibility metadata in the copied folder.
|
||||
3. Verify `gh auth status --hostname github.com`. If authentication is missing, stop with the exact command the user needs to run.
|
||||
4. Create a clean temporary git repository from the copied plugin folder. Do not include project wrapper files or unrelated artifacts.
|
||||
5. Commit with a concise message such as `Publish <plugin title> plugin`.
|
||||
6. Create a public repository with `gh repo create <repo-name> --public --source <temp-repo> --push`.
|
||||
7. Report the final repository URL, commit hash, and any validation performed.
|
||||
|
||||
Prefer the manifest `name` as the repository slug. If that repository already exists, choose the next clear slug and mention the rename.
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
{
|
||||
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
|
||||
"name": "od-plugin-publish-github",
|
||||
"title": "Publish Plugin to GitHub",
|
||||
"version": "0.1.0",
|
||||
"description": "Creates a public GitHub repository for a local Open Design plugin using the GitHub CLI.",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Open Design",
|
||||
"url": "https://github.com/nexu-io"
|
||||
},
|
||||
"homepage": "https://github.com/nexu-io/open-design/tree/main/plugins/_official/examples/od-plugin-publish-github",
|
||||
"tags": [
|
||||
"share",
|
||||
"publish",
|
||||
"github",
|
||||
"github-repo",
|
||||
"open-source",
|
||||
"plugin-authoring",
|
||||
"community-plugin",
|
||||
"first-party"
|
||||
],
|
||||
"compat": {
|
||||
"agentSkills": [
|
||||
{
|
||||
"path": "./SKILL.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
"od": {
|
||||
"kind": "scenario",
|
||||
"taskKind": "new-generation",
|
||||
"mode": "utility",
|
||||
"platform": "desktop",
|
||||
"scenario": "plugin-sharing",
|
||||
"surface": "github",
|
||||
"useCase": {
|
||||
"query": {
|
||||
"en": "Publish the staged Open Design plugin to a new public GitHub repository with gh CLI.",
|
||||
"zh-CN": "使用 gh CLI 把已暂存的 Open Design 插件发布成一个新的公开 GitHub 仓库。"
|
||||
}
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source_plugin_id",
|
||||
"label": "Source plugin id",
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "source_plugin_title",
|
||||
"label": "Source plugin title",
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "source_plugin_version",
|
||||
"label": "Source plugin version",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "plugin_context_path",
|
||||
"label": "Copied plugin folder",
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "source_plugin_path",
|
||||
"label": "Original plugin folder",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"context": {
|
||||
"skills": [
|
||||
{
|
||||
"path": "./SKILL.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": [
|
||||
"fs:read",
|
||||
"fs:write",
|
||||
"bash",
|
||||
"subprocess",
|
||||
"network"
|
||||
]
|
||||
}
|
||||
}
|
||||
75
plugins/_official/scenarios/od-default/SKILL.md
Normal file
75
plugins/_official/scenarios/od-default/SKILL.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
---
|
||||
name: od-default
|
||||
description: Hidden fallback scenario for free-form Home prompts. Ask the task type first, then continue through the matching Open Design flow.
|
||||
od:
|
||||
scenario: default-router
|
||||
mode: scenario
|
||||
---
|
||||
|
||||
# od-default (hidden scenario)
|
||||
|
||||
This plugin runs only when the user types a free-form Home prompt without
|
||||
choosing one of the visible category chips. It is the design-engine
|
||||
fallback, not a visible catalog entry.
|
||||
|
||||
## Turn 1: ask the task type
|
||||
|
||||
Your first response must be one short sentence plus this structured form,
|
||||
then stop. Do not write files, use tools, or start planning until the user
|
||||
answers.
|
||||
|
||||
```html
|
||||
<question-form id="task-type" title="Choose the task type">
|
||||
{
|
||||
"description": "I will route the free-form prompt through the right Open Design workflow.",
|
||||
"questions": [
|
||||
{
|
||||
"id": "taskType",
|
||||
"label": "What should I build?",
|
||||
"type": "radio",
|
||||
"required": true,
|
||||
"options": [
|
||||
"Prototype",
|
||||
"Live artifact",
|
||||
"Slide deck",
|
||||
"Image",
|
||||
"Video",
|
||||
"HyperFrames",
|
||||
"Audio",
|
||||
"Other"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "constraints",
|
||||
"label": "Any important constraints?",
|
||||
"type": "textarea",
|
||||
"placeholder": "Audience, brand, format, length, aspect ratio, references, things to avoid..."
|
||||
}
|
||||
]
|
||||
}
|
||||
</question-form>
|
||||
```
|
||||
|
||||
## After the answer
|
||||
|
||||
When the user replies with `[form answers - task-type]`, bind the chosen
|
||||
task type as authoritative and continue:
|
||||
|
||||
- `Prototype`: run the normal new-generation prototype flow.
|
||||
- `Live artifact`: create a live HTML/CSS/JS artifact and register it for
|
||||
preview when tooling is available.
|
||||
- `Slide deck`: follow the deck workflow and framework rules.
|
||||
- `Image`: plan a concrete image prompt, then use the OD media generation
|
||||
CLI for image output.
|
||||
- `Video`: plan shots, duration, aspect, and motion, then use the OD media
|
||||
generation CLI for video output.
|
||||
- `HyperFrames`: create HTML-driven motion frames or a HyperFrames-ready
|
||||
motion artifact before rendering/exporting.
|
||||
- `Audio`: plan voice/music/SFX intent, then use the OD media generation
|
||||
CLI for audio output.
|
||||
- `Other`: ask only the minimum follow-up needed, then choose the closest
|
||||
Open Design workflow and continue.
|
||||
|
||||
Keep the rest of the run plugin-driven: use the discovery, planning,
|
||||
generation, and critique stages declared by this plugin. Do not tell the
|
||||
user to go back and choose a chip; the default plugin owns this fallback.
|
||||
136
plugins/_official/scenarios/od-default/open-design.json
Normal file
136
plugins/_official/scenarios/od-default/open-design.json
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
{
|
||||
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
|
||||
"name": "od-default",
|
||||
"title": "Default design router",
|
||||
"version": "0.1.0",
|
||||
"description": "Hidden fallback scenario for free-form Home prompts. It asks the user which task type to build, then continues through the matching design flow.",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Open Design",
|
||||
"url": "https://github.com/nexu-io"
|
||||
},
|
||||
"homepage": "https://github.com/nexu-io/open-design/tree/main/plugins/_official/scenarios/od-default",
|
||||
"tags": [
|
||||
"scenario",
|
||||
"first-party",
|
||||
"default",
|
||||
"router",
|
||||
"hidden"
|
||||
],
|
||||
"compat": {
|
||||
"agentSkills": [
|
||||
{
|
||||
"path": "./SKILL.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
"od": {
|
||||
"kind": "scenario",
|
||||
"taskKind": "new-generation",
|
||||
"scenario": "default-router",
|
||||
"mode": "scenario",
|
||||
"hidden": true,
|
||||
"pipeline": {
|
||||
"stages": [
|
||||
{
|
||||
"id": "task-type",
|
||||
"atoms": [
|
||||
"discovery-question-form"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "discovery",
|
||||
"atoms": [
|
||||
"discovery-question-form"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "plan",
|
||||
"atoms": [
|
||||
"direction-picker",
|
||||
"todo-write"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "generate",
|
||||
"atoms": [
|
||||
"file-write",
|
||||
"media-image",
|
||||
"media-video",
|
||||
"media-audio",
|
||||
"live-artifact"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "critique",
|
||||
"atoms": [
|
||||
"critique-theater"
|
||||
],
|
||||
"repeat": true,
|
||||
"until": "critique.score>=4 || iterations>=3"
|
||||
}
|
||||
]
|
||||
},
|
||||
"genui": {
|
||||
"surfaces": [
|
||||
{
|
||||
"id": "task-type",
|
||||
"kind": "choice",
|
||||
"persist": "conversation",
|
||||
"trigger": {
|
||||
"stageId": "task-type",
|
||||
"atom": "discovery-question-form"
|
||||
},
|
||||
"prompt": "What type of design task should this free-form prompt create?",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"taskType"
|
||||
],
|
||||
"properties": {
|
||||
"taskType": {
|
||||
"type": "string",
|
||||
"title": "Task type",
|
||||
"enum": [
|
||||
"prototype",
|
||||
"live-artifact",
|
||||
"deck",
|
||||
"image",
|
||||
"video",
|
||||
"hyperframes",
|
||||
"audio",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": [
|
||||
"prompt:inject",
|
||||
"pipeline:*",
|
||||
"genui:choice",
|
||||
"media:image",
|
||||
"media:video",
|
||||
"media:audio",
|
||||
"fs:write"
|
||||
],
|
||||
"useCase": {
|
||||
"query": {
|
||||
"en": "Route a free-form Home prompt by asking the task type first, then continue through the matching design workflow.",
|
||||
"zh-CN": "先询问自由输入属于哪一类设计任务,然后进入匹配的设计工作流。"
|
||||
},
|
||||
"exampleOutputs": []
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"label": "User prompt",
|
||||
"placeholder": "Describe what you want to design"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue