feat: workflow, UI and document support updates

This commit is contained in:
willchen96 2026-07-08 18:27:28 +08:00
parent a5fe6d6e04
commit 82dcaefc43
139 changed files with 12554 additions and 2233 deletions

View file

@ -6,6 +6,7 @@ const path = require("path");
const ROOT_DIR = path.resolve(__dirname, "..");
const WORKSPACE_DIR = path.resolve(ROOT_DIR, "..");
const WORKFLOWS_DIR = path.join(WORKSPACE_DIR, "mike-workflows");
const SYSTEM_WORKFLOWS_DIR = path.join(WORKFLOWS_DIR, "system");
const BACKEND_OUT = path.join(ROOT_DIR, "backend/src/lib/systemWorkflows.ts");
const LANDING_OUT = path.join(ROOT_DIR, "landing/app/generated-workflows.ts");
@ -29,6 +30,216 @@ function fail(message) {
throw new Error(message);
}
function parseScalar(value, label) {
const trimmed = value.trim();
if (trimmed === "null") return null;
if (trimmed === "true") return true;
if (trimmed === "false") return false;
if (/^-?\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10);
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
try {
return JSON.parse(trimmed);
} catch (error) {
fail(`${label} is not valid inline JSON: ${error.message}`);
}
}
return trimmed;
}
function parseSimpleYaml(source, label) {
const lines = source.replace(/\r\n/g, "\n").split("\n");
const result = {};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.trim() || line.trim().startsWith("#")) continue;
if (line.startsWith(" ")) {
fail(`${label}:${i + 1} has unsupported indentation`);
}
const match = line.match(/^([A-Za-z_][A-Za-z0-9_-]*):(.*)$/);
if (!match) fail(`${label}:${i + 1} is not valid frontmatter`);
const key = match[1];
const rawValue = match[2].trim();
if (rawValue) {
result[key] = parseScalar(rawValue, `${label}.${key}`);
continue;
}
const scalarItems = [];
const objectItems = [];
let mode = null;
i++;
for (; i < lines.length; i++) {
const child = lines[i];
if (!child.trim()) continue;
if (!child.startsWith(" ")) {
i--;
break;
}
const listMatch = child.match(/^ -(?:\s+(.*))?$/);
if (listMatch) {
const itemText = listMatch[1]?.trim() ?? "";
if (itemText.includes(":")) {
mode ??= "objects";
if (mode !== "objects") {
fail(`${label}.${key} mixes scalar and object list items`);
}
const object = {};
if (itemText) {
const itemMatch = itemText.match(/^([A-Za-z_][A-Za-z0-9_-]*):(.*)$/);
if (!itemMatch) {
fail(`${label}:${i + 1} is not a valid object list item`);
}
object[itemMatch[1]] = parseScalar(
itemMatch[2],
`${label}.${key}.${itemMatch[1]}`,
);
}
objectItems.push(object);
continue;
}
mode ??= "scalars";
if (mode !== "scalars") {
fail(`${label}.${key} mixes object and scalar list items`);
}
scalarItems.push(parseScalar(itemText, `${label}.${key}`));
continue;
}
const propMatch = child.match(/^ ([A-Za-z_][A-Za-z0-9_-]*):(.*)$/);
if (!propMatch || mode !== "objects" || objectItems.length === 0) {
fail(`${label}:${i + 1} has unsupported frontmatter structure`);
}
objectItems[objectItems.length - 1][propMatch[1]] = parseScalar(
propMatch[2],
`${label}.${key}.${propMatch[1]}`,
);
}
result[key] = mode === "objects" ? objectItems : scalarItems;
}
return result;
}
function readSkillFile(filePath) {
const text = readText(filePath).replace(/\r\n/g, "\n");
if (!text.startsWith("---\n")) {
fail(`${relative(filePath)} must start with YAML frontmatter`);
}
const close = text.indexOf("\n---", 4);
if (close === -1) {
fail(`${relative(filePath)} is missing closing YAML frontmatter marker`);
}
const afterClose = text.slice(close + 4);
if (afterClose && !afterClose.startsWith("\n")) {
fail(`${relative(filePath)} has invalid frontmatter closing marker`);
}
return {
metadata: parseSimpleYaml(text.slice(4, close), relative(filePath)),
body: afterClose.replace(/^\n/, "").trimEnd(),
fullText: text.trimEnd(),
};
}
function parseTableConfigYaml(filePath) {
const lines = readText(filePath).replace(/\r\n/g, "\n").split("\n");
const result = { columns_config: [] };
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim() || line.trim().startsWith("#")) {
i++;
continue;
}
const schemaMatch = line.match(/^\$schema:\s*(.+)$/);
if (schemaMatch) {
result.$schema = parseScalar(schemaMatch[1], `${relative(filePath)}.$schema`);
i++;
continue;
}
if (line !== "columns_config:") {
fail(`${relative(filePath)}:${i + 1} is not valid table config YAML`);
}
i++;
break;
}
let current = null;
for (; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) continue;
const itemMatch = line.match(/^ - index:\s*(.+)$/);
if (itemMatch) {
current = {
index: parseScalar(itemMatch[1], `${relative(filePath)}.columns_config.index`),
};
result.columns_config.push(current);
continue;
}
if (!current) {
fail(`${relative(filePath)}:${i + 1} column entry must start with index`);
}
const propMatch = line.match(/^ ([A-Za-z_][A-Za-z0-9_-]*):(.*)$/);
if (!propMatch) {
fail(`${relative(filePath)}:${i + 1} is not a valid column property`);
}
const key = propMatch[1];
const rawValue = propMatch[2].trim();
if (key === "tags" && rawValue === "") {
const tags = [];
i++;
for (; i < lines.length; i++) {
const tagMatch = lines[i].match(/^ -\s*(.+)$/);
if (!tagMatch) {
i--;
break;
}
tags.push(parseScalar(tagMatch[1], `${relative(filePath)}.${key}`));
}
current.tags = tags;
continue;
}
if (rawValue === ">-" || rawValue === ">" || rawValue === "|-" || rawValue === "|") {
const parts = [];
i++;
for (; i < lines.length; i++) {
if (!lines[i].startsWith(" ")) {
i--;
break;
}
parts.push(lines[i].slice(6));
}
current[key] = rawValue.startsWith("|")
? parts.join("\n")
: parts.join(" ").replace(/\s+/g, " ").trim();
continue;
}
current[key] = parseScalar(rawValue, `${relative(filePath)}.${key}`);
}
return result;
}
function assertString(value, label) {
if (typeof value !== "string" || value.trim() === "") {
fail(`${label} must be a non-empty string`);
@ -85,28 +296,43 @@ function assertColumnConfig(columns, label) {
assertString(column.name, `${columnLabel}.name`);
assertString(column.prompt, `${columnLabel}.prompt`);
assertOptionalString(column.format, `${columnLabel}.format`);
assertOptionalStringArray(column.tags, `${columnLabel}.tags`);
});
}
function readWorkflow(category, dirent) {
const slug = dirent.name;
const workflowDir = path.join(WORKFLOWS_DIR, category, slug);
const workflowDir = path.join(SYSTEM_WORKFLOWS_DIR, category, slug);
const metadataPath = path.join(workflowDir, "metadata.json");
if (!fs.existsSync(metadataPath)) {
fail(`${relative(metadataPath)} is required`);
if (fs.existsSync(metadataPath)) {
fail(`${relative(metadataPath)} is no longer supported; use SKILL.md frontmatter`);
}
const metadata = readJson(metadataPath);
const label = relative(metadataPath);
const skillPath = path.join(workflowDir, "SKILL.md");
if (!fs.existsSync(skillPath)) {
fail(`${relative(skillPath)} is required`);
}
const { metadata, body: skillMd, fullText: sourceSkillMd } = readSkillFile(skillPath);
const label = `${relative(skillPath)} frontmatter`;
const id = `builtin-${slug}`;
if (metadata.id !== undefined) {
fail(`${label}.id is not supported; the ID is generated from the directory name`);
}
const expectedSchema = "../../schema/workflow-metadata.schema.json";
if (metadata.$schema !== expectedSchema) {
fail(`${label}.$schema must be "${expectedSchema}"`);
if (metadata.$schema !== undefined) {
fail(`${label}.$schema is not supported in SKILL.md frontmatter`);
}
assertString(metadata.title, `${label}.title`);
if (metadata.title !== undefined) {
fail(`${label}.title is not supported; use name`);
}
if (metadata.order !== undefined) {
fail(`${label}.order is not supported`);
}
assertString(metadata.name, `${label}.name`);
if (metadata.name !== slug) {
fail(`${label}.name must match the folder name "${slug}"`);
}
assertString(metadata.display_name, `${label}.display_name`);
assertString(metadata.description, `${label}.description`);
const contributors = normalizeContributors(
metadata.contributors,
`${label}.contributors`,
@ -124,63 +350,47 @@ function readWorkflow(category, dirent) {
}
assertOptionalString(metadata.practice, `${label}.practice`);
assertOptionalStringArray(metadata.jurisdictions, `${label}.jurisdictions`);
if (metadata.order !== undefined && !Number.isInteger(metadata.order)) {
fail(`${label}.order must be an integer`);
}
if (category === "assistant") {
if (metadata.prompt !== undefined) {
fail(`${label}.prompt is not supported; use SKILL.md`);
if (!skillMd.trim()) {
fail(`${relative(skillPath)} must include instructions after frontmatter`);
}
const promptPath = path.join(workflowDir, "SKILL.md");
if (!fs.existsSync(promptPath)) {
fail(`${relative(promptPath)} is required for assistant workflows`);
}
if (metadata.execution_prompt !== undefined) {
fail(`${label}.execution_prompt is not supported; use SKILL.md`);
}
const tableConfigPath = path.join(workflowDir, "table-config.json");
const tableConfigPath = path.join(workflowDir, "table-config.yaml");
if (fs.existsSync(tableConfigPath)) {
fail(`${relative(tableConfigPath)} is only supported for tabular workflows`);
}
const promptMd = readText(promptPath).trimEnd();
return {
id,
title: metadata.title,
type: metadata.type,
contributors,
language: metadata.language,
version: metadata.version,
practice: metadata.practice ?? null,
jurisdictions: metadata.jurisdictions ?? null,
order: metadata.order ?? 0,
prompt_md: promptMd,
metadata: {
title: metadata.display_name,
description: metadata.description,
type: metadata.type,
contributors,
language: metadata.language,
version: metadata.version,
practice: metadata.practice ?? null,
jurisdictions: metadata.jurisdictions ?? null,
},
skill_md: skillMd,
source_skill_md: sourceSkillMd,
columns_config: null,
};
}
let promptMd = null;
if (metadata.prompt !== undefined) {
fail(`${label}.prompt is not supported; use SKILL.md`);
}
const promptPath = path.join(workflowDir, "SKILL.md");
if (fs.existsSync(promptPath)) {
promptMd = readText(promptPath).trimEnd();
}
if (metadata.execution_prompt !== undefined) {
fail(`${label}.execution_prompt is not supported; use SKILL.md`);
}
if (metadata.columns_config !== undefined) {
fail(`${label}.columns_config is not supported; use table-config.json`);
fail(`${label}.columns_config is not supported; use table-config.yaml`);
}
const tableConfigPath = path.join(workflowDir, "table-config.json");
const legacyTableConfigPath = path.join(workflowDir, "table-config.json");
if (fs.existsSync(legacyTableConfigPath)) {
fail(`${relative(legacyTableConfigPath)} is no longer supported; use table-config.yaml`);
}
const tableConfigPath = path.join(workflowDir, "table-config.yaml");
if (!fs.existsSync(tableConfigPath)) {
fail(`${relative(tableConfigPath)} is required for tabular workflows`);
}
const tableConfig = readJson(tableConfigPath);
const tableConfig = parseTableConfigYaml(tableConfigPath);
const tableConfigLabel = relative(tableConfigPath);
const expectedTableConfigSchema = "../../schema/table-config.schema.json";
const expectedTableConfigSchema = "../../../schema/table-config.schema.yaml";
if (tableConfig.$schema !== expectedTableConfigSchema) {
fail(`${tableConfigLabel}.$schema must be "${expectedTableConfigSchema}"`);
}
@ -188,15 +398,18 @@ function readWorkflow(category, dirent) {
return {
id,
title: metadata.title,
type: metadata.type,
contributors,
language: metadata.language,
version: metadata.version,
practice: metadata.practice ?? null,
jurisdictions: metadata.jurisdictions ?? null,
order: metadata.order ?? 0,
prompt_md: promptMd,
metadata: {
title: metadata.display_name,
description: metadata.description,
type: metadata.type,
contributors,
language: metadata.language,
version: metadata.version,
practice: metadata.practice ?? null,
jurisdictions: metadata.jurisdictions ?? null,
},
skill_md: skillMd || null,
source_skill_md: sourceSkillMd,
columns_config: tableConfig.columns_config,
};
}
@ -206,7 +419,7 @@ function loadWorkflows() {
const seenIds = new Set();
for (const category of ["assistant", "tabular"]) {
const categoryDir = path.join(WORKFLOWS_DIR, category);
const categoryDir = path.join(SYSTEM_WORKFLOWS_DIR, category);
if (!fs.existsSync(categoryDir)) continue;
const entries = fs
@ -224,20 +437,7 @@ function loadWorkflows() {
}
}
return workflows.sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
return a.id.localeCompare(b.id);
});
}
function stripGeneratorFields(workflow) {
const { order, ...publicWorkflow } = workflow;
return {
user_id: null,
is_system: true,
created_at: "",
...publicWorkflow,
};
return workflows.sort((a, b) => a.id.localeCompare(b.id));
}
function formatTs(value) {
@ -245,30 +445,31 @@ function formatTs(value) {
}
function writeGeneratedFiles(workflows) {
const systemWorkflows = workflows.map(stripGeneratorFields);
const systemWorkflows = workflows.map((workflow) => ({
user_id: null,
is_system: true,
created_at: "",
id: workflow.id,
metadata: workflow.metadata,
skill_md: workflow.skill_md,
columns_config: workflow.columns_config,
}));
const systemAssistantWorkflows = workflows
.filter((workflow) => workflow.type === "assistant")
.filter((workflow) => workflow.metadata.type === "assistant")
.map((workflow) => ({
id: workflow.id,
title: workflow.title,
prompt_md: workflow.prompt_md,
title: workflow.metadata.title,
skill_md: workflow.skill_md,
}));
const landingWorkflows = workflows.map((workflow) => ({
id: workflow.id,
title: workflow.title,
type: workflow.type,
contributors: workflow.contributors,
language: workflow.language,
version: workflow.version,
practice: workflow.practice,
jurisdictions: workflow.jurisdictions,
order: workflow.order,
skill: workflow.prompt_md,
metadata: workflow.metadata,
skill_md: workflow.source_skill_md,
columnCount: workflow.columns_config?.length ?? 0,
columns: workflow.columns_config ?? [],
}));
const backendText = `// This file is generated by scripts/build-workflows.js. Do not edit it directly.\n\nexport type SystemWorkflowContributor = {\n name: string;\n organisation: string | null;\n role: string | null;\n linkedin: string | null;\n};\n\nexport type SystemWorkflow = {\n id: string;\n user_id: null;\n is_system: true;\n created_at: string;\n title: string;\n type: "assistant" | "tabular";\n contributors: SystemWorkflowContributor[];\n language: string;\n version: string;\n practice: string | null;\n jurisdictions: string[] | null;\n prompt_md: string | null;\n columns_config: { index: number; name: string; format?: string; prompt: string; tags?: string[] }[] | null;\n};\n\nexport const SYSTEM_WORKFLOWS: SystemWorkflow[] = ${formatTs(systemWorkflows)};\n\nexport const SYSTEM_WORKFLOW_IDS = new Set(SYSTEM_WORKFLOWS.map((wf) => wf.id));\n\nexport const SYSTEM_ASSISTANT_WORKFLOWS: { id: string; title: string; prompt_md: string }[] = ${formatTs(systemAssistantWorkflows)};\n`;
const backendText = `// This file is generated by scripts/build-workflows.js. Do not edit it directly.\n\nexport type SystemWorkflowContributor = {\n name: string;\n organisation: string | null;\n role: string | null;\n linkedin: string | null;\n};\n\nexport type SystemWorkflowMetadata = {\n title: string;\n description: string;\n type: "assistant" | "tabular";\n contributors: SystemWorkflowContributor[];\n language: string;\n version: string;\n practice: string | null;\n jurisdictions: string[] | null;\n};\n\nexport type SystemWorkflow = {\n id: string;\n user_id: null;\n is_system: true;\n created_at: string;\n metadata: SystemWorkflowMetadata;\n skill_md: string | null;\n columns_config: { index: number; name: string; format?: string; prompt: string; tags?: string[] }[] | null;\n};\n\nexport const SYSTEM_WORKFLOWS: SystemWorkflow[] = ${formatTs(systemWorkflows)};\n\nexport const SYSTEM_WORKFLOW_IDS = new Set(SYSTEM_WORKFLOWS.map((wf) => wf.id));\n\nexport const SYSTEM_ASSISTANT_WORKFLOWS: { id: string; title: string; skill_md: string }[] = ${formatTs(systemAssistantWorkflows)};\n`;
const landingText = `// This file is generated by scripts/build-workflows.js. Do not edit it directly.\nimport type { LandingWorkflow } from "./workflow-browser";\n\nexport const LANDING_WORKFLOWS: LandingWorkflow[] = ${formatTs(landingWorkflows)};\n`;
@ -282,6 +483,9 @@ function main() {
if (!fs.existsSync(WORKFLOWS_DIR)) {
fail(`Workflow source directory not found: ${relative(WORKFLOWS_DIR)}`);
}
if (!fs.existsSync(SYSTEM_WORKFLOWS_DIR)) {
fail(`System workflow source directory not found: ${relative(SYSTEM_WORKFLOWS_DIR)}`);
}
const workflows = loadWorkflows();
if (workflows.length === 0) {