@worca/app 1.2.0-rc.1 → 1.2.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -2
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +358 -19
- package/src/core/ask/models.mjs +7 -1
- package/src/core/ask/turn.mjs +5 -0
- package/src/core/config.mjs +33 -4
- package/src/core/folder-dialog.mjs +21 -12
- package/src/core/marketplaces.mjs +13 -0
- package/src/core/model-env.mjs +41 -0
- package/src/core/model-test.mjs +2 -1
- package/src/core/plugin-manifest.mjs +45 -8
- package/src/core/run-harness.mjs +7 -0
- package/src/core/settings.mjs +61 -0
- package/src/core/title.mjs +72 -9
- package/src/core/ui-instance.mjs +262 -0
- package/src/core/workflow-export.mjs +1169 -0
- package/src/core/workflow-share.mjs +191 -0
- package/src/core/workflows.mjs +35 -4
- package/src/shared/graph/validate.mjs +5 -3
- package/ui/public/app.js +653 -22
- package/ui/public/ask-panel.mjs +4 -2
- package/ui/public/export-slug.mjs +30 -0
- package/ui/public/graph/composer.mjs +24 -3
- package/ui/public/graph/inspector.mjs +4 -1
- package/ui/public/index.html +127 -1
- package/ui/public/models-view.mjs +34 -3
- package/ui/public/style.css +65 -5
- package/ui/server.mjs +305 -90
package/ui/public/app.js
CHANGED
|
@@ -71,6 +71,7 @@ import { createHljsLoader } from './hljs-loader.mjs';
|
|
|
71
71
|
import {
|
|
72
72
|
buildFileTree, renderFileTree, firstFile,
|
|
73
73
|
} from './file-tree.mjs';
|
|
74
|
+
import { exportSlugPreview } from './export-slug.mjs';
|
|
74
75
|
import {
|
|
75
76
|
renderPluginList, renderInstallConsent, renderUpdatePreview,
|
|
76
77
|
renderConfigForm, collectConfigForm, renderConnectResult, renderDoctorReport, renderReferences409,
|
|
@@ -1681,6 +1682,18 @@ const gvApi = {
|
|
|
1681
1682
|
const d = await safeJson(res);
|
|
1682
1683
|
return { ok: false, status: res.status, error: (d && d.error) || `delete failed (${res.status})` };
|
|
1683
1684
|
},
|
|
1685
|
+
// Import a JSON export (#421). 422 carries the shared validator's issues plus
|
|
1686
|
+
// `summary` (the one-line "agents you do not have" fold) when that is the cause.
|
|
1687
|
+
importWorkflow: async (workflow) => {
|
|
1688
|
+
const res = await fetch('/api/workflows/import-json', {
|
|
1689
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workflow }),
|
|
1690
|
+
});
|
|
1691
|
+
const d = await safeJson(res);
|
|
1692
|
+
if (!res.ok) {
|
|
1693
|
+
return { ok: false, status: res.status, error: (d && d.error) || `import failed (${res.status})`, summary: d && d.summary, issues: d && d.errors };
|
|
1694
|
+
}
|
|
1695
|
+
return { ok: true, workflow: d.workflow, renamed: !!d.renamed, requestedName: d.requestedName, warnings: d.warnings || [] };
|
|
1696
|
+
},
|
|
1684
1697
|
};
|
|
1685
1698
|
|
|
1686
1699
|
function gvEls() {
|
|
@@ -1775,13 +1788,57 @@ function composerExit() {
|
|
|
1775
1788
|
if (gvComposer) gvComposer.suspend();
|
|
1776
1789
|
}
|
|
1777
1790
|
|
|
1791
|
+
// Saved-list state that lives for the PAGE SESSION (not persisted): the selected
|
|
1792
|
+
// domain tab, the last fetched rows (tab switches re-render without a fetch),
|
|
1793
|
+
// and the ids imported since load — those carry a NEW pill until reload.
|
|
1794
|
+
let gvSavedTab = null;
|
|
1795
|
+
let gvSavedRows = [];
|
|
1796
|
+
const gvNewIds = new Set();
|
|
1797
|
+
const gvDomainOf = (wf) => wf.domain || 'general';
|
|
1798
|
+
|
|
1799
|
+
/** Scroll the page to its top so the Workflow Composer title AND the canvas are in
|
|
1800
|
+
* view after a row is opened (the saved list sits below the fold). */
|
|
1801
|
+
function gvScrollToTop() {
|
|
1802
|
+
const main = document.querySelector('.main');
|
|
1803
|
+
try { if (main && typeof main.scrollTo === 'function') main.scrollTo({ top: 0, behavior: 'smooth' }); } catch { /* jsdom */ }
|
|
1804
|
+
try { if (typeof window.scrollTo === 'function') window.scrollTo({ top: 0, behavior: 'smooth' }); } catch { /* jsdom */ }
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1778
1807
|
async function gvRefreshSaved() {
|
|
1808
|
+
gvSavedRows = await gvApi.listWorkflows();
|
|
1809
|
+
gvRenderSaved();
|
|
1810
|
+
await gvRefreshArchived();
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
function gvRenderSaved() {
|
|
1779
1814
|
const els = gvEls();
|
|
1780
|
-
const list =
|
|
1815
|
+
const list = gvSavedRows;
|
|
1781
1816
|
els.savedCount.textContent = list.length ? `· ${list.length}` : '';
|
|
1782
|
-
|
|
1817
|
+
const domains = [...new Set(list.map(gvDomainOf))].sort();
|
|
1818
|
+
gvComposer.setSavedDomains(domains);
|
|
1819
|
+
if (!domains.includes(gvSavedTab)) gvSavedTab = domains[0] || null;
|
|
1820
|
+
// ── One tab per domain (the row no longer repeats the domain) ──
|
|
1821
|
+
const tabs = document.getElementById('gv-saved-tabs');
|
|
1822
|
+
tabs.replaceChildren();
|
|
1823
|
+
tabs.hidden = domains.length === 0;
|
|
1824
|
+
for (const d of domains) {
|
|
1825
|
+
const b = document.createElement('button');
|
|
1826
|
+
b.type = 'button';
|
|
1827
|
+
b.className = 'gv-saved-tab' + (d === gvSavedTab ? ' active' : '');
|
|
1828
|
+
b.dataset.domain = d;
|
|
1829
|
+
b.setAttribute('role', 'tab');
|
|
1830
|
+
b.setAttribute('aria-selected', d === gvSavedTab ? 'true' : 'false');
|
|
1831
|
+
b.appendChild(document.createTextNode(d));
|
|
1832
|
+
const badge = document.createElement('span');
|
|
1833
|
+
badge.className = 'gv-saved-tab-badge';
|
|
1834
|
+
badge.textContent = String(list.filter((w) => gvDomainOf(w) === d).length);
|
|
1835
|
+
b.appendChild(badge);
|
|
1836
|
+
b.addEventListener('click', () => { gvSavedTab = d; gvRenderSaved(); });
|
|
1837
|
+
tabs.appendChild(b);
|
|
1838
|
+
}
|
|
1783
1839
|
els.savedList.replaceChildren();
|
|
1784
1840
|
for (const wf of list) {
|
|
1841
|
+
if (gvDomainOf(wf) !== gvSavedTab) continue;
|
|
1785
1842
|
const item = document.createElement('div');
|
|
1786
1843
|
item.className = 'pl-item';
|
|
1787
1844
|
item.dataset.id = wf.id;
|
|
@@ -1802,10 +1859,15 @@ async function gvRefreshSaved() {
|
|
|
1802
1859
|
const name = document.createElement('div');
|
|
1803
1860
|
name.className = 'pl-name';
|
|
1804
1861
|
name.textContent = wf.name || wf.id;
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1862
|
+
// Imported this page session: a NEW pill until the next reload (gvNewIds is
|
|
1863
|
+
// module state, so a reload clears it by construction).
|
|
1864
|
+
if (gvNewIds.has(wf.id)) {
|
|
1865
|
+
const pill = document.createElement('span');
|
|
1866
|
+
pill.className = 'pl-new';
|
|
1867
|
+
pill.textContent = 'NEW';
|
|
1868
|
+
name.appendChild(pill);
|
|
1869
|
+
}
|
|
1870
|
+
main.append(name);
|
|
1809
1871
|
row.appendChild(main);
|
|
1810
1872
|
// A plugin-owned row is replaced wholesale by the next `worca plugin update`
|
|
1811
1873
|
// (src/core/plugin-workflows.mjs upserts ON CONFLICT), so say so BEFORE the
|
|
@@ -1819,22 +1881,46 @@ async function gvRefreshSaved() {
|
|
|
1819
1881
|
row.appendChild(tag);
|
|
1820
1882
|
}
|
|
1821
1883
|
if (wf.version === 2) {
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1884
|
+
// The ROW is the Open action (no Open button): click or Enter/Space on the
|
|
1885
|
+
// card loads it. openTemplate asks before discarding unsaved edits (MAJ-6)
|
|
1886
|
+
// and resolves null when refused — the canvas and the undo ring must then
|
|
1887
|
+
// be left exactly as they were. On success the page scrolls to its top so
|
|
1888
|
+
// the Composer title and the loaded canvas are both in view.
|
|
1889
|
+
row.classList.add('pl-openable');
|
|
1890
|
+
row.tabIndex = 0;
|
|
1891
|
+
row.setAttribute('role', 'button');
|
|
1892
|
+
row.title = `Open "${wf.name || wf.id}"`;
|
|
1893
|
+
const open = async () => {
|
|
1825
1894
|
const full = await gvApi.readWorkflow(wf.id);
|
|
1826
1895
|
if (!full) return;
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1896
|
+
if (await gvComposer.openTemplate(full)) { gvComposer.fit(); gvScrollToTop(); }
|
|
1897
|
+
};
|
|
1898
|
+
row.addEventListener('click', (e) => {
|
|
1899
|
+
if (e.target && e.target.closest && e.target.closest('button, a, input')) return; // the row's own actions
|
|
1900
|
+
open();
|
|
1830
1901
|
});
|
|
1831
|
-
row.
|
|
1832
|
-
|
|
1833
|
-
|
|
1902
|
+
row.addEventListener('keydown', (e) => {
|
|
1903
|
+
if (e.target !== row) return;
|
|
1904
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); }
|
|
1905
|
+
});
|
|
1906
|
+
// ONE Export… entry point (JSON file / Claude Code skill / Worca plugin): the
|
|
1907
|
+
// dialog asks for the format. Available for every v2 row incl. the built-in.
|
|
1908
|
+
const exportBtn = document.createElement('button');
|
|
1909
|
+
exportBtn.type = 'button'; exportBtn.className = 'btn-ghost pl-export';
|
|
1910
|
+
exportBtn.title = 'Export as a JSON file, a Claude Code skill or a Worca plugin';
|
|
1911
|
+
exportBtn.textContent = 'Export…';
|
|
1912
|
+
exportBtn.addEventListener('click', () => openExportModal({ id: wf.id, name: wf.name || wf.id }));
|
|
1913
|
+
// Appended AFTER delete (below): Export… is the last element of every row, so
|
|
1914
|
+
// it sits on the same right edge whether or not the row has a delete.
|
|
1915
|
+
// No delete on the built-in: DELETE /api/workflows/wf_default always answers
|
|
1916
|
+
// 400 (ui/server.mjs), so the button could only ever fail. Opening stays —
|
|
1834
1917
|
// the built-in is meant to be opened and saved as a copy.
|
|
1835
1918
|
if (wf.id !== RESERVED_WORKFLOW_ID) {
|
|
1836
1919
|
const del = document.createElement('button');
|
|
1837
|
-
del.type = 'button'; del.className = 'pl-del';
|
|
1920
|
+
del.type = 'button'; del.className = 'pl-del';
|
|
1921
|
+
del.title = `Delete "${wf.name || wf.id}"`;
|
|
1922
|
+
del.setAttribute('aria-label', `Delete "${wf.name || wf.id}"`);
|
|
1923
|
+
del.innerHTML = TRASH_SVG; // the one bin icon (static markup)
|
|
1838
1924
|
// A delete is destructive and unrecoverable: it asks first, in red — the
|
|
1839
1925
|
// guard the v1 composer's saved list owned before it was retired.
|
|
1840
1926
|
del.addEventListener('click', async () => {
|
|
@@ -1850,6 +1936,7 @@ async function gvRefreshSaved() {
|
|
|
1850
1936
|
});
|
|
1851
1937
|
row.appendChild(del);
|
|
1852
1938
|
}
|
|
1939
|
+
row.appendChild(exportBtn);
|
|
1853
1940
|
} else {
|
|
1854
1941
|
const tag = document.createElement('span');
|
|
1855
1942
|
tag.className = 'pl-legacy';
|
|
@@ -1859,7 +1946,6 @@ async function gvRefreshSaved() {
|
|
|
1859
1946
|
item.appendChild(row);
|
|
1860
1947
|
els.savedList.appendChild(item);
|
|
1861
1948
|
}
|
|
1862
|
-
await gvRefreshArchived();
|
|
1863
1949
|
}
|
|
1864
1950
|
|
|
1865
1951
|
// The Archived footer only exists once V24 (P8) archives rows: it is rendered
|
|
@@ -2404,7 +2490,9 @@ function renderModelEffortPair(modelSel, effortSel, caption, sel = {}) {
|
|
|
2404
2490
|
};
|
|
2405
2491
|
optgroup('Your models', state.models.filter((m) => m.custom && m.custom !== 'plugin').sort(byLabel));
|
|
2406
2492
|
optgroup('Plugins', state.models.filter((m) => m.custom === 'plugin').sort(byLabel));
|
|
2407
|
-
|
|
2493
|
+
// "Hide built-in models" (#422): a hidden built-in leaves the list — unless
|
|
2494
|
+
// it is THIS selection, which still resolves and must stay visible.
|
|
2495
|
+
optgroup('Built-in', state.models.filter((m) => !m.custom && (!m.hidden || m.id === sel.model)).sort(byLabel));
|
|
2408
2496
|
modelSel.appendChild(option('__add__', '+ Add model…'));
|
|
2409
2497
|
modelSel.value = sel.model || '';
|
|
2410
2498
|
|
|
@@ -7006,9 +7094,11 @@ function basenameOf(p) {
|
|
|
7006
7094
|
}
|
|
7007
7095
|
|
|
7008
7096
|
// Thin wrapper over the native picker endpoint; never throws.
|
|
7009
|
-
async function pickFolder() {
|
|
7097
|
+
async function pickFolder(purpose = 'project') {
|
|
7010
7098
|
try {
|
|
7011
|
-
const res = await fetch('/api/fs/pick-folder', {
|
|
7099
|
+
const res = await fetch('/api/fs/pick-folder', {
|
|
7100
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose }),
|
|
7101
|
+
});
|
|
7012
7102
|
return await safeJson(res); // {status:'picked',path} | {status:'canceled'} | {status:'unsupported'} | {status:'busy'}
|
|
7013
7103
|
} catch {
|
|
7014
7104
|
return { status: 'unsupported' };
|
|
@@ -7702,6 +7792,7 @@ async function loadSettings() {
|
|
|
7702
7792
|
paintBudgetSettings(data);
|
|
7703
7793
|
paintAskSettings(data);
|
|
7704
7794
|
paintDebugSpawnSettings(data);
|
|
7795
|
+
await paintTitleModelSettings(data);
|
|
7705
7796
|
paintBudgetReadout();
|
|
7706
7797
|
refreshBudget();
|
|
7707
7798
|
paintChatSettings(data.chat);
|
|
@@ -8145,6 +8236,105 @@ function saveDebugSpawn() {
|
|
|
8145
8236
|
document.getElementById('debugSpawnSave')?.addEventListener('click', saveDebugSpawn);
|
|
8146
8237
|
document.getElementById('debugSpawnReset')?.addEventListener('click', () => postDebugSpawn({ debugSpawnEnabled: false }));
|
|
8147
8238
|
|
|
8239
|
+
// ---- Title generation card (#422) ----
|
|
8240
|
+
// A SELECT over the project-less catalog, never a text field: free text could
|
|
8241
|
+
// store an id resolveModelEnv cannot route, and the failure would only show up
|
|
8242
|
+
// as a missing title minutes into a run. Same optgroups + order as the New
|
|
8243
|
+
// Pipeline picker. `titleModelCatalog` is fetched on every Settings paint so the
|
|
8244
|
+
// options track catalog edits without a reload.
|
|
8245
|
+
const TITLE_MODEL_DEFAULT_LABEL = "Same as the run's model";
|
|
8246
|
+
let titleModelCatalog = [];
|
|
8247
|
+
function setTitleModelMsg(text, kind) { setHintMsg('titleModelMsg', text, kind); }
|
|
8248
|
+
async function fetchTitleModelCatalog() {
|
|
8249
|
+
try {
|
|
8250
|
+
const res = await fetch('/api/config');
|
|
8251
|
+
const data = await safeJson(res);
|
|
8252
|
+
return res.ok && Array.isArray(data.models) ? data.models : [];
|
|
8253
|
+
} catch { return []; }
|
|
8254
|
+
}
|
|
8255
|
+
function buildTitleModelOptions(sel, stored) {
|
|
8256
|
+
sel.innerHTML = '';
|
|
8257
|
+
sel.appendChild(option('', TITLE_MODEL_DEFAULT_LABEL));
|
|
8258
|
+
const byLabel = (a, b) => (a.label || a.id).localeCompare(b.label || b.id, undefined, { sensitivity: 'base' });
|
|
8259
|
+
// Legacy per-project entries are not global — titles are; hidden built-ins
|
|
8260
|
+
// stay out unless one IS the stored pick (it still resolves).
|
|
8261
|
+
const models = titleModelCatalog.filter((m) => m && m.custom !== 'project' && (!m.hidden || m.id === stored));
|
|
8262
|
+
const group = (label, xs) => {
|
|
8263
|
+
if (!xs.length) return;
|
|
8264
|
+
const og = document.createElement('optgroup');
|
|
8265
|
+
og.label = label;
|
|
8266
|
+
for (const m of xs) og.appendChild(option(m.id, (m.label || m.id) + (m.custom === 'plugin' && m.plugin ? ` (${m.plugin})` : '')));
|
|
8267
|
+
sel.appendChild(og);
|
|
8268
|
+
};
|
|
8269
|
+
group('Your models', models.filter((m) => m.custom && m.custom !== 'plugin').sort(byLabel));
|
|
8270
|
+
group('From plugins', models.filter((m) => m.custom === 'plugin').sort(byLabel));
|
|
8271
|
+
group('Built-in', models.filter((m) => !m.custom).sort(byLabel));
|
|
8272
|
+
if (stored && !models.some((m) => m.id === stored)) {
|
|
8273
|
+
// Loud degrade: the stored id left the catalog (plugin removed, entry deleted).
|
|
8274
|
+
const o = option(stored, `${stored} — not installed`);
|
|
8275
|
+
o.disabled = true;
|
|
8276
|
+
sel.appendChild(o);
|
|
8277
|
+
}
|
|
8278
|
+
sel.value = stored;
|
|
8279
|
+
}
|
|
8280
|
+
async function paintTitleModelSettings(data) {
|
|
8281
|
+
const sel = document.getElementById('titleModel');
|
|
8282
|
+
if (!sel) return;
|
|
8283
|
+
titleModelCatalog = await fetchTitleModelCatalog();
|
|
8284
|
+
const stored = typeof data.titleModel === 'string' ? data.titleModel : '';
|
|
8285
|
+
buildTitleModelOptions(sel, stored);
|
|
8286
|
+
const eff = data.titleModelEffective || {};
|
|
8287
|
+
let note = '', kind = '';
|
|
8288
|
+
if (eff.source === 'env') {
|
|
8289
|
+
note = `WORCA_TITLE_MODEL is set in the environment: titles use ${eff.model} regardless of this setting.`; kind = 'warn';
|
|
8290
|
+
} else if (eff.stale) {
|
|
8291
|
+
note = `Model "${eff.stale}" is no longer in the catalog — titles fall back to the run's model.`; kind = 'warn';
|
|
8292
|
+
}
|
|
8293
|
+
setHintMsg('titleModelEnvNote', note, kind);
|
|
8294
|
+
const testBtn = document.getElementById('titleModelTest');
|
|
8295
|
+
if (testBtn) testBtn.disabled = !sel.value || sel.options[sel.selectedIndex]?.disabled;
|
|
8296
|
+
}
|
|
8297
|
+
function postTitleModel(body) {
|
|
8298
|
+
return postSettingsCard(body, {
|
|
8299
|
+
setMsg: setTitleModelMsg, paint: paintTitleModelSettings,
|
|
8300
|
+
savedText: 'Saved. Applies to the next title — no restart needed.',
|
|
8301
|
+
});
|
|
8302
|
+
}
|
|
8303
|
+
document.getElementById('titleModelSave')?.addEventListener('click', () => {
|
|
8304
|
+
const sel = document.getElementById('titleModel');
|
|
8305
|
+
const opt = sel.options[sel.selectedIndex];
|
|
8306
|
+
if (opt && opt.disabled) { setTitleModelMsg('that model is no longer installed — pick another or use the default', 'err'); return; }
|
|
8307
|
+
postTitleModel({ titleModel: sel.value || '' });
|
|
8308
|
+
});
|
|
8309
|
+
document.getElementById('titleModelReset')?.addEventListener('click', () => postTitleModel({ titleModel: '' }));
|
|
8310
|
+
document.getElementById('titleModel')?.addEventListener('change', () => {
|
|
8311
|
+
const sel = document.getElementById('titleModel');
|
|
8312
|
+
const testBtn = document.getElementById('titleModelTest');
|
|
8313
|
+
if (testBtn) testBtn.disabled = !sel.value || sel.options[sel.selectedIndex]?.disabled;
|
|
8314
|
+
});
|
|
8315
|
+
// Test = the Models-view Test button verbatim (POST /api/models/:id/test): one
|
|
8316
|
+
// tiny spawn through the id's catalog routing. Without it the first evidence of
|
|
8317
|
+
// a bad pick is a missing title three minutes into a run.
|
|
8318
|
+
document.getElementById('titleModelTest')?.addEventListener('click', async () => {
|
|
8319
|
+
const sel = document.getElementById('titleModel');
|
|
8320
|
+
const btn = document.getElementById('titleModelTest');
|
|
8321
|
+
const id = sel.value;
|
|
8322
|
+
if (!id) return;
|
|
8323
|
+
btn.disabled = true;
|
|
8324
|
+
setTitleModelMsg(`Testing ${id}…`);
|
|
8325
|
+
try {
|
|
8326
|
+
const res = await fetch(`/api/models/${encodeURIComponent(id)}/test`, { method: 'POST' });
|
|
8327
|
+
const data = await safeJson(res);
|
|
8328
|
+
if (!res.ok) setTitleModelMsg(`✗ ${data.error || `HTTP ${res.status}`}`, 'err');
|
|
8329
|
+
else if (data.ok) setTitleModelMsg(`✓ ${id} replied: ${data.text}`);
|
|
8330
|
+
else setTitleModelMsg(`✗ ${data.hint || data.message}`, 'err');
|
|
8331
|
+
} catch (e) {
|
|
8332
|
+
setTitleModelMsg(`✗ ${e.message}`, 'err');
|
|
8333
|
+
} finally {
|
|
8334
|
+
btn.disabled = false;
|
|
8335
|
+
}
|
|
8336
|
+
});
|
|
8337
|
+
|
|
8148
8338
|
// Browse… for the projects root: native OS dialog, in-app modal fallback —
|
|
8149
8339
|
// the same two endpoints the add-project Browse button uses (app.js:3793).
|
|
8150
8340
|
if (el.settingsProjectsRootBrowse) {
|
|
@@ -8267,8 +8457,17 @@ async function addMarketplaceFromInput() {
|
|
|
8267
8457
|
if (!ok) return setPluginsMsg(data.error || 'add failed', 'err');
|
|
8268
8458
|
el.marketplaceUrl.value = '';
|
|
8269
8459
|
el.marketplaceAddRow.classList.add('hidden');
|
|
8270
|
-
|
|
8271
|
-
|
|
8460
|
+
// Reload FIRST: loadPluginsView() clears the message line as it starts, so a
|
|
8461
|
+
// message set before it never survived.
|
|
8462
|
+
await loadPluginsView();
|
|
8463
|
+
if (data.linked) {
|
|
8464
|
+
// The path was a single plugin folder (an Export… → Worca plugin result):
|
|
8465
|
+
// the server linked it instead of registering a marketplace.
|
|
8466
|
+
const n = (data.plugin.workflows?.imported || []).length;
|
|
8467
|
+
setPluginsMsg(`Linked plugin "${data.plugin.name}" from ${data.plugin.dir} — ${n} pipeline template${n === 1 ? '' : 's'} added to your saved pipelines.`, 'ok');
|
|
8468
|
+
} else {
|
|
8469
|
+
setPluginsMsg(`Added ${data.marketplace.name} (${data.marketplace.plugins.length} plugins).`, 'ok');
|
|
8470
|
+
}
|
|
8272
8471
|
}
|
|
8273
8472
|
|
|
8274
8473
|
function openInstallConsent(entry) {
|
|
@@ -8792,11 +8991,34 @@ function renderModelsViewBody() {
|
|
|
8792
8991
|
plugins: d.plugin || [],
|
|
8793
8992
|
predefined: d.predefined || [],
|
|
8794
8993
|
efforts: d.efforts || [],
|
|
8994
|
+
hideBuiltin: !!d.hideBuiltinModels,
|
|
8795
8995
|
projectName: pp ? pp.split('/').pop() : '',
|
|
8796
8996
|
}));
|
|
8797
8997
|
el.modelsList.replaceChildren(frag);
|
|
8798
8998
|
}
|
|
8799
8999
|
|
|
9000
|
+
// "Hide built-in models" (#422): one settings key, saved on change. Every picker
|
|
9001
|
+
// reads the flag off /api/config, so the refresh below repaints them all.
|
|
9002
|
+
async function saveHideBuiltinModels(cb) {
|
|
9003
|
+
const wanted = cb.checked;
|
|
9004
|
+
cb.disabled = true;
|
|
9005
|
+
try {
|
|
9006
|
+
const res = await fetch('/api/settings', {
|
|
9007
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
9008
|
+
body: JSON.stringify({ hideBuiltinModels: wanted }),
|
|
9009
|
+
});
|
|
9010
|
+
const data = await safeJson(res);
|
|
9011
|
+
if (!res.ok) { cb.checked = !wanted; return setModelsMsg(data.error || `HTTP ${res.status}`, 'err'); }
|
|
9012
|
+
setModelsMsg(wanted ? 'Built-in models hidden from every picker. Runs that already use one keep working.' : 'Built-in models shown again.');
|
|
9013
|
+
await refreshModelsEverywhere();
|
|
9014
|
+
} catch (e) {
|
|
9015
|
+
cb.checked = !wanted;
|
|
9016
|
+
setModelsMsg(e.message, 'err');
|
|
9017
|
+
} finally {
|
|
9018
|
+
cb.disabled = false;
|
|
9019
|
+
}
|
|
9020
|
+
}
|
|
9021
|
+
|
|
8800
9022
|
// "Edit a copy" prefill (design §9.6): create-mode editor seeded from a plugin
|
|
8801
9023
|
// model — id/label/efforts/pricing plus its literal/${VAR} env values; each
|
|
8802
9024
|
// {secret} key becomes an EMPTY row the user must fill (the plugin's secret is
|
|
@@ -9100,6 +9322,10 @@ async function testModelFlow(btn) {
|
|
|
9100
9322
|
}
|
|
9101
9323
|
|
|
9102
9324
|
if (el.modelsList) {
|
|
9325
|
+
el.modelsList.addEventListener('change', (ev) => {
|
|
9326
|
+
const t = ev.target;
|
|
9327
|
+
if (t && t.classList && t.classList.contains('mv-hide-builtin')) saveHideBuiltinModels(t);
|
|
9328
|
+
});
|
|
9103
9329
|
el.modelsList.addEventListener('click', (ev) => {
|
|
9104
9330
|
const t = ev.target.closest('button');
|
|
9105
9331
|
if (!t) return;
|
|
@@ -16398,3 +16624,408 @@ askPanel = createAskPanel({
|
|
|
16398
16624
|
now: () => Date.now(),
|
|
16399
16625
|
});
|
|
16400
16626
|
document.body.appendChild(askPanel.root);
|
|
16627
|
+
|
|
16628
|
+
// ---------------------------------------------------------------------------
|
|
16629
|
+
// Export to Claude Code — modal wiring. Turns a saved v2 workflow into a runnable
|
|
16630
|
+
// skill via POST /api/workflows/:id/export (dry-run = Plan, apply = Apply). The
|
|
16631
|
+
// export button is added to each v2 row in the graph-view saved list (gvRenderSaved).
|
|
16632
|
+
// (Re-homed from the retired v1 composer after the Node-graph v2 rebase.)
|
|
16633
|
+
// ---------------------------------------------------------------------------
|
|
16634
|
+
|
|
16635
|
+
async function exportCall(id, opts) {
|
|
16636
|
+
const res = await fetch(`/api/workflows/${encodeURIComponent(id)}/export`, {
|
|
16637
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(opts),
|
|
16638
|
+
});
|
|
16639
|
+
const data = await safeJson(res);
|
|
16640
|
+
if (!res.ok) throw new Error(data.error || `export failed (${res.status})`);
|
|
16641
|
+
return data;
|
|
16642
|
+
}
|
|
16643
|
+
async function exportPlan(id, opts) { return exportCall(id, { ...opts, dryRun: true }); }
|
|
16644
|
+
async function exportApply(id, opts) { return exportCall(id, opts); }
|
|
16645
|
+
|
|
16646
|
+
// format: 'json' (download the saved graph) | 'skill' (Claude Code skill; destination
|
|
16647
|
+
// global|project) | 'plugin' (Worca plugin folder). The dialog asks for the format
|
|
16648
|
+
// first — one Export… per row instead of one button per format.
|
|
16649
|
+
// `planned`: a Preview (dry run) for the CURRENT inputs is on screen. Export runs
|
|
16650
|
+
// its own dry run when there is none, so the primary button is never dead.
|
|
16651
|
+
const exportModalState = { item: null, format: 'json', destination: 'global', conflicts: [], planned: false };
|
|
16652
|
+
|
|
16653
|
+
function openExportModal(item) {
|
|
16654
|
+
const modal = document.getElementById('export-modal');
|
|
16655
|
+
if (!modal) return;
|
|
16656
|
+
exportModalState.item = item;
|
|
16657
|
+
exportModalState.format = 'json';
|
|
16658
|
+
exportModalState.destination = 'global';
|
|
16659
|
+
exportModalState.conflicts = [];
|
|
16660
|
+
exportModalState.planned = false;
|
|
16661
|
+
document.getElementById('export-slug').value = '';
|
|
16662
|
+
document.getElementById('export-folder').value = '';
|
|
16663
|
+
document.getElementById('export-plugin-name').value = '';
|
|
16664
|
+
document.getElementById('export-keep-version').checked = false;
|
|
16665
|
+
document.getElementById('export-include-agents').checked = true;
|
|
16666
|
+
document.getElementById('export-msg').textContent = '';
|
|
16667
|
+
const planEl = document.getElementById('export-plan');
|
|
16668
|
+
planEl.textContent = ''; planEl.classList.add('hidden');
|
|
16669
|
+
exportSetDone(false);
|
|
16670
|
+
exportSyncFormat();
|
|
16671
|
+
exportSyncSlugPreview();
|
|
16672
|
+
modal.classList.remove('hidden');
|
|
16673
|
+
}
|
|
16674
|
+
function closeExportModal() {
|
|
16675
|
+
const modal = document.getElementById('export-modal');
|
|
16676
|
+
if (modal) modal.classList.add('hidden');
|
|
16677
|
+
exportSetDone(false);
|
|
16678
|
+
}
|
|
16679
|
+
/** Swap the form for the result view (or back). The dialog stays open so the
|
|
16680
|
+
* user reads where the export went and what to do next, then clicks Done. */
|
|
16681
|
+
function exportSetDone(done) {
|
|
16682
|
+
const g = (id) => document.getElementById(id);
|
|
16683
|
+
g('export-form').classList.toggle('hidden', done);
|
|
16684
|
+
g('export-done').classList.toggle('hidden', !done);
|
|
16685
|
+
for (const id of ['export-cancel', 'export-plan-btn', 'export-apply-btn']) g(id).classList.toggle('hidden', done);
|
|
16686
|
+
if (!done && exportModalState.item) exportSyncFormat(); // Preview visibility is format-driven
|
|
16687
|
+
g('export-done-close').classList.toggle('hidden', !done);
|
|
16688
|
+
if (done) g('export-done-close').focus();
|
|
16689
|
+
}
|
|
16690
|
+
/** @param {{title:string, lines:Array<[string, string|Node]>, next?:string|Node}} r */
|
|
16691
|
+
function exportShowDone(r) {
|
|
16692
|
+
const g = (id) => document.getElementById(id);
|
|
16693
|
+
g('export-subtitle').textContent = '';
|
|
16694
|
+
g('export-done-title').textContent = r.title;
|
|
16695
|
+
const dl = g('export-done-lines');
|
|
16696
|
+
dl.replaceChildren();
|
|
16697
|
+
for (const [k, v] of r.lines) {
|
|
16698
|
+
const dt = document.createElement('dt'); dt.textContent = k;
|
|
16699
|
+
const dd = document.createElement('dd');
|
|
16700
|
+
if (typeof v === 'string') dd.textContent = v; else dd.appendChild(v);
|
|
16701
|
+
dl.append(dt, dd);
|
|
16702
|
+
}
|
|
16703
|
+
const next = g('export-done-next');
|
|
16704
|
+
next.replaceChildren();
|
|
16705
|
+
if (typeof r.next === 'string') next.textContent = r.next; else if (r.next) next.appendChild(r.next);
|
|
16706
|
+
next.classList.toggle('hidden', !r.next);
|
|
16707
|
+
exportSetDone(true);
|
|
16708
|
+
}
|
|
16709
|
+
/** "Then run <code>x</code>": a text + code fragment for the next-step line. */
|
|
16710
|
+
function exportNextStep(before, code, after = '') {
|
|
16711
|
+
const frag = document.createDocumentFragment();
|
|
16712
|
+
frag.appendChild(document.createTextNode(before));
|
|
16713
|
+
const c = document.createElement('code'); c.textContent = code;
|
|
16714
|
+
frag.appendChild(c);
|
|
16715
|
+
if (after) frag.appendChild(document.createTextNode(after));
|
|
16716
|
+
return frag;
|
|
16717
|
+
}
|
|
16718
|
+
/** Mirrors src/core/workflow-share.mjs workflowFileSlug (the download's filename). */
|
|
16719
|
+
function exportJsonFilename(id) {
|
|
16720
|
+
const stem = String(id || '').replace(/^wf_/, '').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
16721
|
+
return `${stem || 'workflow'}.json`;
|
|
16722
|
+
}
|
|
16723
|
+
function exportSyncFormat() {
|
|
16724
|
+
const { format, destination } = exportModalState;
|
|
16725
|
+
const json = format === 'json', skill = format === 'skill', plugin = format === 'plugin';
|
|
16726
|
+
const show = (id, on) => document.getElementById(id).classList.toggle('hidden', !on);
|
|
16727
|
+
for (const b of document.querySelectorAll('#export-format .seg-btn')) b.classList.toggle('on', b.dataset.format === format);
|
|
16728
|
+
for (const b of document.querySelectorAll('#export-dest .seg-btn')) b.classList.toggle('on', b.dataset.dest === destination);
|
|
16729
|
+
show('export-dest-field', skill);
|
|
16730
|
+
show('export-folder-field', (skill && destination === 'project') || plugin);
|
|
16731
|
+
document.getElementById('export-folder-label').textContent = plugin ? 'Plugin folder' : 'Folder';
|
|
16732
|
+
document.getElementById('export-folder').placeholder = plugin ? '/path/to/my-plugin' : '/path/to/repo';
|
|
16733
|
+
show('export-plugin-field', plugin);
|
|
16734
|
+
if (plugin) exportSyncPluginNamePreview();
|
|
16735
|
+
show('export-slug-field', skill);
|
|
16736
|
+
show('export-agents-field', skill);
|
|
16737
|
+
// JSON downloads straight away. The other two formats offer an optional Preview
|
|
16738
|
+
// (dry run); Export runs that dry run itself and writes unless it finds a
|
|
16739
|
+
// conflict, which the user then resolves per file before exporting again.
|
|
16740
|
+
document.getElementById('export-plan-btn').classList.toggle('hidden', json);
|
|
16741
|
+
const apply = document.getElementById('export-apply-btn');
|
|
16742
|
+
apply.textContent = json ? 'Download' : 'Export';
|
|
16743
|
+
apply.disabled = false;
|
|
16744
|
+
const name = exportModalState.item ? exportModalState.item.name : '';
|
|
16745
|
+
document.getElementById('export-subtitle').textContent = json
|
|
16746
|
+
? `Download "${name}" as a JSON file another Worca user can import.`
|
|
16747
|
+
: plugin
|
|
16748
|
+
? `Export "${name}" as a Worca plugin folder to share with other Worca users.`
|
|
16749
|
+
: `Export "${name}" as a runnable /command skill.`;
|
|
16750
|
+
document.getElementById('export-format-hint').textContent = json
|
|
16751
|
+
? 'The saved graph only — no agents or skills travel with it. The recipient uses Import… in their saved list.'
|
|
16752
|
+
: plugin
|
|
16753
|
+
? 'Bundles the pipeline, your own agents it uses and the skills they need. Built-in agents are not copied. The recipient runs: worca plugin link <folder>'
|
|
16754
|
+
: 'Writes a SKILL.md plus the agents it dispatches under .claude/, so the pipeline runs inside Claude Code without Worca.';
|
|
16755
|
+
}
|
|
16756
|
+
/** Plugin names are kebab-case (worca-cc-plugin.json `name`). Derive one from
|
|
16757
|
+
* what the user typed, else from the folder's basename, and say so under the
|
|
16758
|
+
* field — the same look-before-you-export the skill slug has. */
|
|
16759
|
+
function exportPluginName() {
|
|
16760
|
+
const typed = document.getElementById('export-plugin-name').value.trim();
|
|
16761
|
+
const folder = document.getElementById('export-folder').value.trim().replace(/[\\/]+$/, '');
|
|
16762
|
+
const raw = typed || folder.split(/[\\/]/).pop() || '';
|
|
16763
|
+
const name = raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
|
|
16764
|
+
return { raw, name, fromFolder: !typed };
|
|
16765
|
+
}
|
|
16766
|
+
function exportSyncPluginNamePreview() {
|
|
16767
|
+
const { raw, name, fromFolder } = exportPluginName();
|
|
16768
|
+
const el = document.getElementById('export-plugin-name-preview');
|
|
16769
|
+
if (!raw) { el.textContent = 'Plugin name: (choose a folder first)'; return; }
|
|
16770
|
+
el.textContent = name
|
|
16771
|
+
? `Plugin name: ${name}${fromFolder ? ' (from the folder name)' : ''}`
|
|
16772
|
+
: 'Plugin name: needs at least one letter or digit';
|
|
16773
|
+
}
|
|
16774
|
+
function exportSyncSlugPreview() {
|
|
16775
|
+
const raw = document.getElementById('export-slug').value;
|
|
16776
|
+
const { preview, valid } = exportSlugPreview(raw, exportModalState.item ? exportModalState.item.name : '');
|
|
16777
|
+
const el = document.getElementById('export-slug-preview');
|
|
16778
|
+
el.textContent = valid ? `Command: ${preview}` : 'Invalid slug (letters, digits, . _ - only)';
|
|
16779
|
+
}
|
|
16780
|
+
function exportBuildOpts() {
|
|
16781
|
+
const dest = exportModalState.destination;
|
|
16782
|
+
if (exportModalState.format === 'plugin') {
|
|
16783
|
+
const opts = {
|
|
16784
|
+
destination: 'plugin',
|
|
16785
|
+
pluginDir: document.getElementById('export-folder').value.trim(),
|
|
16786
|
+
keepVersion: document.getElementById('export-keep-version').checked,
|
|
16787
|
+
};
|
|
16788
|
+
// Always the NORMALIZED name (typed, else the folder's basename): the server
|
|
16789
|
+
// refuses anything but kebab-case, and "Full-Special" is a typo, not a choice.
|
|
16790
|
+
const { name } = exportPluginName();
|
|
16791
|
+
if (name) opts.pluginName = name;
|
|
16792
|
+
return opts;
|
|
16793
|
+
}
|
|
16794
|
+
const rawSlug = document.getElementById('export-slug').value.trim();
|
|
16795
|
+
const opts = {
|
|
16796
|
+
destination: dest,
|
|
16797
|
+
includeAgents: document.getElementById('export-include-agents').checked,
|
|
16798
|
+
};
|
|
16799
|
+
if (rawSlug) opts.slug = rawSlug;
|
|
16800
|
+
if (dest === 'project') opts.projectDir = document.getElementById('export-folder').value.trim();
|
|
16801
|
+
return opts;
|
|
16802
|
+
}
|
|
16803
|
+
function exportRenderPlan(plan) {
|
|
16804
|
+
const planEl = document.getElementById('export-plan');
|
|
16805
|
+
planEl.textContent = '';
|
|
16806
|
+
planEl.classList.remove('hidden');
|
|
16807
|
+
const line = (label, path) => {
|
|
16808
|
+
const row = document.createElement('div');
|
|
16809
|
+
row.className = 'export-row';
|
|
16810
|
+
row.innerHTML = `<span class="export-tag export-${label}">${label}</span> <code>${escapeHtml(path)}</code>`;
|
|
16811
|
+
planEl.appendChild(row);
|
|
16812
|
+
};
|
|
16813
|
+
for (const p of plan.created) line('create', p);
|
|
16814
|
+
for (const p of plan.updated) line('update', p);
|
|
16815
|
+
for (const p of plan.noop) line('no-op', p);
|
|
16816
|
+
// A plugin plan's `skipped` entries are {path, reason} (a Worca-shipped skill
|
|
16817
|
+
// that is not bundled); the skill export's are plain paths.
|
|
16818
|
+
for (const s of plan.skipped || []) line('skip', typeof s === 'string' ? s : `${s.path} — ${s.reason}`);
|
|
16819
|
+
for (const w of plan.warnings || []) {
|
|
16820
|
+
const row = document.createElement('div');
|
|
16821
|
+
row.className = 'export-row';
|
|
16822
|
+
row.innerHTML = `<span class="export-tag export-warn">warn</span> ${escapeHtml(w)}`;
|
|
16823
|
+
planEl.appendChild(row);
|
|
16824
|
+
}
|
|
16825
|
+
for (const o of plan.orphans || []) line('orphan', o);
|
|
16826
|
+
exportModalState.conflicts = plan.conflicts || [];
|
|
16827
|
+
exportModalState.planned = true;
|
|
16828
|
+
for (const cf of exportModalState.conflicts) {
|
|
16829
|
+
const row = document.createElement('div');
|
|
16830
|
+
row.className = 'export-row export-conflict-row';
|
|
16831
|
+
row.innerHTML = `<span class="export-tag export-conflict">conflict</span> <code>${escapeHtml(cf.path)}</code> <span class="hint">${escapeHtml(cf.reason)}</span>`;
|
|
16832
|
+
const choices = document.createElement('div');
|
|
16833
|
+
choices.className = 'export-choices';
|
|
16834
|
+
for (const opt of cf.options || []) {
|
|
16835
|
+
const label = document.createElement('label');
|
|
16836
|
+
label.className = 'export-choice';
|
|
16837
|
+
const radio = document.createElement('input');
|
|
16838
|
+
radio.type = 'radio'; radio.name = cf.path; radio.value = opt;
|
|
16839
|
+
radio.addEventListener('change', exportUpdateApplyEnabled);
|
|
16840
|
+
label.appendChild(radio);
|
|
16841
|
+
label.appendChild(document.createTextNode(' ' + opt));
|
|
16842
|
+
choices.appendChild(label);
|
|
16843
|
+
}
|
|
16844
|
+
row.appendChild(choices);
|
|
16845
|
+
planEl.appendChild(row);
|
|
16846
|
+
}
|
|
16847
|
+
exportUpdateApplyEnabled();
|
|
16848
|
+
}
|
|
16849
|
+
function exportGatherResolutions() {
|
|
16850
|
+
const resolutions = {};
|
|
16851
|
+
for (const cf of exportModalState.conflicts) {
|
|
16852
|
+
const picked = document.querySelector(`#export-plan input[name="${CSS.escape(cf.path)}"]:checked`);
|
|
16853
|
+
if (picked) resolutions[cf.path] = picked.value;
|
|
16854
|
+
}
|
|
16855
|
+
return resolutions;
|
|
16856
|
+
}
|
|
16857
|
+
function exportUpdateApplyEnabled() {
|
|
16858
|
+
const resolutions = exportGatherResolutions();
|
|
16859
|
+
const allResolved = exportModalState.conflicts.every((cf) => resolutions[cf.path]);
|
|
16860
|
+
document.getElementById('export-apply-btn').disabled = !allResolved;
|
|
16861
|
+
}
|
|
16862
|
+
// A plan is keyed to the slug/destination/include-agents/folder in effect when Plan ran.
|
|
16863
|
+
// If any of those change afterward, the shown plan (and its gathered resolutions) no longer
|
|
16864
|
+
// matches what Apply would do — drop it and re-disable Apply so the user must re-Plan.
|
|
16865
|
+
function exportInvalidatePlan() {
|
|
16866
|
+
exportModalState.conflicts = [];
|
|
16867
|
+
exportModalState.planned = false;
|
|
16868
|
+
const planEl = document.getElementById('export-plan');
|
|
16869
|
+
if (planEl) { planEl.textContent = ''; planEl.classList.add('hidden'); }
|
|
16870
|
+
// Export re-plans on its own, so a stale preview never leaves the button dead.
|
|
16871
|
+
const applyBtn = document.getElementById('export-apply-btn');
|
|
16872
|
+
if (applyBtn) applyBtn.disabled = false;
|
|
16873
|
+
const msg = document.getElementById('export-msg');
|
|
16874
|
+
if (msg) msg.textContent = '';
|
|
16875
|
+
}
|
|
16876
|
+
function bindExportModal() {
|
|
16877
|
+
const modal = document.getElementById('export-modal');
|
|
16878
|
+
if (!modal) return;
|
|
16879
|
+
for (const b of document.querySelectorAll('#export-format .seg-btn')) {
|
|
16880
|
+
b.addEventListener('click', () => { exportModalState.format = b.dataset.format; exportInvalidatePlan(); exportSyncFormat(); });
|
|
16881
|
+
}
|
|
16882
|
+
for (const b of document.querySelectorAll('#export-dest .seg-btn')) {
|
|
16883
|
+
b.addEventListener('click', () => { exportModalState.destination = b.dataset.dest; exportInvalidatePlan(); exportSyncFormat(); });
|
|
16884
|
+
}
|
|
16885
|
+
document.getElementById('export-slug').addEventListener('input', () => { exportSyncSlugPreview(); exportInvalidatePlan(); });
|
|
16886
|
+
document.getElementById('export-include-agents').addEventListener('change', exportInvalidatePlan);
|
|
16887
|
+
document.getElementById('export-folder').addEventListener('input', exportInvalidatePlan);
|
|
16888
|
+
document.getElementById('export-plugin-name').addEventListener('input', () => { exportSyncPluginNamePreview(); exportInvalidatePlan(); });
|
|
16889
|
+
document.getElementById('export-folder').addEventListener('input', () => { if (exportModalState.format === 'plugin') exportSyncPluginNamePreview(); });
|
|
16890
|
+
document.getElementById('export-keep-version').addEventListener('change', exportInvalidatePlan);
|
|
16891
|
+
// Browse…: the native OS folder dialog (the server opens it — a web page never
|
|
16892
|
+
// learns an absolute path from its own file picker), exactly like Add Project;
|
|
16893
|
+
// the in-app browser is the fallback when the native one is unsupported.
|
|
16894
|
+
document.getElementById('export-browse').addEventListener('click', async () => {
|
|
16895
|
+
const folder = document.getElementById('export-folder');
|
|
16896
|
+
const set = (p) => { folder.value = p; exportInvalidatePlan(); };
|
|
16897
|
+
const data = await pickFolder(exportModalState.format === 'plugin' ? 'plugin' : 'export');
|
|
16898
|
+
if (data && data.status === 'picked' && data.path) { set(data.path); return; }
|
|
16899
|
+
if (data && data.status === 'canceled') return;
|
|
16900
|
+
if (data && data.status === 'busy') { document.getElementById('export-msg').textContent = 'A folder dialog is already open — finish or cancel it first.'; return; }
|
|
16901
|
+
openFolderBrowser(folder.value.trim(), set);
|
|
16902
|
+
});
|
|
16903
|
+
document.getElementById('export-cancel').addEventListener('click', closeExportModal);
|
|
16904
|
+
document.getElementById('export-done-close').addEventListener('click', closeExportModal);
|
|
16905
|
+
// Preview: the optional dry run — shows what Export would write, writes nothing.
|
|
16906
|
+
document.getElementById('export-plan-btn').addEventListener('click', async () => {
|
|
16907
|
+
const msg = document.getElementById('export-msg');
|
|
16908
|
+
msg.textContent = 'Previewing…';
|
|
16909
|
+
try {
|
|
16910
|
+
const plan = await exportPlan(exportModalState.item.id, exportBuildOpts());
|
|
16911
|
+
exportRenderPlan(plan);
|
|
16912
|
+
msg.textContent = plan.conflicts.length ? 'Resolve each conflict below, then Export.' : 'Preview only — nothing is written until you click Export.';
|
|
16913
|
+
} catch (err) { msg.textContent = `Preview failed: ${err.message}`; }
|
|
16914
|
+
});
|
|
16915
|
+
// Export: writes. Without a preview for the current inputs it runs the dry run
|
|
16916
|
+
// itself first; a conflict (only the skill format can raise one) stops it and is
|
|
16917
|
+
// shown for per-file resolution — nothing is ever written past an unresolved one.
|
|
16918
|
+
document.getElementById('export-apply-btn').addEventListener('click', async () => {
|
|
16919
|
+
const msg = document.getElementById('export-msg');
|
|
16920
|
+
if (exportModalState.format === 'json') {
|
|
16921
|
+
// A plain download of the stored graph — the server sets Content-Disposition.
|
|
16922
|
+
const a = document.createElement('a');
|
|
16923
|
+
a.href = `/api/workflows/${encodeURIComponent(exportModalState.item.id)}/json`;
|
|
16924
|
+
a.setAttribute('download', '');
|
|
16925
|
+
document.body.appendChild(a);
|
|
16926
|
+
a.click();
|
|
16927
|
+
a.remove();
|
|
16928
|
+
exportShowDone({
|
|
16929
|
+
title: 'JSON file downloaded',
|
|
16930
|
+
lines: [['File', exportJsonFilename(exportModalState.item.id)], ['Pipeline', exportModalState.item.name]],
|
|
16931
|
+
next: 'Send the file to another Worca user — they pick it up with Import… in their saved pipelines. Agents and skills do not travel with it; share a plugin for those.',
|
|
16932
|
+
});
|
|
16933
|
+
return;
|
|
16934
|
+
}
|
|
16935
|
+
msg.textContent = 'Exporting…';
|
|
16936
|
+
try {
|
|
16937
|
+
if (!exportModalState.planned) {
|
|
16938
|
+
const plan = await exportPlan(exportModalState.item.id, exportBuildOpts());
|
|
16939
|
+
if ((plan.conflicts || []).length) {
|
|
16940
|
+
exportRenderPlan(plan);
|
|
16941
|
+
msg.textContent = 'Resolve each conflict below, then Export.';
|
|
16942
|
+
return;
|
|
16943
|
+
}
|
|
16944
|
+
}
|
|
16945
|
+
const opts = { ...exportBuildOpts(), resolutions: exportGatherResolutions() };
|
|
16946
|
+
const applied = await exportApply(exportModalState.item.id, opts);
|
|
16947
|
+
// A conflict Apply left UNWRITTEN (e.g. a TOCTOU conflict between Plan and Apply that
|
|
16948
|
+
// had no resolution and fell to the blanket skip) is a stale partial export — re-render
|
|
16949
|
+
// the fresh plan so the user can resolve it, and keep the modal open.
|
|
16950
|
+
const written = new Set(applied.written || []);
|
|
16951
|
+
const unwritten = (applied.conflicts || []).filter((c) => !written.has(c.path));
|
|
16952
|
+
if (unwritten.length) {
|
|
16953
|
+
exportRenderPlan(applied);
|
|
16954
|
+
appendLog({ source: 'ui', level: 'error', text: `export of ${exportModalState.item.name} incomplete: ${applied.written.length} written, ${unwritten.length} conflict(s) left unwritten` });
|
|
16955
|
+
msg.textContent = `${unwritten.length} unresolved conflict(s) were left unwritten — resolve below and Export again.`;
|
|
16956
|
+
return;
|
|
16957
|
+
}
|
|
16958
|
+
// A plugin folder that does not validate stays open: the recipient's
|
|
16959
|
+
// `worca plugin link` would refuse it, so the problems are the outcome.
|
|
16960
|
+
if (applied.validation && !applied.validation.ok) {
|
|
16961
|
+
exportRenderPlan(applied);
|
|
16962
|
+
const problems = applied.validation.problems.filter((p) => p.level === 'error').map((p) => p.message);
|
|
16963
|
+
appendLog({ source: 'ui', level: 'error', text: `plugin export of ${exportModalState.item.name} does not validate: ${problems.join('; ')}` });
|
|
16964
|
+
msg.textContent = `Written, but the plugin folder does not validate: ${problems.join('; ')}`;
|
|
16965
|
+
return;
|
|
16966
|
+
}
|
|
16967
|
+
appendLog({ source: 'ui', level: 'info', text: `exported ${exportModalState.item.name}: ${applied.written.length} written, ${applied.skipped.length} skipped` });
|
|
16968
|
+
const files = `${applied.written.length} written, ${(applied.noop || []).length} unchanged`;
|
|
16969
|
+
if (applied.validation) { // plugin
|
|
16970
|
+
exportShowDone({
|
|
16971
|
+
title: `Plugin "${applied.name}" v${applied.version} exported`,
|
|
16972
|
+
lines: [['Folder', applied.dir], ['Files', files]],
|
|
16973
|
+
next: exportNextStep('Share the folder. The recipient pastes its path into Plugins → Add marketplace, or runs ',
|
|
16974
|
+
`worca plugin link ${applied.dir}`, ' — once; after a re-export they run worca plugin reimport.'),
|
|
16975
|
+
});
|
|
16976
|
+
} else { // Claude Code skill
|
|
16977
|
+
const command = (document.getElementById('export-slug-preview').textContent.match(/\/[^\s]+/) || [''])[0];
|
|
16978
|
+
const where = exportModalState.destination === 'project'
|
|
16979
|
+
? document.getElementById('export-folder').value.trim() : '~/.claude';
|
|
16980
|
+
exportShowDone({
|
|
16981
|
+
title: 'Claude Code skill exported',
|
|
16982
|
+
lines: [['Skill', command || exportModalState.item.name], ['Location', where], ['Files', files]],
|
|
16983
|
+
next: exportNextStep(exportModalState.destination === 'project' ? 'Open the project in Claude Code and run ' : 'In Claude Code, run ',
|
|
16984
|
+
command || '/<skill>', ' — the pipeline runs there without Worca.'),
|
|
16985
|
+
});
|
|
16986
|
+
}
|
|
16987
|
+
} catch (err) {
|
|
16988
|
+
exportInvalidatePlan();
|
|
16989
|
+
msg.textContent = `Export failed: ${err.message}`;
|
|
16990
|
+
}
|
|
16991
|
+
});
|
|
16992
|
+
// Backdrop click (the overlay itself, not the inner card) closes the modal.
|
|
16993
|
+
modal.addEventListener('click', (e) => { if (e.target === modal) closeExportModal(); });
|
|
16994
|
+
}
|
|
16995
|
+
bindExportModal();
|
|
16996
|
+
|
|
16997
|
+
// Import… (#421) — the saved list's header button. Parses the file in the
|
|
16998
|
+
// browser (so a non-JSON file says so without a round trip) and hands the object
|
|
16999
|
+
// to POST /api/workflows/import-json; the outcome lands on the list's message
|
|
17000
|
+
// line, like a refused delete. On success the imported row's domain tab is
|
|
17001
|
+
// selected and the row carries a NEW pill until reload.
|
|
17002
|
+
async function gvImportWorkflowObject(obj) {
|
|
17003
|
+
const r = await gvApi.importWorkflow(obj);
|
|
17004
|
+
if (!r.ok) {
|
|
17005
|
+
const issues = (r.issues || []).slice(0, 5).map((i) => `${i.code}: ${i.message}`).join(' · ');
|
|
17006
|
+
setGvSavedMsg(r.summary || (issues ? `${r.error} — ${issues}` : r.error), 'err');
|
|
17007
|
+
return false;
|
|
17008
|
+
}
|
|
17009
|
+
gvSavedTab = gvDomainOf(r.workflow);
|
|
17010
|
+
gvNewIds.add(r.workflow.id);
|
|
17011
|
+
setGvSavedMsg(r.renamed
|
|
17012
|
+
? `Imported as "${r.workflow.name}" — "${r.requestedName}" was already taken.`
|
|
17013
|
+
: `Imported "${r.workflow.name}".`, 'ok');
|
|
17014
|
+
await gvRefreshSaved();
|
|
17015
|
+
return true;
|
|
17016
|
+
}
|
|
17017
|
+
function bindGvImport() {
|
|
17018
|
+
const btn = document.getElementById('gv-import-btn');
|
|
17019
|
+
const input = document.getElementById('gv-import-file');
|
|
17020
|
+
if (!btn || !input) return;
|
|
17021
|
+
btn.addEventListener('click', () => { input.value = ''; input.click(); });
|
|
17022
|
+
input.addEventListener('change', async () => {
|
|
17023
|
+
const f = input.files && input.files[0];
|
|
17024
|
+
if (!f) return;
|
|
17025
|
+
let obj;
|
|
17026
|
+
try { obj = JSON.parse(await f.text()); }
|
|
17027
|
+
catch (e) { setGvSavedMsg(`${f.name} is not valid JSON: ${e.message}`, 'err'); return; }
|
|
17028
|
+
await gvImportWorkflowObject(obj);
|
|
17029
|
+
});
|
|
17030
|
+
}
|
|
17031
|
+
bindGvImport();
|